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

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

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

```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) <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**

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

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

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

```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) <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**

```bash
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**

```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<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 체인 끝에:

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

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

```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) <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**

```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 (
    <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**

```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 청색)
→ <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: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) <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**

```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) <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 검토
