# v0.2.3 #6 리마인드 1 spike Implementation Plan

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

**Goal:** Inbox 상단에 "오늘 회상해볼 노트" 1건 추천 배너 (RecallBanner) + 4종 telemetry (recall_shown / recall_opened / recall_dismissed / recall_snoozed).

**Architecture:** `repo.findRecallCandidate()` SQL (KST 보정 + 7일/30일/마감 조건 + LIMIT 1) → CaptureService 5 methods → 5 IPC channels → renderer store `recallCandidate` + 3 actions → RecallBanner 컴포넌트 (ExpiryBanner 다음 위치). Snooze in-memory KST 다음 자정. "열어보기" → `document.getElementById('note-${id}').scrollIntoView()`.

**Tech Stack:** better-sqlite3 12.9, zod 4.3.6, zustand 5, vitest 4, TypeScript strict. Schema 변경 없음 (m003 가 이미 last_recalled_at + recall_dismissed_at 컬럼 prealloc).

**Spec:** `docs/superpowers/specs/2026-05-02-v023-recall-spike-design.md`

---

## File Structure

| File | Role | Action |
|------|------|--------|
| `src/main/repository/NoteRepository.ts` | DB layer | +`findRecallCandidate()` +`markRecallOpened(id, now)` +`dismissRecall(id, now)` |
| `src/main/services/telemetryEvents.ts` | zod schema | +`RecallShownPayload` + 4 union members → 15→19 |
| `src/main/services/telemetryStats.ts` | 누적 통계 | DailyRow +4 cols, accumulators, summary 2 lines |
| `src/main/services/TelemetryService.ts` | EmitInput type | union 15→19 |
| `src/main/services/CaptureService.ts` | recall API | +5 methods (list / open / dismiss / emit shown / emit snoozed) |
| `src/main/ipc/inboxApi.ts` | IPC | +5 handlers |
| `src/preload/index.ts` | preload | +5 inboxApi entries |
| `src/shared/types.ts` | InboxApi type | +5 method signatures |
| `src/renderer/inbox/store.ts` | zustand state | +recallCandidate +recallSnoozeUntilMs +recallShownIds + 4 actions |
| `src/renderer/inbox/components/RecallBanner.tsx` | UI | 신규 컴포넌트 |
| `src/renderer/inbox/App.tsx` | mount | RecallBanner 렌더 + NoteCard 에 `id={\`note-${note.id}\`}` |
| `src/renderer/inbox/components/NoteCard.tsx` | id attr | wrapper div 에 id 추가 |
| `tests/unit/NoteRepository.test.ts` | 테스트 | +5 cases |
| `tests/unit/telemetryEvents.test.ts` | 테스트 | +3 cases |
| `tests/unit/telemetryStats.test.ts` | 테스트 | +2 cases |
| `tests/unit/CaptureService.test.ts` | 테스트 | +4 cases |
| `tests/unit/store.recall.test.ts` | 신규 테스트 파일 | +3 cases |
| `docs/strategy.md` | strategy 갱신 | §2.3/§4.3/§8 |
| `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` | roadmap mark | #6 ✓ 완료 |

총 신규 단위 17개. 단위 386 → **403** 예상.

---

## Task 1: NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall

**Files:**
- Modify: `src/main/repository/NoteRepository.ts`
- Test: `tests/unit/NoteRepository.test.ts`

- [ ] **Step 1: Write failing test — empty db returns null**

Append inside `describe('NoteRepository', ...)` (just before closing `});`):

```typescript
it('findRecallCandidate returns null for empty db', () => {
  expect(repo.findRecallCandidate()).toBeNull();
});
```

- [ ] **Step 2: Run test, verify fail**

Run: `npm test -- NoteRepository`
Expected: FAIL — `repo.findRecallCandidate is not a function`

- [ ] **Step 3: Implement 3 methods**

In `src/main/repository/NoteRepository.ts`, add the 3 methods after `setNextRunAt(...)` method (existing `getTopUsedTags`/`getTagIdByName` are nearby — place these alongside in repo conventions):

```typescript
  /**
   * v0.2.3 #6 — 회상 후보 1건. 가장 오래된 후보 (created_at ASC) 우선.
   * - 7일 이상 안 본 노트 (last_recalled_at NULL 또는 7일 전 이전)
   * - 30일 이상 dismiss 만료 또는 dismiss 안 된 노트
   * - ai_status='done' + deleted_at IS NULL + due_date 임박 X (≥ today)
   * KST 보정: SQLite date('now') 는 UTC 라 +9 hours 항상 추가.
   */
  findRecallCandidate(): Note | null {
    const row = this.db
      .prepare(
        `SELECT * FROM notes
          WHERE (last_recalled_at IS NULL OR last_recalled_at < date('now','+9 hours','-7 day'))
            AND (recall_dismissed_at IS NULL OR recall_dismissed_at < date('now','+9 hours','-30 day'))
            AND ai_status = 'done'
            AND deleted_at IS NULL
            AND (due_date IS NULL OR due_date >= date('now','+9 hours'))
          ORDER BY created_at ASC
          LIMIT 1`
      )
      .get() as Record<string, unknown> | undefined;
    return row ? this.hydrate(row) : null;
  }

  /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at = now. */
  markRecallOpened(id: string, now: string): void {
    this.db
      .prepare(`UPDATE notes SET last_recalled_at = ?, updated_at = ? WHERE id = ?`)
      .run(now, now, id);
  }

  /** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at = now. 30일 후 재추천. */
  dismissRecall(id: string, now: string): void {
    this.db
      .prepare(`UPDATE notes SET recall_dismissed_at = ?, updated_at = ? WHERE id = ?`)
      .run(now, now, id);
  }
```

- [ ] **Step 4: Run test, verify pass**

Run: `npm test -- NoteRepository`
Expected: PASS

- [ ] **Step 5: Add 4 more cases**

Append the following tests after the `findRecallCandidate returns null` case:

