@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,738 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { afterEach, beforeEach, describe, it } from "node:test";
7
+
8
+ let tmpDir;
9
+ let transcriptPath;
10
+ let cwd;
11
+ let dataDir;
12
+
13
+ function writeLine(obj) {
14
+ fs.appendFileSync(transcriptPath, `${JSON.stringify(obj)}\n`);
15
+ }
16
+
17
+ function writeMinimalTranscript() {
18
+ writeLine({
19
+ type: "user",
20
+ message: {
21
+ role: "user",
22
+ content:
23
+ "Please implement the fibonacci function with memoization for our math library. We need it to handle large numbers efficiently.",
24
+ },
25
+ });
26
+ writeLine({
27
+ type: "assistant",
28
+ message: {
29
+ role: "assistant",
30
+ content: [
31
+ {
32
+ type: "text",
33
+ text: "I will implement the fibonacci function with memoization. This approach uses a cache to avoid redundant calculations, making it O(n) instead of O(2^n). Here is the implementation with full error handling and type checking for the math library module.",
34
+ },
35
+ ],
36
+ },
37
+ });
38
+ writeLine({
39
+ type: "user",
40
+ message: {
41
+ role: "user",
42
+ content:
43
+ "Great, now add unit tests for edge cases including negative numbers, zero, and very large inputs like fib(1000).",
44
+ },
45
+ });
46
+ writeLine({
47
+ type: "assistant",
48
+ message: {
49
+ role: "assistant",
50
+ content: [
51
+ {
52
+ type: "text",
53
+ text: "I have added comprehensive unit tests covering negative numbers (should throw), zero (returns 0), one (returns 1), standard cases (fib(10) = 55), and large inputs (fib(1000) using BigInt). All tests pass successfully with the memoized implementation.",
54
+ },
55
+ ],
56
+ },
57
+ });
58
+ }
59
+
60
+ function runCli(args, opts = {}) {
61
+ return execFileSync("node", [path.resolve("lib/compact-cli.mjs"), ...args], {
62
+ encoding: "utf8",
63
+ timeout: 5000,
64
+ cwd: opts.cwd || cwd,
65
+ });
66
+ }
67
+
68
+ function writeStateFile(sessionId, data) {
69
+ fs.writeFileSync(
70
+ path.join(dataDir, `state-${sessionId}.json`),
71
+ JSON.stringify({ transcript_path: transcriptPath, ...data }),
72
+ );
73
+ }
74
+
75
+ beforeEach(() => {
76
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-handoff-test-"));
77
+ transcriptPath = path.join(tmpDir, "transcript.jsonl");
78
+ cwd = path.join(tmpDir, "project");
79
+ dataDir = path.join(tmpDir, "data");
80
+ fs.mkdirSync(cwd, { recursive: true });
81
+ fs.mkdirSync(dataDir, { recursive: true });
82
+ fs.writeFileSync(transcriptPath, "");
83
+ });
84
+
85
+ afterEach(() => {
86
+ fs.rmSync(tmpDir, { recursive: true, force: true });
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // performHandoff (via compact-cli)
91
+ // ---------------------------------------------------------------------------
92
+
93
+ describe("performHandoff via compact-cli", () => {
94
+ it("creates a handoff file in .context-guardian/ dir", () => {
95
+ writeMinimalTranscript();
96
+ writeStateFile("test-session");
97
+
98
+ const result = JSON.parse(runCli(["handoff", "test-session", dataDir]));
99
+
100
+ assert.equal(result.success, true);
101
+ assert.ok(result.statsBlock.includes("Session Handoff"));
102
+ assert.ok(result.statsBlock.includes("Saved to:"));
103
+
104
+ const cgDir = path.join(cwd, ".context-guardian");
105
+ assert.ok(fs.existsSync(cgDir));
106
+ const handoffFiles = fs
107
+ .readdirSync(cgDir)
108
+ .filter((f) => f.startsWith("cg-handoff-"));
109
+ assert.equal(handoffFiles.length, 1);
110
+ });
111
+
112
+ it("includes label in filename and header", () => {
113
+ writeMinimalTranscript();
114
+ writeStateFile("test-session");
115
+
116
+ const result = JSON.parse(
117
+ runCli(["handoff", "test-session", dataDir, "my auth refactor"]),
118
+ );
119
+
120
+ assert.equal(result.success, true);
121
+
122
+ const cgDir = path.join(cwd, ".context-guardian");
123
+ const files = fs
124
+ .readdirSync(cgDir)
125
+ .filter((f) => f.startsWith("cg-handoff-"));
126
+ assert.equal(files.length, 1);
127
+ // Label slug comes before the timestamp
128
+ assert.ok(files[0].startsWith("cg-handoff-my-auth-refactor-"));
129
+
130
+ // Check label in header
131
+ const content = fs.readFileSync(path.join(cgDir, files[0]), "utf8");
132
+ assert.ok(content.includes("> Label: my auth refactor"));
133
+ });
134
+
135
+ it("slugifies label with special characters", () => {
136
+ writeMinimalTranscript();
137
+ writeStateFile("test-session");
138
+
139
+ JSON.parse(
140
+ runCli(["handoff", "test-session", dataDir, "Fix bug #123 (urgent!)"]),
141
+ );
142
+
143
+ const cgDir = path.join(cwd, ".context-guardian");
144
+ const files = fs
145
+ .readdirSync(cgDir)
146
+ .filter((f) => f.startsWith("cg-handoff-"));
147
+ assert.equal(files.length, 1);
148
+ // Special chars replaced with dashes
149
+ assert.ok(files[0].includes("fix-bug-123-urgent"));
150
+ assert.ok(!files[0].includes("#"));
151
+ assert.ok(!files[0].includes("("));
152
+ });
153
+
154
+ it("truncates long labels to 50 chars in filename", () => {
155
+ writeMinimalTranscript();
156
+ writeStateFile("test-session");
157
+
158
+ const longLabel = "a".repeat(100);
159
+ JSON.parse(runCli(["handoff", "test-session", dataDir, longLabel]));
160
+
161
+ const cgDir = path.join(cwd, ".context-guardian");
162
+ const files = fs
163
+ .readdirSync(cgDir)
164
+ .filter((f) => f.startsWith("cg-handoff-"));
165
+ // Slug portion should be capped at 50 chars + dash
166
+ const slug = files[0].replace("cg-handoff-", "").split(/\d{4}-/)[0];
167
+ assert.ok(slug.length <= 51); // 50 chars + trailing dash
168
+ });
169
+
170
+ it("works without a label", () => {
171
+ writeMinimalTranscript();
172
+ writeStateFile("test-session");
173
+
174
+ const result = JSON.parse(runCli(["handoff", "test-session", dataDir]));
175
+
176
+ assert.equal(result.success, true);
177
+ const cgDir = path.join(cwd, ".context-guardian");
178
+ const files = fs
179
+ .readdirSync(cgDir)
180
+ .filter((f) => f.startsWith("cg-handoff-"));
181
+ // No slug — starts with cg-handoff- then a digit (timestamp)
182
+ assert.match(files[0], /^cg-handoff-\d/);
183
+
184
+ // No Label line in content
185
+ const content = fs.readFileSync(path.join(cgDir, files[0]), "utf8");
186
+ assert.ok(!content.includes("> Label:"));
187
+ });
188
+
189
+ it("returns error for empty transcript", () => {
190
+ const emptyTranscript = path.join(tmpDir, "empty.jsonl");
191
+ fs.writeFileSync(emptyTranscript, "");
192
+ fs.writeFileSync(
193
+ path.join(dataDir, "state-test-session.json"),
194
+ JSON.stringify({ transcript_path: emptyTranscript }),
195
+ );
196
+
197
+ const result = JSON.parse(runCli(["handoff", "test-session", dataDir]));
198
+
199
+ assert.equal(result.success, false);
200
+ assert.ok(result.error.includes("No extractable content"));
201
+ });
202
+
203
+ it("returns error for missing session state", () => {
204
+ const result = JSON.parse(runCli(["handoff", "no-such-session", dataDir]));
205
+ assert.equal(result.success, false);
206
+ assert.ok(result.error.includes("No session data"));
207
+ });
208
+
209
+ it("statsBlock includes token stats", () => {
210
+ writeMinimalTranscript();
211
+ writeStateFile("test-session");
212
+
213
+ const result = JSON.parse(runCli(["handoff", "test-session", dataDir]));
214
+
215
+ assert.ok(result.statsBlock.includes("Before:"));
216
+ assert.ok(result.statsBlock.includes("After:"));
217
+ assert.ok(result.statsBlock.includes("Saved:"));
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // listRestoreFiles
223
+ // ---------------------------------------------------------------------------
224
+
225
+ describe("listRestoreFiles", () => {
226
+ it("returns empty array when .context-guardian/ does not exist", async () => {
227
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
228
+ const result = listRestoreFiles(cwd);
229
+ assert.deepEqual(result, []);
230
+ });
231
+
232
+ it("finds handoff files", async () => {
233
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
234
+ const cgDir = path.join(cwd, ".context-guardian");
235
+ fs.mkdirSync(cgDir, { recursive: true });
236
+
237
+ fs.writeFileSync(
238
+ path.join(cgDir, "cg-handoff-2026-03-29T10-00-00.md"),
239
+ "# Session Handoff\n> Created: 2026-03-29T10:00:00Z\n\n## Session State\nGoal: implement fibonacci\n",
240
+ );
241
+
242
+ const result = listRestoreFiles(cwd);
243
+ assert.equal(result.length, 1);
244
+ assert.equal(result[0].type, "handoff");
245
+ assert.equal(result[0].goal, "implement fibonacci");
246
+ });
247
+
248
+ it("excludes checkpoint files by default", async () => {
249
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
250
+ const cgDir = path.join(cwd, ".context-guardian");
251
+ fs.mkdirSync(cgDir, { recursive: true });
252
+
253
+ fs.writeFileSync(
254
+ path.join(cgDir, "cg-checkpoint-2026-03-29T09-00-00-abcd1234.md"),
255
+ "# Context Checkpoint (Smart Compact)\n> Created: 2026-03-29T09:00:00Z\n\n## Session State\nGoal: fix auth bug\n",
256
+ );
257
+
258
+ const result = listRestoreFiles(cwd);
259
+ assert.equal(result.length, 0);
260
+ });
261
+
262
+ it("includes checkpoint files with includeCheckpoints flag", async () => {
263
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
264
+ const cgDir = path.join(cwd, ".context-guardian");
265
+ fs.mkdirSync(cgDir, { recursive: true });
266
+
267
+ fs.writeFileSync(
268
+ path.join(cgDir, "cg-checkpoint-2026-03-29T09-00-00-abcd1234.md"),
269
+ "# Context Checkpoint (Smart Compact)\n> Created: 2026-03-29T09:00:00Z\n\n## Session State\nGoal: fix auth bug\n",
270
+ );
271
+
272
+ const result = listRestoreFiles(cwd, { includeCheckpoints: true });
273
+ assert.equal(result.length, 1);
274
+ assert.equal(result[0].type, "checkpoint");
275
+ assert.equal(result[0].goal, "fix auth bug");
276
+ });
277
+
278
+ it("sorts newest first", async () => {
279
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
280
+ const cgDir = path.join(cwd, ".context-guardian");
281
+ fs.mkdirSync(cgDir, { recursive: true });
282
+
283
+ fs.writeFileSync(
284
+ path.join(cgDir, "cg-handoff-2026-03-28T10-00-00.md"),
285
+ "# Session Handoff\n> Created: 2026-03-28T10:00:00Z\n\n## Session State\nGoal: older session\n",
286
+ );
287
+ fs.writeFileSync(
288
+ path.join(cgDir, "cg-handoff-2026-03-29T10-00-00.md"),
289
+ "# Session Handoff\n> Created: 2026-03-29T10:00:00Z\n\n## Session State\nGoal: newer session\n",
290
+ );
291
+
292
+ const result = listRestoreFiles(cwd);
293
+ assert.equal(result.length, 2);
294
+ assert.equal(result[0].goal, "newer session");
295
+ assert.equal(result[1].goal, "older session");
296
+ });
297
+
298
+ it("shows label in preference to goal", async () => {
299
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
300
+ const cgDir = path.join(cwd, ".context-guardian");
301
+ fs.mkdirSync(cgDir, { recursive: true });
302
+
303
+ fs.writeFileSync(
304
+ path.join(cgDir, "cg-handoff-my-auth-refactor-2026-03-29T10-00-00.md"),
305
+ "# Session Handoff\n> Created: 2026-03-29T10:00:00Z\n> Label: my auth refactor\n\n## Session State\nGoal: implement auth\n",
306
+ );
307
+
308
+ const result = listRestoreFiles(cwd);
309
+ assert.equal(result.length, 1);
310
+ assert.equal(result[0].label, "my auth refactor");
311
+ assert.equal(result[0].goal, "implement auth");
312
+ });
313
+
314
+ it("ignores non-CG files", async () => {
315
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
316
+ const cgDir = path.join(cwd, ".context-guardian");
317
+ fs.mkdirSync(cgDir, { recursive: true });
318
+
319
+ fs.writeFileSync(path.join(cgDir, "random-notes.md"), "nothing");
320
+ fs.writeFileSync(path.join(cgDir, ".gitkeep"), "");
321
+
322
+ const result = listRestoreFiles(cwd);
323
+ assert.equal(result.length, 0);
324
+ });
325
+
326
+ it("limits to 10 handoffs", async () => {
327
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
328
+ const cgDir = path.join(cwd, ".context-guardian");
329
+ fs.mkdirSync(cgDir, { recursive: true });
330
+
331
+ for (let i = 0; i < 15; i++) {
332
+ const d = String(i).padStart(2, "0");
333
+ fs.writeFileSync(
334
+ path.join(cgDir, `cg-handoff-2026-03-${d}T10-00-00.md`),
335
+ `# Session Handoff\n> Created: 2026-03-${d}T10:00:00Z\n\n## Session State\nGoal: session ${i}\n`,
336
+ );
337
+ }
338
+
339
+ const result = listRestoreFiles(cwd);
340
+ assert.equal(result.length, 10);
341
+ });
342
+
343
+ it("limits to 10 handoffs + 10 checkpoints in all mode", async () => {
344
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
345
+ const cgDir = path.join(cwd, ".context-guardian");
346
+ fs.mkdirSync(cgDir, { recursive: true });
347
+
348
+ for (let i = 0; i < 15; i++) {
349
+ const d = String(i).padStart(2, "0");
350
+ fs.writeFileSync(
351
+ path.join(cgDir, `cg-handoff-2026-03-${d}T10-00-00.md`),
352
+ `# Session Handoff\n> Created: 2026-03-${d}T10:00:00Z\n`,
353
+ );
354
+ fs.writeFileSync(
355
+ path.join(cgDir, `cg-checkpoint-2026-03-${d}T10-00-00-abcd.md`),
356
+ `# Context Checkpoint\n> Created: 2026-03-${d}T10:00:00Z\n`,
357
+ );
358
+ }
359
+
360
+ const result = listRestoreFiles(cwd, { includeCheckpoints: true });
361
+ const handoffs = result.filter((f) => f.type === "handoff");
362
+ const checkpoints = result.filter((f) => f.type === "checkpoint");
363
+ assert.equal(handoffs.length, 10);
364
+ assert.equal(checkpoints.length, 10);
365
+ assert.equal(result.length, 20);
366
+ });
367
+
368
+ it("handles unreadable files gracefully", async () => {
369
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
370
+ const cgDir = path.join(cwd, ".context-guardian");
371
+ fs.mkdirSync(cgDir, { recursive: true });
372
+
373
+ // Create a valid file
374
+ fs.writeFileSync(
375
+ path.join(cgDir, "cg-handoff-2026-03-29T10-00-00.md"),
376
+ "# Session Handoff\n> Created: 2026-03-29T10:00:00Z\n",
377
+ );
378
+ // Create a directory with the same naming pattern (will fail on readFileHead)
379
+ fs.mkdirSync(path.join(cgDir, "cg-handoff-fake-dir.md"));
380
+
381
+ const result = listRestoreFiles(cwd);
382
+ assert.equal(result.length, 1); // Only the valid file
383
+ });
384
+
385
+ it("parses goal as null when [not available]", async () => {
386
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
387
+ const cgDir = path.join(cwd, ".context-guardian");
388
+ fs.mkdirSync(cgDir, { recursive: true });
389
+
390
+ fs.writeFileSync(
391
+ path.join(cgDir, "cg-handoff-2026-03-29T10-00-00.md"),
392
+ "# Session Handoff\n> Created: 2026-03-29T10:00:00Z\n\n## Session State\nGoal: [not available]\n",
393
+ );
394
+
395
+ const result = listRestoreFiles(cwd);
396
+ assert.equal(result[0].goal, null);
397
+ assert.equal(result[0].label, null);
398
+ });
399
+
400
+ it("falls back to mtime when Created header is missing", async () => {
401
+ const { listRestoreFiles } = await import("../lib/handoff.mjs");
402
+ const cgDir = path.join(cwd, ".context-guardian");
403
+ fs.mkdirSync(cgDir, { recursive: true });
404
+
405
+ fs.writeFileSync(
406
+ path.join(cgDir, "cg-handoff-2026-03-29T10-00-00.md"),
407
+ "# Session Handoff\nno created header here\n",
408
+ );
409
+
410
+ const result = listRestoreFiles(cwd);
411
+ assert.equal(result.length, 1);
412
+ // created should be an ISO string (from mtime)
413
+ assert.ok(result[0].created.includes("T"));
414
+ });
415
+ });
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // formatRestoreMenu
419
+ // ---------------------------------------------------------------------------
420
+
421
+ describe("formatRestoreMenu", () => {
422
+ it("shows no-files message when empty", async () => {
423
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
424
+ const menu = formatRestoreMenu([]);
425
+ assert.ok(menu.includes("No saved sessions found"));
426
+ assert.ok(menu.includes("/cg:handoff"));
427
+ assert.ok(menu.includes("┌"));
428
+ assert.ok(menu.includes("└"));
429
+ });
430
+
431
+ it("formats files with numbers in box", async () => {
432
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
433
+ const files = [
434
+ {
435
+ path: "/tmp/test/cg-handoff-2026-03-29.md",
436
+ filename: "cg-handoff-2026-03-29.md",
437
+ type: "handoff",
438
+ created: new Date().toISOString(),
439
+ goal: "implement fibonacci",
440
+ size: 42,
441
+ },
442
+ {
443
+ path: "/tmp/test/cg-checkpoint-2026-03-28.md",
444
+ filename: "cg-checkpoint-2026-03-28.md",
445
+ type: "checkpoint",
446
+ created: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(),
447
+ goal: "fix auth bug",
448
+ size: 18,
449
+ },
450
+ ];
451
+ const menu = formatRestoreMenu(files);
452
+
453
+ assert.ok(menu.includes("[1]"));
454
+ assert.ok(menu.includes("implement fibonacci"));
455
+ assert.ok(menu.includes("[2]"));
456
+ assert.ok(menu.includes("fix auth bug"));
457
+ assert.ok(menu.includes("Previous Sessions"));
458
+ assert.ok(menu.includes("Reply with a number"));
459
+ // showType not set, so no type labels
460
+ assert.ok(!menu.includes("[HANDOFF]"));
461
+ assert.ok(!menu.includes("[CHECKPOINT]"));
462
+
463
+ // With showType
464
+ const menuAll = formatRestoreMenu(files, { showType: true });
465
+ assert.ok(menuAll.includes("[HANDOFF]"));
466
+ assert.ok(menuAll.includes("[CHECKPOINT]"));
467
+ });
468
+
469
+ it("prefers label over goal in display", async () => {
470
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
471
+ const menu = formatRestoreMenu([
472
+ {
473
+ path: "/tmp/test.md",
474
+ filename: "test.md",
475
+ type: "handoff",
476
+ created: new Date().toISOString(),
477
+ label: "my custom label",
478
+ goal: "auto-detected goal",
479
+ size: 10,
480
+ },
481
+ ]);
482
+ assert.ok(menu.includes("my custom label"));
483
+ assert.ok(!menu.includes("auto-detected goal"));
484
+ });
485
+
486
+ it("shows no description when label and goal are both null", async () => {
487
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
488
+ const menu = formatRestoreMenu([
489
+ {
490
+ path: "/tmp/test.md",
491
+ filename: "test.md",
492
+ type: "handoff",
493
+ created: new Date().toISOString(),
494
+ label: null,
495
+ goal: null,
496
+ size: 5,
497
+ },
498
+ ]);
499
+ assert.ok(menu.includes("no description"));
500
+ });
501
+
502
+ it("includes box characters", async () => {
503
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
504
+ const menu = formatRestoreMenu([
505
+ {
506
+ path: "/tmp/test.md",
507
+ filename: "test.md",
508
+ type: "handoff",
509
+ created: new Date().toISOString(),
510
+ goal: "test",
511
+ size: 1,
512
+ },
513
+ ]);
514
+ assert.ok(menu.includes("┌"));
515
+ assert.ok(menu.includes("├"));
516
+ assert.ok(menu.includes("└"));
517
+ assert.ok(menu.includes("│"));
518
+ });
519
+ });
520
+
521
+ // ---------------------------------------------------------------------------
522
+ // rotateFiles
523
+ // ---------------------------------------------------------------------------
524
+
525
+ describe("rotateFiles", () => {
526
+ it("keeps only maxKeep files", async () => {
527
+ const { rotateFiles } = await import("../lib/handoff.mjs");
528
+ const cgDir = path.join(cwd, ".context-guardian");
529
+ fs.mkdirSync(cgDir, { recursive: true });
530
+
531
+ for (let i = 1; i <= 8; i++) {
532
+ const filePath = path.join(cgDir, `cg-handoff-2026-03-0${i}T10-00-00.md`);
533
+ fs.writeFileSync(filePath, `handoff ${i}`);
534
+ // Set mtime to ensure correct ordering
535
+ const mtime = new Date(`2026-03-0${i}T10:00:00Z`);
536
+ fs.utimesSync(filePath, mtime, mtime);
537
+ }
538
+
539
+ rotateFiles(cgDir, "cg-handoff-", 5);
540
+
541
+ const remaining = fs
542
+ .readdirSync(cgDir)
543
+ .filter((f) => f.startsWith("cg-handoff-"));
544
+ assert.equal(remaining.length, 5);
545
+ });
546
+
547
+ it("handles label-prefixed files correctly by mtime", async () => {
548
+ const { rotateFiles } = await import("../lib/handoff.mjs");
549
+ const cgDir = path.join(cwd, ".context-guardian");
550
+ fs.mkdirSync(cgDir, { recursive: true });
551
+
552
+ // Newer file has label "zebra" (sorts after alphabetically)
553
+ const newerFile = path.join(
554
+ cgDir,
555
+ "cg-handoff-zebra-2026-03-29T10-00-00.md",
556
+ );
557
+ fs.writeFileSync(newerFile, "newer");
558
+ fs.utimesSync(newerFile, new Date("2026-03-29"), new Date("2026-03-29"));
559
+
560
+ // Older file has label "alpha" (sorts before alphabetically)
561
+ const olderFile = path.join(
562
+ cgDir,
563
+ "cg-handoff-alpha-2026-03-28T10-00-00.md",
564
+ );
565
+ fs.writeFileSync(olderFile, "older");
566
+ fs.utimesSync(olderFile, new Date("2026-03-28"), new Date("2026-03-28"));
567
+
568
+ rotateFiles(cgDir, "cg-handoff-", 1);
569
+
570
+ const remaining = fs
571
+ .readdirSync(cgDir)
572
+ .filter((f) => f.startsWith("cg-handoff-"));
573
+ assert.equal(remaining.length, 1);
574
+ // Newer file (zebra) should survive despite sorting after alpha alphabetically
575
+ assert.ok(remaining[0].includes("zebra"));
576
+ });
577
+
578
+ it("handles empty directory", async () => {
579
+ const { rotateFiles } = await import("../lib/handoff.mjs");
580
+ const cgDir = path.join(cwd, ".context-guardian");
581
+ fs.mkdirSync(cgDir, { recursive: true });
582
+
583
+ // Should not throw
584
+ rotateFiles(cgDir, "cg-handoff-", 5);
585
+
586
+ const remaining = fs.readdirSync(cgDir);
587
+ assert.equal(remaining.length, 0);
588
+ });
589
+
590
+ it("handles nonexistent directory", async () => {
591
+ const { rotateFiles } = await import("../lib/handoff.mjs");
592
+ // Should not throw
593
+ rotateFiles("/nonexistent/path", "cg-handoff-", 5);
594
+ });
595
+ });
596
+
597
+ // ---------------------------------------------------------------------------
598
+ // CG_DIR_NAME constant
599
+ // ---------------------------------------------------------------------------
600
+
601
+ describe("CG_DIR_NAME", () => {
602
+ it("exports .context-guardian", async () => {
603
+ const { CG_DIR_NAME } = await import("../lib/handoff.mjs");
604
+ assert.equal(CG_DIR_NAME, ".context-guardian");
605
+ });
606
+ });
607
+
608
+ // ---------------------------------------------------------------------------
609
+ // performHandoff — direct unit tests for coverage
610
+ // ---------------------------------------------------------------------------
611
+
612
+ describe("performHandoff direct", () => {
613
+ it("returns null for missing transcript", async () => {
614
+ const { performHandoff } = await import("../lib/handoff.mjs");
615
+ const result = performHandoff({
616
+ transcriptPath: "/nonexistent/file.jsonl",
617
+ sessionId: "test",
618
+ });
619
+ assert.equal(result, null);
620
+ });
621
+
622
+ it("returns null for empty transcript", async () => {
623
+ const { performHandoff } = await import("../lib/handoff.mjs");
624
+ const result = performHandoff({
625
+ transcriptPath,
626
+ sessionId: "test",
627
+ });
628
+ assert.equal(result, null);
629
+ });
630
+
631
+ it("creates handoff file with label slug", async () => {
632
+ const { performHandoff } = await import("../lib/handoff.mjs");
633
+ const origCwd = process.cwd();
634
+ process.chdir(cwd);
635
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
636
+ writeMinimalTranscript();
637
+ try {
638
+ const result = performHandoff({
639
+ transcriptPath,
640
+ sessionId: "test-sess",
641
+ label: "My Test Session!",
642
+ });
643
+ assert.ok(result);
644
+ assert.ok(result.handoffPath.includes("cg-handoff-my-test-session-"));
645
+ assert.ok(result.statsBlock.includes("Session Handoff"));
646
+ assert.ok(fs.existsSync(result.handoffPath));
647
+ const content = fs.readFileSync(result.handoffPath, "utf8");
648
+ assert.ok(content.includes("# Session Handoff"));
649
+ assert.ok(content.includes("Label: My Test Session!"));
650
+ } finally {
651
+ process.chdir(origCwd);
652
+ }
653
+ });
654
+
655
+ it("creates handoff file without label", async () => {
656
+ const { performHandoff } = await import("../lib/handoff.mjs");
657
+ const origCwd = process.cwd();
658
+ process.chdir(cwd);
659
+ process.env.CLAUDE_PLUGIN_DATA = dataDir;
660
+ writeMinimalTranscript();
661
+ try {
662
+ const result = performHandoff({
663
+ transcriptPath,
664
+ sessionId: "test-sess",
665
+ });
666
+ assert.ok(result);
667
+ assert.ok(result.handoffPath.includes("cg-handoff-2"));
668
+ assert.ok(
669
+ !fs.readFileSync(result.handoffPath, "utf8").includes("Label:"),
670
+ );
671
+ } finally {
672
+ process.chdir(origCwd);
673
+ }
674
+ });
675
+ });
676
+
677
+ // ---------------------------------------------------------------------------
678
+ // relativeTime — edge cases
679
+ // ---------------------------------------------------------------------------
680
+
681
+ describe("relativeTime via formatRestoreMenu", () => {
682
+ it("shows days ago for old files", async () => {
683
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
684
+ const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
685
+ const menu = formatRestoreMenu(
686
+ [
687
+ {
688
+ path: "/tmp/fake.md",
689
+ name: "old session",
690
+ type: "handoff",
691
+ mtime: threeDaysAgo.getTime(),
692
+ size: 1000,
693
+ created: threeDaysAgo.toISOString(),
694
+ },
695
+ ],
696
+ {},
697
+ );
698
+ assert.ok(menu.includes("3 days ago"));
699
+ });
700
+
701
+ it("shows yesterday for 1 day old", async () => {
702
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
703
+ const yesterday = new Date(Date.now() - 25 * 60 * 60 * 1000);
704
+ const menu = formatRestoreMenu(
705
+ [
706
+ {
707
+ path: "/tmp/fake.md",
708
+ name: "yesterday session",
709
+ type: "handoff",
710
+ mtime: yesterday.getTime(),
711
+ size: 500,
712
+ created: yesterday.toISOString(),
713
+ },
714
+ ],
715
+ {},
716
+ );
717
+ assert.ok(menu.includes("yesterday"));
718
+ });
719
+
720
+ it("shows hours ago", async () => {
721
+ const { formatRestoreMenu } = await import("../lib/handoff.mjs");
722
+ const threeHoursAgo = new Date(Date.now() - 3 * 60 * 60 * 1000);
723
+ const menu = formatRestoreMenu(
724
+ [
725
+ {
726
+ path: "/tmp/fake.md",
727
+ name: "recent session",
728
+ type: "handoff",
729
+ mtime: threeHoursAgo.getTime(),
730
+ size: 2000,
731
+ created: threeHoursAgo.toISOString(),
732
+ },
733
+ ],
734
+ {},
735
+ );
736
+ assert.ok(menu.includes("3 hours ago"));
737
+ });
738
+ });