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