```typescript
it('findRecallCandidate excludes notes recalled within 7 days', () => {
  const id = repo.create({ rawText: 'x' }).id;
  repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
  // 5일 전 본 노트 → 제외
  const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000).toISOString();
  repo.markRecallOpened(id, fiveDaysAgo);
  expect(repo.findRecallCandidate()).toBeNull();
});

it('findRecallCandidate includes notes recalled 8+ days ago', () => {
  const id = repo.create({ rawText: 'x' }).id;
  repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
  const eightDaysAgo = new Date(Date.now() - 8 * 86_400_000).toISOString();
  repo.markRecallOpened(id, eightDaysAgo);
  expect(repo.findRecallCandidate()?.id).toBe(id);
});

it('findRecallCandidate respects dismiss expiry (25일 제외, 35일 후보)', () => {
  const a = repo.create({ rawText: 'a' }).id;
  const b = repo.create({ rawText: 'b' }).id;
  repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
  repo.updateAiResult(b, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
  const twentyFiveDaysAgo = new Date(Date.now() - 25 * 86_400_000).toISOString();
  const thirtyFiveDaysAgo = new Date(Date.now() - 35 * 86_400_000).toISOString();
  repo.dismissRecall(a, twentyFiveDaysAgo);  // 25일 — 아직 dismiss 만료 안 됨
  repo.dismissRecall(b, thirtyFiveDaysAgo);  // 35일 — dismiss 만료, 재추천 가능
  const candidate = repo.findRecallCandidate();
  expect(candidate?.id).toBe(b);
});

it('findRecallCandidate excludes deleted/pending/imminent due', () => {
  const todayKst = new Date(Date.now() + 9 * 60 * 60 * 1000).toISOString().slice(0, 10);
  const yesterdayKst = new Date(Date.now() + 9 * 60 * 60 * 1000 - 86_400_000).toISOString().slice(0, 10);
  // (a) deleted
  const a = repo.create({ rawText: 'a' }).id;
  repo.updateAiResult(a, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
  repo.trash(a, new Date().toISOString());
  // (b) pending (no AI)
  repo.create({ rawText: 'b' });
  // (c) due_date 어제
  const c = repo.create({ rawText: 'c' }).id;
  repo.updateAiResult(c, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: yesterdayKst, provider: 'p' });
  expect(repo.findRecallCandidate()).toBeNull();
  // (d) due_date today 는 OK (>=today 통과)
  const d = repo.create({ rawText: 'd' }).id;
  repo.updateAiResult(d, { title: 't', summary: 'a\nb\nc', tags: ['x'], dueDate: todayKst, provider: 'p' });
  expect(repo.findRecallCandidate()?.id).toBe(d);
});
```

- [ ] **Step 6: Run all tests, verify pass**

Run: `npm test -- NoteRepository`
Expected: PASS — 5 new cases (1 from step 1 + 4 from step 5).

- [ ] **Step 7: Commit**

```bash
git add src/main/repository/NoteRepository.ts tests/unit/NoteRepository.test.ts
git commit -m "$(cat <<'EOF'
feat(recall): NoteRepository — findRecallCandidate + markRecallOpened + dismissRecall (#6 v0.2.3)

- findRecallCandidate(): 7일+ 안 본 + 30일+ dismiss 만료 + ai='done' + 마감 안 임박 + LIMIT 1
- markRecallOpened(id, now): last_recalled_at 갱신
- dismissRecall(id, now): recall_dismissed_at 갱신
- KST 보정 SQL date('now','+9 hours')
- 단위 +5 cases (empty/recent/old/dismiss expiry/exclude variants)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 2: telemetryEvents — recall_shown/opened/dismissed/snoozed zod schemas

**Files:**
- Modify: `src/main/services/telemetryEvents.ts`
- Test: `tests/unit/telemetryEvents.test.ts`

- [ ] **Step 1: Write failing tests**

Append to END of `tests/unit/telemetryEvents.test.ts`:

```typescript
describe('validateEvent — recall', () => {
  it('accepts recall_shown event', () => {
    const e = validateEvent({
      ts: '2026-05-02T00:00:00.000Z',
      kind: 'recall_shown',
      payload: { noteId: 'n1', ageDays: 14 }
    });
    expect(e.kind).toBe('recall_shown');
  });

  it('rejects recall_shown with extra field (privacy)', () => {
    expect(() => validateEvent({
      ts: '2026-05-02T00:00:00.000Z',
      kind: 'recall_shown',
      payload: { noteId: 'n1', ageDays: 14, content: 'leak' }
    })).toThrow();
  });

  it('accepts recall_opened/dismissed/snoozed (NoteIdPayload reused)', () => {
    for (const kind of ['recall_opened', 'recall_dismissed', 'recall_snoozed'] as const) {
      const e = validateEvent({ ts: '2026-05-02T00:00:00.000Z', kind, payload: { noteId: 'n1' } });
      expect(e.kind).toBe(kind);
    }
  });
});
```

- [ ] **Step 2: Run, verify fail**

Run: `npm test -- telemetryEvents`
Expected: FAIL — discriminator missing recall_* kinds.

- [ ] **Step 3: Modify telemetryEvents.ts**

Add `RecallShownPayload` after the existing `TagVocabMissPayload` (around line 53-55):

```typescript
const RecallShownPayload = z.object({
  noteId: z.string().min(1),
  ageDays: z.number().int().nonnegative()
}).strict();
```

Then in `TelemetryEventSchema` discriminatedUnion (currently ends with `tag_vocab_miss` entry), append 4 entries before `]);`:

```typescript
  z.object({ ts: z.string(), kind: z.literal('tag_vocab_miss'), payload: TagVocabMissPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('recall_shown'), payload: RecallShownPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('recall_opened'), payload: NoteIdPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('recall_dismissed'), payload: NoteIdPayload }).strict(),
  z.object({ ts: z.string(), kind: z.literal('recall_snoozed'), payload: NoteIdPayload }).strict()
]);
```

(Add comma after `tag_vocab_miss` line.)

- [ ] **Step 4: Run, verify pass**

Run: `npm test -- telemetryEvents`
Expected: PASS — 3 new cases (recall_shown, extra field reject, recall_opened/dismissed/snoozed).

- [ ] **Step 5: Commit**

```bash
git add src/main/services/telemetryEvents.ts tests/unit/telemetryEvents.test.ts
git commit -m "$(cat <<'EOF'
feat(recall): telemetryEvents — recall_shown/opened/dismissed/snoozed zod schemas (#6 v0.2.3)

- RecallShownPayload { noteId, ageDays: int>=0 } .strict()
- recall_opened/dismissed/snoozed → NoteIdPayload 재사용
- TelemetryEventSchema union 15 → 19
- 단위 +3 cases (recall_shown valid, extra field 거부, opened/dismissed/snoozed valid)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 3: telemetryStats — 4 cols accumulator + summary + TelemetryService.EmitInput union 19

**Files:**
- Modify: `src/main/services/telemetryStats.ts`
- Modify: `src/main/services/TelemetryService.ts:18-31` (EmitInput union)
- Test: `tests/unit/telemetryStats.test.ts`

- [ ] **Step 1: Write failing tests**

Append to END of `describe('aggregateStats', ...)` block in `tests/unit/telemetryStats.test.ts`:

```typescript
  it('aggregates recall events with open rate + average ageDays', () => {
    const events: TelemetryEvent[] = [
      e('2026-05-02T00:00:00Z', 'recall_shown', { noteId: 'n1', ageDays: 10 }),
      e('2026-05-02T00:00:01Z', 'recall_shown', { noteId: 'n2', ageDays: 20 }),
      e('2026-05-02T00:00:02Z', 'recall_shown', { noteId: 'n3', ageDays: 30 }),
      e('2026-05-02T00:00:03Z', 'recall_shown', { noteId: 'n4', ageDays: 40 }),
      e('2026-05-02T00:00:04Z', 'recall_opened', { noteId: 'n1' }),
      e('2026-05-02T00:00:05Z', 'recall_opened', { noteId: 'n2' }),
      e('2026-05-02T00:00:06Z', 'recall_dismissed', { noteId: 'n3' }),
      e('2026-05-02T00:00:07Z', 'recall_snoozed', { noteId: 'n4' })
    ];
    const r = aggregateStats(events, new Date('2026-05-03T00:00:00Z'));
    expect(r.md).toContain('회상 추천: shown 4 / opened 2 / dismissed 1 / snoozed 1');
    expect(r.md).toContain('열림율 50.0%');
    expect(r.md).toContain('회상 평균 ageDays: 25');  // (10+20+30+40)/4
  });

  it('회상 summary shows 데이터 없음 when no recall events', () => {
    const r = aggregateStats([], new Date('2026-05-03T00:00:00Z'));
    expect(r.md).toContain('회상 추천');
    expect(r.md).toContain('데이터 없음');
  });
```

- [ ] **Step 2: Run, verify fail**

Run: `npm test -- telemetryStats`
Expected: FAIL — recall summary lines missing.

- [ ] **Step 3: Modify telemetryStats.ts**

Apply 5 changes:

**3a) DailyRow interface — add 4 fields after `tag_vocab_miss: number;`:**

