Files
inkling/docs/superpowers/plans/2026-05-10-v032-cleanup.md
altair823 4deb7775f3 docs(plan): v0.3.2 cleanup cut implementation plan (8 tasks)
Spec: 2026-05-10-v032-cleanup-design.md
Tasks: time-dep test fix / vocabSet COLLATE / PII reason / KST migration /
       탭 ARIA + loadExpired / Promise.all / recall IPC + .catch / 기록 정리

목표 단위 710 → 약 720 (+10 신규, -2 제거), typecheck 0
2026-05-10 13:36:05 +09:00

32 KiB
Raw Blame History

v0.3.2 Cleanup Cut Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: v024-backlog 잔여 23건 중 잠재 bug 4 + cosmetic 6 + 기록 정리 2 = 12건 일괄 처리 + time-dependent test flake fix. dogfood baseline 정리.

Architecture: 기능 추가 X. 기존 production 코드 수정 + 단위 테스트 보강. canonical helper 재활용 (src/shared/util/kstDate.ts). 8 task = 8 commit (TDD per task). 단일 PR.

Tech Stack: TypeScript 5 / Electron 41 / vitest / better-sqlite3 / undici / zod / RTL

Spec: docs/superpowers/specs/2026-05-10-v032-cleanup-design.md


File Structure

File Task 변경
src/main/repository/NoteRepository.ts 1, 4 create(input, now?: Date) signature + KST import
src/main/repository/ftsHelpers.ts 4 KST import
src/main/services/BackupService.ts 4 KST import
src/main/services/ContinuityService.ts 4 KST import
src/renderer/inbox/components/NoteCard.tsx 4 KST import
src/main/ai/AiWorker.ts 2, 6 vocabSet COLLATE + per-tag Promise.all
src/main/ai/LocalOllamaProvider.ts 3 classifyFetchError + reason mask
src/renderer/inbox/App.tsx 5 role="tab" + aria-selected
src/renderer/inbox/store.ts 5 loadExpired action 제거
src/main/ipc/inboxApi.ts 7 recall handle→on
src/preload/index.ts 7 recall ipcRenderer.send
src/shared/types.ts 7 recall return void (Promise X)
src/main/services/CaptureService.ts 7 telemetry .catch → debug log
tests/unit/NoteRevisions.test.ts 1 v1 capture 시간 주입
tests/unit/NoteRepository.upsertFromSync.test.ts 1
tests/unit/NoteRepository.test.ts 1 create now param 단위
tests/unit/AiWorker.test.ts 2, 6 vocabSet 3 + Promise.all 회귀
tests/unit/LocalOllamaProvider.test.ts 3 classifyFetchError 4
tests/unit/App.test.tsx 5 aria-selected assertion
tests/unit/store.expired.test.ts 5 loadExpired test 제거
tests/unit/recall-ipc.test.ts (신규) 7 ipcRenderer.send 검증
package.json 8 0.3.1 → 0.3.2
docs/superpowers/v024-backlog.md 8 처리 이력 갱신
~/.claude/projects/.../memory/project_v022_feedback.md 8 삭제
~/.claude/projects/.../memory/MEMORY.md 8 라인 제거

Task 1: Time-dependent test flake fix

Files:

  • Modify: src/main/repository/NoteRepository.ts:82-105 (create signature)
  • Modify: tests/unit/NoteRevisions.test.ts:22-105 (v1 capture 시간 주입 4 testcase)
  • Modify: tests/unit/NoteRepository.upsertFromSync.test.ts:10-93 (v1 capture 시간 주입 2 testcase)
  • Test: tests/unit/NoteRepository.test.ts (now param 단위 +2)

Step 1: Write the failing test (create now param)

Add to tests/unit/NoteRepository.test.ts end of describe block:

it('create accepts explicit now param', () => {
  const fixed = new Date('2026-05-09T10:00:00.000Z');
  const { id } = repo.create({ rawText: 'hello' }, fixed);
  const note = repo.findById(id)!;
  expect(note.createdAt).toBe('2026-05-09T10:00:00.000Z');
  expect(note.updatedAt).toBe('2026-05-09T10:00:00.000Z');
});

