@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,350 @@
1
+ /**
2
+ * Post-processing optimisations for compacted checkpoint output.
3
+ *
4
+ * Applies information density improvements after extraction:
5
+ * - Strips phatic assistant filler (R4)
6
+ * - Strips operational noise like stats boxes (R3)
7
+ * - Groups consecutive tool notes into single lines (R2)
8
+ * - Detects and strips project root from paths (R1)
9
+ * - Merges bare tool result lines into previous message
10
+ *
11
+ * @module compact-output
12
+ */
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Constants
16
+ // ---------------------------------------------------------------------------
17
+
18
+ /** Assistant messages that are ONLY collapsible tool notes. */
19
+ const COLLAPSIBLE_NOTE_RE =
20
+ /^(?:\*\*Assistant:\*\* )?→ (?:Read |Grep |Glob |Ran |Write |Serena: (?:find |get |search |list ))/;
21
+
22
+ /** Phatic assistant filler — trivial responses that add no recall value. */
23
+ const PHATIC_RE =
24
+ /^\*\*Assistant:\*\* (?:Confirmed[. —!]|Ready[. —!]|Starting all |Memories checked|Looking at the |Moving on to |Done[.!]|Got it[.!]|File (?:created|edited)[.!])/;
25
+
26
+ /** Meta-tool invocations that are infrastructure, not session content. */
27
+ const META_TOOL_RE =
28
+ /^→ (?:Tool: `?ToolSearch|Serena: list.memor|Serena: check.onboard)/;
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Noise detection
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /**
35
+ * Operational content that becomes meaningless after checkpoint restore.
36
+ */
37
+ function isOperationalNoise(msg) {
38
+ const isAsst = msg.startsWith("**Assistant:**");
39
+ if (!isAsst && !msg.startsWith("→") && !msg.startsWith("←")) return false;
40
+
41
+ const text = msg.replace("**Assistant:** ", "");
42
+
43
+ // CG stats boxes
44
+ if (text.includes("Context Guardian Stats") && text.includes("┌"))
45
+ return true;
46
+ if (text.includes('"success":true') && text.includes("statsBlock"))
47
+ return true;
48
+ if (text.includes("Checkpoint saved") && text.includes("NOT applied"))
49
+ return true;
50
+ // CG operational tool calls
51
+ if (text.startsWith("→ Ran `date +%s`")) return true;
52
+ if (/^→ Read .*state-.*\.json/.test(text)) return true;
53
+ // Meta-tool invocations (ToolSearch, memory checks)
54
+ if (META_TOOL_RE.test(text)) return true;
55
+ if (META_TOOL_RE.test(msg)) return true;
56
+ // Diagnostics JSON output
57
+ if (/^\{?"checks"/.test(text) || /diagnostics\.mjs/.test(text)) return true;
58
+ if (/← \{"checks"/.test(msg)) return true;
59
+ // Bare diagnostics run
60
+ if (/^→ Ran.*diagnostics/.test(text)) return true;
61
+ return false;
62
+ }
63
+
64
+ // ---------------------------------------------------------------------------
65
+ // Phase helpers (extracted for cognitive complexity)
66
+ // ---------------------------------------------------------------------------
67
+
68
+ /**
69
+ * Phase 1: Strip noise and meta-tool content.
70
+ * @param {string[]} messages
71
+ * @returns {string[]}
72
+ */
73
+ function filterNoise(messages) {
74
+ return messages.filter((msg) => {
75
+ if (PHATIC_RE.test(msg) && msg.length < 200) return false;
76
+ if (isOperationalNoise(msg)) return false;
77
+ return true;
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Classify an assistant message body as trivial (should be skipped).
83
+ * @param {string} body - The text after "**Assistant:** "
84
+ * @returns {boolean}
85
+ */
86
+ function isTrivialAssistantBody(body) {
87
+ return (
88
+ !body || /^(?:Done\.?|Got it\.?|File (?:created|edited)\.?)$/i.test(body)
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Decide whether an assistant line should merge without re-prefixing.
94
+ * @param {string} lastLine - Previous line in the exchange
95
+ * @returns {boolean}
96
+ */
97
+ function shouldMergeAssistant(lastLine) {
98
+ return (
99
+ lastLine.startsWith("**Assistant:**") ||
100
+ /^[→←]/.test(lastLine) ||
101
+ /^[→←]/.test(lastLine.trim())
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Handle a single assistant message within an exchange.
107
+ * Merges or prefixes based on previous line context.
108
+ * @param {string} msg - The raw message
109
+ * @param {object} current - The current exchange { lines: string[] }
110
+ * @returns {void}
111
+ */
112
+ function handleAssistantLine(msg, current) {
113
+ const body = msg.slice(14).trim();
114
+ if (isTrivialAssistantBody(body)) return;
115
+
116
+ const lastLine = current.lines.at(-1);
117
+ if (shouldMergeAssistant(lastLine)) {
118
+ current.lines.push(body);
119
+ } else {
120
+ current.lines.push(`**Assistant:** ${body}`);
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Handle a pre-first-user message (startup noise).
126
+ * @param {string} msg
127
+ * @param {object[]} exchanges
128
+ */
129
+ function handlePreUserMessage(msg, exchanges) {
130
+ if (msg.length > 50 && !isOperationalNoise(msg)) {
131
+ if (!exchanges.length) exchanges.push({ lines: [] });
132
+ exchanges[0].lines.push(msg);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Phase 2: Group filtered messages into exchanges.
138
+ * An exchange = one User message + all following Asst/tool messages until next User.
139
+ * @param {string[]} filtered
140
+ * @returns {object[]}
141
+ */
142
+ function groupIntoExchanges(filtered) {
143
+ const exchanges = [];
144
+ let current = null;
145
+
146
+ for (const msg of filtered) {
147
+ if (msg.startsWith("**User:**")) {
148
+ if (current) exchanges.push(current);
149
+ current = { lines: [msg] };
150
+ } else if (current) {
151
+ if (msg.startsWith("**Assistant:**")) {
152
+ handleAssistantLine(msg, current);
153
+ } else {
154
+ current.lines.push(msg);
155
+ }
156
+ } else {
157
+ handlePreUserMessage(msg, exchanges);
158
+ }
159
+ }
160
+ if (current) exchanges.push(current);
161
+ return exchanges;
162
+ }
163
+
164
+ /**
165
+ * Flush accumulated collapsible tool lines into the collapsed array.
166
+ * @param {string[]} toolBatch - Accumulated tool lines
167
+ * @param {string[]} collapsed - Output array
168
+ */
169
+ function flushTools(toolBatch, collapsed) {
170
+ if (toolBatch.length === 0) return;
171
+ if (toolBatch.length === 1) {
172
+ collapsed.push(toolBatch[0]);
173
+ } else {
174
+ const items = toolBatch.map((t) =>
175
+ t.replace(/^→ /, "").replaceAll("`", "").trim(),
176
+ );
177
+ collapsed.push(`→ ${items.join("; ")}`);
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Collapse consecutive collapsible tool lines in an exchange.
183
+ * @param {string[]} lines - Exchange lines
184
+ * @returns {string[]}
185
+ */
186
+ function collapseToolLines(lines) {
187
+ const collapsed = [];
188
+ const toolBatch = [];
189
+
190
+ for (const line of lines) {
191
+ if (isCollapsibleToolLine(line)) {
192
+ toolBatch.push(extractToolLine(line));
193
+ continue;
194
+ }
195
+ flushTools(toolBatch, collapsed);
196
+ toolBatch.length = 0;
197
+ collapsed.push(line);
198
+ }
199
+ flushTools(toolBatch, collapsed);
200
+ return collapsed;
201
+ }
202
+
203
+ /**
204
+ * Check if a line is a collapsible single-line tool note.
205
+ * @param {string} line
206
+ * @returns {boolean}
207
+ */
208
+ function isCollapsibleToolLine(line) {
209
+ if (
210
+ !COLLAPSIBLE_NOTE_RE.test(line) &&
211
+ !COLLAPSIBLE_NOTE_RE.test(`Asst: ${line}`)
212
+ ) {
213
+ return false;
214
+ }
215
+ const toolLine = extractToolLine(line);
216
+ return toolLine.startsWith("→") && toolLine.split("\n").length <= 2;
217
+ }
218
+
219
+ /**
220
+ * Extract the tool portion of a line, stripping "Asst:" prefix if present.
221
+ * @param {string} line
222
+ * @returns {string}
223
+ */
224
+ function extractToolLine(line) {
225
+ return line.startsWith("Asst:") ? line.slice(5).trim() : line;
226
+ }
227
+
228
+ /**
229
+ * Phase 4: Build output with [N] anchors and collapsed tool lines.
230
+ * @param {object[]} exchanges
231
+ * @returns {string[]}
232
+ */
233
+ function buildAnchoredOutput(exchanges) {
234
+ const result = [];
235
+ let exchangeNum = 0;
236
+
237
+ for (const ex of exchanges) {
238
+ const hasUser = ex.lines.some((l) => l.startsWith("**User:**"));
239
+ if (hasUser) exchangeNum++;
240
+
241
+ const collapsed = collapseToolLines(ex.lines);
242
+ if (collapsed.length === 0) continue;
243
+
244
+ const anchor = hasUser ? `[${exchangeNum}] ` : "";
245
+ const firstLine = collapsed[0];
246
+ const rest = collapsed.slice(1);
247
+
248
+ const block = [`${anchor}${firstLine}`, ...rest].join("\n");
249
+ result.push(block);
250
+ }
251
+
252
+ return result;
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Message compaction
257
+ // ---------------------------------------------------------------------------
258
+
259
+ /**
260
+ * Post-process messages for maximum information density.
261
+ * Groups messages into exchanges, strips noise, shortens prefixes,
262
+ * and adds [N] anchors for cross-reference with the Conversation Index.
263
+ *
264
+ * @param {string[]} messages - The formatted message strings
265
+ * @returns {string[]} Optimised exchange blocks (one entry per exchange)
266
+ */
267
+ export function compactMessages(messages) {
268
+ if (messages.length === 0) return messages;
269
+
270
+ const filtered = filterNoise(messages);
271
+ const exchanges = groupIntoExchanges(filtered);
272
+ return buildAnchoredOutput(exchanges);
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Project root detection
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * Collect file paths from tool_use blocks in assistant messages.
281
+ * @param {string[]} lines - Raw JSONL lines
282
+ * @param {number} startIdx - First line to scan
283
+ * @returns {string[]}
284
+ */
285
+ function collectToolPaths(lines, startIdx) {
286
+ const paths = [];
287
+ for (let i = startIdx; i < lines.length; i++) {
288
+ try {
289
+ const obj = JSON.parse(lines[i]);
290
+ if (obj.type !== "assistant" || !Array.isArray(obj.message?.content))
291
+ continue;
292
+ for (const b of obj.message.content) {
293
+ if (b.type !== "tool_use") continue;
294
+ const fp =
295
+ b.input?.file_path || b.input?.path || b.input?.relative_path || "";
296
+ if (fp.startsWith("/") && fp.split("/").filter(Boolean).length >= 3) {
297
+ paths.push(fp);
298
+ }
299
+ }
300
+ } catch {}
301
+ }
302
+ return paths;
303
+ }
304
+
305
+ /**
306
+ * Build a map of directory prefix → occurrence count from a list of paths.
307
+ * @param {string[]} paths
308
+ * @returns {Map<string, number>}
309
+ */
310
+ function buildPrefixCounts(paths) {
311
+ const counts = new Map();
312
+ for (const p of paths) {
313
+ const parts = p.split("/");
314
+ for (let len = 3; len < parts.length; len++) {
315
+ const prefix = `${parts.slice(0, len).join("/")}/`;
316
+ counts.set(prefix, (counts.get(prefix) || 0) + 1);
317
+ }
318
+ }
319
+ return counts;
320
+ }
321
+
322
+ /**
323
+ * Select the longest prefix covering at least 70% of paths.
324
+ * @param {Map<string, number>} counts
325
+ * @returns {string}
326
+ */
327
+ function selectBestPrefix(counts) {
328
+ const maxCount = Math.max(...counts.values());
329
+ const threshold = Math.floor(maxCount * 0.7);
330
+ let best = "";
331
+ for (const [prefix, count] of counts) {
332
+ if (count >= threshold && prefix.length > best.length) {
333
+ best = prefix;
334
+ }
335
+ }
336
+ return best && counts.get(best) >= 3 ? best : "";
337
+ }
338
+
339
+ /**
340
+ * Detect the project root directory from tool_use file paths in the transcript.
341
+ * Scans for Read/Edit/Write/Grep tool inputs and finds the most common
342
+ * directory prefix. Returns it with trailing slash, or empty string.
343
+ */
344
+ export function detectProjectRoot(lines, startIdx) {
345
+ const paths = collectToolPaths(lines, startIdx);
346
+ if (paths.length < 3) return "";
347
+
348
+ const counts = buildPrefixCounts(paths);
349
+ return selectBestPrefix(counts);
350
+ }
package/lib/config.mjs ADDED
@@ -0,0 +1,40 @@
1
+ import fs from "node:fs";
2
+ import { CONFIG_FILE } from "./paths.mjs";
3
+
4
+ const DEFAULT_CONFIG = {
5
+ threshold: 0.35,
6
+ max_tokens: 200000,
7
+ };
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Load / Save
11
+ // ---------------------------------------------------------------------------
12
+
13
+ let _cachedConfig = null;
14
+
15
+ export function loadConfig() {
16
+ if (_cachedConfig) return _cachedConfig;
17
+ try {
18
+ _cachedConfig = {
19
+ ...DEFAULT_CONFIG,
20
+ ...JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8")),
21
+ };
22
+ } catch {
23
+ _cachedConfig = { ...DEFAULT_CONFIG };
24
+ }
25
+ return _cachedConfig;
26
+ }
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Resolve max_tokens.
30
+ // 1. Explicit max_tokens in config (covers most cases)
31
+ // 2. Safe default (200K)
32
+ //
33
+ // The submit hook detects max_tokens from the model name in the transcript
34
+ // (getTokenUsage in tokens.mjs). This config value is the initial fallback
35
+ // before any assistant response provides real model info.
36
+ // ---------------------------------------------------------------------------
37
+ export function resolveMaxTokens() {
38
+ const cfg = loadConfig();
39
+ return cfg.max_tokens ?? 200000;
40
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Extract the first text string from a Claude message content field.
3
+ * Handles string, array-of-blocks, and null/undefined.
4
+ */
5
+ export function flattenContent(content) {
6
+ if (!content) return "";
7
+ if (typeof content === "string") return content;
8
+ if (Array.isArray(content))
9
+ return content
10
+ .filter((b) => b.type === "text")
11
+ .map((b) => b.text)
12
+ .join("\n");
13
+ return "";
14
+ }
15
+
16
+ /**
17
+ * Count the byte size of message content (text + tool input).
18
+ * Used for token estimation when real counts are unavailable.
19
+ */
20
+ export function contentBytesOf(content) {
21
+ if (!content) return 0;
22
+ if (typeof content === "string") return Buffer.byteLength(content, "utf8");
23
+ if (Array.isArray(content)) {
24
+ let sum = 0;
25
+ for (const b of content) {
26
+ if (b.text) sum += Buffer.byteLength(b.text, "utf8");
27
+ if (b.input) sum += Buffer.byteLength(JSON.stringify(b.input), "utf8");
28
+ if (b.content) sum += contentBytesOf(b.content);
29
+ }
30
+ return sum;
31
+ }
32
+ return 0;
33
+ }
@@ -0,0 +1,221 @@
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 { resolveDataDir } from "./paths.mjs";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Diagnostics — lightweight health checks for /cg:stats.
9
+ // Outputs JSON to stdout: { checks: [{name, ok, detail}...] }
10
+ // Always exits 0 so it never breaks the skill.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ // CLI args override env vars (skills run via Bash without plugin env).
14
+ // But Claude often drops the args, so we auto-discover as much as possible.
15
+ const argSessionId = process.argv[2] || process.env.CLAUDE_SESSION_ID || "";
16
+ const argPluginRoot = process.argv[3] || process.env.CLAUDE_PLUGIN_ROOT || "";
17
+ const argPluginData = process.argv[4] || process.env.CLAUDE_PLUGIN_DATA || "";
18
+
19
+ // Plugin root: infer from this file's location (lib/diagnostics.mjs → parent dir)
20
+ const pluginRoot = argPluginRoot || path.resolve(import.meta.dirname, "..");
21
+
22
+ // Data dir: try CLI arg, env var, then discover any cg* dirs.
23
+ // Scans both global (~/.claude/plugins/data/) and project-local (.claude/plugins/data/)
24
+ // to handle all install scopes: user, project, local/inline.
25
+ function discoverDataDirs() {
26
+ const dirs = [argPluginData, resolveDataDir()].filter(Boolean);
27
+ const scanRoots = [
28
+ path.join(os.homedir(), ".claude", "plugins", "data"),
29
+ path.join(process.cwd(), ".claude", "plugins", "data"),
30
+ ];
31
+ for (const root of scanRoots) {
32
+ try {
33
+ for (const d of fs.readdirSync(root)) {
34
+ if (d.startsWith("cg")) {
35
+ dirs.push(path.join(root, d));
36
+ }
37
+ }
38
+ } catch {}
39
+ }
40
+ return [...new Set(dirs)];
41
+ }
42
+ const KNOWN_DATA_DIRS = discoverDataDirs();
43
+
44
+ // Find the most recent state file across all known data dirs
45
+ function findRecentState() {
46
+ let best = null;
47
+ for (const dir of KNOWN_DATA_DIRS) {
48
+ try {
49
+ for (const f of fs
50
+ .readdirSync(dir)
51
+ .filter((f) => f.startsWith("state-") && f.endsWith(".json"))) {
52
+ const fp = path.join(dir, f);
53
+ const stat = fs.statSync(fp);
54
+ if (!best || stat.mtimeMs > best.mtimeMs) {
55
+ best = {
56
+ path: fp,
57
+ mtimeMs: stat.mtimeMs,
58
+ dir,
59
+ sessionId: f.replace("state-", "").replace(".json", ""),
60
+ };
61
+ }
62
+ }
63
+ } catch {}
64
+ }
65
+ return best;
66
+ }
67
+
68
+ const recentState = findRecentState();
69
+ const DATA_DIR = recentState?.dir || argPluginData || resolveDataDir();
70
+ const sessionId = argSessionId || recentState?.sessionId || "unknown";
71
+ const stateFile = (sid) =>
72
+ path.join(DATA_DIR, `state-${sid || "unknown"}.json`);
73
+
74
+ const checks = [];
75
+
76
+ function check(name, ok, detail) {
77
+ checks.push({ name, ok, detail });
78
+ }
79
+
80
+ // 1. Data directory writable
81
+ try {
82
+ const tmp = path.join(DATA_DIR, `.diag-${Date.now()}`);
83
+ fs.writeFileSync(tmp, "ok");
84
+ fs.unlinkSync(tmp);
85
+ check("data_dir", true, DATA_DIR);
86
+ } catch (e) {
87
+ check("data_dir", false, `Not writable: ${e.message}`);
88
+ }
89
+
90
+ // 2. State file present
91
+ try {
92
+ const sf = stateFile(sessionId);
93
+ if (fs.existsSync(sf)) {
94
+ check("state_file", true, "Present");
95
+ } else {
96
+ check(
97
+ "state_file",
98
+ false,
99
+ "Missing — send a message first so hooks can write token counts",
100
+ );
101
+ }
102
+ } catch (e) {
103
+ check("state_file", false, e.message);
104
+ }
105
+
106
+ // 3. Transcript readable
107
+ try {
108
+ const sf = stateFile(sessionId);
109
+ if (fs.existsSync(sf)) {
110
+ const state = JSON.parse(fs.readFileSync(sf, "utf8"));
111
+ if (state.transcript_path && fs.existsSync(state.transcript_path)) {
112
+ check("transcript", true, "Readable");
113
+ } else if (state.transcript_path) {
114
+ check("transcript", false, `Not found: ${state.transcript_path}`);
115
+ } else {
116
+ check("transcript", false, "No transcript_path in state file");
117
+ }
118
+ } else {
119
+ check("transcript", false, "Skipped — no state file");
120
+ }
121
+ } catch (e) {
122
+ check("transcript", false, e.message);
123
+ }
124
+
125
+ // 4. Plugin root exists
126
+ if (fs.existsSync(pluginRoot)) {
127
+ check("plugin_root", true, pluginRoot);
128
+ } else {
129
+ check("plugin_root", false, `Directory missing: ${pluginRoot}`);
130
+ }
131
+
132
+ // 5. Hook files present
133
+ const hookFiles = [
134
+ "hooks/submit.mjs",
135
+ "hooks/stop.mjs",
136
+ "hooks/session-start.mjs",
137
+ "hooks/precompact.mjs",
138
+ ];
139
+ const root = pluginRoot;
140
+ const missingHooks = hookFiles.filter(
141
+ (h) => !fs.existsSync(path.join(root, h)),
142
+ );
143
+ if (missingHooks.length === 0) {
144
+ check("hooks", true, "All 4 hook files present");
145
+ } else {
146
+ check("hooks", false, `Missing: ${missingHooks.join(", ")}`);
147
+ }
148
+
149
+ // 6. Marketplace directory exists
150
+ try {
151
+ const knownPath = path.join(
152
+ os.homedir(),
153
+ ".claude",
154
+ "plugins",
155
+ "known_marketplaces.json",
156
+ );
157
+ if (fs.existsSync(knownPath)) {
158
+ const known = JSON.parse(fs.readFileSync(knownPath, "utf8"));
159
+ const entry = known["context-guardian"];
160
+ if (entry) {
161
+ const mktDir = entry.installLocation;
162
+ if (fs.existsSync(mktDir)) {
163
+ check("marketplace", true, "Repository cache present");
164
+ } else {
165
+ check(
166
+ "marketplace",
167
+ false,
168
+ `Missing: ${mktDir} — run: /plugin marketplace add https://github.com/Ricky-Stevens/context-guardian`,
169
+ );
170
+ }
171
+ } else {
172
+ check(
173
+ "marketplace",
174
+ true,
175
+ "Not marketplace-installed (OK for local dev)",
176
+ );
177
+ }
178
+ } else {
179
+ check("marketplace", true, "No marketplace registry (OK for local dev)");
180
+ }
181
+ } catch (e) {
182
+ check(
183
+ "marketplace",
184
+ false,
185
+ `Error reading marketplace registry: ${e.message}`,
186
+ );
187
+ }
188
+
189
+ // 7. Statusline configured — critical for CG since the statusline is the
190
+ // sole UX for context pressure alerts (no blocking or menus).
191
+ try {
192
+ const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
193
+ if (fs.existsSync(settingsPath)) {
194
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
195
+ if (settings.statusLine?.command?.includes("statusline.mjs")) {
196
+ check("statusline", true, "Configured");
197
+ } else if (settings.statusLine) {
198
+ check(
199
+ "statusline",
200
+ false,
201
+ "Another statusline is active — CG cannot display context warnings. Session-start will reclaim it on next restart.",
202
+ );
203
+ } else {
204
+ check(
205
+ "statusline",
206
+ false,
207
+ "Not configured — will be auto-configured on next session start",
208
+ );
209
+ }
210
+ } else {
211
+ check(
212
+ "statusline",
213
+ false,
214
+ "No settings.json found — will be auto-configured on next session start",
215
+ );
216
+ }
217
+ } catch (e) {
218
+ check("statusline", false, e.message);
219
+ }
220
+
221
+ process.stdout.write(JSON.stringify({ checks }));