```typescript
interface DailyRow {
  date: string;
  capture: number;
  ai_succeeded: number;
  ai_failed: number;
  trash: number;
  restore: number;
  permanent_delete: number;
  empty_trash: number;
  expired_banner_shown: number;
  expired_batch_trash: number;
  ollama_unreachable: number;
  ollama_recovered: number;
  ollama_recheck_manual: number;
  ai_retry_manual: number;
  tag_vocab_hit: number;
  tag_vocab_miss: number;
  recall_shown: number;
  recall_opened: number;
  recall_dismissed: number;
  recall_snoozed: number;
}
```

**3b) Add 5 accumulator vars after `let tagVocabMissCount = 0;`:**

```typescript
  let tagVocabHitCount = 0;
  let tagVocabMissCount = 0;
  let recallShownCount = 0;
  let recallOpenedCount = 0;
  let recallDismissedCount = 0;
  let recallSnoozedCount = 0;
  let recallAgeDaysSum = 0;
```

**3c) Update row init in for-loop — add 4 fields:**

Find:
```typescript
        ai_retry_manual: 0,
        tag_vocab_hit: 0, tag_vocab_miss: 0
      };
```

Replace with:
```typescript
        ai_retry_manual: 0,
        tag_vocab_hit: 0, tag_vocab_miss: 0,
        recall_shown: 0, recall_opened: 0, recall_dismissed: 0, recall_snoozed: 0
      };
```

**3d) Add 4 branches after `tag_vocab_miss` branch:**

Find:
```typescript
    } else if (ev.kind === 'tag_vocab_miss') {
      row.tag_vocab_miss += 1;
      tagVocabMissCount += 1;
    }
```

Replace with:
```typescript
    } else if (ev.kind === 'tag_vocab_miss') {
      row.tag_vocab_miss += 1;
      tagVocabMissCount += 1;
    } else if (ev.kind === 'recall_shown') {
      row.recall_shown += 1;
      recallShownCount += 1;
      recallAgeDaysSum += ev.payload.ageDays;
    } else if (ev.kind === 'recall_opened') {
      row.recall_opened += 1;
      recallOpenedCount += 1;
    } else if (ev.kind === 'recall_dismissed') {
      row.recall_dismissed += 1;
      recallDismissedCount += 1;
    } else if (ev.kind === 'recall_snoozed') {
      row.recall_snoozed += 1;
      recallSnoozedCount += 1;
    }
```

**3e) Add recall summary computation + lines.**

After existing `const tagVocabSummary = ...`:

```typescript
  const recallSummary = recallShownCount === 0
    ? '(데이터 없음)'
    : `shown ${recallShownCount} / opened ${recallOpenedCount} / dismissed ${recallDismissedCount} / snoozed ${recallSnoozedCount} (열림율 ${(recallOpenedCount / recallShownCount * 100).toFixed(1)}%)`;
  const recallAvgAge = recallShownCount === 0
    ? '(데이터 없음)'
    : `${Math.round(recallAgeDaysSum / recallShownCount)}`;
```

**3f) Update table header/separator — add 4 columns:**

Find:
```typescript
  lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss |');
  lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|');
```

Replace with:
```typescript
  lines.push('| 일자 | capture | ai_succeeded | ai_failed | trash | restore | permanent_delete | empty_trash | expired_banner_shown | expired_batch_trash | ollama_unreachable | ollama_recovered | ollama_recheck_manual | ai_retry_manual | tag_vocab_hit | tag_vocab_miss | recall_shown | recall_opened | recall_dismissed | recall_snoozed |');
  lines.push('|------|---------|--------------|-----------|-------|---------|------------------|-------------|----------------------|---------------------|--------------------|------------------|----------------------|-----------------|---------------|----------------|--------------|---------------|------------------|----------------|');
```

**3g) Update table row push — add 4 cells:**

Find:
```typescript
    lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} |`);
```

Replace with:
```typescript
    lines.push(`| ${row.date} | ${row.capture} | ${row.ai_succeeded} | ${row.ai_failed} | ${row.trash} | ${row.restore} | ${row.permanent_delete} | ${row.empty_trash} | ${row.expired_banner_shown} | ${row.expired_batch_trash} | ${row.ollama_unreachable} | ${row.ollama_recovered} | ${row.ollama_recheck_manual} | ${row.ai_retry_manual} | ${row.tag_vocab_hit} | ${row.tag_vocab_miss} | ${row.recall_shown} | ${row.recall_opened} | ${row.recall_dismissed} | ${row.recall_snoozed} |`);
```

**3h) Add summary lines after existing 태그 vocab line:**

Find:
```typescript
  lines.push(`- 태그 vocab: ${tagVocabSummary}`);
