@sdsrs/code-graph 0.134.0 → 0.136.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.
@@ -12,6 +12,27 @@ const PLATFORM = os.platform();
12
12
  const ARCH = os.arch();
13
13
  const CACHE_FILE = path.join(os.homedir(), '.cache', 'code-graph', 'binary-path');
14
14
  const BINARY_NAME = PLATFORM === 'win32' ? 'code-graph-mcp.exe' : 'code-graph-mcp';
15
+ // PATH lookup bound (NEW-07). 2 s matches the sibling `npm root -g` probe; the
16
+ // floor is what a budget-exhausted hook still gives it, because a `which` that
17
+ // cannot answer in 250 ms was not going to rescue this hook anyway.
18
+ const PATH_PROBE_TIMEOUT_MS = 2000;
19
+ const PATH_PROBE_MIN_MS = 250;
20
+
21
+ /**
22
+ * How long the PATH probe may run: the hook budget when one is armed, the
23
+ * default otherwise, never 0 and never fractional.
24
+ *
25
+ * Both edges matter. `timeout: 0` is read by node as NO TIMEOUT AT ALL — it
26
+ * would restore the exact unbounded call this replaced — and a fractional value
27
+ * makes `child_process` throw ERR_OUT_OF_RANGE, which the `catch` below would
28
+ * silently turn into "not on PATH" (bugfix #7's shape, in a function that runs
29
+ * before any hook has spent a millisecond).
30
+ */
31
+ function pathProbeTimeoutMs(remaining = require('./hook-fail-open').remainingMs) {
32
+ const left = remaining(PATH_PROBE_TIMEOUT_MS);
33
+ const ms = left === null ? PATH_PROBE_MIN_MS : Math.floor(left);
34
+ return Math.max(ms, PATH_PROBE_MIN_MS);
35
+ }
15
36
  const PLATFORM_PKG = `@sdsrs/code-graph-${PLATFORM}-${ARCH}`;
16
37
 
