@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.
- package/.claude-plugin/marketplace.json +29 -0
- package/.github/workflows/publish.yml +24 -0
- package/.github/workflows/update-marketplace.yml +28 -0
- package/docs/superpowers/plans/2026-03-28-claude-daily-review-plan.md +2130 -0
- package/docs/superpowers/plans/2026-03-28-no-build-refactor-plan.md +1158 -0
- package/docs/superpowers/plans/2026-03-28-storage-adapter-refactor-plan.md +2207 -0
- package/docs/superpowers/specs/2026-03-28-claude-daily-review-design.md +582 -0
- package/docs/superpowers/specs/2026-03-28-no-build-refactor-design.md +333 -0
- package/docs/superpowers/specs/2026-03-28-storage-adapter-refactor-design.md +365 -0
- package/hooks/hooks.json +3 -2
- package/hooks/on-stop.mjs +24 -0
- package/hooks/run-hook.cmd +27 -0
- package/hooks/session-start-check +27 -0
- package/lib/config.mjs +122 -0
- package/lib/github-auth.mjs +44 -0
- package/lib/github-storage.mjs +81 -0
- package/lib/merge.mjs +51 -0
- package/lib/periods.mjs +82 -0
- package/lib/raw-logger.mjs +19 -0
- package/lib/storage-cli.mjs +48 -0
- package/lib/storage.mjs +63 -0
- package/lib/types.d.ts +64 -0
- package/lib/vault.mjs +43 -0
- package/package.json +3 -23
- package/prompts/session-end.md +5 -5
- package/prompts/session-start.md +5 -5
- package/dist/on-session-start-check.js +0 -56
- package/dist/on-session-start-check.js.map +0 -1
- package/dist/on-stop.js +0 -274
- package/dist/on-stop.js.map +0 -1
- package/dist/storage-cli.js +0 -267
- package/dist/storage-cli.js.map +0 -1
|
@@ -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** | |
|