ruvnet-brain 4.7.20 → 4.7.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +618 -104
  3. package/console/activity.js +122 -38
  4. package/kb/download-retry.mjs +93 -21
  5. package/kb/forge-guard-injection.mjs +277 -0
  6. package/kb/forge-update.mjs +166 -38
  7. package/kb/lifecycle-evidence-retention.mjs +151 -14
  8. package/kb/model-requirements.mjs +4 -1
  9. package/kb/refresh-run.mjs +175 -27
  10. package/kb/verify-citation.mjs +15 -2
  11. package/package.json +3 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  15. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  16. package/plugin/scripts/advisory-contract.mjs +21 -0
  17. package/plugin/scripts/advocacy-route.mjs +5 -4
  18. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  19. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  20. package/plugin/scripts/agentdb-recall.mjs +49 -17
  21. package/plugin/scripts/automatic-update.mjs +9 -1
  22. package/plugin/scripts/brain-confirmation.mjs +2 -1
  23. package/plugin/scripts/brain-footprint.mjs +72 -11
  24. package/plugin/scripts/capability-registry.mjs +1 -1
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  26. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  27. package/plugin/scripts/completion-claim-evidence.mjs +241 -23
  28. package/plugin/scripts/continuation-gate.mjs +75 -11
  29. package/plugin/scripts/decision-gate.mjs +13 -63
  30. package/plugin/scripts/decision-outcomes.mjs +36 -0
  31. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  32. package/plugin/scripts/decision-record.mjs +8 -3
  33. package/plugin/scripts/degradation-watch.mjs +2 -1
  34. package/plugin/scripts/developer-update-lock.mjs +172 -33
  35. package/plugin/scripts/developer-update-owner.mjs +8 -5
  36. package/plugin/scripts/developer-update-policy.mjs +4 -1
  37. package/plugin/scripts/developer-update.mjs +155 -53
  38. package/plugin/scripts/doc-currency.mjs +2 -16
  39. package/plugin/scripts/duplicate-gate.mjs +2 -1
  40. package/plugin/scripts/entrypoint.mjs +34 -0
  41. package/plugin/scripts/footprint-io.mjs +135 -8
  42. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  43. package/plugin/scripts/ground-ruvnet.sh +6 -3
  44. package/plugin/scripts/grounding-answer.mjs +2 -1
  45. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  46. package/plugin/scripts/grounding-substance.mjs +2 -1
  47. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  48. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  49. package/plugin/scripts/hook-context-budget.mjs +2 -1
  50. package/plugin/scripts/hook-input.mjs +2 -2
  51. package/plugin/scripts/hook-shim.mjs +9 -2
  52. package/plugin/scripts/host-update.mjs +29 -5
  53. package/plugin/scripts/identifier-preflight.mjs +2 -1
  54. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  55. package/plugin/scripts/learn-capture.mjs +2 -1
  56. package/plugin/scripts/learning-store.mjs +62 -16
  57. package/plugin/scripts/lesson-bridge.mjs +2 -2
  58. package/plugin/scripts/lesson-gate.mjs +7 -39
  59. package/plugin/scripts/lesson-promote.mjs +83 -22
  60. package/plugin/scripts/lesson-scope.mjs +51 -0
  61. package/plugin/scripts/lesson-store.mjs +1 -1
  62. package/plugin/scripts/md-stamp.mjs +2 -1
  63. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  64. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  65. package/plugin/scripts/package-recommender.mjs +2 -2
  66. package/plugin/scripts/project-store-resolver.mjs +19 -0
  67. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  68. package/plugin/scripts/ruflo-bin.mjs +14 -6
  69. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  70. package/plugin/scripts/session-snapshot-hook.mjs +25 -5
  71. package/plugin/scripts/session-start-core.mjs +7 -16
  72. package/plugin/scripts/session-start-health.mjs +98 -10
  73. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  74. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  75. package/plugin/scripts/signal-watch.mjs +2 -1
  76. package/plugin/scripts/spend-guard.mjs +2 -1
  77. package/plugin/scripts/substitution-wall.mjs +2 -1
  78. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  79. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  80. package/plugin/scripts/update-apply.mjs +186 -15
  81. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  82. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  83. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  84. package/scripts/adr-072-completion.mjs +2 -1
  85. package/scripts/agentdb-context.mjs +2 -1
  86. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  87. package/scripts/approved-runtime.mjs +2 -1
  88. package/scripts/architecture-review-lock.mjs +2 -1
  89. package/scripts/ascii-drift.mjs +2 -1
  90. package/scripts/brain-latency-50.mjs +2 -1
  91. package/scripts/brain-novice-50.mjs +2 -16
  92. package/scripts/brain-score.mjs +13 -2
  93. package/scripts/build-bundle.mjs +2 -1
  94. package/scripts/build-concepts.mjs +2 -1
  95. package/scripts/candidate-ci-receipt.mjs +2 -1
  96. package/scripts/candidate-host-evidence.mjs +2 -1
  97. package/scripts/card-from-source.mjs +2 -1
  98. package/scripts/ci/warm-brain-models.mjs +2 -1
  99. package/scripts/claims-verify.mjs +7 -3
  100. package/scripts/claude-controlled-terminal.mjs +2 -1
  101. package/scripts/claude-terminal-mod.mjs +2 -1
  102. package/scripts/code-release-corpus.mjs +2 -1
  103. package/scripts/codex-boot-surface-proof.mjs +208 -0
  104. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  105. package/scripts/codex-hook-trust.mjs +40 -1
  106. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  107. package/scripts/codex-host-execution-proof.mjs +2 -1
  108. package/scripts/completion-claim-replay.mjs +2 -1
  109. package/scripts/console-engine.mjs +8 -3
  110. package/scripts/corpus-canary.mjs +2 -1
  111. package/scripts/corpus-candidate.mjs +2 -2
  112. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  113. package/scripts/corpus-dispatch-decision.mjs +2 -1
  114. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  115. package/scripts/corpus-next-seed.mjs +2 -1
  116. package/scripts/corpus-qa.mjs +2 -1
  117. package/scripts/corpus-reconcile.mjs +2 -2
  118. package/scripts/corpus-watchdog.mjs +2 -1
  119. package/scripts/count-chunks.mjs +2 -1
  120. package/scripts/customer-state-matrix.mjs +2 -1
  121. package/scripts/derive-passage-content-map.mjs +2 -1
  122. package/scripts/development-push-check.mjs +2 -1
  123. package/scripts/dispatch-receipt.mjs +2 -1
  124. package/scripts/doc-currency.mjs +2 -1
  125. package/scripts/dream-issue-gate.mjs +2 -1
  126. package/scripts/dual-host-deliberation.mjs +2 -16
  127. package/scripts/dual-host-suggest.mjs +2 -16
  128. package/scripts/eval-brain.mjs +7 -3
  129. package/scripts/execution-policy.mjs +2 -1
  130. package/scripts/execution-preflight.mjs +2 -1
  131. package/scripts/falsify.mjs +2 -1
  132. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  133. package/scripts/fix-workstream.mjs +2 -1
  134. package/scripts/full-hints.mjs +2 -1
  135. package/scripts/full-suite-gate.mjs +2 -1
  136. package/scripts/grok-host.mjs +68 -0
  137. package/scripts/hook-qualify-core.mjs +3 -1
  138. package/scripts/hook-qualify.mjs +2 -1
  139. package/scripts/hook-retirement-check.mjs +2 -1
  140. package/scripts/host-registry.mjs +2 -1
  141. package/scripts/independent-review-receipt.mjs +2 -1
  142. package/scripts/ingest-new-repos.mjs +2 -1
  143. package/scripts/ingest-repo.mjs +12 -2
  144. package/scripts/integration-evidence.mjs +2 -1
  145. package/scripts/issue-fix.mjs +7 -24
  146. package/scripts/issue-watch.mjs +116 -40
  147. package/scripts/job-heartbeat.sh +54 -19
  148. package/scripts/key-canary.mjs +9 -3
  149. package/scripts/latency-to-surface.mjs +2 -1
  150. package/scripts/learning-replay-cli.mjs +97 -19
  151. package/scripts/learning-replay-execution.mjs +2 -0
  152. package/scripts/learning-replay-fixture.mjs +3 -0
  153. package/scripts/learning-replay-proof.mjs +14 -13
  154. package/scripts/learning-replay.mjs +3 -3
  155. package/scripts/learnings.mjs +2 -1
  156. package/scripts/lesson-ratify.mjs +88 -40
  157. package/scripts/loop-checkpoint.mjs +2 -1
  158. package/scripts/metaharness-receipts.mjs +2 -1
  159. package/scripts/model-catalog.mjs +2 -1
  160. package/scripts/model-currency-evidence.mjs +6 -4
  161. package/scripts/model-currency.mjs +33 -3
  162. package/scripts/model-native-catalog.mjs +2 -1
  163. package/scripts/model-router-agent-hook.mjs +2 -1
  164. package/scripts/model-router-dispatch.mjs +2 -1
  165. package/scripts/model-router-engine.mjs +2 -1
  166. package/scripts/model-router-outcome.mjs +2 -1
  167. package/scripts/model-router-setup.mjs +2 -1
  168. package/scripts/model-routing-eval.mjs +2 -1
  169. package/scripts/model-routing-gateway.mjs +2 -1
  170. package/scripts/model-routing-launchers.mjs +4 -1
  171. package/scripts/model-terminal-gateway.mjs +2 -1
  172. package/scripts/model-terminal-launchers.mjs +2 -1
  173. package/scripts/model-weekly-analyst.mjs +2 -1
  174. package/scripts/model-weekly-cycle.mjs +2 -1
  175. package/scripts/model-weekly-qualification.mjs +2 -1
  176. package/scripts/nightly-gists.sh +26 -20
  177. package/scripts/nightly-two-run-proof.mjs +2 -1
  178. package/scripts/nightly-watchdog.mjs +158 -47
  179. package/scripts/no-silent-substitution.mjs +2 -1
  180. package/scripts/notify.sh +11 -6
  181. package/scripts/ntfy-inbox.mjs +41 -18
  182. package/scripts/onboarding-console.mjs +123 -23
  183. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  184. package/scripts/oracle/abstain-trace.mjs +2 -1
  185. package/scripts/oracle/doc2query-generate.mjs +2 -1
  186. package/scripts/oracle/doc2query-reach.mjs +2 -1
  187. package/scripts/oracle/judge-train.mjs +2 -1
  188. package/scripts/oracle/need-set-split.mjs +2 -1
  189. package/scripts/oracle/produce-questions.mjs +2 -16
  190. package/scripts/oracle/repo-recall.mjs +2 -2
  191. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  192. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  193. package/scripts/oracle/source-tree.mjs +2 -15
  194. package/scripts/oracle/source-units.mjs +2 -16
  195. package/scripts/oracle/spike-run.mjs +2 -16
  196. package/scripts/oracle/validate-labels.mjs +2 -16
  197. package/scripts/org-repo-count.mjs +2 -1
  198. package/scripts/package-cards.mjs +2 -1
  199. package/scripts/prepublication-evidence.mjs +2 -1
  200. package/scripts/private-overlay.mjs +2 -1
  201. package/scripts/product-integrity-contract.mjs +2 -1
  202. package/scripts/protected-release-invocation.mjs +2 -1
  203. package/scripts/public-verification-abandon.mjs +2 -1
  204. package/scripts/public-verification-aggregate.mjs +2 -1
  205. package/scripts/public-verification-finalizer.mjs +2 -1
  206. package/scripts/public-verification-inputs.mjs +2 -1
  207. package/scripts/public-verification-lane.mjs +2 -1
  208. package/scripts/publication-receipt.mjs +2 -1
  209. package/scripts/published-surface-probe.mjs +2 -1
  210. package/scripts/qe/card-lane-gate.mjs +2 -16
  211. package/scripts/qe/session-start-gate.mjs +2 -16
  212. package/scripts/qe/ux-suite.mjs +2 -1
  213. package/scripts/qualified-candidate-check.mjs +2 -1
  214. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  215. package/scripts/recall-latency-probe.mjs +48 -0
  216. package/scripts/recommendation-eval.mjs +2 -1
  217. package/scripts/recommendation-floor.mjs +2 -1
  218. package/scripts/recommendation-judge-score.mjs +2 -1
  219. package/scripts/recommendation-real-host-score.mjs +2 -1
  220. package/scripts/recommendation-real-host.mjs +2 -1
  221. package/scripts/refresh-capability-only-store.mjs +2 -1
  222. package/scripts/refresh-model-catalog.mjs +2 -1
  223. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  224. package/scripts/release-authority.mjs +2 -1
  225. package/scripts/release-evidence-aggregate.mjs +2 -1
  226. package/scripts/release-payload.mjs +2 -1
  227. package/scripts/release-projection.mjs +2 -1
  228. package/scripts/release-proof.mjs +2 -1
  229. package/scripts/release-qualification-contract.mjs +82 -28
  230. package/scripts/release-qualification.mjs +8 -1
  231. package/scripts/release-vector.mjs +2 -1
  232. package/scripts/restore-local-ingests.mjs +2 -1
  233. package/scripts/retrieval-canary.mjs +2 -1
  234. package/scripts/route-cheap.mjs +2 -1
  235. package/scripts/route-gold-rank.mjs +2 -1
  236. package/scripts/route-index-memory.mjs +2 -1
  237. package/scripts/route-latency-warm.mjs +2 -1
  238. package/scripts/router-utilization.mjs +2 -1
  239. package/scripts/routing-flywheel.mjs +2 -1
  240. package/scripts/run-operational-benchmark.mjs +2 -1
  241. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  242. package/scripts/rvf-index-audit.mjs +2 -16
  243. package/scripts/seal-gist-receipt.mjs +2 -1
  244. package/scripts/security-guidance-codex-compat.mjs +2 -1
  245. package/scripts/selfcheck.mjs +2 -1
  246. package/scripts/signal-watch.mjs +2 -1
  247. package/scripts/source-coverage.mjs +2 -1
  248. package/scripts/stabilization-receipt.mjs +2 -1
  249. package/scripts/stamp-sweep.mjs +2 -1
  250. package/scripts/status-honesty.mjs +2 -1
  251. package/scripts/sync-commands.mjs +2 -2
  252. package/scripts/top100-benchmark.mjs +2 -1
  253. package/scripts/top100-corpus.mjs +2 -1
  254. package/scripts/user-model-hook-install.mjs +2 -1
  255. package/scripts/user-model-prompt-hook.mjs +2 -1
  256. package/scripts/verify-bundle.mjs +3 -1
  257. package/scripts/verify-model-catalog.mjs +2 -1
  258. package/scripts/version.mjs +2 -1
  259. package/scripts/wired-check.mjs +13 -5
  260. package/scripts/worktree-integrity.mjs +2 -2