17
38
  /**
@@ -471,9 +492,28 @@ function findBinaryUncached() {
471
492
  }
472
493
 
473
494
  // --- PATH lookup (last resort for intentionally installed binaries) ---
495
+ //
496
+ // BOUNDED. This had no timeout at all, and it runs BEFORE any hook has called
497
+ // `remainingMs` even once — `findBinary()` is the first thing every hook does
498
+ // (audit 2026-09-05 NEW-07). It was called "the only unbounded child on the
499
+ // hook path" when it was fixed; the pre-ship review then found `ps` in
500
+ // `lifecycle.js`, so that was never true. Do not restore the superlative
501
+ // without re-deriving it. A `which` against a wedged PATH entry (a dead NFS mount, an
502
+ // automounter) hangs until Claude Code kills the hook, which surfaces to the
503
+ // user as an error on THEIR tool call.
504
+ //
505
+ // Spends the hook budget when one is armed, floored at 250 ms so the probe is
506
+ // always actually attempted: skipping it outright would make `findBinary`
507
+ // answer "no binary" for one that is on PATH, and a fabricated absence is
508
+ // worse than a probe that ran out of time. Outside a hook `remainingMs`
509
+ // returns the default, so nothing changes for doctor / statusline / the
510
+ // launcher.
474
511
  try {
475
512
  const which = PLATFORM === 'win32' ? 'where' : 'which';
476
- const found = execFileSync(which, [BINARY_NAME], hidden({ stdio: ['pipe', 'pipe', 'pipe'] }))
513
+ const found = execFileSync(which, [BINARY_NAME], hidden({
514
+ timeout: pathProbeTimeoutMs(),
515
+ stdio: ['pipe', 'pipe', 'pipe'],
516
+ }))
477
517
  .toString().trim().split('\n')[0];
478
518
  const hit = gate.consider(found);
479
519
  if (hit) return hit;
@@ -513,6 +553,7 @@ module.exports = {
513
553
  getPackageVersion, compareVersions, isCachedBinaryFresh,
514
554
  detectLibc, unsupportedPlatformHint,
515
555
  CACHE_FILE, BINARY_NAME, PLATFORM_PKG,
556
+ pathProbeTimeoutMs, PATH_PROBE_TIMEOUT_MS, PATH_PROBE_MIN_MS,
516
557
  };
517
558
 
518
559
  // Allow direct invocation for testing
@@ -19,7 +19,94 @@
19
19
  //
20
20
  // EPIPE is silent by design. It means the consumer closed the pipe — Claude
21
21
  // Code moved on, or a `| head` upstream exited. There is nobody left to tell.
22
+ // The budget Claude Code kills each hook at, in SECONDS — the single source for
23
+ // both halves of that contract: `lifecycle.js` registers these numbers into
24
+ // settings.json, and `remainingMs` below spends against them. `hooks.test.js`
25
+ // pins both registration sites to this table so a bump in one cannot drift from
26
+ // the other.
27
+ //
28
+ // `session-init.js` is registered from `claude-plugin/hooks/hooks.json` rather
29
+ // than by `lifecycle.js` (SessionStart is the one event Claude Code loads from
30
+ // plugin-cache), so `hooks.test.js` pins that file to this table too. It was
31
+ // the last unclamped hook — 21.5 s of serial children against the 5 s below,
32
+ // the largest overrun of the seven — until audit 2026-09-05 NEW-05 wired it.
33
+ // Its skips are not uniform: see the budget block at the top of
34
+ // `session-init.js` for which children may be dropped silently and which two
35
+ // report a distinct result instead of a fabricated all-clear.
36
+ const HOOK_TIMEOUT_SECONDS = {
37
+ 'pre-edit-guide.js': 4,
38
+ 'pre-grep-guide.js': 3,
39
+ 'pre-read-guide.js': 3,
40
+ 'incremental-index.js': 10,
41
+ 'post-grep-inject.js': 5,
42
+ 'user-prompt-context.js': 5,
43
+ 'session-init.js': 5,
44
+ };
45
+
46
+ // Left for the hook to render its answer and exit after the last child returns.
47
+ const WRITE_RESERVE_MS = 400;
48
+
49
+ // Wall-clock instant this process must be finished by, or null when nothing
50
+ // armed one (a hook `require`d by a test, or an unlisted script).
51
+ let deadlineAt = null;
52
+
53
+ /**
54
+ * Arm the process deadline from the registered budget of `script`.
55
+ *
56
+ * The hooks' internal timeouts were each sized in isolation and run in SERIES:
57
+ * pre-edit-guide alone could spend 5 × 2000 ms of candidate greps plus 2500 ms
58
+ * of impact against a 4 s budget, and post-grep-inject looped callgraph over
59
+ * every symbol in the pattern before its show/grep fallbacks — 2–3× the budget
60
+ * either way. Nothing enforced the sum, so a binary wedged on `index.lock` got
61
+ * the hook killed by Claude Code, which surfaces to the user as a hook error on
62
+ * THEIR tool call (audit 2026-09-05 JS-03).
63
+ *
64
+ * `process.uptime()` is subtracted because the budget starts when Claude Code
65
+ * spawns us, not when this line runs: cold node startup is real time already
66
+ * spent, and on a loaded machine it is hundreds of milliseconds.
67
+ */
68
+ function armHookDeadline(script) {
69
+ const seconds = HOOK_TIMEOUT_SECONDS[script];
70
+ if (!seconds) return;
71
+ deadlineAt = Math.floor(Date.now() + seconds * 1000 - process.uptime() * 1000 - WRITE_RESERVE_MS);
72
+ }
73
+
74
+ /**
75
+ * How long a child may run: `defaultMs`, or whatever is left of the budget.
76
+ *
77
+ * Returns `null` when there is nothing left — meaning DO NOT RUN, not "run
78
+ * unbounded". Callers must branch on it: node reads `timeout: 0` as no timeout
79
+ * at all, so a numeric zero here would turn the last child into the unbounded
80
+ * one, which is the exact failure this exists to prevent.
81
+ *
82
+ * Always an INTEGER. `child_process` validates `timeout` with
83
+ * `validateTimeout` and throws `ERR_OUT_OF_RANGE` on a fraction — and the one
84
+ * fractional term here (`process.uptime()`) made every spawn throw, which the
85
+ * runners' own try/catch turned into a silent `unavailable`: the hooks stopped
86
+ * answering and still exited 0. Caught by pre-grep-guide's e2e suite.
87
+ */
88
+ function remainingMs(defaultMs) {
89
+ if (deadlineAt === null) return Math.floor(defaultMs);
90
+ const left = deadlineAt - Date.now();
91
+ if (left <= 0) return null;
92
+ return Math.floor(Math.min(defaultMs, left));
93
+ }
94
+
95
+ /**
96
+ * Test seam: drop any armed deadline so one test file can't leak into another.
97
+ *
98
+ * `at` (an absolute epoch ms) arms one directly instead. `armHookDeadline`
99
+ * derives its instant from `process.uptime()`, so a test that wants the
100
+ * budget-EXHAUSTED branch would otherwise have to wait out a real budget — a
101
+ * clock race dressed up as a test. Pass `Date.now() - 1` to make every
102
+ * `remainingMs` return null deterministically.
103
+ */
104
+ function resetHookDeadline(at = null) {
105
+ deadlineAt = at === null ? null : Math.floor(at);
106
+ }
107
+
22
108
  function installHookFailOpen(label) {
109
+ armHookDeadline(require('path').basename(process.argv[1] || ''));
23
110
  const bail = (err) => {
24
111
  const code = (err && err.code) || (err && err.name) || 'Error';
25
112
  if (code !== 'EPIPE') {
@@ -46,4 +133,10 @@ function installHookFailOpen(label) {
46
133
  process.on('unhandledRejection', bail);
47
134
  }
48
135
 
49
- module.exports = { installHookFailOpen };
136
+ module.exports = {
137
+ installHookFailOpen,
138
+ HOOK_TIMEOUT_SECONDS,
139
+ armHookDeadline,
140
+ remainingMs,
141
+ resetHookDeadline,
142
+ };
@@ -13,6 +13,11 @@ const OLD_PLUGIN_IDS = [
13
13
  ];
14
14
  const MARKETPLACE_NAME = 'code-graph-mcp';
15
15
  const CACHE_DIR = path.join(os.homedir(), '.cache', 'code-graph');
16
+ // Bound for the `ps` fallback in getActiveCmdlines (pre-ship review 2026-09-06).
17
+ // 2 s matches the other hook-path probes; the floor is what a budget-exhausted
18
+ // hook still gives it, since an empty list degrades to recency-only.
19
+ const PS_PROBE_TIMEOUT_MS = 2000;
20
+ const PS_PROBE_MIN_MS = 250;
16
21
  // Always derive from __dirname — CLAUDE_PLUGIN_ROOT env var can leak from other
17
22
  // plugins when hooks run in shared process context (e.g. claude-mem-lite sets it
18
23
  // to its own marketplace path, polluting all subsequent settings.json hook processes).
@@ -142,6 +147,41 @@ function readJson(filePath) {
142
147
  /** How many `.corrupt-*` copies of one file to keep. */
143
148
  const MAX_CORRUPT_BACKUPS = 5;
144
149
 
150
+ /**
151
+ * Owner-only. What every file this module creates under `~/.claude` gets when
152
+ * there is no prior file whose bits to preserve, and what a `.corrupt-*` copy
153
+ * gets unconditionally: settings.json routinely carries an `env` block with API
154
+ * keys, and a backup of one is the same secret in a second file (audit
155
+ * 2026-09-05 JS-01).
156
+ */
157
+ const SECRET_FILE_MODE = 0o600;
158
+
159
+ /**
160
+ * Pin `target` at exactly `mode`, because neither route that creates our files
161
+ * does it on its own: `writeFileSync`'s `mode` option goes to `open(O_CREAT)`
162
+ * and is masked by umask (and ignored outright when the file already exists),
163
+ * and `copyFileSync` carries the SOURCE's bits.
164
+ *
165
+ * Reported, not swallowed: the whole point of the call is that the file may
166
+ * hold a key, so a permission we could not set is exactly the thing the user
167
+ * needs to hear about. The write itself has already succeeded — a failure here
168
+ * is a disclosure, not a reason to unwind it.
169
+ */
170
+ function restrictMode(target, mode) {
171
+ try {
172
+ fs.chmodSync(target, mode);
173
+ } catch (err) {
174
+ // No return value: both call sites act on the stderr line, not on a bool,
175
+ // and a discarded success flag is an invitation to branch on it later
176
+ // without noticing that nothing ever did (pre-ship review 2026-09-05).
177
+ console.error(
178
+ `[code-graph] wrote ${target} but could not set its permissions to ` +
179
+ `0${mode.toString(8)} (${err.code || err.name}). If it contains an API key, ` +
180
+ `it may be readable by other users on this machine.`
181
+ );
182
+ }
183
+ }
184
+
145
185
  /**
146
186
  * Delete all but the newest `MAX_CORRUPT_BACKUPS` copies of `filePath`.
147
187
  *
@@ -180,9 +220,14 @@ function backupCorruptFile(filePath, raw) {
180
220
  const dest = `${filePath}.corrupt-${stamp}`;
181
221
  try {
182
222
  // Buffer, not string: see readJsonResult. A string here would re-encode.
183
- if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw);
184
- else if (typeof raw === 'string') fs.writeFileSync(dest, Buffer.from(raw, 'utf8'));
185
- else fs.copyFileSync(filePath, dest);
223
+ if (Buffer.isBuffer(raw)) fs.writeFileSync(dest, raw, { mode: SECRET_FILE_MODE });
224
+ else if (typeof raw === 'string') {
225
+ fs.writeFileSync(dest, Buffer.from(raw, 'utf8'), { mode: SECRET_FILE_MODE });
226
+ } else fs.copyFileSync(filePath, dest);
227
+ // Owner-only regardless of what the original was: up to MAX_CORRUPT_BACKUPS
228
+ // of these accumulate beside settings.json with its `env` block copied
229
+ // verbatim, so a 0644 original must not mint 0644 duplicates.
230
+ restrictMode(dest, SECRET_FILE_MODE);
186
231
  pruneCorruptBackups(filePath);
187
232
  return dest;
188
233
  } catch {
@@ -261,11 +306,23 @@ function readSettingsForWrite(pre) {
261
306
  return { settings: {}, backedUpTo: backup };
262
307
  }
263
308
 
309
+ // Replace-by-rename loses the permission bits unless they are carried over
310
+ // deliberately: the new inode is the TMP file's, created under our umask, so a
311
+ // settings.json the user had put at 0600 came back 0644 on the first
312
+ // install()/update()/cleanupDisabledStatusline() — with its `env` API keys in
313
+ // it, silently, on a file we were only meant to add two hook entries to (audit
314
+ // 2026-09-05 JS-01). Stat the original and restore its exact mode; when there
315
+ // is no original, create owner-only rather than at whatever umask says.
264
316
  function writeJsonAtomic(filePath, data) {
265
317
  const dir = path.dirname(filePath);
266
318
  fs.mkdirSync(dir, { recursive: true });
319
+ let mode = null;
320
+ try { mode = fs.statSync(filePath).mode & 0o777; } catch { /* new file */ }
267
321
  const tmp = filePath + '.tmp.' + process.pid;
268
- fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n');
322
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: mode ?? SECRET_FILE_MODE });
323
+ // umask can only have cleared bits the original had, so chmod is what makes
324
+ // this preservation rather than tightening.
325
+ restrictMode(tmp, mode ?? SECRET_FILE_MODE);
269
326
  fs.renameSync(tmp, filePath);
270
327
  }