```

Replace with:
```typescript
  lines.push(`- 태그 vocab: ${tagVocabSummary}`);
  lines.push(`- 회상 추천: ${recallSummary}`);
  lines.push(`- 회상 평균 ageDays: ${recallAvgAge}`);
```

- [ ] **Step 4: Modify TelemetryService.EmitInput union**

In `src/main/services/TelemetryService.ts`, find the `EmitInput` union (currently 15 arms ending with `tag_vocab_miss`). Append 4 new arms before the closing `;`:

```typescript
  | { kind: 'tag_vocab_miss'; payload: { vocabSize: number } }
  | { kind: 'recall_shown'; payload: { noteId: string; ageDays: number } }
  | { kind: 'recall_opened'; payload: { noteId: string } }
  | { kind: 'recall_dismissed'; payload: { noteId: string } }
  | { kind: 'recall_snoozed'; payload: { noteId: string } };
```

- [ ] **Step 5: Run, verify pass**

Run: `npm test -- telemetryStats`
Expected: PASS — 2 new cases.

Run: `npm run typecheck`
Expected: 0 errors.

Run: `npm test`
Expected: 386 → 391 (existing TelemetryService.test.ts narrowing guards may need extension — handled in Task 4).

If TelemetryService.test.ts narrowing guards fail at runtime (not just typecheck), include them in this task. Otherwise leave for next task.

- [ ] **Step 6: Extend narrowing guards in TelemetryService.test.ts**

In `tests/unit/TelemetryService.test.ts`, update the 2 narrowing guard chains (lines around 151 + 167) to also exclude `recall_opened`, `recall_dismissed`, `recall_snoozed` (recall_shown HAS noteId so doesn't need exclusion — but wait, all 4 recall events HAVE noteId so they should NOT be in the noteId-less exclusion list. Re-check.).

Actually, all 4 recall events (`recall_shown`, `recall_opened`, `recall_dismissed`, `recall_snoozed`) have `noteId` in payload. So narrowing guards do NOT need updating.

Verify: run `npm test -- TelemetryService` → PASS expected. If pass, no change. If fail, update guards accordingly.

- [ ] **Step 7: Commit**

```bash
git add src/main/services/telemetryStats.ts src/main/services/TelemetryService.ts tests/unit/telemetryStats.test.ts
git commit -m "$(cat <<'EOF'
feat(recall): telemetryStats + EmitInput — recall 누적 + 열림율 + 평균 ageDays (#6 v0.2.3)

- DailyRow +4 cols (recall_shown/opened/dismissed/snoozed)
- accumulators + 4 branches + recallAgeDaysSum
- table 컬럼 +4
- summary lines: "- 회상 추천: shown N / opened O / dismissed D / snoozed S (열림율 X%)"
                 "- 회상 평균 ageDays: avg"
- TelemetryService.EmitInput union 15 → 19
- 단위 +2 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 4: CaptureService — listRecallCandidate + markRecallOpened + dismissRecall + emitRecallShown + emitRecallSnoozed

**Files:**
- Modify: `src/main/services/CaptureService.ts`
- Test: `tests/unit/CaptureService.test.ts`

- [ ] **Step 1: Write failing tests**

Append to END of `describe('CaptureService', ...)` block in `tests/unit/CaptureService.test.ts`:

```typescript
  it('listRecallCandidate delegates to repo.findRecallCandidate', async () => {
    const id = repo.create({ rawText: 'old' }).id;
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    // No last_recalled_at → eligible immediately
    const candidate = await service.listRecallCandidate();
    expect(candidate?.id).toBe(id);
  });

  it('markRecallOpened updates last_recalled_at and emits recall_opened', async () => {
    const id = repo.create({ rawText: 'x' }).id;
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    const before = repo.findById(id)!.lastRecalledAt;
    expect(before).toBeNull();
    await service.markRecallOpened(id);
    expect(repo.findById(id)!.lastRecalledAt).not.toBeNull();
    expect(emits.find((e) => e.kind === 'recall_opened')).toBeDefined();
  });

  it('dismissRecall updates recall_dismissed_at and emits recall_dismissed', async () => {
    const id = repo.create({ rawText: 'x' }).id;
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    expect(repo.findById(id)!.recallDismissedAt).toBeNull();
    await service.dismissRecall(id);
    expect(repo.findById(id)!.recallDismissedAt).not.toBeNull();
    expect(emits.find((e) => e.kind === 'recall_dismissed')).toBeDefined();
  });

  it('emitRecallShown emits with ageDays from createdAt when never recalled', async () => {
    const id = repo.create({ rawText: 'x' }).id;
    repo.updateAiResult(id, { title: 't', summary: 'a\nb\nc', tags: ['x'], provider: 'p' });
    // Backdate created_at to 14 days ago
    db.prepare(`UPDATE notes SET created_at = ? WHERE id = ?`)
      .run(new Date(Date.now() - 14 * 86_400_000).toISOString(), id);
    await service.emitRecallShown(id);
    const shown = emits.find((e) => e.kind === 'recall_shown');
    expect(shown).toBeDefined();
    const payload = shown!.payload as { noteId: string; ageDays: number };
    expect(payload.noteId).toBe(id);
    expect(payload.ageDays).toBeGreaterThanOrEqual(13);
    expect(payload.ageDays).toBeLessThanOrEqual(15);
  });
```

(Confirm `emits` and `service`/`repo`/`db` are set up in the describe's `beforeEach`. If not, look at existing CaptureService tests for setup pattern and adapt.)

- [ ] **Step 2: Run, verify fail**

Run: `npm test -- CaptureService`
Expected: FAIL — `service.listRecallCandidate is not a function` (or similar).

- [ ] **Step 3: Modify CaptureService.ts**

Add 5 new methods to the `CaptureService` class (place after the existing `retryAllFailed` method or wherever recall-related methods naturally cluster). Also add a private helper `computeAgeDays`:

```typescript
  /** v0.2.3 #6 — 회상 후보 1건 fetch. */
  async listRecallCandidate(): Promise<Note | null> {
    return this.repo.findRecallCandidate();
  }

  /** v0.2.3 #6 — 회상 "열어보기" 시 last_recalled_at 갱신 + recall_opened emit. */
  async markRecallOpened(noteId: string): Promise<{ note: Note }> {
    const before = this.repo.findById(noteId);
    if (!before) throw new Error(`note not found: ${noteId}`);
    this.repo.markRecallOpened(noteId, new Date().toISOString());
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({
        kind: 'recall_opened',
        payload: { noteId }
      }).catch(() => {});
    }
    return { note: this.repo.findById(noteId)! };
  }

  /** v0.2.3 #6 — 회상 "더 이상" 시 recall_dismissed_at 갱신 + recall_dismissed emit. */
  async dismissRecall(noteId: string): Promise<{ note: Note }> {
    this.repo.dismissRecall(noteId, new Date().toISOString());
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({
        kind: 'recall_dismissed',
        payload: { noteId }
      }).catch(() => {});
    }
    return { note: this.repo.findById(noteId)! };
  }

  /** v0.2.3 #6 — RecallBanner 첫 렌더 시 recall_shown emit (per-note 1회 제약은 renderer 가 보장). */
  async emitRecallShown(noteId: string): Promise<void> {
    const note = this.repo.findById(noteId);
    if (!note) return;
    const ageDays = this.computeAgeDays(note);
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({
        kind: 'recall_shown',
        payload: { noteId, ageDays }
      }).catch(() => {});
    }
  }

  /** v0.2.3 #6 — 사용자 "다음에" 클릭 시 recall_snoozed emit. */
  async emitRecallSnoozed(noteId: string): Promise<void> {
    if (this.deps.telemetry) {
      await this.deps.telemetry.emit({
        kind: 'recall_snoozed',
        payload: { noteId }
      }).catch(() => {});
    }
  }

  /** ageDays = (now - max(last_recalled_at, created_at)) / 86_400_000, floor. */
  private computeAgeDays(note: Note): number {
    const ref = note.lastRecalledAt ?? note.createdAt;
    const refMs = new Date(ref).getTime();
    const nowMs = Date.now();
    return Math.max(0, Math.floor((nowMs - refMs) / 86_400_000));
  }
```

- [ ] **Step 4: Run, verify pass**

Run: `npm test -- CaptureService`
Expected: PASS — 4 new cases.

Run: `npm run typecheck`
Expected: 0 errors.

- [ ] **Step 5: Commit**

```bash
git add src/main/services/CaptureService.ts tests/unit/CaptureService.test.ts
git commit -m "$(cat <<'EOF'
feat(recall): CaptureService — 5 methods (list/open/dismiss/shown/snoozed) (#6 v0.2.3)

- listRecallCandidate(): repo.findRecallCandidate 위임
- markRecallOpened(id): last_recalled_at 갱신 + recall_opened emit
- dismissRecall(id): recall_dismissed_at 갱신 + recall_dismissed emit
- emitRecallShown(id): ageDays 계산 + recall_shown emit
- emitRecallSnoozed(id): recall_snoozed emit
- private computeAgeDays(note): last_recalled_at ?? created_at 기준 일수
- 단위 +4 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 5: IPC + preload + InboxApi types — 5 channels

**Files:**
- Modify: `src/main/ipc/inboxApi.ts`
- Modify: `src/preload/index.ts`
- Modify: `src/shared/types.ts`

- [ ] **Step 1: Modify inboxApi.ts**

In `src/main/ipc/inboxApi.ts`, add 5 handlers. Place them near other recent handlers (look for `inbox:retryAllFailed` and add nearby):

```typescript
  ipcMain.handle('inbox:listRecallCandidate', () => deps.capture.listRecallCandidate());
  ipcMain.handle('inbox:markRecallOpened', (_e, id: string) => deps.capture.markRecallOpened(id));
  ipcMain.handle('inbox:dismissRecall', (_e, id: string) => deps.capture.dismissRecall(id));
  ipcMain.handle('inbox:emitRecallShown', (_e, id: string) => deps.capture.emitRecallShown(id));
  ipcMain.handle('inbox:emitRecallSnoozed', (_e, id: string) => deps.capture.emitRecallSnoozed(id));
```

- [ ] **Step 2: Modify preload/index.ts**

Add 5 entries to the `inboxApi` object exported by preload (place near `retryAllFailed`):

```typescript
    listRecallCandidate: () => ipcRenderer.invoke('inbox:listRecallCandidate'),
    markRecallOpened: (id: string) => ipcRenderer.invoke('inbox:markRecallOpened', id),
    dismissRecall: (id: string) => ipcRenderer.invoke('inbox:dismissRecall', id),
    emitRecallShown: (id: string) => ipcRenderer.invoke('inbox:emitRecallShown', id),
    emitRecallSnoozed: (id: string) => ipcRenderer.invoke('inbox:emitRecallSnoozed', id),
```

- [ ] **Step 3: Modify shared/types.ts InboxApi**

In `src/shared/types.ts`, add 5 method signatures to the `InboxApi` interface:

```typescript
  listRecallCandidate(): Promise<Note | null>;
  markRecallOpened(id: string): Promise<{ note: Note }>;
  dismissRecall(id: string): Promise<{ note: Note }>;
  emitRecallShown(id: string): Promise<void>;
  emitRecallSnoozed(id: string): Promise<void>;
```

- [ ] **Step 4: Verify**

Run: `npm run typecheck`
Expected: 0 errors. (No new tests in this task — IPC plumbing covered by integration via Tasks 6-7.)

Run: `npm test`
Expected: 393/393 (Task 4 added 4, total now 386+5+3+2+4=400; this task adds 0).

(If totals differ, recount and proceed if all green.)

- [ ] **Step 5: Commit**

```bash
git add src/main/ipc/inboxApi.ts src/preload/index.ts src/shared/types.ts
git commit -m "$(cat <<'EOF'
feat(recall): IPC + preload + InboxApi — 5 channels (#6 v0.2.3)

- ipcMain.handle: list/markOpened/dismiss/emitShown/emitSnoozed
- preload inboxApi: 5 entries (ipcRenderer.invoke)
- shared/types InboxApi: 5 method signatures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 6: Renderer store — recallCandidate + 4 actions

**Files:**
- Modify: `src/renderer/inbox/store.ts`
- Create: `tests/unit/store.recall.test.ts`

- [ ] **Step 1: Write failing test (new file)**

Create `tests/unit/store.recall.test.ts`. Look at existing `tests/unit/store.aiRetry.test.ts` for the pattern (vi.mock the inboxApi, manipulate store directly, assert state). Adapt:

```typescript
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Note } from '@shared/types';

const inboxApiMock = {
  listRecallCandidate: vi.fn(),
  markRecallOpened: vi.fn(),
  dismissRecall: vi.fn(),
  emitRecallShown: vi.fn(),
  emitRecallSnoozed: vi.fn(),
  // ... pad with other inboxApi methods used by loadInitial/refreshMeta if needed
  listNotes: vi.fn(async () => []),
  getContinuity: vi.fn(async () => ({ weekStart: '', weekCount: 0, weekTarget: 7, consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null })),
  getPendingCount: vi.fn(async () => 0),
  getOllamaStatus: vi.fn(async () => ({ ok: true })),
  getTodayCount: vi.fn(async () => 0),
  getTrashCount: vi.fn(async () => 0),
  listExpired: vi.fn(async () => []),
  getFailedCount: vi.fn(async () => 0)
};

vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: inboxApiMock }));

import { useInbox } from '../../src/renderer/inbox/store.js';

const note = (id: string): Note => ({
  id, rawText: 'x', aiTitle: 't', aiSummary: 'a\nb\nc',
  tags: [], aiStatus: 'done', aiProvider: null, aiGeneratedAt: null, aiError: null,
  titleEditedByUser: false, summaryEditedByUser: false,
  dueDate: null, dueDateEditedByUser: false,
  userIntent: null, intentPromptedAt: null,
  createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
  deletedAt: null, lastRecalledAt: null, recallDismissedAt: null
});

describe('store recall actions', () => {
  beforeEach(() => {
    vi.clearAllMocks();
    useInbox.setState({
      recallCandidate: null,
      recallSnoozeUntilMs: null,
      recallShownIds: new Set<string>()
    } as Partial<ReturnType<typeof useInbox.getState>>);
  });

  it('snoozeRecall sets snoozeUntilMs to next KST midnight + emits recall_snoozed', async () => {
    useInbox.setState({ recallCandidate: note('n1') } as Partial<ReturnType<typeof useInbox.getState>>);
    await useInbox.getState().snoozeRecall();
    const ms = useInbox.getState().recallSnoozeUntilMs;
    expect(ms).not.toBeNull();
    expect(ms!).toBeGreaterThan(Date.now());
    expect(inboxApiMock.emitRecallSnoozed).toHaveBeenCalledWith('n1');
  });

  it('openRecall calls API + emits recall_shown once per note (recallShownIds)', async () => {
    inboxApiMock.markRecallOpened.mockResolvedValueOnce({ note: note('n1') });
    inboxApiMock.listRecallCandidate.mockResolvedValueOnce(null);  // no next candidate
    await useInbox.getState().openRecall('n1');
    expect(inboxApiMock.markRecallOpened).toHaveBeenCalledWith('n1');
    // openRecall doesn't emit recall_shown — that's RecallBanner's job. This test just checks
    // the mark/dismiss flow.
  });

  it('dismissRecallNote calls API + fetches next candidate', async () => {
    inboxApiMock.dismissRecall.mockResolvedValueOnce({ note: note('n1') });
    inboxApiMock.listRecallCandidate.mockResolvedValueOnce(note('n2'));
    await useInbox.getState().dismissRecallNote('n1');
    expect(inboxApiMock.dismissRecall).toHaveBeenCalledWith('n1');
    expect(useInbox.getState().recallCandidate?.id).toBe('n2');
  });
});
```

- [ ] **Step 2: Run, verify fail**

Run: `npm test -- store.recall`
Expected: FAIL — `useInbox.getState().snoozeRecall is not a function`.

- [ ] **Step 3: Modify renderer store**

In `src/renderer/inbox/store.ts`:

**3a) Add fields to `InboxState` interface:**

```typescript
interface InboxState {
  // ... existing ...
  failedCount: number;
  recallCandidate: Note | null;
  recallSnoozeUntilMs: number | null;
  recallShownIds: Set<string>;
  // ... existing actions ...
  retryAllFailed: () => Promise<void>;
  loadRecallCandidate: () => Promise<void>;
  openRecall: (id: string) => Promise<void>;
  dismissRecallNote: (id: string) => Promise<void>;
  snoozeRecall: () => Promise<void>;
}
```

**3b) Initialize new state fields in `create<InboxState>(...)`:**

After `failedCount: 0,`:
```typescript
  failedCount: 0,
  recallCandidate: null,
  recallSnoozeUntilMs: null,
  recallShownIds: new Set<string>(),
```

**3c) Update `loadInitial` to also fetch recallCandidate:**

