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

1040 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../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<void> 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:
```ts
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`:
```ts
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:
```ts
const { id } = repo.create({ rawText: 'v1' });
// ...
repo.updateRawText(id, 'v2', new Date('2026-05-10T00:00:00Z'));
```
To:
```ts
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')`:
```ts
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
```bash
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):
```ts
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`:
```ts
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
```bash
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`:
```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):
```ts
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:
```ts
} 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
```bash
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:
```ts
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:
```ts
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):
```ts
import { KST_OFFSET_MS } from '../../shared/util/kstDate.js';
```
### Step 2: ftsHelpers.ts migration
Modify `src/main/repository/ftsHelpers.ts:18`:
Before:
```ts
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
```
Replace with import:
```ts
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:
```ts
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
```
Replace with import (added to imports at top):
```ts
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:
```tsx
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const k = new Date(Date.now() + KST_OFFSET_MS);
```
After:
```tsx
const k = new Date(Date.now() + KST_OFFSET_MS);
```
Add import at top:
```tsx
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
```bash
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):
```ts
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:
```tsx
<button
key={t.key}
onClick={() => setView(t.key)}
aria-pressed={view === t.key}
style={tabBtnStyle(view === t.key)}
>
```
After:
```tsx
<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
```bash
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:
```ts
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:
```ts
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
```bash
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> → 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`:
```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:
```ts
ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
```
After:
```ts
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:
```ts
emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
```
After:
```ts
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:
```ts
emitRecallShown(id: string): Promise<void>;
emitRecallSnoozed(id: string): Promise<void>;
```
After:
```ts
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):
```ts
inboxApi.emitRecallSnoozed(candidate.id);
```
Or leave it (no-op `await` on void).
Same for `src/renderer/inbox/components/RecallBanner.tsx:33`:
```ts
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:
```ts
.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
```bash
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:
```json
"version": "0.3.1",
```
After:
```json
"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:
```markdown
### 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
```bash
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:
```markdown
- [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
```bash
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:
```bash
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 진입.