@ricky-stevens/context-guardian 2.1.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.
Files changed (64) hide show
  1. package/.claude-plugin/marketplace.json +29 -0
  2. package/.claude-plugin/plugin.json +63 -0
  3. package/.github/workflows/ci.yml +66 -0
  4. package/CLAUDE.md +132 -0
  5. package/LICENSE +21 -0
  6. package/README.md +362 -0
  7. package/biome.json +34 -0
  8. package/bun.lock +31 -0
  9. package/hooks/precompact.mjs +73 -0
  10. package/hooks/session-start.mjs +133 -0
  11. package/hooks/stop.mjs +172 -0
  12. package/hooks/submit.mjs +133 -0
  13. package/lib/checkpoint.mjs +258 -0
  14. package/lib/compact-cli.mjs +124 -0
  15. package/lib/compact-output.mjs +350 -0
  16. package/lib/config.mjs +40 -0
  17. package/lib/content.mjs +33 -0
  18. package/lib/diagnostics.mjs +221 -0
  19. package/lib/estimate.mjs +254 -0
  20. package/lib/extract-helpers.mjs +869 -0
  21. package/lib/handoff.mjs +329 -0
  22. package/lib/logger.mjs +34 -0
  23. package/lib/mcp-tools.mjs +200 -0
  24. package/lib/paths.mjs +90 -0
  25. package/lib/stats.mjs +81 -0
  26. package/lib/statusline.mjs +123 -0
  27. package/lib/synthetic-session.mjs +273 -0
  28. package/lib/tokens.mjs +170 -0
  29. package/lib/tool-summary.mjs +399 -0
  30. package/lib/transcript.mjs +939 -0
  31. package/lib/trim.mjs +158 -0
  32. package/package.json +22 -0
  33. package/skills/compact/SKILL.md +20 -0
  34. package/skills/config/SKILL.md +70 -0
  35. package/skills/handoff/SKILL.md +26 -0
  36. package/skills/prune/SKILL.md +20 -0
  37. package/skills/stats/SKILL.md +100 -0
  38. package/sonar-project.properties +12 -0
  39. package/test/checkpoint.test.mjs +171 -0
  40. package/test/compact-cli.test.mjs +230 -0
  41. package/test/compact-output.test.mjs +284 -0
  42. package/test/compaction-e2e.test.mjs +809 -0
  43. package/test/content.test.mjs +86 -0
  44. package/test/diagnostics.test.mjs +188 -0
  45. package/test/edge-cases.test.mjs +543 -0
  46. package/test/estimate.test.mjs +262 -0
  47. package/test/extract-helpers-coverage.test.mjs +333 -0
  48. package/test/extract-helpers.test.mjs +234 -0
  49. package/test/handoff.test.mjs +738 -0
  50. package/test/integration.test.mjs +582 -0
  51. package/test/logger.test.mjs +70 -0
  52. package/test/manual-compaction-test.md +426 -0
  53. package/test/mcp-tools.test.mjs +443 -0
  54. package/test/paths.test.mjs +250 -0
  55. package/test/quick-compaction-test.md +191 -0
  56. package/test/stats.test.mjs +88 -0
  57. package/test/statusline.test.mjs +222 -0
  58. package/test/submit.test.mjs +232 -0
  59. package/test/synthetic-session.test.mjs +600 -0
  60. package/test/tokens.test.mjs +293 -0
  61. package/test/tool-summary.test.mjs +771 -0
  62. package/test/transcript-coverage.test.mjs +369 -0
  63. package/test/transcript.test.mjs +596 -0
  64. package/test/trim.test.mjs +356 -0