Find:
```typescript
  async loadInitial() {
    set({ loading: true });
    const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([
      inboxApi.listNotes({ limit: 50 }),
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus(),
      inboxApi.getTodayCount(),
      inboxApi.getTrashCount(),
      inboxApi.listExpired(),
      inboxApi.getFailedCount()
    ]);
    set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, loading: false });
  },
```

Replace with:
```typescript
  async loadInitial() {
    set({ loading: true });
    const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
      inboxApi.listNotes({ limit: 50 }),
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus(),
      inboxApi.getTodayCount(),
      inboxApi.getTrashCount(),
      inboxApi.listExpired(),
      inboxApi.getFailedCount(),
      inboxApi.listRecallCandidate()
    ]);
    set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, loading: false });
  },
```

**3d) Update `refreshMeta` similarly:**

Find:
```typescript
  async refreshMeta() {
    const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount] = await Promise.all([
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus(),
      inboxApi.getTodayCount(),
      inboxApi.getTrashCount(),
      inboxApi.listExpired(),
      inboxApi.getFailedCount()
    ]);
    set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount });
  },
```

Replace with:
```typescript
  async refreshMeta() {
    const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate] = await Promise.all([
      inboxApi.getContinuity(),
      inboxApi.getPendingCount(),
      inboxApi.getOllamaStatus(),
      inboxApi.getTodayCount(),
      inboxApi.getTrashCount(),
      inboxApi.listExpired(),
      inboxApi.getFailedCount(),
      inboxApi.listRecallCandidate()
    ]);
    set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate });
  },
```