it('create defaults now to new Date when omitted', () => {
  const before = Date.now();
  const { id } = repo.create({ rawText: 'hello' });
  const after = Date.now();
  const note = repo.findById(id)!;
  const ts = new Date(note.createdAt).getTime();
  expect(ts).toBeGreaterThanOrEqual(before);
  expect(ts).toBeLessThanOrEqual(after);
});

Step 2: Run test to verify it fails

npx vitest run tests/unit/NoteRepository.test.ts -t "create accepts explicit now param"

Expected: FAIL with Expected 1 arguments, but got 2 typecheck error or runtime ignore of 2nd arg.

Step 3: Update NoteRepository.create signature

Modify src/main/repository/NoteRepository.ts:82-105:

create(input: CreateNoteInput, now: Date = new Date()): { id: string } {
  const id = uuidv7();
  const ts = now.toISOString();
  const aiStatus: AiStatus = input.aiStatus ?? 'pending';
  const tx = this.db.transaction(() => {
    this.db
      .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at)
                VALUES (?, ?, ?, ?, ?)`)
      .run(id, input.rawText, aiStatus, ts, ts);
    this.db
      .prepare(`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
                VALUES (?, ?, ?, 'capture')`)
      .run(id, input.rawText, ts);
    if (aiStatus === 'pending') {
      this.db
        .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at)
                  VALUES (?, 0, ?)`)
        .run(id, ts);
    }
  });
  tx();
  return { id };
}

Step 4: Run new tests to verify they pass

npx vitest run tests/unit/NoteRepository.test.ts -t "create accepts explicit now param"
npx vitest run tests/unit/NoteRepository.test.ts -t "create defaults now"

Expected: PASS

Step 5: Update flake testcases — NoteRevisions.test.ts

Modify tests/unit/NoteRevisions.test.ts — find every repo.create(...) followed by a repo.updateRawText(..., new Date('2026-05-10T00:00:00Z')) and pass v1 capture time new Date('2026-05-09T00:00:00Z') (a day before v2):

Change all of these patterns:

const { id } = repo.create({ rawText: 'v1' });
// ...
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));

To:

const v1At = new Date('2026-05-09T00:00:00Z');
const { id } = repo.create({ rawText: 'v1' }, v1At);
// ...
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));

Also fix line 30 expectation if it's checking v1 timestamp — change to '2026-05-09T00:00:00.000Z' if comparing notes.updated_at after updateRawText. Actually updated_at after updateRawText should be the v2 time. Keep '2026-05-10T00:00:00.000Z' as-is.

Find line 40: expect(revs.at(1)!.edited_at).toBe('2026-05-10T00:00:00.000Z');revs.at(1) (second element, DESC ordered) should be the OLDER revision = v1 capture. Change expected to '2026-05-09T00:00:00.000Z'.

Apply same pattern to all 4 testcases (lines 22, 45, 57, 77, 105 cluster).

Step 6: Update flake testcases — upsertFromSync.test.ts

Modify tests/unit/NoteRepository.upsertFromSync.test.ts:10-93 — locate the created = repo.create(...) calls. Pass explicit time new Date('2026-05-09T00:00:00Z'):

const created = repo.create({ rawText: 'baseline' }, new Date('2026-05-09T00:00:00Z'));

This ensures created.updatedAt = '2026-05-09T00:00:00.000Z' < '2026-05-10T00:00:00Z' (sync input) → sync wins consistently regardless of system clock.

Step 7: Run full suite to verify all pass

npx vitest run tests/unit/NoteRevisions.test.ts tests/unit/NoteRepository.upsertFromSync.test.ts tests/unit/NoteRepository.test.ts

Expected: PASS — flake testcases now deterministic.

Step 8: Commit

git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts tests/unit/NoteRevisions.test.ts tests/unit/NoteRepository.upsertFromSync.test.ts
git commit -m "$(cat <<'EOF'
fix(v032): NoteRepository.create now param + time-dep test flake fix

- create(input, now?: Date) signature 추가 (기존 setStatus/updateRawText 패턴 정합)
- NoteRevisions.test.ts 4 testcase v1 capture 시간 명시 주입 (2026-05-09T00:00:00Z)
- upsertFromSync.test.ts 2 testcase v1 capture 시간 명시 주입
- 시스템 시계가 2026-05-10T00:00:00Z 초과 시 DESC ordering 깨지던 회귀 회복

backlog: time-dependent flake (Cut F audit 발견)
EOF
)"

