Files
inkling/docs/superpowers/plans/2026-05-02-v023-recall-spike.md
altair823 746671059e docs(recall): #6 plan — 8 tasks TDD + 17 단위 cases (v0.2.3)
8 task TDD plan:
T1 NoteRepository (find/markOpened/dismiss, +5 cases)
T2 telemetryEvents (recall_shown 4 union members, +3 cases)
T3 telemetryStats + EmitInput union 19 (+2 cases)
T4 CaptureService (5 methods, +4 cases)
T5 IPC + preload + types (5 channels)
T6 Renderer store (recallCandidate + 4 actions, +3 cases)
T7 RecallBanner + App.tsx + NoteCard id
T8 closure (strategy.md + roadmap + gates)

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 13:08:32 +09:00

49 KiB

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

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

  /**
   * 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:

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

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

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

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

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

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

  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:

        ai_retry_manual: 0,
        tag_vocab_hit: 0, tag_vocab_miss: 0
      };

Replace with:

        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:

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

Replace with:

    } 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 = ...:

  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:

  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:

  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:

    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:

    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:

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

Replace with:

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

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

  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:

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

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

    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:

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

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:

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

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

3c) Update loadInitial to also fetch recallCandidate:

Find:

  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:

  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:

  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:

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

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

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:

return (
  <div style={{ ... }}>
    {/* card contents */}
  </div>
);

Change to:

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:

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:

<OllamaBanner />
...
<PendingBanner />
<FailedBanner />
<ExpiryBanner />

Add <RecallBanner /> after <ExpiryBanner />:

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

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:

### #6 리마인드 1 spike (7번)

Replace with:

### #6 리마인드 1 spike (7번) ✓ 완료
  • Step 4: Commit closure
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
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 영속화 결정