@sdsrs/code-graph 0.135.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.
@@ -4,7 +4,7 @@
4
4
  "author": {
5
5
  "name": "sdsrs"
6
6
  },
7
- "version": "0.135.0",
7
+ "version": "0.136.0",
8
8
  "keywords": [
9
9
  "code-graph",
10
10
  "ast",
@@ -167,6 +167,43 @@ function saveState(state) {
167
167
  }
168
168
  }
169
169
 
170
+ // ── Failure diagnostics ────────────────────────────────────
171
+ //
172
+ // The updater's main trigger is `launchBackgroundAutoUpdate` in session-init.js:
173
+ // `spawn(..., { detached: true, stdio: 'ignore' })`. On that path EVERY
174
+ // `console.error` in this file goes to /dev/null — the sha-sidecar refusal, the
175
+ // size floor, the checksum mismatch, the promote EACCES, the repoint block. All
176
+ // the user gets is the statusline's "⚠ update stuck" and a `doctor` that re-runs
177
+ // its checks instead of reporting what already failed (audit 2026-09-05 JS-02).
178
+ //
179
+ // Recorded in memory and persisted by the ONE site that already writes state,
180
+ // rather than a `saveState` at each rejection point: `CACHE_DIR` is
181
+ // `~/.cache/code-graph` with no env seam, so a leaf function that persisted on
182
+ // its own would have in-process unit tests writing into the developer's real
183
+ // cache directory.
184
+ let lastFailure = null;
185
+
186
+ /** Record why the updater refused/failed. Returns `message` so call sites can
187
+ * print the same string they store, instead of maintaining two copies. */
188
+ function noteUpdateFailure(stage, message) {
189
+ lastFailure = {
190
+ at: new Date().toISOString(),
191
+ stage,
192
+ // Bounded: `e.message` can carry a whole curl transcript, and this lands in
193
+ // a JSON file read on every session start.
194
+ message: String(message == null ? '' : message).slice(0, 300),
195
+ };
196
+ return message;
197
+ }
198
+
199
+ /** Read-and-clear. The caller is about to persist it (or to record a success,
200
+ * which must not leave the previous attempt's reason behind). */
201
+ function takeUpdateFailure() {
202
+ const f = lastFailure;
203
+ lastFailure = null;
204
+ return f;
205
+ }
206
+
170
207
  // ── Throttle ───────────────────────────────────────────────
171
208
 
172
209
  // The updater has given up on the current target release (MAX_UPDATE_ATTEMPTS
@@ -525,7 +562,8 @@ async function downloadBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate }
525
562
  if (!latest || !latest.binaryUrl) return false;
526
563
  if (!needsUpdate(latest)) return false; // already at latest.version — no fetch
527
564
  if (!commandExists('curl')) {
528
- console.error('[code-graph] Binary download skipped: curl not on PATH.');
565
+ console.error(`[code-graph] ${noteUpdateFailure('curl-missing',
566
+ 'Binary download skipped: curl not on PATH.')}`);
529
567
  return false;
530
568
  }
531
569
 
@@ -569,14 +607,16 @@ async function downloadBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate }
569
607
  }
570
608
  }
571
609
  if (!expectedSha) {
572
- console.error(`[code-graph] Refusing to install: no sha256 sidecar for ${latest.binaryUrl} (fetched twice). The current binary is unchanged; the next update check will retry.`);
610
+ console.error(`[code-graph] ${noteUpdateFailure('binary-sha-sidecar-missing',
611
+ `Refusing to install: no sha256 sidecar for ${latest.binaryUrl} (fetched twice). The current binary is unchanged; the next update check will retry.`)}`);
573
612
  try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
574
613
  return false;
575
614
  }
576
615
 
577
616
  return promoteVerifiedBinary(binaryTmp, binaryDst, latest.version, expectedSha);
578
617
  } catch (e) {
579
- console.error(`[code-graph] Binary download failed: ${e.message}`);
618
+ console.error(`[code-graph] ${noteUpdateFailure('binary-download-failed',
619
+ `Binary download failed: ${e.message}`)}`);
580
620
  return false;
581
621
  }
582
622
  }
@@ -599,11 +639,10 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
599
639
  // burning one of MAX_UPDATE_ATTEMPTS with nothing on stderr to explain it.
600
640
  const stat = fs.statSync(binaryTmp);
601
641
  if (stat.size <= 1_000_000) {
602
- console.error(
603
- `[code-graph] Refusing to install: downloaded binary is ${stat.size} bytes — far below the ~1 MB floor, ` +
642
+ console.error(`[code-graph] ${noteUpdateFailure('binary-too-small',
643
+ `Refusing to install: downloaded binary is ${stat.size} bytes — far below the ~1 MB floor, ` +
604
644
  'so the transfer was truncated or the server returned an error page. ' +
605
- 'The current binary is unchanged; the next update check retries.'
606
- );
645
+ 'The current binary is unchanged; the next update check retries.')}`);
607
646
  return false;
608
647
  }
609
648
 
@@ -618,13 +657,15 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
618
657
  // a fail-closed `src/snapshot/install.rs` — and a warning printed to stderr
619
658
  // during a background auto-update is seen by nobody.
620
659
  if (!expectedSha256) {
621
- console.error('[code-graph] No expected sha256 supplied — refusing to install an unverified binary.');
660
+ console.error(`[code-graph] ${noteUpdateFailure('binary-sha-missing',
661
+ 'No expected sha256 supplied — refusing to install an unverified binary.')}`);
622
662
  try { fs.unlinkSync(binaryTmp); } catch { /* ok */ }
623
663
  return false;
624
664
  }
625
665
  const actualSha = sha256File(binaryTmp);
626
666
  if (actualSha.toLowerCase() !== String(expectedSha256).toLowerCase()) {
627
- console.error(`[code-graph] Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`);
667
+ console.error(`[code-graph] ${noteUpdateFailure('binary-checksum-mismatch',
668
+ `Binary checksum mismatch (sha256): expected ${expectedSha256}, got ${actualSha} — refusing to install.`)}`);
628
669
  return false;
629
670
  }
630
671
 
@@ -642,10 +683,9 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
642
683
  // Sibling of the size floor above: silent for the same reason and with the
643
684
  // same cost. `--version` failing to run at all (wrong arch, missing libc)
644
685
  // reads identically to a version mismatch without this.
645
- console.error(
646
- `[code-graph] Refusing to install: downloaded binary reports ${actualVersion ? `v${actualVersion}` : 'no runnable --version'}` +
647
- `${expectedVersion ? `, expected v${expectedVersion}` : ''} — not installing it.`
648
- );
686
+ console.error(`[code-graph] ${noteUpdateFailure('binary-version-unrunnable',
687
+ `Refusing to install: downloaded binary reports ${actualVersion ? `v${actualVersion}` : 'no runnable --version'}` +
688
+ `${expectedVersion ? `, expected v${expectedVersion}` : ''} — not installing it.`)}`);
649
689
  return false;
650
690
  }