Task 2: vocabSet COLLATE NOCASE 정합 (#31)

Files:

  • Modify: src/main/ai/AiWorker.ts:189-191
  • Test: tests/unit/AiWorker.test.ts (+3)

Step 1: Write 3 failing tests

Add to tests/unit/AiWorker.test.ts (end of vocab describe block, or add new describe):

describe('vocab COLLATE NOCASE', () => {
  it('hits when vocab has lowercase and AI returns capital', async () => {
    // existing test setup pattern: mock repo.getTopUsedTags returns ['design']
    // mock provider returns tags: ['Design']
    // assert telemetry emit kind === 'tag_vocab_hit' (not miss)
    // ... follow existing AiWorker.test.ts test setup ...
  });

  it('hits when vocab has capital and AI returns lowercase', async () => {
    // repo.getTopUsedTags returns ['Design']
    // provider returns tags: ['design']
    // assert tag_vocab_hit
  });

  it('still hits when both vocab and AI tag are same lowercase (regression)', async () => {
    // repo.getTopUsedTags returns ['design']
    // provider returns tags: ['design']
    // assert tag_vocab_hit
  });
});

Use the existing AiWorker.test.ts test setup — mock repo, holder, telemetry. Spy on telemetry.emit and assert kinds.

Step 2: Run tests to verify they fail

npx vitest run tests/unit/AiWorker.test.ts -t "COLLATE"

Expected: FAIL — first 2 tests assert tag_vocab_hit but current code emits tag_vocab_miss due to strict-eq.

Step 3: Implement vocabSet lowercase normalize

Modify src/main/ai/AiWorker.ts:189-191:

const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
for (const tagName of new Set(res.tags)) {
  if (vocabSet.has(tagName.toLowerCase())) {
    const tagId = this.repo.getTagIdByName(tagName);
    if (tagId !== null) {
      await this.telemetry.emit({
        kind: 'tag_vocab_hit',
        payload: { tagId, vocabSize: vocab.length }
      }).catch(() => {});
    }
  } else {
    await this.telemetry.emit({
      kind: 'tag_vocab_miss',
      payload: { vocabSize: vocab.length }
    }).catch(() => {});
  }
}

Step 4: Run tests to verify pass

npx vitest run tests/unit/AiWorker.test.ts -t "COLLATE"

Expected: PASS — all 3 tests green.

Step 5: Run full AiWorker.test.ts to verify no regression

npx vitest run tests/unit/AiWorker.test.ts

Expected: All existing tests still PASS.

Step 6: Commit

git add src/main/ai/AiWorker.ts tests/unit/AiWorker.test.ts
git commit -m "$(cat <<'EOF'
fix(v032): AiWorker vocabSet COLLATE NOCASE 정합 (#31)

DB tags.name 가 COLLATE NOCASE 인데 vocabSet 은 strict-eq 였음 →
대문자/소문자 vocab 과 AI tag 가 다를 때 silently skip.

vocab.toLowerCase() + tagName.toLowerCase() 양쪽 normalize 로 정합.
EOF
)"

Task 3: PII reason 마스킹 (#39)

Files:

  • Modify: src/main/ai/LocalOllamaProvider.ts:113-124
  • Test: tests/unit/LocalOllamaProvider.test.ts (+4)

Step 1: Write 4 failing tests

Add to tests/unit/LocalOllamaProvider.test.ts:

describe('healthCheck PII reason masking', () => {
  it('classifies ECONNREFUSED as network', async () => {
    const provider = new LocalOllamaProvider({ endpoint: 'http://192.168.1.5:11434' });
    // mock undici request to throw Error('connect ECONNREFUSED 192.168.1.5:11434')
    vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('connect ECONNREFUSED 192.168.1.5:11434'));
    const r = await provider.healthCheck();
    expect(r).toEqual({ ok: false, reason: 'unreachable:network' });
  });

  it('classifies AbortError/timeout as timeout', async () => {
    const provider = new LocalOllamaProvider({ endpoint: 'http://localhost:11434' });
    vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('The operation was aborted due to timeout'));
    const r = await provider.healthCheck();
    expect(r).toEqual({ ok: false, reason: 'unreachable:timeout' });
  });

  it('classifies ENOTFOUND as dns', async () => {
    const provider = new LocalOllamaProvider({ endpoint: 'http://nonexistent.local:11434' });
    vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('getaddrinfo ENOTFOUND nonexistent.local'));
    const r = await provider.healthCheck();
    expect(r).toEqual({ ok: false, reason: 'unreachable:dns' });
  });

  it('falls back to other for unknown errors', async () => {
    const provider = new LocalOllamaProvider({ endpoint: 'http://localhost:11434' });
    vi.spyOn(undici, 'request').mockRejectedValueOnce(new Error('something weird happened'));
    const r = await provider.healthCheck();
    expect(r).toEqual({ ok: false, reason: 'unreachable:other' });
  });
});