package/bin/install.mjs CHANGED
@@ -30,7 +30,7 @@ import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update
30
30
  import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
31
31
  import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
32
32
  import { kbCopyProof } from '../plugin/scripts/kb-copy-proof.mjs';
33
- import { assessMoveLeftovers, isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
33
+ import { assessMoveLeftovers, isVolumeMetadata, rescueOperationalFiles } from '../plugin/scripts/footprint-io.mjs';
34
34
  import { confirm, doctorVerdict, formatBytes, formatConfirmation, signatureEvidenceFromReceipts, signatureRecordValid, writeSignatureRecord } from '../plugin/scripts/brain-confirmation.mjs';
35
35
  import {
36
36
  requiredEmbedderModels,
@@ -83,11 +83,15 @@ import {
83
83
  CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
84
84
  } from '../scripts/console-runtime-identity.mjs';
85
85
  import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
86
- import { codexTrustChanges, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
86
+ import { codexPluginContentChanged, codexTrustChanges, codexUsesContentTrust, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
87
87
  import { probeFreshCodexDeclarations } from '../scripts/codex-fresh-host-proof.mjs';
88
+ import { proveCodexBootSurface, parseChangedPaths } from '../scripts/codex-boot-surface-proof.mjs';
89
+ import { refreshSharedCodexAppServer } from '../scripts/codex-hook-trust-reconcile.mjs';
90
+ import { recordCodexOvernightNotice } from '../plugin/scripts/codex-overnight-notice.mjs';
88
91
  import { repairSecurityGuidance } from '../scripts/security-guidance-codex-compat.mjs';
89
92
  import { installNativeLaunchers, installRoutingRuntime, runtimeSnapshot } from '../scripts/model-routing-launchers.mjs';
90
93
  import { installTerminalLaunchers } from '../scripts/model-terminal-launchers.mjs';
94
+ import { addGrokAgentsLine, grokPaths, grokStatus, grokStatusLine, GROK_AGENTS_LINE } from '../scripts/grok-host.mjs';
91
95
  import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
92
96
  import { moveBrain, MoveRefused } from '../scripts/move-brain.mjs';
93
97
  import { brainLocation } from '../plugin/scripts/brain-location.mjs';
@@ -96,6 +100,7 @@ import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
96
100
  import {
97
101
  writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
98
102
  } from '../kb/corpus-release-identity.mjs';
103
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
99
104
 
100
105
  // SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
101
106
  // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
@@ -166,6 +171,7 @@ const FLAG_MOVE_BRAIN = argv.includes('--move-brain');
166
171
  const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; })();
167
172
  const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
168
173
  const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
174
+ const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
169
175
  // ── onboarding-experience flags (all optional; every offer is safe to decline) ──
170
176
  const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
171
177
  const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
@@ -173,6 +179,7 @@ const FLAG_WITH_STACK = argv.includes('--with-stack'); // add missing Ruflo/RuVe
173
179
  const FLAG_NO_STACK = argv.includes('--no-stack'); // skip the toolkit offer entirely
174
180
  const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the CLAUDE.md section without prompting
175
181
  const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
182
+ const FLAG_GROK_AGENTS_MD = argv.includes('--grok-agents-md'); // opt in: add the search-first line to ~/.grok/AGENTS.md (#301)
176
183
  const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
177
184
  const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
178
185
  // --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev); --pin <tag> is the same.
@@ -286,7 +293,8 @@ function download(url, dest) {
286
293
  let lastShown = -1;
287
294
  return downloadFileWithRetry(url, dest, {
288
295
  headers: { 'User-Agent': 'ruvnet-brain-installer', Accept: 'application/octet-stream' },
289
- onRetry: ({ attempt, error }) => warn(`download interrupted (${error.message}); retry ${attempt + 1}/3 from a fresh stage`),
296
+ // The retry continues the staged bytes (HTTP Range) when the server proves the same asset; otherwise it restarts.
297
+ onRetry: ({ attempt, error }) => warn(`download interrupted (${error.message}); retry ${attempt + 1}/3, resuming where it stopped when the server allows`),
290
298
  onProgress: ({ received, total }) => {
291
299
  const mb = Math.floor(received / 1e6);
292
300
  const pct = total ? Math.floor(received / total * 100) : mb;
@@ -550,7 +558,7 @@ function placeFileAtomic(target, bytes) {
550
558
  // one. The signed npm package carries the updater and the sibling modules it imports; the `--update`
551
559
  // preflight places them (same trust as the validator) so a stale updater upgrades itself before it runs.
552
560
  const UPDATER_FILES = ['forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs',
553
- 'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs'];
561
+ 'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs', 'forge-guard-injection.mjs'];
554
562
  export function placeUpdater(kbDir, { sourceDir = path.join(REPO_ROOT, 'kb') } = {}) {
555
563
  const placed = {};
556
564
  for (const name of UPDATER_FILES) {
@@ -804,6 +812,10 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
804
812
  const proof = kbCopyProof({ copyDir: preservedDir, liveDir: cacheDir });
805
813
  if (proof.disposable) {
806
814
  try {
815
+ // Its logs and ruflo scratch are copied out and verified first; a failed rescue keeps the copy. The
816
+ // rescue root is the sweep's (footprintRoots: links resolved, as after --move-brain), so the inventory
817
+ // finds and bounds kb-copy-rescued/ wherever the installer put it.
818
+ rescueOperationalFiles(preservedDir, proof.rescue, footprintRoots().brainHome);
807
819
  fs.rmSync(preservedDir, { recursive: true, force: true });
808
820
  ok(`released the prior generation (${proof.reason})`);
809
821
  priorGeneration = { status: 'RELEASED', path: preservedDir, reason: proof.reason };
@@ -1380,6 +1392,40 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
1380
1392
  }
1381
1393
  }
1382
1394
 
1395
+ // #420 item 4: Claude Code also records per-project installs (scope "local"/"project", keyed by projectPath), and --update
1396
+ // moves only the user row above, so a project row keeps running its old generation. `claude plugin update --scope local`
1397
+ // acts on the project in its cwd, i.e. on someone's repo: REPORTED with the exact command, never run for them. A row whose
1398
+ // projectPath is gone cannot be running anything; it is listed, never counted as behind and never given a `cd` that fails.
1399
+ export function claudeProjectInstalls({ home = os.homedir(), referenceVersion = null } = {}) {
1400
+ let rows;
1401
+ try { rows = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8'))?.plugins?.['ruvnet-brain@ruvnet-brain']; } catch { return []; }
1402
+ if (!Array.isArray(rows)) return [];
1403
+ return rows.filter((row) => (row?.scope === 'local' || row?.scope === 'project') && typeof row.projectPath === 'string' && row.projectPath).map((row) => {
1404
+ let version = null;
1405
+ try { version = JSON.parse(fs.readFileSync(path.join(row.installPath, '.claude-plugin', 'plugin.json'), 'utf8'))?.version || null; } catch { /* registry metadata below */ }
1406
+ version ||= typeof row.version === 'string' ? row.version : null;
1407
+ let exists = false;
1408
+ try { exists = fs.statSync(row.projectPath).isDirectory(); } catch { /* gone */ }
1409
+ return { scope: row.scope, projectPath: row.projectPath, version, exists,
1410
+ behind: exists && Boolean(referenceVersion) && !versionSatisfies(version, referenceVersion),
1411
+ command: exists ? `cd '${row.projectPath.replace(/'/g, `'\\''`)}' && claude plugin update ruvnet-brain@ruvnet-brain --scope ${row.scope}` : null };
1412
+ });
1413
+ }
1414
+
1415
+ /** The doctor's advisory line for claudeProjectInstalls(): '!' while any existing project lags, never a ✗ (it is the user's project). */
1416
+ export function projectInstallsCheck(projects, referenceVersion) {
1417
+ if (!projects.length) return null;
1418
+ const behind = projects.filter((p) => p.behind);
1419
+ const gone = projects.filter((p) => !p.exists);
1420
+ const named = (p) => `${p.projectPath} (${p.scope}, ${p.version || 'unknown'})`;
1421
+ const detail = behind.length
1422
+ ? `${behind.length} per-project Claude Code install(s) behind ${referenceVersion}: ${behind.map(named).join(', ')}`
1423
+ : `${projects.length - gone.length} per-project Claude Code install(s) at ${referenceVersion} or newer`;
1424
+ return { id: 'project-installs', label: 'Projects', state: behind.length ? 'warn' : 'ok',
1425
+ detail: `${detail}${gone.length ? `; ${gone.length} registered for a folder that no longer exists: ${gone.map(named).join(', ')}` : ''}`,
1426
+ fix: behind.length ? behind.map((p) => p.command).join(' ; ') : null };
1427
+ }
1428
+
1383
1429
  // A plugin version can change without changing anything a running host freezes at boot. Compare
1384
1430
  // the actual installed payload with this candidate before updating it so body-only releases do not
1385
1431
  // manufacture a restart requirement. Unknown paths fail closed: if we cannot prove the boundary,
@@ -1432,7 +1478,7 @@ function codexInstalledPluginRoot({ codexHome = codexHomeDir(), status } = {}) {
1432
1478
  function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
1433
1479
  step(
1434
1480
  'Wiring the Claude Code plugin',
1435
- 'this registers search_ruvnet, native skills, and slash commands without automatic lifecycle hooks',
1481
+ `this registers search_ruvnet, native skills, slash commands, and the allowlisted lifecycle hooks (${shippedHookHandlerCounts().claude ?? 'unknown number of'} handlers)`,
1436
1482
  );
1437
1483
  const marketplaceSource = process.env.RUVNET_CLAUDE_MARKETPLACE_SOURCE || 'stuinfla/ruvnet-brain';
1438
1484
  const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
@@ -1484,6 +1530,11 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1484
1530
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1485
1531
  }
1486
1532
  info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rnbc')} (also /rnb, /rvbc), ${c.bold('/ruvnet-brain:configure')}`);
1533
+ // #420 item 4: the user row moved; per-project rows did not. Name each lagging one with its own command (never run here).
1534
+ const projectsBehind = claudeProjectInstalls({ referenceVersion: installed.version }).filter((p) => p.behind);
1535
+ for (const p of projectsBehind) {
1536
+ warn(`per-project install still on ${p.version || 'an unknown version'} (${p.scope} scope, ${p.projectPath}); --update moves only the user install. To update it: ${c.bold(p.command)}`);
1537
+ }
1487
1538
  return {
1488
1539
  host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
1489
1540
  shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
@@ -1973,9 +2024,20 @@ const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
1973
2024
  * [{ key, status: 'modified'|'untrusted' }]. No installed root = a fresh install = every hook untrusted.
1974
2025
  * An unreadable file is treated as absent (fail toward telling the user to review, never toward silence).
1975
2026
  */
1976
- export function codexHooksNeedingReview(installedRoot, candidateRoot) {
2027
+ export function codexHooksNeedingReview(installedRoot, candidateRoot, { contentTrust = false } = {}) {
1977
2028
  const read = (root) => { try { return root ? JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'codex-hooks.json'), 'utf8')) : {}; } catch { return {}; } };
1978
- return codexTrustChanges(read(installedRoot), read(candidateRoot));
2029
+ // Codex 0.164+ (or a version we could not read) also holds back every hook whose plugin bytes changed (#492).
2030
+ const contentChanged = contentTrust !== false && codexPluginContentChanged(installedRoot, candidateRoot);
2031
+ return codexTrustChanges(read(installedRoot), read(candidateRoot), undefined, { contentChanged });
2032
+ }
2033
+
2034
+ /** codex --version → whether it approves plugin hooks by content too (true/false), or null when unknown. */
2035
+ export function codexHostContentTrust({ codexBin = process.env.CODEX_BIN || 'codex', codexHome = codexHomeDir(), run = runHostCli } = {}) {
2036
+ try {
2037
+ const r = run(codexBin, ['--version'], { stdio: 'pipe', shell: false, encoding: 'utf8', timeout: 10_000,
2038
+ env: { ...process.env, CODEX_HOME: codexHome } });
2039
+ return r?.status === 0 ? codexUsesContentTrust(r.stdout) : null;
2040
+ } catch { return null; }
1979
2041
  }
1980
2042
 
1981
2043
  function codexMarketplaceTarget() {
@@ -2064,7 +2126,7 @@ export function runCodexJson(args, {
2064
2126
  }
2065
2127
  if (repaired.length) r = run(codexBin, args, options);
2066
2128
  }
2067
- if (r.missingBinary) return { ok: false, error: r.message };
2129
+ if (r.missingBinary) return { ok: false, error: r.message, missingBinary: true };
2068
2130
  if (r.error || r.status !== 0) {
2069
2131
  let detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
2070
2132
  if (snapshotFailure(r)) {
@@ -2082,7 +2144,8 @@ export function runCodexJson(args, {
2082
2144
  export function codexPluginStatus(options = {}) {
2083
2145
  const { runJson = runCodexJson, ...commandOptions } = options;
2084
2146
  const listed = runJson(['plugin', 'list', '--json'], commandOptions);
2085
- if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error };
2147
+ if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error,
2148
+ ...(listed.missingBinary ? { missingBinary: true } : {}) };
2086
2149
  const rows = Array.isArray(listed.value?.installed) ? listed.value.installed : [];
2087
2150
  const row = rows.find((candidate) => candidate?.pluginId === CODEX_PLUGIN_ID);
2088
2151
  return {
@@ -2131,6 +2194,8 @@ export function wireCodexPlugin({
2131
2194
  announce = true,
2132
2195
  runJson = runCodexJson,
2133
2196
  aliasHome,
2197
+ // true/false/null (unknown): does this Codex pin plugin contents in hook approval (0.164+)? Probed for a real host.
2198
+ contentTrust,
2134
2199
  } = {}) {
2135
2200
  if (!fs.existsSync(codexDir)) return { host: false, action: 'no-host' };
2136
2201
  const options = { codexBin, codexHome, cwd, repair: true };
@@ -2169,11 +2234,6 @@ export function wireCodexPlugin({
2169
2234
  path.join(REPO_ROOT, 'plugin'),
2170
2235
  )
2171
2236
  : { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
2172
- // Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
2173
- // trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
2174
- // a boot-surface path, so the shell boundary above cannot see it. Computed from the two files, offline.
2175
- const hooksNeedingReview = codexHooksNeedingReview(before.installed
2176
- ? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'));
2177
2237
  if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
2178
2238
  const hookTrust = runJson === runCodexJson ? repairReleasedCodexHookTrust(before, options) : null;
2179
2239
  if (announce) {
@@ -2187,6 +2247,13 @@ export function wireCodexPlugin({
2187
2247
  };
2188
2248
  }
2189
2249
 
2250
+ // Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
2251
+ // trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
2252
+ // a boot-surface path, so the shell boundary above cannot see it. Computed offline, before the install
2253
+ // replaces the installed folder; on Codex 0.164+ a change to any plugin byte holds back every hook too (#492).
2254
+ const hooksNeedingReview = codexHooksNeedingReview(before.installed
2255
+ ? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'),
2256
+ { contentTrust: contentTrust ?? (runJson === runCodexJson ? codexHostContentTrust({ codexBin, codexHome }) : false) });
2190
2257
  if (runJson === runCodexJson && !localMarketplace) {
2191
2258
  try { localMarketplace = prepareCodexMarketplace(); }
2192
2259
  catch (error) {
@@ -2236,7 +2303,9 @@ export function wireCodexPlugin({
2236
2303
  }
2237
2304
  if (hooksNeedingReview.length && !trustVerified) {
2238
2305
  warn(`Codex will NOT run ${hooksNeedingReview.length} Brain hook${hooksNeedingReview.length === 1 ? '' : 's'} until you review`
2239
- + ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`);
2306
+ + ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`
2307
+ + (hooksNeedingReview.some((h) => h.reason === 'plugin-content')
2308
+ ? ' This update changed the plugin\'s files, and Codex 0.164+ approves those bytes as well as each hook\'s definition.' : ''));
2240
2309
  info(` ${CODEX_TRUST_ACTION}`);
2241
2310
  }
2242
2311
  }
@@ -2260,6 +2329,38 @@ export function wireCodexPlugin({
2260
2329
  };
2261
2330
  }
2262
2331
 
2332
+ /**
2333
+ * After the Brain changed Codex's view of its plugin (new bytes, or hook trust written from another process), tell an
2334
+ * already-running shared Codex app-server to reload, so its open sessions stop reporting the previous hooks (#492).
2335
+ * Never starts, stops or restarts a server, and never grants an approval (scripts/codex-hook-trust-reconcile.mjs).
2336
+ */
2337
+ export const codexHostViewChanged = (codexPlugin) => ['installed', 'updated'].includes(codexPlugin?.action)
2338
+ || codexPlugin?.hookTrust?.changed === true || codexPlugin?.hookTrust?.changed === 'unknown';
2339
+ export async function refreshCodexSharedHost(codexPlugin, { codexHome = codexHomeDir(), refresh = refreshSharedCodexAppServer } = {}) {
2340
+ if (!codexHostViewChanged(codexPlugin)) return { state: 'not-needed', changed: false };
2341
+ return refresh({ codexHome });
2342
+ }
2343
+
2344
+ /**
2345
+ * What the owner is told after refreshCodexSharedHost. The reload reaches ALREADY-OPEN Codex sessions at once, and
2346
+ * changed Brain hook definitions are pending until reviewed, so those sessions stop running Brain hooks until the
2347
+ * owner re-reviews them in /hooks. Said plainly, so a pause in a session he had open is never a surprise (#492).
2348
+ */
2349
+ export function codexHostRefreshMessage(hostRefresh, codexPlugin = null) {
2350
+ if (hostRefresh?.state === 'refreshed') {
2351
+ // Review r2 (4.7.21): the authorized trust reconciliation may already have re-trusted the released hooks
2352
+ // (registry-verified, or nothing pending); then nothing pauses and saying so would be false.
2353
+ const retrusted = ['registry-verified', 'unchanged'].includes(codexPlugin?.hookTrust?.state);
2354
+ return { level: 'info', text: ' Running Codex app-server reloaded: Codex sessions that are already open pick up the change immediately; '
2355
+ + (retrusted ? 'Brain hooks were already re-trusted, so they keep running there (nothing restarted).'
2356
+ : 'Brain hooks pause in them until you re-review them in /hooks (no approval granted, nothing restarted).') };
2357
+ }
2358
+ if (hostRefresh?.state === 'failed') {
2359
+ return { level: 'warn', text: `Running Codex app-server not reloaded (${hostRefresh.reason}); its open sessions may report the previous Brain hooks until Codex restarts.` };
2360
+ }
2361
+ return null;
2362
+ }
2363
+
2263
2364
  export function codexSessionSafety(status) {
2264
2365
  if (status?.restartRequired === true) return status;
2265
2366
  return {
@@ -2547,6 +2648,61 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
2547
2648
  return { removed, changedFiles, conflicts };
2548
2649
  }
2549
2650
 
2651
+ /** Grok's state for --doctor and the install summary (#301): reported, never wired automatically. */
2652
+ function currentGrokStatus() {
2653
+ return grokStatus({ pluginInstalled: Boolean(pluginCommandsDir()), hookCount: shippedHookHandlerCounts().grok });
2654
+ }
2655
+ function grokCheckLines(check) {
2656
+ const status = currentGrokStatus();
2657
+ const line = grokStatusLine(status);
2658
+ if (!line) return [];
2659
+ // Advisory only when the Brain cannot reach Grok at all; the AGENTS.md line is the user's choice.
2660
+ return [check('grok', 'Grok', !status.pluginInstalled, line, 'npx ruvnet-brain', { advisory: true })];
2661
+ }
2662
+
2663
+ /** Opt-in, never automatic: offer the search-first line when Grok is here and the line is not. */
2664
+ async function offerGrokAgentsMd() {
2665
+ const status = currentGrokStatus();
2666
+ if (!status.present || status.groundingLine || !status.pluginInstalled) return;
2667
+ step('Optional — a search-first line for Grok', 'Grok drops prompt-time context, so this line is what makes it ground itself');
2668
+ info(`Adds ${c.bold('one line')} (between ruvnet-brain markers) to the bottom of ${c.bold(status.agentsFile)}:`);
2669
+ info(c.dim(` ${GROK_AGENTS_LINE}`));
2670
+ info('Backed up first; --uninstall removes it.');
2671
+ const yes = FLAG_GROK_AGENTS_MD || (await ask('Add it?', false, { blanketYes: false }));
2672
+ if (!yes) { info(`Skipped — add it any time with ${c.bold('npx ruvnet-brain --grok-agents-md')}`); return; }
2673
+ reportGrokAgentsLine(addGrokAgentsLine());
2674
+ }
2675
+ function reportGrokAgentsLine(result) {
2676
+ if (result === 'added') ok(`added the search-first line to ${grokPaths().agents}`);
2677
+ else if (result === 'present') ok('~/.grok/AGENTS.md already has a search_ruvnet line — left unchanged');
2678
+ else if (result === 'no-grok') info('Grok CLI not detected (~/.grok/bin/grok) — nothing changed');
2679
+ else warn('could not update ~/.grok/AGENTS.md — your file is untouched');
2680
+ }
2681
+
2682
+ /**
2683
+ * How many lifecycle handlers each SHIPPED host manifest registers, counted from the manifests
2684
+ * themselves so help and progress text can never again claim "zero" while they carry handlers
2685
+ * (#400). A missing or unreadable manifest is null ("unknown"), never 0.
2686
+ */
2687
+ export function shippedHookHandlerCounts(root = REPO_ROOT) {
2688
+ const count = (relative) => {
2689
+ try {
2690
+ const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
2691
+ return Object.values(doc?.hooks || {}).flat().reduce((n, group) => n + (Array.isArray(group?.hooks) ? group.hooks.length : 0), 0);
2692
+ } catch { return null; }
2693
+ };
2694
+ return {
2695
+ claude: count('plugin/hooks/hooks.json'),
2696
+ codex: count('plugin/hooks/codex-hooks.json'),
2697
+ grok: count('plugin/hooks/grok-hooks.json'),
2698
+ };
2699
+ }
2700
+
2701
+ function hookCountPhrase({ claude, codex } = shippedHookHandlerCounts()) {
2702
+ const n = (v) => (Number.isInteger(v) ? String(v) : 'an unknown number of');
2703
+ return `${n(claude)} Claude Code, ${n(codex)} Codex handlers`;
2704
+ }
2705
+
2550
2706
  export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'source' } = {}) {
2551
2707
  if (!['source', 'installed'].includes(scope)) throw new Error('invalid hook retirement scope');
2552
2708
  const files = [
@@ -3189,8 +3345,15 @@ async function doctorRun({ json }) {
3189
3345
  const binary = codexAdministrativeProbeBinary();
3190
3346
  const fresh = await probeFreshCodexDeclarations({ binary, codexHome: codexHomeDir(), cwd: process.cwd(),
3191
3347
  releasedPluginRoot: path.join(REPO_ROOT, 'plugin'), expectedVersion: PACKAGE_VERSION });
3192
- hostConvergence = reconcileFreshCodexDeclarations(recorded, fresh);
3193
- if (fresh.ok) info('Codex fresh-declarations-ready: current trusted hook declarations only; hook bodies and MCP execution were not tested. Existing open windows remain unproven.');
3348
+ // Issue #391: a receipt that names the hook shim, MCP server or skills/ as changed is proven path by path
3349
+ // (scripts/codex-boot-surface-proof.mjs). Doctor only READS: the receipt on disk is never rewritten here.
3350
+ const changedPaths = parseChangedPaths(codex.sessionSafetyReason);
3351
+ const needsBootProof = changedPaths.some((file) => !['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file));
3352
+ const bootSurface = needsBootProof ? await proveCodexBootSurfaceHere(changedPaths, fresh) : null;
3353
+ hostConvergence = reconcileFreshCodexDeclarations(recorded, fresh, PACKAGE_VERSION, bootSurface);
3354
+ if (bootSurface?.ok) info(`Codex boot-surface-ready: ${bootSurface.proven.map((p) => p.path).join(', ')} proven for NEW sessions (real shim run, real MCP initialize + tools/list). Existing open windows remain unproven; a host relaunch was not observed.`);
3355
+ else if (bootSurface) warn(`Codex boot surface remains unproven: ${bootSurface.reason}`);
3356
+ else if (fresh.ok) info('Codex fresh-declarations-ready: current trusted hook declarations only; hook bodies and MCP execution were not tested. Existing open windows remain unproven.');
3194
3357
  else warn(`Codex fresh declarations remain unproven: ${fresh.reason}`);
3195
3358
  }
3196
3359
  if (hostConvergence.healthy) {
@@ -3397,8 +3560,9 @@ async function doctorRun({ json }) {
3397
3560
  const turnLine = { id: 'turn-recording', label: 'Turn recording', state: turnStatus.state, detail: turnStatus.line, fix: null };
3398
3561
 
3399
3562
  // ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
3400
- // `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
3401
- // never execute dormant hook bodies.
3563
+ // `--hooks` is a read-only proof that every registration in the shipped manifests is on the
3564
+ // continuity allowlist (#400: it is not, and never was, a zero-registration proof). It must never
3565
+ // execute hook bodies.
3402
3566
  let hookResult = null;
3403
3567
  if (FLAG_HOOKS) {
3404
3568
  const retirement = automaticHookRetirementStatus(REPO_ROOT, { scope: 'installed' });
@@ -3460,6 +3624,9 @@ async function doctorRun({ json }) {
3460
3624
  const nightlyFailed = nightlyHealth.state === 'degraded'
3461
3625
  || (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state));
3462
3626
  const codexFailed = codexWiringFailed || codexReadinessFailed || (codexLifecycleFailed && !codexTrustBypassed);
3627
+ // #420 item 4: per-project Claude installs lag silently behind the user one; advisory, with each project's own command.
3628
+ const projectReference = claudePluginStatus().version || PACKAGE_VERSION;
3629
+ const projectLine = projectInstallsCheck(claudeProjectInstalls({ referenceVersion: projectReference }), projectReference);
3463
3630
  const checks = [
3464
3631
  // With --hooks the install reading was never part of the verdict (release install verification); keep that.
3465
3632
  check('install', 'Install', !allGreen, `${v.repos} store(s), reader ${v.reader ? 'present' : 'MISSING'}, search server ${v.mcp ? 'present' : 'MISSING'}`,
@@ -3471,13 +3638,15 @@ async function doctorRun({ json }) {
3471
3638
  ...(cx.host ? [check('codex', 'Codex', codexFailed, codexFailed ? (codexWiringFailed ? 'host detected but NOT wired'
3472
3639
  : codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
3473
3640
  check('nightly', 'Nightly', nightlyFailed, `${nightlyHealth.state}${nightlyHealth.runHealth?.state ? `, last run ${nightlyHealth.runHealth.state}` : ''}`,
3474
- nightlyHealth.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update'),
3641
+ nightlyFix(nightlyHealth)),
3475
3642
  // Scheduler ON but running yesterday's code: an advisory naming the exact fix, never a failing verdict.
3476
3643
  ...(nightlyHealth.state === 'on' && nightlyCode.state !== 'unregistered' ? [check('nightly-code', 'Nightly code', nightlyCode.state !== 'current',
3477
3644
  nightlyCode.state === 'current' ? 'registered update modules match the installed set' : `${nightlyCode.state}: registered ${String(nightlyCode.registered || '?').slice(0, 12)}, installed ${String(nightlyCode.current || '?').slice(0, 12)}`,
3478
3645
  nightlyCode.fix || 'npx ruvnet-brain@latest --update', { advisory: true })] : []),
3479
3646
  check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
3647
+ ...grokCheckLines(check),
3480
3648
  ...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
3649
+ ...(projectLine ? [projectLine] : []),
3481
3650
  ...(agentdbLine ? [agentdbLine] : []),
3482
3651
  turnLine,
3483
3652
  ];
@@ -3754,6 +3923,17 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
3754
3923
  // failure mode for a stranger's first contact. With the variable unset, behavior is unchanged.
3755
3924
  const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
3756
3925
 
3926
+ /**
3927
+ * The doctor's fix for the Nightly line. Re-enabling repairs a job that cannot fire (not loaded, command drift);
3928
+ * a job that is loaded, verified, FIRED and exited non-zero (`lastExitCode`) is a failed update run, and
3929
+ * re-enabling it changes nothing — the update itself is the fix (4.7.21).
3930
+ */
3931
+ export function nightlyFix(nightlyHealth) {
3932
+ const fired = Number.isInteger(nightlyHealth?.lastExitCode) && nightlyHealth.lastExitCode !== 0;
3933
+ if (fired) return 'npx ruvnet-brain@latest --update';
3934
+ return nightlyHealth?.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update';
3935
+ }
3936
+
3757
3937
  export function classifyUpdaterExit(status, { fallbackAllowed = true, result = null, requireResult = false } = {}) {
3758
3938
  if (status === 12 && result?.terminalVerdict === 'cleanup-pending') {
3759
3939
  return { verdict: 'cleanup-pending', fallback: false, exitCode: 12 };
@@ -3783,8 +3963,26 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3783
3963
  // Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
3784
3964
  // bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
3785
3965
  // reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
3786
- if (status === 2 && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
3787
- return { verdict: 'transient-network', fallback: false, exitCode: 2 };
3966
+ // Exit 3 is the same transport failure one request later: the bundle downloaded, then its 64-byte .sig hit a
3967
+ // 5xx or a timeout. Nothing was touched, and a fallback would re-download the whole ~745 MB bundle. A 404 on
3968
+ // the .sig (a release published without one) is not transient and keeps the fallback.
3969
+ if ((status === 2 || status === 3) && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
3970
+ return { verdict: 'transient-network', fallback: false, exitCode: status };
3971
+ }
3972
+ // Neither of these is a broken updater, so a fresh reinstall cannot fix them; it only re-downloads ~745 MB
3973
+ // into the same wall, every night (4.7.21, audit C: a `.prune-*` left by a killed run made retention
3974
+ // `unsafe`, which forge-update reported as recovery-required, and the fallback reinstall hit it again).
3975
+ // - recovery-required: the updater stopped on evidence it must not discard; it names the recovery.
3976
+ // - exit 5: the offered corpus is incompatible with (or was already refused by) the runtime this run has
3977
+ // just re-stamped (ensureUpdaterPrerequisites), and a reinstall of the same package stamps the same one.
3978
+ if (result?.terminalVerdict === 'recovery-required') {
3979
+ return { verdict: 'recovery-required', fallback: false, exitCode: status || 1 };
3980
+ }
3981
+ if (status === 5) return { verdict: 'refused-incompatible', fallback: false, exitCode: 5 };
3982
+ // The updater was stopped by a signal (its own TTL, a logout, Ctrl-C) and settled itself as `interrupted`.
3983
+ // Nothing is broken, so a fresh reinstall would only re-download ~745 MB into the same interruption.
3984
+ if (result?.terminalVerdict === 'interrupted' || status === 143 || status === 130) {
3985
+ return { verdict: 'interrupted', fallback: false, exitCode: status || 143 };
3788
3986
  }
3789
3987
  return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
3790
3988
  }
@@ -3919,7 +4117,12 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3919
4117
  return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
3920
4118
  }
3921
4119
  results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
3922
- if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4120
+ // ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
4121
+ // cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
4122
+ // audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
4123
+ if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
4124
+ results.codexAbsent = true;
4125
+ } else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
3923
4126
  return fail();
3924
4127
  }
3925
4128
  }
@@ -3933,7 +4136,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3933
4136
  const applied = runStableSpine(apply);
3934
4137
  const okApplied = !applied.error && applied.status === 0;
3935
4138
  const codexReceipt = {
3936
- state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'),
4139
+ state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
3937
4140
  version: results.codex?.version || null,
3938
4141
  };
3939
4142
  if (results.codex?.restartRequired === true) {
@@ -4019,20 +4222,123 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4019
4222
  }
4020
4223
  }
4021
4224
  }
4022
- if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
4023
- const convergence = classifyHostConvergence({
4225
+ // update-apply exits 75 when another live run holds the spine lock: nothing was applied, so it is not success.
4226
+ if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || (applied.status === 75
4227
+ ? 'Stable Spine is busy (another update holds its lock); nothing was applied — re-run when it finishes'
4228
+ : 'Stable Spine activation failed') });
4229
+ const convergedReceipt = {
4024
4230
  desiredVersion: PACKAGE_VERSION,
4025
4231
  hosts: {
4026
4232
  claude: claudeReceipt,
4027
4233
  codex: codexReceipt,
4028
4234
  },
4029
4235
  consoleRuntime: results.consoleRuntime,
4030
- });
4236
+ };
4237
+ const convergence = classifyHostConvergence(convergedReceipt);
4031
4238
  hostLock.release();
4032
4239
  return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
4240
+ ...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
4033
4241
  error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
4034
4242
  }
4035
4243
 
4244
+ /**
4245
+ * The ONE host-convergence entry for `--update` and `--host-sync-only`: syncHostsAfterUpdate, then the #492 reload of a
4246
+ * running shared Codex app-server when the Codex plugin or its hook trust changed (fresh installs reload it in the
4247
+ * main flow; before this, every existing user's `--update` skipped it — cross-vendor review E). The reload never
4248
+ * grants an approval and never starts, stops or restarts a server (refreshSharedCodexAppServer).
4249
+ */
4250
+ export async function convergeHostsAfterUpdate(cacheDir = resolvedKbDir(), { refreshHost = refreshCodexSharedHost,
4251
+ unattended = process.env.RUVNET_NIGHTLY === '1', recordNotice = recordCodexOvernightNotice,
4252
+ noticeHome = () => footprintRoots().brainHome, proveBootSurface = proveCodexBootSurfaceDefault, ...options } = {}) {
4253
+ const brainHome = options.brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
4254
+ const receiptPath = path.join(brainHome, 'host-convergence.json');
4255
+ const readReceipt = () => { try { return JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch { return null; } };
4256
+ const prior = readReceipt();
4257
+ let convergence = syncHostsAfterUpdate(cacheDir, options);
4258
+ if (!convergence.results?.codex) return convergence;
4259
+ convergence = await recordCodexBootSurface(convergence, { prior, receiptPath, readReceipt, proveBootSurface,
4260
+ hostLockPath: options.hostLockPath || path.join(brainHome, 'host-convergence.lock') });
4261
+ // Owner decision (4.7.21 review r2): the unattended nightly never reloads the shared Codex app-server; a reload
4262
+ // pauses Brain hooks in sessions already open until someone re-reviews them in /hooks. It leaves a notice the next
4263
+ // SessionStart shows once instead. An interactive --update still reloads.
4264
+ if (unattended) {
4265
+ if (!codexHostViewChanged(convergence.results.codex)) return { ...convergence, hostRefresh: { state: 'not-needed', changed: false } };
4266
+ const recorded = recordNotice(noticeHome());
4267
+ return { ...convergence, hostRefresh: { state: 'deferred-unattended', changed: false, noticeRecorded: recorded } };
4268
+ }
4269
+ let hostRefresh;
4270
+ try { hostRefresh = await refreshHost(convergence.results.codex); }
4271
+ catch (error) { hostRefresh = { state: 'failed', changed: false, reason: error?.message || String(error) }; }
4272
+ const note = codexHostRefreshMessage(hostRefresh, convergence.results.codex);
4273
+ if (note) (note.level === 'warn' ? warn : info)(note.text);
4274
+ return { ...convergence, hostRefresh };
4275
+ }
4276
+
4277
+ /**
4278
+ * Issue #391: when Codex still carries a boot-level restart request (a fresh one from this sync, or the previous
4279
+ * receipt's that a repeat sync would otherwise silently drop), prove each changed path and write a NEW receipt:
4280
+ * restartScope 'open-sessions' only when every path is proven, otherwise the request stays 'unproven' with the reason.
4281
+ * A historical receipt is never edited in place; the new one is the atomic replacement the sync itself writes.
4282
+ */
4283
+ async function recordCodexBootSurface(convergence, { prior, receiptPath, readReceipt, proveBootSurface, hostLockPath }) {
4284
+ if (!convergence.ok && convergence.restartOnly !== true) return convergence;
4285
+ const current = readReceipt();
4286
+ const codex = current?.hosts?.codex;
4287
+ if (!current || current.desiredVersion !== PACKAGE_VERSION || codex?.state !== 'ready') return convergence;
4288
+ const priorCodex = prior?.desiredVersion === PACKAGE_VERSION ? prior.hosts?.codex : null;
4289
+ const fresh = codex.restartRequired === true && codex.restartScope === 'unproven';
4290
+ const carried = codex.restartRequired !== true && priorCodex?.state === 'ready' && priorCodex.restartRequired === true
4291
+ && priorCodex.restartScope === 'unproven' && convergence.results.codex.action === 'unchanged';
4292
+ if (!fresh && !carried) return convergence;
4293
+ const reason = fresh ? codex.sessionSafetyReason : priorCodex.sessionSafetyReason;
4294
+ const changedPaths = parseChangedPaths(reason);
4295
+ let proof;
4296
+ try { proof = await proveBootSurface({ changedPaths }); }
4297
+ catch (error) { proof = { ok: false, state: 'boot-surface-unproven', changed: changedPaths, proven: [], unproven: [], reason: error?.message || String(error),
4298
+ existingWindows: 'unproven', hostRelaunch: 'not-observed' }; }
4299
+ const ready = proof?.ok === true && proof.state === 'boot-surface-ready';
4300
+ const next = { ...current, verifiedAt: new Date().toISOString(), hosts: { ...current.hosts, codex: { ...codex,
4301
+ restartRequired: true, sessionSafety: 'restart-required', sessionSafetyReason: reason,
4302
+ restartScope: ready ? 'open-sessions' : 'unproven',
4303
+ bootSurface: { state: proof?.state || 'boot-surface-unproven', changed: proof?.changed || changedPaths,
4304
+ proven: (proof?.proven || []).map((p) => p.path), unproven: proof?.unproven || [], reason: proof?.reason || null,
4305
+ existingWindows: 'unproven', hostRelaunch: 'not-observed' } } } };
4306
+ // The receipt is the sync's own file, so this read-modify-write takes the same lock the sync holds (the proof
4307
+ // above can be slow, so the lock is held only for the write). A receipt that changed while we proved, or a lock
4308
+ // we cannot take, means a newer sync owns it: record nothing and leave the prior, still-truthful receipt.
4309
+ const writeLock = acquireHostConvergenceLock(hostLockPath);
4310
+ if (!writeLock.acquired) return { ...convergence, bootSurface: proof, bootSurfaceNotRecorded: `host synchronization lock unavailable: ${writeLock.error}` };
4311
+ try {
4312
+ const latest = readReceipt();
4313
+ if (latest?.verifiedAt !== current.verifiedAt || latest?.desiredVersion !== current.desiredVersion) {
4314
+ return { ...convergence, bootSurface: proof, bootSurfaceNotRecorded: 'host-convergence receipt changed while the boot surface was proven' };
4315
+ }
4316
+ const tmp = `${receiptPath}.tmp-${process.pid}`;
4317
+ fs.writeFileSync(tmp, JSON.stringify(next, null, ' ')); // two-space indent, same bytes as before
4318
+ fs.renameSync(tmp, receiptPath);
4319
+ } finally { writeLock.release(); }
4320
+ const classified = classifyHostConvergence(next);
4321
+ if (classified.healthy) classified.state = 'boot-surface-ready';
4322
+ const { restartOnly, ...rest } = convergence;
4323
+ return { ...rest, ok: classified.healthy === true, convergence: classified, bootSurface: proof,
4324
+ ...(!classified.healthy && hostRestartIsOnlyGap(next) ? { restartOnly: true } : {}),
4325
+ error: classified.healthy ? null : `host convergence is ${classified.state}: ${classified.action}` };
4326
+ }
4327
+
4328
+ /**
4329
+ * True when a host restart is the ONLY gap in a convergence receipt: every restart-flagged host is ready at the
4330
+ * expected version, and with those hosts read as "already-open windows only" everything else (other hosts, the
4331
+ * Console) is converged. `--update` records that as an advisory and keeps the knowledge update's success; any
4332
+ * other gap behind the restart keeps the failure (4.7.21, audit C: Codex's restartScope is always 'unproven').
4333
+ */
4334
+ export function hostRestartIsOnlyGap(receipt, expectedVersion = PACKAGE_VERSION) {
4335
+ const flagged = Object.values(receipt?.hosts || {}).filter((host) => host?.restartRequired === true);
4336
+ if (!flagged.length || flagged.some((host) => host.state !== 'ready' || !versionSatisfies(host.version, expectedVersion))) return false;
4337
+ const hosts = Object.fromEntries(Object.entries(receipt.hosts).map(([name, host]) => [name,
4338
+ host?.restartRequired === true ? { ...host, restartScope: 'open-sessions' } : host]));
4339
+ return classifyHostConvergence({ ...receipt, hosts }, expectedVersion).healthy === true;
4340
+ }
4341
+
4036
4342
  export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) {
4037
4343
  // Same 'ahead is not behind' rule as host convergence above (#123). A receipt written by a
4038
4344
  // NEWER generation than the installer currently running is not a version mismatch — it is a
@@ -4086,7 +4392,7 @@ export function hostSynchronizationFailureMessage(convergence, active, expectedV
4086
4392
  }
4087
4393
 
4088
4394
  /** Doctor projection only. Fresh hook metadata cannot clear an executable or MCP boot gap. */
4089
- export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion = PACKAGE_VERSION) {
4395
+ export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion = PACKAGE_VERSION, bootSurface = null) {
4090
4396
  const original = classifyHostConvergence(recorded, expectedVersion);
4091
4397
  const codex = recorded?.hosts?.codex;
4092
4398
  if (!proof?.ok || proof.state !== 'fresh-declarations-ready' || proof.expectedVersion !== expectedVersion
@@ -4097,15 +4403,44 @@ export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion
4097
4403
  const prefix = 'boot-level declarations changed: ';
4098
4404
  const reason = codex.sessionSafetyReason;
4099
4405
  const changed = typeof reason === 'string' && reason.startsWith(prefix) ? reason.slice(prefix.length).split(', ') : [];
4100
- if (!changed.length || changed.some((file) => !['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file))) {
4101
- return { ...original, freshDeclarations: proof, notice: 'Fresh Codex hook declarations are ready; executable/MCP boot readiness and existing open windows remain unproven.' };
4406
+ const hookFilesOnly = changed.length > 0 && changed.every((file) => ['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file));
4407
+ // #391: every changed path proven by the boot-surface proof (same list as the receipt names) clears the unproven
4408
+ // scope. The proof's own existingWindows:'unproven' and hostRelaunch:'not-observed' stay on the result.
4409
+ const bootProven = !hookFilesOnly && bootSurface?.ok === true && bootSurface.state === 'boot-surface-ready'
4410
+ && bootSurface.changed?.length === changed.length && changed.every((file) => bootSurface.changed.includes(file));
4411
+ if (!hookFilesOnly && !bootProven) {
4412
+ return { ...original, freshDeclarations: proof, ...(bootSurface ? { bootSurface } : {}),
4413
+ notice: bootSurface?.reason ? `Fresh Codex hook declarations are ready; boot surface unproven — ${bootSurface.reason}. Existing open windows remain unproven.`
4414
+ : 'Fresh Codex hook declarations are ready; executable/MCP boot readiness and existing open windows remain unproven.' };
4102
4415
  }
4103
4416
  const projected = classifyHostConvergence({ ...recorded, hosts: { ...recorded.hosts,
4104
4417
  codex: { ...codex, restartScope: 'open-sessions' } } }, expectedVersion);
4418
+ if (bootProven) {
4419
+ return { ...projected, ...(projected.healthy ? { state: 'boot-surface-ready' } : {}), freshDeclarations: proof, bootSurface,
4420
+ notice: 'Fresh Codex boot surface is proven for new sessions (hook shim run, MCP initialize + tools/list); existing open windows remain unproven and a host relaunch was not observed.' };
4421
+ }
4105
4422
  return { ...projected, ...(projected.healthy ? { state: 'fresh-declarations-ready' } : {}), freshDeclarations: proof,
4106
4423
  notice: 'Fresh Codex hook declarations are ready; hook bodies and MCP execution were not tested. Existing open windows remain unproven.' };
4107
4424
  }
4108
4425
 
4426
+ /** Prove the changed Codex boot paths against the plugin tree Codex loads. Read-only; `declarations` is a fresh-declarations probe. */
4427
+ async function proveCodexBootSurfaceHere(changedPaths, declarations) {
4428
+ const codexHome = codexHomeDir();
4429
+ let installedPluginRoot = declarations?.pluginRoot || null;
4430
+ if (!installedPluginRoot) { try { installedPluginRoot = codexInstalledPluginRoot({ codexHome }); } catch { installedPluginRoot = null; } }
4431
+ return await proveCodexBootSurface({ changedPaths, declarations, installedPluginRoot,
4432
+ releasedPluginRoot: path.join(REPO_ROOT, 'plugin') });
4433
+ }
4434
+
4435
+ async function proveCodexBootSurfaceDefault({ changedPaths }) {
4436
+ let declarations;
4437
+ try {
4438
+ declarations = await probeFreshCodexDeclarations({ binary: codexAdministrativeProbeBinary(), codexHome: codexHomeDir(), cwd: process.cwd(),
4439
+ releasedPluginRoot: path.join(REPO_ROOT, 'plugin'), expectedVersion: PACKAGE_VERSION });
4440
+ } catch (error) { declarations = { ok: false, reason: error?.message || String(error) }; }
4441
+ return await proveCodexBootSurfaceHere(changedPaths, declarations);
4442
+ }
4443
+
4109
4444
  const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
4110
4445
  /** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
4111
4446
  export function openSessionsNotice(hosts, version) {
@@ -4167,7 +4502,7 @@ async function runUpdate() {
4167
4502
  }
4168
4503
  if (FLAG_HOST_SYNC_ONLY) {
4169
4504
  info(c.dim('repairing host shells and managed model catalog without mutating KB bytes…\n'));
4170
- const convergence = syncHostsAfterUpdate(kbDir);
4505
+ const convergence = await convergeHostsAfterUpdate(kbDir);
4171
4506
  if (!convergence.ok) {
4172
4507
  warn(`host synchronization is incomplete${convergence.error ? ` (${convergence.error})` : ''}`);
4173
4508
  process.exitCode = 1;
@@ -4236,6 +4571,36 @@ async function runUpdate() {
4236
4571
  detail: { reason: 'process exited before terminal settlement' } }); } catch { /* retained lock and receipt are recovery evidence */ }
4237
4572
  };
4238
4573
  process.once('exit', exitGuard);
4574
+ // SIGTERM/SIGINT (4.7.21): the SessionStart self-heal kills this process group at its TTL (detach.mjs), and the
4575
+ // default disposition ends Node without an 'exit' event, so the refresh receipt stayed RUNNING and its lock
4576
+ // stayed held until a later run proved the owner dead. Settle FAILED with the signal, release the lock, exit
4577
+ // 128+n. While a child runs under spawnSync the handler cannot run, so the same settlement is applied as soon
4578
+ // as a child is seen to have died of one of these signals (a group kill reaches both).
4579
+ const signalGuard = (signal) => {
4580
+ if (!refreshSettled) {
4581
+ try {
4582
+ settleRefreshRun({ handle: refreshReceipt, lock: refreshLock, status: 'FAILED',
4583
+ detail: { reason: 'signal', signal, phase: 'interrupted', terminalVerdict: 'failed' } });
4584
+ refreshSettled = true;
4585
+ } catch { /* retained lock and receipt are recovery evidence */ }
4586
+ }
4587
+ console.error(`\n${c.red('✗ update interrupted by')} ${signal} — the refresh run was recorded FAILED; nothing is left locked.`);
4588
+ process.exit(128 + (os.constants.signals[signal] || 15));
4589
+ };
4590
+ process.once('SIGTERM', signalGuard);
4591
+ process.once('SIGINT', signalGuard);
4592
+ // The updater now settles its own SIGTERM/SIGINT (kb/forge-update.mjs exits 143/130 with an `interrupted`
4593
+ // result) instead of dying of the signal, so a group kill shows up here as that exit status too.
4594
+ const settleIfSignalled = (child) => {
4595
+ if (['SIGTERM', 'SIGINT'].includes(child?.signal)) signalGuard(child.signal);
4596
+ else if (child?.status === 143) signalGuard('SIGTERM');
4597
+ else if (child?.status === 130) signalGuard('SIGINT');
4598
+ };
4599
+ const detachGuards = () => {
4600
+ process.removeListener('exit', exitGuard);
4601
+ process.removeListener('SIGTERM', signalGuard);
4602
+ process.removeListener('SIGINT', signalGuard);
4603
+ };
4239
4604
  info(`brain dir: ${c.bold(kbDir)}`);
4240
4605
  // RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
4241
4606
  // reasons: (1) a kill between the two directory renames leaves no usable kb/ — the brain sits in a
@@ -4301,7 +4666,9 @@ async function runUpdate() {
4301
4666
  let updaterResult = null;
4302
4667
  try { if (updaterResultFile && fs.existsSync(updaterResultFile)) updaterResult = JSON.parse(fs.readFileSync(updaterResultFile, 'utf8')); }
4303
4668
  catch { updaterResult = null; }
4669
+ // Before settleIfSignalled, which exits: an interrupted run used to leak one ruvnet-update-result-* temp dir.
4304
4670
  if (updaterResultDir) fs.rmSync(updaterResultDir, { recursive: true, force: true });
4671
+ settleIfSignalled(r);
4305
4672
  // FALLBACK (2026-07-17), scoped 2026-07-18 to exists-but-FAILED only. An OLDER bundle whose
4306
4673
  // canonicalManifestUrl points at the dead main/kb/.last-built.json path and 404s (the exact break a
4307
4674
  // real user, Jan Lafko, hit). NEVER leave the user stranded at a 404: re-run THIS installer as a
@@ -4318,7 +4685,7 @@ async function runUpdate() {
4318
4685
  });
4319
4686
  if (outcome.verdict === 'refused-retained-copies') {
4320
4687
  warn('nothing was changed: full copies of earlier brain generations already sit beside this one (listed above).');
4321
- info('Check that you no longer need them, remove them, then re-run npx ruvnet-brain --update');
4688
+ info('Run npx ruvnet-brain --clean --archive (keeps only the files they alone hold, in kb.archive/), then re-run npx ruvnet-brain --update');
4322
4689
  }
4323
4690
  if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
4324
4691
  // Host synchronization has a narrower contract than a full update: it must converge the
@@ -4370,6 +4737,7 @@ async function runUpdate() {
4370
4737
  fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
4371
4738
  env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
4372
4739
  }
4740
+ settleIfSignalled(fr);
4373
4741
  updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
4374
4742
  } else {
4375
4743
  updateStatus = outcome.exitCode;
@@ -4380,8 +4748,9 @@ async function runUpdate() {
4380
4748
  updaterExit, finalExit: updateStatus, fallback: outcome.fallback, verdict: outcome.verdict,
4381
4749
  updateResult: updaterResult,
4382
4750
  });
4383
- settleRefresh(updateStatus, { phase: 'source-enumeration', terminalVerdict: 'failed' });
4384
- process.removeListener('exit', exitGuard);
4751
+ settleRefresh(updateStatus, { phase: 'source-enumeration',
4752
+ terminalVerdict: outcome.verdict === 'recovery-required' ? 'recovery-required' : 'failed' });
4753
+ detachGuards();
4385
4754
  return;
4386
4755
  }
4387
4756
  if (cleanupPending) updateStatus = 0;
@@ -4419,7 +4788,7 @@ async function runUpdate() {
4419
4788
  recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { failures: installed.failures,
4420
4789
  reason: 'legacy updater returned success without a valid installed ReleaseCoverage projection' });
4421
4790
  settleRefresh(1, { phase: 'source-enumeration', terminalVerdict: 'failed' });
4422
- process.removeListener('exit', exitGuard);
4791
+ detachGuards();
4423
4792
  return;
4424
4793
  }
4425
4794
  phaseEvidence = Object.fromEntries(UPDATE_REFRESH_PHASES.map((phase) => [phase, {
@@ -4433,26 +4802,76 @@ async function runUpdate() {
4433
4802
  if (!phaseEvidence[phase]) {
4434
4803
  recordRefreshPhase(refreshReceipt, phase, 'FAIL', { reason: `updater omitted ${phase} evidence` });
4435
4804
  settleRefresh(1, { phase, terminalVerdict: 'failed' });
4436
- process.removeListener('exit', exitGuard);
4805
+ detachGuards();
4437
4806
  return;
4438
4807
  }
4439
4808
  recordRefreshPhase(refreshReceipt, phase, 'PASS', phaseEvidence[phase]);
4440
4809
  }
4441
- if (updateStatus === 0) {
4442
- info(c.dim('\nsynchronizing every detected host to this exact published version…\n'));
4443
- const convergence = syncHostsAfterUpdate(kbDir);
4444
- if (!convergence.ok) {
4445
- let active;
4446
- try { active = JSON.parse(fs.readFileSync(path.join(footprintRoots().brainHome, 'active.json'), 'utf8')); } catch { /* report unknown activation */ }
4447
- warn(hostSynchronizationFailureMessage(convergence, active));
4448
- updateStatus = 1;
4449
- } else if (convergence.convergence?.notice) info(convergence.convergence.notice);
4450
- recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
4451
- state: convergence.convergence?.state || null, error: convergence.error || null,
4452
- ...(convergence.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
4453
- execution: { kind: 'executed', runId: refreshReceipt.runId },
4454
- });
4810
+ const finished = await finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult, cleanupPending,
4811
+ settle: settleRefresh, converge: async () => await convergeHostsAfterUpdate(kbDir) });
4812
+ detachGuards();
4813
+ await printConfirmation({ footprint: finished.footprint?.after || null });
4814
+ }
4815
+
4816
+ /**
4817
+ * What host convergence means for an `--update` whose knowledge phases all PASSED (the shared 4.7.21 exit
4818
+ * contract). Converged: PASS. A host restart as the ONLY gap (Codex reports restartScope 'unproven' on every
4819
+ * boot-level change): the knowledge update stands, exit 0, and the phase is recorded required:false with
4820
+ * restartPending, so the nightly no longer reports a good update as FAILED and still reconciles its modules
4821
+ * (audit C, real receipt on the owner's Mac, Oct 24). Anything else: a required FAIL, exit 1.
4822
+ */
4823
+ export function hostConvergenceUpdateVerdict(convergence) {
4824
+ if (convergence?.ok && convergence.convergence?.healthy === true) return { status: 'PASS', required: true, exitCode: 0 };
4825
+ if (convergence?.restartOnly === true && convergence.convergence?.state === 'host-restart-required') {
4826
+ return { status: 'PASS', required: false, restartPending: true, exitCode: 0 };
4455
4827
  }
4828
+ return { status: 'FAIL', required: true, exitCode: 1 };
4829
+ }
4830
+
4831
+ /**
4832
+ * The part of `--update` after every knowledge phase has PASSED: host convergence, managed router additions,
4833
+ * nightly module reconciliation, lifecycle retention, footprint, terminal settlement. Exported with its effects
4834
+ * injectable so the exit contract is testable without a network or a real host:
4835
+ * 0 knowledge updated (a host restart as the only gap is an advisory); 12 cleanup pending; 1 a real failure.
4836
+ * Lifecycle-evidence retention is an ADVISORY here, never the terminal verdict: it used to turn every good
4837
+ * update into recovery-required exit 1 and a ~745 MB reinstall, nightly (audit C). The updater's own
4838
+ * `retentionAdvisory` (forge-update, 4.7.21) is recorded beside the installer's measurement.
4839
+ */
4840
+ export async function finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult = null, cleanupPending = false,
4841
+ settle,
4842
+ converge = convergeHostsAfterUpdate,
4843
+ reconcile = reconcileNightlyModules,
4844
+ routerDefault = refreshManagedRouterDefault,
4845
+ managedCatalog = () => applyManagedCatalogUpdate({ routerDir: path.join(os.homedir(), '.claude', 'model-router'), packageRoot: REPO_ROOT }),
4846
+ prune = pruneLifecycleEvidence,
4847
+ enforce = enforceFootprint,
4848
+ enroll = () => {
4849
+ // `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
4850
+ // hand. enableNightly() prints its own real verification (plist path + launchctl result).
4851
+ if (!FLAG_AUTO) return;
4852
+ info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
4853
+ enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
4854
+ },
4855
+ } = {}) {
4856
+ let updateStatus = 0;
4857
+ info(c.dim('\nsynchronizing every detected host to this exact published version…\n'));
4858
+ const convergence = await converge(kbDir);
4859
+ const host = hostConvergenceUpdateVerdict(convergence);
4860
+ if (host.exitCode !== 0) {
4861
+ let active;
4862
+ try { active = JSON.parse(fs.readFileSync(path.join(footprintRoots().brainHome, 'active.json'), 'utf8')); } catch { /* report unknown activation */ }
4863
+ warn(hostSynchronizationFailureMessage(convergence, active));
4864
+ updateStatus = 1;
4865
+ } else if (host.restartPending) {
4866
+ warn(`the knowledge update succeeded; a host restart is still pending — ${convergence.convergence.action}`);
4867
+ } else if (convergence.convergence?.notice) info(convergence.convergence.notice);
4868
+ recordRefreshPhase(refreshReceipt, 'host-convergence', host.status, {
4869
+ state: convergence?.convergence?.state || null, error: convergence?.error || null,
4870
+ ...(host.restartPending ? { restartPending: true, advisory: 'host restart pending; knowledge update succeeded' } : {}),
4871
+ ...(convergence?.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
4872
+ execution: { kind: 'executed', runId: refreshReceipt.runId },
4873
+ required: host.required,
4874
+ });
4456
4875
  // Issue #87: a normal lifecycle update must also carry MANAGED MODEL additions to an existing
4457
4876
  // user. This call used to live only in offerRouterProfile() — the fresh-install path — so someone
4458
4877
  // who already had ~/.claude/model-router/catalog.json (i.e. every existing user, the only ones who
@@ -4460,13 +4879,10 @@ async function runUpdate() {
4460
4879
  // updated. Additive and non-interactive; their overrides win, metered rows are never auto-enabled,
4461
4880
  // and a failure here is reported but never fails an otherwise-good update.
4462
4881
  if (updateStatus === 0) {
4463
- const defaultReceipt = refreshManagedRouterDefault();
4882
+ const defaultReceipt = routerDefault();
4464
4883
  recordRefreshAdvisory(refreshReceipt, 'managed-router-default', defaultReceipt.action === 'failed' ? 'SKIP' : 'PASS', defaultReceipt);
4465
4884
  try {
4466
- const managed = applyManagedCatalogUpdate({
4467
- routerDir: path.join(os.homedir(), '.claude', 'model-router'),
4468
- packageRoot: REPO_ROOT,
4469
- });
4885
+ const managed = managedCatalog();
4470
4886
  if (managed.action === 'merged' && managed.added.length) {
4471
4887
  ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
4472
4888
  }
@@ -4476,37 +4892,41 @@ async function runUpdate() {
4476
4892
  recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'SKIP', { reason: e.message });
4477
4893
  }
4478
4894
  }
4479
- // `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
4480
- // hand. Only enroll if the update itself succeeded — never promise "you're set forever" on a failed
4481
- // update. enableNightly() prints its own real verification (plist path + launchctl result).
4482
- if (updateStatus === 0 && FLAG_AUTO) {
4483
- info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
4484
- enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
4485
- }
4486
- if (updateStatus === 0) reconcileNightlyModules({ brainHome, refreshReceipt });
4895
+ // Only enroll if the update itself succeeded — never promise "you're set forever" on a failed update.
4896
+ if (updateStatus === 0) enroll();
4897
+ // The knowledge phases passed, so the nightly must run the installed modules even when a host did not
4898
+ // converge; otherwise a host problem froze the 03:30 job on yesterday's coordinator (audit C).
4899
+ reconcile({ brainHome, refreshReceipt });
4487
4900
  let retention;
4488
4901
  try {
4489
- retention = pruneLifecycleEvidence({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
4902
+ retention = prune({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
4490
4903
  preserveTransactionPaths: updaterResult?.transactionReceipts ? [updaterResult.transactionReceipts] : [] });
4491
4904
  } catch (error) {
4492
4905
  retention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
4493
4906
  withinBudget: false, unsafe: [{ path: brainHome, reason: error.message }] };
4494
4907
  }
4495
- const retentionFailed = retention.withinBudget !== true;
4496
- const cleanupFailed = cleanupPending || retentionFailed;
4497
- recordRefreshPhase(refreshReceipt, 'cleanup', cleanupFailed ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
4908
+ const retentionFailed = retention?.withinBudget !== true;
4909
+ const updaterRetention = updaterResult?.retentionAdvisory || null;
4910
+ recordRefreshPhase(refreshReceipt, 'cleanup', cleanupPending ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
4498
4911
  reason: cleanupPending ? 'verified live generation has redundant rollback cleanup pending'
4499
- : retentionFailed ? 'lifecycle evidence exceeds its fixed retention safety budget'
4500
- : (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
4912
+ : (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
4501
4913
  storageDelta: updaterResult?.storageDelta || null,
4502
4914
  lifecycleRetention: retention,
4503
4915
  execution: { kind: 'executed', runId: refreshReceipt.runId },
4504
- required: cleanupFailed || updateStatus === 0,
4916
+ required: cleanupPending || updateStatus === 0,
4505
4917
  });
4918
+ if (retentionFailed || updaterRetention) {
4919
+ const unsafe = (retentionFailed ? retention?.unsafe || [] : []).map((entry) => entry?.path).filter(Boolean);
4920
+ warn(`lifecycle evidence could not be fully pruned (advisory; the update itself stands)${unsafe.length ? `: ${unsafe.slice(0, 3).join(', ')}` : ''}`);
4921
+ try {
4922
+ recordRefreshAdvisory(refreshReceipt, 'lifecycle-retention', 'FAIL', {
4923
+ installer: retentionFailed ? retention : null, updater: updaterRetention });
4924
+ } catch { /* the advisory never blocks settlement */ }
4925
+ }
4506
4926
  // ADR-0098: after the swap, enforce the footprint (the updater just released its own rollback; this
4507
4927
  // releases everything else that must not exist) and record the result on the receipt as an advisory —
4508
4928
  // a footprint problem is reported, it never fails an otherwise-good update.
4509
- const footprint = enforceFootprint({ holdingRefreshLock: true });
4929
+ const footprint = enforce({ holdingRefreshLock: true });
4510
4930
  try {
4511
4931
  const after = footprint?.after;
4512
4932
  recordRefreshAdvisory(refreshReceipt, 'footprint', after && after.kbCopies === 1 && !after.cruft.length && after.withinBudget ? 'PASS' : 'FAIL', {
@@ -4514,13 +4934,15 @@ async function runUpdate() {
4514
4934
  budgetBytes: after?.budgetBytes ?? null, removed: footprint?.removed.length ?? 0, freedBytes: footprint?.freedBytes ?? 0,
4515
4935
  });
4516
4936
  } catch { /* the advisory never blocks settlement */ }
4517
- settleRefresh(cleanupPending ? 12 : (retentionFailed ? 1 : updateStatus), {
4518
- phase: cleanupFailed ? 'cleanup' : (updateStatus === 0 ? 'complete' : 'failed'),
4519
- terminalVerdict: cleanupPending ? 'cleanup-pending' : retentionFailed ? 'recovery-required'
4520
- : (outcome.verdict === 'noop' ? 'noop' : 'applied'),
4937
+ // A genuine failure (a required host convergence) outranks cleanup-pending: 12 tells the coordinator and
4938
+ // host-update the knowledge applied and only redundant cleanup is owed, so it must not mask an exit 1.
4939
+ const failed = updateStatus !== 0;
4940
+ const exitCode = failed ? updateStatus : (cleanupPending ? 12 : 0);
4941
+ settle(exitCode, {
4942
+ phase: failed ? 'failed' : (cleanupPending ? 'cleanup' : 'complete'),
4943
+ terminalVerdict: failed ? 'failed' : cleanupPending ? 'cleanup-pending' : (outcome?.verdict === 'noop' ? 'noop' : 'applied'),
4521
4944
  storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
4522
- process.removeListener('exit', exitGuard);
4523
- await printConfirmation({ footprint: footprint?.after || null });
4945
+ return { exitCode, footprint, hostConvergence: host };
4524
4946
  }
4525
4947
 
4526
4948
  /**
@@ -4625,15 +5047,56 @@ const SPEND_GUARD_LABEL = 'com.ruvnet.spend-watchdog';
4625
5047
  const spendGuardScriptPath = () => path.join(os.homedir(), '.claude', 'scripts', 'api-spend-watchdog.mjs');
4626
5048
  const spendGuardPlistPath = () => path.join(os.homedir(), 'Library', 'LaunchAgents', `${SPEND_GUARD_LABEL}.plist`);
4627
5049
 
4628
- function enableSpendGuard() {
5050
+ /**
5051
+ * Every LaunchAgent that already runs an api-spend-watchdog.mjs, under ANY label (the owner's runs as
5052
+ * com.stuartkerr.api-spend-watchdog). Matched on ProgramArguments, so a second hourly job is never added beside
5053
+ * an existing one. Read-only; a binary plist is converted with plutil, an unreadable one is skipped.
5054
+ */
5055
+ export function spendGuardAgents({ agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents') } = {}) {
5056
+ let names = [];
5057
+ try { names = fs.readdirSync(agentsDir).filter((name) => name.endsWith('.plist')); } catch { return []; }
5058
+ const found = [];
5059
+ for (const name of names) {
5060
+ const file = path.join(agentsDir, name);
5061
+ let text;
5062
+ try {
5063
+ const bytes = fs.readFileSync(file);
5064
+ text = bytes.subarray(0, 6).toString('latin1') === 'bplist'
5065
+ ? spawnSync('plutil', ['-convert', 'xml1', '-o', '-', file], { encoding: 'utf8' }).stdout || ''
5066
+ : bytes.toString('utf8');
5067
+ } catch { continue; }
5068
+ const args = /<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1] || '';
5069
+ if (!/api-spend-watchdog\.mjs/.test(args)) continue;
5070
+ const label = /<key>\s*Label\s*<\/key>\s*<string>([^<]*)<\/string>/.exec(text)?.[1]?.trim() || name.replace(/\.plist$/, '');
5071
+ found.push({ label, plist: file });
5072
+ }
5073
+ return found;
5074
+ }
5075
+
5076
+ export function enableSpendGuard() {
5077
+ // A spend watchdog already scheduled under another label (the owner's own job, or an earlier hand install)
5078
+ // is the same safety net: say where it is and add nothing. Two hourly jobs mean two alerts per event.
5079
+ const foreign = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
5080
+ if (foreign.length) {
5081
+ ok(`spend watchdog already installed as ${c.bold(foreign[0].label)} (${foreign[0].plist}) — not adding a second hourly job`);
5082
+ return 'already-on';
5083
+ }
4629
5084
  // The npx checkout is ephemeral, so copy the bundled watchdog to a persistent home the launchd
4630
- // job can point at for good.
5085
+ // job can point at for good — but NEVER over a different file already there: it may be the user's own
5086
+ // fixed copy (the owner's 2026-10-25 flood fix was one interactive install away from being reverted).
4631
5087
  const src = path.join(__dirname, 'api-spend-watchdog.mjs');
4632
5088
  const dst = spendGuardScriptPath();
4633
5089
  if (!fs.existsSync(src)) { warn('spend-watchdog source missing from this bundle — skipping (non-fatal)'); return 'no-source'; }
4634
5090
  fs.mkdirSync(path.dirname(dst), { recursive: true });
4635
- fs.copyFileSync(src, dst);
4636
- ok(`installed the spend watchdog → ${c.bold(dst)}`);
5091
+ let existing = null;
5092
+ try { existing = fs.readFileSync(dst); } catch { /* absent */ }
5093
+ if (existing && !existing.equals(fs.readFileSync(src))) {
5094
+ warn(`kept your existing ${c.bold(dst)} — it differs from the bundled watchdog, so it was not overwritten`);
5095
+ info(`to use the bundled one instead: ${c.bold(`rm ${dst}`)} then ${c.bold('npx ruvnet-brain --enable-spend-guard')}`);
5096
+ } else if (!existing) {
5097
+ fs.copyFileSync(src, dst);
5098
+ ok(`installed the spend watchdog → ${c.bold(dst)}`);
5099
+ } else ok(`spend watchdog script already current → ${c.bold(dst)}`);
4637
5100
 
4638
5101
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
4639
5102
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -4686,6 +5149,8 @@ function disableSpendGuard() {
4686
5149
  const plistPath = spendGuardPlistPath();
4687
5150
  const scriptPath = spendGuardScriptPath();
4688
5151
  const existed = fs.existsSync(plistPath);
5152
+ // The script stays when another LaunchAgent runs it: that job is not ours to break.
5153
+ const sharedWith = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
4689
5154
  if (TEST_MODE) {
4690
5155
  warn('RUVNET_BRAIN_TEST=1 — skipping launchctl bootout (plist removal only)');
4691
5156
  } else {
@@ -4693,7 +5158,8 @@ function disableSpendGuard() {
4693
5158
  spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${SPEND_GUARD_LABEL}`], { stdio: 'ignore' });
4694
5159
  }
4695
5160
  let failed = false;
4696
- for (const p of [plistPath, scriptPath]) {
5161
+ if (sharedWith.length && fs.existsSync(scriptPath)) info(`kept ${scriptPath} — ${sharedWith[0].label} runs it`);
5162
+ for (const p of sharedWith.length ? [plistPath] : [plistPath, scriptPath]) {
4697
5163
  if (!fs.existsSync(p)) continue;
4698
5164
  try { fs.rmSync(p); } catch (e) {
4699
5165
  failed = true;
@@ -4748,10 +5214,10 @@ function footprintEvidence() {
4748
5214
 
4749
5215
  /** Enforce the footprint now: remove what must not exist (proof-gated), rotate over-cap logs, collect
4750
5216
  * lease-free plugin generations. Narrates every removal and every refusal; never throws. */
4751
- export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false } = {}) {
5217
+ export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false, archive = false } = {}) {
4752
5218
  let result;
4753
5219
  try {
4754
- result = sweepFootprint({ apply: true, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
5220
+ result = sweepFootprint({ apply: true, archive, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
4755
5221
  collectPluginGenerations: ({ registryPath, apply }) => prunePluginGenerations({ registryPath, apply }),
4756
5222
  pruneEvidence: pruneEvidence ? (args) => pruneLifecycleEvidence(args) : null });
4757
5223
  } catch (error) {
@@ -4810,7 +5276,10 @@ export function machineFootprint() {
4810
5276
  }
4811
5277
  if (process.platform === 'darwin') {
4812
5278
  add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
4813
- add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
5279
+ // A script another LaunchAgent runs is not ours to list for removal (disableSpendGuard keeps it).
5280
+ if (!spendGuardAgents().some((agent) => agent.label !== SPEND_GUARD_LABEL)) {
5281
+ add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
5282
+ }
4814
5283
  }
4815
5284
  const cmds = pluginCommandsDir();
4816
5285
  if (cmds) items.push({
@@ -4831,6 +5300,12 @@ export function machineFootprint() {
4831
5300
  items.push({ label: 'CLAUDE.md block (6 lines, between markers)', path: cmdPath, undo: 'npx ruvnet-brain --uninstall' });
4832
5301
  }
4833
5302
  } catch { /* unreadable */ }
5303
+ try {
5304
+ const grokAgents = grokPaths().agents;
5305
+ if (fs.existsSync(grokAgents) && fs.readFileSync(grokAgents, 'utf8').includes(CLAUDE_MD_START)) {
5306
+ items.push({ label: 'Grok AGENTS.md search-first line (between markers)', path: grokAgents, undo: 'npx ruvnet-brain --uninstall' });
5307
+ }
5308
+ } catch { /* unreadable */ }
4834
5309
  add('Usage-counts preference', telemetryConsentPath(), 'delete this file');
4835
5310
 
4836
5311
  // EVERYTHING ELSE THIS INSTALLER WRITES. The first version of this listed the KB bundle and
@@ -4935,8 +5410,7 @@ function showWhatsNew() {
4935
5410
  * Backed up and written atomically, same as when it was added — taking something away is at least
4936
5411
  * as sensitive as putting it there.
4937
5412
  */
4938
- function removeClaudeMdBlock() {
4939
- const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
5413
+ function removeClaudeMdBlock(p = path.join(os.homedir(), '.claude', 'CLAUDE.md')) {
4940
5414
  let src = '';
4941
5415
  try { src = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { return 'unreadable'; }
4942
5416
  // PAIR THE MARKERS PROPERLY. The first version took the FIRST start and the FIRST end, unpaired.
@@ -5015,6 +5489,7 @@ function uninstallAll() {
5015
5489
 
5016
5490
  const claudeMd = removeClaudeMdBlock();
5017
5491
  if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
5492
+ if (removeClaudeMdBlock(grokPaths().agents) === 'removed') ok('removed our line from ~/.grok/AGENTS.md (your content untouched, backup saved)');
5018
5493
 
5019
5494
  // GAP FIX: this used to be permanently "manual" — machineFootprint() treated ANY settings.json edit
5020
5495
  // as something only the user could safely touch. That blanket rule was right for edits we cannot
@@ -5085,9 +5560,16 @@ function uninstallAll() {
5085
5560
  // Exported (testable under RUVNET_BRAIN_IMPORT_ONLY=1, like offerNightly). Never throws — the caller
5086
5561
  // also guards, because a finished install must never be broken by an optional safety offer.
5087
5562
  export async function offerSpendGuard() {
5563
+ // Read-only detection first, so an existing guard under ANY label is reported as on (and never offered twice).
5564
+ if (process.platform === 'darwin') {
5565
+ const existing = spendGuardAgents();
5566
+ if (existing.length || fs.existsSync(spendGuardPlistPath())) {
5567
+ ok(`spend watchdog already installed${existing.length ? ` as ${existing[0].label}` : ''} — runaway API spend will alert you`);
5568
+ return 'already-on';
5569
+ }
5570
+ }
5088
5571
  if (FLAG_NO_NIGHTLY_PROMPT || TEST_MODE) return 'suppressed';
5089
5572
  if (process.platform !== 'darwin') return 'unsupported';
5090
- if (fs.existsSync(spendGuardPlistPath())) { ok('spend watchdog already installed — runaway API spend will alert you'); return 'already-on'; }
5091
5573
 
5092
5574
  step(
5093
5575
  'One more safety net — a spend watchdog',
@@ -6262,6 +6744,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6262
6744
  ` • the Codex host ${hostReady && pluginReady ? c.green('wired at user scope') : c.yellow('needs attention')} — search_ruvnet + native Brain skills`,
6263
6745
  );
6264
6746
  }
6747
+ const grokLine = grokStatusLine(currentGrokStatus());
6748
+ if (grokLine) console.log(` • ${grokLine}`);
6265
6749
  if (env) {
6266
6750
  const t = (okv) => (okv ? c.green('✓ present') : c.yellow('not added (optional)'));
6267
6751
  console.log(` • rUv build toolkit — Ruflo: ${t(env.ruflo)} · RuVector: ${t(env.ruvector)} ${c.dim('answers work without them')}`);
@@ -6276,8 +6760,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6276
6760
  console.log(`\n ${c.bold('Where it runs:')} ${c.bold(`everywhere you use ${hostLabel}`)} — CLI and supported editor/desktop surfaces.`);
6277
6761
  console.log(` It's ${c.bold('user-level (global)')}: open ANY repo or folder and it's already there. Nothing per project,`);
6278
6762
  console.log(` nothing to copy in, nothing to git-ignore. ${c.dim('(Runs locally — it is not active in the claude.ai web app.)')}`);
6279
- console.log(`\n ${c.bold('How it runs:')} ${c.bold('explicitly')} — use search_ruvnet, a Brain skill, or a proof command when you want`);
6280
- console.log(` source-grounded RuvNet context. Brain code does not run on host lifecycle events.`);
6763
+ console.log(`\n ${c.bold('How it runs:')} search_ruvnet, Brain skills and proof commands when you ask, plus the allowlisted`);
6764
+ console.log(` lifecycle hooks (${hookCountPhrase()}): grounding reminders, a write gate, and continuity snapshots.`);
6281
6765
  console.log(`\n ${c.bold('Keep it fresh:')} re-run ${c.bold('npx ruvnet-brain')} any time — the brain itself always pulls the latest`);
6282
6766
  console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
6283
6767
 
@@ -6294,12 +6778,12 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6294
6778
  console.log(`\n ${c.bold('How you\'ll know it\'s working:')}`);
6295
6779
  console.log(` ${c.green('✓')} ${c.bold('search_ruvnet')} returns cited source paths when invoked.`);
6296
6780
  console.log(` ${c.green('✓')} Brain skills and slash commands remain available.`);
6297
- console.log(` ${c.green('✓')} ${c.bold('No Brain hook message or tool-call refusal appears by itself.')}`);
6781
+ console.log(` ${c.green('✓')} Writing rUv-product code the Brain has not looked up is refused until search_ruvnet grounds it.`);
6298
6782
 
6299
6783
  // ── honest expectations ──
6300
6784
  console.log(`\n ${c.bold('What to expect (honestly):')}`);
6301
- console.log(` • Grounding is available on demand; it is no longer injected into every turn.`);
6302
- console.log(` • Host work proceeds normally unless you explicitly invoke a Brain command or skill.`);
6785
+ console.log(` • Grounding reminders are added only to prompts that touch the rUv stack.`);
6786
+ console.log(` • Other work proceeds normally; the write gate is the only hook that refuses by default.`);
6303
6787
  console.log(` • Not sure it's on? Run ${c.bold('npx ruvnet-brain --doctor')} any time for a health check.
6304
6788
  • Want to see it answer, live, right now? ${c.bold('npx ruvnet-brain --demo')} — 2 real questions, real cited answers.`);
6305
6789
 
@@ -6325,7 +6809,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6325
6809
  console.log(`\n ${c.bold('If it earns it:')} a GitHub star helps other people find the brain —`);
6326
6810
  console.log(` ${c.bold('https://github.com/stuinfla/ruvnet-brain')} ${c.dim('· feedback in one command: npx ruvnet-brain --feedback')}`);
6327
6811
 
6328
- console.log(`\n ${c.dim('The plugin is disable-able and installs no automatic lifecycle handlers.')}`);
6812
+ console.log(`\n ${c.dim(`The plugin is disable-able. It registers lifecycle hooks (${hookCountPhrase()}), only the allowlisted set in continuity-hook-policy.mjs; Codex runs its set once trusted in /hooks.`)}`);
6329
6813
  console.log('');
6330
6814
  }
6331
6815
 
@@ -6345,11 +6829,15 @@ Usage:
6345
6829
  footprint within budget, no cruft. --doctor --json prints only that, as JSON.
6346
6830
  npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
6347
6831
  private files the live brain lacks — old installers, over-cap logs), then confirm.
6832
+ npx ruvnet-brain --clean --archive
6833
+ Same, and for a kept copy: copy only the files it alone holds into
6834
+ kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
6348
6835
  EXITS NON-ZERO when the install is genuinely broken, so it can gate a
6349
6836
  script: npx ruvnet-brain --doctor && ./deploy.sh
6350
6837
  npx ruvnet-brain --doctor --hooks
6351
- Backward-compatible, read-only proof that both shipped Brain hook
6352
- manifests contain zero automatic registrations. Executes no hook body.
6838
+ Read-only proof that the shipped hook manifests register only the
6839
+ allowlisted continuity handlers (${hookCountPhrase()}).
6840
+ Executes no hook body.
6353
6841
  npx ruvnet-brain --demo Guided walkthrough — 2 real questions, real cited answers
6354
6842
  npx ruvnet-brain --feedback Tell us how it went — prefills a GitHub Discussion with your brain
6355
6843
  version, platform, and a 3-line health summary (you see exactly
@@ -6366,6 +6854,8 @@ Usage:
6366
6854
  block only — your own CLAUDE.md content is preserved and backed up)
6367
6855
  npx ruvnet-brain --enable-spend-guard Install the hourly runaway-agent spend alarm (alert-only)
6368
6856
  npx ruvnet-brain --disable-spend-guard Remove it (safe to run any time)
6857
+ npx ruvnet-brain --grok-agents-md Grok CLI only, opt-in: add one search-first line to ~/.grok/AGENTS.md
6858
+ (only if no search_ruvnet line is there; backed up; --uninstall removes it)
6369
6859
  npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
6370
6860
  (appended at the bottom between markers; your file is backed up first)
6371
6861
  (a default install RECOMMENDS nightly and asks, defaulting to yes)
@@ -6412,11 +6902,7 @@ the installer reports that boot-level declarations changed.
6412
6902
  // in a unit test ran a real install against the maintainer's HOME. Direct argv identity is the
6413
6903
  // authority boundary. The old IMPORT_ONLY flag may still be present in a long-lived test worker;
6414
6904
  // it must never suppress a genuinely direct CLI execution.
6415
- const canonical = (value) => {
6416
- try { return fs.realpathSync(value); } catch { return path.resolve(value); }
6417
- };
6418
- const invokedDirectly = Boolean(process.argv[1])
6419
- && canonical(process.argv[1]) === canonical(fileURLToPath(import.meta.url));
6905
+ const invokedDirectly = isDirectInvocation(import.meta.url);
6420
6906
  if (!invokedDirectly) return;
6421
6907
  if (FLAG_HELP) return showHelp();
6422
6908
  if (NAMED_RELEASE?.error) {
@@ -6461,7 +6947,7 @@ the installer reports that boot-level declarations changed.
6461
6947
  // remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
6462
6948
  if (FLAG_CLEAN) {
6463
6949
  if (!FLAG_JSON) printBanner('clean');
6464
- const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON });
6950
+ const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON, archive: FLAG_ARCHIVE });
6465
6951
  const result = await printConfirmation({ footprint: swept?.after || null, json: FLAG_JSON });
6466
6952
  process.exitCode = swept && result.footprint.kbCopies === 1 && result.footprint.cruft.length === 0 ? 0 : 1;
6467
6953
  return;
@@ -6489,6 +6975,7 @@ the installer reports that boot-level declarations changed.
6489
6975
  if (FLAG_ENABLE_SPEND_GUARD) { enableSpendGuard(); return; }
6490
6976
  if (FLAG_DISABLE_SPEND_GUARD) { disableSpendGuard(); return; }
6491
6977
  if (FLAG_UNINSTALL) { uninstallAll(); return; }
6978
+ if (FLAG_GROK_AGENTS_MD) { reportGrokAgentsLine(addGrokAgentsLine()); return; }
6492
6979
  if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
6493
6980
  if (FLAG_WHATS_NEW) { showWhatsNew(); return; }
6494
6981
 
@@ -6538,6 +7025,7 @@ the installer reports that boot-level declarations changed.
6538
7025
  let installedTag = null;
6539
7026
  let latestTag = null;
6540
7027
  let resolvedRelease = null; // reused below so the release is resolved at most once
7028
+ let namedTag = null, namedMove = false, namedDowngrade = false; // --version / --pin over an existing install (#504)
6541
7029
  if (alreadyInstalled && !FLAG_FORCE) {
6542
7030
  installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
6543
7031
  try {
@@ -6580,10 +7068,28 @@ the installer reports that boot-level declarations changed.
6580
7068
  ahead = Boolean(a && b && cmpTag(a, b) > 0);
6581
7069
  staleSkip = Boolean(b && (a === null || (a !== b && !ahead)));
6582
7070
  }
7071
+ // A NAMED release (--version / --pin) runs no lookup, so latestTag stays null and the block below
7072
+ // used to print "the release lookup failed" for a lookup that never happened, installing nothing
7073
+ // (#504). The operator named the target: same tag = truthful skip; different or unknown = take the
7074
+ // same signature-verified download path. Older than installed is allowed (explicit request), loudly.
7075
+ const named = resolvedRelease && (resolvedRelease.source === 'pinned' || resolvedRelease.source === 'forced')
7076
+ ? (resolvedRelease.tag_name || resolvedRelease.tag) : null;
7077
+ if (named) {
7078
+ const nrm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
7079
+ namedTag = named;
7080
+ if (isCorpusReleaseTag(named)) namedMove = installedCorpusTag(cacheDir) !== named;
7081
+ else {
7082
+ namedMove = nrm(installedTag) !== nrm(named);
7083
+ namedDowngrade = Boolean(nrm(installedTag) && cmpTag(nrm(installedTag), nrm(named)) > 0);
7084
+ }
7085
+ }
6583
7086
  }
6584
7087
 
6585
- if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
6586
- if (latestTag && ahead) {
7088
+ if (alreadyInstalled && !FLAG_FORCE && !staleSkip && !namedMove) {
7089
+ if (namedTag) {
7090
+ step('Brain already at the named release — skipping the download', `installed ${installedTag} matches ${namedTag}`);
7091
+ ok(`found ${namedTag} at ${cacheDir} — nothing to download`);
7092
+ } else if (latestTag && ahead) {
6587
7093
  // Never silently imply equality when the user is AHEAD — that would be a small lie, and it is
6588
7094
  // the one that hides a downgrade. Skipping is right; misdescribing why is not.
6589
7095
  step('Brain already current — skipping the download', `installed ${installedTag} is NEWER than the latest release (${latestTag})`);
@@ -6598,6 +7104,10 @@ the installer reports that boot-level declarations changed.
6598
7104
  info(`When you have a connection: ${c.bold('npx ruvnet-brain --update')} ${c.dim('(or --force to refetch now)')}`);
6599
7105
  }
6600
7106
  } else {
7107
+ if (namedMove) {
7108
+ if (namedDowngrade) warn(`${namedTag} is OLDER than the installed ${installedTag} — downgrading because you named it explicitly.`);
7109
+ step('Moving to the named release', `installed ${installedTag || 'unknown'} → named ${namedTag}`);
7110
+ }
6601
7111
  if (staleSkip) {
6602
7112
  step('Brain is out of date — fetching the current release', `installed ${installedTag || 'unknown'} → latest ${latestTag}`);
6603
7113
  info(`${c.dim('(the old installer skipped this whenever any brain was present, which is why stale installs never moved)')}`);
@@ -6686,6 +7196,9 @@ the installer reports that boot-level declarations changed.
6686
7196
  codexHost = wireCodexHost();
6687
7197
  if (codexHost.host) {
6688
7198
  codexPlugin = wireCodexPlugin();
7199
+ const hostRefresh = await refreshCodexSharedHost(codexPlugin);
7200
+ const refreshNote = codexHostRefreshMessage(hostRefresh, codexPlugin);
7201
+ if (refreshNote) (refreshNote.level === 'warn' ? warn : info)(refreshNote.text);
6689
7202
  if (codexPlugin.available) printCodexLifecycle(await codexLifecycleStatus());
6690
7203
  }
6691
7204
  } catch (e) {
@@ -6711,6 +7224,7 @@ the installer reports that boot-level declarations changed.
6711
7224
  const env = detectEnvironment();
6712
7225
  try { await offerStack(env); } catch (e) { warn(`(toolkit check skipped: ${e && e.message})`); }
6713
7226
  try { await offerClaudeMd(); } catch { /* non-fatal — never let an offer break the install */ }
7227
+ try { await offerGrokAgentsMd(); } catch { /* non-fatal — never let an offer break the install */ }
6714
7228
  // Stuart's requirement: a default install must end by clearly recommending nightly auto-updates
6715
7229
  // and asking, DEFAULTING TO YES (TTY + macOS + not already on). Non-fatal like every other offer.
6716
7230
  let nightly = 'skipped';