@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,600 @@
|
|
|
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
|
+
let tmpDir;
|
|
8
|
+
let dataDir;
|
|
9
|
+
let projectCwd;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-synthetic-test-"));
|
|
13
|
+
dataDir = path.join(tmpDir, "data");
|
|
14
|
+
projectCwd = path.join(tmpDir, "project");
|
|
15
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
16
|
+
fs.mkdirSync(projectCwd, { recursive: true });
|
|
17
|
+
process.env.CLAUDE_PLUGIN_DATA = dataDir;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
22
|
+
delete process.env.CLAUDE_PLUGIN_DATA;
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// We need a fresh import each time to avoid manifest caching issues.
|
|
26
|
+
// The module uses process.env at call time so re-import isn't strictly needed,
|
|
27
|
+
// but it keeps tests isolated from any module-level state.
|
|
28
|
+
async function loadModule() {
|
|
29
|
+
// Dynamic import with cache-busting query to get a fresh module each test
|
|
30
|
+
const mod = await import(
|
|
31
|
+
`../lib/synthetic-session.mjs?t=${Date.now()}-${Math.random()}`
|
|
32
|
+
);
|
|
33
|
+
return mod;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// writeSyntheticSession — JSONL structure
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
describe("writeSyntheticSession", () => {
|
|
41
|
+
it("writes a valid 3-line JSONL file", async () => {
|
|
42
|
+
const { writeSyntheticSession } = await loadModule();
|
|
43
|
+
const { sessionUuid, jsonlPath } = writeSyntheticSession({
|
|
44
|
+
checkpointContent: "# Checkpoint\nSome content here",
|
|
45
|
+
title: "cg",
|
|
46
|
+
projectCwd,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
assert.ok(sessionUuid, "should return a session UUID");
|
|
50
|
+
assert.ok(jsonlPath, "should return a JSONL path");
|
|
51
|
+
assert.ok(fs.existsSync(jsonlPath), "JSONL file should exist on disk");
|
|
52
|
+
|
|
53
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
54
|
+
assert.equal(lines.length, 3, "should have exactly 3 lines");
|
|
55
|
+
|
|
56
|
+
const userMsg = JSON.parse(lines[0]);
|
|
57
|
+
const assistantMsg = JSON.parse(lines[1]);
|
|
58
|
+
const titleEntry = JSON.parse(lines[2]);
|
|
59
|
+
|
|
60
|
+
// Line 1: User message
|
|
61
|
+
assert.equal(userMsg.type, "user");
|
|
62
|
+
assert.equal(userMsg.message.role, "user");
|
|
63
|
+
assert.equal(userMsg.message.content, "# Checkpoint\nSome content here");
|
|
64
|
+
assert.equal(userMsg.sessionId, sessionUuid);
|
|
65
|
+
assert.equal(userMsg.cwd, projectCwd);
|
|
66
|
+
assert.equal(userMsg.parentUuid, null);
|
|
67
|
+
assert.equal(userMsg.isSidechain, false);
|
|
68
|
+
assert.equal(userMsg.userType, "external");
|
|
69
|
+
assert.ok(userMsg.uuid, "user message should have a uuid");
|
|
70
|
+
assert.ok(userMsg.timestamp, "user message should have a timestamp");
|
|
71
|
+
|
|
72
|
+
// Line 2: Assistant message
|
|
73
|
+
assert.equal(assistantMsg.type, "assistant");
|
|
74
|
+
assert.equal(assistantMsg.message.role, "assistant");
|
|
75
|
+
assert.ok(Array.isArray(assistantMsg.message.content));
|
|
76
|
+
assert.equal(assistantMsg.message.content[0].type, "text");
|
|
77
|
+
assert.ok(
|
|
78
|
+
assistantMsg.message.content[0].text.includes("Context restored"),
|
|
79
|
+
);
|
|
80
|
+
assert.equal(assistantMsg.message.stop_reason, "end_turn");
|
|
81
|
+
assert.equal(assistantMsg.parentUuid, userMsg.uuid);
|
|
82
|
+
assert.equal(assistantMsg.sessionId, sessionUuid);
|
|
83
|
+
assert.equal(assistantMsg.cwd, projectCwd);
|
|
84
|
+
|
|
85
|
+
// Line 3: Custom title
|
|
86
|
+
assert.equal(titleEntry.type, "custom-title");
|
|
87
|
+
assert.equal(titleEntry.customTitle, "cg");
|
|
88
|
+
assert.equal(titleEntry.sessionId, sessionUuid);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("writes to the correct sessions directory based on projectCwd", async () => {
|
|
92
|
+
const { writeSyntheticSession } = await loadModule();
|
|
93
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
94
|
+
checkpointContent: "test",
|
|
95
|
+
title: "cg",
|
|
96
|
+
projectCwd,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// The path should be under ~/.claude/projects/{sanitized_cwd}/
|
|
100
|
+
// sanitizeCwd replaces all non-alphanumeric chars with hyphens (matching CC)
|
|
101
|
+
const expectedDir = path.join(
|
|
102
|
+
os.homedir(),
|
|
103
|
+
".claude",
|
|
104
|
+
"projects",
|
|
105
|
+
projectCwd.replace(/[^a-zA-Z0-9]/g, "-"),
|
|
106
|
+
);
|
|
107
|
+
assert.ok(
|
|
108
|
+
jsonlPath.startsWith(expectedDir),
|
|
109
|
+
`path ${jsonlPath} should start with ${expectedDir}`,
|
|
110
|
+
);
|
|
111
|
+
assert.ok(jsonlPath.endsWith(".jsonl"));
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("uses the session UUID in the filename", async () => {
|
|
115
|
+
const { writeSyntheticSession } = await loadModule();
|
|
116
|
+
const { sessionUuid, jsonlPath } = writeSyntheticSession({
|
|
117
|
+
checkpointContent: "test",
|
|
118
|
+
title: "cg",
|
|
119
|
+
projectCwd,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
assert.ok(
|
|
123
|
+
jsonlPath.includes(sessionUuid),
|
|
124
|
+
"JSONL filename should contain the session UUID",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("sets file permissions to 0o600", async () => {
|
|
129
|
+
const { writeSyntheticSession } = await loadModule();
|
|
130
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
131
|
+
checkpointContent: "test",
|
|
132
|
+
title: "cg",
|
|
133
|
+
projectCwd,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const stat = fs.statSync(jsonlPath);
|
|
137
|
+
const mode = stat.mode & 0o777;
|
|
138
|
+
assert.equal(
|
|
139
|
+
mode,
|
|
140
|
+
0o600,
|
|
141
|
+
`file mode should be 0600, got ${mode.toString(8)}`,
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("assistant timestamp is 1ms after user timestamp", async () => {
|
|
146
|
+
const { writeSyntheticSession } = await loadModule();
|
|
147
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
148
|
+
checkpointContent: "test",
|
|
149
|
+
title: "cg",
|
|
150
|
+
projectCwd,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
154
|
+
const userTs = new Date(JSON.parse(lines[0]).timestamp).getTime();
|
|
155
|
+
const assistantTs = new Date(JSON.parse(lines[1]).timestamp).getTime();
|
|
156
|
+
assert.equal(assistantTs - userTs, 1, "assistant should be 1ms after user");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Manifest management
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
describe("manifest management", () => {
|
|
165
|
+
it("creates manifest on first write", async () => {
|
|
166
|
+
const { writeSyntheticSession } = await loadModule();
|
|
167
|
+
const manifestPath = path.join(dataDir, "synthetic-sessions.json");
|
|
168
|
+
assert.ok(!fs.existsSync(manifestPath), "manifest should not exist yet");
|
|
169
|
+
|
|
170
|
+
writeSyntheticSession({
|
|
171
|
+
checkpointContent: "test",
|
|
172
|
+
title: "cg",
|
|
173
|
+
projectCwd,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
assert.ok(fs.existsSync(manifestPath), "manifest should be created");
|
|
177
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
178
|
+
assert.ok(manifest.cg, "manifest should have 'cg' entry");
|
|
179
|
+
assert.ok(manifest.cg.uuid);
|
|
180
|
+
assert.ok(manifest.cg.path);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("tracks separate titles independently", async () => {
|
|
184
|
+
const { writeSyntheticSession } = await loadModule();
|
|
185
|
+
|
|
186
|
+
const r1 = writeSyntheticSession({
|
|
187
|
+
checkpointContent: "compact checkpoint",
|
|
188
|
+
title: "cg:a1b2",
|
|
189
|
+
type: "compact",
|
|
190
|
+
projectCwd,
|
|
191
|
+
});
|
|
192
|
+
const r2 = writeSyntheticSession({
|
|
193
|
+
checkpointContent: "handoff checkpoint",
|
|
194
|
+
title: "cg:my-feature",
|
|
195
|
+
type: "handoff",
|
|
196
|
+
projectCwd,
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const manifestPath = path.join(dataDir, "synthetic-sessions.json");
|
|
200
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
|
201
|
+
assert.equal(manifest["cg:a1b2"].uuid, r1.sessionUuid);
|
|
202
|
+
assert.equal(manifest["cg:a1b2"].type, "compact");
|
|
203
|
+
assert.equal(manifest["cg:my-feature"].uuid, r2.sessionUuid);
|
|
204
|
+
assert.equal(manifest["cg:my-feature"].type, "handoff");
|
|
205
|
+
|
|
206
|
+
// Both files should exist — handoff doesn't clean compact entries
|
|
207
|
+
assert.ok(fs.existsSync(r1.jsonlPath));
|
|
208
|
+
assert.ok(fs.existsSync(r2.jsonlPath));
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("sets manifest file permissions to 0o600", async () => {
|
|
212
|
+
const { writeSyntheticSession } = await loadModule();
|
|
213
|
+
writeSyntheticSession({
|
|
214
|
+
checkpointContent: "test",
|
|
215
|
+
title: "cg:f0f0",
|
|
216
|
+
type: "compact",
|
|
217
|
+
projectCwd,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const manifestPath = path.join(dataDir, "synthetic-sessions.json");
|
|
221
|
+
const stat = fs.statSync(manifestPath);
|
|
222
|
+
const mode = stat.mode & 0o777;
|
|
223
|
+
assert.equal(
|
|
224
|
+
mode,
|
|
225
|
+
0o600,
|
|
226
|
+
`manifest mode should be 0600, got ${mode.toString(8)}`,
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Cleanup of previous sessions
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
describe("previous session cleanup", () => {
|
|
236
|
+
it("new compact deletes previous compact JSONL", async () => {
|
|
237
|
+
const { writeSyntheticSession } = await loadModule();
|
|
238
|
+
|
|
239
|
+
const first = writeSyntheticSession({
|
|
240
|
+
checkpointContent: "first checkpoint",
|
|
241
|
+
title: "cg:aaaa",
|
|
242
|
+
type: "compact",
|
|
243
|
+
projectCwd,
|
|
244
|
+
});
|
|
245
|
+
assert.ok(fs.existsSync(first.jsonlPath));
|
|
246
|
+
|
|
247
|
+
const second = writeSyntheticSession({
|
|
248
|
+
checkpointContent: "second checkpoint",
|
|
249
|
+
title: "cg:bbbb",
|
|
250
|
+
type: "compact",
|
|
251
|
+
projectCwd,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// First compact should be deleted, second should exist
|
|
255
|
+
assert.ok(
|
|
256
|
+
!fs.existsSync(first.jsonlPath),
|
|
257
|
+
"previous compact JSONL should be deleted",
|
|
258
|
+
);
|
|
259
|
+
assert.ok(
|
|
260
|
+
fs.existsSync(second.jsonlPath),
|
|
261
|
+
"new compact JSONL should exist",
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("compact does not delete handoff JSONL", async () => {
|
|
266
|
+
const { writeSyntheticSession } = await loadModule();
|
|
267
|
+
|
|
268
|
+
const handoff = writeSyntheticSession({
|
|
269
|
+
checkpointContent: "handoff",
|
|
270
|
+
title: "cg:my-feature",
|
|
271
|
+
type: "handoff",
|
|
272
|
+
projectCwd,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const compact = writeSyntheticSession({
|
|
276
|
+
checkpointContent: "compact",
|
|
277
|
+
title: "cg:cccc",
|
|
278
|
+
type: "compact",
|
|
279
|
+
projectCwd,
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
assert.ok(
|
|
283
|
+
fs.existsSync(handoff.jsonlPath),
|
|
284
|
+
"handoff JSONL must survive compact cleanup",
|
|
285
|
+
);
|
|
286
|
+
assert.ok(fs.existsSync(compact.jsonlPath), "compact JSONL should exist");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("handoff replaces previous handoff with same title", async () => {
|
|
290
|
+
const { writeSyntheticSession } = await loadModule();
|
|
291
|
+
|
|
292
|
+
const first = writeSyntheticSession({
|
|
293
|
+
checkpointContent: "first handoff",
|
|
294
|
+
title: "cg:feature-x",
|
|
295
|
+
type: "handoff",
|
|
296
|
+
projectCwd,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
const second = writeSyntheticSession({
|
|
300
|
+
checkpointContent: "updated handoff",
|
|
301
|
+
title: "cg:feature-x",
|
|
302
|
+
type: "handoff",
|
|
303
|
+
projectCwd,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
assert.ok(
|
|
307
|
+
!fs.existsSync(first.jsonlPath),
|
|
308
|
+
"old handoff should be replaced",
|
|
309
|
+
);
|
|
310
|
+
assert.ok(fs.existsSync(second.jsonlPath), "new handoff should exist");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("handles missing previous file gracefully", async () => {
|
|
314
|
+
const { writeSyntheticSession } = await loadModule();
|
|
315
|
+
|
|
316
|
+
const first = writeSyntheticSession({
|
|
317
|
+
checkpointContent: "first",
|
|
318
|
+
title: "cg:dddd",
|
|
319
|
+
type: "compact",
|
|
320
|
+
projectCwd,
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
// Manually delete the file before the next write
|
|
324
|
+
fs.unlinkSync(first.jsonlPath);
|
|
325
|
+
|
|
326
|
+
// Should not throw
|
|
327
|
+
const second = writeSyntheticSession({
|
|
328
|
+
checkpointContent: "second",
|
|
329
|
+
title: "cg:eeee",
|
|
330
|
+
type: "compact",
|
|
331
|
+
projectCwd,
|
|
332
|
+
});
|
|
333
|
+
assert.ok(fs.existsSync(second.jsonlPath));
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("does not purge files with different custom titles", async () => {
|
|
337
|
+
const { writeSyntheticSession } = await loadModule();
|
|
338
|
+
|
|
339
|
+
const sessionsDir = path.join(
|
|
340
|
+
os.homedir(),
|
|
341
|
+
".claude",
|
|
342
|
+
"projects",
|
|
343
|
+
projectCwd.replace(/[^a-zA-Z0-9]/g, "-"),
|
|
344
|
+
);
|
|
345
|
+
fs.mkdirSync(sessionsDir, { recursive: true });
|
|
346
|
+
|
|
347
|
+
// Create a handoff synthetic session with a different title
|
|
348
|
+
const handoffUuid = "11111111-2222-3333-4444-555555555555";
|
|
349
|
+
const handoffContent = `${[
|
|
350
|
+
JSON.stringify({
|
|
351
|
+
type: "user",
|
|
352
|
+
message: { role: "user", content: "handoff" },
|
|
353
|
+
uuid: "h1",
|
|
354
|
+
parentUuid: null,
|
|
355
|
+
isSidechain: false,
|
|
356
|
+
timestamp: new Date().toISOString(),
|
|
357
|
+
userType: "external",
|
|
358
|
+
cwd: projectCwd,
|
|
359
|
+
sessionId: handoffUuid,
|
|
360
|
+
version: "1.0.0",
|
|
361
|
+
}),
|
|
362
|
+
JSON.stringify({
|
|
363
|
+
type: "assistant",
|
|
364
|
+
message: {
|
|
365
|
+
role: "assistant",
|
|
366
|
+
content: [{ type: "text", text: "ok" }],
|
|
367
|
+
stop_reason: "end_turn",
|
|
368
|
+
},
|
|
369
|
+
uuid: "h2",
|
|
370
|
+
parentUuid: "h1",
|
|
371
|
+
isSidechain: false,
|
|
372
|
+
timestamp: new Date().toISOString(),
|
|
373
|
+
userType: "external",
|
|
374
|
+
cwd: projectCwd,
|
|
375
|
+
sessionId: handoffUuid,
|
|
376
|
+
version: "1.0.0",
|
|
377
|
+
}),
|
|
378
|
+
JSON.stringify({
|
|
379
|
+
type: "custom-title",
|
|
380
|
+
customTitle: "cg:my-feature",
|
|
381
|
+
sessionId: handoffUuid,
|
|
382
|
+
}),
|
|
383
|
+
].join("\n")}\n`;
|
|
384
|
+
|
|
385
|
+
const handoffPath = path.join(sessionsDir, `${handoffUuid}.jsonl`);
|
|
386
|
+
fs.writeFileSync(handoffPath, handoffContent);
|
|
387
|
+
|
|
388
|
+
// Write a compact session with title "cg"
|
|
389
|
+
writeSyntheticSession({
|
|
390
|
+
checkpointContent: "compact",
|
|
391
|
+
title: "cg",
|
|
392
|
+
projectCwd,
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// The handoff file should NOT be purged
|
|
396
|
+
assert.ok(
|
|
397
|
+
fs.existsSync(handoffPath),
|
|
398
|
+
"different-title file should not be purged",
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// Title handling
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
describe("title handling", () => {
|
|
408
|
+
it("supports bare 'cg' title for compact", async () => {
|
|
409
|
+
const { writeSyntheticSession } = await loadModule();
|
|
410
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
411
|
+
checkpointContent: "test",
|
|
412
|
+
title: "cg",
|
|
413
|
+
projectCwd,
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
417
|
+
const titleEntry = JSON.parse(lines[2]);
|
|
418
|
+
assert.equal(titleEntry.customTitle, "cg");
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it("supports 'cg:{label}' title for handoff", async () => {
|
|
422
|
+
const { writeSyntheticSession } = await loadModule();
|
|
423
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
424
|
+
checkpointContent: "test",
|
|
425
|
+
title: "cg:my-auth-refactor",
|
|
426
|
+
projectCwd,
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
430
|
+
const titleEntry = JSON.parse(lines[2]);
|
|
431
|
+
assert.equal(titleEntry.customTitle, "cg:my-auth-refactor");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("supports titles with special characters", async () => {
|
|
435
|
+
const { writeSyntheticSession } = await loadModule();
|
|
436
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
437
|
+
checkpointContent: "test",
|
|
438
|
+
title: "cg:Fix bug #123 (urgent!)",
|
|
439
|
+
projectCwd,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
443
|
+
const titleEntry = JSON.parse(lines[2]);
|
|
444
|
+
assert.equal(titleEntry.customTitle, "cg:Fix bug #123 (urgent!)");
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// CLAUDE_PLUGIN_DATA fallback
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
describe("CLAUDE_PLUGIN_DATA fallback", () => {
|
|
453
|
+
it("uses ~/.claude/cg/ when CLAUDE_PLUGIN_DATA is unset", async () => {
|
|
454
|
+
delete process.env.CLAUDE_PLUGIN_DATA;
|
|
455
|
+
const { writeSyntheticSession } = await loadModule();
|
|
456
|
+
|
|
457
|
+
writeSyntheticSession({
|
|
458
|
+
checkpointContent: "test",
|
|
459
|
+
title: "cg",
|
|
460
|
+
projectCwd,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const fallbackManifest = path.join(
|
|
464
|
+
os.homedir(),
|
|
465
|
+
".claude",
|
|
466
|
+
"cg",
|
|
467
|
+
"synthetic-sessions.json",
|
|
468
|
+
);
|
|
469
|
+
assert.ok(
|
|
470
|
+
fs.existsSync(fallbackManifest),
|
|
471
|
+
"manifest should be at fallback location",
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
// Clean up
|
|
475
|
+
try {
|
|
476
|
+
fs.unlinkSync(fallbackManifest);
|
|
477
|
+
} catch {}
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// Edge cases
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
describe("edge cases", () => {
|
|
486
|
+
it("handles empty checkpoint content", async () => {
|
|
487
|
+
const { writeSyntheticSession } = await loadModule();
|
|
488
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
489
|
+
checkpointContent: "",
|
|
490
|
+
title: "cg",
|
|
491
|
+
projectCwd,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
495
|
+
const userMsg = JSON.parse(lines[0]);
|
|
496
|
+
assert.equal(userMsg.message.content, "");
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("handles very large checkpoint content", async () => {
|
|
500
|
+
const { writeSyntheticSession } = await loadModule();
|
|
501
|
+
const largeContent = "x".repeat(500_000);
|
|
502
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
503
|
+
checkpointContent: largeContent,
|
|
504
|
+
title: "cg",
|
|
505
|
+
projectCwd,
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
509
|
+
const userMsg = JSON.parse(lines[0]);
|
|
510
|
+
assert.equal(userMsg.message.content.length, 500_000);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("handles checkpoint content with special JSON characters", async () => {
|
|
514
|
+
const { writeSyntheticSession } = await loadModule();
|
|
515
|
+
const content =
|
|
516
|
+
'Line with "quotes" and \\ backslashes\nand\ttabs\nand unicode: \u00e9\u00e0\u00fc';
|
|
517
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
518
|
+
checkpointContent: content,
|
|
519
|
+
title: "cg",
|
|
520
|
+
projectCwd,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
const lines = fs.readFileSync(jsonlPath, "utf8").trim().split("\n");
|
|
524
|
+
const userMsg = JSON.parse(lines[0]);
|
|
525
|
+
assert.equal(userMsg.message.content, content);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("generates unique UUIDs across calls", async () => {
|
|
529
|
+
const { writeSyntheticSession } = await loadModule();
|
|
530
|
+
const uuids = new Set();
|
|
531
|
+
for (let i = 0; i < 5; i++) {
|
|
532
|
+
const { sessionUuid } = writeSyntheticSession({
|
|
533
|
+
checkpointContent: `checkpoint ${i}`,
|
|
534
|
+
title: `cg:test-${i}`,
|
|
535
|
+
projectCwd,
|
|
536
|
+
});
|
|
537
|
+
uuids.add(sessionUuid);
|
|
538
|
+
}
|
|
539
|
+
assert.equal(uuids.size, 5, "all session UUIDs should be unique");
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("creates sessions directory if it does not exist", async () => {
|
|
543
|
+
const { writeSyntheticSession } = await loadModule();
|
|
544
|
+
// Use a nested project path that definitely doesn't exist as a sessions dir
|
|
545
|
+
const deepProject = path.join(tmpDir, "deep", "nested", "project");
|
|
546
|
+
fs.mkdirSync(deepProject, { recursive: true });
|
|
547
|
+
|
|
548
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
549
|
+
checkpointContent: "test",
|
|
550
|
+
title: "cg",
|
|
551
|
+
projectCwd: deepProject,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
assert.ok(fs.existsSync(jsonlPath));
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
it("handles corrupt manifest gracefully", async () => {
|
|
558
|
+
const { writeSyntheticSession } = await loadModule();
|
|
559
|
+
// Write corrupt manifest
|
|
560
|
+
fs.writeFileSync(
|
|
561
|
+
path.join(dataDir, "synthetic-sessions.json"),
|
|
562
|
+
"not json{{{",
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
// Should not throw — readManifest returns {} on parse failure
|
|
566
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
567
|
+
checkpointContent: "test",
|
|
568
|
+
title: "cg",
|
|
569
|
+
projectCwd,
|
|
570
|
+
});
|
|
571
|
+
assert.ok(fs.existsSync(jsonlPath));
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("handles very long project paths with hash truncation", async () => {
|
|
575
|
+
const { writeSyntheticSession } = await loadModule();
|
|
576
|
+
// Build a path that after sanitization exceeds 200 chars
|
|
577
|
+
const longSegment = "a".repeat(60);
|
|
578
|
+
const longPath = `/${longSegment}/${longSegment}/${longSegment}/${longSegment}`;
|
|
579
|
+
// Sanitized length: 4*60 + 4 slashes = 244 chars > 200
|
|
580
|
+
|
|
581
|
+
const { jsonlPath } = writeSyntheticSession({
|
|
582
|
+
checkpointContent: "test",
|
|
583
|
+
title: "cg",
|
|
584
|
+
projectCwd: longPath,
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
assert.ok(fs.existsSync(jsonlPath));
|
|
588
|
+
// The directory name should be truncated with a hash suffix
|
|
589
|
+
const dirName = path.basename(path.dirname(jsonlPath));
|
|
590
|
+
assert.ok(
|
|
591
|
+
dirName.length > 200,
|
|
592
|
+
"directory name should include hash suffix beyond 200 chars",
|
|
593
|
+
);
|
|
594
|
+
// Should contain the hash separator
|
|
595
|
+
assert.ok(
|
|
596
|
+
dirName.startsWith(`-${longSegment}`),
|
|
597
|
+
"should start with sanitized path prefix",
|
|
598
|
+
);
|
|
599
|
+
});
|
|
600
|
+
});
|