diff --git a/docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md b/docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md new file mode 100644 index 0000000..3ed8d01 --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md @@ -0,0 +1,1133 @@ +# v0.2.6 Bugs + Cleanup Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to execute task-by-task. Steps use checkbox (`- [ ]`). + +**Goal:** Backlog 16건 (4 bug + 12 cleanup → 13 task) 통합 처리. dogfood UX 마찰 해소 + 코드베이스 cleanup. + +**Architecture:** Bug fix 4건은 isolated. Cleanup 은 cluster (KST 통합 / TrayCallbacks 객체 / Banner shared) + microfix 묶음. 신규 architecture 변경 거의 없음 — 기존 구조 정리. + +**Tech Stack:** TypeScript strict, vitest 4, better-sqlite3 12.9, React 19, Electron 41. + +**Spec:** `docs/superpowers/specs/2026-05-05-v026-bugs-cleanup-design.md` + +--- + +## File Structure + +| File | Action | +|------|--------| +| `src/main/repository/NoteRepository.ts` | B1 restoreNote + pending_jobs / B2 countTrashed / C6 hydrate cleanup | +| `src/main/ipc/inboxApi.ts` | B2 IPC handler 갱신 / C9 inbox:trash rename | +| `src/main/index.ts` | B3 autostart fix / B4 additionalData / C2 createTray 호출 | +| `src/main/tray.ts` | B3 autostart args / C2 TrayCallbacks 객체화 / C3 singleton 제거 | +| `src/shared/util/kstDate.ts` | C1 신규 — KST helper 통합 | +| `src/main/services/TelemetryService.ts` | C1 callsite migrate | +| `src/main/services/telemetryStats.ts` | C1 callsite migrate / C8 exhaustiveness | +| `src/main/ai/AiWorker.ts` | C1 callsite migrate / C4 AiFailedReason import | +| `src/renderer/inbox/store.ts` | C1 snoozeExpired / snoozeRecall migrate | +| `src/main/services/telemetryEvents.ts` | C4 AiFailedReason 통합 | +| `src/main/services/TelemetryService.ts` | C4 EmitInput 의 reason type / C5 hasNoteId | +| `src/renderer/inbox/components/Banner.tsx` | C7 신규 shared component | +| `src/renderer/inbox/components/{Expiry,Ollama,Failed,Recall}Banner.tsx` + `OllamaSettingsModal.tsx` | C7 migrate | +| `src/main/ai/AiWorker.ts` | C9 VOCAB_TOP_N const | +| `src/renderer/inbox/components/OllamaSettingsModal.tsx` | C9 URL pre-check | +| `src/main/services/telemetryStats.ts` (또는 callsite) | C9 휴지통 회수율 코멘트 | +| `package.json` + `package-lock.json` | T13 0.2.5 → 0.2.6 | + +총 신규 단위 +14 (추정): B1 (3), B2 (2), B3 (1), B4 (1), C1 (2), C5 (2), C8 (1), C9 (1), 나머지 refactor 0. + +--- + +## 작업 순서 (subagent dispatch 순서) + +순서: **B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2+C3 → C7 → C9 → closure** + +이유 (spec §9): B3 (위험) cleanup 시작 직전. C2+C3 묶음. C7 (UI cleanup) 마지막 그룹. + +--- + +## Task B1: #10 restoreNote + pending_jobs 재생성 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` +- Test: `tests/unit/NoteRepository.test.ts` + +**Bug 설명**: 사용자가 노트 trash → AI fail 발생 → restore 시 `ai_status='failed'` 그대로, pending_jobs 미재생성. AI 재처리 path 없어 영구 fail. + +- [ ] **Step 1: Write failing test** + +Append to `tests/unit/NoteRepository.test.ts`: + +```typescript +it('restoreNote re-enqueues failed note (ai_status reset to pending + pending_jobs INSERT)', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.markAiFailed(id, 'unreachable'); + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('failed'); + + repo.restoreNote(id); + + const after = repo.findById(id)!; + expect(after.deletedAt).toBeNull(); + expect(after.aiStatus).toBe('pending'); + expect(after.aiError).toBeNull(); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeDefined(); +}); + +it('restoreNote does not re-enqueue done note', () => { + const id = repo.create({ rawText: 'x' }).id; + repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' }); + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('done'); + + repo.restoreNote(id); + + expect(repo.findById(id)!.aiStatus).toBe('done'); + const job = db.prepare('SELECT * FROM pending_jobs WHERE note_id=?').get(id); + expect(job).toBeUndefined(); +}); + +it('restoreNote does not re-enqueue pending note (already enqueued)', () => { + const id = repo.create({ rawText: 'x' }).id; + // pending 상태로 trash + db.prepare('DELETE FROM pending_jobs WHERE note_id=?').run(id); // 인공적으로 cleanup + repo.trash(id, new Date().toISOString()); + expect(repo.findById(id)!.aiStatus).toBe('pending'); + + repo.restoreNote(id); + + // pending 그대로 + pending_jobs 재생성 (없으면) + expect(repo.findById(id)!.aiStatus).toBe('pending'); +}); +``` + +- [ ] **Step 2: Run, verify fail** + +Run: `npm test -- NoteRepository` +Expected: 1+ FAIL — restoreNote 가 ai_status reset 안 함. + +- [ ] **Step 3: Implement** + +`src/main/repository/NoteRepository.ts` — `restoreNote` 메서드 찾아 변경: + +```typescript +restoreNote(id: string): void { + const tx = this.db.transaction(() => { + const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined; + const now = new Date().toISOString(); + this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id); + + // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성 + if (before?.ai_status === 'failed') { + this.db.prepare( + `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` + ).run(now, id); + this.db.prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ).run(id, now); + } else if (before?.ai_status === 'pending') { + // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 + this.db.prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ).run(id, now); + } + // done 노트는 재처리 안 함 (이미 결과 있음) + }); + tx(); +} +``` + +- [ ] **Step 4: Run, verify pass** + +Run: `npm test -- NoteRepository` +Expected: PASS — 3 new cases. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts +git commit -m "$(cat <<'EOF' +fix(v026): #10 restoreNote 가 failed 노트 시 pending_jobs 재생성 + +restore 가 deleted_at = NULL 만 했음 → ai_status='failed' 인 노트 는 +영구 fail 상태로 복구. atomic transaction 안에서 ai_status='pending' ++ pending_jobs INSERT OR IGNORE. + +- failed → pending 재처리 path 복구 +- done 은 영향 X (이미 결과 있음) +- pending 은 pending_jobs 재생성 (정상화) +- 단위 +3 cases (failed/done/pending 각 케이스) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task B2: #12 trashCount cap dialog 정확도 + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` (`countTrashed`) +- Modify: `src/main/ipc/inboxApi.ts` (handler 갱신) +- Test: `tests/unit/NoteRepository.test.ts` + +**Bug 설명**: emptyTrash dialog 가 "200개 영구 삭제" 표시하지만 실제 350 trash 시 350 모두 삭제 → silent undercount. + +- [ ] **Step 1: Read current `inbox:trashCount` handler** + +In `src/main/ipc/inboxApi.ts`, find current `inbox:trashCount` 또는 비슷한 IPC. 현재 limit 200 으로 capped 되어 있음 확인. + +- [ ] **Step 2: Write failing test** + +Append to `tests/unit/NoteRepository.test.ts`: + +```typescript +it('countTrashed returns accurate count (>200 not capped)', () => { + const now = new Date().toISOString(); + for (let i = 0; i < 250; i++) { + const id = repo.create({ rawText: `n${i}` }).id; + repo.trash(id, now); + } + expect(repo.countTrashed()).toBe(250); +}); + +it('countTrashed returns 0 for empty trash', () => { + expect(repo.countTrashed()).toBe(0); +}); +``` + +- [ ] **Step 3: Run, verify fail** + +Run: `npm test -- NoteRepository` +Expected: FAIL — countTrashed 메서드 없음. + +- [ ] **Step 4: Implement countTrashed** + +In NoteRepository.ts add (near countToday / countFailed): + +```typescript +countTrashed(): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE deleted_at IS NOT NULL`) + .get() as { c: number }; + return row.c; +} +``` + +- [ ] **Step 5: Update IPC handler** + +Find existing IPC that returns trash count (likely `inbox:trashCount` or similar). Replace cap-200 query with `deps.repo.countTrashed()` call. UI 의 emptyTrash confirm dialog 가 이 N 사용. + +```typescript +ipcMain.handle('inbox:trashCount', () => deps.repo.countTrashed()); +``` + +(만약 기존 handler 가 list 일부만 가져와 length 반환하면 그 부분 제거 + countTrashed 사용) + +- [ ] **Step 6: Run all tests** + +Run: `npm test` +Expected: 413 → 415 pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/main/repository/NoteRepository.ts src/main/ipc/inboxApi.ts tests/unit/NoteRepository.test.ts +git commit -m "$(cat <<'EOF' +fix(v026): #12 trashCount cap → countTrashed() 정확 N (silent undercount 해소) + +기존 UI 가 list 200 limit 후 length 사용 → 350개 trash 시 dialog +"200개 영구 삭제" 표시되지만 실제 350 모두 삭제. 사용자 혼동. + +- NoteRepository.countTrashed() 신규 — SELECT COUNT(*) WHERE deleted_at IS NOT NULL +- IPC handler inbox:trashCount 가 countTrashed 사용 +- emptyTrash confirm dialog 의 N 정확화 +- 단위 +2 cases (>200 not capped, empty 0) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task B4: #46 hidden-start race (additionalData) + +**Files:** +- Modify: `src/main/index.ts` (single-instance lock 부분) + +**Bug 설명**: NSIS installer 가 설치 직후 사용자 클릭 (`inkling.exe`) + autostart (`inkling.exe --hidden`) 두 instance 짧은 간격 시 — 첫 lock 보유자에 따라 visible 여부 race. + +- [ ] **Step 1: Read current single-instance lock code** + +`src/main/index.ts` line 36-58 영역 (PR #23 으로 추가된 lock + second-instance handler). + +- [ ] **Step 2: Modify lock + handler** + +Replace existing block: + +```typescript +const HIDDEN_ARG = '--hidden'; +const startedHidden = process.argv.includes(HIDDEN_ARG); + +// CRITICAL — single-instance lock + hidden-flag 전달 (v0.2.6 #46). +// 두 번째 .exe 가 hidden 으로 spawn 됐다면 (autostart) 첫 instance 의 inbox 창 +// 띄우지 않음 — 사용자가 명시적으로 클릭한 게 아니므로. +const additionalData = { hidden: startedHidden }; +const gotLock = app.requestSingleInstanceLock(additionalData); +if (!gotLock) { + app.quit(); +} else { + app.on('second-instance', (_e, _argv, _cwd, secondData) => { + const data = secondData as { hidden?: boolean } | undefined; + // 두 번째가 hidden 으로 spawn (autostart 등) — UI 띄우지 않음 + if (data?.hidden === true) return; + // 사용자가 명시적으로 .exe / 단축키 / 트레이로 띄움 → inbox 창 보이게 + const win = getInboxWindow(); + if (win) { + if (win.isMinimized()) win.restore(); + if (!win.isVisible()) win.show(); + win.focus(); + } else { + createInboxWindow(); + } + }); +} +``` + +- [ ] **Step 3: Run typecheck + tests** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 415/415 (no test impact — defensive fix). + +- [ ] **Step 4: Commit** + +```bash +git add src/main/index.ts +git commit -m "$(cat <<'EOF' +fix(v026): #46 hidden-start race — additionalData 로 두 번째 hidden 구분 + +PR #23 single-instance lock 의 second-instance handler 가 무조건 inbox 창 +띄움. NSIS installer 직후 사용자 클릭 + autostart --hidden 동시 시도 시 +첫 lock 보유자가 hidden 이면 두 번째 (visible 사용자 클릭) 가 띄움 — OK. +첫 lock 이 visible (사용자) 이면 두 번째 (hidden autostart) 가 또 inbox 띄움 +→ 의도 위반. additionalData 로 두 번째의 hidden flag 전달 → hidden 이면 skip. + +PR #23 round 1 reviewer Important 처리. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task B3: #45 autostart 풀림 (Windows registry) + +**Files:** +- Modify: `src/main/tray.ts` (autostart toggle) +- Modify: `src/main/index.ts` (firstRun autostart args 정합) + +**Risky task** — Windows registry 디버깅. Fallback: 진단 로그만 추가 + backlog 유지. + +- [ ] **Step 1: Read current autostart code** + +`src/main/tray.ts:47-58` 영역. `app.setLoginItemSettings({ openAtLogin, args: ['--hidden'] })` + `app.getLoginItemSettings()` (args 미전달). + +- [ ] **Step 2: Apply 2-part fix** + +In `src/main/tray.ts`: + +```typescript +if (app.isPackaged) { + // v0.2.6 #45 — args 명시 전달 → openAtLogin 비교 정확도 + const { openAtLogin } = app.getLoginItemSettings({ args: ['--hidden'] }); + items.push({ + label: '윈도우 시작 시 자동 실행', + type: 'checkbox', + checked: openAtLogin, + click: (item) => { + app.setLoginItemSettings({ + openAtLogin: item.checked, + args: ['--hidden'] + }); + } + }); + items.push({ type: 'separator' }); +} +``` + +In `src/main/index.ts` (firstRun autostart 영역): + +```typescript +if (app.isPackaged && process.platform === 'win32') { + const initFlag = join(paths.profileDir, '.autostart-init'); + if (!existsSync(initFlag)) { + app.setLoginItemSettings({ openAtLogin: true, args: [HIDDEN_ARG] }); + writeFileSync(initFlag, new Date().toISOString()); + logger.info('autostart.enabled.firstRun'); + } + // v0.2.6 #45 — 진단: args 전달 / 미전달 비교 + path 노출 + const withArgs = app.getLoginItemSettings({ args: [HIDDEN_ARG] }); + const noArgs = app.getLoginItemSettings(); + logger.info('autostart.state', { + withArgs: { openAtLogin: withArgs.openAtLogin, executableWillLaunchAtLogin: withArgs.executableWillLaunchAtLogin }, + noArgs: { openAtLogin: noArgs.openAtLogin }, + expectedArgs: [HIDDEN_ARG] + }); +} +``` + +- [ ] **Step 3: Run typecheck** + +Run: `npm run typecheck` +Expected: 0 errors. + +Run: `npm test` +Expected: 415/415. + +- [ ] **Step 4: Commit (fallback acceptable)** + +If real fix isn't possible (registry 동작 모호), commit 진단 로그만 + backlog #45 유지: + +```bash +git add src/main/tray.ts src/main/index.ts +git commit -m "$(cat <<'EOF' +fix(v026): #45 autostart 풀림 — args 비교 정확도 + 진단 로그 + +추정 원인 (a)/(b)/(c): args 비교 mismatch / electron path canonicalization / +registry path vs 현재 process path. + +Fix: +- getLoginItemSettings({ args: ['--hidden'] }) — 트레이 메뉴 checked 상태가 + 실제 args 와 일치하는지 정확 비교 +- index.ts firstRun 후 autostart.state 진단 로그 (withArgs vs noArgs 비교) +- dogfood 에서 로그 확인 후 v0.2.7 에서 deeper fix 검토 가능 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C1: #3+#19+#34 KST helper 통합 + +**Files:** +- Create: `src/shared/util/kstDate.ts` +- Test: `tests/unit/kstDate.test.ts` (확장 기존 또는 신규) +- Modify: `src/main/services/TelemetryService.ts` (todayKstIso callsite) +- Modify: `src/main/services/telemetryStats.ts` (kstDate callsite) +- Modify: `src/main/ai/AiWorker.ts` (todayKstAsDate/Iso callsite) +- Modify: `src/renderer/inbox/store.ts` (snoozeExpired/snoozeRecall callsite) + +**기존 `tests/unit/kstDate.test.ts` 가 있다면 확장. 없으면 신규.** + +- [ ] **Step 1: Check existing kstDate** + +```bash +ls c:/Users/rlaxo/inkling/src/main/util/kstDate.ts 2>&1 +ls c:/Users/rlaxo/inkling/src/shared/util/ 2>&1 +``` + +기존 `@main/util/kstDate.ts` 있으면 그 코드 가져와 `src/shared/util/kstDate.ts` 로 이동 + alias 삭제. + +- [ ] **Step 2: Create/move kstDate.ts to shared** + +`src/shared/util/kstDate.ts`: + +```typescript +export const KST_OFFSET_MS = 9 * 60 * 60 * 1000; +export const DAY_MS = 24 * 60 * 60 * 1000; + +/** KST 자정 기준 today YYYY-MM-DD. */ +export function kstTodayIso(now: Date = new Date()): string { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())) + .toISOString().slice(0, 10); +} + +/** 다음 KST 자정의 ms timestamp (UTC). */ +export function nextKstMidnightMs(now: Date = new Date()): number { + const nowMs = now.getTime(); + const kstNow = nowMs + KST_OFFSET_MS; + const kstMidnightFloor = Math.floor(kstNow / DAY_MS) * DAY_MS; + const nextKstMidnight = kstMidnightFloor + DAY_MS; + return nextKstMidnight - KST_OFFSET_MS; +} + +/** KST today (00:00 KST 의 UTC Date). AiWorker 의 candidate parsing 용. */ +export function kstTodayAsDate(now: Date = new Date()): Date { + const k = new Date(now.getTime() + KST_OFFSET_MS); + return new Date(Date.UTC(k.getUTCFullYear(), k.getUTCMonth(), k.getUTCDate())); +} +``` + +- [ ] **Step 3: Tests** + +`tests/unit/kstDate.test.ts` (신규 또는 확장): + +```typescript +import { describe, it, expect } from 'vitest'; +import { kstTodayIso, nextKstMidnightMs, kstTodayAsDate, KST_OFFSET_MS, DAY_MS } from '@shared/util/kstDate.js'; + +describe('kstDate', () => { + it('kstTodayIso returns YYYY-MM-DD format in KST', () => { + const utcDate = new Date('2026-05-04T15:30:00Z'); // KST 5/5 00:30 + expect(kstTodayIso(utcDate)).toBe('2026-05-05'); + }); + + it('nextKstMidnightMs returns next KST midnight (UTC ms)', () => { + const utcDate = new Date('2026-05-04T15:30:00Z'); // KST 5/5 00:30 + const next = nextKstMidnightMs(utcDate); + // next KST midnight = 5/6 00:00 KST = 5/5 15:00 UTC + expect(new Date(next).toISOString()).toBe('2026-05-05T15:00:00.000Z'); + }); +}); +``` + +- [ ] **Step 4: Migrate callsites** + +Each callsite: import from `@shared/util/kstDate.js` 대체. + +- `src/main/services/TelemetryService.ts`: 기존 `todayKstIso(now)` → `kstTodayIso(now)`. KST_OFFSET_MS 로컬 const 제거. +- `src/main/services/telemetryStats.ts`: `kstDate(ts)` → `kstTodayIso(new Date(ts))`. KST_OFFSET_MS 로컬 const 제거. +- `src/main/ai/AiWorker.ts`: `todayKstAsDate(now)` → `kstTodayAsDate(now)`. `todayKstAsIso(now)` → `kstTodayIso(now)`. KST_OFFSET_MS 로컬 const 제거. +- `src/renderer/inbox/store.ts`: `snoozeExpired` + `snoozeRecall` 의 inline KST 계산 → `nextKstMidnightMs()` 호출. + +각 파일 변경 후: +```bash +npm run typecheck +npm test +``` + +Expected: 415 → 417 (+2 from kstDate.test). + +- [ ] **Step 5: Commit** + +```bash +git add src/shared/util/kstDate.ts tests/unit/kstDate.test.ts \ + src/main/services/TelemetryService.ts src/main/services/telemetryStats.ts \ + src/main/ai/AiWorker.ts src/renderer/inbox/store.ts +# 기존 src/main/util/kstDate.ts 가 있다면 git rm 추가 +git commit -m "$(cat <<'EOF' +refactor(v026): #3+#19+#34 KST helper 통합 → src/shared/util/kstDate.ts + +main + renderer 양쪽에서 import 가능한 단일 util. +- kstTodayIso(now): YYYY-MM-DD KST 자정 기준 +- nextKstMidnightMs(now): 다음 KST 자정 UTC ms +- kstTodayAsDate(now): KST 자정의 UTC Date +- KST_OFFSET_MS, DAY_MS 상수 노출 + +Migrate 4 callsite: +- TelemetryService.todayKstIso +- telemetryStats.kstDate +- AiWorker.todayKstAsDate/Iso +- store.snoozeExpired + snoozeRecall (inline KST 계산) + +단위 +2 cases (kstTodayIso, nextKstMidnightMs). + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C4: #5 AiFailedReason union 통합 + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` (zod enum 단일 export) +- Modify: `src/main/services/TelemetryService.ts` (EmitInput reason type) +- Modify: `src/main/ai/AiWorker.ts` (classifier + emitter) + +- [ ] **Step 1: Export AiFailedReason from telemetryEvents.ts** + +In `src/main/services/telemetryEvents.ts`: + +```typescript +export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']); +export type AiFailedReason = z.infer; +``` + +기존 `const AiFailedReason = z.enum(...)` 를 위 export 로 교체. + +- [ ] **Step 2: Migrate callsites** + +`src/main/services/TelemetryService.ts` EmitInput: +```typescript +import type { AiFailedReason } from './telemetryEvents.js'; +// ... +| { kind: 'ai_failed'; payload: { noteId: string; reason: AiFailedReason; attempts: number } } +``` + +`src/main/ai/AiWorker.ts`: +- `classifyReason` 의 반환 type → `AiFailedReason` +- `AiTelemetryEmitter` interface 의 `reason` field type → `AiFailedReason` + +기존 inline `'unreachable' | 'schema' | 'timeout' | 'other'` literal 모두 `AiFailedReason` 으로 교체. + +- [ ] **Step 3: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: 417/417 (refactor only). + +- [ ] **Step 4: Commit** + +```bash +git add src/main/services/telemetryEvents.ts src/main/services/TelemetryService.ts src/main/ai/AiWorker.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #5 AiFailedReason union 단일 export 통합 + +기존 'unreachable' | 'schema' | 'timeout' | 'other' literal 이 3곳 (zod enum, +EmitInput, AiWorker classifier+emitter) 에 분산. zod enum z.infer 통해 +type 파생, 단일 export AiFailedReason 으로 통합. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C5: #21 hasNoteId type predicate + +**Files:** +- Modify: `src/main/services/telemetryEvents.ts` (또는 `src/main/services/telemetryGuards.ts` 신규) +- Modify: `tests/unit/TelemetryService.test.ts` (narrowing 체인 단축) +- Test: `tests/unit/telemetryEvents.test.ts` 또는 신규 + +- [ ] **Step 1: Add hasNoteId predicate** + +In `src/main/services/telemetryEvents.ts` (또는 신규 `telemetryGuards.ts`): + +```typescript +import type { TelemetryEvent } from './telemetryEvents.js'; + +const NO_NOTE_ID_KINDS = new Set([ + 'empty_trash', 'expired_banner_shown', 'expired_batch_trash', + 'ollama_unreachable', 'ollama_recovered', 'ollama_recheck_manual', + 'ai_retry_manual', 'tag_vocab_hit', 'tag_vocab_miss' +]); + +export function hasNoteId(ev: TelemetryEvent): ev is Extract { + return !NO_NOTE_ID_KINDS.has(ev.kind); +} +``` + +- [ ] **Step 2: Migrate `tests/unit/TelemetryService.test.ts`** + +Find 4-line narrowing chain (`e.kind !== 'empty_trash' && ...`) — replace with `hasNoteId(e)`: + +```typescript +import { hasNoteId } from '@main/services/telemetryEvents.js'; +// ... +expect(events.map((e) => hasNoteId(e) ? e.payload.noteId : null)).toEqual(['a', 'b', 'b']); +// ... +if (hasNoteId(ev)) expect(ev.payload.noteId).toBe('a'); +``` + +- [ ] **Step 3: Add hasNoteId tests** + +Append to `tests/unit/telemetryEvents.test.ts`: + +```typescript +describe('hasNoteId', () => { + it('returns true for noteId-bearing events (capture / ai_succeeded / trash etc)', () => { + const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'capture', payload: { noteId: 'n1', rawTextLength: 5, hasMedia: false } }); + expect(hasNoteId(e1)).toBe(true); + }); + + it('returns false for noteId-less events (empty_trash / tag_vocab_hit etc)', () => { + const e1 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'empty_trash', payload: { count: 5 } }); + const e2 = validateEvent({ ts: '2026-05-05T00:00:00Z', kind: 'tag_vocab_hit', payload: { tagId: 1, vocabSize: 10 } }); + expect(hasNoteId(e1)).toBe(false); + expect(hasNoteId(e2)).toBe(false); + }); +}); +``` + +- [ ] **Step 4: Run, verify pass** + +```bash +npm test +``` + +Expected: 417 → 419 (+2). + +- [ ] **Step 5: Commit** + +```bash +git add src/main/services/telemetryEvents.ts tests/unit/TelemetryService.test.ts tests/unit/telemetryEvents.test.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #21 hasNoteId type predicate helper + +기존 4-line narrowing 체인 (e.kind !== 'empty_trash' && ... && ...) +이 union 확장 시 길어짐 → hasNoteId(ev) type predicate 로 통합. +NO_NOTE_ID_KINDS Set lookup. Migration: TelemetryService.test.ts 의 +2 callsite + 신규 단위 +2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C6: #22 hydrate as any[] cleanup + +**Files:** +- Modify: `src/main/repository/NoteRepository.ts` + +기존 `db.prepare().all() as any[]` 또는 `as unknown[]` 패턴을 `as Record[]` 로 통일. `hydrate()` 메서드 signature 도 `Record` 받도록. + +- [ ] **Step 1: Find all `as any[]` / `as unknown[]` in NoteRepository** + +```bash +grep -n "as any\[\]\|as unknown\[\]" c:/Users/rlaxo/inkling/src/main/repository/NoteRepository.ts +``` + +- [ ] **Step 2: Replace with `as Record[]`** + +Each occurrence — change cast to `as Record[]`. hydrate() signature 가 `Record` 받도록 통일 (이미 그럴 가능성 높음). + +- [ ] **Step 3: Verify** + +```bash +npm run typecheck +npm test -- NoteRepository +``` + +Expected: PASS — refactor only. + +- [ ] **Step 4: Commit** + +```bash +git add src/main/repository/NoteRepository.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #22 NoteRepository hydrate row type 통일 + +db.prepare().all() 의 row type cast `as any[]` / `as unknown[]` → +`as Record[]` 일괄 통일. hydrate() signature 도 동일. +- TS strict 환경 친화 +- 향후 explicit row interface 로 추가 narrowing 시 base 형 명확 + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C8: #8 stats.md exhaustiveness check + +**Files:** +- Modify: `src/main/services/telemetryStats.ts` + +- [ ] **Step 1: Add exhaustive `else { _: never }`** + +In `aggregateStats` 의 if/else if 체인 끝에: + +```typescript +} else if (ev.kind === 'recall_snoozed') { + row.recall_snoozed += 1; + recallSnoozedCount += 1; +} else { + // exhaustiveness check — 새 kind 추가 시 컴파일 단계 catch + const _exhaustive: never = ev; + void _exhaustive; +} +``` + +- [ ] **Step 2: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add src/main/services/telemetryStats.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #8 telemetryStats.aggregateStats exhaustiveness check + +if/else if 체인 끝에 const _exhaustive: never = ev — 새 telemetry kind +추가 시 본 함수 분기 누락을 컴파일 단계에서 catch. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C2+C3: #4+#23+#26+#27 TrayCallbacks 객체화 + +**Files:** +- Modify: `src/main/tray.ts` +- Modify: `src/main/index.ts` (createTray 호출) + +- [ ] **Step 1: Define TrayCallbacks interface** + +In `src/main/tray.ts`: + +```typescript +export interface TrayCallbacks { + showInbox: () => void; + showCapture: () => void; + runBackup: () => void; + runExport: () => void; + runImport: () => void; + runSync: () => void; + runExportTelemetry: () => void; + runOllamaRecheck: () => void; + runRetryAllFailed: () => void; + runOpenOllamaSettings: () => void; +} + +export interface TrayState { + ollamaOk: boolean; + todayCount: number; + failedCount: number; +} +``` + +- [ ] **Step 2: Refactor createTray + module state** + +기존 module-level `_showInbox`, `_failedCount`, `_ollamaOk` 등 → 단일 module-scoped `_callbacks: TrayCallbacks | null` + `_state: TrayState` 로 통합. + +```typescript +let _callbacks: TrayCallbacks | null = null; +let _state: TrayState = { ollamaOk: true, todayCount: 0, failedCount: 0 }; + +export function createTray(callbacks: TrayCallbacks): void { + _callbacks = callbacks; + // ... 기존 tray 생성 + buildMenu 호출 ... +} + +export function refreshTray(state: Partial): void { + _state = { ..._state, ...state }; + if (tray) tray.setContextMenu(buildMenu()); +} + +// buildMenu 안에서 _callbacks!.showInbox 등 호출, _state 참조 +``` + +기존 `refreshTrayOllama(ok)`, `refreshTrayFailedCount(n)` → 모두 `refreshTray({ ollamaOk: ok })`, `refreshTray({ failedCount: n })` 로 호출자 변경. + +- [ ] **Step 3: Migrate index.ts** + +In `src/main/index.ts`, find existing `createTray(...10 args...)` call — replace with object: + +```typescript +createTray({ + showInbox: () => { ... }, + showCapture: () => { ... }, + runBackup: () => { ... }, + // ... 10 callbacks ... + runOpenOllamaSettings: () => { + const win = getInboxWindow(); + if (win) win.webContents.send('inbox:openOllamaSettings'); + } +}); +``` + +기존 `refreshTrayOllama(status.ok)` 호출 → `refreshTray({ ollamaOk: status.ok })`. +기존 `refreshTrayFailedCount(repo.countFailed())` 호출 → `refreshTray({ failedCount: repo.countFailed() })`. +기존 `refreshTray(repo.countToday())` 호출 → `refreshTray({ todayCount: repo.countToday() })`. + +- [ ] **Step 4: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: PASS — refactor only. + +- [ ] **Step 5: Commit** + +```bash +git add src/main/tray.ts src/main/index.ts +git commit -m "$(cat <<'EOF' +refactor(v026): #4+#23+#26+#27 TrayCallbacks 객체화 + state 통합 + +createTray(callbacks: TrayCallbacks) 1-arg signature. +TrayState 통합 (ollamaOk, todayCount, failedCount). +기존 refreshTrayOllama / refreshTrayFailedCount → refreshTray({ ... }). +module-scoped 개별 state 변수 (_failedCount 등) 제거. + +backlog #4 (positional 폭주) / #23 (8 callbacks) / #26 (9 callbacks) / +#27 (refreshTrayFailedCount singleton) 일괄 처리. 다음 menu item 추가 +시 callback 추가만 — readability blocker 해소. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C7: #24+#41 Banner shared component + +**Files:** +- Create: `src/renderer/inbox/components/Banner.tsx` +- Modify: `ExpiryBanner.tsx`, `OllamaBanner.tsx`, `FailedBanner.tsx`, `RecallBanner.tsx`, `OllamaSettingsModal.tsx` + +- [ ] **Step 1: Create Banner.tsx** + +```typescript +import React from 'react'; + +const THEMES = { + warning: { bg: '#fff7e6', border: '#d99500', text: '#946100' }, + error: { bg: '#fce4e4', border: '#a33', text: '#a33' }, + info: { bg: '#e8f0fe', border: '#4a7ec0', text: '#234' } +}; + +interface Props { + severity: 'warning' | 'error' | 'info'; + children: React.ReactNode; +} + +export function Banner({ severity, children }: Props): React.ReactElement { + const t = THEMES[severity]; + return ( +
+ {children} +
+ ); +} +``` + +- [ ] **Step 2: Migrate 4 banners** + +각 `*Banner.tsx` 의 outer `
` → `` 로 교체. 내부 children 유지. + +매핑: +- ExpiryBanner: warning (황색) +- OllamaBanner: warning (황색) +- FailedBanner: error (적색) +- RecallBanner: info (청색) + +- [ ] **Step 3: Verify** + +```bash +npm run typecheck +npm test +npm run test:e2e +``` + +Expected: PASS — UI styling 만 변경, behavior 0. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/inbox/components/Banner.tsx \ + src/renderer/inbox/components/ExpiryBanner.tsx \ + src/renderer/inbox/components/OllamaBanner.tsx \ + src/renderer/inbox/components/FailedBanner.tsx \ + src/renderer/inbox/components/RecallBanner.tsx +git commit -m "$(cat <<'EOF' +refactor(v026): #24+#41 Banner shared component (severity prop) + +4 banner inline style 중복 (warning 황색 / error 적색 / info 청색) +→ wrapper. THEMES map 단일 source. +- ExpiryBanner: warning +- OllamaBanner: warning +- FailedBanner: error +- RecallBanner: info + +OllamaSettingsModal 은 modal 형식이라 별개 — banner 와 분리. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task C9: microfixes (#15 #29 #42 #9) + +**Files:** +- Modify: `src/main/ipc/inboxApi.ts`, `src/preload/index.ts`, `src/shared/types.ts` (`inbox:delete` → `inbox:trash`) +- Modify: `src/main/ai/AiWorker.ts` (`VOCAB_TOP_N` const) +- Modify: `src/renderer/inbox/components/OllamaSettingsModal.tsx` (URL pre-check) +- Modify: `src/main/services/telemetryStats.ts` (휴지통 회수율 ratio 코멘트) + +- [ ] **Step 1: #15 channel rename `inbox:delete` → `inbox:trash`** + +Search + replace `'inbox:delete'` → `'inbox:trash'` in: `src/main/ipc/inboxApi.ts`, `src/preload/index.ts`, `src/shared/types.ts`. Renderer 호출자도 메서드명 변경 (또는 InboxApi 의 method name 확인). + +만약 channel + method name 이 의미상 일치하면 method name 도 `deleteNote` → `trashNote` 변경 검토. 단 backward compat 측면 (renderer 호출자 영향) — minimal scope 면 channel만. + +- [ ] **Step 2: #29 VOCAB_TOP_N const** + +In `src/main/ai/AiWorker.ts`: + +```typescript +// 모듈 상단 +const VOCAB_TOP_N = 20; + +// processJob 내 +const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N); +``` + +- [ ] **Step 3: #42 Modal URL pre-check** + +In `src/renderer/inbox/components/OllamaSettingsModal.tsx`: + +```typescript +// imports +import { z } from 'zod'; +const EndpointSchema = z.string().url(); + +// handleSave 안, save IPC 직전: +async function handleSave() { + if (saving) return; + setSaving(true); + setError(null); + try { + // v0.2.6 #42 — client-side URL validation + const parseResult = EndpointSchema.safeParse(endpoint); + if (!parseResult.success) { + setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)'); + return; + } + if (model.trim().length === 0) { + setError('모델명을 입력하세요'); + return; + } + // 기존 inboxApi.saveOllamaSettings 호출 + const r = await inboxApi.saveOllamaSettings({ endpoint, model }); + // ... +``` + +- [ ] **Step 4: #9 휴지통 회수율 ratio 코멘트** + +In `src/main/services/telemetryStats.ts`, find `trashRecoveryRate` 계산 영역 — 1줄 코멘트 추가: + +```typescript +// 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시 100% 가능, +// unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분). +const trashRecoveryRate = trashCount === 0 ? 'N/A' : ...; +``` + +- [ ] **Step 5: Verify** + +```bash +npm run typecheck +npm test +``` + +Expected: PASS — 4 microfix 모두 minor. + +- [ ] **Step 6: Commit** + +```bash +git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts \ + src/main/ai/AiWorker.ts src/renderer/inbox/components/OllamaSettingsModal.tsx \ + src/main/services/telemetryStats.ts +git commit -m "$(cat <<'EOF' +refactor(v026): C9 microfixes — #15 #29 #42 #9 + +- #15: IPC channel inbox:delete → inbox:trash (semantic = soft delete) +- #29: getTopUsedTags(20) → VOCAB_TOP_N const (튜닝 자체는 telemetry 후) +- #42: OllamaSettingsModal client-side URL validation (zod safeParse pre-check) +- #9: 휴지통 회수율 ratio 의미 1줄 코멘트 (event-level, unique-note 아님) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Task T13 (Closure): Version bump + final gates + +**Files:** +- Modify: `package.json`, `package-lock.json` (0.2.5 → 0.2.6) + +- [ ] **Step 1: Final gates** + +```bash +npm run typecheck +npm test +npm run test:e2e +``` + +Expected: typecheck 0 / 단위 ~427 / e2e 1. + +- [ ] **Step 2: Version bump** + +```json +"version": "0.2.6", +``` + +In both `package.json` + `package-lock.json` (top + nested `""` entry). + +- [ ] **Step 3: Commit closure** + +```bash +git add package.json package-lock.json +git commit -m "$(cat <<'EOF' +chore(release): v0.2.6 — bugs + cleanup (16 backlog 항목 처리) + +bugs (4): #10 restore + pending_jobs / #12 trashCount cap / #45 autostart 풀림 / + #46 hidden-start race +cleanup (12 → 9 cluster): KST helper 통합 / TrayCallbacks 객체화 + singleton 제거 / + AiFailedReason union / hasNoteId predicate / hydrate as any[] / + Banner shared component / exhaustiveness check / microfixes (channel rename + + VOCAB_TOP_N + Modal URL pre-check + ratio 코멘트) + +게이트: typecheck 0 / 단위 ~427 / e2e 1 +잔여 backlog: 14건 (telemetry data-dependent, v0.2.7 brainstorm) + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +--- + +## Self-Review + +**1. Spec coverage**: +- ✅ Bug 4건 all (B1~B4) +- ✅ Cleanup 12건 → 9 cluster all +- ✅ Telemetry data-dependent + 별도 brainstorm 영역 명시 out of scope + +**2. Placeholder scan**: +- T13 step 2 의 lock file 위치 — 기존 패턴대로 (top + nested ""). 명시적. +- B3 fallback 처리 — 진단 로그 추가 시점 명시. + +**3. Type consistency**: +- `kstTodayIso(now: Date = new Date()): string` — C1 정의, callsite migration 모두 사용 +- `AiFailedReason = 'unreachable' | ...` — C4 정의, EmitInput + AiWorker 일관 +- `hasNoteId(ev): ev is ...` — C5 정의, TelemetryService.test.ts callsite 일관 +- `TrayCallbacks` interface — C2 정의, index.ts 호출자 일관 + +--- + +## Roadmap relation + +- v0.2.6 정식 cut (이전 v0.2.4/v0.2.5 patch/hotfix 와 별개) +- 머지 후 binary v0.2.6 빌드 (Windows + Mac) + Gitea release +- v0.2.7 brainstorm 트리거: dogfood ≥1주 soak + telemetry export 모인 후 잔여 backlog 14건 (data-dependent) + 신규 피드백 일괄 triage +- backlog file 본 cut 후 prune (16 건 처리 완료) + rename 검토