651
691
 
@@ -658,7 +698,8 @@ function promoteVerifiedBinary(binaryTmp, binaryDst, expectedVersion, expectedSh
658
698
  // server is running), EBUSY, EXDEV. A bare `catch { return false }` made all
659
699
  // of them one indistinguishable failure that the caller counted as an
660
700
  // attempt and printed nothing about.
661
- console.error(`[code-graph] Binary promote failed${e && e.code ? ` (${e.code})` : ''}: ${e && e.message}`);
701
+ console.error(`[code-graph] ${noteUpdateFailure('binary-promote-failed',
702
+ `Binary promote failed${e && e.code ? ` (${e.code})` : ''}: ${e && e.message}`)}`);
662
703
  return false;
663
704
  } finally {
664
705
  try {
@@ -706,7 +747,8 @@ async function downloadAndInstall(latest, {
706
747
  // Pre-flight: check required CLI tools before attempting any download
707
748
  const missingTools = ['curl', 'tar'].filter(cmd => !cmdExists(cmd));
708
749
  if (missingTools.length > 0) {
709
- console.error(`[code-graph] Auto-update skipped: missing required tools: ${missingTools.join(', ')}. Install them to enable auto-updates.`);
750
+ console.error(`[code-graph] ${noteUpdateFailure('missing-tools',
751
+ `Auto-update skipped: missing required tools: ${missingTools.join(', ')}. Install them to enable auto-updates.`)}`);
710
752
  return { pluginUpdated: false, binaryUpdated: false };
711
753
  }
712
754
 
@@ -743,7 +785,8 @@ async function downloadAndInstall(latest, {
743
785
  // the user on their current, working plugin version; the binary update below
744
786
  // still runs.
745
787
  if (!latest.pluginTarballUrl) {
746
- console.error(`[code-graph] Plugin update skipped: release ${latest.version} publishes no ${PLUGIN_ASSET_NAME} — refusing to install plugin code from an unverifiable source archive.`);
788
+ console.error(`[code-graph] ${noteUpdateFailure('plugin-tarball-absent',
789
+ `Plugin update skipped: release ${latest.version} publishes no ${PLUGIN_ASSET_NAME} — refusing to install plugin code from an unverifiable source archive.`)}`);
747
790
  return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
748
791
  }
749
792
  const tarballPath = path.join(tmpDir, PLUGIN_ASSET_NAME);
@@ -773,7 +816,8 @@ async function downloadAndInstall(latest, {
773
816
  }
774
817
  const actualSha = fs.existsSync(tarballPath) ? sha256File(tarballPath) : null;
775
818
  if (!expectedSha || !actualSha || expectedSha.toLowerCase() !== actualSha.toLowerCase()) {
776
- console.error(`[code-graph] Plugin tarball integrity check failed (expected ${expectedSha || '<no sidecar>'}, got ${actualSha || '<no download>'}) — refusing to extract.`);
819
+ console.error(`[code-graph] ${noteUpdateFailure('plugin-tarball-integrity',
820
+ `Plugin tarball integrity check failed (expected ${expectedSha || '<no sidecar>'}, got ${actualSha || '<no download>'}) — refusing to extract.`)}`);
777
821
  return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
778
822
  }
779
823
 
@@ -837,11 +881,10 @@ async function downloadAndInstall(latest, {
837
881
  const why = installedRead.error
838
882
  ? (installedRead.error.code || installedRead.error.message)
839
883
  : 'it does not contain a JSON object';
840
- console.error(
841
- `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
884
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-registry-unreadable',
885
+ `plugin ${latest.version} is installed, but ${installedPath} ` +
842
886
  `could not be read (${why}) — its entry for this plugin still points at the ` +
843
- 'previous version. Run `/plugin update` or repair that file by hand.'
844
- );
887
+ 'previous version. Run `/plugin update` or repair that file by hand.')}`);
845
888
  repointBlocked = true;
846
889
  } else {
847
890
  let installed = installedRead.value;
@@ -861,12 +904,11 @@ async function downloadAndInstall(latest, {
861
904
  `to ${backup} first.`
862
905
  );
863
906
  } else {
864
- console.error(
865
- `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
907
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-lossy-no-backup',
908
+ `plugin ${latest.version} is installed, but ${installedPath} ` +
866
909
  'contains bytes that are not valid UTF-8 and no backup copy could be made — ' +
867
910
  'its entry for this plugin still points at the previous version. Rewriting it ' +
868
- 'would replace those bytes permanently. Run `/plugin update` after repairing it.'
869
- );
911
+ 'would replace those bytes permanently. Run `/plugin update` after repairing it.')}`);
870
912
  installed = null;
871
913
  repointBlocked = true;
872
914
  }
@@ -885,11 +927,10 @@ async function downloadAndInstall(latest, {
885
927
  // Present but not the shape we can write into (`[]`, or a truthy
886
928
  // non-array). Blocked, NOT skipped: a silent skip here would feed the
887
929
  // JS-02 treadmill through a new door.
888
- console.error(
889
- `[code-graph] plugin ${latest.version} is installed, but this plugin's entry in ` +
930
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-entry-malformed',
931
+ `plugin ${latest.version} is installed, but this plugin's entry in ` +
890
932
  `${installedPath} is malformed (expected a non-empty array) — it still points at ` +
891
- 'the previous version. Run `/plugin update` or repair that file by hand.'
892
- );
933
+ 'the previous version. Run `/plugin update` or repair that file by hand.')}`);
893
934
  repointBlocked = true;
894
935
  }
895
936
  if (repointable) {
@@ -899,11 +940,10 @@ async function downloadAndInstall(latest, {
899
940
  try {
900
941
  writeJsonAtomic(installedPath, installed);
901
942
  } catch (err) {
902
- console.error(
903
- `[code-graph] plugin ${latest.version} is installed, but ${installedPath} ` +
943
+ console.error(`[code-graph] ${noteUpdateFailure('repoint-write-failed',
944
+ `plugin ${latest.version} is installed, but ${installedPath} ` +
904
945
  `could not be written (${err.code || err.name}) — its entry for this plugin ` +
905
- 'still points at the previous version. Run `/plugin update`.'
906
- );
946
+ 'still points at the previous version. Run `/plugin update`.')}`);
907
947
  repointBlocked = true;
908
948
  }
909
949
  }
@@ -946,7 +986,8 @@ async function downloadAndInstall(latest, {
946
986
 
947
987
  return { pluginUpdated, binaryUpdated, marketplaceRefreshed, repointBlocked };
948
988
  } catch (e) {
949
- console.error(`[code-graph] Plugin download/extract failed: ${e.message}`);
989
+ console.error(`[code-graph] ${noteUpdateFailure('plugin-extract-failed',
990
+ `Plugin download/extract failed: ${e.message}`)}`);
950
991
  return { pluginUpdated: false, binaryUpdated: false, marketplaceRefreshed };
951
992
  } finally {
952
993
  try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ok */ }
@@ -1311,6 +1352,11 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1311
1352
  latestVersion: latest.version,
1312
1353
  updateAvailable: true,
1313
1354
  updateAttempts: attempts,
1355
+ // The suspension notice below says "N failed attempts" without ever
1356
+ // saying what failed. Carry the last recorded reason forward so
1357
+ // `doctor` can answer that; `healedMissing` above may have recorded a
1358
+ // fresh one on the way in.
1359
+ lastError: takeUpdateFailure() || state.lastError || null,
1314
1360
  // Stamp on ENTRY to suspension, then leave it alone: the retry clock
1315
1361
  // must measure time since we gave up, not time since the last check
1316
1362
  // (which every session would reset, making the retry unreachable).
@@ -1366,6 +1412,13 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1366
1412
  suspendedAt: nextSuspendedAt,
1367
1413
  lastUpdate: success ? new Date().toISOString() : state.lastUpdate,
1368
1414
  rateLimited: false,
1415
+ // The reason this attempt failed, or null once one succeeds. Every
1416
+ // refusal above printed to a stderr nobody reads (detached, stdio
1417
+ // 'ignore'); this is the only channel that survives to `doctor`.
1418
+ // `takeUpdateFailure()` runs on BOTH arms so a stale reason from an
1419
+ // earlier attempt in the same process can never be attributed to this
1420
+ // one (JS-02).
1421
+ lastError: (() => { const f = takeUpdateFailure(); return success ? null : (f || state.lastError || null); })(),
1369
1422
  binaryUpdated: result.binaryUpdated,
1370
1423
  marketplaceRefreshed: result.marketplaceRefreshed,
1371
1424
  };
@@ -1413,6 +1466,11 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1413
1466
  updateAttempts: 0,
1414
1467
  suspendedAt: null,
1415
1468
  rateLimited: false,
1469
+ // Same reasoning one field up: the shell is current, so a stored reason
1470
+ // describes an update that is no longer pending. Cleared unless the
1471
+ // binary self-heal ON THIS PASS recorded a fresh one — that chain keeps
1472
+ // its own budget and can still be failing while the shell is fine.
1473
+ lastError: takeUpdateFailure() || null,
1416
1474
  binaryUpdated: selfHealedBinary || state.binaryUpdated,
1417
1475
  // The shell-update counters above reset because the shell IS current.
1418
1476
  // The BINARY heal keeps its own, un-reset budget — clearing it here is
@@ -1445,6 +1503,7 @@ module.exports = {
1445
1503
  selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
1446
1504
  shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
1447
1505
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
1506
+ noteUpdateFailure, takeUpdateFailure,
1448
1507
  };
1449
1508
 
1450
1509
  // CLI: node auto-update.js [check|status] [--silent] [--install-missing]
@@ -33,9 +33,17 @@ const { hidden } = require('./proc-opts');
33
33
  // roughly one run in seven, while 12/12 isolated runs passed. An intermittently
34
34
  // red suite teaches people to re-run instead of read, which is the one habit
35
35
  // this whole audit is about.
36
+ //
37
+ // FLOORED AT THE SOURCE. `Number('1500.5')` is finite and positive, and
38
+ // `child_process` rejects a fractional `timeout` with ERR_OUT_OF_RANGE — which
39
+ // each runner's own try/catch turns into a silent `unavailable`, so the hook
40
+ // exits 0 having answered nothing. That regression is what `remainingMs` was
41
+ // hardened for; this value happens to pass through it today, which means the
42
+ // property is currently held one file away by a function that has no obligation
43
+ // to keep holding it (audit 2026-09-05 NEW-02).
36
44
  const DEFAULT_TIMEOUT_MS = (() => {
37
45
  const override = Number(process.env._CG_ANSWER_TIMEOUT_MS);
38
- return Number.isFinite(override) && override > 0 ? override : 2000;
46
+ return Number.isFinite(override) && override >= 1 ? Math.floor(override) : 2000;
39
47
  })();
40
48
  // ~1000 tokens. A deny reason carrying more than this stops being an answer
41
49
  // and starts being a context tax.
@@ -217,7 +225,13 @@ function runGrepAnswer(opts = {}) {
217
225
  // Older binaries exit 0 on no-match with NO_MATCH_PREFIX on stdout — that
218
226
  // shape resolves to 'no-hits' through isEmptyAnswer below.
219
227
  const verdict = classifyRun(res, { exitOneIsNoHits: true });
220
- if (verdict !== 'ok') return { status: verdict };
228
+ // `reason` separates the two things `unavailable` covers. The binary failing
229
+ // and the binary never being given time are different facts, and the caller
230
+ // renders one of them to the user — "ran but failed" is simply untrue of a
231
+ // run that never started (audit 2026-09-05 NEW-08).
232
+ if (verdict !== 'ok') {
233
+ return { status: verdict, ...(res.budgetExhausted ? { reason: 'budget' } : {}) };
234
+ }
221
235
  const out = (res.stdout || '').trim();
222
236
  if (isEmptyAnswer(out)) {
223
237
  return { status: 'no-hits' };
@@ -268,7 +282,7 @@ function runShowAnswer(opts = {}) {
268
282
  // docs) is that the deny funnel has to tell those causes apart. Nothing
269
283
  // later in the loop can succeed either: the budget is gone for all three
270
284
  // (pre-ship review 2026-09-05).
271
- if (res.budgetExhausted) return { status: 'unavailable' };
285
+ if (res.budgetExhausted) return { status: 'unavailable', reason: 'budget' };
272
286
  // A symbol that did not resolve is SKIPPED, not fatal — exit 1 included,
273
287
  // which is why this asks for `exitOneIsNoHits: false` and then treats
274
288
  // every non-`ok` verdict the same way.
@@ -368,7 +382,13 @@ function runCallgraphAnswer(opts = {}) {
368
382
  const res = runCg(binary, ['callgraph', symbol], { cwd, timeoutMs });
369
383
  // grep-parity exit codes: 1 = symbol not found (no graph node).
370
384
  const verdict = classifyRun(res, { exitOneIsNoHits: true });
371
- if (verdict !== 'ok') return { status: verdict };
385
+ // `reason` separates the two things `unavailable` covers. The binary failing
386
+ // and the binary never being given time are different facts, and the caller
387
+ // renders one of them to the user — "ran but failed" is simply untrue of a
388
+ // run that never started (audit 2026-09-05 NEW-08).
389
+ if (verdict !== 'ok') {
390
+ return { status: verdict, ...(res.budgetExhausted ? { reason: 'budget' } : {}) };
391
+ }
372
392
  const out = (res.stdout || '').trim();
373
393
  // Only an edge-bearing tree is marginal over the grep the model already ran.
374
394
  if (isEmptyAnswer(out) ||
@@ -385,4 +405,8 @@ function runCallgraphAnswer(opts = {}) {
385
405
  module.exports = {
386
406
  runGrepAnswer, runShowAnswer, runOverviewAnswer, runCallgraphAnswer,
387
407
  truncateAtLine, sanitizeSearchPath,
408
+ // Exported so the integer property can be asserted where it is ESTABLISHED.
409
+ // Asserting it end-to-end instead passes either way: `remainingMs` floors
410
+ // again downstream, so such a test cannot fail and proves nothing (NEW-02).
411
+ DEFAULT_TIMEOUT_MS,
388
412
  };
@@ -384,17 +384,25 @@ function runDiagnostics({ checkOnly = false } = {}) {
384
384
  // suspended, so offering it as a repair would print "✅ Update check
385
385
  // complete" and count a fix that cannot happen. Say what is true and hand
386
386
  // the user the manual route.
387
+ const last = autoUpdateLastError(state);
387
388
  results.push({
388
389
  name: 'Auto-update',
389
390
  status: 'warn',
391
+ // "failed to install 5×" was the whole diagnosis until JS-02: the count
392
+ // without the cause, for a chain whose every explanation had already
393
+ // been written to a discarded stderr. A user cannot tell a missing
394
+ // `curl` from a full disk from a blocked CDN out of a number.
390
395
  detail: `v${state.latestVersion} failed to install ${attempts}× — auto-retry throttled to once a day. `
396
+ + (last ? `${last}. ` : '')
391
397
  + 'Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)',
392
398
  });
393
399
  } else if (state && state.updateAvailable && state.binaryUpdated === false) {
400
+ const last = autoUpdateLastError(state);
394
401
  results.push({
395
402
  name: 'Auto-update',
396
403
  status: 'warn',
397
- detail: `plugin v${state.latestVersion}, binary download incomplete`,
404
+ detail: `plugin v${state.latestVersion}, binary download incomplete`
405
+ + (last ? ` — ${last}` : ''),
398
406
  fixId: 'update-incomplete',
399
407
  });
400
408
  } else {
@@ -530,7 +538,17 @@ function runDiagnostics({ checkOnly = false } = {}) {
530
538
  const failed = fire.results.filter(r => !r.ok).map(r => r.label).join(', ') || fire.error || 'unknown';
531
539
  results.push({ name: 'Hook firing', status: 'warn', detail: `did not fire: ${failed}` });
532
540
  }
533
- } catch { /* probe failed — skip */ }
541
+ } catch (err) {
542
+ // Same stance as step 7 above, which learned it the hard way: dropping the
543
+ // row makes the table look complete while a check never ran, and doctor
544
+ // then exits 0 on a shorter all-green report. A probe that cannot run is
545
+ // itself a finding (audit 2026-09-05 JS-04).
546
+ results.push({
547
+ name: 'Hook firing',
548
+ status: 'warn',
549
+ detail: `probe could not run (${(err && err.message) || err}) — the registered hooks were NOT verified`,
550
+ });
551
+ }
534
552
 
535
553
  // 9. Global npm residue — the launcher's background install (or the user)
536
554
  // may have `npm install -g`'d the shell + platform packages. Surface what
@@ -584,7 +602,16 @@ function runDiagnostics({ checkOnly = false } = {}) {
584
602
  : ` — no plugin-install marker; uninstall leaves them (remove: npm uninstall -g ${found.map((p) => p.name).join(' ')})`)),
585
603
  });
586
604
  }
587
- } catch { /* probe failed — skip */ }
605
+ } catch (err) {
606
+ // Sibling of the step-8 arm above (JS-04). Here the row is conditional on
607
+ // `found.length`, so absence is ALREADY the normal "nothing installed"
608
+ // answer — which is exactly why a throw must not produce the same silence.
609
+ results.push({
610
+ name: 'Global npm packages',
611
+ status: 'warn',
612
+ detail: `probe could not run (${(err && err.message) || err}) — global npm residue was NOT checked`,
613
+ });
614
+ }
588
615
 
589
616
  return results;
590
617
  }
@@ -742,6 +769,30 @@ function buildBinaryFromSource(cmd) {
742
769
  return true;
743
770
  }
744
771
 
772
+ /**
773
+ * The part of a failed child that its own output does NOT already show.
774
+ *
775
+ * The build / update / rebuild helpers all run `stdio: 'inherit'`, so a cargo
776
+ * error or an npm error is already on the user's terminal and repeating it here
777
+ * would be noise. What that stream cannot show is a child that never ran
778
+ * (`ENOENT` — cargo or node not on PATH) or one this process killed
779
+ * (`ETIMEDOUT`, `SIGTERM` — the 10-minute build budget): the inherited stream is
780
+ * empty and "Build failed" is then the whole explanation the user gets
781
+ * (audit 2026-09-05 JS-04). Returns '' when the child spoke for itself.
782
+ */
783
+ function silentFailureReason(e) {
784
+ if (!e) return '';
785
+ if (e.code === 'ETIMEDOUT' || e.signal === 'SIGTERM' || e.signal === 'SIGKILL') {
786
+ return ' — timed out; it was still running when the budget ran out';
787
+ }
788
+ if (e.code === 'ENOENT') {
789
+ return ' — the command is not on PATH';
790
+ }
791
+ // A non-zero exit means the child ran and printed its own diagnosis above.
792
+ if (typeof e.status === 'number') return '';
793
+ return e.code ? ` (${e.code})` : '';
794
+ }
795
+
745
796
  /** Manual recovery for a binary we could not repair — the end of every failed arm. */
746
797
  function printBinaryRecovery() {
747
798
  console.log(' Reinstall: npm install -g @sdsrs/code-graph');
@@ -846,10 +897,35 @@ function autoUpdateNoOpReason(state = readUpdateState(), env = process.env) {
846
897
  return null;
847
898
  }
848
899
 
900
+ /**
901
+ * What the updater's last failed attempt actually said, if it recorded one.
902
+ *
903
+ * Every refusal in auto-update.js prints to stderr, and the main trigger path
904
+ * spawns it `detached` with `stdio: 'ignore'` — so on the path that matters the
905
+ * explanation went to /dev/null and doctor could only re-run its own checks
906
+ * (audit 2026-09-05 JS-02). `lastError` is that explanation, persisted.
907
+ *
908
+ * Separate from `autoUpdateNoOpReason` on purpose: that answers "why is nothing
909
+ * happening" (suspended / throttled / switched off) and can be null while this
910
+ * is set — a single failed attempt records a reason without parking anything.
911
+ */
912
+ function autoUpdateLastError(state = readUpdateState()) {
913
+ const e = state && state.lastError;
914
+ if (!e || !e.message) return null;
915
+ const when = e.at ? new Date(e.at) : null;
916
+ const stamp = when && !Number.isNaN(when.getTime()) ? ` on ${when.toISOString().slice(0, 16).replace('T', ' ')} UTC` : '';
917
+ return `last failure${stamp}${e.stage ? ` [${e.stage}]` : ''}: ${e.message}`;
918
+ }
919
+
849
920
  function reportAutoUpdateNoOp(what) {
850
921
  console.log(` ❌ ${what}`);
851
922
  const why = autoUpdateNoOpReason();
852
923
  if (why) console.log(` Why: ${why}.`);
924
+ // Printed even when `why` is null: "nothing is parked" and "the last attempt
925
+ // failed for reason X" are different facts, and X is the one the user can act
926
+ // on (a missing curl, a full disk, a blocked CDN).
927
+ const last = autoUpdateLastError();
928
+ if (last) console.log(` ${last}`);
853
929
  console.log(' Update manually: `npm install -g @sdsrs/code-graph` (or `/plugin update code-graph-mcp`)');
854
930
  }
855
931
 
@@ -875,8 +951,8 @@ function runRepairs(results, {
875
951
  console.log('\n Triggering binary update...');
876
952
  try {
877
953
  runAutoUpdate();
878
- } catch {
879
- console.log(' \u274c Update check failed — install manually');
954
+ } catch (e) {
955
+ console.log(` \u274c Update check failed${silentFailureReason(e)} — install manually`);
880
956
  break;
881
957
  }
882
958
  // Exited 0 — which says nothing about whether the binary moved (see
@@ -908,8 +984,8 @@ function runRepairs(results, {
908
984
  buildBinary(buildCmd);
909
985
  console.log(' \u2705 Build complete');
910
986
  fixed++;
911
- } catch {
912
- console.log(' \u274c Build failed');
987
+ } catch (e) {
988
+ console.log(` \u274c Build failed${silentFailureReason(e)}`);
913
989
  }
914
990
  break;
915
991
  }
@@ -926,8 +1002,8 @@ function runRepairs(results, {
926
1002
  buildBinary('cargo build --release --no-default-features');
927
1003
  console.log(' \u2705 Build complete');
928
1004
  fixed++;
929
- } catch {
930
- console.log(' \u274c Build failed');
1005
+ } catch (e) {
1006
+ console.log(` \u274c Build failed${silentFailureReason(e)}`);
931
1007
  }
932
1008
  } else {
933
1009
  console.log(' Install: npm install -g @sdsrs/code-graph');
@@ -959,8 +1035,8 @@ function runRepairs(results, {
959
1035
  console.log(' ❌ Build failed');
960
1036
  break;
961
1037
  }
962
- } catch {
963
- console.log(' ❌ Build failed');
1038
+ } catch (e) {
1039
+ console.log(` ❌ Build failed${silentFailureReason(e)}`);
964
1040
  break;
965
1041
  }
966
1042
  } else {
@@ -1000,8 +1076,12 @@ function runRepairs(results, {
1000
1076
  fs.chmodSync(binary, 0o755);
1001
1077
  console.log(`\n \u2705 Fixed permissions: chmod +x ${binary}`);
1002
1078
  fixed++;
1003
- } catch {
1004
- console.log(`\n \u274c Could not fix permissions: ${binary}`);
1079
+ } catch (e) {
1080
+ // Nothing else here speaks: `chmodSync` inherits no stream, so the
1081
+ // errno IS the diagnosis — EPERM (not the owner), EROFS (read-only
1082
+ // mount) and ENOENT are three different next steps for the user.
1083
+ console.log(`\n \u274c Could not fix permissions: ${binary}` +
1084
+ `${e && e.code ? ` (${e.code})` : ''}`);
1005
1085
  }
1006
1086
  if (os.platform() === 'darwin') {
1007
1087
  console.log(` Also try: xattr -d com.apple.quarantine "${binary}"`);
@@ -1023,8 +1103,8 @@ function runRepairs(results, {
1023
1103
  }));
1024
1104
  console.log(' \u2705 Index rebuilt');
1025
1105
  fixed++;
1026
- } catch {
1027
- console.log(' \u274c Index rebuild failed');
1106
+ } catch (e) {
1107
+ console.log(` \u274c Index rebuild failed${silentFailureReason(e)}`);
1028
1108
  }
1029
1109
  }
1030
1110
  break;
@@ -1034,8 +1114,8 @@ function runRepairs(results, {
1034
1114
  console.log('\n Completing auto-update...');
1035
1115
  try {
1036
1116
  runAutoUpdate();
1037
- } catch {
1038
- console.log(' \u274c Update check failed');
1117
+ } catch (e) {
1118
+ console.log(` \u274c Update check failed${silentFailureReason(e)}`);
1039
1119
  break;
1040
1120
  }
1041
1121
  // Same as the version-mismatch arm: exit 0 is not evidence. Re-read
@@ -1208,7 +1288,7 @@ function runDoctor(opts = {}) {
1208
1288
  return { results, issueCount: issues.length, unresolved };
1209
1289
  }
1210
1290
 
1211
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, binaryBrokenResolved, autoUpdateNoOpReason };
1291
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, binaryBrokenResolved, autoUpdateNoOpReason, autoUpdateLastError, silentFailureReason };
1212
1292
 
1213
1293
  // Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
1214
1294
  // doctor …`. It exists as one function because the first version of this guard
@@ -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
@@ -25,15 +25,14 @@
25
25
  // pins both registration sites to this table so a bump in one cannot drift from
26
26
  // the other.
27
27
  //
28
- // `session-init.js` is here for its REGISTRATION only. It is registered from
29
- // `claude-plugin/hooks/hooks.json` (SessionStart is the one event Claude Code
30
- // loads from plugin-cache), it predates `installHookFailOpen` and wraps its own
31
- // main in a try/catch, so nothing arms a deadline for it and none of its six
32
- // child spawns clamp 15.5 s of serial timeouts against the 5 s below. That is
33
- // the largest overrun of the seven and it is NOT fixed here; pre-ship review
34
- // 2026-09-05 finding 1, carried in the audit report as NEW-05. Wiring it means
35
- // deciding, per child, whether an out-of-budget SessionStart should skip a
36
- // binary health check or a quarantine probe, which is not a mechanical change.
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.
37
36
  const HOOK_TIMEOUT_SECONDS = {
38
37
  'pre-edit-guide.js': 4,
39
38
  'pre-grep-guide.js': 3,
@@ -93,9 +92,17 @@ function remainingMs(defaultMs) {
93
92
  return Math.floor(Math.min(defaultMs, left));
94
93
  }
95
94
 
96
- /** Test seam: drop any armed deadline so one test file can't leak into another. */
97
- function resetHookDeadline() {
98
- deadlineAt = null;
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);
99
106
  }
100
107
 
101
108
  function installHookFailOpen(label) {
@@ -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).
@@ -1647,10 +1652,19 @@ function update() {
1647
1652
  settingsChanged = true;
1648
1653
  }
1649
1654
 
1650
- // 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).
1651
1665
  if (isOurComposite(settings)) {
1652
1666
  const cmd = compositeCommand();
1653
- if (settings.statusLine.command !== cmd) {
1667
+ if (settings.statusLine.command !== cmd && compositeSlotIsStale(settings.statusLine.command)) {
1654
1668
  settings.statusLine.command = cmd;
1655
1669
  settingsChanged = true;
1656
1670
  }
@@ -1775,10 +1789,33 @@ function readActiveProcessCmdlines() {
1775
1789
  } catch { /* fall through to ps */ }
1776
1790
  try {
1777
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);
1778
1814
  return execFileSync('ps', ['-axww', '-o', 'command='], hidden({
1815
+ timeout: budget === null ? PS_PROBE_MIN_MS : Math.max(budget, PS_PROBE_MIN_MS),
1779
1816
  encoding: 'utf8', maxBuffer: 8 * 1024 * 1024,
1780
1817
  })).split('\n').filter(Boolean);
1781
- } catch { /* unsupported platform — caller falls back to recency-only */ }
1818
+ } catch { /* unsupported platform, or out of time — caller falls back to recency-only */ }
1782
1819
  return [];
1783
1820
  }
1784
1821
 
@@ -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
 
@@ -12,11 +12,47 @@ const { readBinaryVersion, isDevMode, getNewestMtime } = require('./version-util
12
12
  const { maybeAutoAdopt, isAdopted, unadopt } = require('./adopt');
13
13
  const { isNonProjectCwd } = require('./project-detect');
14
14
  const { hidden } = require('./proc-opts');
15
+ const { installHookFailOpen, remainingMs } = require('./hook-fail-open');
15
16
  // Module scope on purpose: `detectHookDark` reads it inside a try/catch that
16
17
  // treats any throw as "nothing to conclude", so a lazy require in there would
17
18
  // turn a resolution failure into a silent disable (pre-tag review, JS-08).
18
19
  const { resolveProjectRoot } = require('./project-root');
19
20
 
21
+ // ── SessionStart budget (audit 2026-09-05 NEW-05) ─────────────────────────
22
+ //
23
+ // Claude Code kills this hook at 5 s (`HOOK_TIMEOUT_SECONDS['session-init.js']`,
24
+ // mirrored by hooks.json). Its blocking children were each sized alone and run
25
+ // in SERIES: the darwin quarantine probe 3 s, `git log` 2 s, `health-check` 3 s,
26
+ // `map --compact` 5 s, `git status` 1 s, `git diff` 1 s, `affected` 1.5 s and
27
+ // `readBinaryVersion` 5 s — 21.5 s worst case. Nothing enforced the sum, so a
28
+ // binary wedged on `index.lock` got the hook killed and took the SessionStart
29
+ // output the user had already earned with it. JS-03 fixed the other six hooks;
30
+ // this one was left because clamping it is a per-child decision, not a
31
+ // mechanical edit.
32
+ //
33
+ // Every blocking child now spends `budgetFor(...)`, which returns null when the
34
+ // budget is gone — meaning SKIP, never "run unbounded" (node reads `timeout: 0`
35
+ // as no timeout at all).
36
+ //
37
+ // Two skips would otherwise fabricate a POSITIVE result, so they get their own
38
+ // answer rather than folding into an existing bucket — the mistake NEW-09 fixed
39
+ // in cg-answer, where "budget exhausted" arrived as `no-hits`:
40
+ // * a skipped freshness probe reports 'unknown', NOT 'fresh';
41
+ // * a skipped Gatekeeper probe reports `quarantine-probe-skipped`, NOT a
42
+ // silent "the binary runs".
43
+ // The rest degrade to "nothing injected", which is a state they already reach
44
+ // for ordinary reasons (no index, clean tree) and which claims nothing untrue.
45
+ // `budgetSkipped` in the return value names whichever ones actually skipped, so
46
+ // a starved SessionStart is legible to `doctor` and to tests instead of just
47
+ // being quieter than usual.
48
+ let budgetSkips = [];
49
+
50
+ function budgetFor(label, defaultMs) {
51
+ const ms = remainingMs(defaultMs);
52
+ if (ms === null) budgetSkips.push(label);
53
+ return ms;
54
+ }
55
+
20
56
  // v0.17.0 — quietHooks: unconditional quiet 默认。
21
57
  // 项目地图与 MEMORY.md plugin contract + on-demand `project_map` 工具高度重叠,
22
58
  // 默认每次 SessionStart 都注入 ≈2.3 KB 是不必要的常驻上下文成本。
@@ -344,13 +380,19 @@ function syncLifecycleConfig() {
344
380
  * project where the MCP server isn't running, nothing else nudges a rebuild.
345
381
  * health-check carries the verdict in `index_version_stale`. Best-effort: any
346
382
  * failure → false (never force work off a bad probe).
383
+ *
384
+ * Returns true | false | null, where null means the SessionStart budget ran out
385
+ * before the probe could run. `false` says "asked, not stale"; conflating the
386
+ * two would let the caller report a freshness it never established.
347
387
  */
348
388
  function indexNeedsRevalidation(bin, cwd) {
389
+ const budget = budgetFor('health-check', 3000);
390
+ if (budget === null) return null;
349
391
  try {
350
392
  let out;
351
393
  try {
352
394
  out = execFileSync(bin, ['health-check', '--format', 'json'],
353
- hidden({ cwd, timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] })).toString();
395
+ hidden({ cwd, timeout: budget, stdio: ['pipe', 'pipe', 'pipe'] })).toString();
354
396
  } catch (e) {
355
397
  // health-check exits non-zero on an unhealthy index but still writes JSON.
356
398
  out = ((e && e.stdout) || '').toString();
@@ -370,7 +412,10 @@ function indexNeedsRevalidation(bin, cwd) {
370
412
  * 1. git HEAD newer than index.db mtime — content drifted since last index.
371
413
  * 2. INDEX_VERSION mismatch (post-upgrade) — see indexNeedsRevalidation.
372
414
  *
373
- * Returns 'fresh' | 'refreshing' | 'skipped'.
415
+ * Returns 'fresh' | 'refreshing' | 'skipped' | 'unknown', where 'unknown' means
416
+ * the SessionStart budget ran out before either trigger could be evaluated —
417
+ * distinct from 'fresh' (both triggers asked and neither fired) and from
418
+ * 'skipped' (no binary / no index, so there was nothing to ask).
374
419
  */
375
420
  function ensureIndexFresh() {
376
421
  const { findBinary } = require('./find-binary');
@@ -387,19 +432,33 @@ function ensureIndexFresh() {
387
432
  if (!fs.existsSync(dbPath)) return 'skipped';
388
433
 
389
434
  let needsRefresh = false;
435
+ let unprobed = false;
390
436
  // Trigger 1: git HEAD newer than index mtime.
391
- try {
392
- const dbMtime = fs.statSync(dbPath).mtimeMs;
393
- const gitTs = parseInt(
394
- execSync('git log -1 --format=%ct', hidden({ cwd, timeout: 2000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })).trim()
395
- ) * 1000;
396
- if (gitTs > dbMtime) needsRefresh = true;
397
- } catch { /* no git / not a repo — fall through to the version probe */ }
437
+ const gitBudget = budgetFor('git-log', 2000);
438
+ if (gitBudget === null) {
439
+ unprobed = true;
440
+ } else {
441
+ try {
442
+ const dbMtime = fs.statSync(dbPath).mtimeMs;
443
+ const gitTs = parseInt(
444
+ execSync('git log -1 --format=%ct', hidden({ cwd, timeout: gitBudget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })).trim()
445
+ ) * 1000;
446
+ if (gitTs > dbMtime) needsRefresh = true;
447
+ } catch { /* no git / not a repo — fall through to the version probe */ }
448
+ }
398
449
 
399
450
  // Trigger 2: INDEX_VERSION mismatch (only probe when mtime looked fresh).
400
- if (!needsRefresh && indexNeedsRevalidation(bin, cwd)) needsRefresh = true;
451
+ if (!needsRefresh) {
452
+ const stale = indexNeedsRevalidation(bin, cwd);
453
+ if (stale === true) needsRefresh = true;
454
+ else if (stale === null) unprobed = true;
455
+ }
401
456
 
402
- if (!needsRefresh) return 'fresh';
457
+ // No trigger fired. Whether that means "fresh" depends on whether anything
458
+ // was actually asked: with a spent budget this is a claim about an index
459
+ // nothing looked at, and 'fresh' is the one answer that would stop a caller
460
+ // from looking again.
461
+ if (!needsRefresh) return unprobed ? 'unknown' : 'fresh';
403
462
 
404
463
  const child = spawn(bin, ['incremental-index', '--quiet'], hidden({
405
464
  cwd,
@@ -463,8 +522,17 @@ function verifyBinary() {
463
522
 
464
523
  // On macOS, verify the binary can actually run (Gatekeeper may block it)
465
524
  if (process.platform === 'darwin') {
525
+ const budget = budgetFor('quarantine-probe', 3000);
526
+ if (budget === null) {
527
+ // Out of budget before the probe. The binary exists and is executable, so
528
+ // `available: false` here would be a false alarm that sends the user to
529
+ // `xattr -d` for nothing. But "it actually runs" is precisely what the
530
+ // probe establishes and we did not establish it — so say which half is
531
+ // unverified instead of returning the clean shape.
532
+ return { available: true, binary, issue: 'quarantine-probe-skipped' };
533
+ }
466
534
  try {
467
- execFileSync(binary, ['--version'], hidden({ timeout: 3000, stdio: 'pipe' }));
535
+ execFileSync(binary, ['--version'], hidden({ timeout: budget, stdio: 'pipe' }));
468
536
  } catch (err) {
469
537
  const msg = (err.message || '') + (err.stderr ? err.stderr.toString() : '');
470
538
  if (msg.includes('quarantine') || msg.includes('not permitted') ||
@@ -499,7 +567,15 @@ function consistencyCheck(binary) {
499
567
  // Check 1: Binary version vs plugin version
500
568
  try {
501
569
  const pluginVersion = getPluginVersion();
502
- const binaryVersion = readBinaryVersion(binary);
570
+ // The last child of the hook and the most expensive one (5s of its own
571
+ // against a 5s total). Skipping folds into `binaryVersion === null`, which
572
+ // this check already treats as "no comparison to make" — it only ever
573
+ // reports a MISMATCH, so a skip suppresses a warning rather than inventing
574
+ // an all-clear.
575
+ const versionBudget = budgetFor('binary-version', 5000);
576
+ const binaryVersion = versionBudget === null
577
+ ? null
578
+ : readBinaryVersion(binary, { timeoutMs: versionBudget });
503
579
  if (binaryVersion && binaryVersion !== pluginVersion) {
504
580
  issues.push({
505
581
  id: 'version-mismatch',
@@ -565,6 +641,10 @@ function samePath(a, b) {
565
641
  }
566
642
 
567
643
  function runSessionInit({ source } = {}) {
644
+ // Fresh per run: this is module state, and the test suite calls this function
645
+ // many times in one process. A carried-over array would report last run's
646
+ // skips as this one's.
647
+ budgetSkips = [];
568
648
  // GC the shared tmp dir before anything else, so it happens even on the
569
649
  // inactive / non-project early returns below — those sessions still wrote
570
650
  // cooldown flags on the way in. Cheap (one readdir + a stat per entry) and
@@ -775,6 +855,10 @@ function runSessionInit({ source } = {}) {
775
855
  autoUpdateLaunched, indexFreshness, mapInjected, recentImpactInjected, binaryCheck, consistencyIssues,
776
856
  quietHooks, adopted, autoAdopted: autoAdopt.attempted,
777
857
  hookFireWarn: !!hookFireWarn, hookDarkWarn: !!hookDarkWarn,
858
+ // Which children the 5s budget cut, in the order they were reached. Empty
859
+ // on every healthy run; non-empty is the only signal that this SessionStart
860
+ // did less than it looks like it did.
861
+ budgetSkipped: budgetSkips.slice(),
778
862
  };
779
863
  }
780
864
 
@@ -797,9 +881,15 @@ function injectProjectMap() {
797
881
  const bin = findBinary();
798
882
  if (!bin) return false;
799
883
 
884
+ // Skipping here degrades to "nothing injected", the same state a project
885
+ // with no index reaches — no false claim, just less context. Safe to fold
886
+ // (unlike the freshness/quarantine probes) because this dump is opt-in,
887
+ // off by default, and duplicates MEMORY.md plus the on-demand tool.
888
+ const mapBudget = budgetFor('map', 5000);
889
+ if (mapBudget === null) return false;
800
890
  const output = execFileSync(bin, ['map', '--compact'], hidden({
801
891
  cwd,
802
- timeout: 5000,
892
+ timeout: mapBudget,
803
893
  encoding: 'utf8',
804
894
  stdio: ['pipe', 'pipe', 'pipe'],
805
895
  // Hook-internal delivery, not a model conversion — keep record_cli_use from
@@ -846,16 +936,28 @@ function injectRecentImpact({ source } = {}) {
846
936
  // last commit. Timeouts tightened (finding #1): worst-case cap sum is now
847
937
  // status(1s) + HEAD~1(1s) + affected(1.5s) = 3.5s, comfortably under the 5s
848
938
  // SessionStart hook budget; the old 2+2+3=7s could get the whole hook killed.
849
- const gitOpts = hidden({ cwd: sessionDir, timeout: 1000, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
939
+ // Each git call re-reads the budget and builds its own opts rather than
940
+ // sharing one object: the fallback `git diff` runs AFTER `git status` has
941
+ // already spent wall clock, so a timeout computed once would let the pair
942
+ // overshoot by up to its own value. `hidden(...)` stays inline at both call
943
+ // sites — windows-hide.test.js follows a `const x = hidden(...)` binding but
944
+ // not one returned from a helper, and that guard is right to be that strict.
850
945
  let changed = [];
851
946
  let isWip = false;
852
947
  try {
948
+ const statusMs = budgetFor('git-status', 1000);
949
+ if (statusMs === null) return false;
853
950
  changed = filterSourceFiles(parseGitStatusPaths(
854
- execSync('git status --porcelain --untracked-files=all', gitOpts)));
951
+ execSync('git status --porcelain --untracked-files=all',
952
+ hidden({ cwd: sessionDir, timeout: statusMs, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }))));
855
953
  isWip = changed.length > 0;
856
954
  // Clean tree → fall back to what the last commit touched.
857
955
  if (!isWip) {
858
- changed = filterSourceFiles(execSync('git diff --name-only HEAD~1 HEAD', gitOpts));
956
+ const diffMs = budgetFor('git-diff', 1000);
957
+ if (diffMs === null) return false;
958
+ changed = filterSourceFiles(
959
+ execSync('git diff --name-only HEAD~1 HEAD',
960
+ hidden({ cwd: sessionDir, timeout: diffMs, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })));
859
961
  }
860
962
  } catch {
861
963
  return false; // not a git repo / no commits — nothing to diff
@@ -870,10 +972,13 @@ function injectRecentImpact({ source } = {}) {
870
972
  const bin = findBinary();
871
973
  if (!bin) return false;
872
974
 
975
+ const affectedBudget = budgetFor('affected', 1500);
976
+ if (affectedBudget === null) return false;
977
+
873
978
  let affected;
874
979
  try {
875
980
  const raw = execFileSync(bin, ['affected', ...changed, '--json'], hidden({
876
- cwd, timeout: 1500, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
981
+ cwd, timeout: affectedBudget, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],
877
982
  env: { ...process.env, CODE_GRAPH_INTERNAL: '1' },
878
983
  }));
879
984
  affected = JSON.parse(raw);
@@ -998,6 +1103,12 @@ module.exports = {
998
1103
  };
999
1104
 
1000
1105
  if (require.main === module) {
1106
+ // Arms the 5s deadline every `budgetFor` above spends against, and adds the
1107
+ // async-throw coverage the try/catch below cannot reach (a rejected promise
1108
+ // from a spawn or a read). Only here, never on `require`: the test suite
1109
+ // calls `runSessionInit` in-process, where an armed deadline from a foreign
1110
+ // argv[1] would clamp children against a budget nobody granted.
1111
+ installHookFailOpen('SessionStart');
1001
1112
  // SessionStart passes {source:"startup"|"clear"|"compact"|"resume"} on stdin.
1002
1113
  // Best-effort + TTY-guarded: a hook gets piped JSON (EOF closes it), but a
1003
1114
  // manual `node session-init.js` in a terminal must not block on fd 0.
@@ -12,12 +12,17 @@ const { hidden } = require('./proc-opts');
12
12
  // rejected by the same parse: a self-sustaining loop.
13
13
  const VERSION_OUTPUT_RE = /^code-graph-mcp\s+v?(\d+\.\d+\.\d+)/m;
14
14
 
15
- function readBinaryVersion(binaryPath) {
15
+ function readBinaryVersion(binaryPath, { timeoutMs = 5000 } = {}) {
16
16
  try {
17
17
  const out = execFileSync(binaryPath, ['--version'], hidden({
18
- // 5s: a cold exec of a freshly-written ~40MB binary (page-in, Windows AV
19
- // scan) regularly exceeded the old 2s, misclassifying a good binary.
20
- timeout: 5000,
18
+ // 5s default: a cold exec of a freshly-written ~40MB binary (page-in,
19
+ // Windows AV scan) regularly exceeded the old 2s, misclassifying a good
20
+ // binary. `timeoutMs` exists for callers running inside a hook budget —
21
+ // session-init spends what is LEFT of its 5s SessionStart allowance here,
22
+ // which is otherwise the single largest unclamped child in that hook
23
+ // (audit 2026-09-05 NEW-05). Must be an integer: `child_process`
24
+ // validates it and throws ERR_OUT_OF_RANGE on a fraction.
25
+ timeout: timeoutMs,
21
26
  stdio: ['pipe', 'pipe', 'pipe'],
22
27
  })).toString().trim();
23
28
  const match = out.match(VERSION_OUTPUT_RE);
@@ -35,7 +35,7 @@ jobs:
35
35
  node-version: '20'
36
36
  - name: Build snapshot
37
37
  run: |
38
- npx -y -p @sdsrs/code-graph@0.135.0 code-graph-mcp snapshot create --out snapshot.db
38
+ npx -y -p @sdsrs/code-graph@0.136.0 code-graph-mcp snapshot create --out snapshot.db
39
39
  zstd -9 snapshot.db -o snapshot.db.zst
40
40
  mv snapshot.db.zst "code-graph-snapshot-${GITHUB_SHA:0:7}.db.zst"
41
41
  - name: Upload to release
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdsrs/code-graph",
3
- "version": "0.135.0",
3
+ "version": "0.136.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.135.0",
39
- "@sdsrs/code-graph-linux-arm64": "0.135.0",
40
- "@sdsrs/code-graph-darwin-x64": "0.135.0",
41
- "@sdsrs/code-graph-darwin-arm64": "0.135.0",
42
- "@sdsrs/code-graph-win32-x64": "0.135.0"
38
+ "@sdsrs/code-graph-linux-x64": "0.136.0",
39
+ "@sdsrs/code-graph-linux-arm64": "0.136.0",
40
+ "@sdsrs/code-graph-darwin-x64": "0.136.0",
41
+ "@sdsrs/code-graph-darwin-arm64": "0.136.0",
42
+ "@sdsrs/code-graph-win32-x64": "0.136.0"
43
43
  }
44
44
  }