**3e) Add 4 actions at the end (after `retryAllFailed`):**

```typescript
  async loadRecallCandidate() {
    const recallCandidate = await inboxApi.listRecallCandidate();
    set({ recallCandidate });
  },
  async openRecall(id) {
    await inboxApi.markRecallOpened(id);
    // 다음 후보 fetch
    const recallCandidate = await inboxApi.listRecallCandidate();
    set({ recallCandidate });
  },
  async dismissRecallNote(id) {
    await inboxApi.dismissRecall(id);
    const recallCandidate = await inboxApi.listRecallCandidate();
    set({ recallCandidate });
  },
  async snoozeRecall() {
    const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
    const now = Date.now();
    const kstNow = now + KST_OFFSET_MS;
    const kstMidnightFloor = Math.floor(kstNow / 86_400_000) * 86_400_000;
    const nextKstMidnight = kstMidnightFloor + 86_400_000;
    set({ recallSnoozeUntilMs: nextKstMidnight - KST_OFFSET_MS });
    const candidate = get().recallCandidate;
    if (candidate) {
      await inboxApi.emitRecallSnoozed(candidate.id);
    }
  }
```

- [ ] **Step 4: Run tests, verify pass**

Run: `npm test -- store.recall`
Expected: PASS — 3 new cases.

