@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,232 @@
|
|
|
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
|
+
const HOOK_PATH = path.resolve("hooks/submit.mjs");
|
|
9
|
+
|
|
10
|
+
let tmpDir;
|
|
11
|
+
let transcriptPath;
|
|
12
|
+
let cwd;
|
|
13
|
+
let dataDir;
|
|
14
|
+
function writeLine(obj) {
|
|
15
|
+
fs.appendFileSync(transcriptPath, `${JSON.stringify(obj)}\n`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function runHook(input) {
|
|
19
|
+
const stdin = JSON.stringify({
|
|
20
|
+
session_id: "test-session-1234",
|
|
21
|
+
prompt: input.prompt ?? "",
|
|
22
|
+
transcript_path: input.transcript_path ?? transcriptPath,
|
|
23
|
+
cwd: input.cwd ?? cwd,
|
|
24
|
+
...input,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const stdout = execFileSync("node", [HOOK_PATH], {
|
|
29
|
+
input: stdin,
|
|
30
|
+
encoding: "utf8",
|
|
31
|
+
timeout: 5000,
|
|
32
|
+
env: {
|
|
33
|
+
...process.env,
|
|
34
|
+
CLAUDE_PLUGIN_DATA: dataDir,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return stdout ? JSON.parse(stdout) : null;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e.status === 0 && !e.stdout?.trim()) return null;
|
|
40
|
+
if (e.status === 0 && e.stdout?.trim()) return JSON.parse(e.stdout);
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const HIGH_USAGE = {
|
|
46
|
+
input_tokens: 5000,
|
|
47
|
+
cache_creation_input_tokens: 0,
|
|
48
|
+
cache_read_input_tokens: 0,
|
|
49
|
+
output_tokens: 10,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const LOW_USAGE = {
|
|
53
|
+
input_tokens: 5,
|
|
54
|
+
cache_creation_input_tokens: 0,
|
|
55
|
+
cache_read_input_tokens: 0,
|
|
56
|
+
output_tokens: 2,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function makeAssistant(text, usage, model) {
|
|
60
|
+
return {
|
|
61
|
+
type: "assistant",
|
|
62
|
+
message: {
|
|
63
|
+
role: "assistant",
|
|
64
|
+
model: model || "claude-sonnet-4-20250514",
|
|
65
|
+
content: [{ type: "text", text }],
|
|
66
|
+
usage: usage || undefined,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeUser(text) {
|
|
72
|
+
return { type: "user", message: { role: "user", content: text } };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cg-submit-"));
|
|
77
|
+
cwd = path.join(tmpDir, "project");
|
|
78
|
+
dataDir = path.join(tmpDir, "data");
|
|
79
|
+
fs.mkdirSync(cwd, { recursive: true });
|
|
80
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
81
|
+
transcriptPath = path.join(tmpDir, "transcript.jsonl");
|
|
82
|
+
fs.writeFileSync(
|
|
83
|
+
path.join(dataDir, "config.json"),
|
|
84
|
+
JSON.stringify({ threshold: 0.01, max_tokens: 200000 }),
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
afterEach(() => {
|
|
89
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// =========================================================================
|
|
93
|
+
// Slash command bypass
|
|
94
|
+
// =========================================================================
|
|
95
|
+
describe("slash command bypass", () => {
|
|
96
|
+
it("exits silently for slash commands", () => {
|
|
97
|
+
writeLine(makeUser("hello"));
|
|
98
|
+
writeLine(makeAssistant("hi", HIGH_USAGE));
|
|
99
|
+
const result = runHook({ prompt: "/cg:stats" });
|
|
100
|
+
assert.equal(result, null);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// =========================================================================
|
|
105
|
+
// Token state writing
|
|
106
|
+
// =========================================================================
|
|
107
|
+
describe("token state writing", () => {
|
|
108
|
+
it("writes state file with correct fields for high usage", () => {
|
|
109
|
+
writeLine(makeUser("hello"));
|
|
110
|
+
writeLine(makeAssistant("hi", HIGH_USAGE));
|
|
111
|
+
|
|
112
|
+
runHook({ prompt: "do something" });
|
|
113
|
+
|
|
114
|
+
const sf = path.join(dataDir, "state-test-session-1234.json");
|
|
115
|
+
assert.ok(fs.existsSync(sf));
|
|
116
|
+
const state = JSON.parse(fs.readFileSync(sf, "utf8"));
|
|
117
|
+
assert.equal(state.current_tokens, 5000);
|
|
118
|
+
assert.equal(state.session_id, "test-session-1234");
|
|
119
|
+
assert.equal(typeof state.headroom, "number");
|
|
120
|
+
assert.equal(typeof state.recommendation, "string");
|
|
121
|
+
assert.equal(typeof state.threshold, "number");
|
|
122
|
+
assert.equal(state.source, "real");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("writes state file for low usage", () => {
|
|
126
|
+
writeLine(makeUser("hi"));
|
|
127
|
+
writeLine(makeAssistant("hello", LOW_USAGE));
|
|
128
|
+
|
|
129
|
+
runHook({ prompt: "do something" });
|
|
130
|
+
|
|
131
|
+
const sf = path.join(dataDir, "state-test-session-1234.json");
|
|
132
|
+
assert.ok(fs.existsSync(sf));
|
|
133
|
+
const state = JSON.parse(fs.readFileSync(sf, "utf8"));
|
|
134
|
+
assert.equal(state.current_tokens, 5);
|
|
135
|
+
assert.ok(state.recommendation.includes("All clear"));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("includes savings estimates in state", () => {
|
|
139
|
+
writeLine(makeUser("hello"));
|
|
140
|
+
writeLine(makeAssistant("hi", HIGH_USAGE));
|
|
141
|
+
|
|
142
|
+
runHook({ prompt: "test" });
|
|
143
|
+
|
|
144
|
+
const sf = path.join(dataDir, "state-test-session-1234.json");
|
|
145
|
+
const state = JSON.parse(fs.readFileSync(sf, "utf8"));
|
|
146
|
+
assert.ok(
|
|
147
|
+
state.smart_estimate_pct != null,
|
|
148
|
+
"smart_estimate_pct should exist",
|
|
149
|
+
);
|
|
150
|
+
assert.ok(
|
|
151
|
+
state.recent_estimate_pct != null,
|
|
152
|
+
"recent_estimate_pct should exist",
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// =========================================================================
|
|
158
|
+
// No blocking — above threshold exits silently
|
|
159
|
+
// =========================================================================
|
|
160
|
+
describe("no blocking above threshold", () => {
|
|
161
|
+
it("exits silently when above threshold (no warning menu)", () => {
|
|
162
|
+
writeLine(makeUser("hello"));
|
|
163
|
+
writeLine(makeAssistant("hi", HIGH_USAGE));
|
|
164
|
+
|
|
165
|
+
const result = runHook({ prompt: "do something" });
|
|
166
|
+
// Should NOT block — just write state and exit
|
|
167
|
+
assert.equal(result, null);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("does not create any warning flag files", () => {
|
|
171
|
+
writeLine(makeUser("hello"));
|
|
172
|
+
writeLine(makeAssistant("hi", HIGH_USAGE));
|
|
173
|
+
|
|
174
|
+
runHook({ prompt: "do something" });
|
|
175
|
+
|
|
176
|
+
// No warning-related flags should exist
|
|
177
|
+
const claudeDir = path.join(cwd, ".claude");
|
|
178
|
+
fs.mkdirSync(claudeDir, { recursive: true });
|
|
179
|
+
const files = fs.readdirSync(claudeDir);
|
|
180
|
+
const warningFlags = files.filter(
|
|
181
|
+
(f) =>
|
|
182
|
+
f.includes("cg-warned") ||
|
|
183
|
+
f.includes("cg-menu") ||
|
|
184
|
+
f.includes("cg-prompt"),
|
|
185
|
+
);
|
|
186
|
+
assert.equal(warningFlags.length, 0);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("writes recommendation mentioning compaction at threshold", () => {
|
|
190
|
+
writeLine(makeUser("hello"));
|
|
191
|
+
writeLine(makeAssistant("hi", HIGH_USAGE));
|
|
192
|
+
|
|
193
|
+
runHook({ prompt: "test" });
|
|
194
|
+
|
|
195
|
+
const sf = path.join(dataDir, "state-test-session-1234.json");
|
|
196
|
+
const state = JSON.parse(fs.readFileSync(sf, "utf8"));
|
|
197
|
+
assert.ok(state.recommendation.includes("Compaction recommended"));
|
|
198
|
+
assert.ok(state.recommendation.includes("/cg:compact"));
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// =========================================================================
|
|
203
|
+
// Submit hook never injects additionalContext
|
|
204
|
+
// =========================================================================
|
|
205
|
+
describe("no additionalContext", () => {
|
|
206
|
+
it("submit hook never returns additionalContext", () => {
|
|
207
|
+
const usage = {
|
|
208
|
+
input_tokens: 130000,
|
|
209
|
+
cache_creation_input_tokens: 0,
|
|
210
|
+
cache_read_input_tokens: 0,
|
|
211
|
+
output_tokens: 10,
|
|
212
|
+
};
|
|
213
|
+
writeLine(makeUser("hello"));
|
|
214
|
+
writeLine(makeAssistant("hi", usage));
|
|
215
|
+
|
|
216
|
+
const result = runHook({ prompt: "do something" });
|
|
217
|
+
assert.equal(result, null);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// =========================================================================
|
|
222
|
+
// No transcript
|
|
223
|
+
// =========================================================================
|
|
224
|
+
describe("no transcript", () => {
|
|
225
|
+
it("exits silently when transcript path is missing", () => {
|
|
226
|
+
const result = runHook({
|
|
227
|
+
prompt: "hello",
|
|
228
|
+
transcript_path: "/nonexistent/transcript.jsonl",
|
|
229
|
+
});
|
|
230
|
+
assert.equal(result, null);
|
|
231
|
+
});
|
|
232
|
+
});
|