@sdsrs/code-graph 0.116.0 → 0.118.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.
@@ -7,7 +7,7 @@ const http = require('http');
7
7
  const crypto = require('crypto');
8
8
  const path = require('path');
9
9
  const os = require('os');
10
- const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
10
+ const { CACHE_DIR, PLUGIN_ID, MARKETPLACE_NAME, readManifest, readJson, readJsonResult, writeJsonAtomic, installedPluginsPath, pluginsCacheDir } = require('./lifecycle');
11
11
  const { claudeHome } = require('./claude-config');
12
12
  const { clearCache: clearBinaryCache, globalNodeModulesCandidates, nvmNodeModulesDirs, PLATFORM_PKG, detectLibc } = require('./find-binary');
13
13
  const { readBinaryVersion, compareVersions, isDevMode } = require('./version-utils');
@@ -118,14 +118,53 @@ function getPlatformAssetName({ platform = os.platform(), arch = os.arch(), libc
118
118
 
119
119
  // ── State Persistence ──────────────────────────────────────
120
120
 
121
+ // `readJson(STATE_FILE) || {}` was the lossy-read shape the audit swept for on
122
+ // settings.json — still live here, on the one file that holds THREE independent
123
+ // give-up budgets: the update suspension (`updateAttempts` / `suspendedAt`), the
124
+ // binary self-heal budget (`binaryHealAttempts`) and the GitHub rate-limit
125
+ // backoff (`rateLimited` + `lastCheck`). Collapsing "could not read it" into
126
+ // "fresh install" re-armed all three at once, so one corrupt or unreadable cache
127
+ // file turned off every guard that exists to stop an unbounded retry loop —
128
+ // silently, and on every session thereafter (audit 2026-08-16 review Minor tail).
129
+ //
130
+ // Only a genuine ENOENT (or an empty file, which is what a crash mid-write
131
+ // leaves) may be read as a fresh start. Anything else returns the marker below;
132
+ // `checkForUpdate` skips the session and rewrites a clean file, so the next
133
+ // session starts from real state rather than looping here.
121
134
  function readState() {
122
- return readJson(STATE_FILE) || {};
135
+ const res = readJsonResult(STATE_FILE);
136
+ if (res.value) return res.value;
137
+ if (res.missing) return {};
138
+ return { stateUnreadable: (res.error && res.error.code) || 'invalid-json' };
123
139
  }
124
140
 
141
+ // One stderr line per process when the state file cannot be written. Not a
142
+ // throw: the caller's job (checking for an update) is unaffected, and a hook that
143
+ // dies over its own bookkeeping is worse than one that keeps going. But not
144
+ // silence either — every throttle in this file (update cooldown, GitHub
145
+ // rate-limit backoff, binary self-heal budget) is stored in that one file, so a
146
+ // read-only or full ~/.claude means the updater re-runs its whole check EVERY
147
+ // session, forever, with nothing anywhere saying why (2026-08-16 audit §四).
148
+ // The unlink/cleanup `catch {}`s elsewhere in this file stay silent on purpose:
149
+ // a failed cleanup costs a stale temp file, not a broken invariant.
150
+ let stateWriteWarned = false;
125
151
  function saveState(state) {
126
152
  try {
127
- writeJsonAtomic(STATE_FILE, state);
128
- } catch { /* ok */ }
153
+ // The marker is an in-memory signal, never a persisted field: several call
154
+ // sites do `saveState({ ...readState(), ... })`, and a persisted
155
+ // `stateUnreadable` would park the updater permanently.
156
+ const { stateUnreadable, ...clean } = state || {};
157
+ void stateUnreadable;
158
+ writeJsonAtomic(STATE_FILE, clean);
159
+ } catch (e) {
160
+ if (!stateWriteWarned) {
161
+ stateWriteWarned = true;
162
+ console.error(
163
+ `[code-graph] Could not save update state to ${STATE_FILE} (${e && e.message ? e.message : e}). ` +
164
+ 'Update throttling and rate-limit backoff will not persist across sessions.',
165
+ );
166
+ }
167
+ }
129
168
  }
130
169
 
131
170
  // ── Throttle ───────────────────────────────────────────────
@@ -177,7 +216,11 @@ function shouldCheck(state, { force = false, binaryMissing = false, binaryStale
177
216
  if (state.rateLimited) return elapsed >= RATE_LIMIT_INTERVAL_MS;
178
217
  if (binaryMissing) return true;
179
218
  if (!isUpdateSuspended(state)) {
180
- if (binaryStale) return true;
219
+ // ...and only while that heal still has a retry budget. Once it is spent,
220
+ // the bypass re-fetched the API and re-entered the ~40MB download on every
221
+ // single session (P1-14) — the same reasoning that keeps `binaryStale` out
222
+ // of the suspended branch.
223
+ if (binaryStale && !isBinaryHealExhausted(state)) return true;
181
224
  if (force) return elapsed >= SESSION_START_MIN_GAP_MS;
182
225
  }
183
226
  const interval = state.updateAvailable === false ? UP_TO_DATE_RECHECK_MS : CHECK_INTERVAL_MS;
@@ -606,8 +649,13 @@ async function downloadAndInstall(latest, {
606
649
  return { pluginUpdated: false, binaryUpdated: await downloadBin(latest), marketplaceRefreshed: false };
607
650
  }
608
651
  const tarballPath = path.join(tmpDir, PLUGIN_ASSET_NAME);
652
+ // `-f` like every sibling fetch in this file (the binary at :435 and both
653
+ // sha256 sidecars). This was the one download without it, so a 404/503 wrote
654
+ // GitHub's HTML body here and exited 0; the checksum below still failed
655
+ // closed, but as "sha mismatch" — a wrong diagnosis of a fetch that never
656
+ // succeeded (2026-08-16 audit §四).
609
657
  exec('curl', [
610
- '-sL', '-o', tarballPath,
658
+ '-sfL', '-o', tarballPath,
611
659
  '-H', 'Accept: application/octet-stream',
612
660
  latest.pluginTarballUrl,
613
661
  ], hidden({ timeout: 30000, stdio: 'pipe' }));
@@ -726,9 +774,82 @@ async function downloadAndInstall(latest, {
726
774
  * on the shell-matches-latest path. Extracted + injectable so the wiring itself is
727
775
  * regression-tested, not just the predicate. Returns true iff a download promoted.
728
776
  */
729
- async function selfHealStaleBinary(latest, { needsUpdate = cachedBinaryNeedsUpdate, download = downloadBinary } = {}) {
730
- if (!needsUpdate(latest)) return false;
731
- return await download(latest);
777
+ /**
778
+ * Replace a missing/stale cached binary — BOUNDED, per target version.
779
+ *
780
+ * This had no counter, so a promote that could not land (the Windows case named
781
+ * at promoteVerifiedBinary: the running MCP server holds the .exe, rename →
782
+ * EACCES) re-downloaded ~40MB on every session forever: the caller cleared
783
+ * `updateAttempts`/`suspendedAt` unconditionally right after calling this, and
784
+ * `shouldCheck`'s `binaryStale` arm bypasses the throttle (audit 2026-08-16
785
+ * P1-14; measured 8 calls → 8 downloads).
786
+ *
787
+ * Counted the same way as selfHealGlobalPkgs, including its hard-won rule:
788
+ * success is "the binary is no longer stale", NOT "download() returned true".
789
+ * A download whose promote silently failed used to reset the budget on every
790
+ * run, which is a cap that can never be reached.
791
+ *
792
+ * The counter is deliberately SEPARATE from `updateAttempts`: that one tracks
793
+ * the plugin-shell update, and the branch this runs in resets it because the
794
+ * shell IS current. Sharing it would have made each reset re-arm the other.
795
+ *
796
+ * @returns {{healed: boolean, patch: object}} patch is spread into the state save
797
+ */
798
+ async function selfHealStaleBinary(latest, {
799
+ state = {}, needsUpdate = cachedBinaryNeedsUpdate, download = downloadBinary,
800
+ // "Present" must mean USABLE, not merely on disk. A truncated, non-executable
801
+ // or wrong-arch cached binary leaves the MCP server exactly as dead as a
802
+ // missing one, and every sibling predicate here already treats unreadable as
803
+ // needing replacement (cachedBinaryNeedsUpdate, cachedBinaryStaleVsState).
804
+ // Keying on existsSync alone put a corrupt binary under the stale budget,
805
+ // which isBinaryHealExhausted only re-arms when a NEW release ships — so five
806
+ // quick failures parked the only recovery path permanently (pre-tag review of
807
+ // the P1-14 fix).
808
+ binaryPresent = () => {
809
+ const p = cachedBinaryPath();
810
+ return fs.existsSync(p) && readBinaryVersion(p) !== null;
811
+ },
812
+ } = {}) {
813
+ if (!latest || !needsUpdate(latest)) {
814
+ // Healthy → clear any leftover counter so the next real staleness starts fresh.
815
+ return {
816
+ healed: false,
817
+ patch: state.binaryHealAttempts ? { binaryHealAttempts: 0, binaryHealVersion: null } : {},
818
+ };
819
+ }
820
+ // A MISSING binary is exempt from the attempt budget: with no engine at all
821
+ // the MCP server is dead, and `needsUpdate` returns true for "absent" too —
822
+ // letting the stale-heal counter absorb those failures would permanently
823
+ // park the only recovery path after five offline session starts (batch
824
+ // review of the P1-14 fix). The budget exists to stop re-downloading over a
825
+ // binary that RUNS but cannot be replaced (Windows EACCES-on-rename); a
826
+ // missing binary keeps the pre-P1-14 unbounded retry on purpose.
827
+ const missing = !binaryPresent();
828
+ const attempts = state.binaryHealVersion === latest.version ? (state.binaryHealAttempts || 0) : 0;
829
+ if (!missing && attempts >= MAX_UPDATE_ATTEMPTS) return { healed: false, patch: {} };
830
+ await download(latest);
831
+ // Re-read the disk, not the return value (see above).
832
+ const stillStale = needsUpdate(latest);
833
+ return {
834
+ healed: !stillStale,
835
+ patch: {
836
+ binaryHealVersion: latest.version,
837
+ binaryHealAttempts: !stillStale ? 0 : missing ? attempts : attempts + 1,
838
+ },
839
+ };
840
+ }
841
+
842
+ /**
843
+ * The stale-binary heal has spent its budget on the release we are tracking.
844
+ * Read by shouldCheck: with the heal parked, the `binaryStale` throttle bypass
845
+ * can accomplish nothing and would just re-fetch the API (and, worse, re-enter
846
+ * the download path) every session. Re-arms itself when `latestVersion` moves.
847
+ */
848
+ function isBinaryHealExhausted(state) {
849
+ return Boolean(state)
850
+ && Boolean(state.binaryHealVersion)
851
+ && state.binaryHealVersion === state.latestVersion
852
+ && (state.binaryHealAttempts || 0) >= MAX_UPDATE_ATTEMPTS;
732
853
  }
733
854
 
734
855
  // ── Global npm package self-heal ───────────────────────────
@@ -889,6 +1010,16 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
889
1010
  // bypasses auto-update.js, so re-sync state.installedVersion every call.
890
1011
  const installedVersion = readManifest().version || '0.0.0';
891
1012
 
1013
+ // A state we could not read authorises nothing: every throttle, budget and
1014
+ // suspension below is derived from it, so acting on a blank stand-in would
1015
+ // bypass all of them at once. Skip this session and rewrite a clean file —
1016
+ // `lastCheck` stamped now means the ordinary interval applies from here, so
1017
+ // the recovery is bounded rather than an immediate retry.
1018
+ if (state.stateUnreadable) {
1019
+ saveState({ installedVersion, lastCheck: new Date().toISOString() });
1020
+ return null;
1021
+ }
1022
+
892
1023
  // Time-based throttle. Two conditions override it: a missing cache binary
893
1024
  // (launcher cannot start) and a present-but-stale binary (otherwise it stays
894
1025
  // pinned to the old version for up to a full check interval — the binary
@@ -1052,7 +1183,8 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1052
1183
  // OR stale (see selfHealStaleBinary). The shell version (manifest.version)
1053
1184
  // can match latest while the cached binary lags — this is exactly the wild
1054
1185
  // failure observed in the field (shell at v0.45, binary pinned at v0.16.6).
1055
- const selfHealedBinary = await selfHealStaleBinary(latest);
1186
+ const binaryHeal = await selfHealStaleBinary(latest, { state });
1187
+ const selfHealedBinary = binaryHeal.healed;
1056
1188
 
1057
1189
  // Same for the GLOBAL npm delivery surface (the `code-graph-mcp` CLI on
1058
1190
  // PATH + any explicitly-installed platform package): nothing else ever
@@ -1078,6 +1210,10 @@ async function checkForUpdate({ installMissing = false, force = false, requestJs
1078
1210
  suspendedAt: null,
1079
1211
  rateLimited: false,
1080
1212
  binaryUpdated: selfHealedBinary || state.binaryUpdated,
1213
+ // The shell-update counters above reset because the shell IS current.
1214
+ // The BINARY heal keeps its own, un-reset budget — clearing it here is
1215
+ // what made the stale-binary re-download unbounded (P1-14).
1216
+ ...binaryHeal.patch,
1081
1217
  ...globalHeal,
1082
1218
  });
1083
1219
  return selfHealedBinary
@@ -1101,7 +1237,7 @@ module.exports = {
1101
1237
  PLUGIN_ASSET_NAME,
1102
1238
  downloadBinary, cachedBinaryPath, cachedBinaryNeedsUpdate, cachedBinaryStaleVsState,
1103
1239
  getPlatformAssetName,
1104
- selfHealStaleBinary,
1240
+ selfHealStaleBinary, isBinaryHealExhausted,
1105
1241
  selfHealGlobalPkgs, staleGlobalPkgs, globalPkgVersion, npmInstallGlobal,
1106
1242
  shouldHealGlobalsOnThrottle, inactiveNodeGlobalRelics,
1107
1243
  downloadAndInstall, refreshMarketplaceClone, marketplaceCloneDir,
@@ -12,7 +12,7 @@ const {
12
12
  } = require('./lifecycle');
13
13
  const { findBinary, clearCache: clearBinaryCache } = require('./find-binary');
14
14
  const { hidden } = require('./proc-opts');
15
- const { MAX_UPDATE_ATTEMPTS } = require('./auto-update');
15
+ const { MAX_UPDATE_ATTEMPTS, isBinaryHealExhausted } = require('./auto-update');
16
16
 
17
17
  // ── Diagnostics ───────────────────────────────────────────
18
18
 
@@ -29,7 +29,7 @@ function classifyEmbeddings(hc) {
29
29
  const ep = (hc && hc.embedding_progress) || '0/0';
30
30
  const [done, total] = ep.split('/').map(Number);
31
31
  if (hc && hc.model_available === false) {
32
- return { name: 'Embeddings', status: 'warn',
32
+ return { name: 'Embeddings', status: 'warn', advisory: true,
33
33
  detail: 'binary built without embed-model — semantic search is FTS5-only; reinstall via npm/plugin for the hybrid binary' };
34
34
  }
35
35
  if (!total) {
@@ -65,7 +65,7 @@ function classifyEmbeddings(hc) {
65
65
  ? `last model download: ${hc.model_download}`
66
66
  : 'model not loaded and NO download has ever been attempted on this machine — restart the MCP server, or set CODE_GRAPH_MODEL_DIR to a manually populated model dir (see README → Offline usage)')
67
67
  : `embedding_status=${(hc && hc.embedding_status) || 'unknown'}`;
68
- return { name: 'Embeddings', status: 'warn',
68
+ return { name: 'Embeddings', status: 'warn', advisory: true,
69
69
  detail: `vector INACTIVE — ${total} embeddable nodes, 0 embedded; semantic search is FTS5-only (${why})` };
70
70
  }
71
71
  if (done < total) {
@@ -156,7 +156,10 @@ function classifyHealthReport(hc) {
156
156
  }
157
157
  const rows = [];
158
158
  if (hc.issue && String(hc.issue).includes('schema')) {
159
- rows.push({ name: 'Schema', status: 'warn', detail: hc.issue, fixId: 'schema-mismatch' });
159
+ // advisory: its "repair" only prints guidance (migration happens when the
160
+ // binary next runs), so it can never be counted fixed and would pin the
161
+ // exit code at 1 forever.
162
+ rows.push({ name: 'Schema', status: 'warn', advisory: true, detail: hc.issue, fixId: 'schema-mismatch' });
160
163
  } else {
161
164
  rows.push({ name: 'Schema', status: 'ok', detail: `v${hc.schema_version}` });
162
165
  }
@@ -215,6 +218,10 @@ function runHealthCheckCli(binary) {
215
218
  return execFileSync(binary, ['health-check', '--json'], hidden({
216
219
  cwd: process.cwd(),
217
220
  timeout: 5000,
221
+ // Same shape statusline.js hardened (audit P1-17): a wedged binary that
222
+ // ignores SIGTERM makes Node's timeout unreachable, so doctor — the tool
223
+ // you run BECAUSE something is wrong — would hang instead of diagnosing.
224
+ killSignal: 'SIGKILL',
218
225
  encoding: 'utf8',
219
226
  stdio: ['pipe', 'pipe', 'pipe'],
220
227
  })).trim();
@@ -402,6 +409,7 @@ function runDiagnostics({ checkOnly = false } = {}) {
402
409
  results.push({
403
410
  name: 'Hooks',
404
411
  status: 'warn',
412
+ advisory: true,
405
413
  detail:
406
414
  `settings.json was unusable and has been REBUILT — your original is at ` +
407
415
  `${hookResult.rebuiltFrom}. Merge anything you need back by hand.`,
@@ -530,6 +538,7 @@ function runDiagnostics({ checkOnly = false } = {}) {
530
538
  results.push({
531
539
  name: 'Global npm relics',
532
540
  status: 'warn',
541
+ advisory: true,
533
542
  detail: relics.map((r) => `${r.name}@${r.version} (${r.nodeModulesDir.replace(home, '~')})`).join('; ')
534
543
  + ' — installed under a non-active node version; auto-heal cannot reach another node\'s prefix. '
535
544
  + 'Remove each via `nvm use <that node> && npm rm -g <pkg>`, or uninstall the unused node (`nvm uninstall <ver>`).',
@@ -676,6 +685,52 @@ function binaryVersionResolved({
676
685
  return Boolean(actual) && actual === pluginVersion();
677
686
  }
678
687
 
688
+ /**
689
+ * Mirror of BOTH `binary-broken` diagnoses: the binary is on disk but does not
690
+ * run (runDiagnostics step 2, `--version` unreadable) or its health-check failed
691
+ * with no recoverable payload (healthRows' last arm). Re-asks the same two
692
+ * questions the diagnosis asked, so "resolved" cannot mean something weaker than
693
+ * "not raised". Cache dropped first: the promote happens in a CHILD process and
694
+ * find-binary memoizes.
695
+ */
696
+ function binaryBrokenResolved({
697
+ find = findBinary, readVersion = readBinaryVersion, rows = healthRows,
698
+ } = {}) {
699
+ clearBinaryCache();
700
+ const binary = find();
701
+ if (!binary) return false;
702
+ if (!readVersion(binary)) return false;
703
+ try {
704
+ return !rows(binary).some((r) => r.fixId === 'binary-broken');
705
+ } catch { return false; }
706
+ }
707
+
708
+ /**
709
+ * Build the binary in the source checkout. Injectable for the same reason
710
+ * rebuildIndexInPlace is: `execSync` is destructured at load, so a test that
711
+ * patches child_process afterwards would silently run a real 10-minute cargo
712
+ * build. Returns true on exit 0; throws what the build threw.
713
+ */
714
+ function buildBinaryFromSource(cmd) {
715
+ execSync(cmd, hidden({
716
+ cwd: path.resolve(__dirname, '..', '..'),
717
+ stdio: 'inherit',
718
+ timeout: 600000, // embed-model (Candle) builds exceed the old 5min
719
+ }));
720
+ clearBinaryCache();
721
+ return true;
722
+ }
723
+
724
+ /** Manual recovery for a binary we could not repair — the end of every failed arm. */
725
+ function printBinaryRecovery() {
726
+ console.log(' Reinstall: npm install -g @sdsrs/code-graph');
727
+ console.log(' Or download the release asset for your platform:');
728
+ console.log(' https://github.com/sdsrss/code-graph-mcp/releases');
729
+ if (os.platform() === 'darwin') {
730
+ console.log(' macOS may also have quarantined it: xattr -d com.apple.quarantine <binary>');
731
+ }
732
+ }
733
+
679
734
  /** Mirror of the `update-incomplete` diagnosis (runDiagnostics step 5). */
680
735
  function updateIncompleteResolved({ readStateFile = readUpdateState } = {}) {
681
736
  const state = readStateFile();
@@ -750,6 +805,20 @@ function autoUpdateNoOpReason(state = readUpdateState(), env = process.env) {
750
805
  return `auto-update is SUSPENDED after ${state.updateAttempts} failed attempts on v${state.latestVersion} `
751
806
  + '(it retries once a day, and immediately when a newer release is published)';
752
807
  }
808
+ // The BINARY self-heal carries its own budget, independent of the update
809
+ // suspension above: the updater can be perfectly healthy while the binary
810
+ // download has given up on this release. That is precisely the state a
811
+ // `binary-broken` / stale-version row comes from, and it was the one parked
812
+ // state doctor could not name — so the user was told to update manually with
813
+ // no hint that the automatic repair had already stopped trying (audit
814
+ // 2026-08-16 review Minor tail). Uses the updater's own predicate rather than
815
+ // a second copy of the condition, which is keyed to `latestVersion` and so
816
+ // re-arms itself when a newer release appears.
817
+ if (isBinaryHealExhausted(state)) {
818
+ return `the binary self-heal has given up on v${state.latestVersion} after `
819
+ + `${state.binaryHealAttempts} failed download attempts (it re-arms when a newer `
820
+ + 'release is published)';
821
+ }
753
822
  if (state.rateLimited) {
754
823
  return 'the updater is in its GitHub rate-limit backoff (up to 1h)';
755
824
  }
@@ -770,6 +839,8 @@ function runRepairs(results, {
770
839
  updateResolved = updateIncompleteResolved,
771
840
  integrityOk = integrityResolved,
772
841
  rebuildIndex = rebuildIndexInPlace,
842
+ binaryUsable = binaryBrokenResolved,
843
+ buildBinary = buildBinaryFromSource,
773
844
  } = {}) {
774
845
  const fixable = results.filter(r => r.fixId);
775
846
  if (fixable.length === 0) return 0;
@@ -851,6 +922,63 @@ function runRepairs(results, {
851
922
  break;
852
923
  }
853
924
 
925
+ case 'binary-broken': {
926
+ // The binary EXISTS but cannot run: a truncated or corrupted download, a
927
+ // wrong-arch asset, a missing libc, macOS quarantine, or a real crash.
928
+ // This fixId had no arm at all, so doctor printed "1 issue(s) found.
929
+ // Fixing..." and then "0/1 addressed" with nothing between the two
930
+ // (audit 2026-08-16 P1-13).
931
+ if (devMode()) {
932
+ // A source checkout is never repaired by downloading a release asset.
933
+ // Preserve the feature set for the same reason the binary-stale arm
934
+ // does — never silently downgrade a hybrid dev binary to FTS5-only.
935
+ const embed = detectEmbedModel(findBinary());
936
+ const buildCmd = devBuildCommand(embed === true);
937
+ console.log('\n Binary is present but does not run — rebuilding from source...');
938
+ if (embed === null) {
939
+ console.log(' (could not probe the current feature set — building FTS5-only;');
940
+ console.log(' for semantic search rebuild with `cargo build --release --features embed-model`)');
941
+ }
942
+ console.log(` → ${buildCmd}`);
943
+ try {
944
+ if (!buildBinary(buildCmd)) {
945
+ console.log(' ❌ Build failed');
946
+ break;
947
+ }
948
+ } catch {
949
+ console.log(' ❌ Build failed');
950
+ break;
951
+ }
952
+ } else {
953
+ console.log('\n Binary is present but does not run — re-downloading it...');
954
+ try {
955
+ // The updater's stale-binary self-heal treats an unreadable
956
+ // `--version` as "replace it", so this reaches the verified
957
+ // download+promote path even without a newer release.
958
+ runAutoUpdate();
959
+ } catch {
960
+ console.log(' ❌ Update check failed');
961
+ printBinaryRecovery();
962
+ break;
963
+ }
964
+ }
965
+ // Exit 0 proves the command ran, not that the binary works. Ask it.
966
+ if (binaryUsable()) {
967
+ console.log(' ✅ Binary runs again');
968
+ fixed++;
969
+ } else {
970
+ console.log(' ❌ The binary still cannot run');
971
+ if (!devMode()) {
972
+ const why = autoUpdateNoOpReason();
973
+ if (why) console.log(` Why the re-download may have done nothing: ${why}.`);
974
+ printBinaryRecovery();
975
+ } else {
976
+ console.log(' The build completed but the produced binary still fails --version/health-check.');
977
+ }
978
+ }
979
+ break;
980
+ }
981
+
854
982
  case 'binary-not-exec': {
855
983
  const binary = findBinary();
856
984
  if (binary) {
@@ -1017,17 +1145,30 @@ function runRepairs(results, {
1017
1145
  // ── Main ──────────────────────────────────────────────────
1018
1146
 
1019
1147
  // Exit status for a doctor run reflects what remains BROKEN, not what was found:
1020
- // --check-only → every found issue is unresolved (report cleanliness, no repair).
1021
- // repair mode → issueCount minus what runRepairs resolved. A run that fixes
1148
+ // --check-only → every found BLOCKING issue is unresolved (report cleanliness,
1149
+ // no repair).
1150
+ // repair mode → blocking count minus what runRepairs resolved. A run that fixes
1022
1151
  // everything ("N/N addressed") reports 0 so `doctor && …` and
1023
1152
  // self-heal automation don't read a successful repair as a
1024
1153
  // failure. runRepairs counts an issue fixed only when its repair
1025
1154
  // reports success — and the hooks arm re-scans after install() to
1026
1155
  // confirm, so a still-broken re-scan is NOT counted (stays
1027
- // unresolved → exit 1). An issue with no working repair
1028
- // (schema-mismatch is advisory only) likewise keeps this > 0.
1156
+ // unresolved → exit 1).
1157
+ //
1158
+ // `advisory: true` rows are excluded. They are reported like any other warn but
1159
+ // describe something this tool cannot act on and that is not broken: a binary
1160
+ // deliberately built without embed-model, npm relics under a node version whose
1161
+ // prefix we cannot reach, a settings.json we already rebuilt, a schema note whose
1162
+ // "repair" only prints guidance. Every one of those used to pin the exit code at
1163
+ // 1 for the life of the install, so `doctor && <next step>` could never proceed —
1164
+ // a permanently-red check is one nobody reads (2026-08-16 audit §四).
1165
+ //
1166
+ // Advisory is an EXPLICIT marker, never inferred from a missing fixId: inferring
1167
+ // it would silently exempt the next row somebody forgets to wire to a repair,
1168
+ // which is the opposite failure. `doctor_rows_are_repairable_or_advisory`
1169
+ // (doctor.test.js) holds that line.
1029
1170
  function unresolvedCount({ checkOnly, issueCount, fixed }) {
1030
- return checkOnly ? issueCount : issueCount - fixed;
1171
+ return checkOnly ? issueCount : Math.max(0, issueCount - fixed);
1031
1172
  }
1032
1173
 
1033
1174
  function runDoctor(opts = {}) {
@@ -1035,20 +1176,25 @@ function runDoctor(opts = {}) {
1035
1176
  console.log(formatReport(results, { checkOnly: opts.checkOnly }));
1036
1177
 
1037
1178
  const issues = results.filter(r => r.status === 'warn' || r.status === 'error');
1179
+ const blocking = issues.filter(r => !r.advisory);
1038
1180
 
1039
1181
  let fixed = 0;
1040
1182
  if (issues.length > 0 && !opts.checkOnly) {
1041
1183
  fixed = runRepairs(results);
1042
- console.log(`\n ${fixed}/${issues.length} issue(s) addressed.`);
1184
+ console.log(`\n ${fixed}/${blocking.length} issue(s) addressed.`);
1185
+ const advisoryCount = issues.length - blocking.length;
1186
+ if (advisoryCount > 0) {
1187
+ console.log(` ${advisoryCount} advisory note(s) above need no action here.`);
1188
+ }
1043
1189
  }
1044
1190
 
1045
1191
  const unresolved = unresolvedCount({
1046
- checkOnly: opts.checkOnly, issueCount: issues.length, fixed,
1192
+ checkOnly: opts.checkOnly, issueCount: blocking.length, fixed,
1047
1193
  });
1048
1194
  return { results, issueCount: issues.length, unresolved };
1049
1195
  }
1050
1196
 
1051
- module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, autoUpdateNoOpReason };
1197
+ module.exports = { runDiagnostics, formatReport, runRepairs, runDoctor, runDoctorCli, parseDoctorArgs, unresolvedCount, surveyHookCoverage, relicRepairGuard, classifyEmbeddings, classifyIntegrity, classifyHealthReport, parseHealthPayload, integrityResolved, healthRows, detectEmbedModel, devBuildCommand, binaryVersionResolved, updateIncompleteResolved, binaryBrokenResolved, autoUpdateNoOpReason };
1052
1198
 
1053
1199
  // Shared by BOTH doctor entry points: `node doctor.js …` and `node lifecycle.js
1054
1200
  // doctor …`. It exists as one function because the first version of this guard
@@ -5,22 +5,88 @@
5
5
  // post-grep-inject) cannot drift apart (feedback_hook_class_bug_sweep — no
6
6
  // inline copies of shared logic). DRY mirror of the project-root.js precedent.
7
7
  //
8
- // Why these two shapes:
8
+ // Why these three shapes:
9
9
  // - PreToolUse plain stdout on exit 0 goes to the DEBUG LOG ONLY — it never
10
10
  // reaches the model (CC docs, code.claude.com/docs/en/hooks.md, v2026-06).
11
- // `additionalContext` DOES reach the model, but only alongside a
12
- // permissionDecision. For the safe-tool pre hooks (Read fanout hint, Edit
13
- // impact summary) the elevation to `allow` is negligible and is what makes
14
- // the carried context visible.
11
+ // `additionalContext` is what surfaces the carried text.
12
+ // - `permissionDecision: 'allow'` is NOT a delivery detail. The CC hooks
13
+ // reference defines it as: "skip the interactive permission prompt" (deny
14
+ // rules and connector/`requiresUserInteraction` prompts still apply). On a
15
+ // machine that prompts for a tool, a hook sending `allow` has answered the
16
+ // user's prompt for them. That is defensible for READ-ONLY Read; it is not
17
+ // for Edit, which writes to disk — a delivery hook must never buy context
18
+ // visibility with the user's write consent (audit 2026-08-16 P0-2).
19
+ // Therefore: `emitPreToolAllowContext` is for Read ONLY, and every
20
+ // write-capable tool uses `emitPreToolContext` (no decision at all, which
21
+ // the docs' own PreToolUse example marks as "no decision; normal permission
22
+ // flow applies"). If a future CC drops additionalContext without a decision,
23
+ // the correct outcome is that the Edit impact summary goes quiet — NOT that
24
+ // it re-acquires the elevation.
15
25
  // - PostToolUse honors `additionalContext` permission-neutrally (no
16
26
  // permissionDecision), so the Bash-side grep answer can be injected without
17
27
  // skipping CC's default permission prompt for the underlying tool call.
18
28
 
29
+ // Ceiling on injected context, applied at the ONE place all three hooks emit
30
+ // through. `cg-answer.js` has capped its own output at 4000 bytes since it was
31
+ // written; the hook payloads it sits alongside had no cap at all, and they are
32
+ // assembled from unbounded lists — pre-edit-guide joins every direct caller's
33
+ // `name (file)` onto a single line, so editing a 200-caller symbol injected a
34
+ // multi-kilobyte wall into the model's context on every Edit (2026-08-16 audit
35
+ // §四). This is the model's context window, not a log: the whole value of an
36
+ // impact summary is that it is small enough to read.
37
+ //
38
+ // Truncation is announced, never silent — a summary that stops mid-list without
39
+ // saying so is worse than one that says it was cut, because the reader cannot
40
+ // tell a short blast radius from a clipped one.
41
+ const MAX_INJECTED_BYTES = 4000;
42
+
43
+ function capContext(text) {
44
+ const s = String(text == null ? '' : text);
45
+ if (Buffer.byteLength(s, 'utf8') <= MAX_INJECTED_BYTES) return s;
46
+ const notice = `\n … truncated at ${MAX_INJECTED_BYTES} bytes — re-run the CLI command above for the full result.\n`;
47
+ const budget = MAX_INJECTED_BYTES - Buffer.byteLength(notice, 'utf8');
48
+ // Slice on a UTF-16 code-unit boundary that fits the byte budget. This keeps
49
+ // the byte cap exact and never splits a 1-3 byte UTF-8 character (ASCII, Latin,
50
+ // CJK — what file paths and symbol names actually contain). It CAN split an
51
+ // astral-plane character (emoji, 2 code units) into a lone surrogate:
52
+ // `JSON.stringify` escapes that, so the envelope stays parseable and the model
53
+ // sees one replacement character at the cut. Saying so rather than claiming
54
+ // "never cut in half", which is what this comment used to claim (v0.118.0
55
+ // pre-tag review verified the emoji case).
56
+ let end = s.length;
57
+ while (end > 0 && Buffer.byteLength(s.slice(0, end), 'utf8') > budget) {
58
+ end -= Math.max(1, Math.ceil((Buffer.byteLength(s.slice(0, end), 'utf8') - budget) / 4));
59
+ }
60
+ // Prefer cutting at the last newline inside the budget, so the truncated text
61
+ // ends on a whole line rather than mid-token.
62
+ const nl = s.lastIndexOf('\n', end);
63
+ if (nl > budget / 2) end = nl;
64
+ return s.slice(0, end) + notice;
65
+ }
66
+
67
+ /**
68
+ * PreToolUse additionalContext envelope with NO permissionDecision (string, no
69
+ * trailing newline). The permission-neutral shape: the tool's normal permission
70
+ * flow is untouched. Use this for every write-capable tool (Edit/Write/…).
71
+ * @param {string} text
72
+ * @returns {string} JSON line
73
+ */
74
+ function emitPreToolContext(text) {
75
+ return JSON.stringify({
76
+ hookSpecificOutput: {
77
+ hookEventName: 'PreToolUse',
78
+ additionalContext: capContext(text),
79
+ },
80
+ });
81
+ }
82
+
19
83
  /**
20
84
  * PreToolUse allow + additionalContext envelope (string, no trailing newline).
21
- * Used by the safe-tool delivery hooks (pre-read-guide / pre-edit-guide) so
22
- * their carried hint/impact text reaches the model instead of dying in the
23
- * debug log.
85
+ *
86
+ * READ-ONLY TOOLS ONLY (pre-read-guide). `allow` skips the user's interactive
87
+ * permission prompt; for Read that grants nothing the model could not already
88
+ * get, and it is what keeps the fanout hint visible. Do not reuse it for a tool
89
+ * that mutates state — see the note above.
24
90
  * @param {string} text
25
91
  * @returns {string} JSON line
26
92
  */
@@ -29,7 +95,7 @@ function emitPreToolAllowContext(text) {
29
95
  hookSpecificOutput: {
30
96
  hookEventName: 'PreToolUse',
31
97
  permissionDecision: 'allow',
32
- additionalContext: text,
98
+ additionalContext: capContext(text),
33
99
  },
34
100
  });
35
101
  }
@@ -45,9 +111,12 @@ function emitPostToolContext(text) {
45
111
  return JSON.stringify({
46
112
  hookSpecificOutput: {
47
113
  hookEventName: 'PostToolUse',
48
- additionalContext: text,
114
+ additionalContext: capContext(text),
49
115
  },
50
116
  });
51
117
  }
52
118
 
53
- module.exports = { emitPreToolAllowContext, emitPostToolContext };
119
+ module.exports = {
120
+ emitPreToolContext, emitPreToolAllowContext, emitPostToolContext,
121
+ capContext, MAX_INJECTED_BYTES,
122
+ };