@giwonn/claude-daily-review 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2130 @@
1
+ # claude-daily-review Implementation Plan
2
+
3
+ > **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.
4
+
5
+ **Goal:** Build a Claude Code plugin that auto-captures conversations via hooks and generates structured daily/periodic review markdown files in an Obsidian vault.
6
+
7
+ **Architecture:** Session-isolated file writes (no concurrent conflicts) with deferred merge at session start. Stop hook appends raw logs per-session, SessionEnd agent generates per-session review, SessionStart agent merges reviews into daily files and generates periodic summaries. TDD with vitest.
8
+
9
+ **Tech Stack:** TypeScript, Node.js, vitest (test), tsup (build), proper-lockfile (merge safety)
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ claude-daily-review/
17
+ ├── hooks/
18
+ │ └── hooks.json ← Hook definitions
19
+ ├── skills/
20
+ │ └── daily-review-setup.md ← Setup skill (onboarding)
21
+ ├── prompts/
22
+ │ ├── session-end.md ← SessionEnd agent prompt
23
+ │ └── session-start.md ← SessionStart agent prompt
24
+ ├── src/
25
+ │ ├── core/
26
+ │ │ ├── config.ts ← Config CRUD + validation
27
+ │ │ ├── periods.ts ← Date/period utilities
28
+ │ │ ├── vault.ts ← Vault path generation + directory management
29
+ │ │ ├── raw-logger.ts ← Raw log append (session-isolated)
30
+ │ │ └── merge.ts ← Review file merge logic
31
+ │ └── hooks/
32
+ │ └── on-stop.ts ← Stop hook entry point (stdin → raw log)
33
+ ├── tests/
34
+ │ ├── core/
35
+ │ │ ├── config.test.ts
36
+ │ │ ├── periods.test.ts
37
+ │ │ ├── vault.test.ts
38
+ │ │ ├── raw-logger.test.ts
39
+ │ │ └── merge.test.ts
40
+ │ └── hooks/
41
+ │ └── on-stop.test.ts
42
+ ├── package.json
43
+ ├── tsconfig.json
44
+ ├── vitest.config.ts
45
+ └── README.md
46
+ ```
47
+
48
+ ---
49
+
50
+ ### Task 1: Project Scaffolding
51
+
52
+ **Files:**
53
+ - Create: `package.json`
54
+ - Create: `tsconfig.json`
55
+ - Create: `vitest.config.ts`
56
+
57
+ - [ ] **Step 1: Initialize package.json**
58
+
59
+ ```json
60
+ {
61
+ "name": "claude-daily-review",
62
+ "version": "0.1.0",
63
+ "description": "Claude Code plugin that auto-captures conversations for daily review and career documentation",
64
+ "type": "module",
65
+ "main": "dist/hooks/on-stop.js",
66
+ "scripts": {
67
+ "build": "tsup",
68
+ "test": "vitest run",
69
+ "test:watch": "vitest"
70
+ },
71
+ "keywords": ["claude-code", "plugin", "daily-review", "obsidian"],
72
+ "license": "MIT",
73
+ "devDependencies": {
74
+ "typescript": "^5.4.0",
75
+ "vitest": "^3.0.0",
76
+ "tsup": "^8.0.0",
77
+ "@types/node": "^20.0.0",
78
+ "@types/proper-lockfile": "^4.1.4"
79
+ },
80
+ "dependencies": {
81
+ "proper-lockfile": "^4.1.2"
82
+ }
83
+ }
84
+ ```
85
+
86
+ - [ ] **Step 2: Create tsconfig.json**
87
+
88
+ ```json
89
+ {
90
+ "compilerOptions": {
91
+ "target": "ES2022",
92
+ "module": "ES2022",
93
+ "moduleResolution": "bundler",
94
+ "lib": ["ES2022"],
95
+ "outDir": "dist",
96
+ "rootDir": "src",
97
+ "strict": true,
98
+ "esModuleInterop": true,
99
+ "skipLibCheck": true,
100
+ "declaration": true,
101
+ "sourceMap": true
102
+ },
103
+ "include": ["src"],
104
+ "exclude": ["node_modules", "dist", "tests"]
105
+ }
106
+ ```
107
+
108
+ - [ ] **Step 3: Create vitest.config.ts**
109
+
110
+ ```typescript
111
+ import { defineConfig } from "vitest/config";
112
+
113
+ export default defineConfig({
114
+ test: {
115
+ globals: true,
116
+ environment: "node",
117
+ },
118
+ });
119
+ ```
120
+
121
+ - [ ] **Step 4: Create tsup.config.ts**
122
+
123
+ ```typescript
124
+ import { defineConfig } from "tsup";
125
+
126
+ export default defineConfig({
127
+ entry: ["src/hooks/on-stop.ts"],
128
+ format: ["esm"],
129
+ target: "node20",
130
+ outDir: "dist",
131
+ clean: true,
132
+ sourcemap: true,
133
+ splitting: false,
134
+ bundle: true,
135
+ });
136
+ ```
137
+
138
+ - [ ] **Step 5: Install dependencies**
139
+
140
+ Run: `npm install`
141
+ Expected: `node_modules/` created, `package-lock.json` generated
142
+
143
+ - [ ] **Step 6: Verify setup**
144
+
145
+ Run: `npx vitest run`
146
+ Expected: "No test files found" (no tests yet, but vitest runs)
147
+
148
+ - [ ] **Step 7: Commit**
149
+
150
+ ```bash
151
+ git init
152
+ git add package.json package-lock.json tsconfig.json vitest.config.ts tsup.config.ts
153
+ git commit -m "chore: scaffold project with TypeScript, vitest, tsup"
154
+ ```
155
+
156
+ ---
157
+
158
+ ### Task 2: Config Module (TDD)
159
+
160
+ **Files:**
161
+ - Create: `tests/core/config.test.ts`
162
+ - Create: `src/core/config.ts`
163
+
164
+ - [ ] **Step 1: Write failing tests for config**
165
+
166
+ ```typescript
167
+ // tests/core/config.test.ts
168
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
169
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "fs";
170
+ import { join } from "path";
171
+ import { tmpdir } from "os";
172
+ import {
173
+ getConfigPath,
174
+ loadConfig,
175
+ saveConfig,
176
+ validateConfig,
177
+ createDefaultConfig,
178
+ } from "../../src/core/config.js";
179
+
180
+ describe("config", () => {
181
+ let tempDir: string;
182
+ const originalEnv = process.env.CLAUDE_PLUGIN_DATA;
183
+
184
+ beforeEach(() => {
185
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-test-"));
186
+ process.env.CLAUDE_PLUGIN_DATA = tempDir;
187
+ });
188
+
189
+ afterEach(() => {
190
+ rmSync(tempDir, { recursive: true, force: true });
191
+ process.env.CLAUDE_PLUGIN_DATA = originalEnv;
192
+ });
193
+
194
+ describe("getConfigPath", () => {
195
+ it("returns path under CLAUDE_PLUGIN_DATA", () => {
196
+ const result = getConfigPath();
197
+ expect(result).toBe(join(tempDir, "config.json"));
198
+ });
199
+
200
+ it("throws when CLAUDE_PLUGIN_DATA is not set", () => {
201
+ delete process.env.CLAUDE_PLUGIN_DATA;
202
+ expect(() => getConfigPath()).toThrow("CLAUDE_PLUGIN_DATA");
203
+ });
204
+ });
205
+
206
+ describe("loadConfig", () => {
207
+ it("returns null when config does not exist", () => {
208
+ const result = loadConfig();
209
+ expect(result).toBeNull();
210
+ });
211
+
212
+ it("returns parsed config when file exists", () => {
213
+ const config = {
214
+ vaultPath: "/my/vault",
215
+ reviewFolder: "daily-review",
216
+ language: "ko",
217
+ periods: { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false },
218
+ profile: { company: "Test", role: "Dev", team: "A", context: "B" },
219
+ };
220
+ writeFileSync(join(tempDir, "config.json"), JSON.stringify(config));
221
+ const result = loadConfig();
222
+ expect(result).toEqual(config);
223
+ });
224
+ });
225
+
226
+ describe("saveConfig", () => {
227
+ it("writes config to disk", () => {
228
+ const config = createDefaultConfig("/my/vault");
229
+ saveConfig(config);
230
+ const raw = readFileSync(join(tempDir, "config.json"), "utf-8");
231
+ expect(JSON.parse(raw)).toEqual(config);
232
+ });
233
+
234
+ it("creates parent directories if needed", () => {
235
+ process.env.CLAUDE_PLUGIN_DATA = join(tempDir, "nested", "dir");
236
+ const config = createDefaultConfig("/my/vault");
237
+ saveConfig(config);
238
+ expect(existsSync(join(tempDir, "nested", "dir", "config.json"))).toBe(true);
239
+ });
240
+ });
241
+
242
+ describe("validateConfig", () => {
243
+ it("returns true for valid config", () => {
244
+ const config = createDefaultConfig("/my/vault");
245
+ expect(validateConfig(config)).toBe(true);
246
+ });
247
+
248
+ it("returns false when vaultPath is missing", () => {
249
+ expect(validateConfig({ reviewFolder: "test" })).toBe(false);
250
+ });
251
+
252
+ it("returns false when vaultPath is empty string", () => {
253
+ expect(validateConfig({ vaultPath: "" })).toBe(false);
254
+ });
255
+
256
+ it("returns false for null", () => {
257
+ expect(validateConfig(null)).toBe(false);
258
+ });
259
+
260
+ it("returns false for non-object", () => {
261
+ expect(validateConfig("string")).toBe(false);
262
+ });
263
+ });
264
+
265
+ describe("createDefaultConfig", () => {
266
+ it("creates config with defaults and given vaultPath", () => {
267
+ const config = createDefaultConfig("/my/vault");
268
+ expect(config.vaultPath).toBe("/my/vault");
269
+ expect(config.reviewFolder).toBe("daily-review");
270
+ expect(config.language).toBe("ko");
271
+ expect(config.periods.daily).toBe(true);
272
+ expect(config.periods.weekly).toBe(true);
273
+ expect(config.periods.monthly).toBe(true);
274
+ expect(config.periods.quarterly).toBe(true);
275
+ expect(config.periods.yearly).toBe(false);
276
+ expect(config.profile.company).toBe("");
277
+ expect(config.profile.role).toBe("");
278
+ expect(config.profile.team).toBe("");
279
+ expect(config.profile.context).toBe("");
280
+ });
281
+ });
282
+ });
283
+ ```
284
+
285
+ - [ ] **Step 2: Run tests to verify they fail**
286
+
287
+ Run: `npx vitest run tests/core/config.test.ts`
288
+ Expected: FAIL — cannot resolve `../../src/core/config.js`
289
+
290
+ - [ ] **Step 3: Implement config module**
291
+
292
+ ```typescript
293
+ // src/core/config.ts
294
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
295
+ import { dirname, join } from "path";
296
+
297
+ export interface Profile {
298
+ company: string;
299
+ role: string;
300
+ team: string;
301
+ context: string;
302
+ }
303
+
304
+ export interface Periods {
305
+ daily: true;
306
+ weekly: boolean;
307
+ monthly: boolean;
308
+ quarterly: boolean;
309
+ yearly: boolean;
310
+ }
311
+
312
+ export interface Config {
313
+ vaultPath: string;
314
+ reviewFolder: string;
315
+ language: string;
316
+ periods: Periods;
317
+ profile: Profile;
318
+ }
319
+
320
+ const DEFAULT_PERIODS: Periods = {
321
+ daily: true,
322
+ weekly: true,
323
+ monthly: true,
324
+ quarterly: true,
325
+ yearly: false,
326
+ };
327
+
328
+ const DEFAULT_PROFILE: Profile = {
329
+ company: "",
330
+ role: "",
331
+ team: "",
332
+ context: "",
333
+ };
334
+
335
+ export function getConfigPath(): string {
336
+ const dataDir = process.env.CLAUDE_PLUGIN_DATA;
337
+ if (!dataDir) {
338
+ throw new Error("CLAUDE_PLUGIN_DATA environment variable is not set");
339
+ }
340
+ return join(dataDir, "config.json");
341
+ }
342
+
343
+ export function loadConfig(): Config | null {
344
+ const configPath = getConfigPath();
345
+ if (!existsSync(configPath)) return null;
346
+ const raw = readFileSync(configPath, "utf-8");
347
+ return JSON.parse(raw) as Config;
348
+ }
349
+
350
+ export function saveConfig(config: Config): void {
351
+ const configPath = getConfigPath();
352
+ mkdirSync(dirname(configPath), { recursive: true });
353
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
354
+ }
355
+
356
+ export function validateConfig(config: unknown): config is Config {
357
+ if (!config || typeof config !== "object") return false;
358
+ const c = config as Record<string, unknown>;
359
+ if (typeof c.vaultPath !== "string" || c.vaultPath === "") return false;
360
+ return true;
361
+ }
362
+
363
+ export function createDefaultConfig(vaultPath: string): Config {
364
+ return {
365
+ vaultPath,
366
+ reviewFolder: "daily-review",
367
+ language: "ko",
368
+ periods: { ...DEFAULT_PERIODS },
369
+ profile: { ...DEFAULT_PROFILE },
370
+ };
371
+ }
372
+ ```
373
+
374
+ - [ ] **Step 4: Run tests to verify they pass**
375
+
376
+ Run: `npx vitest run tests/core/config.test.ts`
377
+ Expected: All 8 tests PASS
378
+
379
+ - [ ] **Step 5: Commit**
380
+
381
+ ```bash
382
+ git add src/core/config.ts tests/core/config.test.ts
383
+ git commit -m "feat: add config module with load/save/validate/defaults"
384
+ ```
385
+
386
+ ---
387
+
388
+ ### Task 3: Periods Utility Module (TDD)
389
+
390
+ **Files:**
391
+ - Create: `tests/core/periods.test.ts`
392
+ - Create: `src/core/periods.ts`
393
+
394
+ - [ ] **Step 1: Write failing tests for period utilities**
395
+
396
+ ```typescript
397
+ // tests/core/periods.test.ts
398
+ import { describe, it, expect } from "vitest";
399
+ import {
400
+ getISOWeek,
401
+ getISOWeekYear,
402
+ getQuarter,
403
+ formatDate,
404
+ formatWeek,
405
+ formatMonth,
406
+ formatQuarter,
407
+ formatYear,
408
+ checkPeriodsNeeded,
409
+ } from "../../src/core/periods.js";
410
+
411
+ describe("periods", () => {
412
+ describe("getISOWeek", () => {
413
+ it("returns week 1 for 2026-01-01 (Thursday)", () => {
414
+ expect(getISOWeek(new Date(2026, 0, 1))).toBe(1);
415
+ });
416
+
417
+ it("returns week 53 for 2020-12-31 (Thursday of W53)", () => {
418
+ expect(getISOWeek(new Date(2020, 11, 31))).toBe(53);
419
+ });
420
+
421
+ it("returns week 13 for 2026-03-28 (Saturday)", () => {
422
+ expect(getISOWeek(new Date(2026, 2, 28))).toBe(13);
423
+ });
424
+ });
425
+
426
+ describe("getISOWeekYear", () => {
427
+ it("returns 2026 for 2026-01-01", () => {
428
+ expect(getISOWeekYear(new Date(2026, 0, 1))).toBe(2026);
429
+ });
430
+
431
+ it("returns previous year for dates in week 1 belonging to prev year", () => {
432
+ // 2025-12-29 is Monday of W01 of 2026
433
+ expect(getISOWeekYear(new Date(2025, 11, 29))).toBe(2026);
434
+ });
435
+ });
436
+
437
+ describe("getQuarter", () => {
438
+ it("returns Q1 for January", () => {
439
+ expect(getQuarter(new Date(2026, 0, 15))).toBe(1);
440
+ });
441
+
442
+ it("returns Q1 for March", () => {
443
+ expect(getQuarter(new Date(2026, 2, 28))).toBe(1);
444
+ });
445
+
446
+ it("returns Q2 for April", () => {
447
+ expect(getQuarter(new Date(2026, 3, 1))).toBe(2);
448
+ });
449
+
450
+ it("returns Q4 for December", () => {
451
+ expect(getQuarter(new Date(2026, 11, 31))).toBe(4);
452
+ });
453
+ });
454
+
455
+ describe("formatDate", () => {
456
+ it("formats as YYYY-MM-DD", () => {
457
+ expect(formatDate(new Date(2026, 2, 28))).toBe("2026-03-28");
458
+ });
459
+
460
+ it("zero-pads single digit months and days", () => {
461
+ expect(formatDate(new Date(2026, 0, 5))).toBe("2026-01-05");
462
+ });
463
+ });
464
+
465
+ describe("formatWeek", () => {
466
+ it("formats as YYYY-Www", () => {
467
+ expect(formatWeek(new Date(2026, 2, 28))).toBe("2026-W13");
468
+ });
469
+
470
+ it("zero-pads single digit weeks", () => {
471
+ expect(formatWeek(new Date(2026, 0, 5))).toBe("2026-W02");
472
+ });
473
+ });
474
+
475
+ describe("formatMonth", () => {
476
+ it("formats as YYYY-MM", () => {
477
+ expect(formatMonth(new Date(2026, 2, 28))).toBe("2026-03");
478
+ });
479
+ });
480
+
481
+ describe("formatQuarter", () => {
482
+ it("formats as YYYY-Qn", () => {
483
+ expect(formatQuarter(new Date(2026, 2, 28))).toBe("2026-Q1");
484
+ });
485
+ });
486
+
487
+ describe("formatYear", () => {
488
+ it("formats as YYYY", () => {
489
+ expect(formatYear(new Date(2026, 2, 28))).toBe("2026");
490
+ });
491
+ });
492
+
493
+ describe("checkPeriodsNeeded", () => {
494
+ it("returns all false when same day", () => {
495
+ const today = new Date(2026, 2, 28);
496
+ const lastRun = new Date(2026, 2, 28);
497
+ const result = checkPeriodsNeeded(today, lastRun);
498
+ expect(result.needsWeekly).toBe(false);
499
+ expect(result.needsMonthly).toBe(false);
500
+ expect(result.needsQuarterly).toBe(false);
501
+ expect(result.needsYearly).toBe(false);
502
+ });
503
+
504
+ it("detects new week", () => {
505
+ const today = new Date(2026, 2, 30); // Monday W14
506
+ const lastRun = new Date(2026, 2, 28); // Saturday W13
507
+ const result = checkPeriodsNeeded(today, lastRun);
508
+ expect(result.needsWeekly).toBe(true);
509
+ expect(result.previousWeek).toBe("2026-W13");
510
+ });
511
+
512
+ it("detects new month", () => {
513
+ const today = new Date(2026, 3, 1); // April 1
514
+ const lastRun = new Date(2026, 2, 31); // March 31
515
+ const result = checkPeriodsNeeded(today, lastRun);
516
+ expect(result.needsMonthly).toBe(true);
517
+ expect(result.previousMonth).toBe("2026-03");
518
+ });
519
+
520
+ it("detects new quarter", () => {
521
+ const today = new Date(2026, 3, 1); // April 1 = Q2
522
+ const lastRun = new Date(2026, 2, 31); // March 31 = Q1
523
+ const result = checkPeriodsNeeded(today, lastRun);
524
+ expect(result.needsQuarterly).toBe(true);
525
+ expect(result.previousQuarter).toBe("2026-Q1");
526
+ });
527
+
528
+ it("detects new year", () => {
529
+ const today = new Date(2027, 0, 1);
530
+ const lastRun = new Date(2026, 11, 31);
531
+ const result = checkPeriodsNeeded(today, lastRun);
532
+ expect(result.needsYearly).toBe(true);
533
+ expect(result.previousYear).toBe("2026");
534
+ });
535
+
536
+ it("handles null lastRun (first run ever)", () => {
537
+ const today = new Date(2026, 2, 28);
538
+ const result = checkPeriodsNeeded(today, null);
539
+ expect(result.needsWeekly).toBe(false);
540
+ expect(result.needsMonthly).toBe(false);
541
+ expect(result.needsQuarterly).toBe(false);
542
+ expect(result.needsYearly).toBe(false);
543
+ });
544
+
545
+ it("detects multiple periods at once (new year = new month + quarter + year)", () => {
546
+ const today = new Date(2027, 0, 5); // Jan 5 2027 (Monday W02)
547
+ const lastRun = new Date(2026, 11, 28); // Dec 28 2026
548
+ const result = checkPeriodsNeeded(today, lastRun);
549
+ expect(result.needsWeekly).toBe(true);
550
+ expect(result.needsMonthly).toBe(true);
551
+ expect(result.needsQuarterly).toBe(true);
552
+ expect(result.needsYearly).toBe(true);
553
+ });
554
+ });
555
+ });
556
+ ```
557
+
558
+ - [ ] **Step 2: Run tests to verify they fail**
559
+
560
+ Run: `npx vitest run tests/core/periods.test.ts`
561
+ Expected: FAIL — cannot resolve `../../src/core/periods.js`
562
+
563
+ - [ ] **Step 3: Implement periods module**
564
+
565
+ ```typescript
566
+ // src/core/periods.ts
567
+ export function getISOWeek(date: Date): number {
568
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
569
+ const dayNum = d.getUTCDay() || 7;
570
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
571
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
572
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
573
+ }
574
+
575
+ export function getISOWeekYear(date: Date): number {
576
+ const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
577
+ const dayNum = d.getUTCDay() || 7;
578
+ d.setUTCDate(d.getUTCDate() + 4 - dayNum);
579
+ return d.getUTCFullYear();
580
+ }
581
+
582
+ export function getQuarter(date: Date): number {
583
+ return Math.ceil((date.getMonth() + 1) / 3);
584
+ }
585
+
586
+ export function formatDate(date: Date): string {
587
+ const y = date.getFullYear();
588
+ const m = String(date.getMonth() + 1).padStart(2, "0");
589
+ const d = String(date.getDate()).padStart(2, "0");
590
+ return `${y}-${m}-${d}`;
591
+ }
592
+
593
+ export function formatWeek(date: Date): string {
594
+ return `${getISOWeekYear(date)}-W${String(getISOWeek(date)).padStart(2, "0")}`;
595
+ }
596
+
597
+ export function formatMonth(date: Date): string {
598
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
599
+ }
600
+
601
+ export function formatQuarter(date: Date): string {
602
+ return `${date.getFullYear()}-Q${getQuarter(date)}`;
603
+ }
604
+
605
+ export function formatYear(date: Date): string {
606
+ return `${date.getFullYear()}`;
607
+ }
608
+
609
+ export interface PeriodCheck {
610
+ needsWeekly: boolean;
611
+ needsMonthly: boolean;
612
+ needsQuarterly: boolean;
613
+ needsYearly: boolean;
614
+ previousWeek: string;
615
+ previousMonth: string;
616
+ previousQuarter: string;
617
+ previousYear: string;
618
+ }
619
+
620
+ export function checkPeriodsNeeded(today: Date, lastRun: Date | null): PeriodCheck {
621
+ if (!lastRun) {
622
+ return {
623
+ needsWeekly: false,
624
+ needsMonthly: false,
625
+ needsQuarterly: false,
626
+ needsYearly: false,
627
+ previousWeek: "",
628
+ previousMonth: "",
629
+ previousQuarter: "",
630
+ previousYear: "",
631
+ };
632
+ }
633
+
634
+ const todayWeek = formatWeek(today);
635
+ const lastWeek = formatWeek(lastRun);
636
+ const todayMonth = formatMonth(today);
637
+ const lastMonth = formatMonth(lastRun);
638
+ const todayQuarter = formatQuarter(today);
639
+ const lastQuarter = formatQuarter(lastRun);
640
+ const todayYear = formatYear(today);
641
+ const lastYear = formatYear(lastRun);
642
+
643
+ return {
644
+ needsWeekly: todayWeek !== lastWeek,
645
+ needsMonthly: todayMonth !== lastMonth,
646
+ needsQuarterly: todayQuarter !== lastQuarter,
647
+ needsYearly: todayYear !== lastYear,
648
+ previousWeek: lastWeek,
649
+ previousMonth: lastMonth,
650
+ previousQuarter: lastQuarter,
651
+ previousYear: lastYear,
652
+ };
653
+ }
654
+ ```
655
+
656
+ - [ ] **Step 4: Run tests to verify they pass**
657
+
658
+ Run: `npx vitest run tests/core/periods.test.ts`
659
+ Expected: All 17 tests PASS
660
+
661
+ - [ ] **Step 5: Commit**
662
+
663
+ ```bash
664
+ git add src/core/periods.ts tests/core/periods.test.ts
665
+ git commit -m "feat: add periods module with date formatting and period detection"
666
+ ```
667
+
668
+ ---
669
+
670
+ ### Task 4: Vault Module (TDD)
671
+
672
+ **Files:**
673
+ - Create: `tests/core/vault.test.ts`
674
+ - Create: `src/core/vault.ts`
675
+
676
+ - [ ] **Step 1: Write failing tests for vault**
677
+
678
+ ```typescript
679
+ // tests/core/vault.test.ts
680
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
681
+ import { mkdtempSync, rmSync, existsSync } from "fs";
682
+ import { join } from "path";
683
+ import { tmpdir } from "os";
684
+ import {
685
+ getReviewBasePath,
686
+ getRawDir,
687
+ getReviewsDir,
688
+ getDailyPath,
689
+ getWeeklyPath,
690
+ getMonthlyPath,
691
+ getQuarterlyPath,
692
+ getYearlyPath,
693
+ getProjectDailyPath,
694
+ getProjectSummaryPath,
695
+ getUncategorizedPath,
696
+ ensureVaultDirectories,
697
+ } from "../../src/core/vault.js";
698
+ import type { Config } from "../../src/core/config.js";
699
+
700
+ describe("vault", () => {
701
+ let tempDir: string;
702
+ let config: Config;
703
+
704
+ beforeEach(() => {
705
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-vault-"));
706
+ config = {
707
+ vaultPath: tempDir,
708
+ reviewFolder: "daily-review",
709
+ language: "ko",
710
+ periods: { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false },
711
+ profile: { company: "", role: "", team: "", context: "" },
712
+ };
713
+ });
714
+
715
+ afterEach(() => {
716
+ rmSync(tempDir, { recursive: true, force: true });
717
+ });
718
+
719
+ describe("getReviewBasePath", () => {
720
+ it("combines vaultPath and reviewFolder", () => {
721
+ expect(getReviewBasePath(config)).toBe(join(tempDir, "daily-review"));
722
+ });
723
+ });
724
+
725
+ describe("path generators", () => {
726
+ it("getRawDir returns .raw/{sessionId}", () => {
727
+ expect(getRawDir(config, "sess-123")).toBe(
728
+ join(tempDir, "daily-review", ".raw", "sess-123")
729
+ );
730
+ });
731
+
732
+ it("getReviewsDir returns .reviews", () => {
733
+ expect(getReviewsDir(config)).toBe(join(tempDir, "daily-review", ".reviews"));
734
+ });
735
+
736
+ it("getDailyPath returns daily/{date}.md", () => {
737
+ expect(getDailyPath(config, "2026-03-28")).toBe(
738
+ join(tempDir, "daily-review", "daily", "2026-03-28.md")
739
+ );
740
+ });
741
+
742
+ it("getWeeklyPath returns weekly/{week}.md", () => {
743
+ expect(getWeeklyPath(config, "2026-W13")).toBe(
744
+ join(tempDir, "daily-review", "weekly", "2026-W13.md")
745
+ );
746
+ });
747
+
748
+ it("getMonthlyPath returns monthly/{month}.md", () => {
749
+ expect(getMonthlyPath(config, "2026-03")).toBe(
750
+ join(tempDir, "daily-review", "monthly", "2026-03.md")
751
+ );
752
+ });
753
+
754
+ it("getQuarterlyPath returns quarterly/{quarter}.md", () => {
755
+ expect(getQuarterlyPath(config, "2026-Q1")).toBe(
756
+ join(tempDir, "daily-review", "quarterly", "2026-Q1.md")
757
+ );
758
+ });
759
+
760
+ it("getYearlyPath returns yearly/{year}.md", () => {
761
+ expect(getYearlyPath(config, "2026")).toBe(
762
+ join(tempDir, "daily-review", "yearly", "2026.md")
763
+ );
764
+ });
765
+
766
+ it("getProjectDailyPath returns projects/{name}/{date}.md", () => {
767
+ expect(getProjectDailyPath(config, "my-app", "2026-03-28")).toBe(
768
+ join(tempDir, "daily-review", "projects", "my-app", "2026-03-28.md")
769
+ );
770
+ });
771
+
772
+ it("getProjectSummaryPath returns projects/{name}/summary.md", () => {
773
+ expect(getProjectSummaryPath(config, "my-app")).toBe(
774
+ join(tempDir, "daily-review", "projects", "my-app", "summary.md")
775
+ );
776
+ });
777
+
778
+ it("getUncategorizedPath returns uncategorized/{date}.md", () => {
779
+ expect(getUncategorizedPath(config, "2026-03-28")).toBe(
780
+ join(tempDir, "daily-review", "uncategorized", "2026-03-28.md")
781
+ );
782
+ });
783
+ });
784
+
785
+ describe("ensureVaultDirectories", () => {
786
+ it("creates base directories", () => {
787
+ ensureVaultDirectories(config);
788
+ expect(existsSync(join(tempDir, "daily-review", "daily"))).toBe(true);
789
+ expect(existsSync(join(tempDir, "daily-review", "projects"))).toBe(true);
790
+ expect(existsSync(join(tempDir, "daily-review", "uncategorized"))).toBe(true);
791
+ expect(existsSync(join(tempDir, "daily-review", ".raw"))).toBe(true);
792
+ expect(existsSync(join(tempDir, "daily-review", ".reviews"))).toBe(true);
793
+ });
794
+
795
+ it("creates period directories only when enabled", () => {
796
+ ensureVaultDirectories(config);
797
+ expect(existsSync(join(tempDir, "daily-review", "weekly"))).toBe(true);
798
+ expect(existsSync(join(tempDir, "daily-review", "monthly"))).toBe(true);
799
+ expect(existsSync(join(tempDir, "daily-review", "quarterly"))).toBe(true);
800
+ expect(existsSync(join(tempDir, "daily-review", "yearly"))).toBe(false);
801
+ });
802
+
803
+ it("is idempotent", () => {
804
+ ensureVaultDirectories(config);
805
+ ensureVaultDirectories(config);
806
+ expect(existsSync(join(tempDir, "daily-review", "daily"))).toBe(true);
807
+ });
808
+ });
809
+ });
810
+ ```
811
+
812
+ - [ ] **Step 2: Run tests to verify they fail**
813
+
814
+ Run: `npx vitest run tests/core/vault.test.ts`
815
+ Expected: FAIL — cannot resolve `../../src/core/vault.js`
816
+
817
+ - [ ] **Step 3: Implement vault module**
818
+
819
+ ```typescript
820
+ // src/core/vault.ts
821
+ import { mkdirSync } from "fs";
822
+ import { join } from "path";
823
+ import type { Config } from "./config.js";
824
+
825
+ export function getReviewBasePath(config: Config): string {
826
+ return join(config.vaultPath, config.reviewFolder);
827
+ }
828
+
829
+ export function getRawDir(config: Config, sessionId: string): string {
830
+ return join(getReviewBasePath(config), ".raw", sessionId);
831
+ }
832
+
833
+ export function getReviewsDir(config: Config): string {
834
+ return join(getReviewBasePath(config), ".reviews");
835
+ }
836
+
837
+ export function getDailyPath(config: Config, date: string): string {
838
+ return join(getReviewBasePath(config), "daily", `${date}.md`);
839
+ }
840
+
841
+ export function getWeeklyPath(config: Config, week: string): string {
842
+ return join(getReviewBasePath(config), "weekly", `${week}.md`);
843
+ }
844
+
845
+ export function getMonthlyPath(config: Config, month: string): string {
846
+ return join(getReviewBasePath(config), "monthly", `${month}.md`);
847
+ }
848
+
849
+ export function getQuarterlyPath(config: Config, quarter: string): string {
850
+ return join(getReviewBasePath(config), "quarterly", `${quarter}.md`);
851
+ }
852
+
853
+ export function getYearlyPath(config: Config, year: string): string {
854
+ return join(getReviewBasePath(config), "yearly", `${year}.md`);
855
+ }
856
+
857
+ export function getProjectDailyPath(config: Config, projectName: string, date: string): string {
858
+ return join(getReviewBasePath(config), "projects", projectName, `${date}.md`);
859
+ }
860
+
861
+ export function getProjectSummaryPath(config: Config, projectName: string): string {
862
+ return join(getReviewBasePath(config), "projects", projectName, "summary.md");
863
+ }
864
+
865
+ export function getUncategorizedPath(config: Config, date: string): string {
866
+ return join(getReviewBasePath(config), "uncategorized", `${date}.md`);
867
+ }
868
+
869
+ export function ensureVaultDirectories(config: Config): void {
870
+ const base = getReviewBasePath(config);
871
+ const dirs = [
872
+ join(base, "daily"),
873
+ join(base, "projects"),
874
+ join(base, "uncategorized"),
875
+ join(base, ".raw"),
876
+ join(base, ".reviews"),
877
+ ];
878
+
879
+ if (config.periods.weekly) dirs.push(join(base, "weekly"));
880
+ if (config.periods.monthly) dirs.push(join(base, "monthly"));
881
+ if (config.periods.quarterly) dirs.push(join(base, "quarterly"));
882
+ if (config.periods.yearly) dirs.push(join(base, "yearly"));
883
+
884
+ for (const dir of dirs) {
885
+ mkdirSync(dir, { recursive: true });
886
+ }
887
+ }
888
+ ```
889
+
890
+ - [ ] **Step 4: Run tests to verify they pass**
891
+
892
+ Run: `npx vitest run tests/core/vault.test.ts`
893
+ Expected: All 13 tests PASS
894
+
895
+ - [ ] **Step 5: Commit**
896
+
897
+ ```bash
898
+ git add src/core/vault.ts tests/core/vault.test.ts
899
+ git commit -m "feat: add vault module with path generators and directory management"
900
+ ```
901
+
902
+ ---
903
+
904
+ ### Task 5: Raw Logger Module (TDD)
905
+
906
+ **Files:**
907
+ - Create: `tests/core/raw-logger.test.ts`
908
+ - Create: `src/core/raw-logger.ts`
909
+
910
+ - [ ] **Step 1: Write failing tests for raw-logger**
911
+
912
+ ```typescript
913
+ // tests/core/raw-logger.test.ts
914
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
915
+ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "fs";
916
+ import { join } from "path";
917
+ import { tmpdir } from "os";
918
+ import {
919
+ parseHookInput,
920
+ appendRawLog,
921
+ type HookInput,
922
+ } from "../../src/core/raw-logger.js";
923
+
924
+ describe("raw-logger", () => {
925
+ let tempDir: string;
926
+
927
+ beforeEach(() => {
928
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-raw-"));
929
+ });
930
+
931
+ afterEach(() => {
932
+ rmSync(tempDir, { recursive: true, force: true });
933
+ });
934
+
935
+ describe("parseHookInput", () => {
936
+ it("parses valid JSON from stdin", () => {
937
+ const input = JSON.stringify({
938
+ session_id: "abc-123",
939
+ transcript_path: "/tmp/transcript.jsonl",
940
+ cwd: "/projects/my-app",
941
+ hook_event_name: "Stop",
942
+ });
943
+ const result = parseHookInput(input);
944
+ expect(result.session_id).toBe("abc-123");
945
+ expect(result.transcript_path).toBe("/tmp/transcript.jsonl");
946
+ expect(result.cwd).toBe("/projects/my-app");
947
+ expect(result.hook_event_name).toBe("Stop");
948
+ });
949
+
950
+ it("throws on invalid JSON", () => {
951
+ expect(() => parseHookInput("not json")).toThrow();
952
+ });
953
+
954
+ it("throws when session_id is missing", () => {
955
+ const input = JSON.stringify({ cwd: "/tmp" });
956
+ expect(() => parseHookInput(input)).toThrow("session_id");
957
+ });
958
+ });
959
+
960
+ describe("appendRawLog", () => {
961
+ it("creates session directory and appends log entry", () => {
962
+ const sessionDir = join(tempDir, "sess-1");
963
+ const entry: HookInput = {
964
+ session_id: "sess-1",
965
+ transcript_path: "/tmp/t.jsonl",
966
+ cwd: "/projects/my-app",
967
+ hook_event_name: "Stop",
968
+ };
969
+
970
+ appendRawLog(sessionDir, "2026-03-28", entry);
971
+
972
+ const logPath = join(sessionDir, "2026-03-28.jsonl");
973
+ expect(existsSync(logPath)).toBe(true);
974
+ const lines = readFileSync(logPath, "utf-8").trim().split("\n");
975
+ expect(lines).toHaveLength(1);
976
+ const parsed = JSON.parse(lines[0]);
977
+ expect(parsed.session_id).toBe("sess-1");
978
+ expect(parsed.cwd).toBe("/projects/my-app");
979
+ expect(typeof parsed.timestamp).toBe("string");
980
+ });
981
+
982
+ it("appends multiple entries to same file", () => {
983
+ const sessionDir = join(tempDir, "sess-2");
984
+ const entry: HookInput = {
985
+ session_id: "sess-2",
986
+ transcript_path: "/tmp/t.jsonl",
987
+ cwd: "/projects/my-app",
988
+ hook_event_name: "Stop",
989
+ };
990
+
991
+ appendRawLog(sessionDir, "2026-03-28", entry);
992
+ appendRawLog(sessionDir, "2026-03-28", entry);
993
+
994
+ const logPath = join(sessionDir, "2026-03-28.jsonl");
995
+ const lines = readFileSync(logPath, "utf-8").trim().split("\n");
996
+ expect(lines).toHaveLength(2);
997
+ });
998
+
999
+ it("creates separate files for different dates", () => {
1000
+ const sessionDir = join(tempDir, "sess-3");
1001
+ const entry: HookInput = {
1002
+ session_id: "sess-3",
1003
+ transcript_path: "/tmp/t.jsonl",
1004
+ cwd: "/projects/my-app",
1005
+ hook_event_name: "Stop",
1006
+ };
1007
+
1008
+ appendRawLog(sessionDir, "2026-03-28", entry);
1009
+ appendRawLog(sessionDir, "2026-03-29", entry);
1010
+
1011
+ expect(existsSync(join(sessionDir, "2026-03-28.jsonl"))).toBe(true);
1012
+ expect(existsSync(join(sessionDir, "2026-03-29.jsonl"))).toBe(true);
1013
+ });
1014
+
1015
+ it("stores timestamp in each entry", () => {
1016
+ const sessionDir = join(tempDir, "sess-4");
1017
+ const entry: HookInput = {
1018
+ session_id: "sess-4",
1019
+ transcript_path: "/tmp/t.jsonl",
1020
+ cwd: "/projects/my-app",
1021
+ hook_event_name: "Stop",
1022
+ };
1023
+
1024
+ appendRawLog(sessionDir, "2026-03-28", entry);
1025
+
1026
+ const logPath = join(sessionDir, "2026-03-28.jsonl");
1027
+ const parsed = JSON.parse(readFileSync(logPath, "utf-8").trim());
1028
+ expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
1029
+ });
1030
+ });
1031
+ });
1032
+ ```
1033
+
1034
+ - [ ] **Step 2: Run tests to verify they fail**
1035
+
1036
+ Run: `npx vitest run tests/core/raw-logger.test.ts`
1037
+ Expected: FAIL — cannot resolve `../../src/core/raw-logger.js`
1038
+
1039
+ - [ ] **Step 3: Implement raw-logger module**
1040
+
1041
+ ```typescript
1042
+ // src/core/raw-logger.ts
1043
+ import { appendFileSync, mkdirSync } from "fs";
1044
+ import { join } from "path";
1045
+
1046
+ export interface HookInput {
1047
+ session_id: string;
1048
+ transcript_path: string;
1049
+ cwd: string;
1050
+ hook_event_name: string;
1051
+ [key: string]: unknown;
1052
+ }
1053
+
1054
+ export function parseHookInput(raw: string): HookInput {
1055
+ const parsed = JSON.parse(raw);
1056
+ if (!parsed || typeof parsed !== "object") {
1057
+ throw new Error("Invalid hook input: expected object");
1058
+ }
1059
+ if (typeof parsed.session_id !== "string" || !parsed.session_id) {
1060
+ throw new Error("Invalid hook input: missing session_id");
1061
+ }
1062
+ return parsed as HookInput;
1063
+ }
1064
+
1065
+ export function appendRawLog(sessionDir: string, date: string, entry: HookInput): void {
1066
+ mkdirSync(sessionDir, { recursive: true });
1067
+ const logPath = join(sessionDir, `${date}.jsonl`);
1068
+ const record = {
1069
+ ...entry,
1070
+ timestamp: new Date().toISOString(),
1071
+ };
1072
+ appendFileSync(logPath, JSON.stringify(record) + "\n", "utf-8");
1073
+ }
1074
+ ```
1075
+
1076
+ - [ ] **Step 4: Run tests to verify they pass**
1077
+
1078
+ Run: `npx vitest run tests/core/raw-logger.test.ts`
1079
+ Expected: All 7 tests PASS
1080
+
1081
+ - [ ] **Step 5: Commit**
1082
+
1083
+ ```bash
1084
+ git add src/core/raw-logger.ts tests/core/raw-logger.test.ts
1085
+ git commit -m "feat: add raw-logger module with stdin parsing and session-isolated append"
1086
+ ```
1087
+
1088
+ ---
1089
+
1090
+ ### Task 6: Merge Module (TDD)
1091
+
1092
+ **Files:**
1093
+ - Create: `tests/core/merge.test.ts`
1094
+ - Create: `src/core/merge.ts`
1095
+
1096
+ - [ ] **Step 1: Write failing tests for merge**
1097
+
1098
+ ```typescript
1099
+ // tests/core/merge.test.ts
1100
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
1101
+ import {
1102
+ mkdtempSync,
1103
+ rmSync,
1104
+ mkdirSync,
1105
+ writeFileSync,
1106
+ readFileSync,
1107
+ existsSync,
1108
+ } from "fs";
1109
+ import { join } from "path";
1110
+ import { tmpdir } from "os";
1111
+ import {
1112
+ findUnprocessedSessions,
1113
+ findPendingReviews,
1114
+ markSessionCompleted,
1115
+ isSessionCompleted,
1116
+ mergeReviewsIntoDaily,
1117
+ } from "../../src/core/merge.js";
1118
+
1119
+ describe("merge", () => {
1120
+ let tempDir: string;
1121
+ let rawDir: string;
1122
+ let reviewsDir: string;
1123
+ let dailyDir: string;
1124
+
1125
+ beforeEach(() => {
1126
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-merge-"));
1127
+ rawDir = join(tempDir, ".raw");
1128
+ reviewsDir = join(tempDir, ".reviews");
1129
+ dailyDir = join(tempDir, "daily");
1130
+ mkdirSync(rawDir, { recursive: true });
1131
+ mkdirSync(reviewsDir, { recursive: true });
1132
+ mkdirSync(dailyDir, { recursive: true });
1133
+ });
1134
+
1135
+ afterEach(() => {
1136
+ rmSync(tempDir, { recursive: true, force: true });
1137
+ });
1138
+
1139
+ describe("findUnprocessedSessions", () => {
1140
+ it("returns empty array when no sessions exist", () => {
1141
+ const result = findUnprocessedSessions(rawDir);
1142
+ expect(result).toEqual([]);
1143
+ });
1144
+
1145
+ it("returns session dirs without .completed marker", () => {
1146
+ mkdirSync(join(rawDir, "sess-1"));
1147
+ writeFileSync(join(rawDir, "sess-1", "2026-03-28.jsonl"), "{}");
1148
+
1149
+ mkdirSync(join(rawDir, "sess-2"));
1150
+ writeFileSync(join(rawDir, "sess-2", "2026-03-28.jsonl"), "{}");
1151
+ writeFileSync(join(rawDir, "sess-2", ".completed"), "");
1152
+
1153
+ const result = findUnprocessedSessions(rawDir);
1154
+ expect(result).toEqual(["sess-1"]);
1155
+ });
1156
+
1157
+ it("ignores non-directory entries", () => {
1158
+ writeFileSync(join(rawDir, "stray-file.txt"), "");
1159
+ const result = findUnprocessedSessions(rawDir);
1160
+ expect(result).toEqual([]);
1161
+ });
1162
+ });
1163
+
1164
+ describe("findPendingReviews", () => {
1165
+ it("returns empty array when no reviews exist", () => {
1166
+ const result = findPendingReviews(reviewsDir);
1167
+ expect(result).toEqual([]);
1168
+ });
1169
+
1170
+ it("returns .md files in reviews directory", () => {
1171
+ writeFileSync(join(reviewsDir, "sess-1.md"), "# Review");
1172
+ writeFileSync(join(reviewsDir, "sess-2.md"), "# Review 2");
1173
+ const result = findPendingReviews(reviewsDir);
1174
+ expect(result.sort()).toEqual(["sess-1.md", "sess-2.md"]);
1175
+ });
1176
+
1177
+ it("ignores non-md files", () => {
1178
+ writeFileSync(join(reviewsDir, "sess-1.md"), "# Review");
1179
+ writeFileSync(join(reviewsDir, "notes.txt"), "text");
1180
+ const result = findPendingReviews(reviewsDir);
1181
+ expect(result).toEqual(["sess-1.md"]);
1182
+ });
1183
+ });
1184
+
1185
+ describe("markSessionCompleted / isSessionCompleted", () => {
1186
+ it("creates .completed marker", () => {
1187
+ const sessDir = join(rawDir, "sess-1");
1188
+ mkdirSync(sessDir);
1189
+ expect(isSessionCompleted(sessDir)).toBe(false);
1190
+
1191
+ markSessionCompleted(sessDir);
1192
+ expect(isSessionCompleted(sessDir)).toBe(true);
1193
+ expect(existsSync(join(sessDir, ".completed"))).toBe(true);
1194
+ });
1195
+ });
1196
+
1197
+ describe("mergeReviewsIntoDaily", () => {
1198
+ it("creates daily file from single review", () => {
1199
+ const review = "## [my-app] Auth work\n**작업 요약:** JWT 구현\n";
1200
+ writeFileSync(join(reviewsDir, "sess-1.md"), review);
1201
+
1202
+ const dailyPath = join(dailyDir, "2026-03-28.md");
1203
+ mergeReviewsIntoDaily([join(reviewsDir, "sess-1.md")], dailyPath);
1204
+
1205
+ const content = readFileSync(dailyPath, "utf-8");
1206
+ expect(content).toContain("[my-app] Auth work");
1207
+ });
1208
+
1209
+ it("appends to existing daily file", () => {
1210
+ const existing = "# 2026-03-28 Daily Review\n\n## [blog] SEO work\nDone.\n";
1211
+ const dailyPath = join(dailyDir, "2026-03-28.md");
1212
+ writeFileSync(dailyPath, existing);
1213
+
1214
+ const newReview = "\n## [my-app] Auth work\n**작업 요약:** JWT 구현\n";
1215
+ writeFileSync(join(reviewsDir, "sess-2.md"), newReview);
1216
+
1217
+ mergeReviewsIntoDaily([join(reviewsDir, "sess-2.md")], dailyPath);
1218
+
1219
+ const content = readFileSync(dailyPath, "utf-8");
1220
+ expect(content).toContain("[blog] SEO work");
1221
+ expect(content).toContain("[my-app] Auth work");
1222
+ });
1223
+
1224
+ it("merges multiple reviews", () => {
1225
+ writeFileSync(join(reviewsDir, "sess-1.md"), "## Session 1 content\n");
1226
+ writeFileSync(join(reviewsDir, "sess-2.md"), "## Session 2 content\n");
1227
+
1228
+ const dailyPath = join(dailyDir, "2026-03-28.md");
1229
+ mergeReviewsIntoDaily(
1230
+ [join(reviewsDir, "sess-1.md"), join(reviewsDir, "sess-2.md")],
1231
+ dailyPath,
1232
+ );
1233
+
1234
+ const content = readFileSync(dailyPath, "utf-8");
1235
+ expect(content).toContain("Session 1 content");
1236
+ expect(content).toContain("Session 2 content");
1237
+ });
1238
+
1239
+ it("handles empty review files gracefully", () => {
1240
+ writeFileSync(join(reviewsDir, "sess-empty.md"), "");
1241
+ const dailyPath = join(dailyDir, "2026-03-28.md");
1242
+
1243
+ expect(() =>
1244
+ mergeReviewsIntoDaily([join(reviewsDir, "sess-empty.md")], dailyPath),
1245
+ ).not.toThrow();
1246
+ });
1247
+ });
1248
+ });
1249
+ ```
1250
+
1251
+ - [ ] **Step 2: Run tests to verify they fail**
1252
+
1253
+ Run: `npx vitest run tests/core/merge.test.ts`
1254
+ Expected: FAIL — cannot resolve `../../src/core/merge.js`
1255
+
1256
+ - [ ] **Step 3: Implement merge module**
1257
+
1258
+ ```typescript
1259
+ // src/core/merge.ts
1260
+ import {
1261
+ readdirSync,
1262
+ readFileSync,
1263
+ writeFileSync,
1264
+ existsSync,
1265
+ statSync,
1266
+ } from "fs";
1267
+ import { join } from "path";
1268
+
1269
+ export function findUnprocessedSessions(rawDir: string): string[] {
1270
+ if (!existsSync(rawDir)) return [];
1271
+ return readdirSync(rawDir).filter((entry) => {
1272
+ const entryPath = join(rawDir, entry);
1273
+ if (!statSync(entryPath).isDirectory()) return false;
1274
+ return !existsSync(join(entryPath, ".completed"));
1275
+ });
1276
+ }
1277
+
1278
+ export function findPendingReviews(reviewsDir: string): string[] {
1279
+ if (!existsSync(reviewsDir)) return [];
1280
+ return readdirSync(reviewsDir).filter((f) => f.endsWith(".md"));
1281
+ }
1282
+
1283
+ export function markSessionCompleted(sessionDir: string): void {
1284
+ writeFileSync(join(sessionDir, ".completed"), new Date().toISOString(), "utf-8");
1285
+ }
1286
+
1287
+ export function isSessionCompleted(sessionDir: string): boolean {
1288
+ return existsSync(join(sessionDir, ".completed"));
1289
+ }
1290
+
1291
+ export function mergeReviewsIntoDaily(reviewPaths: string[], dailyPath: string): void {
1292
+ const reviewContents = reviewPaths
1293
+ .map((p) => readFileSync(p, "utf-8").trim())
1294
+ .filter((c) => c.length > 0);
1295
+
1296
+ if (reviewContents.length === 0) {
1297
+ if (!existsSync(dailyPath)) {
1298
+ writeFileSync(dailyPath, "", "utf-8");
1299
+ }
1300
+ return;
1301
+ }
1302
+
1303
+ let existing = "";
1304
+ if (existsSync(dailyPath)) {
1305
+ existing = readFileSync(dailyPath, "utf-8");
1306
+ }
1307
+
1308
+ const merged = existing
1309
+ ? existing.trimEnd() + "\n\n" + reviewContents.join("\n\n") + "\n"
1310
+ : reviewContents.join("\n\n") + "\n";
1311
+
1312
+ writeFileSync(dailyPath, merged, "utf-8");
1313
+ }
1314
+ ```
1315
+
1316
+ - [ ] **Step 4: Run tests to verify they pass**
1317
+
1318
+ Run: `npx vitest run tests/core/merge.test.ts`
1319
+ Expected: All 10 tests PASS
1320
+
1321
+ - [ ] **Step 5: Commit**
1322
+
1323
+ ```bash
1324
+ git add src/core/merge.ts tests/core/merge.test.ts
1325
+ git commit -m "feat: add merge module with session recovery and daily file merging"
1326
+ ```
1327
+
1328
+ ---
1329
+
1330
+ ### Task 7: On-Stop Hook Entry Point (TDD)
1331
+
1332
+ **Files:**
1333
+ - Create: `tests/hooks/on-stop.test.ts`
1334
+ - Create: `src/hooks/on-stop.ts`
1335
+
1336
+ - [ ] **Step 1: Write failing tests for on-stop hook**
1337
+
1338
+ ```typescript
1339
+ // tests/hooks/on-stop.test.ts
1340
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
1341
+ import { mkdtempSync, rmSync, readFileSync, existsSync, writeFileSync } from "fs";
1342
+ import { join } from "path";
1343
+ import { tmpdir } from "os";
1344
+ import { handleStopHook } from "../../src/hooks/on-stop.js";
1345
+
1346
+ describe("on-stop hook", () => {
1347
+ let tempVault: string;
1348
+ let tempPluginData: string;
1349
+ const originalEnv = { ...process.env };
1350
+
1351
+ beforeEach(() => {
1352
+ tempVault = mkdtempSync(join(tmpdir(), "cdr-stop-vault-"));
1353
+ tempPluginData = mkdtempSync(join(tmpdir(), "cdr-stop-data-"));
1354
+ process.env.CLAUDE_PLUGIN_DATA = tempPluginData;
1355
+
1356
+ const config = {
1357
+ vaultPath: tempVault,
1358
+ reviewFolder: "daily-review",
1359
+ language: "ko",
1360
+ periods: { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false },
1361
+ profile: { company: "", role: "", team: "", context: "" },
1362
+ };
1363
+ writeFileSync(join(tempPluginData, "config.json"), JSON.stringify(config));
1364
+ });
1365
+
1366
+ afterEach(() => {
1367
+ rmSync(tempVault, { recursive: true, force: true });
1368
+ rmSync(tempPluginData, { recursive: true, force: true });
1369
+ process.env = { ...originalEnv };
1370
+ });
1371
+
1372
+ it("appends raw log for valid input", () => {
1373
+ const input = JSON.stringify({
1374
+ session_id: "test-sess",
1375
+ transcript_path: "/tmp/t.jsonl",
1376
+ cwd: "/projects/my-app",
1377
+ hook_event_name: "Stop",
1378
+ });
1379
+
1380
+ handleStopHook(input);
1381
+
1382
+ const rawDir = join(tempVault, "daily-review", ".raw", "test-sess");
1383
+ expect(existsSync(rawDir)).toBe(true);
1384
+
1385
+ const files = require("fs").readdirSync(rawDir);
1386
+ expect(files.some((f: string) => f.endsWith(".jsonl"))).toBe(true);
1387
+ });
1388
+
1389
+ it("exits silently when config is missing", () => {
1390
+ rmSync(join(tempPluginData, "config.json"));
1391
+ const input = JSON.stringify({
1392
+ session_id: "test-sess",
1393
+ transcript_path: "/tmp/t.jsonl",
1394
+ cwd: "/projects/my-app",
1395
+ hook_event_name: "Stop",
1396
+ });
1397
+
1398
+ expect(() => handleStopHook(input)).not.toThrow();
1399
+ });
1400
+
1401
+ it("exits silently on invalid JSON input", () => {
1402
+ expect(() => handleStopHook("not-json")).not.toThrow();
1403
+ });
1404
+ });
1405
+ ```
1406
+
1407
+ - [ ] **Step 2: Run tests to verify they fail**
1408
+
1409
+ Run: `npx vitest run tests/hooks/on-stop.test.ts`
1410
+ Expected: FAIL — cannot resolve `../../src/hooks/on-stop.js`
1411
+
1412
+ - [ ] **Step 3: Implement on-stop hook**
1413
+
1414
+ ```typescript
1415
+ // src/hooks/on-stop.ts
1416
+ import { loadConfig } from "../core/config.js";
1417
+ import { parseHookInput, appendRawLog } from "../core/raw-logger.js";
1418
+ import { getRawDir } from "../core/vault.js";
1419
+ import { formatDate } from "../core/periods.js";
1420
+
1421
+ export function handleStopHook(stdinData: string): void {
1422
+ try {
1423
+ const config = loadConfig();
1424
+ if (!config) return;
1425
+
1426
+ const input = parseHookInput(stdinData);
1427
+ const sessionDir = getRawDir(config, input.session_id);
1428
+ const date = formatDate(new Date());
1429
+
1430
+ appendRawLog(sessionDir, date, input);
1431
+ } catch {
1432
+ // async hook — fail silently, data will be recovered from transcript
1433
+ }
1434
+ }
1435
+
1436
+ // Main execution: read stdin and run
1437
+ if (process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/\\/g, "/"))) {
1438
+ let data = "";
1439
+ process.stdin.setEncoding("utf-8");
1440
+ process.stdin.on("data", (chunk) => (data += chunk));
1441
+ process.stdin.on("end", () => {
1442
+ handleStopHook(data);
1443
+ });
1444
+ }
1445
+ ```
1446
+
1447
+ - [ ] **Step 4: Run tests to verify they pass**
1448
+
1449
+ Run: `npx vitest run tests/hooks/on-stop.test.ts`
1450
+ Expected: All 3 tests PASS
1451
+
1452
+ - [ ] **Step 5: Run all tests**
1453
+
1454
+ Run: `npx vitest run`
1455
+ Expected: All tests across all files PASS
1456
+
1457
+ - [ ] **Step 6: Commit**
1458
+
1459
+ ```bash
1460
+ git add src/hooks/on-stop.ts tests/hooks/on-stop.test.ts
1461
+ git commit -m "feat: add on-stop hook entry point with silent failure handling"
1462
+ ```
1463
+
1464
+ ---
1465
+
1466
+ ### Task 8: Agent Prompts
1467
+
1468
+ **Files:**
1469
+ - Create: `prompts/session-end.md`
1470
+ - Create: `prompts/session-start.md`
1471
+
1472
+ - [ ] **Step 1: Write SessionEnd agent prompt**
1473
+
1474
+ ```markdown
1475
+ <!-- prompts/session-end.md -->
1476
+ # Daily Review — SessionEnd Agent
1477
+
1478
+ You are a daily review generator for the claude-daily-review plugin.
1479
+ Your job is to read the conversation transcript and generate a structured review markdown file.
1480
+
1481
+ ## Context
1482
+
1483
+ The user's profile and config are stored at the path provided in the CLAUDE_PLUGIN_DATA environment variable, in `config.json`. Read it first to understand the user's context.
1484
+
1485
+ ## Instructions
1486
+
1487
+ 1. Read `${CLAUDE_PLUGIN_DATA}/config.json` to get:
1488
+ - `vaultPath` and `reviewFolder` — where to write files
1489
+ - `profile` — user's company, role, team, context (use this to add business context to summaries)
1490
+ - `language` — write the review in this language
1491
+
1492
+ 2. Read the session's hook input from stdin (passed as JSON). Extract `session_id`, `transcript_path`, and `cwd`.
1493
+
1494
+ 3. Read the transcript file at `transcript_path`. This is a JSONL file with the full conversation.
1495
+
1496
+ 4. Analyze the conversation and classify interactions into:
1497
+ - **Project work**: Based on `cwd`, determine the project name (last segment of the path). Group related Q&A under this project.
1498
+ - **Uncategorized**: General questions not tied to a specific project (e.g., "What is Rust ownership?")
1499
+
1500
+ 5. For each project group, generate:
1501
+ - **작업 요약**: What was done (use profile context for business framing)
1502
+ - **배운 것**: New knowledge gained
1503
+ - **고민한 포인트**: Decisions made and reasoning
1504
+ - **질문과 답변**: Key Q&A highlights (not every single message)
1505
+
1506
+ 6. Write the review to: `{vaultPath}/{reviewFolder}/.reviews/{session_id}.md`
1507
+ - Do NOT write to daily/ directly — that happens at merge time.
1508
+ - Include frontmatter with date, type, projects, and tags.
1509
+
1510
+ 7. Update the project summary if applicable:
1511
+ - Read existing `{vaultPath}/{reviewFolder}/projects/{project-name}/summary.md`
1512
+ - Append new learnings, decisions, and tech stack entries
1513
+ - Write updated summary back
1514
+
1515
+ 8. Mark the raw log session as completed:
1516
+ - Write an empty `.completed` file to `{vaultPath}/{reviewFolder}/.raw/{session_id}/`
1517
+
1518
+ ## Output Format
1519
+
1520
+ The .reviews/{session_id}.md file should follow this structure:
1521
+
1522
+ ```
1523
+ ## [{project-name}] {brief title}
1524
+ **작업 요약:** {summary with business context from profile}
1525
+ **배운 것:**
1526
+ - {learning 1}
1527
+ - {learning 2}
1528
+ **고민한 포인트:**
1529
+ - {decision}: {choice} ({reasoning})
1530
+ **질문과 답변:**
1531
+ - Q: {question}
1532
+ → A: {concise answer}
1533
+
1534
+ ## 미분류
1535
+ **질문과 답변:**
1536
+ - Q: {question}
1537
+ → A: {concise answer}
1538
+ ```
1539
+
1540
+ ## Important
1541
+
1542
+ - Use the language specified in config (default: Korean)
1543
+ - Keep summaries concise but meaningful — this is for career documentation
1544
+ - Include Obsidian tags at the bottom: #project-name #technology #concept
1545
+ - When profile.context exists, frame work summaries in that business context
1546
+ - Do NOT include raw code blocks or full conversations — extract the insights
1547
+ ```
1548
+
1549
+ - [ ] **Step 2: Write SessionStart agent prompt**
1550
+
1551
+ ```markdown
1552
+ <!-- prompts/session-start.md -->
1553
+ # Daily Review — SessionStart Agent
1554
+
1555
+ You are a daily review assistant for the claude-daily-review plugin.
1556
+ Your job is to check configuration, recover unprocessed sessions, merge pending reviews, and generate periodic summaries.
1557
+
1558
+ ## Instructions
1559
+
1560
+ ### Step 1: Check Configuration
1561
+
1562
+ Read `${CLAUDE_PLUGIN_DATA}/config.json`.
1563
+
1564
+ If the file does not exist, output to stderr:
1565
+ ```
1566
+ daily-review: Vault 경로가 설정되지 않았습니다. /daily-review-setup 을 실행해주세요.
1567
+ ```
1568
+ Then exit with code 2 to inform the user.
1569
+
1570
+ If the file exists, proceed.
1571
+
1572
+ ### Step 2: Recover Unprocessed Sessions
1573
+
1574
+ Scan `{vaultPath}/{reviewFolder}/.raw/` for session directories that do NOT contain a `.completed` file.
1575
+
1576
+ For each unprocessed session:
1577
+ 1. Read all `.jsonl` files in the session directory
1578
+ 2. Read the transcript at the `transcript_path` from the log entries (if accessible)
1579
+ 3. Generate a review following the same format as the SessionEnd agent
1580
+ 4. Write to `{vaultPath}/{reviewFolder}/.reviews/{session_id}.md`
1581
+ 5. Mark `.completed`
1582
+
1583
+ If the transcript is not accessible (deleted, moved), generate a minimal review from the raw log data only.
1584
+
1585
+ ### Step 3: Merge Pending Reviews
1586
+
1587
+ Scan `{vaultPath}/{reviewFolder}/.reviews/` for `.md` files.
1588
+
1589
+ For each review file:
1590
+ 1. Determine the date from the review content or file metadata
1591
+ 2. Read the existing daily file at `{vaultPath}/{reviewFolder}/daily/{date}.md` (if any)
1592
+ 3. Append the review content to the daily file
1593
+ 4. Delete the review file from `.reviews/` after successful merge
1594
+
1595
+ Use a lockfile for the daily file to prevent concurrent writes:
1596
+ - Lock: `{dailyPath}.lock` with stale timeout of 30 seconds
1597
+ - If lock acquisition fails, skip this merge (will retry next SessionStart)
1598
+
1599
+ ### Step 4: Generate Periodic Summaries
1600
+
1601
+ Read `${CLAUDE_PLUGIN_DATA}/last-run.json` to get the last run date.
1602
+ Compare with today to determine which summaries are needed.
1603
+
1604
+ Check `config.periods` to see which periods are enabled.
1605
+
1606
+ Generate summaries in cascading order (each uses the previous level as input):
1607
+
1608
+ #### Weekly (if new week started and periods.weekly is true)
1609
+ - Read all daily files from the previous week
1610
+ - Generate `{vaultPath}/{reviewFolder}/weekly/{YYYY-Www}.md`
1611
+
1612
+ #### Monthly (if new month started and periods.monthly is true)
1613
+ - Read all weekly files from the previous month
1614
+ - If weekly is disabled, read daily files from the previous month instead
1615
+ - Generate `{vaultPath}/{reviewFolder}/monthly/{YYYY-MM}.md`
1616
+
1617
+ #### Quarterly (if new quarter started and periods.quarterly is true)
1618
+ - Read all monthly files from the previous quarter
1619
+ - If monthly is disabled, read the next available lower-level files
1620
+ - Generate `{vaultPath}/{reviewFolder}/quarterly/{YYYY-Qn}.md`
1621
+
1622
+ #### Yearly (if new year started and periods.yearly is true)
1623
+ - Read all quarterly files from the previous year
1624
+ - If quarterly is disabled, read the next available lower-level files
1625
+ - Generate `{vaultPath}/{reviewFolder}/yearly/{YYYY}.md`
1626
+
1627
+ After all summaries are generated, save today's date to `${CLAUDE_PLUGIN_DATA}/last-run.json`:
1628
+ ```json
1629
+ { "lastRun": "2026-03-28" }
1630
+ ```
1631
+
1632
+ ### Summary Template Guidelines
1633
+
1634
+ - Use the user's `profile` to frame accomplishments in business context
1635
+ - Include frontmatter with date, type, period, and projects
1636
+ - Follow the markdown templates defined in the spec (see design doc section 6)
1637
+ - Use the configured language
1638
+
1639
+ ## Important
1640
+
1641
+ - If any step fails, continue to the next step — partial recovery is better than none
1642
+ - Never delete `.raw/` data — only add `.completed` markers
1643
+ - Only delete `.reviews/` files after confirmed successful merge
1644
+ - Periodic summaries should be concise — the point is progressive compression
1645
+ ```
1646
+
1647
+ - [ ] **Step 3: Commit**
1648
+
1649
+ ```bash
1650
+ git add prompts/session-end.md prompts/session-start.md
1651
+ git commit -m "feat: add agent prompts for SessionEnd and SessionStart hooks"
1652
+ ```
1653
+
1654
+ ---
1655
+
1656
+ ### Task 9: Setup Skill
1657
+
1658
+ **Files:**
1659
+ - Create: `skills/daily-review-setup.md`
1660
+
1661
+ - [ ] **Step 1: Write setup skill**
1662
+
1663
+ ```markdown
1664
+ <!-- skills/daily-review-setup.md -->
1665
+ ---
1666
+ name: daily-review-setup
1667
+ description: Configure the daily review plugin — set Obsidian vault path, user profile, and review periods
1668
+ ---
1669
+
1670
+ # Daily Review Setup
1671
+
1672
+ You are setting up the claude-daily-review plugin for the user.
1673
+
1674
+ ## Check Existing Config
1675
+
1676
+ First, read `${CLAUDE_PLUGIN_DATA}/config.json` to see if a config already exists.
1677
+
1678
+ - If it exists, show the current settings and ask what the user wants to change.
1679
+ - If it does not exist, proceed with the full onboarding flow below.
1680
+
1681
+ ## Onboarding Flow
1682
+
1683
+ ### Step 1: Vault Path
1684
+
1685
+ Ask the user:
1686
+ > "Obsidian vault 경로를 알려주세요. (예: C:/Users/name/Documents/MyVault)"
1687
+
1688
+ After they provide a path:
1689
+ - Verify the directory exists using the Bash tool: `test -d "{path}" && echo "OK" || echo "NOT_FOUND"`
1690
+ - If not found, ask them to check the path
1691
+ - Normalize the path (resolve ~, remove trailing slashes)
1692
+
1693
+ ### Step 2: Profile
1694
+
1695
+ Ask the user these questions one at a time:
1696
+ 1. "어떤 회사에서 일하고 계신가요? (선택사항, 엔터로 건너뛰기)"
1697
+ 2. "역할/직무가 뭔가요? (예: 프론트엔드 개발자)"
1698
+ 3. "팀이나 담당 도메인이 있다면? (예: 결제플랫폼팀)"
1699
+ 4. "하고 계신 일을 한 줄로 설명하면? (예: B2B SaaS 결제 시스템 개발 및 운영)"
1700
+
1701
+ ### Step 3: Periods
1702
+
1703
+ Show the available periods and defaults:
1704
+ > "어떤 주기로 회고를 요약할까요? (기본값으로 진행하려면 엔터)"
1705
+ > - [x] daily (항상 활성화)
1706
+ > - [x] weekly (주간)
1707
+ > - [x] monthly (월간)
1708
+ > - [x] quarterly (분기)
1709
+ > - [ ] yearly (연간)
1710
+
1711
+ ### Step 4: Save
1712
+
1713
+ Construct the config object and write it to `${CLAUDE_PLUGIN_DATA}/config.json`:
1714
+
1715
+ ```json
1716
+ {
1717
+ "vaultPath": "{user input}",
1718
+ "reviewFolder": "daily-review",
1719
+ "language": "ko",
1720
+ "periods": {
1721
+ "daily": true,
1722
+ "weekly": {user choice},
1723
+ "monthly": {user choice},
1724
+ "quarterly": {user choice},
1725
+ "yearly": {user choice}
1726
+ },
1727
+ "profile": {
1728
+ "company": "{user input}",
1729
+ "role": "{user input}",
1730
+ "team": "{user input}",
1731
+ "context": "{user input}"
1732
+ }
1733
+ }
1734
+ ```
1735
+
1736
+ Then create the vault directories by running:
1737
+ ```bash
1738
+ node -e "
1739
+ const fs = require('fs');
1740
+ const path = require('path');
1741
+ const config = JSON.parse(fs.readFileSync('${CLAUDE_PLUGIN_DATA}/config.json', 'utf-8'));
1742
+ const base = path.join(config.vaultPath, config.reviewFolder);
1743
+ const dirs = ['daily', 'projects', 'uncategorized', '.raw', '.reviews'];
1744
+ if (config.periods.weekly) dirs.push('weekly');
1745
+ if (config.periods.monthly) dirs.push('monthly');
1746
+ if (config.periods.quarterly) dirs.push('quarterly');
1747
+ if (config.periods.yearly) dirs.push('yearly');
1748
+ dirs.forEach(d => fs.mkdirSync(path.join(base, d), { recursive: true }));
1749
+ console.log('Directories created at: ' + base);
1750
+ "
1751
+ ```
1752
+
1753
+ ### Step 5: Confirm
1754
+
1755
+ Tell the user:
1756
+ > "설정 완료! 이제부터 대화 내용이 자동으로 기록됩니다."
1757
+ > "회고 파일은 `{vaultPath}/{reviewFolder}/` 에서 확인하세요."
1758
+ > "설정을 변경하려면 `/daily-review-setup`을 다시 실행하세요."
1759
+ ```
1760
+
1761
+ - [ ] **Step 2: Commit**
1762
+
1763
+ ```bash
1764
+ git add skills/daily-review-setup.md
1765
+ git commit -m "feat: add setup skill for onboarding with vault path, profile, and periods"
1766
+ ```
1767
+
1768
+ ---
1769
+
1770
+ ### Task 10: Hooks Configuration + Build
1771
+
1772
+ **Files:**
1773
+ - Create: `hooks/hooks.json`
1774
+
1775
+ - [ ] **Step 1: Write hooks.json**
1776
+
1777
+ ```json
1778
+ {
1779
+ "hooks": {
1780
+ "Stop": [
1781
+ {
1782
+ "hooks": [
1783
+ {
1784
+ "type": "command",
1785
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/hooks/on-stop.js\"",
1786
+ "async": true,
1787
+ "timeout": 10
1788
+ }
1789
+ ]
1790
+ }
1791
+ ],
1792
+ "SessionEnd": [
1793
+ {
1794
+ "hooks": [
1795
+ {
1796
+ "type": "agent",
1797
+ "prompt": "Follow the instructions in the file at ${CLAUDE_PLUGIN_ROOT}/prompts/session-end.md exactly. The CLAUDE_PLUGIN_DATA directory is: ${CLAUDE_PLUGIN_DATA}",
1798
+ "timeout": 120
1799
+ }
1800
+ ]
1801
+ }
1802
+ ],
1803
+ "SessionStart": [
1804
+ {
1805
+ "hooks": [
1806
+ {
1807
+ "type": "agent",
1808
+ "prompt": "Follow the instructions in the file at ${CLAUDE_PLUGIN_ROOT}/prompts/session-start.md exactly. The CLAUDE_PLUGIN_DATA directory is: ${CLAUDE_PLUGIN_DATA}",
1809
+ "timeout": 180
1810
+ }
1811
+ ]
1812
+ }
1813
+ ]
1814
+ }
1815
+ }
1816
+ ```
1817
+
1818
+ - [ ] **Step 2: Build the project**
1819
+
1820
+ Run: `npm run build`
1821
+ Expected: `dist/hooks/on-stop.js` created
1822
+
1823
+ - [ ] **Step 3: Verify build output exists**
1824
+
1825
+ Run: `ls dist/hooks/on-stop.js`
1826
+ Expected: File listed
1827
+
1828
+ - [ ] **Step 4: Commit**
1829
+
1830
+ ```bash
1831
+ git add hooks/hooks.json
1832
+ git commit -m "feat: add hooks.json with Stop, SessionEnd, and SessionStart hooks"
1833
+ ```
1834
+
1835
+ ---
1836
+
1837
+ ### Task 11: Integration Test
1838
+
1839
+ **Files:**
1840
+ - Create: `tests/integration/full-flow.test.ts`
1841
+
1842
+ - [ ] **Step 1: Write integration test for the full Stop → merge flow**
1843
+
1844
+ ```typescript
1845
+ // tests/integration/full-flow.test.ts
1846
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
1847
+ import {
1848
+ mkdtempSync,
1849
+ rmSync,
1850
+ writeFileSync,
1851
+ readFileSync,
1852
+ existsSync,
1853
+ mkdirSync,
1854
+ } from "fs";
1855
+ import { join } from "path";
1856
+ import { tmpdir } from "os";
1857
+ import { handleStopHook } from "../../src/hooks/on-stop.js";
1858
+ import { findUnprocessedSessions, mergeReviewsIntoDaily, markSessionCompleted } from "../../src/core/merge.js";
1859
+ import { loadConfig, saveConfig, createDefaultConfig } from "../../src/core/config.js";
1860
+ import { ensureVaultDirectories } from "../../src/core/vault.js";
1861
+ import { checkPeriodsNeeded, formatDate } from "../../src/core/periods.js";
1862
+
1863
+ describe("integration: full flow", () => {
1864
+ let tempVault: string;
1865
+ let tempPluginData: string;
1866
+ const originalEnv = { ...process.env };
1867
+
1868
+ beforeEach(() => {
1869
+ tempVault = mkdtempSync(join(tmpdir(), "cdr-int-vault-"));
1870
+ tempPluginData = mkdtempSync(join(tmpdir(), "cdr-int-data-"));
1871
+ process.env.CLAUDE_PLUGIN_DATA = tempPluginData;
1872
+
1873
+ const config = createDefaultConfig(tempVault);
1874
+ saveConfig(config);
1875
+ ensureVaultDirectories(config);
1876
+ });
1877
+
1878
+ afterEach(() => {
1879
+ rmSync(tempVault, { recursive: true, force: true });
1880
+ rmSync(tempPluginData, { recursive: true, force: true });
1881
+ process.env = { ...originalEnv };
1882
+ });
1883
+
1884
+ it("Stop hook creates raw log, then merge recovers it", () => {
1885
+ // 1. Simulate Stop hook
1886
+ const input = JSON.stringify({
1887
+ session_id: "int-sess-1",
1888
+ transcript_path: "/tmp/transcript.jsonl",
1889
+ cwd: "/projects/my-app",
1890
+ hook_event_name: "Stop",
1891
+ });
1892
+ handleStopHook(input);
1893
+
1894
+ // 2. Verify raw log exists
1895
+ const rawDir = join(tempVault, "daily-review", ".raw", "int-sess-1");
1896
+ expect(existsSync(rawDir)).toBe(true);
1897
+
1898
+ // 3. Check it's unprocessed
1899
+ const unprocessed = findUnprocessedSessions(
1900
+ join(tempVault, "daily-review", ".raw"),
1901
+ );
1902
+ expect(unprocessed).toContain("int-sess-1");
1903
+
1904
+ // 4. Simulate SessionEnd: write review to .reviews/
1905
+ const reviewsDir = join(tempVault, "daily-review", ".reviews");
1906
+ writeFileSync(
1907
+ join(reviewsDir, "int-sess-1.md"),
1908
+ "## [my-app] Auth work\n**작업 요약:** JWT 구현\n",
1909
+ );
1910
+ markSessionCompleted(rawDir);
1911
+
1912
+ // 5. Simulate SessionStart: merge reviews into daily
1913
+ const today = formatDate(new Date());
1914
+ const dailyPath = join(tempVault, "daily-review", "daily", `${today}.md`);
1915
+ mergeReviewsIntoDaily([join(reviewsDir, "int-sess-1.md")], dailyPath);
1916
+
1917
+ // 6. Verify daily file
1918
+ expect(existsSync(dailyPath)).toBe(true);
1919
+ const content = readFileSync(dailyPath, "utf-8");
1920
+ expect(content).toContain("[my-app] Auth work");
1921
+ expect(content).toContain("JWT 구현");
1922
+ });
1923
+
1924
+ it("multiple sessions merge into same daily file", () => {
1925
+ // Session A
1926
+ handleStopHook(
1927
+ JSON.stringify({
1928
+ session_id: "sess-a",
1929
+ transcript_path: "/tmp/a.jsonl",
1930
+ cwd: "/projects/app-a",
1931
+ hook_event_name: "Stop",
1932
+ }),
1933
+ );
1934
+
1935
+ // Session B
1936
+ handleStopHook(
1937
+ JSON.stringify({
1938
+ session_id: "sess-b",
1939
+ transcript_path: "/tmp/b.jsonl",
1940
+ cwd: "/projects/app-b",
1941
+ hook_event_name: "Stop",
1942
+ }),
1943
+ );
1944
+
1945
+ // Both sessions write reviews
1946
+ const reviewsDir = join(tempVault, "daily-review", ".reviews");
1947
+ writeFileSync(join(reviewsDir, "sess-a.md"), "## [app-a] Feature A\n");
1948
+ writeFileSync(join(reviewsDir, "sess-b.md"), "## [app-b] Feature B\n");
1949
+
1950
+ // Merge
1951
+ const today = formatDate(new Date());
1952
+ const dailyPath = join(tempVault, "daily-review", "daily", `${today}.md`);
1953
+ mergeReviewsIntoDaily(
1954
+ [join(reviewsDir, "sess-a.md"), join(reviewsDir, "sess-b.md")],
1955
+ dailyPath,
1956
+ );
1957
+
1958
+ const content = readFileSync(dailyPath, "utf-8");
1959
+ expect(content).toContain("[app-a] Feature A");
1960
+ expect(content).toContain("[app-b] Feature B");
1961
+ });
1962
+
1963
+ it("period detection works correctly for summary triggers", () => {
1964
+ const monday = new Date(2026, 2, 30); // Monday W14
1965
+ const prevSaturday = new Date(2026, 2, 28); // Saturday W13
1966
+ const periods = checkPeriodsNeeded(monday, prevSaturday);
1967
+ expect(periods.needsWeekly).toBe(true);
1968
+ expect(periods.previousWeek).toBe("2026-W13");
1969
+ });
1970
+ });
1971
+ ```
1972
+
1973
+ - [ ] **Step 2: Run all tests**
1974
+
1975
+ Run: `npx vitest run`
1976
+ Expected: All tests PASS (config: 8, periods: 17, vault: 13, raw-logger: 7, merge: 10, on-stop: 3, integration: 3 = ~61 tests)
1977
+
1978
+ - [ ] **Step 3: Commit**
1979
+
1980
+ ```bash
1981
+ git add tests/integration/full-flow.test.ts
1982
+ git commit -m "test: add integration tests for full Stop → merge flow"
1983
+ ```
1984
+
1985
+ ---
1986
+
1987
+ ### Task 12: README and Package Finalization
1988
+
1989
+ **Files:**
1990
+ - Create: `README.md`
1991
+ - Modify: `package.json` (add files field)
1992
+
1993
+ - [ ] **Step 1: Write README.md**
1994
+
1995
+ ```markdown
1996
+ # claude-daily-review
1997
+
1998
+ Claude Code plugin that automatically captures your conversations and generates structured daily review markdown files in your Obsidian vault.
1999
+
2000
+ Turn your daily AI-assisted development work into career documentation — automatically.
2001
+
2002
+ ## Features
2003
+
2004
+ - **Auto-capture**: Hook-based conversation logging with zero manual effort
2005
+ - **Structured reviews**: Work summaries, learnings, decisions, and Q&A organized by project
2006
+ - **Cascading summaries**: Daily → Weekly → Monthly → Quarterly → Yearly
2007
+ - **Project tracking**: Per-project summaries for resume/portfolio building
2008
+ - **Obsidian integration**: Direct markdown output with tags and links
2009
+ - **Concurrency-safe**: Session-isolated writes with deferred merge
2010
+ - **Crash recovery**: Raw logs preserved even on force-quit
2011
+
2012
+ ## Installation
2013
+
2014
+ ```bash
2015
+ claude plugin add claude-daily-review
2016
+ ```
2017
+
2018
+ ## Setup
2019
+
2020
+ On first run, you'll be prompted to configure the plugin. Or run manually:
2021
+
2022
+ ```
2023
+ /daily-review-setup
2024
+ ```
2025
+
2026
+ This will ask for:
2027
+ 1. Your Obsidian vault path
2028
+ 2. A brief professional profile (company, role, team)
2029
+ 3. Which summary periods to enable
2030
+
2031
+ ## How It Works
2032
+
2033
+ ```
2034
+ Every response → Raw log saved (async, non-blocking)
2035
+ Session end → AI generates structured review
2036
+ Next session → Reviews merged into daily file + periodic summaries generated
2037
+ ```
2038
+
2039
+ ## Vault Structure
2040
+
2041
+ ```
2042
+ vault/daily-review/
2043
+ ├── daily/2026-03-28.md ← Daily review (all projects)
2044
+ ├── weekly/2026-W13.md ← Weekly summary
2045
+ ├── monthly/2026-03.md ← Monthly summary
2046
+ ├── quarterly/2026-Q1.md ← Quarterly summary
2047
+ ├── yearly/2026.md ← Yearly summary
2048
+ ├── projects/my-app/
2049
+ │ ├── 2026-03-28.md ← Project daily detail
2050
+ │ └── summary.md ← Cumulative project summary
2051
+ └── uncategorized/2026-03-28.md ← Non-project questions
2052
+ ```
2053
+
2054
+ ## Configuration
2055
+
2056
+ Config is stored at `$CLAUDE_PLUGIN_DATA/config.json`:
2057
+
2058
+ ```json
2059
+ {
2060
+ "vaultPath": "/path/to/obsidian/vault",
2061
+ "reviewFolder": "daily-review",
2062
+ "language": "ko",
2063
+ "periods": {
2064
+ "daily": true,
2065
+ "weekly": true,
2066
+ "monthly": true,
2067
+ "quarterly": true,
2068
+ "yearly": false
2069
+ },
2070
+ "profile": {
2071
+ "company": "Your Company",
2072
+ "role": "Your Role",
2073
+ "team": "Your Team",
2074
+ "context": "What you do in one line"
2075
+ }
2076
+ }
2077
+ ```
2078
+
2079
+ ## License
2080
+
2081
+ MIT
2082
+ ```
2083
+
2084
+ - [ ] **Step 2: Update package.json with files field**
2085
+
2086
+ Add to `package.json`:
2087
+
2088
+ ```json
2089
+ {
2090
+ "files": [
2091
+ "dist",
2092
+ "hooks",
2093
+ "prompts",
2094
+ "skills",
2095
+ "README.md"
2096
+ ]
2097
+ }
2098
+ ```
2099
+
2100
+ - [ ] **Step 3: Final build and test**
2101
+
2102
+ Run: `npm run build && npm test`
2103
+ Expected: Build succeeds, all tests pass
2104
+
2105
+ - [ ] **Step 4: Commit**
2106
+
2107
+ ```bash
2108
+ git add README.md package.json
2109
+ git commit -m "docs: add README and finalize package configuration"
2110
+ ```
2111
+
2112
+ ---
2113
+
2114
+ ## Summary
2115
+
2116
+ | Task | Module | Tests | Description |
2117
+ |------|--------|-------|-------------|
2118
+ | 1 | Scaffolding | - | package.json, tsconfig, vitest, tsup |
2119
+ | 2 | config.ts | 8 | Config CRUD, validation, defaults |
2120
+ | 3 | periods.ts | 17 | Date formatting, period detection |
2121
+ | 4 | vault.ts | 13 | Path generation, directory management |
2122
+ | 5 | raw-logger.ts | 7 | stdin parsing, session-isolated append |
2123
+ | 6 | merge.ts | 10 | Session recovery, daily file merging |
2124
+ | 7 | on-stop.ts | 3 | Hook entry point, silent failure |
2125
+ | 8 | Agent prompts | - | SessionEnd + SessionStart prompts |
2126
+ | 9 | Setup skill | - | Onboarding flow |
2127
+ | 10 | hooks.json | - | Hook definitions + build |
2128
+ | 11 | Integration | 3 | Full flow test |
2129
+ | 12 | README | - | Documentation + packaging |
2130
+ | **Total** | | **~61** | |