Run: `npm run typecheck`
Expected: 0 errors.

Run: `npm test`
Expected: total +3 from previous task, all green.

- [ ] **Step 5: Commit**

```bash
git add src/renderer/inbox/store.ts tests/unit/store.recall.test.ts
git commit -m "$(cat <<'EOF'
feat(recall): renderer store — recallCandidate + 4 actions (#6 v0.2.3)

- recallCandidate, recallSnoozeUntilMs, recallShownIds (Set) state
- loadInitial / refreshMeta 가 listRecallCandidate Promise.all 합류
- loadRecallCandidate / openRecall / dismissRecallNote / snoozeRecall actions
- snoozeRecall: KST 다음 자정 (snoozeExpired 패턴 일관) + emitRecallSnoozed
- openRecall / dismissRecallNote: API 호출 후 다음 후보 fetch
- 신규 store.recall.test.ts +3 cases

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 7: RecallBanner 컴포넌트 + App.tsx mount + NoteCard id

**Files:**
- Create: `src/renderer/inbox/components/RecallBanner.tsx`
- Modify: `src/renderer/inbox/App.tsx` (import + mount + scroll handler)
- Modify: `src/renderer/inbox/components/NoteCard.tsx` (wrapper div id attr)

- [ ] **Step 1: Create RecallBanner.tsx**

Create `src/renderer/inbox/components/RecallBanner.tsx`:

```typescript
import React, { useEffect, useState } from 'react';
import { useInbox } from '../store.js';
import { inboxApi } from '../api.js';

export function RecallBanner(): React.ReactElement | null {
  const candidate = useInbox((s) => s.recallCandidate);
  const snoozeUntilMs = useInbox((s) => s.recallSnoozeUntilMs);
  const shownIds = useInbox((s) => s.recallShownIds);
  const openRecall = useInbox((s) => s.openRecall);
  const dismissRecallNote = useInbox((s) => s.dismissRecallNote);
  const snoozeRecall = useInbox((s) => s.snoozeRecall);

  // n1 fix pattern from ExpiryBanner — snoozeUntilMs 만료 시 force re-render
  const [, setTick] = useState(0);
  useEffect(() => {
    if (snoozeUntilMs === null) return;
    const remaining = snoozeUntilMs - Date.now();
    if (remaining <= 0) return;
    const t = setTimeout(() => setTick((n) => n + 1), remaining);
    return () => clearTimeout(t);
  }, [snoozeUntilMs]);

  // first-render emit recall_shown (per-session 1회 per note)
  useEffect(() => {
    if (!candidate) return;
    if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return;
    if (shownIds.has(candidate.id)) return;
    void inboxApi.emitRecallShown(candidate.id);
    useInbox.setState({ recallShownIds: new Set([...shownIds, candidate.id]) });
  }, [candidate, snoozeUntilMs, shownIds]);

  if (candidate === null) return null;
  if (snoozeUntilMs !== null && Date.now() < snoozeUntilMs) return null;

  const ageDays = computeAgeDays(candidate.lastRecalledAt ?? candidate.createdAt);
  const title = candidate.aiTitle ?? candidate.rawText.slice(0, 60);

  function onOpen() {
    void openRecall(candidate!.id);
    const el = document.getElementById(`note-${candidate!.id}`);
    if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  }

  return (
    <div style={{
      background: '#e8f0fe', border: '1px solid #4a7ec0', borderRadius: 6,
      padding: '8px 12px', margin: '8px 0', fontSize: 13
    }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        <span>💭 <b>오늘 회상해볼 노트</b></span>
        <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#234' }}>
          {title}
        </span>
        <span style={{ color: '#6a7e9a', fontSize: 12 }}>{ageDays}일 전</span>
      </div>
      <div style={{ display: 'flex', gap: 6, marginTop: 8 }}>
        <button
          onClick={onOpen}
          style={{
            background: '#4a7ec0', color: '#fff',
            border: 'none', borderRadius: 4,
            padding: '4px 12px', fontSize: 12, cursor: 'pointer'
          }}
        >
          열어보기
        </button>
        <button
          onClick={() => void snoozeRecall()}
          style={{
            background: 'transparent', color: '#4a7ec0',
            border: '1px solid #4a7ec0', borderRadius: 4,
            padding: '4px 12px', fontSize: 12, cursor: 'pointer'
          }}
        >
          다음에
        </button>
        <button
          onClick={() => void dismissRecallNote(candidate.id)}
          style={{
            marginLeft: 'auto',
            background: 'transparent', color: '#888',
            border: 'none', fontSize: 12, cursor: 'pointer'
          }}
        >
          더 이상
        </button>
      </div>
    </div>
  );
}

function computeAgeDays(refIso: string): number {
  const refMs = new Date(refIso).getTime();
  return Math.max(0, Math.floor((Date.now() - refMs) / 86_400_000));
}
```

- [ ] **Step 2: Modify NoteCard.tsx — add wrapper div id**

In `src/renderer/inbox/components/NoteCard.tsx`, locate the top-level returned `<div>` (or `<article>` / wrapper element) and add `id={\`note-${note.id}\`}` attribute.

For example, if the current return looks like:
```typescript
return (
  <div style={{ ... }}>
    {/* card contents */}
  </div>
);
```

Change to:
```typescript
return (
  <div id={`note-${note.id}`} style={{ ... }}>
    {/* card contents */}
  </div>
);
```

(Find the exact element by reading the file — apply id to outermost wrapper of the card.)

- [ ] **Step 3: Modify App.tsx — import + mount RecallBanner**

In `src/renderer/inbox/App.tsx`:

**3a) Add import:**

```typescript
import { ExpiryBanner } from './components/ExpiryBanner.js';
import { FailedBanner } from './components/FailedBanner.js';
import { RecallBanner } from './components/RecallBanner.js';
```

**3b) Mount RecallBanner after ExpiryBanner:**

Find the existing banner stack:
```typescript
<OllamaBanner />
...
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
```

Add `<RecallBanner />` after `<ExpiryBanner />`:
```typescript
<OllamaBanner />
...
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
```

- [ ] **Step 4: Run tests + typecheck**

Run: `npm run typecheck`
Expected: 0 errors.

Run: `npm test`
Expected: all pass (no new tests, but no regressions).

- [ ] **Step 5: Commit**

