@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,2207 @@
1
+ # StorageAdapter Refactor + GitHub Integration 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:** Abstract storage behind a `StorageAdapter` interface, add GitHub as a storage backend with OAuth Device Flow authentication, and refactor all modules to use async adapter injection.
6
+
7
+ **Architecture:** Define `StorageAdapter` interface with read/write/append/exists/list/mkdir/isDirectory. Implement `LocalStorageAdapter` (fs) and `GitHubStorageAdapter` (Contents API). Refactor vault/raw-logger/merge/on-stop to accept adapter. Config schema changes to support storage type selection. All storage-touching code becomes async.
8
+
9
+ **Tech Stack:** TypeScript, Node.js fetch (built-in), vitest, tsup, GitHub OAuth Device Flow, GitHub Contents API
10
+
11
+ ---
12
+
13
+ ## File Structure
14
+
15
+ ```
16
+ src/
17
+ ├── core/
18
+ │ ├── storage.ts ← NEW: StorageAdapter interface
19
+ │ ├── local-storage.ts ← NEW: LocalStorageAdapter (wraps fs)
20
+ │ ├── github-storage.ts ← NEW: GitHubStorageAdapter (GitHub API)
21
+ │ ├── github-auth.ts ← NEW: OAuth Device Flow
22
+ │ ├── config.ts ← MODIFY: new schema, migration, adapter factory
23
+ │ ├── vault.ts ← MODIFY: relative paths, async, adapter injection
24
+ │ ├── raw-logger.ts ← MODIFY: async, adapter injection
25
+ │ ├── merge.ts ← MODIFY: async, adapter injection
26
+ │ └── periods.ts ← UNCHANGED
27
+ ├── hooks/
28
+ │ └── on-stop.ts ← MODIFY: async, adapter creation
29
+ └── cli/
30
+ └── storage-cli.ts ← NEW: CLI for agent prompts to access storage
31
+
32
+ tests/
33
+ ├── core/
34
+ │ ├── storage.test.ts ← NEW: LocalStorageAdapter tests
35
+ │ ├── github-storage.test.ts ← NEW: GitHubStorageAdapter tests (fetch mock)
36
+ │ ├── github-auth.test.ts ← NEW: Device Flow tests (fetch mock)
37
+ │ ├── config.test.ts ← MODIFY: new schema tests
38
+ │ ├── vault.test.ts ← MODIFY: relative paths, async
39
+ │ ├── raw-logger.test.ts ← MODIFY: async, adapter injection
40
+ │ ├── merge.test.ts ← MODIFY: async, adapter injection
41
+ │ └── periods.test.ts ← UNCHANGED
42
+ ├── hooks/
43
+ │ └── on-stop.test.ts ← MODIFY: async
44
+ └── integration/
45
+ └── full-flow.test.ts ← MODIFY: async, adapter injection
46
+ ```
47
+
48
+ ---
49
+
50
+ ### Task 1: StorageAdapter Interface + LocalStorageAdapter (TDD)
51
+
52
+ **Files:**
53
+ - Create: `src/core/storage.ts`
54
+ - Create: `src/core/local-storage.ts`
55
+ - Create: `tests/core/storage.test.ts`
56
+
57
+ - [ ] **Step 1: Write failing tests for StorageAdapter + LocalStorageAdapter**
58
+
59
+ ```typescript
60
+ // tests/core/storage.test.ts
61
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
62
+ import { mkdtempSync, rmSync, readFileSync, mkdirSync, writeFileSync } from "fs";
63
+ import { join } from "path";
64
+ import { tmpdir } from "os";
65
+ import { LocalStorageAdapter } from "../../src/core/local-storage.js";
66
+ import type { StorageAdapter } from "../../src/core/storage.js";
67
+
68
+ describe("LocalStorageAdapter", () => {
69
+ let tempDir: string;
70
+ let storage: StorageAdapter;
71
+
72
+ beforeEach(() => {
73
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-storage-"));
74
+ storage = new LocalStorageAdapter(tempDir);
75
+ });
76
+
77
+ afterEach(() => {
78
+ rmSync(tempDir, { recursive: true, force: true });
79
+ });
80
+
81
+ describe("write + read", () => {
82
+ it("writes and reads a file", async () => {
83
+ await storage.write("test.txt", "hello");
84
+ const content = await storage.read("test.txt");
85
+ expect(content).toBe("hello");
86
+ });
87
+
88
+ it("creates parent directories on write", async () => {
89
+ await storage.write("a/b/c.txt", "nested");
90
+ const content = await storage.read("a/b/c.txt");
91
+ expect(content).toBe("nested");
92
+ });
93
+
94
+ it("returns null for non-existent file", async () => {
95
+ const content = await storage.read("nope.txt");
96
+ expect(content).toBeNull();
97
+ });
98
+ });
99
+
100
+ describe("append", () => {
101
+ it("creates file if not exists", async () => {
102
+ await storage.append("log.txt", "line1\n");
103
+ const content = await storage.read("log.txt");
104
+ expect(content).toBe("line1\n");
105
+ });
106
+
107
+ it("appends to existing file", async () => {
108
+ await storage.append("log.txt", "line1\n");
109
+ await storage.append("log.txt", "line2\n");
110
+ const content = await storage.read("log.txt");
111
+ expect(content).toBe("line1\nline2\n");
112
+ });
113
+
114
+ it("creates parent directories on append", async () => {
115
+ await storage.append("deep/dir/log.txt", "data\n");
116
+ const content = await storage.read("deep/dir/log.txt");
117
+ expect(content).toBe("data\n");
118
+ });
119
+ });
120
+
121
+ describe("exists", () => {
122
+ it("returns false for non-existent path", async () => {
123
+ expect(await storage.exists("nope")).toBe(false);
124
+ });
125
+
126
+ it("returns true for existing file", async () => {
127
+ await storage.write("file.txt", "x");
128
+ expect(await storage.exists("file.txt")).toBe(true);
129
+ });
130
+
131
+ it("returns true for existing directory", async () => {
132
+ await storage.mkdir("mydir");
133
+ expect(await storage.exists("mydir")).toBe(true);
134
+ });
135
+ });
136
+
137
+ describe("list", () => {
138
+ it("returns empty array for non-existent directory", async () => {
139
+ expect(await storage.list("nope")).toEqual([]);
140
+ });
141
+
142
+ it("lists entries in directory", async () => {
143
+ await storage.write("dir/a.txt", "a");
144
+ await storage.write("dir/b.txt", "b");
145
+ const entries = await storage.list("dir");
146
+ expect(entries.sort()).toEqual(["a.txt", "b.txt"]);
147
+ });
148
+ });
149
+
150
+ describe("mkdir", () => {
151
+ it("creates directory recursively", async () => {
152
+ await storage.mkdir("a/b/c");
153
+ expect(await storage.exists("a/b/c")).toBe(true);
154
+ });
155
+
156
+ it("is idempotent", async () => {
157
+ await storage.mkdir("dir");
158
+ await storage.mkdir("dir");
159
+ expect(await storage.exists("dir")).toBe(true);
160
+ });
161
+ });
162
+
163
+ describe("isDirectory", () => {
164
+ it("returns true for directory", async () => {
165
+ await storage.mkdir("dir");
166
+ expect(await storage.isDirectory("dir")).toBe(true);
167
+ });
168
+
169
+ it("returns false for file", async () => {
170
+ await storage.write("file.txt", "x");
171
+ expect(await storage.isDirectory("file.txt")).toBe(false);
172
+ });
173
+
174
+ it("returns false for non-existent path", async () => {
175
+ expect(await storage.isDirectory("nope")).toBe(false);
176
+ });
177
+ });
178
+ });
179
+ ```
180
+
181
+ - [ ] **Step 2: Run tests to verify they fail**
182
+
183
+ Run: `npx vitest run tests/core/storage.test.ts`
184
+ Expected: FAIL — cannot resolve modules
185
+
186
+ - [ ] **Step 3: Create StorageAdapter interface**
187
+
188
+ ```typescript
189
+ // src/core/storage.ts
190
+ export interface StorageAdapter {
191
+ read(path: string): Promise<string | null>;
192
+ write(path: string, content: string): Promise<void>;
193
+ append(path: string, content: string): Promise<void>;
194
+ exists(path: string): Promise<boolean>;
195
+ list(dir: string): Promise<string[]>;
196
+ mkdir(dir: string): Promise<void>;
197
+ isDirectory(path: string): Promise<boolean>;
198
+ }
199
+ ```
200
+
201
+ - [ ] **Step 4: Implement LocalStorageAdapter**
202
+
203
+ ```typescript
204
+ // src/core/local-storage.ts
205
+ import {
206
+ readFileSync,
207
+ writeFileSync,
208
+ appendFileSync,
209
+ existsSync,
210
+ mkdirSync,
211
+ readdirSync,
212
+ statSync,
213
+ } from "fs";
214
+ import { dirname, join } from "path";
215
+ import type { StorageAdapter } from "./storage.js";
216
+
217
+ export class LocalStorageAdapter implements StorageAdapter {
218
+ constructor(private basePath: string) {}
219
+
220
+ private resolve(path: string): string {
221
+ return join(this.basePath, path);
222
+ }
223
+
224
+ async read(path: string): Promise<string | null> {
225
+ const full = this.resolve(path);
226
+ if (!existsSync(full)) return null;
227
+ return readFileSync(full, "utf-8");
228
+ }
229
+
230
+ async write(path: string, content: string): Promise<void> {
231
+ const full = this.resolve(path);
232
+ mkdirSync(dirname(full), { recursive: true });
233
+ writeFileSync(full, content, "utf-8");
234
+ }
235
+
236
+ async append(path: string, content: string): Promise<void> {
237
+ const full = this.resolve(path);
238
+ mkdirSync(dirname(full), { recursive: true });
239
+ appendFileSync(full, content, "utf-8");
240
+ }
241
+
242
+ async exists(path: string): Promise<boolean> {
243
+ return existsSync(this.resolve(path));
244
+ }
245
+
246
+ async list(dir: string): Promise<string[]> {
247
+ const full = this.resolve(dir);
248
+ if (!existsSync(full)) return [];
249
+ return readdirSync(full);
250
+ }
251
+
252
+ async mkdir(dir: string): Promise<void> {
253
+ mkdirSync(this.resolve(dir), { recursive: true });
254
+ }
255
+
256
+ async isDirectory(path: string): Promise<boolean> {
257
+ try {
258
+ return statSync(this.resolve(path)).isDirectory();
259
+ } catch {
260
+ return false;
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ - [ ] **Step 5: Run tests to verify they pass**
267
+
268
+ Run: `npx vitest run tests/core/storage.test.ts`
269
+ Expected: All 14 tests PASS
270
+
271
+ - [ ] **Step 6: Commit**
272
+
273
+ ```bash
274
+ git add src/core/storage.ts src/core/local-storage.ts tests/core/storage.test.ts
275
+ git commit -m "feat: add StorageAdapter interface and LocalStorageAdapter"
276
+ ```
277
+
278
+ ---
279
+
280
+ ### Task 2: Config Schema Refactor (TDD)
281
+
282
+ **Files:**
283
+ - Modify: `src/core/config.ts`
284
+ - Modify: `tests/core/config.test.ts`
285
+
286
+ - [ ] **Step 1: Rewrite config tests for new schema**
287
+
288
+ ```typescript
289
+ // tests/core/config.test.ts
290
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
291
+ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "fs";
292
+ import { join } from "path";
293
+ import { tmpdir } from "os";
294
+ import {
295
+ getConfigPath,
296
+ loadConfig,
297
+ saveConfig,
298
+ validateConfig,
299
+ createDefaultLocalConfig,
300
+ createDefaultGitHubConfig,
301
+ createStorageAdapter,
302
+ } from "../../src/core/config.js";
303
+ import { LocalStorageAdapter } from "../../src/core/local-storage.js";
304
+
305
+ describe("config", () => {
306
+ let tempDir: string;
307
+ const originalEnv = process.env.CLAUDE_PLUGIN_DATA;
308
+
309
+ beforeEach(() => {
310
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-test-"));
311
+ process.env.CLAUDE_PLUGIN_DATA = tempDir;
312
+ });
313
+
314
+ afterEach(() => {
315
+ rmSync(tempDir, { recursive: true, force: true });
316
+ process.env.CLAUDE_PLUGIN_DATA = originalEnv;
317
+ });
318
+
319
+ describe("getConfigPath", () => {
320
+ it("returns path under CLAUDE_PLUGIN_DATA", () => {
321
+ expect(getConfigPath()).toBe(join(tempDir, "config.json"));
322
+ });
323
+
324
+ it("throws when CLAUDE_PLUGIN_DATA is not set", () => {
325
+ delete process.env.CLAUDE_PLUGIN_DATA;
326
+ expect(() => getConfigPath()).toThrow("CLAUDE_PLUGIN_DATA");
327
+ });
328
+ });
329
+
330
+ describe("loadConfig", () => {
331
+ it("returns null when config does not exist", () => {
332
+ expect(loadConfig()).toBeNull();
333
+ });
334
+
335
+ it("returns parsed config with local storage", () => {
336
+ const config = createDefaultLocalConfig("/my/vault/daily-review");
337
+ saveConfig(config);
338
+ const result = loadConfig();
339
+ expect(result).toEqual(config);
340
+ });
341
+
342
+ it("migrates old config format", () => {
343
+ const oldConfig = {
344
+ vaultPath: "/my/vault",
345
+ reviewFolder: "daily-review",
346
+ language: "ko",
347
+ periods: { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false },
348
+ profile: { company: "Test", role: "Dev", team: "A", context: "B" },
349
+ };
350
+ writeFileSync(join(tempDir, "config.json"), JSON.stringify(oldConfig));
351
+ const result = loadConfig();
352
+ expect(result!.storage.type).toBe("local");
353
+ expect(result!.storage.local!.basePath).toBe("/my/vault/daily-review");
354
+ expect(result!.language).toBe("ko");
355
+ expect(result!.profile.company).toBe("Test");
356
+ });
357
+ });
358
+
359
+ describe("saveConfig", () => {
360
+ it("writes config to disk", () => {
361
+ const config = createDefaultLocalConfig("/my/vault");
362
+ saveConfig(config);
363
+ const raw = readFileSync(join(tempDir, "config.json"), "utf-8");
364
+ expect(JSON.parse(raw)).toEqual(config);
365
+ });
366
+
367
+ it("creates parent directories if needed", () => {
368
+ process.env.CLAUDE_PLUGIN_DATA = join(tempDir, "nested", "dir");
369
+ const config = createDefaultLocalConfig("/my/vault");
370
+ saveConfig(config);
371
+ expect(existsSync(join(tempDir, "nested", "dir", "config.json"))).toBe(true);
372
+ });
373
+ });
374
+
375
+ describe("validateConfig", () => {
376
+ it("returns true for valid local config", () => {
377
+ const config = createDefaultLocalConfig("/my/vault");
378
+ expect(validateConfig(config)).toBe(true);
379
+ });
380
+
381
+ it("returns true for valid github config", () => {
382
+ const config = createDefaultGitHubConfig("user", "repo", "token123");
383
+ expect(validateConfig(config)).toBe(true);
384
+ });
385
+
386
+ it("returns false when storage is missing", () => {
387
+ expect(validateConfig({ language: "ko" })).toBe(false);
388
+ });
389
+
390
+ it("returns false for null", () => {
391
+ expect(validateConfig(null)).toBe(false);
392
+ });
393
+
394
+ it("returns false for non-object", () => {
395
+ expect(validateConfig("string")).toBe(false);
396
+ });
397
+ });
398
+
399
+ describe("createDefaultLocalConfig", () => {
400
+ it("creates config with local storage", () => {
401
+ const config = createDefaultLocalConfig("/my/vault");
402
+ expect(config.storage.type).toBe("local");
403
+ expect(config.storage.local!.basePath).toBe("/my/vault");
404
+ expect(config.language).toBe("ko");
405
+ expect(config.periods.daily).toBe(true);
406
+ expect(config.profile.company).toBe("");
407
+ });
408
+ });
409
+
410
+ describe("createDefaultGitHubConfig", () => {
411
+ it("creates config with github storage", () => {
412
+ const config = createDefaultGitHubConfig("user", "repo", "tok");
413
+ expect(config.storage.type).toBe("github");
414
+ expect(config.storage.github!.owner).toBe("user");
415
+ expect(config.storage.github!.repo).toBe("repo");
416
+ expect(config.storage.github!.token).toBe("tok");
417
+ expect(config.storage.github!.basePath).toBe("daily-review");
418
+ });
419
+ });
420
+
421
+ describe("createStorageAdapter", () => {
422
+ it("returns LocalStorageAdapter for local config", () => {
423
+ const config = createDefaultLocalConfig("/my/vault");
424
+ const adapter = createStorageAdapter(config);
425
+ expect(adapter).toBeInstanceOf(LocalStorageAdapter);
426
+ });
427
+
428
+ it("throws for unknown storage type", () => {
429
+ const config = createDefaultLocalConfig("/my/vault");
430
+ (config.storage as any).type = "unknown";
431
+ expect(() => createStorageAdapter(config)).toThrow("Unknown storage type");
432
+ });
433
+ });
434
+ });
435
+ ```
436
+
437
+ - [ ] **Step 2: Run tests to verify they fail**
438
+
439
+ Run: `npx vitest run tests/core/config.test.ts`
440
+ Expected: FAIL — old exports not matching
441
+
442
+ - [ ] **Step 3: Rewrite config.ts with new schema**
443
+
444
+ ```typescript
445
+ // src/core/config.ts
446
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
447
+ import { dirname, join } from "path";
448
+ import type { StorageAdapter } from "./storage.js";
449
+ import { LocalStorageAdapter } from "./local-storage.js";
450
+
451
+ export interface Profile {
452
+ company: string;
453
+ role: string;
454
+ team: string;
455
+ context: string;
456
+ }
457
+
458
+ export interface Periods {
459
+ daily: true;
460
+ weekly: boolean;
461
+ monthly: boolean;
462
+ quarterly: boolean;
463
+ yearly: boolean;
464
+ }
465
+
466
+ export interface LocalStorageConfig {
467
+ basePath: string;
468
+ }
469
+
470
+ export interface GitHubStorageConfig {
471
+ owner: string;
472
+ repo: string;
473
+ token: string;
474
+ basePath: string;
475
+ }
476
+
477
+ export interface StorageConfig {
478
+ type: "local" | "github";
479
+ local?: LocalStorageConfig;
480
+ github?: GitHubStorageConfig;
481
+ }
482
+
483
+ export interface Config {
484
+ storage: StorageConfig;
485
+ language: string;
486
+ periods: Periods;
487
+ profile: Profile;
488
+ }
489
+
490
+ interface OldConfig {
491
+ vaultPath: string;
492
+ reviewFolder: string;
493
+ language: string;
494
+ periods: Periods;
495
+ profile: Profile;
496
+ }
497
+
498
+ const DEFAULT_PERIODS: Periods = {
499
+ daily: true,
500
+ weekly: true,
501
+ monthly: true,
502
+ quarterly: true,
503
+ yearly: false,
504
+ };
505
+
506
+ const DEFAULT_PROFILE: Profile = {
507
+ company: "",
508
+ role: "",
509
+ team: "",
510
+ context: "",
511
+ };
512
+
513
+ export function getConfigPath(): string {
514
+ const dataDir = process.env.CLAUDE_PLUGIN_DATA;
515
+ if (!dataDir) {
516
+ throw new Error("CLAUDE_PLUGIN_DATA environment variable is not set");
517
+ }
518
+ return join(dataDir, "config.json");
519
+ }
520
+
521
+ function isOldConfig(raw: unknown): raw is OldConfig {
522
+ if (!raw || typeof raw !== "object") return false;
523
+ return "vaultPath" in raw && "reviewFolder" in raw;
524
+ }
525
+
526
+ function migrateOldConfig(old: OldConfig): Config {
527
+ return {
528
+ storage: {
529
+ type: "local",
530
+ local: {
531
+ basePath: join(old.vaultPath, old.reviewFolder),
532
+ },
533
+ },
534
+ language: old.language,
535
+ periods: old.periods,
536
+ profile: old.profile,
537
+ };
538
+ }
539
+
540
+ export function loadConfig(): Config | null {
541
+ const configPath = getConfigPath();
542
+ if (!existsSync(configPath)) return null;
543
+ const raw = JSON.parse(readFileSync(configPath, "utf-8"));
544
+ if (isOldConfig(raw)) {
545
+ const migrated = migrateOldConfig(raw);
546
+ saveConfig(migrated);
547
+ return migrated;
548
+ }
549
+ return raw as Config;
550
+ }
551
+
552
+ export function saveConfig(config: Config): void {
553
+ const configPath = getConfigPath();
554
+ mkdirSync(dirname(configPath), { recursive: true });
555
+ writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
556
+ }
557
+
558
+ export function validateConfig(config: unknown): config is Config {
559
+ if (!config || typeof config !== "object") return false;
560
+ const c = config as Record<string, unknown>;
561
+ if (!c.storage || typeof c.storage !== "object") return false;
562
+ const s = c.storage as Record<string, unknown>;
563
+ if (s.type !== "local" && s.type !== "github") return false;
564
+ if (s.type === "local") {
565
+ if (!s.local || typeof s.local !== "object") return false;
566
+ const l = s.local as Record<string, unknown>;
567
+ if (typeof l.basePath !== "string" || l.basePath === "") return false;
568
+ }
569
+ if (s.type === "github") {
570
+ if (!s.github || typeof s.github !== "object") return false;
571
+ const g = s.github as Record<string, unknown>;
572
+ if (typeof g.owner !== "string" || !g.owner) return false;
573
+ if (typeof g.repo !== "string" || !g.repo) return false;
574
+ if (typeof g.token !== "string" || !g.token) return false;
575
+ }
576
+ return true;
577
+ }
578
+
579
+ export function createDefaultLocalConfig(basePath: string): Config {
580
+ return {
581
+ storage: { type: "local", local: { basePath } },
582
+ language: "ko",
583
+ periods: { ...DEFAULT_PERIODS },
584
+ profile: { ...DEFAULT_PROFILE },
585
+ };
586
+ }
587
+
588
+ export function createDefaultGitHubConfig(owner: string, repo: string, token: string): Config {
589
+ return {
590
+ storage: { type: "github", github: { owner, repo, token, basePath: "daily-review" } },
591
+ language: "ko",
592
+ periods: { ...DEFAULT_PERIODS },
593
+ profile: { ...DEFAULT_PROFILE },
594
+ };
595
+ }
596
+
597
+ export function createStorageAdapter(config: Config): StorageAdapter {
598
+ if (config.storage.type === "local") {
599
+ return new LocalStorageAdapter(config.storage.local!.basePath);
600
+ }
601
+ if (config.storage.type === "github") {
602
+ // GitHubStorageAdapter will be imported dynamically in a later task
603
+ throw new Error("GitHub storage not yet implemented");
604
+ }
605
+ throw new Error(`Unknown storage type: ${(config.storage as any).type}`);
606
+ }
607
+ ```
608
+
609
+ - [ ] **Step 4: Run tests to verify they pass**
610
+
611
+ Run: `npx vitest run tests/core/config.test.ts`
612
+ Expected: All 13 tests PASS
613
+
614
+ - [ ] **Step 5: Commit**
615
+
616
+ ```bash
617
+ git add src/core/config.ts tests/core/config.test.ts
618
+ git commit -m "refactor: config schema with storage type, migration from old format"
619
+ ```
620
+
621
+ ---
622
+
623
+ ### Task 3: Vault Module Refactor (TDD)
624
+
625
+ **Files:**
626
+ - Modify: `src/core/vault.ts`
627
+ - Modify: `tests/core/vault.test.ts`
628
+
629
+ Vault path generators become pure functions returning relative paths (no config needed). `ensureVaultDirectories` takes StorageAdapter + Periods.
630
+
631
+ - [ ] **Step 1: Rewrite vault tests**
632
+
633
+ ```typescript
634
+ // tests/core/vault.test.ts
635
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
636
+ import { mkdtempSync, rmSync, existsSync } from "fs";
637
+ import { join } from "path";
638
+ import { tmpdir } from "os";
639
+ import {
640
+ getRawDir,
641
+ getReviewsDir,
642
+ getDailyPath,
643
+ getWeeklyPath,
644
+ getMonthlyPath,
645
+ getQuarterlyPath,
646
+ getYearlyPath,
647
+ getProjectDailyPath,
648
+ getProjectSummaryPath,
649
+ getUncategorizedPath,
650
+ ensureVaultDirectories,
651
+ } from "../../src/core/vault.js";
652
+ import { LocalStorageAdapter } from "../../src/core/local-storage.js";
653
+ import type { Periods } from "../../src/core/config.js";
654
+
655
+ describe("vault", () => {
656
+ describe("path generators (pure, relative)", () => {
657
+ it("getRawDir returns .raw/{sessionId}", () => {
658
+ expect(getRawDir("sess-123")).toBe(".raw/sess-123");
659
+ });
660
+
661
+ it("getReviewsDir returns .reviews", () => {
662
+ expect(getReviewsDir()).toBe(".reviews");
663
+ });
664
+
665
+ it("getDailyPath returns daily/{date}.md", () => {
666
+ expect(getDailyPath("2026-03-28")).toBe("daily/2026-03-28.md");
667
+ });
668
+
669
+ it("getWeeklyPath returns weekly/{week}.md", () => {
670
+ expect(getWeeklyPath("2026-W13")).toBe("weekly/2026-W13.md");
671
+ });
672
+
673
+ it("getMonthlyPath returns monthly/{month}.md", () => {
674
+ expect(getMonthlyPath("2026-03")).toBe("monthly/2026-03.md");
675
+ });
676
+
677
+ it("getQuarterlyPath returns quarterly/{quarter}.md", () => {
678
+ expect(getQuarterlyPath("2026-Q1")).toBe("quarterly/2026-Q1.md");
679
+ });
680
+
681
+ it("getYearlyPath returns yearly/{year}.md", () => {
682
+ expect(getYearlyPath("2026")).toBe("yearly/2026.md");
683
+ });
684
+
685
+ it("getProjectDailyPath returns projects/{name}/{date}.md", () => {
686
+ expect(getProjectDailyPath("my-app", "2026-03-28")).toBe("projects/my-app/2026-03-28.md");
687
+ });
688
+
689
+ it("getProjectSummaryPath returns projects/{name}/summary.md", () => {
690
+ expect(getProjectSummaryPath("my-app")).toBe("projects/my-app/summary.md");
691
+ });
692
+
693
+ it("getUncategorizedPath returns uncategorized/{date}.md", () => {
694
+ expect(getUncategorizedPath("2026-03-28")).toBe("uncategorized/2026-03-28.md");
695
+ });
696
+ });
697
+
698
+ describe("ensureVaultDirectories", () => {
699
+ let tempDir: string;
700
+ let storage: LocalStorageAdapter;
701
+
702
+ beforeEach(() => {
703
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-vault-"));
704
+ storage = new LocalStorageAdapter(tempDir);
705
+ });
706
+
707
+ afterEach(() => {
708
+ rmSync(tempDir, { recursive: true, force: true });
709
+ });
710
+
711
+ it("creates base directories", async () => {
712
+ const periods: Periods = { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false };
713
+ await ensureVaultDirectories(storage, periods);
714
+ expect(existsSync(join(tempDir, "daily"))).toBe(true);
715
+ expect(existsSync(join(tempDir, "projects"))).toBe(true);
716
+ expect(existsSync(join(tempDir, "uncategorized"))).toBe(true);
717
+ expect(existsSync(join(tempDir, ".raw"))).toBe(true);
718
+ expect(existsSync(join(tempDir, ".reviews"))).toBe(true);
719
+ });
720
+
721
+ it("creates period directories only when enabled", async () => {
722
+ const periods: Periods = { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false };
723
+ await ensureVaultDirectories(storage, periods);
724
+ expect(existsSync(join(tempDir, "weekly"))).toBe(true);
725
+ expect(existsSync(join(tempDir, "monthly"))).toBe(true);
726
+ expect(existsSync(join(tempDir, "quarterly"))).toBe(true);
727
+ expect(existsSync(join(tempDir, "yearly"))).toBe(false);
728
+ });
729
+
730
+ it("is idempotent", async () => {
731
+ const periods: Periods = { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false };
732
+ await ensureVaultDirectories(storage, periods);
733
+ await ensureVaultDirectories(storage, periods);
734
+ expect(existsSync(join(tempDir, "daily"))).toBe(true);
735
+ });
736
+ });
737
+ });
738
+ ```
739
+
740
+ - [ ] **Step 2: Run tests to verify they fail**
741
+
742
+ Run: `npx vitest run tests/core/vault.test.ts`
743
+ Expected: FAIL — old signatures don't match
744
+
745
+ - [ ] **Step 3: Rewrite vault.ts**
746
+
747
+ ```typescript
748
+ // src/core/vault.ts
749
+ import type { StorageAdapter } from "./storage.js";
750
+ import type { Periods } from "./config.js";
751
+
752
+ export function getRawDir(sessionId: string): string {
753
+ return `.raw/${sessionId}`;
754
+ }
755
+
756
+ export function getReviewsDir(): string {
757
+ return ".reviews";
758
+ }
759
+
760
+ export function getDailyPath(date: string): string {
761
+ return `daily/${date}.md`;
762
+ }
763
+
764
+ export function getWeeklyPath(week: string): string {
765
+ return `weekly/${week}.md`;
766
+ }
767
+
768
+ export function getMonthlyPath(month: string): string {
769
+ return `monthly/${month}.md`;
770
+ }
771
+
772
+ export function getQuarterlyPath(quarter: string): string {
773
+ return `quarterly/${quarter}.md`;
774
+ }
775
+
776
+ export function getYearlyPath(year: string): string {
777
+ return `yearly/${year}.md`;
778
+ }
779
+
780
+ export function getProjectDailyPath(projectName: string, date: string): string {
781
+ return `projects/${projectName}/${date}.md`;
782
+ }
783
+
784
+ export function getProjectSummaryPath(projectName: string): string {
785
+ return `projects/${projectName}/summary.md`;
786
+ }
787
+
788
+ export function getUncategorizedPath(date: string): string {
789
+ return `uncategorized/${date}.md`;
790
+ }
791
+
792
+ export async function ensureVaultDirectories(storage: StorageAdapter, periods: Periods): Promise<void> {
793
+ const dirs = ["daily", "projects", "uncategorized", ".raw", ".reviews"];
794
+ if (periods.weekly) dirs.push("weekly");
795
+ if (periods.monthly) dirs.push("monthly");
796
+ if (periods.quarterly) dirs.push("quarterly");
797
+ if (periods.yearly) dirs.push("yearly");
798
+
799
+ for (const dir of dirs) {
800
+ await storage.mkdir(dir);
801
+ }
802
+ }
803
+ ```
804
+
805
+ - [ ] **Step 4: Run tests to verify they pass**
806
+
807
+ Run: `npx vitest run tests/core/vault.test.ts`
808
+ Expected: All 13 tests PASS
809
+
810
+ - [ ] **Step 5: Commit**
811
+
812
+ ```bash
813
+ git add src/core/vault.ts tests/core/vault.test.ts
814
+ git commit -m "refactor: vault to relative paths and async StorageAdapter injection"
815
+ ```
816
+
817
+ ---
818
+
819
+ ### Task 4: Raw Logger Refactor (TDD)
820
+
821
+ **Files:**
822
+ - Modify: `src/core/raw-logger.ts`
823
+ - Modify: `tests/core/raw-logger.test.ts`
824
+
825
+ - [ ] **Step 1: Rewrite raw-logger tests with StorageAdapter**
826
+
827
+ ```typescript
828
+ // tests/core/raw-logger.test.ts
829
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
830
+ import { mkdtempSync, rmSync, readFileSync } from "fs";
831
+ import { join } from "path";
832
+ import { tmpdir } from "os";
833
+ import { LocalStorageAdapter } from "../../src/core/local-storage.js";
834
+ import {
835
+ parseHookInput,
836
+ appendRawLog,
837
+ type HookInput,
838
+ } from "../../src/core/raw-logger.js";
839
+
840
+ describe("raw-logger", () => {
841
+ let tempDir: string;
842
+ let storage: LocalStorageAdapter;
843
+
844
+ beforeEach(() => {
845
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-raw-"));
846
+ storage = new LocalStorageAdapter(tempDir);
847
+ });
848
+
849
+ afterEach(() => {
850
+ rmSync(tempDir, { recursive: true, force: true });
851
+ });
852
+
853
+ describe("parseHookInput", () => {
854
+ it("parses valid JSON from stdin", () => {
855
+ const input = JSON.stringify({
856
+ session_id: "abc-123",
857
+ transcript_path: "/tmp/transcript.jsonl",
858
+ cwd: "/projects/my-app",
859
+ hook_event_name: "Stop",
860
+ });
861
+ const result = parseHookInput(input);
862
+ expect(result.session_id).toBe("abc-123");
863
+ expect(result.transcript_path).toBe("/tmp/transcript.jsonl");
864
+ expect(result.cwd).toBe("/projects/my-app");
865
+ expect(result.hook_event_name).toBe("Stop");
866
+ });
867
+
868
+ it("throws on invalid JSON", () => {
869
+ expect(() => parseHookInput("not json")).toThrow();
870
+ });
871
+
872
+ it("throws when session_id is missing", () => {
873
+ const input = JSON.stringify({ cwd: "/tmp" });
874
+ expect(() => parseHookInput(input)).toThrow("session_id");
875
+ });
876
+ });
877
+
878
+ describe("appendRawLog", () => {
879
+ it("creates session directory and appends log entry", async () => {
880
+ const entry: HookInput = {
881
+ session_id: "sess-1",
882
+ transcript_path: "/tmp/t.jsonl",
883
+ cwd: "/projects/my-app",
884
+ hook_event_name: "Stop",
885
+ };
886
+
887
+ await appendRawLog(storage, ".raw/sess-1", "2026-03-28", entry);
888
+
889
+ const content = await storage.read(".raw/sess-1/2026-03-28.jsonl");
890
+ expect(content).not.toBeNull();
891
+ const lines = content!.trim().split("\n");
892
+ expect(lines).toHaveLength(1);
893
+ const parsed = JSON.parse(lines[0]);
894
+ expect(parsed.session_id).toBe("sess-1");
895
+ expect(parsed.cwd).toBe("/projects/my-app");
896
+ expect(typeof parsed.timestamp).toBe("string");
897
+ });
898
+
899
+ it("appends multiple entries to same file", async () => {
900
+ const entry: HookInput = {
901
+ session_id: "sess-2",
902
+ transcript_path: "/tmp/t.jsonl",
903
+ cwd: "/projects/my-app",
904
+ hook_event_name: "Stop",
905
+ };
906
+
907
+ await appendRawLog(storage, ".raw/sess-2", "2026-03-28", entry);
908
+ await appendRawLog(storage, ".raw/sess-2", "2026-03-28", entry);
909
+
910
+ const content = await storage.read(".raw/sess-2/2026-03-28.jsonl");
911
+ const lines = content!.trim().split("\n");
912
+ expect(lines).toHaveLength(2);
913
+ });
914
+
915
+ it("creates separate files for different dates", async () => {
916
+ const entry: HookInput = {
917
+ session_id: "sess-3",
918
+ transcript_path: "/tmp/t.jsonl",
919
+ cwd: "/projects/my-app",
920
+ hook_event_name: "Stop",
921
+ };
922
+
923
+ await appendRawLog(storage, ".raw/sess-3", "2026-03-28", entry);
924
+ await appendRawLog(storage, ".raw/sess-3", "2026-03-29", entry);
925
+
926
+ expect(await storage.exists(".raw/sess-3/2026-03-28.jsonl")).toBe(true);
927
+ expect(await storage.exists(".raw/sess-3/2026-03-29.jsonl")).toBe(true);
928
+ });
929
+
930
+ it("stores timestamp in each entry", async () => {
931
+ const entry: HookInput = {
932
+ session_id: "sess-4",
933
+ transcript_path: "/tmp/t.jsonl",
934
+ cwd: "/projects/my-app",
935
+ hook_event_name: "Stop",
936
+ };
937
+
938
+ await appendRawLog(storage, ".raw/sess-4", "2026-03-28", entry);
939
+
940
+ const content = await storage.read(".raw/sess-4/2026-03-28.jsonl");
941
+ const parsed = JSON.parse(content!.trim());
942
+ expect(parsed.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
943
+ });
944
+ });
945
+ });
946
+ ```
947
+
948
+ - [ ] **Step 2: Rewrite raw-logger.ts**
949
+
950
+ ```typescript
951
+ // src/core/raw-logger.ts
952
+ import type { StorageAdapter } from "./storage.js";
953
+
954
+ export interface HookInput {
955
+ session_id: string;
956
+ transcript_path: string;
957
+ cwd: string;
958
+ hook_event_name: string;
959
+ [key: string]: unknown;
960
+ }
961
+
962
+ export function parseHookInput(raw: string): HookInput {
963
+ const parsed = JSON.parse(raw);
964
+ if (!parsed || typeof parsed !== "object") {
965
+ throw new Error("Invalid hook input: expected object");
966
+ }
967
+ if (typeof parsed.session_id !== "string" || !parsed.session_id) {
968
+ throw new Error("Invalid hook input: missing session_id");
969
+ }
970
+ return parsed as HookInput;
971
+ }
972
+
973
+ export async function appendRawLog(storage: StorageAdapter, sessionDir: string, date: string, entry: HookInput): Promise<void> {
974
+ await storage.mkdir(sessionDir);
975
+ const logPath = `${sessionDir}/${date}.jsonl`;
976
+ const record = {
977
+ ...entry,
978
+ timestamp: new Date().toISOString(),
979
+ };
980
+ await storage.append(logPath, JSON.stringify(record) + "\n");
981
+ }
982
+ ```
983
+
984
+ - [ ] **Step 3: Run tests**
985
+
986
+ Run: `npx vitest run tests/core/raw-logger.test.ts`
987
+ Expected: All 7 tests PASS
988
+
989
+ - [ ] **Step 4: Commit**
990
+
991
+ ```bash
992
+ git add src/core/raw-logger.ts tests/core/raw-logger.test.ts
993
+ git commit -m "refactor: raw-logger to async StorageAdapter injection"
994
+ ```
995
+
996
+ ---
997
+
998
+ ### Task 5: Merge Module Refactor (TDD)
999
+
1000
+ **Files:**
1001
+ - Modify: `src/core/merge.ts`
1002
+ - Modify: `tests/core/merge.test.ts`
1003
+
1004
+ - [ ] **Step 1: Rewrite merge tests with StorageAdapter**
1005
+
1006
+ ```typescript
1007
+ // tests/core/merge.test.ts
1008
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
1009
+ import { mkdtempSync, rmSync } from "fs";
1010
+ import { join } from "path";
1011
+ import { tmpdir } from "os";
1012
+ import { LocalStorageAdapter } from "../../src/core/local-storage.js";
1013
+ import {
1014
+ findUnprocessedSessions,
1015
+ findPendingReviews,
1016
+ markSessionCompleted,
1017
+ isSessionCompleted,
1018
+ mergeReviewsIntoDaily,
1019
+ } from "../../src/core/merge.js";
1020
+
1021
+ describe("merge", () => {
1022
+ let tempDir: string;
1023
+ let storage: LocalStorageAdapter;
1024
+
1025
+ beforeEach(async () => {
1026
+ tempDir = mkdtempSync(join(tmpdir(), "cdr-merge-"));
1027
+ storage = new LocalStorageAdapter(tempDir);
1028
+ await storage.mkdir(".raw");
1029
+ await storage.mkdir(".reviews");
1030
+ await storage.mkdir("daily");
1031
+ });
1032
+
1033
+ afterEach(() => {
1034
+ rmSync(tempDir, { recursive: true, force: true });
1035
+ });
1036
+
1037
+ describe("findUnprocessedSessions", () => {
1038
+ it("returns empty array when no sessions exist", async () => {
1039
+ const result = await findUnprocessedSessions(storage, ".raw");
1040
+ expect(result).toEqual([]);
1041
+ });
1042
+
1043
+ it("returns session dirs without .completed marker", async () => {
1044
+ await storage.write(".raw/sess-1/2026-03-28.jsonl", "{}");
1045
+ await storage.write(".raw/sess-2/2026-03-28.jsonl", "{}");
1046
+ await storage.write(".raw/sess-2/.completed", "");
1047
+
1048
+ const result = await findUnprocessedSessions(storage, ".raw");
1049
+ expect(result).toEqual(["sess-1"]);
1050
+ });
1051
+
1052
+ it("ignores non-directory entries", async () => {
1053
+ await storage.write(".raw/stray-file.txt", "");
1054
+ const result = await findUnprocessedSessions(storage, ".raw");
1055
+ expect(result).toEqual([]);
1056
+ });
1057
+ });
1058
+
1059
+ describe("findPendingReviews", () => {
1060
+ it("returns empty array when no reviews exist", async () => {
1061
+ const result = await findPendingReviews(storage, ".reviews");
1062
+ expect(result).toEqual([]);
1063
+ });
1064
+
1065
+ it("returns .md files in reviews directory", async () => {
1066
+ await storage.write(".reviews/sess-1.md", "# Review");
1067
+ await storage.write(".reviews/sess-2.md", "# Review 2");
1068
+ const result = await findPendingReviews(storage, ".reviews");
1069
+ expect(result.sort()).toEqual(["sess-1.md", "sess-2.md"]);
1070
+ });
1071
+
1072
+ it("ignores non-md files", async () => {
1073
+ await storage.write(".reviews/sess-1.md", "# Review");
1074
+ await storage.write(".reviews/notes.txt", "text");
1075
+ const result = await findPendingReviews(storage, ".reviews");
1076
+ expect(result).toEqual(["sess-1.md"]);
1077
+ });
1078
+ });
1079
+
1080
+ describe("markSessionCompleted / isSessionCompleted", () => {
1081
+ it("creates .completed marker", async () => {
1082
+ await storage.mkdir(".raw/sess-1");
1083
+ expect(await isSessionCompleted(storage, ".raw/sess-1")).toBe(false);
1084
+
1085
+ await markSessionCompleted(storage, ".raw/sess-1");
1086
+ expect(await isSessionCompleted(storage, ".raw/sess-1")).toBe(true);
1087
+ });
1088
+ });
1089
+
1090
+ describe("mergeReviewsIntoDaily", () => {
1091
+ it("creates daily file from single review", async () => {
1092
+ await storage.write(".reviews/sess-1.md", "## [my-app] Auth work\n**작업 요약:** JWT 구현\n");
1093
+
1094
+ await mergeReviewsIntoDaily(storage, [".reviews/sess-1.md"], "daily/2026-03-28.md");
1095
+
1096
+ const content = await storage.read("daily/2026-03-28.md");
1097
+ expect(content).toContain("[my-app] Auth work");
1098
+ });
1099
+
1100
+ it("appends to existing daily file", async () => {
1101
+ await storage.write("daily/2026-03-28.md", "# 2026-03-28 Daily Review\n\n## [blog] SEO work\nDone.\n");
1102
+ await storage.write(".reviews/sess-2.md", "\n## [my-app] Auth work\n**작업 요약:** JWT 구현\n");
1103
+
1104
+ await mergeReviewsIntoDaily(storage, [".reviews/sess-2.md"], "daily/2026-03-28.md");
1105
+
1106
+ const content = await storage.read("daily/2026-03-28.md");
1107
+ expect(content).toContain("[blog] SEO work");
1108
+ expect(content).toContain("[my-app] Auth work");
1109
+ });
1110
+
1111
+ it("merges multiple reviews", async () => {
1112
+ await storage.write(".reviews/sess-1.md", "## Session 1 content\n");
1113
+ await storage.write(".reviews/sess-2.md", "## Session 2 content\n");
1114
+
1115
+ await mergeReviewsIntoDaily(storage, [".reviews/sess-1.md", ".reviews/sess-2.md"], "daily/2026-03-28.md");
1116
+
1117
+ const content = await storage.read("daily/2026-03-28.md");
1118
+ expect(content).toContain("Session 1 content");
1119
+ expect(content).toContain("Session 2 content");
1120
+ });
1121
+
1122
+ it("handles empty review files gracefully", async () => {
1123
+ await storage.write(".reviews/sess-empty.md", "");
1124
+ await expect(
1125
+ mergeReviewsIntoDaily(storage, [".reviews/sess-empty.md"], "daily/2026-03-28.md"),
1126
+ ).resolves.not.toThrow();
1127
+ });
1128
+ });
1129
+ });
1130
+ ```
1131
+
1132
+ - [ ] **Step 2: Rewrite merge.ts**
1133
+
1134
+ ```typescript
1135
+ // src/core/merge.ts
1136
+ import type { StorageAdapter } from "./storage.js";
1137
+
1138
+ export async function findUnprocessedSessions(storage: StorageAdapter, rawDir: string): Promise<string[]> {
1139
+ if (!(await storage.exists(rawDir))) return [];
1140
+ const entries = await storage.list(rawDir);
1141
+ const results: string[] = [];
1142
+ for (const entry of entries) {
1143
+ const entryPath = `${rawDir}/${entry}`;
1144
+ if (!(await storage.isDirectory(entryPath))) continue;
1145
+ if (await storage.exists(`${entryPath}/.completed`)) continue;
1146
+ results.push(entry);
1147
+ }
1148
+ return results;
1149
+ }
1150
+
1151
+ export async function findPendingReviews(storage: StorageAdapter, reviewsDir: string): Promise<string[]> {
1152
+ if (!(await storage.exists(reviewsDir))) return [];
1153
+ const entries = await storage.list(reviewsDir);
1154
+ return entries.filter((f) => f.endsWith(".md"));
1155
+ }
1156
+
1157
+ export async function markSessionCompleted(storage: StorageAdapter, sessionDir: string): Promise<void> {
1158
+ await storage.write(`${sessionDir}/.completed`, new Date().toISOString());
1159
+ }
1160
+
1161
+ export async function isSessionCompleted(storage: StorageAdapter, sessionDir: string): Promise<boolean> {
1162
+ return storage.exists(`${sessionDir}/.completed`);
1163
+ }
1164
+
1165
+ export async function mergeReviewsIntoDaily(storage: StorageAdapter, reviewPaths: string[], dailyPath: string): Promise<void> {
1166
+ const reviewContents: string[] = [];
1167
+ for (const p of reviewPaths) {
1168
+ const content = await storage.read(p);
1169
+ if (content && content.trim().length > 0) {
1170
+ reviewContents.push(content.trim());
1171
+ }
1172
+ }
1173
+
1174
+ if (reviewContents.length === 0) {
1175
+ if (!(await storage.exists(dailyPath))) {
1176
+ await storage.write(dailyPath, "");
1177
+ }
1178
+ return;
1179
+ }
1180
+
1181
+ const existing = await storage.read(dailyPath);
1182
+ const merged = existing
1183
+ ? existing.trimEnd() + "\n\n" + reviewContents.join("\n\n") + "\n"
1184
+ : reviewContents.join("\n\n") + "\n";
1185
+
1186
+ await storage.write(dailyPath, merged);
1187
+ }
1188
+ ```
1189
+
1190
+ - [ ] **Step 3: Run tests**
1191
+
1192
+ Run: `npx vitest run tests/core/merge.test.ts`
1193
+ Expected: All 10 tests PASS
1194
+
1195
+ - [ ] **Step 4: Commit**
1196
+
1197
+ ```bash
1198
+ git add src/core/merge.ts tests/core/merge.test.ts
1199
+ git commit -m "refactor: merge module to async StorageAdapter injection"
1200
+ ```
1201
+
1202
+ ---
1203
+
1204
+ ### Task 6: On-Stop Hook + Integration Test Refactor (TDD)
1205
+
1206
+ **Files:**
1207
+ - Modify: `src/hooks/on-stop.ts`
1208
+ - Modify: `tests/hooks/on-stop.test.ts`
1209
+ - Modify: `tests/integration/full-flow.test.ts`
1210
+
1211
+ - [ ] **Step 1: Rewrite on-stop tests**
1212
+
1213
+ ```typescript
1214
+ // tests/hooks/on-stop.test.ts
1215
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
1216
+ import { mkdtempSync, rmSync, writeFileSync, existsSync, readdirSync } from "fs";
1217
+ import { join } from "path";
1218
+ import { tmpdir } from "os";
1219
+ import { handleStopHook } from "../../src/hooks/on-stop.js";
1220
+
1221
+ describe("on-stop hook", () => {
1222
+ let tempVault: string;
1223
+ let tempPluginData: string;
1224
+ const originalEnv = { ...process.env };
1225
+
1226
+ beforeEach(() => {
1227
+ tempVault = mkdtempSync(join(tmpdir(), "cdr-stop-vault-"));
1228
+ tempPluginData = mkdtempSync(join(tmpdir(), "cdr-stop-data-"));
1229
+ process.env.CLAUDE_PLUGIN_DATA = tempPluginData;
1230
+
1231
+ const config = {
1232
+ storage: { type: "local", local: { basePath: join(tempVault, "daily-review") } },
1233
+ language: "ko",
1234
+ periods: { daily: true, weekly: true, monthly: true, quarterly: true, yearly: false },
1235
+ profile: { company: "", role: "", team: "", context: "" },
1236
+ };
1237
+ writeFileSync(join(tempPluginData, "config.json"), JSON.stringify(config));
1238
+ });
1239
+
1240
+ afterEach(() => {
1241
+ rmSync(tempVault, { recursive: true, force: true });
1242
+ rmSync(tempPluginData, { recursive: true, force: true });
1243
+ process.env = { ...originalEnv };
1244
+ });
1245
+
1246
+ it("appends raw log for valid input", async () => {
1247
+ const input = JSON.stringify({
1248
+ session_id: "test-sess",
1249
+ transcript_path: "/tmp/t.jsonl",
1250
+ cwd: "/projects/my-app",
1251
+ hook_event_name: "Stop",
1252
+ });
1253
+
1254
+ await handleStopHook(input);
1255
+
1256
+ const rawDir = join(tempVault, "daily-review", ".raw", "test-sess");
1257
+ expect(existsSync(rawDir)).toBe(true);
1258
+ const files = readdirSync(rawDir);
1259
+ expect(files.some((f: string) => f.endsWith(".jsonl"))).toBe(true);
1260
+ });
1261
+
1262
+ it("exits silently when config is missing", async () => {
1263
+ rmSync(join(tempPluginData, "config.json"));
1264
+ const input = JSON.stringify({
1265
+ session_id: "test-sess",
1266
+ transcript_path: "/tmp/t.jsonl",
1267
+ cwd: "/projects/my-app",
1268
+ hook_event_name: "Stop",
1269
+ });
1270
+ await expect(handleStopHook(input)).resolves.not.toThrow();
1271
+ });
1272
+
1273
+ it("exits silently on invalid JSON input", async () => {
1274
+ await expect(handleStopHook("not-json")).resolves.not.toThrow();
1275
+ });
1276
+ });
1277
+ ```
1278
+
1279
+ - [ ] **Step 2: Rewrite on-stop.ts**
1280
+
1281
+ ```typescript
1282
+ // src/hooks/on-stop.ts
1283
+ import { fileURLToPath } from "url";
1284
+ import { resolve } from "path";
1285
+ import { loadConfig, createStorageAdapter } from "../core/config.js";
1286
+ import { parseHookInput, appendRawLog } from "../core/raw-logger.js";
1287
+ import { getRawDir } from "../core/vault.js";
1288
+ import { formatDate } from "../core/periods.js";
1289
+
1290
+ export async function handleStopHook(stdinData: string): Promise<void> {
1291
+ try {
1292
+ const config = loadConfig();
1293
+ if (!config) return;
1294
+
1295
+ const storage = createStorageAdapter(config);
1296
+ const input = parseHookInput(stdinData);
1297
+ const sessionDir = getRawDir(input.session_id);
1298
+ const date = formatDate(new Date());
1299
+
1300
+ await appendRawLog(storage, sessionDir, date, input);
1301
+ } catch {
1302
+ // async hook — fail silently
1303
+ }
1304
+ }
1305
+
1306
+ // Main execution
1307
+ const isMainModule = process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1]);
1308
+
1309
+ if (isMainModule) {
1310
+ let data = "";
1311
+ process.stdin.setEncoding("utf-8");
1312
+ process.stdin.on("data", (chunk) => (data += chunk));
1313
+ process.stdin.on("end", () => {
1314
+ handleStopHook(data);
1315
+ });
1316
+ }
1317
+ ```
1318
+
1319
+ - [ ] **Step 3: Rewrite integration test**
1320
+
1321
+ ```typescript
1322
+ // tests/integration/full-flow.test.ts
1323
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
1324
+ import { mkdtempSync, rmSync } from "fs";
1325
+ import { join } from "path";
1326
+ import { tmpdir } from "os";
1327
+ import { handleStopHook } from "../../src/hooks/on-stop.js";
1328
+ import { findUnprocessedSessions, mergeReviewsIntoDaily, markSessionCompleted } from "../../src/core/merge.js";
1329
+ import { saveConfig, createDefaultLocalConfig } from "../../src/core/config.js";
1330
+ import { ensureVaultDirectories, getDailyPath } from "../../src/core/vault.js";
1331
+ import { LocalStorageAdapter } from "../../src/core/local-storage.js";
1332
+ import { checkPeriodsNeeded, formatDate } from "../../src/core/periods.js";
1333
+
1334
+ describe("integration: full flow", () => {
1335
+ let tempVault: string;
1336
+ let tempPluginData: string;
1337
+ let storage: LocalStorageAdapter;
1338
+ const originalEnv = { ...process.env };
1339
+
1340
+ beforeEach(async () => {
1341
+ tempVault = mkdtempSync(join(tmpdir(), "cdr-int-vault-"));
1342
+ tempPluginData = mkdtempSync(join(tmpdir(), "cdr-int-data-"));
1343
+ process.env.CLAUDE_PLUGIN_DATA = tempPluginData;
1344
+
1345
+ const basePath = join(tempVault, "daily-review");
1346
+ const config = createDefaultLocalConfig(basePath);
1347
+ saveConfig(config);
1348
+ storage = new LocalStorageAdapter(basePath);
1349
+ await ensureVaultDirectories(storage, config.periods);
1350
+ });
1351
+
1352
+ afterEach(() => {
1353
+ rmSync(tempVault, { recursive: true, force: true });
1354
+ rmSync(tempPluginData, { recursive: true, force: true });
1355
+ process.env = { ...originalEnv };
1356
+ });
1357
+
1358
+ it("Stop hook creates raw log, then merge recovers it", async () => {
1359
+ const input = JSON.stringify({
1360
+ session_id: "int-sess-1",
1361
+ transcript_path: "/tmp/transcript.jsonl",
1362
+ cwd: "/projects/my-app",
1363
+ hook_event_name: "Stop",
1364
+ });
1365
+ await handleStopHook(input);
1366
+
1367
+ expect(await storage.exists(".raw/int-sess-1")).toBe(true);
1368
+
1369
+ const unprocessed = await findUnprocessedSessions(storage, ".raw");
1370
+ expect(unprocessed).toContain("int-sess-1");
1371
+
1372
+ await storage.write(".reviews/int-sess-1.md", "## [my-app] Auth work\n**작업 요약:** JWT 구현\n");
1373
+ await markSessionCompleted(storage, ".raw/int-sess-1");
1374
+
1375
+ const today = formatDate(new Date());
1376
+ const dailyPath = getDailyPath(today);
1377
+ await mergeReviewsIntoDaily(storage, [".reviews/int-sess-1.md"], dailyPath);
1378
+
1379
+ const content = await storage.read(dailyPath);
1380
+ expect(content).toContain("[my-app] Auth work");
1381
+ expect(content).toContain("JWT 구현");
1382
+ });
1383
+
1384
+ it("multiple sessions merge into same daily file", async () => {
1385
+ await handleStopHook(JSON.stringify({
1386
+ session_id: "sess-a", transcript_path: "/tmp/a.jsonl",
1387
+ cwd: "/projects/app-a", hook_event_name: "Stop",
1388
+ }));
1389
+ await handleStopHook(JSON.stringify({
1390
+ session_id: "sess-b", transcript_path: "/tmp/b.jsonl",
1391
+ cwd: "/projects/app-b", hook_event_name: "Stop",
1392
+ }));
1393
+
1394
+ await storage.write(".reviews/sess-a.md", "## [app-a] Feature A\n");
1395
+ await storage.write(".reviews/sess-b.md", "## [app-b] Feature B\n");
1396
+
1397
+ const today = formatDate(new Date());
1398
+ const dailyPath = getDailyPath(today);
1399
+ await mergeReviewsIntoDaily(storage, [".reviews/sess-a.md", ".reviews/sess-b.md"], dailyPath);
1400
+
1401
+ const content = await storage.read(dailyPath);
1402
+ expect(content).toContain("[app-a] Feature A");
1403
+ expect(content).toContain("[app-b] Feature B");
1404
+ });
1405
+
1406
+ it("period detection works correctly for summary triggers", () => {
1407
+ const monday = new Date(2026, 2, 30);
1408
+ const prevSaturday = new Date(2026, 2, 28);
1409
+ const periods = checkPeriodsNeeded(monday, prevSaturday);
1410
+ expect(periods.needsWeekly).toBe(true);
1411
+ expect(periods.previousWeek).toBe("2026-W13");
1412
+ });
1413
+ });
1414
+ ```
1415
+
1416
+ - [ ] **Step 4: Run all tests**
1417
+
1418
+ Run: `npx vitest run`
1419
+ Expected: All tests PASS
1420
+
1421
+ - [ ] **Step 5: Commit**
1422
+
1423
+ ```bash
1424
+ git add src/hooks/on-stop.ts tests/hooks/on-stop.test.ts tests/integration/full-flow.test.ts
1425
+ git commit -m "refactor: on-stop hook and integration tests to async StorageAdapter"
1426
+ ```
1427
+
1428
+ ---
1429
+
1430
+ ### Task 7: GitHub Auth — Device Flow (TDD)
1431
+
1432
+ **Files:**
1433
+ - Create: `src/core/github-auth.ts`
1434
+ - Create: `tests/core/github-auth.test.ts`
1435
+
1436
+ - [ ] **Step 1: Write failing tests for Device Flow**
1437
+
1438
+ ```typescript
1439
+ // tests/core/github-auth.test.ts
1440
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1441
+ import { requestDeviceCode, pollForToken, type DeviceCodeResponse } from "../../src/core/github-auth.js";
1442
+
1443
+ describe("github-auth", () => {
1444
+ beforeEach(() => {
1445
+ vi.stubGlobal("fetch", vi.fn());
1446
+ });
1447
+
1448
+ afterEach(() => {
1449
+ vi.restoreAllMocks();
1450
+ });
1451
+
1452
+ describe("requestDeviceCode", () => {
1453
+ it("returns device code response", async () => {
1454
+ const mockResponse = {
1455
+ device_code: "dc_123",
1456
+ user_code: "ABCD-1234",
1457
+ verification_uri: "https://github.com/login/device",
1458
+ expires_in: 900,
1459
+ interval: 5,
1460
+ };
1461
+ vi.mocked(fetch).mockResolvedValueOnce(
1462
+ new Response(JSON.stringify(mockResponse), {
1463
+ status: 200,
1464
+ headers: { "Content-Type": "application/json" },
1465
+ }),
1466
+ );
1467
+
1468
+ const result = await requestDeviceCode();
1469
+ expect(result.user_code).toBe("ABCD-1234");
1470
+ expect(result.device_code).toBe("dc_123");
1471
+ expect(result.verification_uri).toBe("https://github.com/login/device");
1472
+ });
1473
+
1474
+ it("throws on API error", async () => {
1475
+ vi.mocked(fetch).mockResolvedValueOnce(
1476
+ new Response("error", { status: 500 }),
1477
+ );
1478
+ await expect(requestDeviceCode()).rejects.toThrow();
1479
+ });
1480
+ });
1481
+
1482
+ describe("pollForToken", () => {
1483
+ it("returns token on success", async () => {
1484
+ vi.mocked(fetch).mockResolvedValueOnce(
1485
+ new Response(JSON.stringify({ access_token: "gho_abc123", token_type: "bearer" }), {
1486
+ status: 200,
1487
+ headers: { "Content-Type": "application/json" },
1488
+ }),
1489
+ );
1490
+
1491
+ const deviceCode: DeviceCodeResponse = {
1492
+ device_code: "dc_123",
1493
+ user_code: "ABCD-1234",
1494
+ verification_uri: "https://github.com/login/device",
1495
+ expires_in: 900,
1496
+ interval: 0,
1497
+ };
1498
+
1499
+ const token = await pollForToken(deviceCode, 1);
1500
+ expect(token).toBe("gho_abc123");
1501
+ });
1502
+
1503
+ it("retries on authorization_pending", async () => {
1504
+ vi.mocked(fetch)
1505
+ .mockResolvedValueOnce(
1506
+ new Response(JSON.stringify({ error: "authorization_pending" }), {
1507
+ status: 200,
1508
+ headers: { "Content-Type": "application/json" },
1509
+ }),
1510
+ )
1511
+ .mockResolvedValueOnce(
1512
+ new Response(JSON.stringify({ access_token: "gho_abc123", token_type: "bearer" }), {
1513
+ status: 200,
1514
+ headers: { "Content-Type": "application/json" },
1515
+ }),
1516
+ );
1517
+
1518
+ const deviceCode: DeviceCodeResponse = {
1519
+ device_code: "dc_123",
1520
+ user_code: "ABCD-1234",
1521
+ verification_uri: "https://github.com/login/device",
1522
+ expires_in: 900,
1523
+ interval: 0,
1524
+ };
1525
+
1526
+ const token = await pollForToken(deviceCode, 5);
1527
+ expect(token).toBe("gho_abc123");
1528
+ expect(fetch).toHaveBeenCalledTimes(2);
1529
+ });
1530
+
1531
+ it("throws on timeout (max attempts)", async () => {
1532
+ vi.mocked(fetch).mockResolvedValue(
1533
+ new Response(JSON.stringify({ error: "authorization_pending" }), {
1534
+ status: 200,
1535
+ headers: { "Content-Type": "application/json" },
1536
+ }),
1537
+ );
1538
+
1539
+ const deviceCode: DeviceCodeResponse = {
1540
+ device_code: "dc_123",
1541
+ user_code: "ABCD-1234",
1542
+ verification_uri: "https://github.com/login/device",
1543
+ expires_in: 900,
1544
+ interval: 0,
1545
+ };
1546
+
1547
+ await expect(pollForToken(deviceCode, 2)).rejects.toThrow("timed out");
1548
+ });
1549
+ });
1550
+ });
1551
+ ```
1552
+
1553
+ - [ ] **Step 2: Implement github-auth.ts**
1554
+
1555
+ ```typescript
1556
+ // src/core/github-auth.ts
1557
+ const GITHUB_CLIENT_ID = "PLACEHOLDER_CLIENT_ID"; // Replace after OAuth App registration
1558
+
1559
+ export interface DeviceCodeResponse {
1560
+ device_code: string;
1561
+ user_code: string;
1562
+ verification_uri: string;
1563
+ expires_in: number;
1564
+ interval: number;
1565
+ }
1566
+
1567
+ export async function requestDeviceCode(): Promise<DeviceCodeResponse> {
1568
+ const res = await fetch("https://github.com/login/device/code", {
1569
+ method: "POST",
1570
+ headers: {
1571
+ Accept: "application/json",
1572
+ "Content-Type": "application/json",
1573
+ },
1574
+ body: JSON.stringify({
1575
+ client_id: GITHUB_CLIENT_ID,
1576
+ scope: "repo",
1577
+ }),
1578
+ });
1579
+
1580
+ if (!res.ok) {
1581
+ throw new Error(`GitHub device code request failed: ${res.status}`);
1582
+ }
1583
+
1584
+ return res.json() as Promise<DeviceCodeResponse>;
1585
+ }
1586
+
1587
+ function sleep(ms: number): Promise<void> {
1588
+ return new Promise((resolve) => setTimeout(resolve, ms));
1589
+ }
1590
+
1591
+ export async function pollForToken(deviceCode: DeviceCodeResponse, maxAttempts: number = 180): Promise<string> {
1592
+ let interval = deviceCode.interval * 1000;
1593
+
1594
+ for (let i = 0; i < maxAttempts; i++) {
1595
+ if (interval > 0) await sleep(interval);
1596
+
1597
+ const res = await fetch("https://github.com/login/oauth/access_token", {
1598
+ method: "POST",
1599
+ headers: {
1600
+ Accept: "application/json",
1601
+ "Content-Type": "application/json",
1602
+ },
1603
+ body: JSON.stringify({
1604
+ client_id: GITHUB_CLIENT_ID,
1605
+ device_code: deviceCode.device_code,
1606
+ grant_type: "urn:ietf:params:oauth:grant-type:device_code",
1607
+ }),
1608
+ });
1609
+
1610
+ const data = await res.json() as Record<string, unknown>;
1611
+
1612
+ if (data.access_token) {
1613
+ return data.access_token as string;
1614
+ }
1615
+
1616
+ if (data.error === "slow_down") {
1617
+ interval += 5000;
1618
+ continue;
1619
+ }
1620
+
1621
+ if (data.error === "authorization_pending") {
1622
+ continue;
1623
+ }
1624
+
1625
+ throw new Error(`GitHub auth error: ${data.error}`);
1626
+ }
1627
+
1628
+ throw new Error("GitHub auth timed out waiting for authorization");
1629
+ }
1630
+ ```
1631
+
1632
+ - [ ] **Step 3: Run tests**
1633
+
1634
+ Run: `npx vitest run tests/core/github-auth.test.ts`
1635
+ Expected: All 5 tests PASS
1636
+
1637
+ - [ ] **Step 4: Commit**
1638
+
1639
+ ```bash
1640
+ git add src/core/github-auth.ts tests/core/github-auth.test.ts
1641
+ git commit -m "feat: add GitHub OAuth Device Flow authentication"
1642
+ ```
1643
+
1644
+ ---
1645
+
1646
+ ### Task 8: GitHubStorageAdapter (TDD)
1647
+
1648
+ **Files:**
1649
+ - Create: `src/core/github-storage.ts`
1650
+ - Create: `tests/core/github-storage.test.ts`
1651
+
1652
+ - [ ] **Step 1: Write failing tests with fetch mocks**
1653
+
1654
+ ```typescript
1655
+ // tests/core/github-storage.test.ts
1656
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
1657
+ import { GitHubStorageAdapter } from "../../src/core/github-storage.js";
1658
+
1659
+ describe("GitHubStorageAdapter", () => {
1660
+ let storage: GitHubStorageAdapter;
1661
+
1662
+ beforeEach(() => {
1663
+ vi.stubGlobal("fetch", vi.fn());
1664
+ storage = new GitHubStorageAdapter("testowner", "testrepo", "tok_123", "daily-review");
1665
+ });
1666
+
1667
+ afterEach(() => {
1668
+ vi.restoreAllMocks();
1669
+ });
1670
+
1671
+ describe("read", () => {
1672
+ it("returns decoded content for existing file", async () => {
1673
+ vi.mocked(fetch).mockResolvedValueOnce(
1674
+ new Response(JSON.stringify({
1675
+ content: Buffer.from("hello world").toString("base64"),
1676
+ sha: "abc123",
1677
+ }), { status: 200, headers: { "Content-Type": "application/json" } }),
1678
+ );
1679
+
1680
+ const result = await storage.read("test.txt");
1681
+ expect(result).toBe("hello world");
1682
+ expect(vi.mocked(fetch)).toHaveBeenCalledWith(
1683
+ "https://api.github.com/repos/testowner/testrepo/contents/daily-review/test.txt",
1684
+ expect.objectContaining({ method: "GET" }),
1685
+ );
1686
+ });
1687
+
1688
+ it("returns null for 404", async () => {
1689
+ vi.mocked(fetch).mockResolvedValueOnce(
1690
+ new Response("", { status: 404 }),
1691
+ );
1692
+ const result = await storage.read("nope.txt");
1693
+ expect(result).toBeNull();
1694
+ });
1695
+ });
1696
+
1697
+ describe("write", () => {
1698
+ it("creates new file without SHA", async () => {
1699
+ // First GET returns 404 (file doesn't exist)
1700
+ vi.mocked(fetch).mockResolvedValueOnce(
1701
+ new Response("", { status: 404 }),
1702
+ );
1703
+ // PUT creates file
1704
+ vi.mocked(fetch).mockResolvedValueOnce(
1705
+ new Response(JSON.stringify({ content: { sha: "new123" } }), { status: 201 }),
1706
+ );
1707
+
1708
+ await storage.write("new.txt", "content");
1709
+
1710
+ const putCall = vi.mocked(fetch).mock.calls[1];
1711
+ expect(putCall[0]).toContain("daily-review/new.txt");
1712
+ const body = JSON.parse(putCall[1]!.body as string);
1713
+ expect(body.content).toBe(Buffer.from("content").toString("base64"));
1714
+ expect(body.sha).toBeUndefined();
1715
+ });
1716
+
1717
+ it("updates existing file with SHA", async () => {
1718
+ // First GET returns existing file
1719
+ vi.mocked(fetch).mockResolvedValueOnce(
1720
+ new Response(JSON.stringify({
1721
+ content: Buffer.from("old").toString("base64"),
1722
+ sha: "old123",
1723
+ }), { status: 200, headers: { "Content-Type": "application/json" } }),
1724
+ );
1725
+ // PUT updates file
1726
+ vi.mocked(fetch).mockResolvedValueOnce(
1727
+ new Response(JSON.stringify({ content: { sha: "new123" } }), { status: 200 }),
1728
+ );
1729
+
1730
+ await storage.write("existing.txt", "new content");
1731
+
1732
+ const putCall = vi.mocked(fetch).mock.calls[1];
1733
+ const body = JSON.parse(putCall[1]!.body as string);
1734
+ expect(body.sha).toBe("old123");
1735
+ });
1736
+ });
1737
+
1738
+ describe("append", () => {
1739
+ it("creates file if not exists", async () => {
1740
+ // read returns null
1741
+ vi.mocked(fetch).mockResolvedValueOnce(
1742
+ new Response("", { status: 404 }),
1743
+ );
1744
+ // write: GET 404 + PUT 201
1745
+ vi.mocked(fetch).mockResolvedValueOnce(
1746
+ new Response("", { status: 404 }),
1747
+ );
1748
+ vi.mocked(fetch).mockResolvedValueOnce(
1749
+ new Response(JSON.stringify({ content: { sha: "new123" } }), { status: 201 }),
1750
+ );
1751
+
1752
+ await storage.append("log.txt", "line1\n");
1753
+ });
1754
+
1755
+ it("appends to existing file", async () => {
1756
+ // read returns existing content
1757
+ vi.mocked(fetch).mockResolvedValueOnce(
1758
+ new Response(JSON.stringify({
1759
+ content: Buffer.from("line1\n").toString("base64"),
1760
+ sha: "sha1",
1761
+ }), { status: 200, headers: { "Content-Type": "application/json" } }),
1762
+ );
1763
+ // write: GET existing + PUT update
1764
+ vi.mocked(fetch).mockResolvedValueOnce(
1765
+ new Response(JSON.stringify({
1766
+ content: Buffer.from("line1\n").toString("base64"),
1767
+ sha: "sha1",
1768
+ }), { status: 200, headers: { "Content-Type": "application/json" } }),
1769
+ );
1770
+ vi.mocked(fetch).mockResolvedValueOnce(
1771
+ new Response(JSON.stringify({ content: { sha: "sha2" } }), { status: 200 }),
1772
+ );
1773
+
1774
+ await storage.append("log.txt", "line2\n");
1775
+ });
1776
+ });
1777
+
1778
+ describe("exists", () => {
1779
+ it("returns true for 200", async () => {
1780
+ vi.mocked(fetch).mockResolvedValueOnce(
1781
+ new Response(JSON.stringify({ sha: "abc" }), { status: 200, headers: { "Content-Type": "application/json" } }),
1782
+ );
1783
+ expect(await storage.exists("file.txt")).toBe(true);
1784
+ });
1785
+
1786
+ it("returns false for 404", async () => {
1787
+ vi.mocked(fetch).mockResolvedValueOnce(
1788
+ new Response("", { status: 404 }),
1789
+ );
1790
+ expect(await storage.exists("nope.txt")).toBe(false);
1791
+ });
1792
+ });
1793
+
1794
+ describe("list", () => {
1795
+ it("returns file names from directory listing", async () => {
1796
+ vi.mocked(fetch).mockResolvedValueOnce(
1797
+ new Response(JSON.stringify([
1798
+ { name: "a.txt", type: "file" },
1799
+ { name: "b.md", type: "file" },
1800
+ { name: "subdir", type: "dir" },
1801
+ ]), { status: 200, headers: { "Content-Type": "application/json" } }),
1802
+ );
1803
+
1804
+ const result = await storage.list("mydir");
1805
+ expect(result).toEqual(["a.txt", "b.md", "subdir"]);
1806
+ });
1807
+
1808
+ it("returns empty array for 404", async () => {
1809
+ vi.mocked(fetch).mockResolvedValueOnce(
1810
+ new Response("", { status: 404 }),
1811
+ );
1812
+ expect(await storage.list("nope")).toEqual([]);
1813
+ });
1814
+ });
1815
+
1816
+ describe("mkdir", () => {
1817
+ it("is a no-op (GitHub creates dirs implicitly)", async () => {
1818
+ await storage.mkdir("some/dir");
1819
+ expect(fetch).not.toHaveBeenCalled();
1820
+ });
1821
+ });
1822
+
1823
+ describe("isDirectory", () => {
1824
+ it("returns true when API returns array", async () => {
1825
+ vi.mocked(fetch).mockResolvedValueOnce(
1826
+ new Response(JSON.stringify([{ name: "file.txt" }]), { status: 200, headers: { "Content-Type": "application/json" } }),
1827
+ );
1828
+ expect(await storage.isDirectory("mydir")).toBe(true);
1829
+ });
1830
+
1831
+ it("returns false when API returns object (file)", async () => {
1832
+ vi.mocked(fetch).mockResolvedValueOnce(
1833
+ new Response(JSON.stringify({ type: "file", sha: "abc" }), { status: 200, headers: { "Content-Type": "application/json" } }),
1834
+ );
1835
+ expect(await storage.isDirectory("file.txt")).toBe(false);
1836
+ });
1837
+
1838
+ it("returns false for 404", async () => {
1839
+ vi.mocked(fetch).mockResolvedValueOnce(
1840
+ new Response("", { status: 404 }),
1841
+ );
1842
+ expect(await storage.isDirectory("nope")).toBe(false);
1843
+ });
1844
+ });
1845
+ });
1846
+ ```
1847
+
1848
+ - [ ] **Step 2: Implement GitHubStorageAdapter**
1849
+
1850
+ ```typescript
1851
+ // src/core/github-storage.ts
1852
+ import type { StorageAdapter } from "./storage.js";
1853
+
1854
+ export class GitHubStorageAdapter implements StorageAdapter {
1855
+ private baseUrl: string;
1856
+ private headers: Record<string, string>;
1857
+
1858
+ constructor(
1859
+ private owner: string,
1860
+ private repo: string,
1861
+ private token: string,
1862
+ private basePath: string,
1863
+ ) {
1864
+ this.baseUrl = `https://api.github.com/repos/${owner}/${repo}/contents`;
1865
+ this.headers = {
1866
+ Authorization: `Bearer ${token}`,
1867
+ Accept: "application/vnd.github.v3+json",
1868
+ "Content-Type": "application/json",
1869
+ };
1870
+ }
1871
+
1872
+ private getUrl(path: string): string {
1873
+ return `${this.baseUrl}/${this.basePath}/${path}`;
1874
+ }
1875
+
1876
+ private async getSha(path: string): Promise<string | null> {
1877
+ const res = await fetch(this.getUrl(path), { method: "GET", headers: this.headers });
1878
+ if (res.status === 404) return null;
1879
+ const data = await res.json() as Record<string, unknown>;
1880
+ return (data.sha as string) || null;
1881
+ }
1882
+
1883
+ async read(path: string): Promise<string | null> {
1884
+ const res = await fetch(this.getUrl(path), { method: "GET", headers: this.headers });
1885
+ if (res.status === 404) return null;
1886
+ const data = await res.json() as Record<string, unknown>;
1887
+ const content = data.content as string;
1888
+ return Buffer.from(content, "base64").toString("utf-8");
1889
+ }
1890
+
1891
+ async write(path: string, content: string): Promise<void> {
1892
+ const sha = await this.getSha(path);
1893
+ const body: Record<string, unknown> = {
1894
+ message: `update ${path}`,
1895
+ content: Buffer.from(content).toString("base64"),
1896
+ };
1897
+ if (sha) body.sha = sha;
1898
+
1899
+ const res = await fetch(this.getUrl(path), {
1900
+ method: "PUT",
1901
+ headers: this.headers,
1902
+ body: JSON.stringify(body),
1903
+ });
1904
+
1905
+ if (!res.ok && res.status === 409) {
1906
+ // Conflict — retry once with fresh SHA
1907
+ const freshSha = await this.getSha(path);
1908
+ if (freshSha) body.sha = freshSha;
1909
+ await fetch(this.getUrl(path), {
1910
+ method: "PUT",
1911
+ headers: this.headers,
1912
+ body: JSON.stringify(body),
1913
+ });
1914
+ }
1915
+ }
1916
+
1917
+ async append(path: string, content: string): Promise<void> {
1918
+ const existing = await this.read(path);
1919
+ const newContent = existing ? existing + content : content;
1920
+ await this.write(path, newContent);
1921
+ }
1922
+
1923
+ async exists(path: string): Promise<boolean> {
1924
+ const res = await fetch(this.getUrl(path), { method: "GET", headers: this.headers });
1925
+ return res.status !== 404;
1926
+ }
1927
+
1928
+ async list(dir: string): Promise<string[]> {
1929
+ const res = await fetch(this.getUrl(dir), { method: "GET", headers: this.headers });
1930
+ if (res.status === 404) return [];
1931
+ const data = await res.json() as Array<{ name: string }>;
1932
+ if (!Array.isArray(data)) return [];
1933
+ return data.map((entry) => entry.name);
1934
+ }
1935
+
1936
+ async mkdir(_dir: string): Promise<void> {
1937
+ // GitHub creates directories implicitly when files are created
1938
+ }
1939
+
1940
+ async isDirectory(path: string): Promise<boolean> {
1941
+ const res = await fetch(this.getUrl(path), { method: "GET", headers: this.headers });
1942
+ if (res.status === 404) return false;
1943
+ const data = await res.json();
1944
+ return Array.isArray(data);
1945
+ }
1946
+ }
1947
+ ```
1948
+
1949
+ - [ ] **Step 3: Wire GitHubStorageAdapter into config.ts**
1950
+
1951
+ Update `createStorageAdapter` in `src/core/config.ts`:
1952
+
1953
+ Replace the `"github"` case:
1954
+ ```typescript
1955
+ if (config.storage.type === "github") {
1956
+ const { GitHubStorageAdapter } = await import("./github-storage.js");
1957
+ const g = config.storage.github!;
1958
+ return new GitHubStorageAdapter(g.owner, g.repo, g.token, g.basePath);
1959
+ }
1960
+ ```
1961
+
1962
+ Note: `createStorageAdapter` becomes async:
1963
+ ```typescript
1964
+ export async function createStorageAdapter(config: Config): Promise<StorageAdapter> {
1965
+ ```
1966
+
1967
+ Update all callers (on-stop.ts, tests) to `await createStorageAdapter(config)`.
1968
+
1969
+ - [ ] **Step 4: Run all tests**
1970
+
1971
+ Run: `npx vitest run`
1972
+ Expected: All tests PASS
1973
+
1974
+ - [ ] **Step 5: Commit**
1975
+
1976
+ ```bash
1977
+ git add src/core/github-storage.ts tests/core/github-storage.test.ts src/core/config.ts
1978
+ git commit -m "feat: add GitHubStorageAdapter with Contents API"
1979
+ ```
1980
+
1981
+ ---
1982
+
1983
+ ### Task 9: CLI Storage Scripts for Agent Prompts
1984
+
1985
+ **Files:**
1986
+ - Create: `src/cli/storage-cli.ts`
1987
+
1988
+ - [ ] **Step 1: Implement storage CLI**
1989
+
1990
+ ```typescript
1991
+ // src/cli/storage-cli.ts
1992
+ import { loadConfig, createStorageAdapter } from "../core/config.js";
1993
+
1994
+ async function main() {
1995
+ const [command, ...args] = process.argv.slice(2);
1996
+ const config = loadConfig();
1997
+ if (!config) {
1998
+ process.stderr.write("config not found\n");
1999
+ process.exit(1);
2000
+ }
2001
+
2002
+ const storage = await createStorageAdapter(config);
2003
+
2004
+ switch (command) {
2005
+ case "read": {
2006
+ const content = await storage.read(args[0]);
2007
+ if (content !== null) process.stdout.write(content);
2008
+ break;
2009
+ }
2010
+ case "write": {
2011
+ let data = "";
2012
+ process.stdin.setEncoding("utf-8");
2013
+ for await (const chunk of process.stdin) {
2014
+ data += chunk;
2015
+ }
2016
+ await storage.write(args[0], data);
2017
+ break;
2018
+ }
2019
+ case "append": {
2020
+ let data = "";
2021
+ process.stdin.setEncoding("utf-8");
2022
+ for await (const chunk of process.stdin) {
2023
+ data += chunk;
2024
+ }
2025
+ await storage.append(args[0], data);
2026
+ break;
2027
+ }
2028
+ case "list": {
2029
+ const entries = await storage.list(args[0]);
2030
+ process.stdout.write(entries.join("\n") + "\n");
2031
+ break;
2032
+ }
2033
+ case "exists": {
2034
+ const exists = await storage.exists(args[0]);
2035
+ process.stdout.write(exists ? "true\n" : "false\n");
2036
+ process.exit(exists ? 0 : 1);
2037
+ break;
2038
+ }
2039
+ default:
2040
+ process.stderr.write(`Unknown command: ${command}\nUsage: storage-cli <read|write|append|list|exists> <path>\n`);
2041
+ process.exit(1);
2042
+ }
2043
+ }
2044
+
2045
+ main().catch((err) => {
2046
+ process.stderr.write(`Error: ${err.message}\n`);
2047
+ process.exit(1);
2048
+ });
2049
+ ```
2050
+
2051
+ - [ ] **Step 2: Update tsup.config.ts to include new entry points**
2052
+
2053
+ ```typescript
2054
+ // tsup.config.ts
2055
+ import { defineConfig } from "tsup";
2056
+
2057
+ export default defineConfig({
2058
+ entry: ["src/hooks/on-stop.ts", "src/cli/storage-cli.ts"],
2059
+ format: ["esm"],
2060
+ target: "node20",
2061
+ outDir: "dist",
2062
+ clean: true,
2063
+ sourcemap: true,
2064
+ splitting: false,
2065
+ bundle: true,
2066
+ });
2067
+ ```
2068
+
2069
+ Note: outDir changes from `dist/hooks` to `dist` since we now have multiple entry points. Update `hooks/hooks.json` to reference `dist/on-stop.js` instead of `dist/hooks/on-stop.js`.
2070
+
2071
+ - [ ] **Step 3: Update hooks.json**
2072
+
2073
+ ```json
2074
+ {
2075
+ "hooks": {
2076
+ "Stop": [
2077
+ {
2078
+ "hooks": [
2079
+ {
2080
+ "type": "command",
2081
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/on-stop.js\"",
2082
+ "async": true,
2083
+ "timeout": 10
2084
+ }
2085
+ ]
2086
+ }
2087
+ ],
2088
+ "SessionEnd": [
2089
+ {
2090
+ "hooks": [
2091
+ {
2092
+ "type": "agent",
2093
+ "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}. The plugin root is: ${CLAUDE_PLUGIN_ROOT}",
2094
+ "timeout": 120
2095
+ }
2096
+ ]
2097
+ }
2098
+ ],
2099
+ "SessionStart": [
2100
+ {
2101
+ "hooks": [
2102
+ {
2103
+ "type": "agent",
2104
+ "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}. The plugin root is: ${CLAUDE_PLUGIN_ROOT}",
2105
+ "timeout": 180
2106
+ }
2107
+ ]
2108
+ }
2109
+ ]
2110
+ }
2111
+ }
2112
+ ```
2113
+
2114
+ - [ ] **Step 4: Build and verify**
2115
+
2116
+ Run: `npm run build`
2117
+ Expected: `dist/on-stop.js` and `dist/storage-cli.js` created
2118
+
2119
+ - [ ] **Step 5: Commit**
2120
+
2121
+ ```bash
2122
+ git add src/cli/storage-cli.ts tsup.config.ts hooks/hooks.json
2123
+ git commit -m "feat: add storage CLI for agent prompts, update build config"
2124
+ ```
2125
+
2126
+ ---
2127
+
2128
+ ### Task 10: Setup Skill + Agent Prompts Update
2129
+
2130
+ **Files:**
2131
+ - Modify: `skills/daily-review-setup.md`
2132
+ - Modify: `prompts/session-end.md`
2133
+ - Modify: `prompts/session-start.md`
2134
+
2135
+ - [ ] **Step 1: Update setup skill with storage selection + GitHub auth**
2136
+
2137
+ Rewrite `skills/daily-review-setup.md` to add:
2138
+ - Step 0: "저장소를 어디에 둘까요? (1) 로컬 폴더 (2) GitHub 저장소"
2139
+ - If local: existing vault path flow
2140
+ - If github: Device Flow auth → repo selection (existing or new) → done
2141
+ - Keep profile and periods steps
2142
+
2143
+ The setup skill must instruct the agent to use `node dist/storage-cli.js` for GitHub operations when verifying connectivity.
2144
+
2145
+ - [ ] **Step 2: Update session-end prompt**
2146
+
2147
+ Update `prompts/session-end.md` to:
2148
+ - Read config and check `storage.type`
2149
+ - If `local`: use Read/Write tools directly
2150
+ - If `github`: use `node "${CLAUDE_PLUGIN_ROOT}/dist/storage-cli.js" <command> <path>` via Bash
2151
+
2152
+ - [ ] **Step 3: Update session-start prompt**
2153
+
2154
+ Update `prompts/session-start.md` similarly to use storage-cli for GitHub storage.
2155
+
2156
+ - [ ] **Step 4: Commit**
2157
+
2158
+ ```bash
2159
+ git add skills/daily-review-setup.md prompts/session-end.md prompts/session-start.md
2160
+ git commit -m "feat: update setup skill and agent prompts for storage selection"
2161
+ ```
2162
+
2163
+ ---
2164
+
2165
+ ### Task 11: README Update
2166
+
2167
+ **Files:**
2168
+ - Modify: `README.md`
2169
+
2170
+ - [ ] **Step 1: Update README**
2171
+
2172
+ Add GitHub storage section:
2173
+ - New setup option for GitHub
2174
+ - Device Flow auth description
2175
+ - Configuration example for GitHub
2176
+ - Note about `client_id` placeholder
2177
+
2178
+ - [ ] **Step 2: Final build and test**
2179
+
2180
+ Run: `npm run build && npm test`
2181
+ Expected: Build succeeds, all tests pass
2182
+
2183
+ - [ ] **Step 3: Commit**
2184
+
2185
+ ```bash
2186
+ git add README.md
2187
+ git commit -m "docs: update README with GitHub storage option"
2188
+ ```
2189
+
2190
+ ---
2191
+
2192
+ ## Summary
2193
+
2194
+ | Task | Module | Tests | Description |
2195
+ |------|--------|-------|-------------|
2196
+ | 1 | StorageAdapter + LocalStorageAdapter | 14 | Interface + fs wrapper |
2197
+ | 2 | Config refactor | 13 | New schema, migration, adapter factory |
2198
+ | 3 | Vault refactor | 13 | Relative paths, async, adapter |
2199
+ | 4 | Raw Logger refactor | 7 | Async, adapter injection |
2200
+ | 5 | Merge refactor | 10 | Async, adapter injection |
2201
+ | 6 | On-Stop + Integration refactor | 6 | Async entry point + full flow |
2202
+ | 7 | GitHub Auth | 5 | OAuth Device Flow |
2203
+ | 8 | GitHubStorageAdapter | 12 | GitHub Contents API |
2204
+ | 9 | CLI + Build | - | storage-cli, tsup, hooks.json |
2205
+ | 10 | Setup + Prompts | - | Storage selection, agent instructions |
2206
+ | 11 | README | - | Documentation update |
2207
+ | **Total** | | **~80** | |