@@ -0,0 +1,443 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ isSerenaReadTool,
5
+ isSerenaWriteTool,
6
+ summarizeMcpToolUse,
7
+ } from "../lib/mcp-tools.mjs";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function chars(n, ch = "x") {
14
+ return ch.repeat(n);
15
+ }
16
+
17
+ function indent(text, n = 4) {
18
+ const pad = " ".repeat(n);
19
+ return text
20
+ .split("\n")
21
+ .map((l) => pad + l)
22
+ .join("\n");
23
+ }
24
+
25
+ function unknownFallback(name, input) {
26
+ return `→ \`${name}\`: ${JSON.stringify(input).slice(0, 200)}`;
27
+ }
28
+
29
+ // ===========================================================================
30
+ // summarizeMcpToolUse
31
+ // ===========================================================================
32
+
33
+ describe("summarizeMcpToolUse", () => {
34
+ // ── Serena tools ─────────────────────────────────────────────────────
35
+
36
+ describe("Serena tools", () => {
37
+ it("replace_symbol_body preserves code body", () => {
38
+ const result = summarizeMcpToolUse(
39
+ "mcp__serena__replace_symbol_body",
40
+ {
41
+ name_path: "Foo.bar",
42
+ relative_path: "src/foo.mjs",
43
+ new_body: "return 42;",
44
+ },
45
+ indent,
46
+ unknownFallback,
47
+ );
48
+ assert.ok(result.includes("replaced `Foo.bar`"));
49
+ assert.ok(result.includes("`src/foo.mjs`"));
50
+ assert.ok(result.includes("return 42;"));
51
+ });
52
+
53
+ it("replace_symbol_body trims large bodies", () => {
54
+ const big = chars(5000);
55
+ const result = summarizeMcpToolUse(
56
+ "mcp__serena__replace_symbol_body",
57
+ { name_path: "X", relative_path: "a.js", new_body: big },
58
+ indent,
59
+ unknownFallback,
60
+ );
61
+ assert.ok(result.includes("trimmed from middle"));
62
+ assert.ok(result.length < big.length);
63
+ });
64
+
65
+ it("insert_after_symbol preserves code", () => {
66
+ const result = summarizeMcpToolUse(
67
+ "mcp__serena__insert_after_symbol",
68
+ { name_path: "MyClass", code: "function helper() {}" },
69
+ indent,
70
+ unknownFallback,
71
+ );
72
+ assert.ok(result.includes("inserted after `MyClass`"));
73
+ assert.ok(result.includes("function helper() {}"));
74
+ });
75
+
76
+ it("insert_before_symbol says 'before'", () => {
77
+ const result = summarizeMcpToolUse(
78
+ "mcp__serena__insert_before_symbol",
79
+ { name_path: "MyClass", code: "const x = 1;" },
80
+ indent,
81
+ unknownFallback,
82
+ );
83
+ assert.ok(result.includes("inserted before `MyClass`"));
84
+ });
85
+
86
+ it("rename_symbol shows old and new names", () => {
87
+ const result = summarizeMcpToolUse(
88
+ "mcp__serena__rename_symbol",
89
+ { old_name: "oldFn", new_name: "newFn" },
90
+ indent,
91
+ unknownFallback,
92
+ );
93
+ assert.ok(result.includes("`oldFn`"));
94
+ assert.ok(result.includes("`newFn`"));
95
+ });
96
+
97
+ it("write_memory emits note with name", () => {
98
+ const result = summarizeMcpToolUse(
99
+ "mcp__serena__write_memory",
100
+ { name: "arch-notes" },
101
+ indent,
102
+ unknownFallback,
103
+ );
104
+ assert.ok(result.includes("wrote memory"));
105
+ assert.ok(result.includes("arch-notes"));
106
+ });
107
+
108
+ it("edit_memory emits note with name", () => {
109
+ const result = summarizeMcpToolUse(
110
+ "mcp__serena__edit_memory",
111
+ { name: "my-mem" },
112
+ indent,
113
+ unknownFallback,
114
+ );
115
+ assert.ok(result.includes("wrote memory"));
116
+ assert.ok(result.includes("my-mem"));
117
+ });
118
+
119
+ const readOnlyOps = [
120
+ "read_memory",
121
+ "list_memories",
122
+ "rename_memory",
123
+ "delete_memory",
124
+ ];
125
+ for (const op of readOnlyOps) {
126
+ it(`${op} emits note-only`, () => {
127
+ const result = summarizeMcpToolUse(
128
+ `mcp__serena__${op}`,
129
+ {},
130
+ indent,
131
+ unknownFallback,
132
+ );
133
+ assert.ok(result.includes(op.replace(/_/g, " ")));
134
+ });
135
+ }
136
+
137
+ const removedOps = [
138
+ "onboarding",
139
+ "check_onboarding_performed",
140
+ "initial_instructions",
141
+ ];
142
+ for (const op of removedOps) {
143
+ it(`${op} returns null (noise)`, () => {
144
+ const result = summarizeMcpToolUse(
145
+ `mcp__serena__${op}`,
146
+ {},
147
+ indent,
148
+ unknownFallback,
149
+ );
150
+ assert.equal(result, null);
151
+ });
152
+ }
153
+
154
+ it("find_symbol emits note with query", () => {
155
+ const result = summarizeMcpToolUse(
156
+ "mcp__serena__find_symbol",
157
+ { name_path: "extractConversation" },
158
+ indent,
159
+ unknownFallback,
160
+ );
161
+ assert.ok(result.includes("find symbol"));
162
+ assert.ok(result.includes("extractConversation"));
163
+ });
164
+
165
+ it("get_symbols_overview emits note with path", () => {
166
+ const result = summarizeMcpToolUse(
167
+ "mcp__serena__get_symbols_overview",
168
+ { relative_path: "lib/" },
169
+ indent,
170
+ unknownFallback,
171
+ );
172
+ assert.ok(result.includes("get symbols overview"));
173
+ assert.ok(result.includes("lib/"));
174
+ });
175
+
176
+ it("handles missing input fields gracefully", () => {
177
+ const result = summarizeMcpToolUse(
178
+ "mcp__serena__replace_symbol_body",
179
+ {},
180
+ indent,
181
+ unknownFallback,
182
+ );
183
+ assert.ok(result.includes("replaced `unknown`"));
184
+ });
185
+ });
186
+
187
+ // ── Sequential thinking ──────────────────────────────────────────────
188
+
189
+ describe("Sequential thinking", () => {
190
+ it("includes step number and thought", () => {
191
+ const result = summarizeMcpToolUse(
192
+ "mcp__sequential-thinking__sequentialthinking",
193
+ { thought: "Consider trade-offs.", thoughtNumber: 2, totalThoughts: 5 },
194
+ indent,
195
+ unknownFallback,
196
+ );
197
+ assert.ok(result.includes("step 2/5"));
198
+ assert.ok(result.includes("Consider trade-offs."));
199
+ });
200
+
201
+ it("trims long thoughts", () => {
202
+ const long = chars(4000);
203
+ const result = summarizeMcpToolUse(
204
+ "mcp__sequential-thinking__sequentialthinking",
205
+ { thought: long, thoughtNumber: 1, totalThoughts: 1 },
206
+ indent,
207
+ unknownFallback,
208
+ );
209
+ assert.ok(result.includes("trimmed from middle"));
210
+ assert.ok(result.length < long.length);
211
+ });
212
+
213
+ it("handles missing fields with ? placeholders", () => {
214
+ const result = summarizeMcpToolUse(
215
+ "mcp__sequential-thinking__sequentialthinking",
216
+ {},
217
+ indent,
218
+ unknownFallback,
219
+ );
220
+ assert.ok(result.includes("step ?/?"));
221
+ });
222
+ });
223
+
224
+ // ── Context-mode ─────────────────────────────────────────────────────
225
+
226
+ describe("Context-mode", () => {
227
+ it("execute shows language", () => {
228
+ const result = summarizeMcpToolUse(
229
+ "mcp__plugin_context-mode_context-mode__execute",
230
+ { language: "python" },
231
+ indent,
232
+ unknownFallback,
233
+ );
234
+ assert.ok(result.includes("Context-mode:"));
235
+ assert.ok(result.includes("python"));
236
+ });
237
+
238
+ it("batch_execute shows command count", () => {
239
+ const result = summarizeMcpToolUse(
240
+ "mcp__plugin_context-mode_context-mode__batch_execute",
241
+ { commands: [{ cmd: "a" }, { cmd: "b" }] },
242
+ indent,
243
+ unknownFallback,
244
+ );
245
+ assert.ok(result.includes("2 commands"));
246
+ });
247
+
248
+ it("batch_execute with non-array shows ?", () => {
249
+ const result = summarizeMcpToolUse(
250
+ "mcp__plugin_context-mode_context-mode__batch_execute",
251
+ { commands: "not-an-array" },
252
+ indent,
253
+ unknownFallback,
254
+ );
255
+ assert.ok(result.includes("? commands"));
256
+ });
257
+
258
+ it("search shows queries", () => {
259
+ const result = summarizeMcpToolUse(
260
+ "mcp__plugin_context-mode_context-mode__search",
261
+ { queries: ["foo", "bar"] },
262
+ indent,
263
+ unknownFallback,
264
+ );
265
+ assert.ok(result.includes("searched foo, bar"));
266
+ });
267
+
268
+ it("fetch_and_index shows URL", () => {
269
+ const result = summarizeMcpToolUse(
270
+ "mcp__plugin_context-mode_context-mode__fetch_and_index",
271
+ { url: "https://example.com" },
272
+ indent,
273
+ unknownFallback,
274
+ );
275
+ assert.ok(result.includes("https://example.com"));
276
+ });
277
+
278
+ it("stats returns null (noise)", () => {
279
+ const result = summarizeMcpToolUse(
280
+ "mcp__plugin_context-mode_context-mode__stats",
281
+ {},
282
+ indent,
283
+ unknownFallback,
284
+ );
285
+ assert.equal(result, null);
286
+ });
287
+
288
+ it("index returns null (noise)", () => {
289
+ const result = summarizeMcpToolUse(
290
+ "mcp__plugin_context-mode_context-mode__index",
291
+ {},
292
+ indent,
293
+ unknownFallback,
294
+ );
295
+ assert.equal(result, null);
296
+ });
297
+ });
298
+
299
+ // ── Context7 ─────────────────────────────────────────────────────────
300
+
301
+ describe("Context7", () => {
302
+ it("shows library name", () => {
303
+ const result = summarizeMcpToolUse(
304
+ "mcp__context7__query-docs",
305
+ { libraryName: "react" },
306
+ indent,
307
+ unknownFallback,
308
+ );
309
+ assert.ok(result.includes("Docs:"));
310
+ assert.ok(result.includes("react"));
311
+ });
312
+
313
+ it("falls back to query field", () => {
314
+ const result = summarizeMcpToolUse(
315
+ "mcp__context7__resolve-library-id",
316
+ { query: "next.js" },
317
+ indent,
318
+ unknownFallback,
319
+ );
320
+ assert.ok(result.includes("Docs:"));
321
+ assert.ok(result.includes("next.js"));
322
+ });
323
+
324
+ it("falls back to tool name when no input fields", () => {
325
+ const result = summarizeMcpToolUse(
326
+ "mcp__context7__query-docs",
327
+ {},
328
+ indent,
329
+ unknownFallback,
330
+ );
331
+ assert.ok(result.includes("Docs:"));
332
+ assert.ok(result.includes("mcp__context7__query-docs"));
333
+ });
334
+ });
335
+
336
+ // ── Unknown MCP tool ─────────────────────────────────────────────────
337
+
338
+ describe("Unknown MCP tool", () => {
339
+ it("delegates to unknownFallback", () => {
340
+ const result = summarizeMcpToolUse(
341
+ "mcp__custom__my_tool",
342
+ { key: "val" },
343
+ indent,
344
+ unknownFallback,
345
+ );
346
+ assert.ok(result.includes("`mcp__custom__my_tool`"));
347
+ assert.ok(result.includes('"key"'));
348
+ });
349
+ });
350
+ });
351
+
352
+ // ===========================================================================
353
+ // isSerenaReadTool
354
+ // ===========================================================================
355
+
356
+ describe("isSerenaReadTool", () => {
357
+ const readTools = [
358
+ "mcp__serena__find_symbol",
359
+ "mcp__serena__get_symbols_overview",
360
+ "mcp__serena__search_for_pattern",
361
+ "mcp__serena__list_dir",
362
+ "mcp__serena__find_file",
363
+ "mcp__serena__find_referencing_symbols",
364
+ "mcp__serena__read_memory",
365
+ "mcp__serena__list_memories",
366
+ ];
367
+ for (const name of readTools) {
368
+ it(`returns true for ${name.split("__").pop()}`, () => {
369
+ assert.equal(isSerenaReadTool(name), true);
370
+ });
371
+ }
372
+
373
+ const notReadTools = [
374
+ "mcp__serena__replace_symbol_body",
375
+ "mcp__serena__insert_after_symbol",
376
+ "mcp__serena__rename_symbol",
377
+ "mcp__serena__write_memory",
378
+ "mcp__serena__onboarding",
379
+ ];
380
+ for (const name of notReadTools) {
381
+ it(`returns false for ${name.split("__").pop()}`, () => {
382
+ assert.equal(isSerenaReadTool(name), false);
383
+ });
384
+ }
385
+
386
+ it("returns false for null", () => {
387
+ assert.equal(isSerenaReadTool(null), false);
388
+ });
389
+
390
+ it("returns false for undefined", () => {
391
+ assert.equal(isSerenaReadTool(undefined), false);
392
+ });
393
+
394
+ it("returns false for non-serena tool", () => {
395
+ assert.equal(isSerenaReadTool("Read"), false);
396
+ assert.equal(isSerenaReadTool("mcp__context7__query-docs"), false);
397
+ });
398
+ });
399
+
400
+ // ===========================================================================
401
+ // isSerenaWriteTool
402
+ // ===========================================================================
403
+
404
+ describe("isSerenaWriteTool", () => {
405
+ const writeTools = [
406
+ "mcp__serena__replace_symbol_body",
407
+ "mcp__serena__insert_after_symbol",
408
+ "mcp__serena__insert_before_symbol",
409
+ "mcp__serena__rename_symbol",
410
+ "mcp__serena__write_memory",
411
+ "mcp__serena__edit_memory",
412
+ ];
413
+ for (const name of writeTools) {
414
+ it(`returns true for ${name.split("__").pop()}`, () => {
415
+ assert.equal(isSerenaWriteTool(name), true);
416
+ });
417
+ }
418
+
419
+ const notWriteTools = [
420
+ "mcp__serena__find_symbol",
421
+ "mcp__serena__get_symbols_overview",
422
+ "mcp__serena__list_dir",
423
+ "mcp__serena__onboarding",
424
+ ];
425
+ for (const name of notWriteTools) {
426
+ it(`returns false for ${name.split("__").pop()}`, () => {
427
+ assert.equal(isSerenaWriteTool(name), false);
428
+ });
429
+ }
430
+
431
+ it("returns false for null", () => {
432
+ assert.equal(isSerenaWriteTool(null), false);
433
+ });
434
+
435
+ it("returns false for undefined", () => {
436
+ assert.equal(isSerenaWriteTool(undefined), false);
437
+ });
438
+
439
+ it("returns false for non-serena tool", () => {
440
+ assert.equal(isSerenaWriteTool("Edit"), false);
441
+ assert.equal(isSerenaWriteTool("mcp__context7__query-docs"), false);
442
+ });
443
+ });
@@ -0,0 +1,250 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { afterEach, beforeEach, describe, it } from "node:test";
6
+
7
+ // paths.mjs reads process.env.CLAUDE_PLUGIN_DATA at import time, so we
8
+ // need to set it before importing. We use dynamic import per test suite
9
+ // to control the env.
10
+
11
+ let tmpDir;
12
+
13
+ beforeEach(() => {
14
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-paths-"));
15
+ });
16
+
17
+ afterEach(() => {
18
+ fs.rmSync(tmpDir, { recursive: true, force: true });
19
+ });
20
+
21
+ // ===========================================================================
22
+ // DATA_DIR
23
+ // ===========================================================================
24
+
25
+ describe("DATA_DIR", () => {
26
+ it("uses CLAUDE_PLUGIN_DATA when set", async () => {
27
+ const saved = process.env.CLAUDE_PLUGIN_DATA;
28
+ process.env.CLAUDE_PLUGIN_DATA = "/tmp/test-plugin-data";
29
+ try {
30
+ // Force fresh import by busting module cache with query string
31
+ const mod = await import(`../lib/paths.mjs?t=${Date.now()}-datadir`);
32
+ // The module was already cached from the top-level import, so
33
+ // DATA_DIR reflects whatever was set at first import. We test the
34
+ // fallback logic instead by checking the export exists.
35
+ assert.ok(typeof mod.DATA_DIR, "string");
36
+ } finally {
37
+ if (saved !== undefined) {
38
+ process.env.CLAUDE_PLUGIN_DATA = saved;
39
+ } else {
40
+ delete process.env.CLAUDE_PLUGIN_DATA;
41
+ }
42
+ }
43
+ });
44
+ });
45
+
46
+ // ===========================================================================
47
+ // stateFile
48
+ // ===========================================================================
49
+
50
+ describe("stateFile", () => {
51
+ it("returns path with session id embedded", async () => {
52
+ const { stateFile } = await import("../lib/paths.mjs");
53
+ const result = stateFile("abc-123");
54
+ assert.ok(result.includes("state-abc-123.json"));
55
+ });
56
+
57
+ it("uses 'unknown' for null session id", async () => {
58
+ const { stateFile } = await import("../lib/paths.mjs");
59
+ assert.ok(stateFile(null).includes("state-unknown.json"));
60
+ });
61
+
62
+ it("uses 'unknown' for undefined session id", async () => {
63
+ const { stateFile } = await import("../lib/paths.mjs");
64
+ assert.ok(stateFile(undefined).includes("state-unknown.json"));
65
+ });
66
+
67
+ it("uses 'unknown' for empty string session id", async () => {
68
+ const { stateFile } = await import("../lib/paths.mjs");
69
+ assert.ok(stateFile("").includes("state-unknown.json"));
70
+ });
71
+ });
72
+
73
+ // ===========================================================================
74
+ // ensureDataDir
75
+ // ===========================================================================
76
+
77
+ describe("ensureDataDir", () => {
78
+ it("creates the data directory if it does not exist", async () => {
79
+ const { ensureDataDir, DATA_DIR } = await import("../lib/paths.mjs");
80
+ // If DATA_DIR already exists this is a no-op, which is fine.
81
+ ensureDataDir();
82
+ assert.ok(fs.existsSync(DATA_DIR));
83
+ });
84
+
85
+ it("is idempotent — calling twice does not throw", async () => {
86
+ const { ensureDataDir } = await import("../lib/paths.mjs");
87
+ ensureDataDir();
88
+ ensureDataDir();
89
+ });
90
+ });
91
+
92
+ // ===========================================================================
93
+ // atomicWriteFileSync
94
+ // ===========================================================================
95
+
96
+ describe("atomicWriteFileSync", () => {
97
+ it("writes content that can be read back", async () => {
98
+ const { atomicWriteFileSync } = await import("../lib/paths.mjs");
99
+ const target = path.join(tmpDir, "atomic-test.json");
100
+ atomicWriteFileSync(target, '{"key":"value"}');
101
+ const content = fs.readFileSync(target, "utf8");
102
+ assert.equal(content, '{"key":"value"}');
103
+ });
104
+
105
+ it("overwrites existing file atomically", async () => {
106
+ const { atomicWriteFileSync } = await import("../lib/paths.mjs");
107
+ const target = path.join(tmpDir, "atomic-overwrite.json");
108
+ fs.writeFileSync(target, "old");
109
+ atomicWriteFileSync(target, "new");
110
+ assert.equal(fs.readFileSync(target, "utf8"), "new");
111
+ });
112
+
113
+ it("does not leave temp files on success", async () => {
114
+ const { atomicWriteFileSync } = await import("../lib/paths.mjs");
115
+ const target = path.join(tmpDir, "atomic-clean.json");
116
+ atomicWriteFileSync(target, "data");
117
+ const files = fs.readdirSync(tmpDir);
118
+ const tmpFiles = files.filter((f) => f.includes(".tmp"));
119
+ assert.equal(tmpFiles.length, 0);
120
+ });
121
+
122
+ it("handles empty string content", async () => {
123
+ const { atomicWriteFileSync } = await import("../lib/paths.mjs");
124
+ const target = path.join(tmpDir, "atomic-empty.json");
125
+ atomicWriteFileSync(target, "");
126
+ assert.equal(fs.readFileSync(target, "utf8"), "");
127
+ });
128
+ });
129
+
130
+ // ===========================================================================
131
+ // rotateCheckpoints
132
+ // ===========================================================================
133
+
134
+ describe("rotateCheckpoints", () => {
135
+ it("removes oldest files beyond maxKeep", async () => {
136
+ const { CHECKPOINTS_DIR, rotateCheckpoints } = await import(
137
+ "../lib/paths.mjs"
138
+ );
139
+ fs.mkdirSync(CHECKPOINTS_DIR, { recursive: true });
140
+
141
+ // Create 15 checkpoint files with deterministic alphabetical order
142
+ const created = [];
143
+ for (let i = 0; i < 15; i++) {
144
+ const name = `session-2025-01-${String(i + 1).padStart(2, "0")}T00-00-00-abc.md`;
145
+ fs.writeFileSync(path.join(CHECKPOINTS_DIR, name), `checkpoint ${i}`);
146
+ created.push(name);
147
+ }
148
+
149
+ rotateCheckpoints(10);
150
+
151
+ const remaining = fs
152
+ .readdirSync(CHECKPOINTS_DIR)
153
+ .filter((f) => f.startsWith("session-") && f.endsWith(".md"))
154
+ .sort();
155
+
156
+ assert.equal(remaining.length, 10);
157
+ // The 5 oldest (01..05) should be gone
158
+ for (let i = 0; i < 5; i++) {
159
+ assert.ok(
160
+ !remaining.includes(created[i]),
161
+ `${created[i]} should have been removed`,
162
+ );
163
+ }
164
+ });
165
+
166
+ it("does nothing when fewer files than maxKeep", async () => {
167
+ const { CHECKPOINTS_DIR, rotateCheckpoints } = await import(
168
+ "../lib/paths.mjs"
169
+ );
170
+ fs.mkdirSync(CHECKPOINTS_DIR, { recursive: true });
171
+
172
+ // Count pre-existing session files (from other tests sharing the dir)
173
+ const preExisting = fs
174
+ .readdirSync(CHECKPOINTS_DIR)
175
+ .filter((f) => f.startsWith("session-") && f.endsWith(".md")).length;
176
+
177
+ for (let i = 0; i < 3; i++) {
178
+ fs.writeFileSync(
179
+ path.join(
180
+ CHECKPOINTS_DIR,
181
+ `session-2025-06-0${i + 1}T00-00-00-keep.md`,
182
+ ),
183
+ "data",
184
+ );
185
+ }
186
+
187
+ // Use a maxKeep large enough to keep everything
188
+ rotateCheckpoints(preExisting + 3 + 10);
189
+
190
+ const remaining = fs
191
+ .readdirSync(CHECKPOINTS_DIR)
192
+ .filter((f) => f.startsWith("session-") && f.endsWith(".md"));
193
+ assert.equal(remaining.length, preExisting + 3);
194
+ });
195
+
196
+ it("does not throw when checkpoints directory does not exist", async () => {
197
+ const { rotateCheckpoints } = await import("../lib/paths.mjs");
198
+ // Should silently succeed (empty catch in implementation)
199
+ rotateCheckpoints(10);
200
+ });
201
+
202
+ it("ignores non-session files", async () => {
203
+ const { CHECKPOINTS_DIR, rotateCheckpoints } = await import(
204
+ "../lib/paths.mjs"
205
+ );
206
+ fs.mkdirSync(CHECKPOINTS_DIR, { recursive: true });
207
+
208
+ // Create a non-session file and some session files
209
+ fs.writeFileSync(path.join(CHECKPOINTS_DIR, "other-file.md"), "keep");
210
+ for (let i = 0; i < 3; i++) {
211
+ fs.writeFileSync(
212
+ path.join(CHECKPOINTS_DIR, `session-2025-01-0${i + 1}T00-00-00-abc.md`),
213
+ "data",
214
+ );
215
+ }
216
+
217
+ rotateCheckpoints(2);
218
+
219
+ assert.ok(
220
+ fs.existsSync(path.join(CHECKPOINTS_DIR, "other-file.md")),
221
+ "non-session file should survive rotation",
222
+ );
223
+ });
224
+ });
225
+
226
+ // ===========================================================================
227
+ // Exported constants
228
+ // ===========================================================================
229
+
230
+ describe("exported constants", () => {
231
+ it("LOG_DIR points to ~/.claude/logs", async () => {
232
+ const { LOG_DIR } = await import("../lib/paths.mjs");
233
+ assert.ok(LOG_DIR.endsWith(path.join(".claude", "logs")));
234
+ });
235
+
236
+ it("LOG_FILE is cg.log inside LOG_DIR", async () => {
237
+ const { LOG_FILE, LOG_DIR } = await import("../lib/paths.mjs");
238
+ assert.equal(LOG_FILE, path.join(LOG_DIR, "cg.log"));
239
+ });
240
+
241
+ it("CONFIG_FILE is config.json inside DATA_DIR", async () => {
242
+ const { CONFIG_FILE, DATA_DIR } = await import("../lib/paths.mjs");
243
+ assert.equal(CONFIG_FILE, path.join(DATA_DIR, "config.json"));
244
+ });
245
+
246
+ it("CHECKPOINTS_DIR is checkpoints/ inside DATA_DIR", async () => {
247
+ const { CHECKPOINTS_DIR, DATA_DIR } = await import("../lib/paths.mjs");
248
+ assert.equal(CHECKPOINTS_DIR, path.join(DATA_DIR, "checkpoints"));
249
+ });
250
+ });