```bash
git add src/renderer/inbox/components/RecallBanner.tsx src/renderer/inbox/App.tsx src/renderer/inbox/components/NoteCard.tsx
git commit -m "$(cat <<'EOF'
feat(recall): RecallBanner + App.tsx mount + NoteCard id (#6 v0.2.3)

- RecallBanner: 노트 제목 + N일 전 + 3 버튼 (열어보기/다음에/더 이상)
- 첫 렌더 시 emitRecallShown (recallShownIds Set 으로 per-session 1회 제약)
- snoozeUntilMs 만료 setTimeout (ExpiryBanner 패턴)
- 위치: ExpiryBanner 다음 (banner stack 끝)
- NoteCard 외곽 div 에 id="note-${note.id}" — 회상 "열어보기" scrollIntoView target
- 컬러 테마: 파랑 (#e8f0fe / #4a7ec0) — 다른 banner (적/황/적) 와 구별

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

---

## Task 8: Closure — strategy.md 갱신 + roadmap mark complete + final gates

**Files:**
- Modify: `docs/strategy.md` (§2.3, §4.3, §8)
- Modify: `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md` (#6 mark complete)

- [ ] **Step 1: Verify final gate matrix**

Run sequentially:
```bash
npm run typecheck
npm test
npm run test:e2e
```

Expected:
- typecheck: 0 errors
- 단위: 386 + 17 = **403/403**
- e2e: 1/1

If any failures, fix before proceeding.

- [ ] **Step 2: Update strategy.md**

Read `docs/strategy.md` first to understand the current §2.3, §4.3, §8 structure. Then make these targeted edits:

**§2.3** — add "오늘 회상" surface:

Look for §2.3 heading and add a paragraph describing RecallBanner as the formal "Capitalize 본격 진입" surface. Example phrasing (adapt to existing voice):

```
**오늘 회상 (RecallBanner, v0.2.3 #6):**
Inbox 상단의 회상 추천 배너가 7일 이상 안 본 노트 1건을 제시한다. 사용자는 "열어보기"
(노트 카드 스크롤 + last_recalled_at 갱신), "다음에" (KST 자정까지 in-memory snooze),
"더 이상" (recall_dismissed_at 갱신, 30일 후 재추천) 중 선택. 본 surface 가
Capitalize 단계의 첫 본격 진입점이다.
```

**§4.3** — F4-A/B/D 측정 인프라:

Add a sentence noting that recall_shown / recall_opened / recall_dismissed / recall_snoozed
telemetry events provide the measurement substrate for future F4-A (잠금해제 hook) /
F4-B (ambient if-then) / F4-D (무작위 토스트) work.

**§8** — Inbox surface stack:

Add RecallBanner to the documented banner order: Ollama → Pending → Failed → Expiry → **Recall** (시간 민감도 낮음 순).

Since I don't have the exact strategy.md content here, the implementer should:
1. Read the file
2. Locate the relevant sections
3. Add concise paragraphs that fit the existing voice
4. Cross-reference v0.2.3 #6 as the source

Keep additions tight (3-5 lines per section).

- [ ] **Step 3: Mark #6 complete in roadmap**

In `docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md`, find the heading:
```markdown
### #6 리마인드 1 spike (7번)
```

Replace with:
```markdown
### #6 리마인드 1 spike (7번) ✓ 완료
```

- [ ] **Step 4: Commit closure**

```bash
git add docs/strategy.md docs/superpowers/specs/2026-05-01-v023-feedback-roadmap-design.md
git commit -m "$(cat <<'EOF'
chore(recall): #6 closure — strategy.md 갱신 + roadmap mark + 게이트 검증

- strategy.md §2.3 (오늘 회상 surface) / §4.3 (F4 측정 인프라) / §8 (banner stack) 갱신
- typecheck 0 / 단위 403 / e2e 1
- v0.2.3 7/7 — 모든 cut 완료. 다음: v0.2.3 binary 빌드

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

- [ ] **Step 5: Push branch**

```bash
git push -u origin feat/v023-recall-spike
```

- [ ] **Step 6: Create PR via Gitea API (controller-side)**

Using curl + author-token (gitea-pr CLI requires `tea` which isn't installed). Body and title go in temp files for UTF-8 safety.

```
PR title: feat(recall): #6 리마인드 1 spike — RecallBanner + telemetry (v0.2.3 7/7)
PR body: Summary + Changes + Spec/Plan refs + Test Plan
```

(Implementer: this is the controller's job — report DONE and let controller handle.)

---

## Self-Review (executed by plan author inline)

**1. Spec coverage:**
- ✅ Q1=A (in-memory snooze, KST 자정) → T6 store snoozeRecall
- ✅ Q2=B (ageDays = last_recalled_at ?? created_at) → T4 CaptureService.computeAgeDays
- ✅ All 6 invariants (LIMIT 1, KST 보정, 마감 제외, in-memory snooze, emit 순서, per-session 1 shown) → T1+T6+T7
- ✅ Privacy invariant (.strict, no content leak) → T2 schema + extra field reject test
- ✅ All "Out of scope" items NOT implemented (잠금해제, F4-A/B/D, 임베딩, SM-2, 다중 후보, 영속 snooze, 사용자 정의 주기, history)
- ✅ strategy.md §2.3/§4.3/§8 갱신 → T8 step 2

**2. Placeholder scan:**
- ✅ No "TBD" / "TODO" / "fill in details" anywhere
- T8 step 2 references "read existing structure" — actionable since strategy.md is real existing file

**3. Type consistency:**
- `findRecallCandidate(): Note | null` (T1) === T4 `service.listRecallCandidate(): Promise<Note | null>` (await wraps) === T5 IPC return shape === T6 store recallCandidate type
- `markRecallOpened(id, now): void` (T1) === T4 `service.markRecallOpened(id): Promise<{ note: Note }>` (note: T4 generates `now` internally and returns updated note)
- `RecallShownPayload { noteId, ageDays }` (T2) === T4 emit shape `{ noteId, ageDays }` === T3 stats branch
- 4 telemetry kinds consistent across T2/T3/T4

**4. Test count verification:**
- T1: 5 (NoteRepository)
- T2: 3 (telemetryEvents)
- T3: 2 (telemetryStats)
- T4: 4 (CaptureService)
- T6: 3 (store.recall.test.ts new file)
- Total: **17** new cases. 386 + 17 = **403** target.

---

## Roadmap Relation

- v0.2.3 dogfood feedback #6 (7번째 / 마지막 cut)
- 머지 후 v0.2.3 cut **7/7 완료** → v0.2.3 binary 빌드 + 핸드오프
- v0.2.4 후속: dogfood telemetry 분석 (열림율, 평균 ageDays), F4-A/B/D 본격 진행, snooze 영속화 결정
