@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
package/biome.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
3
+ "vcs": {
4
+ "enabled": true,
5
+ "clientKind": "git",
6
+ "useIgnoreFile": true
7
+ },
8
+ "files": {
9
+ "ignoreUnknown": false
10
+ },
11
+ "formatter": {
12
+ "enabled": true,
13
+ "indentStyle": "tab"
14
+ },
15
+ "linter": {
16
+ "enabled": true,
17
+ "rules": {
18
+ "recommended": true
19
+ }
20
+ },
21
+ "javascript": {
22
+ "formatter": {
23
+ "quoteStyle": "double"
24
+ }
25
+ },
26
+ "assist": {
27
+ "enabled": true,
28
+ "actions": {
29
+ "source": {
30
+ "organizeImports": "on"
31
+ }
32
+ }
33
+ }
34
+ }
package/bun.lock ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "context-guardian",
7
+ "devDependencies": {
8
+ "@biomejs/biome": "^2.4.10",
9
+ },
10
+ },
11
+ },
12
+ "packages": {
13
+ "@biomejs/biome": ["@biomejs/biome@2.4.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.10", "@biomejs/cli-darwin-x64": "2.4.10", "@biomejs/cli-linux-arm64": "2.4.10", "@biomejs/cli-linux-arm64-musl": "2.4.10", "@biomejs/cli-linux-x64": "2.4.10", "@biomejs/cli-linux-x64-musl": "2.4.10", "@biomejs/cli-win32-arm64": "2.4.10", "@biomejs/cli-win32-x64": "2.4.10" }, "bin": { "biome": "bin/biome" } }, "sha512-xxA3AphFQ1geij4JTHXv4EeSTda1IFn22ye9LdyVPoJU19fNVl0uzfEuhsfQ4Yue/0FaLs2/ccVi4UDiE7R30w=="],
14
+
15
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vuzzI1cWqDVzOMIkYyHbKqp+AkQq4K7k+UCXWpkYcY/HDn1UxdsbsfgtVpa40shem8Kax4TLDLlx8kMAecgqiw=="],
16
+
17
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-14fzASRo+BPotwp7nWULy2W5xeUyFnTaq1V13Etrrxkrih+ez/2QfgFm5Ehtf5vSjtgx/IJycMMpn5kPd5ZNaA=="],
18
+
19
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-7MH1CMW5uuxQ/s7FLST63qF8B3Hgu2HRdZ7tA1X1+mk+St4JOuIrqdhIBnnyqeyWJNI+Bww7Es5QZ0wIc1Cmkw=="],
20
+
21
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-WrJY6UuiSD/Dh+nwK2qOTu8kdMDlLV3dLMmychIghHPAysWFq1/DGC1pVZx8POE3ZkzKR3PUUnVrtZfMfaJjyQ=="],
22
+
23
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-tZLvEEi2u9Xu1zAqRjTcpIDGVtldigVvzug2fTuPG0ME/g8/mXpRPcNgLB22bGn6FvLJpHHnqLnwliOu8xjYrg=="],
24
+
25
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.10", "", { "os": "linux", "cpu": "x64" }, "sha512-kDTi3pI6PBN6CiczsWYOyP2zk0IJI08EWEQyDMQWW221rPaaEz6FvjLhnU07KMzLv8q3qSuoB93ua6inSQ55Tw=="],
26
+
27
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-umwQU6qPzH+ISTf/eHyJ/QoQnJs3V9Vpjz2OjZXe9MVBZ7prgGafMy7yYeRGnlmDAn87AKTF3Q6weLoMGpeqdQ=="],
28
+
29
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.10", "", { "os": "win32", "cpu": "x64" }, "sha512-aW/JU5GuyH4uxMrNYpoC2kjaHlyJGLgIa3XkhPEZI0uKhZhJZU8BuEyJmvgzSPQNGozBwWjC972RaNdcJ9KyJg=="],
30
+ }
31
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * PreCompact hook — safety net for native auto-compaction.
4
+ *
5
+ * When Claude Code's built-in compaction fires (auto or manual /compact),
6
+ * this hook runs CG's deterministic extraction and injects it as
7
+ * additionalContext. The native compaction model then works with pre-cleaned
8
+ * input, producing a better summary than it would from the raw transcript.
9
+ *
10
+ * This is a silent safety net — no user-facing output.
11
+ *
12
+ * @module precompact-hook
13
+ */
14
+ import fs from "node:fs";
15
+ import { log } from "../lib/logger.mjs";
16
+ import { extractConversation } from "../lib/transcript.mjs";
17
+
18
+ let input;
19
+ try {
20
+ input = JSON.parse(fs.readFileSync(0, "utf8"));
21
+ } catch (e) {
22
+ process.stderr.write(`cg: precompact parse error: ${e.message}\n`);
23
+ process.exit(0);
24
+ }
25
+
26
+ const { session_id = "unknown", transcript_path, trigger = "unknown" } = input;
27
+ log(`PRECOMPACT session=${session_id} trigger=${trigger}`);
28
+
29
+ if (!transcript_path || !fs.existsSync(transcript_path)) {
30
+ log(`precompact-skip: no transcript`);
31
+ process.exit(0);
32
+ }
33
+
34
+ try {
35
+ const extraction = extractConversation(transcript_path);
36
+
37
+ if (!extraction || extraction === "(no transcript available)") {
38
+ log(`precompact-skip: empty extraction`);
39
+ process.exit(0);
40
+ }
41
+
42
+ // Inject the extraction as context for the compaction model.
43
+ // Limit to 50K chars to avoid overwhelming the compaction prompt.
44
+ const MAX_INJECT = 50000;
45
+ const trimmed =
46
+ extraction.length > MAX_INJECT
47
+ ? `${extraction.slice(0, MAX_INJECT)}\n\n[...extraction truncated at ${MAX_INJECT} chars for compaction input...]`
48
+ : extraction;
49
+
50
+ const output = {
51
+ hookSpecificOutput: {
52
+ hookEventName: "PreCompact",
53
+ additionalContext: [
54
+ "[Context Guardian — Pre-Compaction Extraction]",
55
+ "The following is a high-fidelity deterministic extraction of the conversation.",
56
+ "Tool outputs that can be re-obtained (file reads, search results) have been stripped.",
57
+ "All user messages, assistant reasoning, code changes, and command outputs are preserved.",
58
+ "Use this extraction as the primary input for your compaction. It is already noise-reduced — prefer keeping its content over re-summarizing.",
59
+ "",
60
+ trimmed,
61
+ ].join("\n"),
62
+ },
63
+ };
64
+
65
+ process.stdout.write(JSON.stringify(output));
66
+ log(
67
+ `precompact-injected session=${session_id} chars=${extraction.length} injected=${trimmed.length}`,
68
+ );
69
+ } catch (e) {
70
+ log(`precompact-error: ${e.message}`);
71
+ // Fail silently — don't block compaction
72
+ process.exit(0);
73
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { log } from "../lib/logger.mjs";
6
+ import { atomicWriteFileSync, resolveDataDir } from "../lib/paths.mjs";
7
+
8
+ let input;
9
+ try {
10
+ input = JSON.parse(fs.readFileSync(0, "utf8"));
11
+ } catch (e) {
12
+ process.stderr.write(`cg: failed to parse stdin: ${e.message}\n`);
13
+ process.exit(0);
14
+ }
15
+
16
+ const STALE_MS = 30 * 60 * 1000;
17
+
18
+ // Clean up stale session-scoped state files (state-*.json) in data dir.
19
+ // Each session writes its own state file; old ones accumulate.
20
+ const dataDir = resolveDataDir();
21
+ if (fs.existsSync(dataDir)) {
22
+ try {
23
+ const now3 = Date.now();
24
+ for (const f of fs
25
+ .readdirSync(dataDir)
26
+ .filter((f) => f.startsWith("state-") && f.endsWith(".json"))) {
27
+ const filePath = path.join(dataDir, f);
28
+ try {
29
+ if (now3 - fs.statSync(filePath).mtimeMs > STALE_MS) {
30
+ fs.unlinkSync(filePath);
31
+ }
32
+ } catch {}
33
+ }
34
+ } catch {}
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Compact synthetics use unique titles (cg:{hash}) per cycle, so stale-title
39
+ // collisions are no longer possible. No defensive purge needed at startup.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Self-healing: if the marketplace repo dir is missing, background-clone it.
44
+ // Claude Code resolves CLAUDE_PLUGIN_ROOT from the marketplace location for
45
+ // some hooks; if that dir doesn't exist, hooks fail with
46
+ // "Plugin directory does not exist". Fire-and-forget so we don't block startup.
47
+ // ---------------------------------------------------------------------------
48
+ try {
49
+ const knownPath = path.join(
50
+ os.homedir(),
51
+ ".claude",
52
+ "plugins",
53
+ "known_marketplaces.json",
54
+ );
55
+ if (fs.existsSync(knownPath)) {
56
+ const known = JSON.parse(fs.readFileSync(knownPath, "utf8"));
57
+ const entry = known["context-guardian"];
58
+ if (entry?.installLocation && !fs.existsSync(entry.installLocation)) {
59
+ const url =
60
+ entry.source?.url ||
61
+ (entry.source?.repo
62
+ ? `https://github.com/${entry.source.repo}.git`
63
+ : null);
64
+ if (url?.startsWith("https://")) {
65
+ log(
66
+ `self-heal: marketplace dir missing at ${entry.installLocation}, cloning from ${url}`,
67
+ );
68
+ const { spawn } = await import("node:child_process");
69
+ const child = spawn(
70
+ "git",
71
+ ["clone", "--depth", "1", url, entry.installLocation],
72
+ { stdio: "ignore", detached: true },
73
+ );
74
+ child.on("error", (e) => log(`self-heal-clone-error: ${e.message}`));
75
+ child.unref();
76
+ }
77
+ }
78
+ }
79
+ } catch (e) {
80
+ log(`self-heal-error: ${e.message}`);
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Statusline dominance — the statusline is CG's primary UX for context
85
+ // pressure. We ensure it's always configured and reclaim it if overwritten.
86
+ // Takes effect next session (Claude Code reads settings at startup, before hooks).
87
+ // ---------------------------------------------------------------------------
88
+ let statuslineReclaimed = false;
89
+ try {
90
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
91
+ let settings = {};
92
+ if (fs.existsSync(settingsPath)) {
93
+ settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
94
+ }
95
+ const pluginRoot =
96
+ process.env.CLAUDE_PLUGIN_ROOT || path.resolve(import.meta.dirname, "..");
97
+ const statuslineCmd = `node ${pluginRoot}/lib/statusline.mjs`;
98
+ const isCG = settings.statusLine?.command?.includes("statusline.mjs");
99
+
100
+ if (!settings.statusLine) {
101
+ // No statusline configured — set ours
102
+ settings.statusLine = { type: "command", command: statuslineCmd };
103
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
104
+ atomicWriteFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
105
+ log("auto-configured statusline in settings.json");
106
+ } else if (!isCG) {
107
+ // Another statusline is configured — reclaim it for CG
108
+ const prev = settings.statusLine.command || "(unknown)";
109
+ settings.statusLine = { type: "command", command: statuslineCmd };
110
+ atomicWriteFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
111
+ log(`statusline-reclaimed: overwriting "${prev}" with CG statusline`);
112
+ statuslineReclaimed = true;
113
+ }
114
+ } catch (e) {
115
+ log(`statusline-autoconfig-error: ${e.message}`);
116
+ }
117
+
118
+ log(
119
+ `session-start session=${input.session_id || "unknown"} cwd=${input.cwd || "unknown"}`,
120
+ );
121
+
122
+ // Warn user if statusline was reclaimed from another tool
123
+ if (statuslineReclaimed) {
124
+ process.stdout.write(
125
+ JSON.stringify({
126
+ hookSpecificOutput: {
127
+ hookEventName: "SessionStart",
128
+ additionalContext:
129
+ "[Context Guardian] Statusline reclaimed — another tool had overwritten it. Takes effect next session.",
130
+ },
131
+ }),
132
+ );
133
+ }
package/hooks/stop.mjs ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import { loadConfig, resolveMaxTokens } from "../lib/config.mjs";
4
+ import { estimateSavings } from "../lib/estimate.mjs";
5
+ import { log } from "../lib/logger.mjs";
6
+ import {
7
+ atomicWriteFileSync,
8
+ ensureDataDir,
9
+ STATUSLINE_STATE_DIR,
10
+ stateFile,
11
+ statuslineStateFile,
12
+ } from "../lib/paths.mjs";
13
+ import { estimateTokens, getTokenUsage } from "../lib/tokens.mjs";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Stop hook — writes fresh token counts after each assistant response.
17
+ //
18
+ // PERFORMANCE: Does NOT call estimateSavings (which reads the full transcript).
19
+ // The submit hook already computed and saved savings estimates. This hook only
20
+ // updates the token counts (cheap — tail-reads 32KB) and carries forward the
21
+ // existing savings estimates from the state file.
22
+ // ---------------------------------------------------------------------------
23
+ let input;
24
+ try {
25
+ input = JSON.parse(fs.readFileSync(0, "utf8"));
26
+ } catch (e) {
27
+ process.stderr.write(`cg: failed to parse stdin: ${e.message}\n`);
28
+ process.exit(0);
29
+ }
30
+
31
+ const { session_id = "unknown", transcript_path } = input;
32
+ log(`STOP session=${session_id}`);
33
+
34
+ if (!transcript_path || !fs.existsSync(transcript_path)) process.exit(0);
35
+
36
+ // Measure raw transcript file size — proxy for API request payload size.
37
+ let payloadBytes = 0;
38
+ try {
39
+ payloadBytes = fs.statSync(transcript_path).size;
40
+ } catch {}
41
+
42
+ const cfg = loadConfig();
43
+ const threshold = cfg.threshold ?? 0.35;
44
+
45
+ const realUsage = getTokenUsage(transcript_path);
46
+ const currentTokens = realUsage
47
+ ? realUsage.current_tokens
48
+ : estimateTokens(transcript_path);
49
+ const maxTokens = realUsage?.max_tokens || resolveMaxTokens() || 200000;
50
+ const pct = currentTokens / maxTokens;
51
+ const source = realUsage ? "real" : "estimated";
52
+
53
+ const headroom = Math.max(0, Math.round(maxTokens * threshold - currentTokens));
54
+ const pctDisplay = (pct * 100).toFixed(1);
55
+ const thresholdDisplay = Math.round(threshold * 100);
56
+ let recommendation;
57
+ if (pct < threshold * 0.5)
58
+ recommendation = "All clear. Plenty of context remaining.";
59
+ else if (pct < threshold)
60
+ recommendation = "Approaching threshold. Consider wrapping up complex tasks.";
61
+ else
62
+ recommendation =
63
+ "At threshold. Compaction recommended — run /cg:compact or /cg:prune.";
64
+
65
+ // Don't overwrite a recent state file with estimated data — checkpoint writes
66
+ // or the submit hook may have written accurate post-compaction counts that we'd clobber.
67
+ if (source === "estimated") {
68
+ const sf = stateFile(session_id);
69
+ try {
70
+ const sfStat = fs.statSync(sf);
71
+ if (Date.now() - sfStat.mtimeMs < 30000) {
72
+ log(
73
+ `state-skip session=${session_id} — not overwriting recent state with estimate`,
74
+ );
75
+ process.exit(0);
76
+ }
77
+ } catch {}
78
+ }
79
+
80
+ // Carry forward savings estimates and baseline overhead from the existing state file.
81
+ // This avoids re-reading and re-parsing the full transcript (~50MB at scale).
82
+ let smartEstimatePct = 0;
83
+ let recentEstimatePct = 0;
84
+ let baselineOverhead = 0;
85
+ let baselineResponseCount = 0;
86
+ try {
87
+ const sf = stateFile(session_id);
88
+ if (fs.existsSync(sf)) {
89
+ const prev = JSON.parse(fs.readFileSync(sf, "utf8"));
90
+ smartEstimatePct = prev.smart_estimate_pct ?? 0;
91
+ recentEstimatePct = prev.recent_estimate_pct ?? 0;
92
+ baselineOverhead = prev.baseline_overhead ?? 0;
93
+ baselineResponseCount = prev.baseline_response_count ?? 0;
94
+ }
95
+ } catch (e) {
96
+ log(`state-read-error session=${session_id}: ${e.message}`);
97
+ }
98
+
99
+ if (baselineResponseCount < 2 && currentTokens > 0) {
100
+ if (baselineOverhead) {
101
+ baselineOverhead = Math.min(baselineOverhead, currentTokens);
102
+ } else {
103
+ baselineOverhead = currentTokens;
104
+ }
105
+ baselineResponseCount++;
106
+ log(
107
+ `baseline-overhead session=${session_id} tokens=${baselineOverhead} response=${baselineResponseCount}`,
108
+ );
109
+
110
+ // Recompute estimates now that we have the baseline — the submit hook ran
111
+ // before us and wrote 0 estimates because it didn't have the baseline yet.
112
+ try {
113
+ const savings = estimateSavings(
114
+ transcript_path,
115
+ currentTokens,
116
+ maxTokens,
117
+ baselineOverhead,
118
+ );
119
+ smartEstimatePct = savings.smartPct;
120
+ recentEstimatePct = savings.recentPct;
121
+ log(
122
+ `baseline-recompute session=${session_id} smart=${smartEstimatePct}% recent=${recentEstimatePct}%`,
123
+ );
124
+ } catch (e) {
125
+ log(`baseline-recompute-error: ${e.message}`);
126
+ }
127
+ }
128
+
129
+ try {
130
+ ensureDataDir();
131
+ const remaining = Math.max(
132
+ 0,
133
+ Math.round(thresholdDisplay - Number.parseFloat(pctDisplay)),
134
+ );
135
+ const stateJson = JSON.stringify({
136
+ current_tokens: currentTokens,
137
+ max_tokens: maxTokens,
138
+ pct,
139
+ pct_display: pctDisplay,
140
+ threshold,
141
+ threshold_display: thresholdDisplay,
142
+ remaining_to_alert: remaining,
143
+ headroom,
144
+ recommendation,
145
+ source,
146
+ model: realUsage?.model || "unknown",
147
+ smart_estimate_pct: smartEstimatePct,
148
+ recent_estimate_pct: recentEstimatePct,
149
+ baseline_overhead: baselineOverhead,
150
+ baseline_response_count: baselineResponseCount,
151
+ payload_bytes: payloadBytes,
152
+ session_id,
153
+ transcript_path,
154
+ ts: Date.now(),
155
+ });
156
+ atomicWriteFileSync(stateFile(session_id), stateJson);
157
+
158
+ // Also write to fixed fallback location so the statusline can find it
159
+ // (statusline process doesn't receive CLAUDE_PLUGIN_DATA).
160
+ const slFile = statuslineStateFile(session_id);
161
+ if (slFile !== stateFile(session_id)) {
162
+ fs.mkdirSync(STATUSLINE_STATE_DIR, { recursive: true });
163
+ atomicWriteFileSync(slFile, stateJson);
164
+ }
165
+ } catch (e) {
166
+ log(`state-write-error session=${session_id}: ${e.message}`);
167
+ process.stderr.write(`cg: state-write-error: ${e.message}\n`);
168
+ }
169
+
170
+ log(
171
+ `state-update session=${session_id} tokens=${currentTokens}/${maxTokens} pct=${pctDisplay}% source=${source}`,
172
+ );
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * UserPromptSubmit hook — Context Guardian's main entry point.
4
+ *
5
+ * Runs on every user message. Writes token usage state for the statusline
6
+ * and /cg:stats. Compaction is handled entirely by skills (compact-cli.mjs).
7
+ *
8
+ * @module submit-hook
9
+ */
10
+ import fs from "node:fs";
11
+ import { loadConfig, resolveMaxTokens } from "../lib/config.mjs";
12
+ import { estimateSavings } from "../lib/estimate.mjs";
13
+ import { log } from "../lib/logger.mjs";
14
+ import {
15
+ atomicWriteFileSync,
16
+ ensureDataDir,
17
+ STATUSLINE_STATE_DIR,
18
+ stateFile,
19
+ statuslineStateFile,
20
+ } from "../lib/paths.mjs";
21
+ import { estimateTokens, getTokenUsage } from "../lib/tokens.mjs";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Input
25
+ // ---------------------------------------------------------------------------
26
+ let input;
27
+ try {
28
+ input = JSON.parse(fs.readFileSync(0, "utf8"));
29
+ } catch (e) {
30
+ process.stderr.write(`cg: failed to parse stdin: ${e.message}\n`);
31
+ process.exit(0);
32
+ }
33
+ const { session_id = "unknown", transcript_path } = input;
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Token usage check — write state for statusline and /cg:stats
37
+ // ---------------------------------------------------------------------------
38
+ if (!transcript_path || !fs.existsSync(transcript_path)) process.exit(0);
39
+
40
+ // Measure raw transcript file size — proxy for API request payload size.
41
+ // The ~20MB API payload limit is separate from the token context window and
42
+ // can lock users out of a session entirely (can't even compact).
43
+ let payloadBytes = 0;
44
+ try {
45
+ payloadBytes = fs.statSync(transcript_path).size;
46
+ } catch {}
47
+
48
+ const cfg = loadConfig();
49
+ const threshold = cfg.threshold ?? 0.35;
50
+
51
+ const realUsage = getTokenUsage(transcript_path);
52
+ const currentTokens = realUsage
53
+ ? realUsage.current_tokens
54
+ : estimateTokens(transcript_path);
55
+ const maxTokens = realUsage?.max_tokens || resolveMaxTokens() || 200000;
56
+ const pct = currentTokens / maxTokens;
57
+ const source = realUsage ? "real" : "estimated";
58
+
59
+ log(
60
+ `check session=${session_id} tokens=${currentTokens}/${maxTokens} pct=${(pct * 100).toFixed(1)}% threshold=${(threshold * 100).toFixed(0)}% source=${source}`,
61
+ );
62
+
63
+ // Write state for statusline and /cg:stats
64
+ const headroom = Math.max(0, Math.round(maxTokens * threshold - currentTokens));
65
+ const pctDisplay = (pct * 100).toFixed(1);
66
+ const thresholdDisplay = Math.round(threshold * 100);
67
+ let recommendation;
68
+ if (pct < threshold * 0.5)
69
+ recommendation = "All clear. Plenty of context remaining.";
70
+ else if (pct < threshold)
71
+ recommendation = "Approaching threshold. Consider wrapping up complex tasks.";
72
+ else
73
+ recommendation =
74
+ "At threshold. Compaction recommended — run /cg:compact or /cg:prune.";
75
+
76
+ // Read measured baseline overhead from state (captured by stop hook on first response)
77
+ let baselineOverhead = 0;
78
+ try {
79
+ const sf = stateFile(session_id);
80
+ if (fs.existsSync(sf)) {
81
+ const prev = JSON.parse(fs.readFileSync(sf, "utf8"));
82
+ baselineOverhead = prev.baseline_overhead ?? 0;
83
+ }
84
+ } catch (e) {
85
+ log(`state-read-error session=${session_id}: ${e.message}`);
86
+ }
87
+
88
+ const savings = estimateSavings(
89
+ transcript_path,
90
+ currentTokens,
91
+ maxTokens,
92
+ baselineOverhead,
93
+ );
94
+
95
+ try {
96
+ ensureDataDir();
97
+ const remaining = Math.max(
98
+ 0,
99
+ Math.round(thresholdDisplay - Number.parseFloat(pctDisplay)),
100
+ );
101
+ const stateJson = JSON.stringify({
102
+ current_tokens: currentTokens,
103
+ max_tokens: maxTokens,
104
+ pct,
105
+ pct_display: pctDisplay,
106
+ threshold,
107
+ threshold_display: thresholdDisplay,
108
+ remaining_to_alert: remaining,
109
+ headroom,
110
+ recommendation,
111
+ source,
112
+ model: realUsage?.model || "unknown",
113
+ smart_estimate_pct: savings.smartPct,
114
+ recent_estimate_pct: savings.recentPct,
115
+ baseline_overhead: baselineOverhead,
116
+ payload_bytes: payloadBytes,
117
+ session_id,
118
+ transcript_path,
119
+ ts: Date.now(),
120
+ });
121
+ atomicWriteFileSync(stateFile(session_id), stateJson);
122
+
123
+ // Also write to fixed fallback location so the statusline can find it
124
+ // (statusline process doesn't receive CLAUDE_PLUGIN_DATA).
125
+ const slFile = statuslineStateFile(session_id);
126
+ if (slFile !== stateFile(session_id)) {
127
+ fs.mkdirSync(STATUSLINE_STATE_DIR, { recursive: true });
128
+ atomicWriteFileSync(slFile, stateJson);
129
+ }
130
+ } catch (e) {
131
+ log(`state-write-error session=${session_id}: ${e.message}`);
132
+ process.stderr.write(`cg: state-write-error: ${e.message}\n`);
133
+ }