@sdsrs/code-graph 0.111.1 → 0.112.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/README.md CHANGED
@@ -5,14 +5,16 @@ A high-performance code knowledge graph server implementing the [Model Context P
5
5
  ## Features
6
6
 
7
7
  - **Multi-language parsing** — Tree-sitter AST extraction across tiers of depth:
8
- - **Full** (calls + imports + inheritance + test markers): TypeScript/TSX, JavaScript, Go, Python, Rust, Java. HTTP route extraction additionally covers TypeScript/TSX + JavaScript (Express/Connect), Go (`net/http`), and Python (Flask/FastAPI) only — Rust and Java web frameworks are not yet route-extracted
8
+ - **Full** (calls + imports + inheritance): TypeScript/TSX, JavaScript, Go, Python, Rust, Java. HTTP route extraction additionally covers TypeScript/TSX + JavaScript (Express/Connect), Go (`net/http`), Python (Flask/FastAPI), and Rust axum (`.route()` chains with inline `.nest()` prefixes; named handlers only)actix/rocket and Java Spring are not yet route-extracted
9
9
  - **Smoke-tested** (calls + imports + inheritance): C#, Kotlin, Ruby, PHP, Swift, Dart
10
- - **Limited** (functions + calls + `#include` imports + gtest test markers + C++ base-class inheritance; `Class::method` scope qualification deferred): C, C++
10
+ - **Limited** (functions + calls + `#include` imports + gtest test markers + C++ base-class inheritance + `Class::method` scope qualification): C, C++
11
11
  - **Scripting**: Bash (functions + commands + `source`/`.` imports), Markdown (headings)
12
12
  - **File-FTS only** (no AST symbol extraction): HTML, CSS, JSON
13
+ - **Test markers** — detected from the AST for Rust (`#[test]` / `#[cfg(test)]`), JavaScript/TypeScript/TSX (`describe`/`it`/`test` blocks and their callbacks) and C/C++ (gtest `TEST` macros). Go, Python, Java and the rest fall back to path/name heuristics (`tests/`, `src/test/java/`, `*_test.*`, `*.test.js`, `test_*`, `*Test`)
14
+ - **Value/type references** — a `references` relation for symbols that are used without being called, imported or inherited (path-qualified constants, type-position uses, functions passed as values): Rust, TypeScript/TSX, JavaScript, Python, Go, Java, C, C++
13
15
  - **Semantic code search** — Hybrid BM25 full-text + vector semantic search with Reciprocal Rank Fusion (RRF), powered by sqlite-vec
14
16
  - **Call graph traversal** — Recursive CTE queries to trace callers/callees with cycle detection
15
- - **HTTP route tracing** — Map route paths to backend handler functions (Express, Flask/FastAPI, Go `net/http`)
17
+ - **HTTP route tracing** — Map route paths to backend handler functions (Express, Flask/FastAPI, Go `net/http`, Rust axum)
16
18
  - **Dead code detection** — Find unreferenced symbols with smart Orphan/Exported-Unused classification
17
19
  - **Impact analysis** — Determine the blast radius of code changes by tracing all dependents
18
20
  - **Incremental indexing** — Merkle tree change detection with file system watcher for real-time updates. Smart event filtering skips metadata-only changes (chmod, xattr)
@@ -36,11 +38,11 @@ Combines BM25 full-text ranking (FTS5) with vector semantic similarity (sqlite-v
36
38
 
37
39
  ### Scope-Aware Relation Extraction
38
40
 
39
- The parser doesn't just find function calls — it tracks them within their proper scope context. Extracts calls, imports, inheritance, interface implementations, exports, and HTTP route bindings. Same-file targets are preferred over cross-file matches to minimize false-positive edges.
41
+ The parser doesn't just find function calls — it tracks them within their proper scope context. Extracts calls, imports, inheritance, interface implementations, exports (ESM `export` **and** CommonJS `module.exports = { … }` / `exports.name = …`), value/type references, and HTTP route bindings. Same-file targets are preferred over cross-file matches to minimize false-positive edges.
40
42
 
41
43
  ### HTTP Request Flow Tracing
42
44
 
43
- Unique to code-graph-mcp: trace from `GET /api/users` → route handler → service layer → database call in a single query. Supports Express, Flask/FastAPI, and Go HTTP frameworks.
45
+ Unique to code-graph-mcp: trace from `GET /api/users` → route handler → service layer → database call in a single query. Supports Express/Connect, Flask/FastAPI, Go `net/http`, and Rust axum.
44
46
 
45
47
  ### Zero External Dependencies at Runtime
46
48
 
@@ -73,10 +75,10 @@ Real-world benchmarks comparing code-graph-mcp tools against traditional approac
73
75
  | Project architecture overview | 5-8 calls | 1 call (`project_map`) | **~85%** |
74
76
  | Find function by concept | 3-5 calls | 1 call (`semantic_code_search`) | **~75%** |
75
77
  | Trace 2-level call chain | 8-15 calls | 1 call (`get_call_graph`) | **~90%** |
76
- | Pre-change impact analysis | 10-20+ calls | 1 call (`impact_analysis`) | **~95%** |
78
+ | Pre-change impact analysis | 10-20+ calls | 1 call (`get_ast_node` + `include_impact`) | **~95%** |
77
79
  | Module structure & exports | 5+ calls | 1 call (`module_overview`) | **~80%** |
78
- | File dependency mapping | 3-5 calls | 1 call (`dependency_graph`) | **~75%** |
79
- | Similar code detection | N/A | 1 call (`find_similar_code`) | **unique** |
80
+ | File dependency mapping | 3-5 calls | 1 call (`module_overview` + `include_deps`) | **~75%** |
81
+ | Similar code detection | N/A | 1 call (`get_ast_node` + `include_similar`) | **unique** |
80
82
 
81
83
  ### Overall Session Efficiency
82
84
 
@@ -263,20 +265,19 @@ npm uninstall -g @sdsrs/code-graph
263
265
 
264
266
  ## MCP Tools
265
267
 
268
+ `tools/list` advertises exactly these seven. Several older niche tools were folded into flags on them, so one call now covers what used to take a separate tool:
269
+
266
270
  | Tool | Description |
267
271
  |------|-------------|
268
- | `project_map` | Full project architecture: modules, dependencies, entry points, hot functions |
269
- | `semantic_code_search` | Hybrid BM25 + vector + graph search for AST nodes |
270
- | `get_call_graph` | Trace upstream/downstream call chains for a function |
271
- | `trace_http_chain` | Full request flow: route handler downstream call chain |
272
- | `impact_analysis` | Analyze the blast radius of changing a symbol |
273
- | `module_overview` | High-level overview of a module's structure and exports |
274
- | `dependency_graph` | Visualize dependency relationships between modules. Supports `compact` mode |
275
- | `find_similar_code` | Find semantically similar code via embeddings. Requires `symbol_name` or `node_id` |
276
- | `get_ast_node` | Extract a specific code symbol with signature, body, and relations. Supports `compact` mode |
272
+ | `project_map` | Full project architecture: modules, dependencies, entry points, hot functions. `include_centrality` adds architectural chokepoints |
273
+ | `semantic_code_search` | Hybrid BM25 + vector search (RRF) for AST nodes. Supports `compact` mode |
274
+ | `get_call_graph` | Trace upstream/downstream call chains for a function. Pass `route_path='GET /api/x'` to trace an HTTP route → handler → downstream instead |
275
+ | `get_ast_node` | One symbol with signature, body and relations. `include_impact` adds the blast radius, `include_similar` embedding-similar nodes, `include_references` callers/callees. Supports `compact` mode |
276
+ | `module_overview` | Symbols in a directory or file, grouped by type and caller count. `include_dead` lists unreferenced symbols under the path; `include_deps` adds the file dependency graph (single-file paths only) |
277
277
  | `ast_search` | Search AST nodes by text and/or structural filters (type, return type, params) |
278
- | `find_references` | Find all references to a symbol (callers, importers, inheritors). Supports `compact` mode |
279
- | `find_dead_code` | Find unused code — orphan symbols and exported-but-unused public APIs |
278
+ | `find_references` | Find all references to a symbol (callers, importers, inheritors, implementors, value/type references). Supports `compact` mode |
279
+
280
+ **Hidden aliases.** These names are not in `tools/list` but still dispatch via `tools/call`, so existing clients keep working: `trace_http_chain` / `find_http_route` (→ `get_call_graph` with `route_path`), `read_snippet` (→ `get_ast_node`), `dependency_graph`, `find_similar_code`, `find_dead_code`, plus the management tools `start_watch`, `stop_watch`, `get_index_status` and `rebuild_index`. `impact_analysis` is **removed** — calling it returns `Unknown tool`; use `get_ast_node` with `include_impact=true`, or the CLI's `impact --json` for the full report.
280
281
 
281
282
  ## CLI Commands
282
283
 
@@ -287,15 +288,15 @@ All tools are also available as CLI subcommands for shell scripts, hooks, and te
287
288
  | `search <query>` | `semantic_code_search` | FTS5 search by concept |
288
289
  | `ast-search [query]` | `ast_search` | Structural search with `--type`/`--returns`/`--params` filters |
289
290
  | `callgraph <symbol>` | `get_call_graph` | Show call graph (callers/callees) |
290
- | `impact <symbol>` | `impact_analysis` | Impact analysis (callers, routes, risk level) |
291
+ | `impact <symbol>` | `get_ast_node` (`include_impact=true`) | Impact analysis (callers, routes, risk level) |
291
292
  | `show <symbol>` | `get_ast_node` | Show symbol details (code, type, signature) |
292
293
  | `map` | `project_map` | Project architecture map |
293
294
  | `overview <path>` | `module_overview` | Module symbols grouped by file and type |
294
- | `deps <file>` | `dependency_graph` | File-level dependency graph |
295
- | `trace <route>` | `trace_http_chain` | Trace HTTP route → handler → downstream calls |
296
- | `similar <symbol>` | `find_similar_code` | Find semantically similar code (requires embeddings) |
295
+ | `deps <file>` | `module_overview` (`include_deps=true`) | File-level dependency graph |
296
+ | `trace <route>` | `get_call_graph` (`route_path=…`) | Trace HTTP route → handler → downstream calls |
297
+ | `similar <symbol>` | `get_ast_node` (`include_similar=true`) | Find semantically similar code (requires embeddings) |
297
298
  | `refs <symbol>` | `find_references` | Find all references to a symbol |
298
- | `dead-code [path]` | `find_dead_code` | Find unused code (orphans and exported-unused) |
299
+ | `dead-code [path]` | `module_overview` (`include_dead=true`) | Find unused code (orphans and exported-unused) |
299
300
  | `grep <pattern>` | — | AST-context grep (ripgrep + containing function/class) |
300
301
  | `incremental-index` | — | Run incremental index update (auto-creates DB if needed) |
301
302
  | `health-check` | `get_index_status` | Query index status and freshness |
@@ -321,20 +322,20 @@ Available when installed as a Claude Code plugin:
321
322
 
322
323
  | Language | Extensions | Relations Extracted |
323
324
  |----------|-----------|-------------------|
324
- | TypeScript | .ts, .tsx | calls, imports, exports, inherits, implements, routes_to |
325
- | JavaScript | .js, .jsx, .mjs, .cjs | calls, imports, exports, inherits, routes_to |
326
- | Go | .go | calls, imports, inherits, routes_to |
327
- | Python | .py, .pyi | calls, imports, inherits, routes_to |
328
- | Rust | .rs | calls, imports, inherits, implements |
329
- | Java | .java | calls, imports, inherits, implements |
325
+ | TypeScript | .ts, .tsx | calls, imports, exports, inherits, implements, routes_to, references |
326
+ | JavaScript | .js, .jsx, .mjs, .cjs | calls, imports, exports (ESM + CommonJS), inherits, routes_to, references |
327
+ | Go | .go | calls, imports, inherits, routes_to, references |
328
+ | Python | .py, .pyi | calls, imports, inherits, routes_to, references |
329
+ | Rust | .rs | calls, imports, implements, routes_to (axum), references |
330
+ | Java | .java | calls, imports, inherits, implements, references |
330
331
  | C# | .cs | calls, imports, inherits, implements |
331
332
  | Kotlin | .kt, .kts | calls, imports, inherits |
332
333
  | Ruby | .rb | calls, imports, inherits |
333
334
  | PHP | .php | calls, imports, inherits, implements |
334
335
  | Swift | .swift | calls, imports, inherits |
335
336
  | Dart | .dart | calls, imports, inherits, implements |
336
- | C | .c, .h | calls, imports |
337
- | C++ | .cpp, .cc, .cxx, .hpp | calls, imports, inherits |
337
+ | C | .c, .h | calls, imports, references |
338
+ | C++ | .cpp, .cc, .cxx, .hpp, .hh, .hxx | calls, imports, inherits, references |
338
339
  | Bash | .sh, .bash | functions, commands, `source`/`.` imports |
339
340
  | Markdown | .md, .mdx, .markdown | headings |
340
341
  | HTML | .html, .htm | file-FTS only (no AST symbols) |
@@ -342,6 +343,7 @@ Available when installed as a Claude Code plugin:
342
343
  | JSON | .json | file-FTS only (no AST symbols) |
343
344
 
344
345
  **Known limitations:**
346
+ - **Rust has no `inherits` edges** — the language has no class inheritance, so `impl Trait for Type` is recorded as `implements` and an `inherits`-filtered query returns empty for Rust.
345
347
  - **Kotlin/Swift interface conformance** is recorded as `inherits` (both use a single `: Type` grammar for base classes and protocols/interfaces), so `implements`-filtered queries return empty for these two languages.
346
348
  - **Cross-file dead-code detection** may false-positive a type whose only cross-file reference sits beyond the 4096-byte stored-content cap per node (documented accepted limitation, v0.97.1).
347
349
 
package/bin/cli.js CHANGED
@@ -131,9 +131,14 @@ if (!binary) {
131
131
  }
132
132
 
133
133
  // Spawn the binary, forwarding stdio for MCP JSON-RPC communication
134
+ // windowsHide as a literal rather than via claude-plugin/scripts/proc-opts:
135
+ // this file is the npm package's bin entry and ships in tarballs that may not
136
+ // carry claude-plugin/. Same meaning — CREATE_NO_WINDOW, so a console-less
137
+ // parent (the MCP client) does not flash a window; inherited stdio still works.
134
138
  const child = spawn(binary, process.argv.slice(2), {
135
139
  stdio: "inherit",
136
140
  env: process.env,
141
+ windowsHide: true,
137
142
  });
138
143
 
139
144
  child.on("error", (err) => {
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.111.1",
7
+ "version": "0.112.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -945,6 +945,17 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
945
945
  else if (!sameTarget) nextSuspendedAt = null;
946
946
  else nextSuspendedAt = state.suspendedAt || null;
947
947
  const newState = {
948
+ // Carry the prior state forward. Every OTHER saveState in this function
949
+ // spreads `...state`; this one rebuilt from scratch, so any key it does
950
+ // not name was dropped on every update check that found a new release.
951
+ // The keys that matter are selfHealGlobalPkgs' — globalPkgHealAttempts /
952
+ // globalPkgHealVersion — because that function returns `{}` once the
953
+ // attempt cap is hit, meaning the spread below contributes nothing and
954
+ // the counter reset to zero. A capped-out global heal therefore got a
955
+ // fresh budget on every release, which is precisely the retry treadmill
956
+ // the cap exists to stop. Keys this object sets are overwritten below;
957
+ // nothing stale leaks through.
958
+ ...state,
948
959
  lastCheck: new Date().toISOString(),
949
960
  installedVersion: success ? latest.version : installedVersion,
950
961
  latestVersion: latest.version,
@@ -993,6 +1004,16 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
993
1004
  lastCheck: new Date().toISOString(),
994
1005
  latestVersion: latest.version,
995
1006
  updateAvailable: false,
1007
+ // Reaching here means the installed shell IS the latest release, so any
1008
+ // failure record describes an update that is no longer pending. Leaving it
1009
+ // set kept `doctor` warning "vX failed to install 5× — auto-retry
1010
+ // throttled" about a version already installed, and left a suspension
1011
+ // stamp that the next release then had to age out. The common way to get
1012
+ // here from a suspended state is the manual route the suspension notice
1013
+ // itself recommends (`npm install -g` / `/plugin update`) — the updater
1014
+ // has to notice that its advice was taken.
1015
+ updateAttempts: 0,
1016
+ suspendedAt: null,
996
1017
  rateLimited: false,
997
1018
  binaryUpdated: selfHealedBinary || state.binaryUpdated,
998
1019
  ...globalHeal,
@@ -482,9 +482,9 @@ function relicRepairGuard({ log = console.log, relic = undefined } = {}) {
482
482
  function detectEmbedModel(binary, run = execFileSync) {
483
483
  if (!binary) return null;
484
484
  try {
485
- const out = run(binary, ['health-check', '--json'], {
485
+ const out = run(binary, ['health-check', '--json'], hidden({
486
486
  timeout: 10000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
487
- });
487
+ }));
488
488
  return JSON.parse(out).model_available === true;
489
489
  } catch { return null; }
490
490
  }
@@ -114,7 +114,13 @@ function readJsonResult(filePath) {
114
114
 
115
115
  // Lenient reader kept exactly as-is for its 20+ callers (manifests, registries,
116
116
  // plugin.json …) where "absent" and "unreadable" genuinely mean the same thing.
117
- // Only the settings write path needs readJsonResult's distinction.
117
+ // A caller that will WRITE settings.json back must use readSettingsForWrite()
118
+ // + tryWriteSettings() instead — the pair that detects the lossy/corrupt cases
119
+ // and preserves the original bytes. Reading settings.json with THIS function is
120
+ // fine as long as nothing is written back (isPluginInactive, syncLifecycleConfig's
121
+ // self-heal probe); the destructive combination is `readJson(settingsPath())`
122
+ // followed by a write, which is how cleanupDisabledStatusline and uninstall
123
+ // destroyed non-UTF-8 bytes for four releases after the detector landed.
118
124
  function readJson(filePath) {
119
125
  try { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } catch { return null; }
120
126
  }
@@ -158,9 +164,18 @@ function backupCorruptFile(filePath, raw) {
158
164
  // printing "Hooks ✅ 1 issue(s) auto-repaired" for a run that moved the user's
159
165
  // model / env / permissions into a `.corrupt-*` file it never mentioned. A
160
166
  // destructive repair has to be reported as one, with the path to get it back.
161
- function readSettingsForWrite() {
167
+ //
168
+ // `pre` lets a caller that ALREADY probed the file with readJsonResult reuse
169
+ // that result instead of reading twice. It matters for more than speed: the
170
+ // backup this function may take is a side effect, and a caller which only
171
+ // writes conditionally (cleanupDisabledStatusline runs on every statusline
172
+ // render) must be able to probe first and pay the backup only on the write
173
+ // path — otherwise a lossy settings.json accumulates one `.corrupt-*` copy per
174
+ // prompt. Passing `pre` also keeps the returned `settings` object IDENTICAL to
175
+ // the one the caller already mutated.
176
+ function readSettingsForWrite(pre) {
162
177
  const p = settingsPath();
163
- const res = readJsonResult(p);
178
+ const res = pre || readJsonResult(p);
164
179
  if (res.value && res.lossy) {
165
180
  // Usable JSON whose bytes will not survive our rewrite (see readJsonResult).
166
181
  // Preserve the true bytes, then proceed with the parsed value — refusing
@@ -413,7 +428,11 @@ function detachStatuslineIntegration(settings, { compositeDoomed = true } = {})
413
428
  }
414
429
 
415
430
  function cleanupDisabledStatusline() {
416
- const settings = readJson(settingsPath());
431
+ // Probe without side effects first — this runs on every statusline render and
432
+ // usually finds nothing to do. A corrupt/unreadable file yields no value and
433
+ // is left strictly alone (same outcome the old lenient readJson produced).
434
+ const probe = readJsonResult(settingsPath());
435
+ const settings = probe.value;
417
436
  if (!settings || !isPluginInactive(settings)) {
418
437
  return { cleaned: false, settingsChanged: false };
419
438
  }
@@ -425,7 +444,15 @@ function cleanupDisabledStatusline() {
425
444
  let settingsChanged = detachStatuslineIntegration(settings, { compositeDoomed: uninstalled });
426
445
  if (removeHooksFromSettings(settings)) settingsChanged = true;
427
446
  if (settingsChanged) {
428
- writeJsonAtomic(settingsPath(), settings);
447
+ // Now that a write is certain, take the guarded path: readSettingsForWrite
448
+ // preserves bytes JSON.stringify would destroy (the lossy-UTF8 case) and
449
+ // returns null when it could not, and tryWriteSettings turns a read-only
450
+ // ~/.claude into a diagnosed no-op instead of an uncaught EACCES — this
451
+ // function is called at the top of statusline.js, where a throw blanks the
452
+ // user's status line. `guarded` is the same object we just mutated.
453
+ const { settings: guarded } = readSettingsForWrite(probe);
454
+ if (!guarded) return { cleaned: false, settingsChanged: false };
455
+ if (tryWriteSettings(guarded)) settingsChanged = false;
429
456
  }
430
457
 
431
458
  // Genuine uninstall (not a temporary disable): reclaim ~/.cache/code-graph
@@ -1071,7 +1098,13 @@ function defaultRunNpm(args) {
1071
1098
  }
1072
1099
 
1073
1100
  function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRunNpm, scanGlobalPkgs = installedGlobalPkgs } = {}) {
1074
- const settings = readJson(settingsPath());
1101
+ // Same guarded pair as install()/update(): probe without side effects, and
1102
+ // only take the backup + write path once there is something to write. Reading
1103
+ // this file with the lenient readJson and writing it back raw is what
1104
+ // destroyed non-UTF-8 bytes here — a teardown has even less license to lose
1105
+ // the user's model / env / permissions than an install does.
1106
+ const probe = readJsonResult(settingsPath());
1107
+ const settings = probe.value;
1075
1108
  let settingsChanged = false;
1076
1109
 
1077
1110
  if (settings) {
@@ -1097,7 +1130,8 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1097
1130
 
1098
1131
  // 4. Write settings if changed
1099
1132
  if (settingsChanged) {
1100
- writeJsonAtomic(settingsPath(), settings);
1133
+ const { settings: guarded } = readSettingsForWrite(probe);
1134
+ if (!guarded || tryWriteSettings(guarded)) settingsChanged = false;
1101
1135
  }
1102
1136
  }
1103
1137
 
@@ -1155,6 +1189,14 @@ function uninstall({ purgeGlobal = false, unadoptAll = false, runNpm = defaultRu
1155
1189
  // 6. Remove cache directory
1156
1190
  try { fs.rmSync(CACHE_DIR, { recursive: true, force: true }); } catch { /* ok */ }
1157
1191
 
1192
+ // 6.5. The shared tmp dir (cooldown flags, read-fanout state, interrupted
1193
+ // `update-*` staging). Nothing in it outlives an uninstall, and the periodic
1194
+ // prune that keeps it bounded stops running the moment the hooks are gone —
1195
+ // so without this it is residue with no remaining owner.
1196
+ try {
1197
+ fs.rmSync(require('./tmp-dir').CG_TMP_DIR, { recursive: true, force: true });
1198
+ } catch { /* ok */ }
1199
+
1158
1200
  // 7. Remove plugin files from cache (all known paths, including parent dirs)
1159
1201
  const cacheRoot = pluginsCacheDir();
1160
1202
  const pluginCacheDirs = [
@@ -1354,11 +1396,22 @@ function scanForBrokenPaths() {
1354
1396
  const settings = settingsRead.value || {};
1355
1397
  const issues = [];
1356
1398
 
1399
+ // Every path extraction below goes through hookCmdScript, the same parser
1400
+ // compositeSlotIsStale and surveyHookCoverage use. These three sites each had
1401
+ // their own inline `/node\s+"([^"]+)"/`, which requires the literal word
1402
+ // `node` followed by whitespace — so a command naming the interpreter by
1403
+ // absolute path, the spelling a Windows install produces
1404
+ // (`"C:\Program Files\nodejs\node.exe" "C:\…\script.js"`), matched nothing.
1405
+ // A path this scan cannot READ is reported as no path at all, i.e. as healthy,
1406
+ // which is the failure mode the whole function exists to prevent. Six sites,
1407
+ // two spellings, one of them blind: collapse to one.
1408
+ const scriptOf = (cmd) => hookCmdScript(cmd);
1409
+
1357
1410
  // Check statusLine path
1358
1411
  if (isOurComposite(settings)) {
1359
- const m = settings.statusLine.command.match(/node\s+"([^"]+)"/);
1360
- if (m && m[1] && !fs.existsSync(m[1])) {
1361
- issues.push({ type: 'statusLine', path: m[1] });
1412
+ const script = scriptOf(settings.statusLine.command);
1413
+ if (script && !fs.existsSync(script)) {
1414
+ issues.push({ type: 'statusLine', path: script });
1362
1415
  }
1363
1416
  }
1364
1417
 
@@ -1369,9 +1422,9 @@ function scanForBrokenPaths() {
1369
1422
  for (const entry of entries) {
1370
1423
  if (!isOurHookEntry(entry) || !entry.hooks) continue;
1371
1424
  for (const h of entry.hooks) {
1372
- const m = h.command && h.command.match(/node\s+"([^"]+)"/);
1373
- if (m && m[1] && !fs.existsSync(m[1])) {
1374
- issues.push({ type: 'hook', event, path: m[1] });
1425
+ const script = h.command && scriptOf(h.command);
1426
+ if (script && !fs.existsSync(script)) {
1427
+ issues.push({ type: 'hook', event, path: script });
1375
1428
  }
1376
1429
  }
1377
1430
  }
@@ -1382,9 +1435,9 @@ function scanForBrokenPaths() {
1382
1435
  const registry = readRegistry();
1383
1436
  for (const provider of registry) {
1384
1437
  if (provider.id === '_previous') continue;
1385
- const m = provider.command && provider.command.match(/node\s+"([^"]+)"/);
1386
- if (m && m[1] && !fs.existsSync(m[1])) {
1387
- issues.push({ type: 'registry', id: provider.id, path: m[1] });
1438
+ const script = provider.command && scriptOf(provider.command);
1439
+ if (script && !fs.existsSync(script)) {
1440
+ issues.push({ type: 'registry', id: provider.id, path: script });
1388
1441
  }
1389
1442
  }
1390
1443
 
@@ -1480,6 +1533,7 @@ module.exports = {
1480
1533
  removeHooksFromSettings, isOurHookEntry,
1481
1534
  registerHooksToSettings, buildSettingsHookEntries, // v0.32.0
1482
1535
  surveyHookCoverage, compositeCommand, compositeSlotIsStale, // v0.49.1 — version-aware self-heal
1536
+ hookCmdScript, // the ONE hook-command path parser (session-init reuses it)
1483
1537
  cacheDirVersion, // exported for the separator-agnostic test
1484
1538
 
1485
1539
  verifyHooksFire, defaultHookFireProbes, // v0.67.0 — firing self-test
@@ -23,7 +23,7 @@
23
23
  const fs = require('fs');
24
24
  const path = require('path');
25
25
  const crypto = require('crypto');
26
- const { cgTmpDir } = require('./tmp-dir');
26
+ const { cgTmpDir, cwdHash } = require('./tmp-dir');
27
27
  const { recordRecommendation } = require('./recommendation-log');
28
28
  const { runGrepAnswer, runShowAnswer, runCallgraphAnswer, sanitizeSearchPath } = require('./cg-answer');
29
29
  const { emitPostToolContext } = require('./hook-emit');
@@ -217,18 +217,20 @@ function commandHash(cmd) {
217
217
  return crypto.createHash('sha1').update(String(cmd)).digest('hex').slice(0, 12);
218
218
  }
219
219
 
220
- function flagPath(cmd) {
221
- return path.join(cgTmpDir(), `.code-graph-postinject-${commandHash(cmd)}`);
220
+ // Project-scoped for the same reason as pre-grep-guide's (see cwdHash in
221
+ // tmp-dir.js): one shared tmp dir means an un-scoped flag is machine-global.
222
+ function flagPath(cmd, cwd = process.cwd()) {
223
+ return path.join(cgTmpDir(), `.code-graph-postinject-${cwdHash(cwd)}-${commandHash(cmd)}`);
222
224
  }
223
225
 
224
- function isOnCooldown(cmd, now = Date.now(), windowMs = 60000) {
226
+ function isOnCooldown(cmd, now = Date.now(), windowMs = 60000, cwd = process.cwd()) {
225
227
  try {
226
- return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs;
228
+ return now - fs.statSync(flagPath(cmd, cwd)).mtimeMs < windowMs;
227
229
  } catch { return false; }
228
230
  }
229
231
 
230
- function markCooldown(cmd) {
231
- try { fs.writeFileSync(flagPath(cmd), ''); } catch { /* ok */ }
232
+ function markCooldown(cmd, cwd = process.cwd()) {
233
+ try { fs.writeFileSync(flagPath(cmd, cwd), ''); } catch { /* ok */ }
232
234
  }
233
235
 
234
236
  // --- Main execution ---
@@ -259,8 +261,8 @@ function runMain() {
259
261
  const found = findFoldableGrepSegment(cmd);
260
262
  if (!found) return;
261
263
 
262
- if (isOnCooldown(rawCmd)) return;
263
- markCooldown(rawCmd);
264
+ if (isOnCooldown(rawCmd, Date.now(), 60000, root)) return;
265
+ markCooldown(rawCmd, root);
264
266
 
265
267
  const { segment, block } = found;
266
268
  // Run the answer exactly like the deny path.
@@ -10,7 +10,7 @@ const { execFileSync } = require('child_process');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const { findBinary } = require('./find-binary');
13
- const { cgTmpDir } = require('./tmp-dir');
13
+ const { cgTmpDir, cwdHash } = require('./tmp-dir');
14
14
  const { resolveProjectRoot } = require('./project-root');
15
15
  const { recordRecommendation } = require('./recommendation-log');
16
16
  const { formatCoveringTests } = require('./covering-tests');
@@ -118,7 +118,11 @@ function isCommonKeyword(s) {
118
118
  }
119
119
 
120
120
  // --- Per-symbol cooldown: 2 minutes ---
121
- const cooldownFile = path.join(cgTmpDir(), `.cg-impact-${symbol}`);
121
+ // Project-scoped (see cwdHash in tmp-dir.js). A symbol name is the LEAST
122
+ // project-unique key there is — `main`, `run`, `new`, `parse` collide across
123
+ // every repo on the machine, so editing `parse` in one project suppressed the
124
+ // impact push for a completely different `parse` in another for two minutes.
125
+ const cooldownFile = path.join(cgTmpDir(), `.cg-impact-${cwdHash(cwd)}-${symbol}`);
122
126
  try {
123
127
  if (Date.now() - fs.statSync(cooldownFile).mtimeMs < 120000) process.exit(0);
124
128
  } catch { /* first time for this symbol */ }
@@ -39,7 +39,7 @@
39
39
  const fs = require('fs');
40
40
  const path = require('path');
41
41
  const crypto = require('crypto');
42
- const { cgTmpDir } = require('./tmp-dir');
42
+ const { cgTmpDir, cwdHash } = require('./tmp-dir');
43
43
  const { recordRecommendation } = require('./recommendation-log');
44
44
  const { runGrepAnswer, runShowAnswer, sanitizeSearchPath } = require('./cg-answer');
45
45
 
@@ -513,18 +513,20 @@ function commandHash(cmd) {
513
513
  return crypto.createHash('sha1').update(cmd).digest('hex').slice(0, 12);
514
514
  }
515
515
 
516
- function flagPath(cmd) {
517
- return path.join(cgTmpDir(), `.code-graph-bash-${commandHash(cmd)}`);
516
+ // Project-scoped: the same `grep -rn "foo" src/` in two repos is two different
517
+ // questions and must not share one 60s cooldown (see cwdHash in tmp-dir.js).
518
+ function flagPath(cmd, cwd = process.cwd()) {
519
+ return path.join(cgTmpDir(), `.code-graph-bash-${cwdHash(cwd)}-${commandHash(cmd)}`);
518
520
  }
519
521
 
520
- function isOnCooldown(cmd, now = Date.now(), windowMs = 60000) {
522
+ function isOnCooldown(cmd, now = Date.now(), windowMs = 60000, cwd = process.cwd()) {
521
523
  try {
522
- return now - fs.statSync(flagPath(cmd)).mtimeMs < windowMs;
524
+ return now - fs.statSync(flagPath(cmd, cwd)).mtimeMs < windowMs;
523
525
  } catch { return false; }
524
526
  }
525
527
 
526
- function markCooldown(cmd) {
527
- try { fs.writeFileSync(flagPath(cmd), ''); } catch { /* ok */ }
528
+ function markCooldown(cmd, cwd = process.cwd()) {
529
+ try { fs.writeFileSync(flagPath(cmd, cwd), ''); } catch { /* ok */ }
528
530
  }
529
531
 
530
532
  function buildHint() {
@@ -720,7 +722,7 @@ function runMain() {
720
722
  return;
721
723
  }
722
724
 
723
- if (isOnCooldown(rawCmd)) {
725
+ if (isOnCooldown(rawCmd, Date.now(), 60000, root)) {
724
726
  // Outcome proxy: a source grep re-issued within the cooldown window runs
725
727
  // silently (no deny/hint). Record it so `stats` sees the model's grep
726
728
  // fan-out — especially a re-grep right after cg answered the same query.
@@ -728,7 +730,7 @@ function runMain() {
728
730
  return;
729
731
  }
730
732
 
731
- markCooldown(rawCmd);
733
+ markCooldown(rawCmd, root);
732
734
 
733
735
  const block = isBlockDisabled() ? null : classifyBlock(cmd);
734
736
  if (block) {
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const {
7
7
  install, update, readManifest, getPluginVersion, checkScopeConflict,
8
8
  cleanupDisabledStatusline, isPluginInactive, isPluginUninstalled, removeCacheResidue,
9
- readJson, CACHE_DIR, settingsPath, isStaleRelicContext,
9
+ readJson, CACHE_DIR, settingsPath, isStaleRelicContext, hookCmdScript,
10
10
  } = require('./lifecycle');
11
11
  const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-utils');
12
12
  const { maybeAutoAdopt, isAdopted, unadopt } = require('./adopt');
@@ -251,9 +251,13 @@ function syncLifecycleConfig() {
251
251
  installReporting();
252
252
  return 'self-healed';
253
253
  }
254
- // Also self-heal if composite path points to a non-existent script (path pollution)
255
- const scriptMatch = settings.statusLine.command.match(/node\s+"([^"]+)"/);
256
- if (scriptMatch && scriptMatch[1] && !fs.existsSync(scriptMatch[1])) {
254
+ // Also self-heal if composite path points to a non-existent script (path
255
+ // pollution). hookCmdScript, not another inline `/node\s+""/`: that spelling
256
+ // cannot read a command whose interpreter is an absolute path
257
+ // (`"C:\Program Files\nodejs\node.exe" "…\statusline-composite.js"`), and an
258
+ // unreadable command silently reads as a healthy one.
259
+ const compositeScript = hookCmdScript(settings.statusLine.command);
260
+ if (compositeScript && !fs.existsSync(compositeScript)) {
257
261
  installReporting();
258
262
  return 'self-healed-bad-path';
259
263
  }
@@ -282,8 +286,8 @@ function syncLifecycleConfig() {
282
286
  for (const entry of entries) {
283
287
  if (!entry.hooks) continue;
284
288
  for (const h of entry.hooks) {
285
- const m = h.command && h.command.match(/node\s+"([^"]+)"/);
286
- if (m && m[1] && m[1].includes('code-graph') && !fs.existsSync(m[1])) {
289
+ const script = h.command && hookCmdScript(h.command);
290
+ if (script && script.includes('code-graph') && !fs.existsSync(script)) {
287
291
  installReporting();
288
292
  return 'self-healed-bad-hook';
289
293
  }
@@ -509,13 +513,22 @@ function consistencyCheck(binary) {
509
513
  }
510
514
 
511
515
  function runSessionInit({ source } = {}) {
516
+ // GC the shared tmp dir before anything else, so it happens even on the
517
+ // inactive / non-project early returns below — those sessions still wrote
518
+ // cooldown flags on the way in. Cheap (one readdir + a stat per entry) and
519
+ // fully swallowed: reclaiming disk must never be able to fail a SessionStart.
520
+ try { require('./tmp-dir').pruneCgTmp(); } catch { /* best-effort GC */ }
521
+
512
522
  if (isPluginInactive()) {
513
523
  // Capture the uninstalled-vs-disabled verdict BEFORE cleanupDisabledStatusline()
514
524
  // runs — it removes our composite + registry entry, which are the very signals
515
525
  // isPluginUninstalled()/isPluginInactive() read, so calling it afterwards would
516
526
  // always see "no composite/registry" and report not-uninstalled (teardown skipped).
517
527
  const uninstalled = isPluginUninstalled();
518
- cleanupDisabledStatusline();
528
+ // Third caller of the same unguarded teardown (statusline.js and
529
+ // statusline-composite.js are the others): a read-only ~/.claude turns this
530
+ // into an uncaught throw that takes down the whole SessionStart hook.
531
+ try { cleanupDisabledStatusline(); } catch { /* best-effort teardown */ }
519
532
  // Genuine uninstall (not a temporary disable) leaves residue the settings-only
520
533
  // self-heal can't reach: ~/.cache/code-graph (the ~40MB binary + state) and the
521
534
  // current project's CLAUDE.md adoption block. CC fires no uninstall hook, AND it
@@ -16,7 +16,14 @@ const cleanupDisabledStatusline = lifecycle.cleanupDisabledStatusline || (() =>
16
16
  const SEPARATOR = ' \x1b[2m|\x1b[0m ';
17
17
 
18
18
  function main() {
19
- const disabledCleanup = cleanupDisabledStatusline();
19
+ // Same reasoning as statusline.js: the teardown writes settings.json and the
20
+ // registry, both of which throw on a read-only config dir, and this is THE
21
+ // command Claude Code runs for the status line — an uncaught throw here blanks
22
+ // every provider's segment, not just ours.
23
+ let disabledCleanup = { cleaned: false };
24
+ try {
25
+ disabledCleanup = cleanupDisabledStatusline();
26
+ } catch { /* teardown is optional; rendering is not */ }
20
27
  if (disabledCleanup.cleaned) process.exit(0);
21
28
 
22
29
  // Collect stdin (Claude Code pipes JSON context)
@@ -43,7 +43,17 @@ function updateStuck(st = readUpdateState()) {
43
43
  return !!(st && st.updateAvailable && (st.updateAttempts || 0) >= STUCK_UPDATE_ATTEMPTS);
44
44
  }
45
45
 
46
- const disabledCleanup = cleanupDisabledStatusline();
46
+ // The teardown is best-effort housekeeping, never a precondition for rendering.
47
+ // It writes ~/.claude/settings.json and ~/.cache/code-graph/statusline-registry.json,
48
+ // and on a read-only config dir (EROFS container mount, a `sudo` that left root
49
+ // ownership behind, restrictive umask) those writes throw — from module scope,
50
+ // where nothing catches them. The user's whole status line then goes blank plus a
51
+ // node stack trace, for a cleanup they never asked for. Swallow it: the next run
52
+ // with a writable dir does the work.
53
+ let disabledCleanup = { cleaned: false };
54
+ try {
55
+ disabledCleanup = cleanupDisabledStatusline();
56
+ } catch { /* teardown is optional; rendering is not */ }
47
57
  if (disabledCleanup.cleaned) process.exit(0);
48
58
 
49
59
  // Only show status in projects that have a code-graph directory. The statusLine
@@ -18,6 +18,7 @@
18
18
  //
19
19
  // Fix: pin all hook + auto-update artifacts to a `code-graph-mcp/` subdir of
20
20
  // whatever `os.tmpdir()` resolves to. Contained, deterministic, easy to GC.
21
+ const crypto = require('crypto');
21
22
  const fs = require('fs');
22
23
  const os = require('os');
23
24
  const path = require('path');
@@ -29,4 +30,60 @@ function cgTmpDir() {
29
30
  return CG_TMP_DIR;
30
31
  }
31
32
 
32
- module.exports = { cgTmpDir, CG_TMP_DIR };
33
+ // Short, filename-safe digest of a project root. Cooldown flags live in ONE
34
+ // shared tmp dir for the whole machine, so a flag named only after its subject
35
+ // (a command, a symbol, a context type) is global: an `impact` push in project A
36
+ // silenced the identical prompt in project B for the next 30s, and a grep
37
+ // cooldown followed the developer across every repo they had open. Folding the
38
+ // cwd in makes the flag mean what it always claimed to — "recently done HERE".
39
+ // pre-read-guide.js already did this; the other four hooks did not.
40
+ function cwdHash(cwd) {
41
+ return crypto.createHash('sha1').update(String(cwd)).digest('hex').slice(0, 12);
42
+ }
43
+
44
+ // Nothing ever deleted what cgTmpDir() collects. Every cooldown flag, read-fanout
45
+ // state file and interrupted `update-*` download stayed forever: measured 281
46
+ // entries on a working dev box, 232 of them older than a day. They are 0-byte
47
+ // flags, so the cost is inode churn and a directory that makes `ls` in the shared
48
+ // tmp useless — but the `update-*` dirs hold a partly-extracted release tarball,
49
+ // which is megabytes each.
50
+ //
51
+ // The window has to exceed the longest cooldown that reads these files, or the
52
+ // prune would silently shorten it. The longest is user-prompt-context's
53
+ // `overview` at 5 minutes, so 24h is ~288× the margin — this is garbage
54
+ // collection, not expiry.
55
+ const PRUNE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
56
+
57
+ /**
58
+ * Delete cgTmpDir() entries older than `maxAgeMs`. Best-effort and total: any
59
+ * failure (missing dir, a file another process just removed, a permission
60
+ * problem) is skipped rather than raised — this runs from a SessionStart hook,
61
+ * where a throw is a visible failure and unreclaimed disk is not.
62
+ * @returns {number} entries removed
63
+ */
64
+ function pruneCgTmp({ now = Date.now(), maxAgeMs = PRUNE_MAX_AGE_MS, dir = CG_TMP_DIR } = {}) {
65
+ // The recursive delete below is the reason for this check. `dir` is a
66
+ // parameter only so tests can point it at a sandbox, and a future caller
67
+ // passing the wrong path must not be able to turn this into `rm -rf` on
68
+ // something that isn't ours.
69
+ if (path.basename(dir) !== 'code-graph-mcp') return 0;
70
+ let removed = 0;
71
+ let entries;
72
+ try {
73
+ entries = fs.readdirSync(dir, { withFileTypes: true });
74
+ } catch { return 0; }
75
+ for (const entry of entries) {
76
+ const target = path.join(dir, entry.name);
77
+ try {
78
+ if (now - fs.statSync(target).mtimeMs <= maxAgeMs) continue;
79
+ // `update-*` staging dirs from an interrupted auto-update are the only
80
+ // subdirectories here, and they are the ones that actually hold bytes.
81
+ if (entry.isDirectory()) fs.rmSync(target, { recursive: true, force: true });
82
+ else fs.unlinkSync(target);
83
+ removed++;
84
+ } catch { /* raced, unreadable, or already gone — the next run retries */ }
85
+ }
86
+ return removed;
87
+ }
88
+
89
+ module.exports = { cgTmpDir, CG_TMP_DIR, cwdHash, pruneCgTmp, PRUNE_MAX_AGE_MS };
@@ -12,7 +12,7 @@ const os = require('os');
12
12
  // ~/.claude/tmp/, so bare-tmp flags interleave with transcript captures —
13
13
  // diagnostic blindness + the §8 recursive-grep footgun (see tmp-dir.js). The
14
14
  // other hook scripts already route through here; this one was the lone holdout.
15
- const { cgTmpDir } = require('./tmp-dir');
15
+ const { cgTmpDir, cwdHash } = require('./tmp-dir');
16
16
  const { hidden } = require('./proc-opts');
17
17
 
18
18
  // Mid-session install detection: hook fires but no manifest yet.
@@ -27,17 +27,24 @@ const COOLDOWNS = {
27
27
  symptom: 10 * 60 * 1000, // 10min — Phase E meta-advisory hint, low value to repeat
28
28
  };
29
29
 
30
- function isCoolingDown(type) {
30
+ // Project-scoped (see cwdHash in tmp-dir.js). There are only five flag names
31
+ // here, so an un-scoped flag was effectively a machine-wide mute: one `impact`
32
+ // push in any repo silenced the next 30s of impact pushes in every other repo,
33
+ // and `overview` did it for five minutes.
34
+ function ctxFlagPath(type, cwd) {
35
+ return path.join(cgTmpDir(), `.code-graph-ctx-${cwdHash(cwd)}-${type}`);
36
+ }
37
+
38
+ function isCoolingDown(type, cwd = process.cwd()) {
31
39
  try {
32
- const flag = path.join(cgTmpDir(), `.code-graph-ctx-${type}`);
33
- const stat = fs.statSync(flag);
40
+ const stat = fs.statSync(ctxFlagPath(type, cwd));
34
41
  return Date.now() - stat.mtimeMs < (COOLDOWNS[type] || 60000);
35
42
  } catch { return false; }
36
43
  }
37
44
 
38
- function markCooldown(type) {
45
+ function markCooldown(type, cwd = process.cwd()) {
39
46
  try {
40
- fs.writeFileSync(path.join(cgTmpDir(), `.code-graph-ctx-${type}`), '');
47
+ fs.writeFileSync(ctxFlagPath(type, cwd), '');
41
48
  } catch { /* ok */ }
42
49
  }
43
50
 
@@ -424,7 +431,12 @@ function runMain() {
424
431
  // --- Read user message ---
425
432
  let message;
426
433
  try {
427
- const input = JSON.parse(fs.readFileSync('/dev/stdin', 'utf8'));
434
+ // fd 0, not '/dev/stdin': the path form open(2)s the symlink target, which
435
+ // fails with ENXIO when stdin is a socketpair (e.g. spawnSync {input}).
436
+ // Reading the fd directly works for pipes, sockets, and files alike. Five
437
+ // sibling hooks already did this; this one was the holdout, so it read
438
+ // nothing under exactly the conditions verifyHooksFire spawns it with.
439
+ const input = JSON.parse(fs.readFileSync(0, 'utf8'));
428
440
  message = (input && input.message) || '';
429
441
  } catch {
430
442
  return;
@@ -445,14 +457,16 @@ function runMain() {
445
457
  const filePaths = extractFilePaths(message);
446
458
  const symbols = extractSymbols(message);
447
459
  const intents = detectIntents(message);
448
- const query = determineQueryType(intents, symbols, filePaths, isCoolingDown, message);
460
+ // Key the cooldowns on the resolved project root, not the raw shell cwd, so a
461
+ // `cd` into a subdir does not read as a different project and re-fire.
462
+ const query = determineQueryType(intents, symbols, filePaths, (type) => isCoolingDown(type, cwd), message);
449
463
 
450
464
  if (!query) return;
451
465
 
452
466
  // Phase E: symptom-hint is prose-only (no CLI execution). Emit + cooldown
453
467
  // before the result-fetching paths so it can short-circuit cleanly.
454
468
  if (query.type === 'symptom-hint') {
455
- markCooldown('symptom');
469
+ markCooldown('symptom', cwd);
456
470
  process.stdout.write(
457
471
  '[code-graph:hint] indexed repo — for vague-symptom prompts, try `semantic_code_search "<symptom>"` ' +
458
472
  'or `module_overview <suspected-dir>` to surface candidate code structurally. Skip if not searching code.\n'
@@ -467,8 +481,17 @@ function runMain() {
467
481
  search: '[code-graph:search] Relevant code:',
468
482
  };
469
483
 
470
- function run(cmd, args) {
471
- return execFileSync(cmd, args, hidden({
484
+ // Resolve the binary the way every other hook does. The bare name relied on
485
+ // `code-graph-mcp` being on PATH, which it is not for plugin-only installs
486
+ // (the binary lives in ~/.cache/code-graph/bin), and when it IS on PATH it may
487
+ // be an unrelated or years-stale global shim — the same finding cg-answer.js
488
+ // and session-init.js were already fixed for. Null means no engine: nothing to
489
+ // run, and nothing to say about it.
490
+ const binary = require('./find-binary').findBinary();
491
+ if (!binary) return;
492
+
493
+ function run(args) {
494
+ return execFileSync(binary, args, hidden({
472
495
  cwd,
473
496
  timeout: 3000,
474
497
  encoding: 'utf8',
@@ -477,15 +500,21 @@ function runMain() {
477
500
  }));
478
501
  }
479
502
 
503
+ // Stamp the cooldown on ATTEMPT, not on success. Keyed on the outcome, a
504
+ // binary that fails or times out produced no flag, so the very next prompt of
505
+ // the same shape re-ran it — a 3s execFileSync timeout on EVERY turn, which is
506
+ // the worst case wearing the cheapest disguise. The cooldown's job is rate
507
+ // limiting; whether the run had something to say is a separate question.
508
+ markCooldown(query.type, cwd);
509
+
480
510
  try {
481
511
  let result = '';
482
- if (query.type === 'impact') result = run('code-graph-mcp', ['impact', query.symbol]);
483
- else if (query.type === 'callgraph') result = run('code-graph-mcp', ['callgraph', query.symbol, '--depth', '2']);
484
- else if (query.type === 'overview') result = run('code-graph-mcp', ['overview', query.path]);
485
- else if (query.type === 'search') result = run('code-graph-mcp', ['search', query.symbol, '--limit', '8']);
512
+ if (query.type === 'impact') result = run(['impact', query.symbol]);
513
+ else if (query.type === 'callgraph') result = run(['callgraph', query.symbol, '--depth', '2']);
514
+ else if (query.type === 'overview') result = run(['overview', query.path]);
515
+ else if (query.type === 'search') result = run(['search', query.symbol, '--limit', '8']);
486
516
 
487
517
  if (result && result.trim()) {
488
- markCooldown(query.type);
489
518
  process.stdout.write(`${PREFIXES[query.type]}\n${result.trim()}\n`);
490
519
  }
491
520
  } catch {
@@ -497,4 +526,4 @@ if (require.main === module) {
497
526
  runMain();
498
527
  }
499
528
 
500
- module.exports = { shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS, buildRunEnv };
529
+ module.exports = { COOLDOWNS, shouldSkip, extractFilePaths, extractSymbols, detectIntents, scoreIntent, INTENT_PATTERNS, INTENT_THRESHOLD, determineQueryType, computeQuietHooks, STOP_WORDS, PLAIN_WORD_EXCLUDE, hasSymptom, SYMPTOM_PATTERNS, buildRunEnv };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.111.1",
3
+ "version": "0.112.0",
4
4
  "description": "MCP server that indexes codebases into an AST knowledge graph with semantic search, call graph traversal, and HTTP route tracing",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -35,10 +35,10 @@
35
35
  "node": ">=16"
36
36
  },
37
37
  "optionalDependencies": {
38
- "@sdsrs/code-graph-linux-x64": "0.111.1",
39
- "@sdsrs/code-graph-linux-arm64": "0.111.1",
40
- "@sdsrs/code-graph-darwin-x64": "0.111.1",
41
- "@sdsrs/code-graph-darwin-arm64": "0.111.1",
42
- "@sdsrs/code-graph-win32-x64": "0.111.1"
38
+ "@sdsrs/code-graph-linux-x64": "0.112.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.112.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.112.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.112.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.112.0"
43
43
  }
44
44
  }