Files
inkling/docs/superpowers/plans/2026-05-05-v026-bugs-cleanup.md
altair823 6fdb72101f docs(v026): plan — 13 task TDD (4 bug + 9 cleanup cluster + closure)
순서: B1 → B2 → B4 → B3 → C1 → C4 → C5 → C6 → C8 → C2+C3 → C7 → C9 → T13.
B3 (autostart) 위험 task 는 cleanup 시작 직전, fail 시 빠른 회피.

각 task 별 file path / 상세 step / commit message 포함.
신규 단위 추정 +14 (413 → ~427).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 01:12:15 +09:00

36 KiB

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:

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.tsrestoreNote 메서드 찾아 변경:

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
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) <noreply@anthropic.com>
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:

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):

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 사용.

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
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) <noreply@anthropic.com>
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:

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
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) <noreply@anthropic.com>
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:

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 영역):

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 유지:

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) <noreply@anthropic.com>
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
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:

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 (신규 또는 확장):

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() 호출.

각 파일 변경 후:

npm run typecheck
npm test

Expected: 415 → 417 (+2 from kstDate.test).

  • Step 5: Commit
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) <noreply@anthropic.com>
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:

export const AiFailedReasonSchema = z.enum(['unreachable', 'schema', 'timeout', 'other']);
export type AiFailedReason = z.infer<typeof AiFailedReasonSchema>;

기존 const AiFailedReason = z.enum(...) 를 위 export 로 교체.

  • Step 2: Migrate callsites

src/main/services/TelemetryService.ts EmitInput:

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
npm run typecheck
npm test

Expected: 417/417 (refactor only).

  • Step 4: Commit
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) <noreply@anthropic.com>
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):

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<TelemetryEvent, { payload: { noteId: string } }> {
  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):

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:

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
npm test

Expected: 417 → 419 (+2).

  • Step 5: Commit
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) <noreply@anthropic.com>
EOF
)"

Task C6: #22 hydrate as any[] cleanup

Files:

  • Modify: src/main/repository/NoteRepository.ts

기존 db.prepare().all() as any[] 또는 as unknown[] 패턴을 as Record<string, unknown>[] 로 통일. hydrate() 메서드 signature 도 Record<string, unknown> 받도록.

  • Step 1: Find all as any[] / as unknown[] in NoteRepository
grep -n "as any\[\]\|as unknown\[\]" c:/Users/rlaxo/inkling/src/main/repository/NoteRepository.ts
  • Step 2: Replace with as Record<string, unknown>[]

Each occurrence — change cast to as Record<string, unknown>[]. hydrate() signature 가 Record<string, unknown> 받도록 통일 (이미 그럴 가능성 높음).

  • Step 3: Verify
npm run typecheck
npm test -- NoteRepository

Expected: PASS — refactor only.

  • Step 4: Commit
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<string, unknown>[]` 일괄 통일. hydrate() signature 도 동일.
- TS strict 환경 친화
- 향후 explicit row interface 로 추가 narrowing 시 base 형 명확

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 체인 끝에:

} 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
npm run typecheck
npm test

Expected: PASS.

  • Step 3: Commit
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) <noreply@anthropic.com>
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:

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 로 통합.

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<TrayState>): 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:

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
npm run typecheck
npm test

Expected: PASS — refactor only.

  • Step 5: Commit
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) <noreply@anthropic.com>
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

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 (
    <div style={{
      background: t.bg, border: `1px solid ${t.border}`, color: t.text,
      borderRadius: 6, padding: '8px 12px', margin: '8px 0', fontSize: 13
    }}>
      {children}
    </div>
  );
}
  • Step 2: Migrate 4 banners

*Banner.tsx 의 outer <div style={{ background: '...', border: '...', ... }}><Banner severity="warning|error|info"> 로 교체. 내부 children 유지.

매핑:

  • ExpiryBanner: warning (황색)

  • OllamaBanner: warning (황색)

  • FailedBanner: error (적색)

  • RecallBanner: info (청색)

  • Step 3: Verify

npm run typecheck
npm test
npm run test:e2e

Expected: PASS — UI styling 만 변경, behavior 0.

  • Step 4: Commit
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 청색)
→ <Banner severity="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) <noreply@anthropic.com>
EOF
)"

Task C9: microfixes (#15 #29 #42 #9)

Files:

  • Modify: src/main/ipc/inboxApi.ts, src/preload/index.ts, src/shared/types.ts (inbox:deleteinbox: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:deleteinbox: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 도 deleteNotetrashNote 변경 검토. 단 backward compat 측면 (renderer 호출자 영향) — minimal scope 면 channel만.

  • Step 2: #29 VOCAB_TOP_N const

In src/main/ai/AiWorker.ts:

// 모듈 상단
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:

// 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줄 코멘트 추가:

// 회수율 = restore / trash event 비율 (event-level — 한 노트 trash-restore 반복 시 100% 가능,
// unique-note 회수율 아님. spec §6.2 "회수 도구 동작?" 질문에 충분).
const trashRecoveryRate = trashCount === 0 ? 'N/A' : ...;
  • Step 5: Verify
npm run typecheck
npm test

Expected: PASS — 4 microfix 모두 minor.

  • Step 6: Commit
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) <noreply@anthropic.com>
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

npm run typecheck
npm test
npm run test:e2e

Expected: typecheck 0 / 단위 ~427 / e2e 1.

  • Step 2: Version bump
"version": "0.2.6",

In both package.json + package-lock.json (top + nested "" entry).

  • Step 3: Commit closure
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) <noreply@anthropic.com>
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 검토