271
328
 
@@ -907,7 +964,13 @@ function removeHooksFromSettings(settings) {
907
964
 
908
965
  function buildSettingsHookEntries() {
909
966
  const root = PLUGIN_ROOT;
910
- const scriptCmd = (name, timeout) => {
967
+ // The budget is read from the table the hooks THEMSELVES spend against
968
+ // (hook-fail-open.js), not written twice: a number registered here that the
969
+ // hook does not know about is the JS-03 shape — the hook overruns a limit it
970
+ // cannot see and Claude Code kills it mid-tool-call.
971
+ const { HOOK_TIMEOUT_SECONDS } = require('./hook-fail-open');
972
+ const scriptCmd = (name) => {
973
+ const timeout = HOOK_TIMEOUT_SECONDS[name];
911
974
  const script = path.join(root, 'scripts', name);
912
975
  // POSIX: existence-guarded. After `/plugin uninstall`, CC may delete the
913
976
  // plugin-cache dir before our statusline teardown gets to strip these
@@ -924,16 +987,16 @@ function buildSettingsHookEntries() {
924
987
 
925
988
  return {
926
989
  PreToolUse: [
927
- { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js', 4)] },
928
- { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js', 3)] },
929
- { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js', 3)] },
990
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Edit', hooks: [scriptCmd('pre-edit-guide.js')] },
991
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Bash', hooks: [scriptCmd('pre-grep-guide.js')] },
992
+ { description: SETTINGS_HOOK_DESC.preToolUse, matcher: 'Read', hooks: [scriptCmd('pre-read-guide.js')] },
930
993
  ],
931
994
  PostToolUse: [
932
- { description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js', 10)] },
933
- { description: SETTINGS_HOOK_DESC.postToolUseInject, matcher: 'Bash', hooks: [scriptCmd('post-grep-inject.js', 5)] },
995
+ { description: SETTINGS_HOOK_DESC.postToolUseEdit, matcher: 'Write|Edit', hooks: [scriptCmd('incremental-index.js')] },
996
+ { description: SETTINGS_HOOK_DESC.postToolUseInject, matcher: 'Bash', hooks: [scriptCmd('post-grep-inject.js')] },
934
997
  ],
935
998
  UserPromptSubmit: [
936
- { description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js', 5)] },
999
+ { description: SETTINGS_HOOK_DESC.userPromptSubmit, matcher: '', hooks: [scriptCmd('user-prompt-context.js')] },
937
1000
  ],
938
1001
  };
939
1002
  }
@@ -1589,10 +1652,19 @@ function update() {
1589
1652
  settingsChanged = true;
1590
1653
  }
1591
1654
 
1592
- // 1. Update composite command path if version changed
1655
+ // 1. Update composite command path if version changed.
1656
+ //
1657
+ // Same predicate as install()'s heal arm, not a bare string comparison: an
1658
+ // exact mismatch is NOT staleness. Two copies of this plugin (plugin cache +
1659
+ // global npm, or a dev checkout) derive different absolute paths for the SAME
1660
+ // current composite, so rewriting on mismatch makes each surface take the slot
1661
+ // back from the other. install() has carried the guard since that ping-pong
1662
+ // was diagnosed; update() kept the string test, which reopened the pair for
1663
+ // one round on every version bump and walked straight past the
1664
+ // `statuslineDisplaced` stand-down (audit 2026-09-05 JS-08).
1593
1665
  if (isOurComposite(settings)) {
1594
1666
  const cmd = compositeCommand();
1595
- if (settings.statusLine.command !== cmd) {
1667
+ if (settings.statusLine.command !== cmd && compositeSlotIsStale(settings.statusLine.command)) {
1596
1668
  settings.statusLine.command = cmd;
1597
1669
  settingsChanged = true;
1598
1670
  }
@@ -1717,10 +1789,33 @@ function readActiveProcessCmdlines() {
1717
1789
  } catch { /* fall through to ps */ }
1718
1790
  try {
1719
1791
  const { execFileSync } = require('child_process');
1792
+ // BOUNDED. Reached in-process from the SessionStart hook —
1793
+ // runSessionInit -> syncLifecycleConfig -> update() ->
1794
+ // cleanupOldCacheVersions -> here, unconditionally, on the first session
1795
+ // after every plugin update, which is precisely the session the hook budget
1796
+ // work targets. It carried no timeout, so a wedged `ps` (a stuck process
1797
+ // table, a paused container) hung the hook until Claude Code killed it —
1798
+ // which the user sees as an error on their own tool call. Found by pre-ship
1799
+ // review 2026-09-06 after find-binary's `which` was fixed and the release
1800
+ // notes called it "the only unbounded child a hook could reach".
1801
+ //
1802
+ // Spends the hook budget when one is armed, floored so the probe is always
1803
+ // attempted.
1804
+ //
1805
+ // Be honest about the cost: an empty answer drops pruning back to
1806
+ // recency-only, which is exactly the state this guard exists to escape —
1807
+ // see cleanupOldCacheVersions, where pruning a version a live process is
1808
+ // bound to breaks `/mcp` reconnect with MODULE_NOT_FOUND. So a timeout is a
1809
+ // NEW route to the guard being off, not a free fallback. It is still the
1810
+ // right trade: a `ps` slow enough to hit this is rare, and hanging the hook
1811
+ // past its budget is a certain failure rather than a possible one.
1812
+ const { remainingMs } = require('./hook-fail-open');
1813
+ const budget = remainingMs(PS_PROBE_TIMEOUT_MS);
1720
1814
  return execFileSync('ps', ['-axww', '-o', 'command='], hidden({
1815
+ timeout: budget === null ? PS_PROBE_MIN_MS : Math.max(budget, PS_PROBE_MIN_MS),
1721
1816
  encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
1722
1817
  })).split('\n').filter(Boolean);
1723
- } catch { /* unsupported platform — caller falls back to recency-only */ }
1818
+ } catch { /* unsupported platform, or out of time — caller falls back to recency-only */ }
1724
1819
  return [];
1725
1820
  }
1726
1821
 
@@ -24,6 +24,7 @@ const { recordRecommendation } = require('./recommendation-log');
24
24
  const { formatCoveringTests } = require('./covering-tests');
25
25
  const { emitPreToolContext } = require('./hook-emit');
26
26
  const { hidden } = require('./proc-opts');
27
+ const { remainingMs } = require('./hook-fail-open');
27
28
 
28
29
  // v0.49 — walk up from the shell cwd (subdir-cwd fix). The per-cwd index.db
29
30
  // gate kept this hook dark for entire sessions after `cd backend/` — daagu
@@ -114,10 +115,17 @@ if (!symbol || symbol.length < 3) {
114
115
  const candidates = [...new Set(identifiers)]
115
116
  .filter(id => !skipWords.has(id.toLowerCase()))
116
117
  .sort((a, b) => b.length - a.length);
117
- for (const candidate of candidates.slice(0, 5)) {
118
+ // Two candidates, not five. They are sorted most-specific-first, so the
119
+ // 3rd–5th were the least likely to resolve AND the ones that pushed this
120
+ // loop past the hook's whole 4 s budget (5 × 2000 ms here + 2500 ms of
121
+ // impact below); the impact query they starve is the hook's actual output
122
+ // (audit 2026-09-05 JS-03).
123
+ for (const candidate of candidates.slice(0, 2)) {
124
+ const budget = remainingMs(2000);
125
+ if (budget === null) break;
118
126
  try {
119
127
  const raw = execFileSync(binary, ['grep', candidate, filePath, '--json'], hidden({
120
- cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
128
+ cwd, timeout: budget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
121
129
  env: internalEnv,
122
130
  }));
123
131
  const grepResult = JSON.parse(raw);
@@ -170,6 +178,11 @@ try {
170
178
  const editedFile = (input.tool_input && input.tool_input.file_path) || '';
171
179
  const relFile = editedFile ? path.relative(cwd, editedFile) : '';
172
180
  let jsonResult;
181
+ // Whatever is left of the registered 4 s, capped at this call's own 2500 ms.
182
+ // `null` = the candidate loop above already spent the budget; running anyway is
183
+ // what got the hook killed by Claude Code mid-Edit (audit 2026-09-05 JS-03).
184
+ const impactBudget = remainingMs(2500);
185
+ if (impactBudget === null) process.exit(0);
173
186
  try {
174
187
  const args = ['impact', symbol, '--json'];
175
188
  if (relFile && !relFile.startsWith('..')) args.push('--file', relFile);
@@ -177,7 +190,7 @@ try {
177
190
  // diverging from the findBinary() result the rest of the hook trusts).
178
191
  const raw = execFileSync(binary, args, hidden({
179
192
  cwd,
180
- timeout: 2500,
193
+ timeout: impactBudget,
181
194
  encoding: 'utf8',
182
195
  stdio: ['pipe', 'pipe', 'pipe'],
183
196
  env: internalEnv,
@@ -642,8 +642,14 @@ function buildNoHitsFyi(pattern) {
642
642
  // only (PreToolUse exit-0 stdout → debug log, never the model); the operative
643
643
  // effect at the call site is the ALLOW (no deny emitted) so the raw grep runs
644
644
  // intact instead of a static deny that would hand the model nothing.
645
- function buildUnavailableFyi(pattern, status) {
646
- const why = status === 'no-binary' ? 'binary not found' : 'ran but failed';
645
+ function buildUnavailableFyi(pattern, status, reason) {
646
+ // Three causes, not two. A hook whose budget was already spent at startup
647
+ // (cold node on a loaded machine — see the reserve note in cg-answer.js)
648
+ // deliberately runs no children at all; calling that "ran but failed" blames
649
+ // the binary for something it was never asked to do (audit 2026-09-05 NEW-08).
650
+ const why = status === 'no-binary' ? 'binary not found'
651
+ : reason === 'budget' ? 'no time left in the hook budget'
652
+ : 'ran but failed';
647
653
  return `[code-graph] FYI: \`code-graph-mcp grep "${pattern}"\` unavailable (${why}) — raw grep proceeding.`;
648
654
  }
649
655
 
@@ -777,11 +783,16 @@ function runMain() {
777
783
  // static deny (the answer never ran → status stays the default 'unavailable')
778
784
  // — that path falls through to the v0.46 static deny below.
779
785
  if (answer.status !== 'hits' && !isAnswerDisabled()) {
780
- recordRecommendation(root, { hook: 'grep', action: 'hint', fallthrough: answer.status });
786
+ recordRecommendation(root, {
787
+ hook: 'grep', action: 'hint', fallthrough: answer.status,
788
+ // So the funnel can tell a starved hook from a broken binary; both
789
+ // arrive as `unavailable` and only one of them means anything is wrong.
790
+ ...(answer.reason ? { fallthrough_reason: answer.reason } : {}),
791
+ });
781
792
  process.stdout.write(
782
793
  (answer.status === 'no-hits'
783
794
  ? buildNoHitsFyi(pattern)
784
- : buildUnavailableFyi(pattern, answer.status)) + '\n');
795
+ : buildUnavailableFyi(pattern, answer.status, answer.reason)) + '\n');
785
796
  return;
786
797
  }
787
798