@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.
- package/.claude-plugin/marketplace.json +29 -0
- package/.claude-plugin/plugin.json +63 -0
- package/.github/workflows/ci.yml +66 -0
- package/CLAUDE.md +132 -0
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/biome.json +34 -0
- package/bun.lock +31 -0
- package/hooks/precompact.mjs +73 -0
- package/hooks/session-start.mjs +133 -0
- package/hooks/stop.mjs +172 -0
- package/hooks/submit.mjs +133 -0
- package/lib/checkpoint.mjs +258 -0
- package/lib/compact-cli.mjs +124 -0
- package/lib/compact-output.mjs +350 -0
- package/lib/config.mjs +40 -0
- package/lib/content.mjs +33 -0
- package/lib/diagnostics.mjs +221 -0
- package/lib/estimate.mjs +254 -0
- package/lib/extract-helpers.mjs +869 -0
- package/lib/handoff.mjs +329 -0
- package/lib/logger.mjs +34 -0
- package/lib/mcp-tools.mjs +200 -0
- package/lib/paths.mjs +90 -0
- package/lib/stats.mjs +81 -0
- package/lib/statusline.mjs +123 -0
- package/lib/synthetic-session.mjs +273 -0
- package/lib/tokens.mjs +170 -0
- package/lib/tool-summary.mjs +399 -0
- package/lib/transcript.mjs +939 -0
- package/lib/trim.mjs +158 -0
- package/package.json +22 -0
- package/skills/compact/SKILL.md +20 -0
- package/skills/config/SKILL.md +70 -0
- package/skills/handoff/SKILL.md +26 -0
- package/skills/prune/SKILL.md +20 -0
- package/skills/stats/SKILL.md +100 -0
- package/sonar-project.properties +12 -0
- package/test/checkpoint.test.mjs +171 -0
- package/test/compact-cli.test.mjs +230 -0
- package/test/compact-output.test.mjs +284 -0
- package/test/compaction-e2e.test.mjs +809 -0
- package/test/content.test.mjs +86 -0
- package/test/diagnostics.test.mjs +188 -0
- package/test/edge-cases.test.mjs +543 -0
- package/test/estimate.test.mjs +262 -0
- package/test/extract-helpers-coverage.test.mjs +333 -0
- package/test/extract-helpers.test.mjs +234 -0
- package/test/handoff.test.mjs +738 -0
- package/test/integration.test.mjs +582 -0
- package/test/logger.test.mjs +70 -0
- package/test/manual-compaction-test.md +426 -0
- package/test/mcp-tools.test.mjs +443 -0
- package/test/paths.test.mjs +250 -0
- package/test/quick-compaction-test.md +191 -0
- package/test/stats.test.mjs +88 -0
- package/test/statusline.test.mjs +222 -0
- package/test/submit.test.mjs +232 -0
- package/test/synthetic-session.test.mjs +600 -0
- package/test/tokens.test.mjs +293 -0
- package/test/tool-summary.test.mjs +771 -0
- package/test/transcript-coverage.test.mjs +369 -0
- package/test/transcript.test.mjs +596 -0
- 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
|
+
});
|