(Adjust vi.spyOn based on existing LocalOllamaProvider.test.ts mock pattern — likely vi.mock('undici') at top with request: vi.fn().)

Step 2: Run tests to verify they fail

npx vitest run tests/unit/LocalOllamaProvider.test.ts -t "PII reason masking"

Expected: FAIL — current code returns unreachable: connect ECONNREFUSED 192.168.1.5:11434 (full message including IP).

Step 3: Add classifyFetchError helper + apply

Modify src/main/ai/LocalOllamaProvider.ts — add helper at module top (after imports, line 7-ish):

function classifyFetchError(e: unknown): 'network' | 'timeout' | 'dns' | 'other' {
  const msg = ((e as Error)?.message ?? '').toLowerCase();
  if (msg.includes('aborted') || msg.includes('timeout')) return 'timeout';
  if (msg.includes('econnrefused') || msg.includes('econnreset')) return 'network';
  if (msg.includes('enotfound') || msg.includes('eai_again')) return 'dns';
  return 'other';
}

Modify healthCheck() line 121-123:

} catch (err) {
  const cls = classifyFetchError(err);
  return { ok: false, reason: `unreachable:${cls}` };
}

Step 4: Run tests to verify pass

npx vitest run tests/unit/LocalOllamaProvider.test.ts -t "PII reason masking"

Expected: PASS — all 4 tests green.

Step 5: Run full LocalOllamaProvider.test.ts to verify no regression

npx vitest run tests/unit/LocalOllamaProvider.test.ts

Expected: existing tests still PASS. Existing tests checking reason: 'unreachable: ...' may break — update them to match new format unreachable:other or unreachable:network.

Step 6: Commit

git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts
git commit -m "$(cat <<'EOF'
fix(v032): healthCheck reason PII 마스킹 (#39)

err.message 안에 LAN endpoint URL (예: 192.168.x.x:11434) 이 포함될 수
있어 telemetry 파일에 PII 우회 노출. v0.2.3.1 in-app endpoint UI 가 LAN
사용을 흔하게 만들어 노출 경로 확대.

classifyFetchError 로 error class 분류 (network/timeout/dns/other) 후
reason: 'unreachable:{class}' 형태만 emit. host/IP 노출 0.
EOF
)"

