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>
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:
- Read the file
- Locate the relevant sections
- Add concise paragraphs that fit the existing voice
- 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) === T4service.listRecallCandidate(): Promise<Note | null>(await wraps) === T5 IPC return shape === T6 store recallCandidate typemarkRecallOpened(id, now): void(T1) === T4service.markRecallOpened(id): Promise<{ note: Note }>(note: T4 generatesnowinternally 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 영속화 결정