@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,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
+ });