Task 4: KST inline 5 callsite migration (#19)

Files:

  • Modify: src/main/repository/NoteRepository.ts:1042-1047 (delete inline + import)
  • Modify: src/main/repository/ftsHelpers.ts:18-29
  • Modify: src/main/services/BackupService.ts:6-10
  • Modify: src/main/services/ContinuityService.ts:4-15
  • Modify: src/renderer/inbox/components/NoteCard.tsx:30-31

Step 1: NoteRepository.ts migration

Replace src/main/repository/NoteRepository.ts:1042-1047:

Before:

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const kstYear = kstNow.getUTCFullYear();
const kstMonth = kstNow.getUTCMonth();
const kstDate = kstNow.getUTCDate();
const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS;

After:

const kstNow = new Date(now.getTime() + KST_OFFSET_MS);
const kstYear = kstNow.getUTCFullYear();
const kstMonth = kstNow.getUTCMonth();
const kstDate = kstNow.getUTCDate();
const kstMidnightUtc = Date.UTC(kstYear, kstMonth, kstDate) - KST_OFFSET_MS;

Add at top of file (with other imports):

import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';

Step 2: ftsHelpers.ts migration

Modify src/main/repository/ftsHelpers.ts:18:

Before:

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;

Replace with import:

import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';

(Remove the const line.)

Step 3: BackupService.ts migration

Modify src/main/services/BackupService.ts:6:

Before:

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;

Replace with import (added to imports at top):

import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';

Step 4: ContinuityService.ts migration

Modify src/main/services/ContinuityService.ts:4:

Same pattern — replace local const with import.

Step 5: NoteCard.tsx migration

Modify src/renderer/inbox/components/NoteCard.tsx:30-31:

Before:

const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const k = new Date(Date.now() + KST_OFFSET_MS);

After:

const k = new Date(Date.now() + KST_OFFSET_MS);

Add import at top:

import { KST_OFFSET_MS } from '@shared/util/kstDate.js';

(Use the renderer alias @shared/... as already used elsewhere in renderer.)

Step 6: Verify no other inline duplicates remain

git grep -n "KST_OFFSET_MS = 9" -- src/

Expected: only src/shared/util/kstDate.ts:6 (canonical export) — no inline duplicates.

Step 7: Run full suite to verify regression

npm run typecheck
npm test

Expected: typecheck 0 errors, all existing tests PASS (algorithm identical, just import path).

Step 8: Commit

git add src/main/repository/NoteRepository.ts src/main/repository/ftsHelpers.ts src/main/services/BackupService.ts src/main/services/ContinuityService.ts src/renderer/inbox/components/NoteCard.tsx
git commit -m "$(cat <<'EOF'
refactor(v032): KST_OFFSET_MS inline → @shared/util/kstDate import (#19)

5 callsite (NoteRepository, ftsHelpers, BackupService, ContinuityService,
NoteCard) 모두 canonical export 로 정리. 알고리즘 동일 (9 * 60 * 60 * 1000),
회귀 PASS 검증.

v0.2.6 commit 3cfa60b 가 4 callsite migrate 했지만 5 callsite 잔여.
Cut F audit 에서 발견.
EOF
)"

Task 5: 탭 ARIA + loadExpired 제거 (#14, #18)

Files:

  • Modify: src/renderer/inbox/App.tsx:107-115 (탭 button → role="tab" + aria-selected)
  • Modify: src/renderer/inbox/store.ts:61, 238-241 (loadExpired 제거)
  • Modify: tests/unit/App.test.tsx (+1 aria-selected assertion)
  • Modify: tests/unit/store.expired.test.ts:55-58 (loadExpired test 제거)

Step 1: Write failing test for aria-selected

Add to tests/unit/App.test.tsx (end of describe — find existing tab navigation tests):

it('inbox tab has aria-selected="true" when active', async () => {
  // existing render setup ...
  const inboxTab = screen.getByRole('tab', { name: /Inbox/ });
  expect(inboxTab).toHaveAttribute('aria-selected', 'true');
});

If existing test was using aria-pressed, update those references too — they should query by role="tab" going forward.

Step 2: Run test to verify it fails

npx vitest run tests/unit/App.test.tsx -t "aria-selected"

Expected: FAIL — current aria-pressed doesn't expose role="tab".

Step 3: Update App.tsx tab buttons

Modify src/renderer/inbox/App.tsx:107-115:

Before:

<button
  key={t.key}
  onClick={() => setView(t.key)}
  aria-pressed={view === t.key}
  style={tabBtnStyle(view === t.key)}
>

After:

<button
  key={t.key}
  type="button"
  role="tab"
  aria-selected={view === t.key}
  onClick={() => setView(t.key)}
  style={tabBtnStyle(view === t.key)}
>

(Optional: wrap the tab list <div> with role="tablist". Skip if it complicates <select> peer in same row — role="tab" standalone is acceptable for this UI.)

Step 4: Run test to verify pass

npx vitest run tests/unit/App.test.tsx -t "aria-selected"

Expected: PASS.

Step 5: Remove loadExpired from store

Modify src/renderer/inbox/store.ts:

  • Line 61: delete loadExpired: () => Promise<void>;
  • Lines 238-241: delete the entire async loadExpired() { ... } action

Step 6: Remove loadExpired test

Modify tests/unit/store.expired.test.ts:55-58 — delete the entire it('loadExpired sets expiredCandidates from inboxApi', ...) block.

Step 7: Verify nothing else calls loadExpired

git grep -n "loadExpired" -- src/ tests/

Expected: 0 hits in src/. 0 hits in tests/.

Step 8: Run full suite

npm run typecheck
npm test

Expected: typecheck 0, all PASS.

Step 9: Commit

git add src/renderer/inbox/App.tsx src/renderer/inbox/store.ts tests/unit/App.test.tsx tests/unit/store.expired.test.ts
git commit -m "$(cat <<'EOF'
refactor(v032): 탭 ARIA canonical + loadExpired dead-code 제거 (#14, #18)

- App.tsx 탭 button 의 aria-pressed → role="tab" + aria-selected
  (canonical pattern, a11y audit 정정)
- store.ts loadExpired action + test 제거 (App.tsx 호출 0건,
  loadInitial/refreshMeta 가 inline fetch — dead code)
EOF
)"

Task 6: AiWorker per-tag emit Promise.all 병렬화 (#32)

Files:

  • Modify: src/main/ai/AiWorker.ts:189-205
  • Verify: tests/unit/AiWorker.test.ts (회귀 PASS, +0 신규)

Step 1: Modify the for-loop to Promise.all

Modify src/main/ai/AiWorker.ts:189-205:

Before:

const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
for (const tagName of new Set(res.tags)) {
  if (vocabSet.has(tagName.toLowerCase())) {
    const tagId = this.repo.getTagIdByName(tagName);
    if (tagId !== null) {
      await this.telemetry.emit({
        kind: 'tag_vocab_hit',
        payload: { tagId, vocabSize: vocab.length }
      }).catch(() => {});
    }
  } else {
    await this.telemetry.emit({
      kind: 'tag_vocab_miss',
      payload: { vocabSize: vocab.length }
    }).catch(() => {});
  }
}

After:

const vocabSet = new Set(vocab.map((v) => v.toLowerCase()));
await Promise.all(
  Array.from(new Set(res.tags)).map(async (tagName) => {
    if (vocabSet.has(tagName.toLowerCase())) {
      const tagId = this.repo.getTagIdByName(tagName);
      if (tagId !== null) {
        await this.telemetry.emit({
          kind: 'tag_vocab_hit',
          payload: { tagId, vocabSize: vocab.length }
        }).catch(() => {});
      }
    } else {
      await this.telemetry.emit({
        kind: 'tag_vocab_miss',
        payload: { vocabSize: vocab.length }
      }).catch(() => {});
    }
  })
);

Step 2: Run AiWorker.test.ts to verify regression

npx vitest run tests/unit/AiWorker.test.ts

Expected: All existing tests PASS — including Task 2's COLLATE tests + vocab hit/miss counters.

Step 3: Verify no other test depends on emit ordering

git grep -n "tag_vocab_hit\|tag_vocab_miss" -- tests/

Inspect each result — assertions should be on count or presence, not on ordering. If any test asserts mockEmit.mock.calls[0] strictly, that's a problem (Promise.all order is non-deterministic).

Expected: assertions use .toContainEqual / .toHaveBeenCalledWith (any-order) — not index-based.

Step 4: Commit

git add src/main/ai/AiWorker.ts
git commit -m "$(cat <<'EOF'
perf(v032): AiWorker per-tag emit Promise.all 병렬화 (#32)

기존 serial for-await: 3 태그 → 3 round-trip file-append.
Promise.all: 동일 결과, file-append 동시 실행 (telemetry 파일은
append-only, 순서 의존 단위 0).
EOF
)"

Task 7: recall IPC handle→on + telemetry .catch debug log (#36, #20)

Files:

  • Modify: src/main/ipc/inboxApi.ts:156-157 (handle → on)
  • Modify: src/preload/index.ts:46-47 (invoke → send)
  • Modify: src/shared/types.ts:155-156 (Promise → void return)
  • Modify: src/main/services/CaptureService.ts (.catch debug log)
  • Test: tests/unit/recall-ipc.test.ts (신규 1)

Step 1: Write failing test (recall IPC send)

Create tests/unit/recall-ipc.test.ts:

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { EventEmitter } from 'events';

// Stub electron.ipcMain
const ipcMain = new EventEmitter() as EventEmitter & {
  on: typeof EventEmitter.prototype.on;
  handle: ReturnType<typeof vi.fn>;
};
ipcMain.handle = vi.fn();

vi.mock('electron', () => ({
  ipcMain,
  app: { on: vi.fn(), getPath: vi.fn(() => '/tmp') },
  BrowserWindow: { getAllWindows: () => [] }
}));

import { registerInboxApi } from '@main/ipc/inboxApi.js';

describe('recall IPC handle→on migration', () => {
  beforeEach(() => {
    ipcMain.removeAllListeners();
    ipcMain.handle = vi.fn();
  });

  it('emitRecallShown registered with ipcMain.on (not handle)', () => {
    const capture = { emitRecallShown: vi.fn(), emitRecallSnoozed: vi.fn() };
    registerInboxApi({ capture } as never);
    expect(ipcMain.listenerCount('inbox:emitRecallShown')).toBe(1);
    expect(ipcMain.handle).not.toHaveBeenCalledWith(
      'inbox:emitRecallShown', expect.anything()
    );
  });

  it('emitRecallSnoozed registered with ipcMain.on (not handle)', () => {
    const capture = { emitRecallShown: vi.fn(), emitRecallSnoozed: vi.fn() };
    registerInboxApi({ capture } as never);
    expect(ipcMain.listenerCount('inbox:emitRecallSnoozed')).toBe(1);
  });
});

Adjust the stub setup based on existing vision-ipc.test.ts pattern (it likely already has electron mock helpers).

Step 2: Run test to verify it fails

npx vitest run tests/unit/recall-ipc.test.ts

Expected: FAIL — current code uses ipcMain.handle, so ipcMain.listenerCount('inbox:emitRecallShown') returns 0.

Step 3: Update inboxApi.ts

Modify src/main/ipc/inboxApi.ts:156-157:

Before:

ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));

After:

ipcMain.on('inbox:emitRecallShown', (_e, id: string) => { void deps.capture.emitRecallShown(id); });
ipcMain.on('inbox:emitRecallSnoozed', (_e, id: string) => { void deps.capture.emitRecallSnoozed(id); });

Step 4: Update preload to use send

Modify src/preload/index.ts:46-47:

Before:

emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),

After:

emitRecallShown: (id: string) => { ipcRenderer.send('inbox:emitRecallShown', id); },
emitRecallSnoozed: (id: string) => { ipcRenderer.send('inbox:emitRecallSnoozed', id); },

Step 5: Update shared types

Modify src/shared/types.ts:155-156:

Before:

emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;

After:

emitRecallShown(id: string): void;
emitRecallSnoozed(id: string): void;

Step 6: Update store.ts and RecallBanner.tsx callsites

src/renderer/inbox/store.ts:288 currently has await inboxApi.emitRecallSnoozed(...). With sync return, the await becomes a no-op but doesn't break.

Either remove the await (cleaner):

inboxApi.emitRecallSnoozed(candidate.id);

Or leave it (no-op await on void).

Same for src/renderer/inbox/components/RecallBanner.tsx:33:

void inboxApi.emitRecallShown(candidate.id);

The void operator now applies to a void value — fine, no change needed.

Step 7: Add telemetry .catch debug log to CaptureService

Locate CaptureService calls that emit telemetry with .catch(() => {}). Specifically listExpired and trashExpiredBatch (per spec #20). Read src/main/services/CaptureService.ts to find the locations.

Replace each .catch(() => {}) (only those tied to telemetry.emit) with:

.catch((e) => { this.logger.debug('telemetry.emit.failed', { reason: String(e) }); });

If CaptureService doesn't have a logger field, inject it via constructor (existing pattern — many services have logger: Logger). Skip if no logger available and use console.debug fallback (project debug-log pattern).

Step 8: Run full suite

npm run typecheck
npm test

Expected: typecheck 0, all tests PASS including new recall-ipc.test.ts.

Step 9: Commit

git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts src/main/services/CaptureService.ts src/renderer/inbox/store.ts src/renderer/inbox/components/RecallBanner.tsx tests/unit/recall-ipc.test.ts
git commit -m "$(cat <<'EOF'
refactor(v032): recall IPC handle→on + telemetry .catch debug log (#36, #20)

- inbox:emitRecallShown / emitRecallSnoozed: ipcMain.handle → on
  (fire-and-forget honest pattern, return value 의존자 0)
- preload: ipcRenderer.invoke → send
- shared/types: Promise<void> → void
- CaptureService telemetry .catch silent → logger.debug (디버그 시
  reproduce 가능)
EOF
)"

Task 8: 기록 정리 + version bump

Files:

  • Modify: package.json (version: 0.3.1 → 0.3.2)
  • Modify: docs/superpowers/v024-backlog.md (처리 이력 갱신)
  • Delete: ~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md
  • Modify: ~/.claude/projects/c--Users-rlaxo-inkling/memory/MEMORY.md (line 8 제거)

Step 1: Bump version

Modify package.json line 3:

Before:

  "version": "0.3.1",

After:

  "version": "0.3.2",

Step 2: Verify rebuild target version updated automatically

package.json line 13: "rebuild:electron": "cd node_modules/better-sqlite3 && prebuild-install --runtime=electron --target=41.3.0" — the target=41.3.0 is Electron version, not app version. No change.

Step 3: Update v024-backlog.md processing history

Modify docs/superpowers/v024-backlog.md — find the processing history table (around lines 18-43). After the existing v0.2.7 entries, add:

### v0.3.2 cleanup cut (2026-05-10)

| 항목 | 상태 | Cut |
|---|---|---|
| #14 (탭 ARIA `aria-pressed``role="tab"`) | ✅ 처리 | v0.3.2 |
| #18 (`loadExpired` 미사용 제거) | ✅ 처리 | v0.3.2 |
| #19 (KST inline 5 callsite 잔여 migrate) | ✅ 처리 | v0.3.2 |
| #20 (telemetry `.catch` silent → debug log) | ✅ 처리 | v0.3.2 |
| #31 (vocabSet COLLATE NOCASE 정합) | ✅ 처리 | v0.3.2 |
| #32 (per-tag emit `Promise.all` 병렬화) | ✅ 처리 | v0.3.2 |
| #36 (recall IPC `handle``on`) | ✅ 처리 | v0.3.2 |
| #39 (`ollama_unreachable.reason` PII 마스킹) | ✅ 처리 | v0.3.2 |
| #41+#42 (`OllamaSettingsModal` 폐기 audit) | ✅ 자연 소멸 (v0.2.7) | v0.3.2 audit |
| time-dependent test flake fix | ✅ 처리 | v0.3.2 |

Update the header line:

Before:

**총 항목 수:** 46 (#1 stale 포함)
**잔여:** 23건 (=46  처리 22  stale 1)

After:

**총 항목 수:** 46 (#1 stale 포함)
**잔여:** 13건 (=46  처리 32  stale 1)
**최종 갱신:** 2026-05-10 (v0.3.2 cleanup cut — 잠재 bug + cosmetic 10건)

Step 4: Delete stale v0.2.2 memory file

rm ~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md

(On Windows PowerShell: Remove-Item ~/.claude/projects/c--Users-rlaxo-inkling/memory/project_v022_feedback.md)

Step 5: Update MEMORY.md index

Modify ~/.claude/projects/c--Users-rlaxo-inkling/memory/MEMORY.md — delete the line:

- [v0.2.2 feedback](project_v022_feedback.md) — 2026-05-01 dogfood 6건: ollama 회복/AI 큐/태그 vocab/휴지통/만료 추천/저노이즈 리마인드. 미구현, 다음 brainstorm 후보

Step 6: Verify everything

git status
git diff --stat
npm run typecheck
npm test

Expected:

  • typecheck 0 errors
  • 단위 ~720 PASS (710 + 10 신규 - 2 제거)
  • staged files: package.json, docs/superpowers/v024-backlog.md
  • memory files modified outside repo (not staged in git)

Step 7: Commit release

git add package.json docs/superpowers/v024-backlog.md
git commit -m "$(cat <<'EOF'
chore(release): v0.3.2 — cleanup cut (잠재 bug + cosmetic 10 + 기록 정리)

backlog 잔여 23 → 13 (-10 처리, +1 stale 유지):
- 잠재 bug 4: vocabSet COLLATE / time-dep test flake / PII reason / KST inline
- cosmetic 6: 탭 ARIA / loadExpired 제거 / per-tag Promise.all / recall IPC on
  / OllamaSettingsModal 폐기 audit / .catch debug log

기록 정리: v0.2.2 stale memory 폐기 + v024-backlog 처리 이력 갱신

단위 710 → 약 720, typecheck 0
EOF
)"

Final verification

After all 8 tasks:

git log --oneline | head -10
npm run typecheck
npm test 2>&1 | tail -20

Expected:

  • 8 commits on cleanup branch
  • typecheck 0
  • ~720/720 PASS

PR 본문 작성 + Gitea 머지 흐름은 finishing-a-development-branch skill 진입.