ruvnet-brain 4.7.19 → 4.7.21

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 (250) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +494 -97
  3. package/kb/download-retry.mjs +93 -21
  4. package/kb/forge-guard-injection.mjs +277 -0
  5. package/kb/forge-update.mjs +166 -38
  6. package/kb/lifecycle-evidence-retention.mjs +151 -14
  7. package/kb/model-requirements.mjs +4 -1
  8. package/kb/refresh-run.mjs +175 -27
  9. package/kb/verify-citation.mjs +15 -2
  10. package/package.json +3 -1
  11. package/plugin/.claude-plugin/plugin.json +1 -1
  12. package/plugin/.codex-plugin/plugin.json +1 -1
  13. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  14. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  15. package/plugin/scripts/advisory-contract.mjs +21 -0
  16. package/plugin/scripts/advocacy-route.mjs +5 -4
  17. package/plugin/scripts/agentdb-recall.mjs +15 -5
  18. package/plugin/scripts/automatic-update.mjs +9 -1
  19. package/plugin/scripts/brain-confirmation.mjs +2 -1
  20. package/plugin/scripts/brain-footprint.mjs +72 -11
  21. package/plugin/scripts/capability-registry.mjs +1 -1
  22. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  23. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  24. package/plugin/scripts/completion-claim-evidence.mjs +56 -12
  25. package/plugin/scripts/continuation-gate.mjs +28 -9
  26. package/plugin/scripts/decision-gate.mjs +13 -63
  27. package/plugin/scripts/decision-outcomes.mjs +36 -0
  28. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  29. package/plugin/scripts/decision-record.mjs +8 -3
  30. package/plugin/scripts/degradation-watch.mjs +2 -1
  31. package/plugin/scripts/developer-update-lock.mjs +172 -33
  32. package/plugin/scripts/developer-update-owner.mjs +8 -5
  33. package/plugin/scripts/developer-update-policy.mjs +4 -1
  34. package/plugin/scripts/developer-update.mjs +155 -53
  35. package/plugin/scripts/doc-currency.mjs +2 -16
  36. package/plugin/scripts/duplicate-gate.mjs +2 -1
  37. package/plugin/scripts/entrypoint.mjs +34 -0
  38. package/plugin/scripts/footprint-io.mjs +135 -8
  39. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  40. package/plugin/scripts/ground-ruvnet.sh +6 -3
  41. package/plugin/scripts/grounding-answer.mjs +2 -1
  42. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  43. package/plugin/scripts/grounding-substance.mjs +2 -1
  44. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  45. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  46. package/plugin/scripts/hook-context-budget.mjs +2 -1
  47. package/plugin/scripts/hook-input.mjs +2 -2
  48. package/plugin/scripts/hook-shim.mjs +9 -2
  49. package/plugin/scripts/host-update.mjs +29 -5
  50. package/plugin/scripts/identifier-preflight.mjs +2 -1
  51. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  52. package/plugin/scripts/learn-capture.mjs +2 -1
  53. package/plugin/scripts/learning-store.mjs +62 -16
  54. package/plugin/scripts/lesson-bridge.mjs +2 -2
  55. package/plugin/scripts/lesson-promote.mjs +83 -22
  56. package/plugin/scripts/md-stamp.mjs +2 -1
  57. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  58. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  59. package/plugin/scripts/package-recommender.mjs +2 -2
  60. package/plugin/scripts/project-store-resolver.mjs +19 -0
  61. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  62. package/plugin/scripts/session-snapshot-hook.mjs +24 -4
  63. package/plugin/scripts/session-start-core.mjs +8 -16
  64. package/plugin/scripts/session-start-health.mjs +97 -10
  65. package/plugin/scripts/session-start-issue-alert.mjs +105 -0
  66. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  67. package/plugin/scripts/signal-watch.mjs +2 -1
  68. package/plugin/scripts/spend-guard.mjs +2 -1
  69. package/plugin/scripts/substitution-wall.mjs +2 -1
  70. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  71. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  72. package/plugin/scripts/update-apply.mjs +186 -15
  73. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  74. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  75. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  76. package/scripts/adr-072-completion.mjs +2 -1
  77. package/scripts/agentdb-context.mjs +2 -1
  78. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  79. package/scripts/approved-runtime.mjs +2 -1
  80. package/scripts/architecture-review-lock.mjs +2 -1
  81. package/scripts/ascii-drift.mjs +2 -1
  82. package/scripts/brain-latency-50.mjs +2 -1
  83. package/scripts/brain-novice-50.mjs +2 -16
  84. package/scripts/brain-score.mjs +13 -2
  85. package/scripts/build-bundle.mjs +2 -1
  86. package/scripts/build-concepts.mjs +2 -1
  87. package/scripts/candidate-ci-receipt.mjs +2 -1
  88. package/scripts/candidate-host-evidence.mjs +2 -1
  89. package/scripts/card-from-source.mjs +2 -1
  90. package/scripts/ci/warm-brain-models.mjs +2 -1
  91. package/scripts/claims-verify.mjs +7 -3
  92. package/scripts/claude-controlled-terminal.mjs +2 -1
  93. package/scripts/claude-terminal-mod.mjs +2 -1
  94. package/scripts/code-release-corpus.mjs +2 -1
  95. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  96. package/scripts/codex-hook-trust.mjs +40 -1
  97. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  98. package/scripts/codex-host-execution-proof.mjs +2 -1
  99. package/scripts/completion-claim-replay.mjs +2 -1
  100. package/scripts/console-engine.mjs +8 -3
  101. package/scripts/corpus-canary.mjs +2 -1
  102. package/scripts/corpus-candidate.mjs +2 -2
  103. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  104. package/scripts/corpus-dispatch-decision.mjs +2 -1
  105. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  106. package/scripts/corpus-next-seed.mjs +2 -1
  107. package/scripts/corpus-qa.mjs +2 -1
  108. package/scripts/corpus-reconcile.mjs +2 -2
  109. package/scripts/corpus-watchdog.mjs +2 -1
  110. package/scripts/count-chunks.mjs +2 -1
  111. package/scripts/customer-state-matrix.mjs +2 -1
  112. package/scripts/derive-passage-content-map.mjs +2 -1
  113. package/scripts/development-push-check.mjs +2 -1
  114. package/scripts/dispatch-receipt.mjs +2 -1
  115. package/scripts/doc-currency.mjs +2 -1
  116. package/scripts/dream-issue-gate.mjs +2 -1
  117. package/scripts/dual-host-deliberation.mjs +2 -16
  118. package/scripts/dual-host-suggest.mjs +2 -16
  119. package/scripts/eval-brain.mjs +7 -3
  120. package/scripts/execution-policy.mjs +2 -1
  121. package/scripts/execution-preflight.mjs +2 -1
  122. package/scripts/falsify.mjs +2 -1
  123. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  124. package/scripts/fix-workstream.mjs +2 -1
  125. package/scripts/full-hints.mjs +2 -1
  126. package/scripts/full-suite-gate.mjs +2 -1
  127. package/scripts/grok-host.mjs +68 -0
  128. package/scripts/hook-qualify-core.mjs +3 -1
  129. package/scripts/hook-qualify.mjs +2 -1
  130. package/scripts/hook-retirement-check.mjs +2 -1
  131. package/scripts/host-registry.mjs +2 -1
  132. package/scripts/independent-review-receipt.mjs +2 -1
  133. package/scripts/ingest-new-repos.mjs +2 -1
  134. package/scripts/ingest-repo.mjs +12 -2
  135. package/scripts/integration-evidence.mjs +2 -1
  136. package/scripts/issue-fix.mjs +7 -24
  137. package/scripts/issue-watch.mjs +164 -47
  138. package/scripts/job-heartbeat.sh +145 -13
  139. package/scripts/key-canary.mjs +9 -3
  140. package/scripts/latency-to-surface.mjs +2 -1
  141. package/scripts/learning-replay-cli.mjs +97 -19
  142. package/scripts/learning-replay-execution.mjs +2 -0
  143. package/scripts/learning-replay-fixture.mjs +3 -0
  144. package/scripts/learning-replay-proof.mjs +14 -13
  145. package/scripts/learning-replay.mjs +3 -3
  146. package/scripts/learnings.mjs +2 -1
  147. package/scripts/lesson-ratify.mjs +88 -40
  148. package/scripts/loop-checkpoint.mjs +2 -1
  149. package/scripts/metaharness-receipts.mjs +2 -1
  150. package/scripts/model-catalog.mjs +2 -1
  151. package/scripts/model-currency-evidence.mjs +6 -4
  152. package/scripts/model-currency.mjs +33 -3
  153. package/scripts/model-native-catalog.mjs +2 -1
  154. package/scripts/model-router-agent-hook.mjs +2 -1
  155. package/scripts/model-router-dispatch.mjs +2 -1
  156. package/scripts/model-router-engine.mjs +2 -1
  157. package/scripts/model-router-outcome.mjs +2 -1
  158. package/scripts/model-router-setup.mjs +2 -1
  159. package/scripts/model-routing-eval.mjs +2 -1
  160. package/scripts/model-routing-gateway.mjs +2 -1
  161. package/scripts/model-routing-launchers.mjs +4 -1
  162. package/scripts/model-terminal-gateway.mjs +2 -1
  163. package/scripts/model-terminal-launchers.mjs +2 -1
  164. package/scripts/model-weekly-analyst.mjs +2 -1
  165. package/scripts/model-weekly-cycle.mjs +2 -1
  166. package/scripts/model-weekly-qualification.mjs +2 -1
  167. package/scripts/nightly-gists.sh +30 -21
  168. package/scripts/nightly-two-run-proof.mjs +2 -1
  169. package/scripts/nightly-watchdog.mjs +158 -47
  170. package/scripts/no-silent-substitution.mjs +2 -1
  171. package/scripts/notify.sh +11 -6
  172. package/scripts/ntfy-inbox.mjs +549 -0
  173. package/scripts/onboarding-console.mjs +47 -8
  174. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  175. package/scripts/oracle/abstain-trace.mjs +2 -1
  176. package/scripts/oracle/doc2query-generate.mjs +2 -1
  177. package/scripts/oracle/doc2query-reach.mjs +2 -1
  178. package/scripts/oracle/judge-train.mjs +2 -1
  179. package/scripts/oracle/need-set-split.mjs +2 -1
  180. package/scripts/oracle/produce-questions.mjs +2 -16
  181. package/scripts/oracle/repo-recall.mjs +2 -2
  182. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  183. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  184. package/scripts/oracle/source-tree.mjs +2 -15
  185. package/scripts/oracle/source-units.mjs +2 -16
  186. package/scripts/oracle/spike-run.mjs +2 -16
  187. package/scripts/oracle/validate-labels.mjs +2 -16
  188. package/scripts/org-repo-count.mjs +2 -1
  189. package/scripts/package-cards.mjs +2 -1
  190. package/scripts/prepublication-evidence.mjs +2 -1
  191. package/scripts/private-overlay.mjs +2 -1
  192. package/scripts/product-integrity-contract.mjs +2 -1
  193. package/scripts/protected-release-invocation.mjs +2 -1
  194. package/scripts/public-verification-abandon.mjs +2 -1
  195. package/scripts/public-verification-aggregate.mjs +2 -1
  196. package/scripts/public-verification-finalizer.mjs +2 -1
  197. package/scripts/public-verification-inputs.mjs +2 -1
  198. package/scripts/public-verification-lane.mjs +2 -1
  199. package/scripts/publication-receipt.mjs +2 -1
  200. package/scripts/published-surface-probe.mjs +2 -1
  201. package/scripts/qe/card-lane-gate.mjs +2 -16
  202. package/scripts/qe/session-start-gate.mjs +2 -16
  203. package/scripts/qe/ux-suite.mjs +2 -1
  204. package/scripts/qualified-candidate-check.mjs +2 -1
  205. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  206. package/scripts/recommendation-eval.mjs +2 -1
  207. package/scripts/recommendation-floor.mjs +2 -1
  208. package/scripts/recommendation-judge-score.mjs +2 -1
  209. package/scripts/recommendation-real-host-score.mjs +2 -1
  210. package/scripts/recommendation-real-host.mjs +2 -1
  211. package/scripts/refresh-capability-only-store.mjs +2 -1
  212. package/scripts/refresh-model-catalog.mjs +2 -1
  213. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  214. package/scripts/release-authority.mjs +2 -1
  215. package/scripts/release-evidence-aggregate.mjs +2 -1
  216. package/scripts/release-payload.mjs +2 -1
  217. package/scripts/release-projection.mjs +2 -1
  218. package/scripts/release-proof.mjs +2 -1
  219. package/scripts/release-qualification-contract.mjs +71 -26
  220. package/scripts/release-qualification.mjs +8 -1
  221. package/scripts/release-vector.mjs +2 -1
  222. package/scripts/restore-local-ingests.mjs +2 -1
  223. package/scripts/retrieval-canary.mjs +2 -1
  224. package/scripts/route-cheap.mjs +2 -1
  225. package/scripts/route-gold-rank.mjs +2 -1
  226. package/scripts/route-index-memory.mjs +2 -1
  227. package/scripts/route-latency-warm.mjs +2 -1
  228. package/scripts/router-utilization.mjs +2 -1
  229. package/scripts/routing-flywheel.mjs +2 -1
  230. package/scripts/run-operational-benchmark.mjs +2 -1
  231. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  232. package/scripts/rvf-index-audit.mjs +2 -16
  233. package/scripts/seal-gist-receipt.mjs +2 -1
  234. package/scripts/security-guidance-codex-compat.mjs +2 -1
  235. package/scripts/selfcheck.mjs +2 -1
  236. package/scripts/signal-watch.mjs +2 -1
  237. package/scripts/source-coverage.mjs +2 -1
  238. package/scripts/stabilization-receipt.mjs +2 -1
  239. package/scripts/stamp-sweep.mjs +2 -1
  240. package/scripts/status-honesty.mjs +2 -1
  241. package/scripts/sync-commands.mjs +2 -2
  242. package/scripts/top100-benchmark.mjs +2 -1
  243. package/scripts/top100-corpus.mjs +2 -1
  244. package/scripts/user-model-hook-install.mjs +2 -1
  245. package/scripts/user-model-prompt-hook.mjs +2 -1
  246. package/scripts/verify-bundle.mjs +3 -1
  247. package/scripts/verify-model-catalog.mjs +2 -1
  248. package/scripts/version.mjs +2 -1
  249. package/scripts/wired-check.mjs +11 -5
  250. 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,14 @@ 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 { refreshSharedCodexAppServer } from '../scripts/codex-hook-trust-reconcile.mjs';
89
+ import { recordCodexOvernightNotice } from '../plugin/scripts/codex-overnight-notice.mjs';
88
90
  import { repairSecurityGuidance } from '../scripts/security-guidance-codex-compat.mjs';
89
91
  import { installNativeLaunchers, installRoutingRuntime, runtimeSnapshot } from '../scripts/model-routing-launchers.mjs';
90
92
  import { installTerminalLaunchers } from '../scripts/model-terminal-launchers.mjs';
93
+ import { addGrokAgentsLine, grokPaths, grokStatus, grokStatusLine, GROK_AGENTS_LINE } from '../scripts/grok-host.mjs';
91
94
  import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
92
95
  import { moveBrain, MoveRefused } from '../scripts/move-brain.mjs';
93
96
  import { brainLocation } from '../plugin/scripts/brain-location.mjs';
@@ -96,6 +99,7 @@ import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
96
99
  import {
97
100
  writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
98
101
  } from '../kb/corpus-release-identity.mjs';
102
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
99
103
 
100
104
  // SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
101
105
  // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
@@ -166,6 +170,7 @@ const FLAG_MOVE_BRAIN = argv.includes('--move-brain');
166
170
  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
171
  const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
168
172
  const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
173
+ const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
169
174
  // ── onboarding-experience flags (all optional; every offer is safe to decline) ──
170
175
  const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
171
176
  const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
@@ -173,6 +178,7 @@ const FLAG_WITH_STACK = argv.includes('--with-stack'); // add missing Ruflo/RuVe
173
178
  const FLAG_NO_STACK = argv.includes('--no-stack'); // skip the toolkit offer entirely
174
179
  const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the CLAUDE.md section without prompting
175
180
  const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
181
+ const FLAG_GROK_AGENTS_MD = argv.includes('--grok-agents-md'); // opt in: add the search-first line to ~/.grok/AGENTS.md (#301)
176
182
  const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
177
183
  const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
178
184
  // --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev); --pin <tag> is the same.
@@ -286,7 +292,8 @@ function download(url, dest) {
286
292
  let lastShown = -1;
287
293
  return downloadFileWithRetry(url, dest, {
288
294
  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`),
295
+ // The retry continues the staged bytes (HTTP Range) when the server proves the same asset; otherwise it restarts.
296
+ onRetry: ({ attempt, error }) => warn(`download interrupted (${error.message}); retry ${attempt + 1}/3, resuming where it stopped when the server allows`),
290
297
  onProgress: ({ received, total }) => {
291
298
  const mb = Math.floor(received / 1e6);
292
299
  const pct = total ? Math.floor(received / total * 100) : mb;
@@ -550,7 +557,7 @@ function placeFileAtomic(target, bytes) {
550
557
  // one. The signed npm package carries the updater and the sibling modules it imports; the `--update`
551
558
  // preflight places them (same trust as the validator) so a stale updater upgrades itself before it runs.
552
559
  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'];
560
+ 'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs', 'forge-guard-injection.mjs'];
554
561
  export function placeUpdater(kbDir, { sourceDir = path.join(REPO_ROOT, 'kb') } = {}) {
555
562
  const placed = {};
556
563
  for (const name of UPDATER_FILES) {
@@ -804,6 +811,10 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
804
811
  const proof = kbCopyProof({ copyDir: preservedDir, liveDir: cacheDir });
805
812
  if (proof.disposable) {
806
813
  try {
814
+ // Its logs and ruflo scratch are copied out and verified first; a failed rescue keeps the copy. The
815
+ // rescue root is the sweep's (footprintRoots: links resolved, as after --move-brain), so the inventory
816
+ // finds and bounds kb-copy-rescued/ wherever the installer put it.
817
+ rescueOperationalFiles(preservedDir, proof.rescue, footprintRoots().brainHome);
807
818
  fs.rmSync(preservedDir, { recursive: true, force: true });
808
819
  ok(`released the prior generation (${proof.reason})`);
809
820
  priorGeneration = { status: 'RELEASED', path: preservedDir, reason: proof.reason };
@@ -1380,6 +1391,40 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
1380
1391
  }
1381
1392
  }
1382
1393
 
1394
+ // #420 item 4: Claude Code also records per-project installs (scope "local"/"project", keyed by projectPath), and --update
1395
+ // moves only the user row above, so a project row keeps running its old generation. `claude plugin update --scope local`
1396
+ // 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
1397
+ // projectPath is gone cannot be running anything; it is listed, never counted as behind and never given a `cd` that fails.
1398
+ export function claudeProjectInstalls({ home = os.homedir(), referenceVersion = null } = {}) {
1399
+ let rows;
1400
+ try { rows = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8'))?.plugins?.['ruvnet-brain@ruvnet-brain']; } catch { return []; }
1401
+ if (!Array.isArray(rows)) return [];
1402
+ return rows.filter((row) => (row?.scope === 'local' || row?.scope === 'project') && typeof row.projectPath === 'string' && row.projectPath).map((row) => {
1403
+ let version = null;
1404
+ try { version = JSON.parse(fs.readFileSync(path.join(row.installPath, '.claude-plugin', 'plugin.json'), 'utf8'))?.version || null; } catch { /* registry metadata below */ }
1405
+ version ||= typeof row.version === 'string' ? row.version : null;
1406
+ let exists = false;
1407
+ try { exists = fs.statSync(row.projectPath).isDirectory(); } catch { /* gone */ }
1408
+ return { scope: row.scope, projectPath: row.projectPath, version, exists,
1409
+ behind: exists && Boolean(referenceVersion) && !versionSatisfies(version, referenceVersion),
1410
+ command: exists ? `cd '${row.projectPath.replace(/'/g, `'\\''`)}' && claude plugin update ruvnet-brain@ruvnet-brain --scope ${row.scope}` : null };
1411
+ });
1412
+ }
1413
+
1414
+ /** The doctor's advisory line for claudeProjectInstalls(): '!' while any existing project lags, never a ✗ (it is the user's project). */
1415
+ export function projectInstallsCheck(projects, referenceVersion) {
1416
+ if (!projects.length) return null;
1417
+ const behind = projects.filter((p) => p.behind);
1418
+ const gone = projects.filter((p) => !p.exists);
1419
+ const named = (p) => `${p.projectPath} (${p.scope}, ${p.version || 'unknown'})`;
1420
+ const detail = behind.length
1421
+ ? `${behind.length} per-project Claude Code install(s) behind ${referenceVersion}: ${behind.map(named).join(', ')}`
1422
+ : `${projects.length - gone.length} per-project Claude Code install(s) at ${referenceVersion} or newer`;
1423
+ return { id: 'project-installs', label: 'Projects', state: behind.length ? 'warn' : 'ok',
1424
+ detail: `${detail}${gone.length ? `; ${gone.length} registered for a folder that no longer exists: ${gone.map(named).join(', ')}` : ''}`,
1425
+ fix: behind.length ? behind.map((p) => p.command).join(' ; ') : null };
1426
+ }
1427
+
1383
1428
  // A plugin version can change without changing anything a running host freezes at boot. Compare
1384
1429
  // the actual installed payload with this candidate before updating it so body-only releases do not
1385
1430
  // manufacture a restart requirement. Unknown paths fail closed: if we cannot prove the boundary,
@@ -1432,7 +1477,7 @@ function codexInstalledPluginRoot({ codexHome = codexHomeDir(), status } = {}) {
1432
1477
  function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
1433
1478
  step(
1434
1479
  'Wiring the Claude Code plugin',
1435
- 'this registers search_ruvnet, native skills, and slash commands without automatic lifecycle hooks',
1480
+ `this registers search_ruvnet, native skills, slash commands, and the allowlisted lifecycle hooks (${shippedHookHandlerCounts().claude ?? 'unknown number of'} handlers)`,
1436
1481
  );
1437
1482
  const marketplaceSource = process.env.RUVNET_CLAUDE_MARKETPLACE_SOURCE || 'stuinfla/ruvnet-brain';
1438
1483
  const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
@@ -1484,6 +1529,11 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1484
1529
  info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
1485
1530
  }
1486
1531
  info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rnbc')} (also /rnb, /rvbc), ${c.bold('/ruvnet-brain:configure')}`);
1532
+ // #420 item 4: the user row moved; per-project rows did not. Name each lagging one with its own command (never run here).
1533
+ const projectsBehind = claudeProjectInstalls({ referenceVersion: installed.version }).filter((p) => p.behind);
1534
+ for (const p of projectsBehind) {
1535
+ 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)}`);
1536
+ }
1487
1537
  return {
1488
1538
  host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
1489
1539
  shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
@@ -1973,9 +2023,20 @@ const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
1973
2023
  * [{ key, status: 'modified'|'untrusted' }]. No installed root = a fresh install = every hook untrusted.
1974
2024
  * An unreadable file is treated as absent (fail toward telling the user to review, never toward silence).
1975
2025
  */
1976
- export function codexHooksNeedingReview(installedRoot, candidateRoot) {
2026
+ export function codexHooksNeedingReview(installedRoot, candidateRoot, { contentTrust = false } = {}) {
1977
2027
  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));
2028
+ // Codex 0.164+ (or a version we could not read) also holds back every hook whose plugin bytes changed (#492).
2029
+ const contentChanged = contentTrust !== false && codexPluginContentChanged(installedRoot, candidateRoot);
2030
+ return codexTrustChanges(read(installedRoot), read(candidateRoot), undefined, { contentChanged });
2031
+ }
2032
+
2033
+ /** codex --version → whether it approves plugin hooks by content too (true/false), or null when unknown. */
2034
+ export function codexHostContentTrust({ codexBin = process.env.CODEX_BIN || 'codex', codexHome = codexHomeDir(), run = runHostCli } = {}) {
2035
+ try {
2036
+ const r = run(codexBin, ['--version'], { stdio: 'pipe', shell: false, encoding: 'utf8', timeout: 10_000,
2037
+ env: { ...process.env, CODEX_HOME: codexHome } });
2038
+ return r?.status === 0 ? codexUsesContentTrust(r.stdout) : null;
2039
+ } catch { return null; }
1979
2040
  }
1980
2041
 
1981
2042
  function codexMarketplaceTarget() {
@@ -2064,7 +2125,7 @@ export function runCodexJson(args, {
2064
2125
  }
2065
2126
  if (repaired.length) r = run(codexBin, args, options);
2066
2127
  }
2067
- if (r.missingBinary) return { ok: false, error: r.message };
2128
+ if (r.missingBinary) return { ok: false, error: r.message, missingBinary: true };
2068
2129
  if (r.error || r.status !== 0) {
2069
2130
  let detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
2070
2131
  if (snapshotFailure(r)) {
@@ -2082,7 +2143,8 @@ export function runCodexJson(args, {
2082
2143
  export function codexPluginStatus(options = {}) {
2083
2144
  const { runJson = runCodexJson, ...commandOptions } = options;
2084
2145
  const listed = runJson(['plugin', 'list', '--json'], commandOptions);
2085
- if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error };
2146
+ if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error,
2147
+ ...(listed.missingBinary ? { missingBinary: true } : {}) };
2086
2148
  const rows = Array.isArray(listed.value?.installed) ? listed.value.installed : [];
2087
2149
  const row = rows.find((candidate) => candidate?.pluginId === CODEX_PLUGIN_ID);
2088
2150
  return {
@@ -2131,6 +2193,8 @@ export function wireCodexPlugin({
2131
2193
  announce = true,
2132
2194
  runJson = runCodexJson,
2133
2195
  aliasHome,
2196
+ // true/false/null (unknown): does this Codex pin plugin contents in hook approval (0.164+)? Probed for a real host.
2197
+ contentTrust,
2134
2198
  } = {}) {
2135
2199
  if (!fs.existsSync(codexDir)) return { host: false, action: 'no-host' };
2136
2200
  const options = { codexBin, codexHome, cwd, repair: true };
@@ -2169,11 +2233,6 @@ export function wireCodexPlugin({
2169
2233
  path.join(REPO_ROOT, 'plugin'),
2170
2234
  )
2171
2235
  : { 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
2236
  if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
2178
2237
  const hookTrust = runJson === runCodexJson ? repairReleasedCodexHookTrust(before, options) : null;
2179
2238
  if (announce) {
@@ -2187,6 +2246,13 @@ export function wireCodexPlugin({
2187
2246
  };
2188
2247
  }
2189
2248
 
2249
+ // Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
2250
+ // trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
2251
+ // a boot-surface path, so the shell boundary above cannot see it. Computed offline, before the install
2252
+ // replaces the installed folder; on Codex 0.164+ a change to any plugin byte holds back every hook too (#492).
2253
+ const hooksNeedingReview = codexHooksNeedingReview(before.installed
2254
+ ? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'),
2255
+ { contentTrust: contentTrust ?? (runJson === runCodexJson ? codexHostContentTrust({ codexBin, codexHome }) : false) });
2190
2256
  if (runJson === runCodexJson && !localMarketplace) {
2191
2257
  try { localMarketplace = prepareCodexMarketplace(); }
2192
2258
  catch (error) {
@@ -2236,7 +2302,9 @@ export function wireCodexPlugin({
2236
2302
  }
2237
2303
  if (hooksNeedingReview.length && !trustVerified) {
2238
2304
  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(', ')}).`);
2305
+ + ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`
2306
+ + (hooksNeedingReview.some((h) => h.reason === 'plugin-content')
2307
+ ? ' This update changed the plugin\'s files, and Codex 0.164+ approves those bytes as well as each hook\'s definition.' : ''));
2240
2308
  info(` ${CODEX_TRUST_ACTION}`);
2241
2309
  }
2242
2310
  }
@@ -2260,6 +2328,38 @@ export function wireCodexPlugin({
2260
2328
  };
2261
2329
  }
2262
2330
 
2331
+ /**
2332
+ * After the Brain changed Codex's view of its plugin (new bytes, or hook trust written from another process), tell an
2333
+ * already-running shared Codex app-server to reload, so its open sessions stop reporting the previous hooks (#492).
2334
+ * Never starts, stops or restarts a server, and never grants an approval (scripts/codex-hook-trust-reconcile.mjs).
2335
+ */
2336
+ export const codexHostViewChanged = (codexPlugin) => ['installed', 'updated'].includes(codexPlugin?.action)
2337
+ || codexPlugin?.hookTrust?.changed === true || codexPlugin?.hookTrust?.changed === 'unknown';
2338
+ export async function refreshCodexSharedHost(codexPlugin, { codexHome = codexHomeDir(), refresh = refreshSharedCodexAppServer } = {}) {
2339
+ if (!codexHostViewChanged(codexPlugin)) return { state: 'not-needed', changed: false };
2340
+ return refresh({ codexHome });
2341
+ }
2342
+
2343
+ /**
2344
+ * What the owner is told after refreshCodexSharedHost. The reload reaches ALREADY-OPEN Codex sessions at once, and
2345
+ * changed Brain hook definitions are pending until reviewed, so those sessions stop running Brain hooks until the
2346
+ * owner re-reviews them in /hooks. Said plainly, so a pause in a session he had open is never a surprise (#492).
2347
+ */
2348
+ export function codexHostRefreshMessage(hostRefresh, codexPlugin = null) {
2349
+ if (hostRefresh?.state === 'refreshed') {
2350
+ // Review r2 (4.7.21): the authorized trust reconciliation may already have re-trusted the released hooks
2351
+ // (registry-verified, or nothing pending); then nothing pauses and saying so would be false.
2352
+ const retrusted = ['registry-verified', 'unchanged'].includes(codexPlugin?.hookTrust?.state);
2353
+ return { level: 'info', text: ' Running Codex app-server reloaded: Codex sessions that are already open pick up the change immediately; '
2354
+ + (retrusted ? 'Brain hooks were already re-trusted, so they keep running there (nothing restarted).'
2355
+ : 'Brain hooks pause in them until you re-review them in /hooks (no approval granted, nothing restarted).') };
2356
+ }
2357
+ if (hostRefresh?.state === 'failed') {
2358
+ return { level: 'warn', text: `Running Codex app-server not reloaded (${hostRefresh.reason}); its open sessions may report the previous Brain hooks until Codex restarts.` };
2359
+ }
2360
+ return null;
2361
+ }
2362
+
2263
2363
  export function codexSessionSafety(status) {
2264
2364
  if (status?.restartRequired === true) return status;
2265
2365
  return {
@@ -2547,6 +2647,61 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
2547
2647
  return { removed, changedFiles, conflicts };
2548
2648
  }
2549
2649
 
2650
+ /** Grok's state for --doctor and the install summary (#301): reported, never wired automatically. */
2651
+ function currentGrokStatus() {
2652
+ return grokStatus({ pluginInstalled: Boolean(pluginCommandsDir()), hookCount: shippedHookHandlerCounts().grok });
2653
+ }
2654
+ function grokCheckLines(check) {
2655
+ const status = currentGrokStatus();
2656
+ const line = grokStatusLine(status);
2657
+ if (!line) return [];
2658
+ // Advisory only when the Brain cannot reach Grok at all; the AGENTS.md line is the user's choice.
2659
+ return [check('grok', 'Grok', !status.pluginInstalled, line, 'npx ruvnet-brain', { advisory: true })];
2660
+ }
2661
+
2662
+ /** Opt-in, never automatic: offer the search-first line when Grok is here and the line is not. */
2663
+ async function offerGrokAgentsMd() {
2664
+ const status = currentGrokStatus();
2665
+ if (!status.present || status.groundingLine || !status.pluginInstalled) return;
2666
+ step('Optional — a search-first line for Grok', 'Grok drops prompt-time context, so this line is what makes it ground itself');
2667
+ info(`Adds ${c.bold('one line')} (between ruvnet-brain markers) to the bottom of ${c.bold(status.agentsFile)}:`);
2668
+ info(c.dim(` ${GROK_AGENTS_LINE}`));
2669
+ info('Backed up first; --uninstall removes it.');
2670
+ const yes = FLAG_GROK_AGENTS_MD || (await ask('Add it?', false, { blanketYes: false }));
2671
+ if (!yes) { info(`Skipped — add it any time with ${c.bold('npx ruvnet-brain --grok-agents-md')}`); return; }
2672
+ reportGrokAgentsLine(addGrokAgentsLine());
2673
+ }
2674
+ function reportGrokAgentsLine(result) {
2675
+ if (result === 'added') ok(`added the search-first line to ${grokPaths().agents}`);
2676
+ else if (result === 'present') ok('~/.grok/AGENTS.md already has a search_ruvnet line — left unchanged');
2677
+ else if (result === 'no-grok') info('Grok CLI not detected (~/.grok/bin/grok) — nothing changed');
2678
+ else warn('could not update ~/.grok/AGENTS.md — your file is untouched');
2679
+ }
2680
+
2681
+ /**
2682
+ * How many lifecycle handlers each SHIPPED host manifest registers, counted from the manifests
2683
+ * themselves so help and progress text can never again claim "zero" while they carry handlers
2684
+ * (#400). A missing or unreadable manifest is null ("unknown"), never 0.
2685
+ */
2686
+ export function shippedHookHandlerCounts(root = REPO_ROOT) {
2687
+ const count = (relative) => {
2688
+ try {
2689
+ const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
2690
+ return Object.values(doc?.hooks || {}).flat().reduce((n, group) => n + (Array.isArray(group?.hooks) ? group.hooks.length : 0), 0);
2691
+ } catch { return null; }
2692
+ };
2693
+ return {
2694
+ claude: count('plugin/hooks/hooks.json'),
2695
+ codex: count('plugin/hooks/codex-hooks.json'),
2696
+ grok: count('plugin/hooks/grok-hooks.json'),
2697
+ };
2698
+ }
2699
+
2700
+ function hookCountPhrase({ claude, codex } = shippedHookHandlerCounts()) {
2701
+ const n = (v) => (Number.isInteger(v) ? String(v) : 'an unknown number of');
2702
+ return `${n(claude)} Claude Code, ${n(codex)} Codex handlers`;
2703
+ }
2704
+
2550
2705
  export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'source' } = {}) {
2551
2706
  if (!['source', 'installed'].includes(scope)) throw new Error('invalid hook retirement scope');
2552
2707
  const files = [
@@ -3397,8 +3552,9 @@ async function doctorRun({ json }) {
3397
3552
  const turnLine = { id: 'turn-recording', label: 'Turn recording', state: turnStatus.state, detail: turnStatus.line, fix: null };
3398
3553
 
3399
3554
  // ── 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.
3555
+ // `--hooks` is a read-only proof that every registration in the shipped manifests is on the
3556
+ // continuity allowlist (#400: it is not, and never was, a zero-registration proof). It must never
3557
+ // execute hook bodies.
3402
3558
  let hookResult = null;
3403
3559
  if (FLAG_HOOKS) {
3404
3560
  const retirement = automaticHookRetirementStatus(REPO_ROOT, { scope: 'installed' });
@@ -3460,6 +3616,9 @@ async function doctorRun({ json }) {
3460
3616
  const nightlyFailed = nightlyHealth.state === 'degraded'
3461
3617
  || (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state));
3462
3618
  const codexFailed = codexWiringFailed || codexReadinessFailed || (codexLifecycleFailed && !codexTrustBypassed);
3619
+ // #420 item 4: per-project Claude installs lag silently behind the user one; advisory, with each project's own command.
3620
+ const projectReference = claudePluginStatus().version || PACKAGE_VERSION;
3621
+ const projectLine = projectInstallsCheck(claudeProjectInstalls({ referenceVersion: projectReference }), projectReference);
3463
3622
  const checks = [
3464
3623
  // With --hooks the install reading was never part of the verdict (release install verification); keep that.
3465
3624
  check('install', 'Install', !allGreen, `${v.repos} store(s), reader ${v.reader ? 'present' : 'MISSING'}, search server ${v.mcp ? 'present' : 'MISSING'}`,
@@ -3471,13 +3630,15 @@ async function doctorRun({ json }) {
3471
3630
  ...(cx.host ? [check('codex', 'Codex', codexFailed, codexFailed ? (codexWiringFailed ? 'host detected but NOT wired'
3472
3631
  : codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
3473
3632
  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'),
3633
+ nightlyFix(nightlyHealth)),
3475
3634
  // Scheduler ON but running yesterday's code: an advisory naming the exact fix, never a failing verdict.
3476
3635
  ...(nightlyHealth.state === 'on' && nightlyCode.state !== 'unregistered' ? [check('nightly-code', 'Nightly code', nightlyCode.state !== 'current',
3477
3636
  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
3637
  nightlyCode.fix || 'npx ruvnet-brain@latest --update', { advisory: true })] : []),
3479
3638
  check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
3639
+ ...grokCheckLines(check),
3480
3640
  ...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
3641
+ ...(projectLine ? [projectLine] : []),
3481
3642
  ...(agentdbLine ? [agentdbLine] : []),
3482
3643
  turnLine,
3483
3644
  ];
@@ -3754,6 +3915,17 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
3754
3915
  // failure mode for a stranger's first contact. With the variable unset, behavior is unchanged.
3755
3916
  const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
3756
3917
 
3918
+ /**
3919
+ * The doctor's fix for the Nightly line. Re-enabling repairs a job that cannot fire (not loaded, command drift);
3920
+ * a job that is loaded, verified, FIRED and exited non-zero (`lastExitCode`) is a failed update run, and
3921
+ * re-enabling it changes nothing — the update itself is the fix (4.7.21).
3922
+ */
3923
+ export function nightlyFix(nightlyHealth) {
3924
+ const fired = Number.isInteger(nightlyHealth?.lastExitCode) && nightlyHealth.lastExitCode !== 0;
3925
+ if (fired) return 'npx ruvnet-brain@latest --update';
3926
+ return nightlyHealth?.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update';
3927
+ }
3928
+
3757
3929
  export function classifyUpdaterExit(status, { fallbackAllowed = true, result = null, requireResult = false } = {}) {
3758
3930
  if (status === 12 && result?.terminalVerdict === 'cleanup-pending') {
3759
3931
  return { verdict: 'cleanup-pending', fallback: false, exitCode: 12 };
@@ -3783,8 +3955,26 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
3783
3955
  // Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
3784
3956
  // bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
3785
3957
  // 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 };
3958
+ // Exit 3 is the same transport failure one request later: the bundle downloaded, then its 64-byte .sig hit a
3959
+ // 5xx or a timeout. Nothing was touched, and a fallback would re-download the whole ~745 MB bundle. A 404 on
3960
+ // the .sig (a release published without one) is not transient and keeps the fallback.
3961
+ if ((status === 2 || status === 3) && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
3962
+ return { verdict: 'transient-network', fallback: false, exitCode: status };
3963
+ }
3964
+ // Neither of these is a broken updater, so a fresh reinstall cannot fix them; it only re-downloads ~745 MB
3965
+ // into the same wall, every night (4.7.21, audit C: a `.prune-*` left by a killed run made retention
3966
+ // `unsafe`, which forge-update reported as recovery-required, and the fallback reinstall hit it again).
3967
+ // - recovery-required: the updater stopped on evidence it must not discard; it names the recovery.
3968
+ // - exit 5: the offered corpus is incompatible with (or was already refused by) the runtime this run has
3969
+ // just re-stamped (ensureUpdaterPrerequisites), and a reinstall of the same package stamps the same one.
3970
+ if (result?.terminalVerdict === 'recovery-required') {
3971
+ return { verdict: 'recovery-required', fallback: false, exitCode: status || 1 };
3972
+ }
3973
+ if (status === 5) return { verdict: 'refused-incompatible', fallback: false, exitCode: 5 };
3974
+ // The updater was stopped by a signal (its own TTL, a logout, Ctrl-C) and settled itself as `interrupted`.
3975
+ // Nothing is broken, so a fresh reinstall would only re-download ~745 MB into the same interruption.
3976
+ if (result?.terminalVerdict === 'interrupted' || status === 143 || status === 130) {
3977
+ return { verdict: 'interrupted', fallback: false, exitCode: status || 143 };
3788
3978
  }
3789
3979
  return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
3790
3980
  }
@@ -3919,7 +4109,12 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3919
4109
  return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
3920
4110
  }
3921
4111
  results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
3922
- if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4112
+ // ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
4113
+ // cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
4114
+ // audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
4115
+ if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
4116
+ results.codexAbsent = true;
4117
+ } else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
3923
4118
  return fail();
3924
4119
  }
3925
4120
  }
@@ -3933,7 +4128,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3933
4128
  const applied = runStableSpine(apply);
3934
4129
  const okApplied = !applied.error && applied.status === 0;
3935
4130
  const codexReceipt = {
3936
- state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'),
4131
+ state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
3937
4132
  version: results.codex?.version || null,
3938
4133
  };
3939
4134
  if (results.codex?.restartRequired === true) {
@@ -4019,20 +4214,66 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
4019
4214
  }
4020
4215
  }
4021
4216
  }
4022
- if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' });
4023
- const convergence = classifyHostConvergence({
4217
+ // update-apply exits 75 when another live run holds the spine lock: nothing was applied, so it is not success.
4218
+ if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || (applied.status === 75
4219
+ ? 'Stable Spine is busy (another update holds its lock); nothing was applied — re-run when it finishes'
4220
+ : 'Stable Spine activation failed') });
4221
+ const convergedReceipt = {
4024
4222
  desiredVersion: PACKAGE_VERSION,
4025
4223
  hosts: {
4026
4224
  claude: claudeReceipt,
4027
4225
  codex: codexReceipt,
4028
4226
  },
4029
4227
  consoleRuntime: results.consoleRuntime,
4030
- });
4228
+ };
4229
+ const convergence = classifyHostConvergence(convergedReceipt);
4031
4230
  hostLock.release();
4032
4231
  return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
4232
+ ...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
4033
4233
  error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
4034
4234
  }
4035
4235
 
4236
+ /**
4237
+ * The ONE host-convergence entry for `--update` and `--host-sync-only`: syncHostsAfterUpdate, then the #492 reload of a
4238
+ * running shared Codex app-server when the Codex plugin or its hook trust changed (fresh installs reload it in the
4239
+ * main flow; before this, every existing user's `--update` skipped it — cross-vendor review E). The reload never
4240
+ * grants an approval and never starts, stops or restarts a server (refreshSharedCodexAppServer).
4241
+ */
4242
+ export async function convergeHostsAfterUpdate(cacheDir = resolvedKbDir(), { refreshHost = refreshCodexSharedHost,
4243
+ unattended = process.env.RUVNET_NIGHTLY === '1', recordNotice = recordCodexOvernightNotice,
4244
+ noticeHome = () => footprintRoots().brainHome, ...options } = {}) {
4245
+ const convergence = syncHostsAfterUpdate(cacheDir, options);
4246
+ if (!convergence.results?.codex) return convergence;
4247
+ // Owner decision (4.7.21 review r2): the unattended nightly never reloads the shared Codex app-server; a reload
4248
+ // pauses Brain hooks in sessions already open until someone re-reviews them in /hooks. It leaves a notice the next
4249
+ // SessionStart shows once instead. An interactive --update still reloads.
4250
+ if (unattended) {
4251
+ if (!codexHostViewChanged(convergence.results.codex)) return { ...convergence, hostRefresh: { state: 'not-needed', changed: false } };
4252
+ const recorded = recordNotice(noticeHome());
4253
+ return { ...convergence, hostRefresh: { state: 'deferred-unattended', changed: false, noticeRecorded: recorded } };
4254
+ }
4255
+ let hostRefresh;
4256
+ try { hostRefresh = await refreshHost(convergence.results.codex); }
4257
+ catch (error) { hostRefresh = { state: 'failed', changed: false, reason: error?.message || String(error) }; }
4258
+ const note = codexHostRefreshMessage(hostRefresh, convergence.results.codex);
4259
+ if (note) (note.level === 'warn' ? warn : info)(note.text);
4260
+ return { ...convergence, hostRefresh };
4261
+ }
4262
+
4263
+ /**
4264
+ * True when a host restart is the ONLY gap in a convergence receipt: every restart-flagged host is ready at the
4265
+ * expected version, and with those hosts read as "already-open windows only" everything else (other hosts, the
4266
+ * Console) is converged. `--update` records that as an advisory and keeps the knowledge update's success; any
4267
+ * other gap behind the restart keeps the failure (4.7.21, audit C: Codex's restartScope is always 'unproven').
4268
+ */
4269
+ export function hostRestartIsOnlyGap(receipt, expectedVersion = PACKAGE_VERSION) {
4270
+ const flagged = Object.values(receipt?.hosts || {}).filter((host) => host?.restartRequired === true);
4271
+ if (!flagged.length || flagged.some((host) => host.state !== 'ready' || !versionSatisfies(host.version, expectedVersion))) return false;
4272
+ const hosts = Object.fromEntries(Object.entries(receipt.hosts).map(([name, host]) => [name,
4273
+ host?.restartRequired === true ? { ...host, restartScope: 'open-sessions' } : host]));
4274
+ return classifyHostConvergence({ ...receipt, hosts }, expectedVersion).healthy === true;
4275
+ }
4276
+
4036
4277
  export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) {
4037
4278
  // Same 'ahead is not behind' rule as host convergence above (#123). A receipt written by a
4038
4279
  // NEWER generation than the installer currently running is not a version mismatch — it is a
@@ -4167,7 +4408,7 @@ async function runUpdate() {
4167
4408
  }
4168
4409
  if (FLAG_HOST_SYNC_ONLY) {
4169
4410
  info(c.dim('repairing host shells and managed model catalog without mutating KB bytes…\n'));
4170
- const convergence = syncHostsAfterUpdate(kbDir);
4411
+ const convergence = await convergeHostsAfterUpdate(kbDir);
4171
4412
  if (!convergence.ok) {
4172
4413
  warn(`host synchronization is incomplete${convergence.error ? ` (${convergence.error})` : ''}`);
4173
4414
  process.exitCode = 1;
@@ -4236,6 +4477,36 @@ async function runUpdate() {
4236
4477
  detail: { reason: 'process exited before terminal settlement' } }); } catch { /* retained lock and receipt are recovery evidence */ }
4237
4478
  };
4238
4479
  process.once('exit', exitGuard);
4480
+ // SIGTERM/SIGINT (4.7.21): the SessionStart self-heal kills this process group at its TTL (detach.mjs), and the
4481
+ // default disposition ends Node without an 'exit' event, so the refresh receipt stayed RUNNING and its lock
4482
+ // stayed held until a later run proved the owner dead. Settle FAILED with the signal, release the lock, exit
4483
+ // 128+n. While a child runs under spawnSync the handler cannot run, so the same settlement is applied as soon
4484
+ // as a child is seen to have died of one of these signals (a group kill reaches both).
4485
+ const signalGuard = (signal) => {
4486
+ if (!refreshSettled) {
4487
+ try {
4488
+ settleRefreshRun({ handle: refreshReceipt, lock: refreshLock, status: 'FAILED',
4489
+ detail: { reason: 'signal', signal, phase: 'interrupted', terminalVerdict: 'failed' } });
4490
+ refreshSettled = true;
4491
+ } catch { /* retained lock and receipt are recovery evidence */ }
4492
+ }
4493
+ console.error(`\n${c.red('✗ update interrupted by')} ${signal} — the refresh run was recorded FAILED; nothing is left locked.`);
4494
+ process.exit(128 + (os.constants.signals[signal] || 15));
4495
+ };
4496
+ process.once('SIGTERM', signalGuard);
4497
+ process.once('SIGINT', signalGuard);
4498
+ // The updater now settles its own SIGTERM/SIGINT (kb/forge-update.mjs exits 143/130 with an `interrupted`
4499
+ // result) instead of dying of the signal, so a group kill shows up here as that exit status too.
4500
+ const settleIfSignalled = (child) => {
4501
+ if (['SIGTERM', 'SIGINT'].includes(child?.signal)) signalGuard(child.signal);
4502
+ else if (child?.status === 143) signalGuard('SIGTERM');
4503
+ else if (child?.status === 130) signalGuard('SIGINT');
4504
+ };
4505
+ const detachGuards = () => {
4506
+ process.removeListener('exit', exitGuard);
4507
+ process.removeListener('SIGTERM', signalGuard);
4508
+ process.removeListener('SIGINT', signalGuard);
4509
+ };
4239
4510
  info(`brain dir: ${c.bold(kbDir)}`);
4240
4511
  // RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
4241
4512
  // reasons: (1) a kill between the two directory renames leaves no usable kb/ — the brain sits in a
@@ -4301,7 +4572,9 @@ async function runUpdate() {
4301
4572
  let updaterResult = null;
4302
4573
  try { if (updaterResultFile && fs.existsSync(updaterResultFile)) updaterResult = JSON.parse(fs.readFileSync(updaterResultFile, 'utf8')); }
4303
4574
  catch { updaterResult = null; }
4575
+ // Before settleIfSignalled, which exits: an interrupted run used to leak one ruvnet-update-result-* temp dir.
4304
4576
  if (updaterResultDir) fs.rmSync(updaterResultDir, { recursive: true, force: true });
4577
+ settleIfSignalled(r);
4305
4578
  // FALLBACK (2026-07-17), scoped 2026-07-18 to exists-but-FAILED only. An OLDER bundle whose
4306
4579
  // canonicalManifestUrl points at the dead main/kb/.last-built.json path and 404s (the exact break a
4307
4580
  // real user, Jan Lafko, hit). NEVER leave the user stranded at a 404: re-run THIS installer as a
@@ -4318,7 +4591,7 @@ async function runUpdate() {
4318
4591
  });
4319
4592
  if (outcome.verdict === 'refused-retained-copies') {
4320
4593
  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');
4594
+ 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
4595
  }
4323
4596
  if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
4324
4597
  // Host synchronization has a narrower contract than a full update: it must converge the
@@ -4370,6 +4643,7 @@ async function runUpdate() {
4370
4643
  fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
4371
4644
  env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
4372
4645
  }
4646
+ settleIfSignalled(fr);
4373
4647
  updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
4374
4648
  } else {
4375
4649
  updateStatus = outcome.exitCode;
@@ -4380,8 +4654,9 @@ async function runUpdate() {
4380
4654
  updaterExit, finalExit: updateStatus, fallback: outcome.fallback, verdict: outcome.verdict,
4381
4655
  updateResult: updaterResult,
4382
4656
  });
4383
- settleRefresh(updateStatus, { phase: 'source-enumeration', terminalVerdict: 'failed' });
4384
- process.removeListener('exit', exitGuard);
4657
+ settleRefresh(updateStatus, { phase: 'source-enumeration',
4658
+ terminalVerdict: outcome.verdict === 'recovery-required' ? 'recovery-required' : 'failed' });
4659
+ detachGuards();
4385
4660
  return;
4386
4661
  }
4387
4662
  if (cleanupPending) updateStatus = 0;
@@ -4419,7 +4694,7 @@ async function runUpdate() {
4419
4694
  recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { failures: installed.failures,
4420
4695
  reason: 'legacy updater returned success without a valid installed ReleaseCoverage projection' });
4421
4696
  settleRefresh(1, { phase: 'source-enumeration', terminalVerdict: 'failed' });
4422
- process.removeListener('exit', exitGuard);
4697
+ detachGuards();
4423
4698
  return;
4424
4699
  }
4425
4700
  phaseEvidence = Object.fromEntries(UPDATE_REFRESH_PHASES.map((phase) => [phase, {
@@ -4433,26 +4708,76 @@ async function runUpdate() {
4433
4708
  if (!phaseEvidence[phase]) {
4434
4709
  recordRefreshPhase(refreshReceipt, phase, 'FAIL', { reason: `updater omitted ${phase} evidence` });
4435
4710
  settleRefresh(1, { phase, terminalVerdict: 'failed' });
4436
- process.removeListener('exit', exitGuard);
4711
+ detachGuards();
4437
4712
  return;
4438
4713
  }
4439
4714
  recordRefreshPhase(refreshReceipt, phase, 'PASS', phaseEvidence[phase]);
4440
4715
  }
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
- });
4716
+ const finished = await finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult, cleanupPending,
4717
+ settle: settleRefresh, converge: async () => await convergeHostsAfterUpdate(kbDir) });
4718
+ detachGuards();
4719
+ await printConfirmation({ footprint: finished.footprint?.after || null });
4720
+ }
4721
+
4722
+ /**
4723
+ * What host convergence means for an `--update` whose knowledge phases all PASSED (the shared 4.7.21 exit
4724
+ * contract). Converged: PASS. A host restart as the ONLY gap (Codex reports restartScope 'unproven' on every
4725
+ * boot-level change): the knowledge update stands, exit 0, and the phase is recorded required:false with
4726
+ * restartPending, so the nightly no longer reports a good update as FAILED and still reconciles its modules
4727
+ * (audit C, real receipt on the owner's Mac, Oct 24). Anything else: a required FAIL, exit 1.
4728
+ */
4729
+ export function hostConvergenceUpdateVerdict(convergence) {
4730
+ if (convergence?.ok && convergence.convergence?.healthy === true) return { status: 'PASS', required: true, exitCode: 0 };
4731
+ if (convergence?.restartOnly === true && convergence.convergence?.state === 'host-restart-required') {
4732
+ return { status: 'PASS', required: false, restartPending: true, exitCode: 0 };
4455
4733
  }
4734
+ return { status: 'FAIL', required: true, exitCode: 1 };
4735
+ }
4736
+
4737
+ /**
4738
+ * The part of `--update` after every knowledge phase has PASSED: host convergence, managed router additions,
4739
+ * nightly module reconciliation, lifecycle retention, footprint, terminal settlement. Exported with its effects
4740
+ * injectable so the exit contract is testable without a network or a real host:
4741
+ * 0 knowledge updated (a host restart as the only gap is an advisory); 12 cleanup pending; 1 a real failure.
4742
+ * Lifecycle-evidence retention is an ADVISORY here, never the terminal verdict: it used to turn every good
4743
+ * update into recovery-required exit 1 and a ~745 MB reinstall, nightly (audit C). The updater's own
4744
+ * `retentionAdvisory` (forge-update, 4.7.21) is recorded beside the installer's measurement.
4745
+ */
4746
+ export async function finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult = null, cleanupPending = false,
4747
+ settle,
4748
+ converge = convergeHostsAfterUpdate,
4749
+ reconcile = reconcileNightlyModules,
4750
+ routerDefault = refreshManagedRouterDefault,
4751
+ managedCatalog = () => applyManagedCatalogUpdate({ routerDir: path.join(os.homedir(), '.claude', 'model-router'), packageRoot: REPO_ROOT }),
4752
+ prune = pruneLifecycleEvidence,
4753
+ enforce = enforceFootprint,
4754
+ enroll = () => {
4755
+ // `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
4756
+ // hand. enableNightly() prints its own real verification (plist path + launchctl result).
4757
+ if (!FLAG_AUTO) return;
4758
+ info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
4759
+ enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
4760
+ },
4761
+ } = {}) {
4762
+ let updateStatus = 0;
4763
+ info(c.dim('\nsynchronizing every detected host to this exact published version…\n'));
4764
+ const convergence = await converge(kbDir);
4765
+ const host = hostConvergenceUpdateVerdict(convergence);
4766
+ if (host.exitCode !== 0) {
4767
+ let active;
4768
+ try { active = JSON.parse(fs.readFileSync(path.join(footprintRoots().brainHome, 'active.json'), 'utf8')); } catch { /* report unknown activation */ }
4769
+ warn(hostSynchronizationFailureMessage(convergence, active));
4770
+ updateStatus = 1;
4771
+ } else if (host.restartPending) {
4772
+ warn(`the knowledge update succeeded; a host restart is still pending — ${convergence.convergence.action}`);
4773
+ } else if (convergence.convergence?.notice) info(convergence.convergence.notice);
4774
+ recordRefreshPhase(refreshReceipt, 'host-convergence', host.status, {
4775
+ state: convergence?.convergence?.state || null, error: convergence?.error || null,
4776
+ ...(host.restartPending ? { restartPending: true, advisory: 'host restart pending; knowledge update succeeded' } : {}),
4777
+ ...(convergence?.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
4778
+ execution: { kind: 'executed', runId: refreshReceipt.runId },
4779
+ required: host.required,
4780
+ });
4456
4781
  // Issue #87: a normal lifecycle update must also carry MANAGED MODEL additions to an existing
4457
4782
  // user. This call used to live only in offerRouterProfile() — the fresh-install path — so someone
4458
4783
  // who already had ~/.claude/model-router/catalog.json (i.e. every existing user, the only ones who
@@ -4460,13 +4785,10 @@ async function runUpdate() {
4460
4785
  // updated. Additive and non-interactive; their overrides win, metered rows are never auto-enabled,
4461
4786
  // and a failure here is reported but never fails an otherwise-good update.
4462
4787
  if (updateStatus === 0) {
4463
- const defaultReceipt = refreshManagedRouterDefault();
4788
+ const defaultReceipt = routerDefault();
4464
4789
  recordRefreshAdvisory(refreshReceipt, 'managed-router-default', defaultReceipt.action === 'failed' ? 'SKIP' : 'PASS', defaultReceipt);
4465
4790
  try {
4466
- const managed = applyManagedCatalogUpdate({
4467
- routerDir: path.join(os.homedir(), '.claude', 'model-router'),
4468
- packageRoot: REPO_ROOT,
4469
- });
4791
+ const managed = managedCatalog();
4470
4792
  if (managed.action === 'merged' && managed.added.length) {
4471
4793
  ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
4472
4794
  }
@@ -4476,37 +4798,41 @@ async function runUpdate() {
4476
4798
  recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'SKIP', { reason: e.message });
4477
4799
  }
4478
4800
  }
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 });
4801
+ // Only enroll if the update itself succeeded — never promise "you're set forever" on a failed update.
4802
+ if (updateStatus === 0) enroll();
4803
+ // The knowledge phases passed, so the nightly must run the installed modules even when a host did not
4804
+ // converge; otherwise a host problem froze the 03:30 job on yesterday's coordinator (audit C).
4805
+ reconcile({ brainHome, refreshReceipt });
4487
4806
  let retention;
4488
4807
  try {
4489
- retention = pruneLifecycleEvidence({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
4808
+ retention = prune({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
4490
4809
  preserveTransactionPaths: updaterResult?.transactionReceipts ? [updaterResult.transactionReceipts] : [] });
4491
4810
  } catch (error) {
4492
4811
  retention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
4493
4812
  withinBudget: false, unsafe: [{ path: brainHome, reason: error.message }] };
4494
4813
  }
4495
- const retentionFailed = retention.withinBudget !== true;
4496
- const cleanupFailed = cleanupPending || retentionFailed;
4497
- recordRefreshPhase(refreshReceipt, 'cleanup', cleanupFailed ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
4814
+ const retentionFailed = retention?.withinBudget !== true;
4815
+ const updaterRetention = updaterResult?.retentionAdvisory || null;
4816
+ recordRefreshPhase(refreshReceipt, 'cleanup', cleanupPending ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
4498
4817
  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'),
4818
+ : (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
4501
4819
  storageDelta: updaterResult?.storageDelta || null,
4502
4820
  lifecycleRetention: retention,
4503
4821
  execution: { kind: 'executed', runId: refreshReceipt.runId },
4504
- required: cleanupFailed || updateStatus === 0,
4822
+ required: cleanupPending || updateStatus === 0,
4505
4823
  });
4824
+ if (retentionFailed || updaterRetention) {
4825
+ const unsafe = (retentionFailed ? retention?.unsafe || [] : []).map((entry) => entry?.path).filter(Boolean);
4826
+ warn(`lifecycle evidence could not be fully pruned (advisory; the update itself stands)${unsafe.length ? `: ${unsafe.slice(0, 3).join(', ')}` : ''}`);
4827
+ try {
4828
+ recordRefreshAdvisory(refreshReceipt, 'lifecycle-retention', 'FAIL', {
4829
+ installer: retentionFailed ? retention : null, updater: updaterRetention });
4830
+ } catch { /* the advisory never blocks settlement */ }
4831
+ }
4506
4832
  // ADR-0098: after the swap, enforce the footprint (the updater just released its own rollback; this
4507
4833
  // releases everything else that must not exist) and record the result on the receipt as an advisory —
4508
4834
  // a footprint problem is reported, it never fails an otherwise-good update.
4509
- const footprint = enforceFootprint({ holdingRefreshLock: true });
4835
+ const footprint = enforce({ holdingRefreshLock: true });
4510
4836
  try {
4511
4837
  const after = footprint?.after;
4512
4838
  recordRefreshAdvisory(refreshReceipt, 'footprint', after && after.kbCopies === 1 && !after.cruft.length && after.withinBudget ? 'PASS' : 'FAIL', {
@@ -4514,13 +4840,15 @@ async function runUpdate() {
4514
4840
  budgetBytes: after?.budgetBytes ?? null, removed: footprint?.removed.length ?? 0, freedBytes: footprint?.freedBytes ?? 0,
4515
4841
  });
4516
4842
  } 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'),
4843
+ // A genuine failure (a required host convergence) outranks cleanup-pending: 12 tells the coordinator and
4844
+ // host-update the knowledge applied and only redundant cleanup is owed, so it must not mask an exit 1.
4845
+ const failed = updateStatus !== 0;
4846
+ const exitCode = failed ? updateStatus : (cleanupPending ? 12 : 0);
4847
+ settle(exitCode, {
4848
+ phase: failed ? 'failed' : (cleanupPending ? 'cleanup' : 'complete'),
4849
+ terminalVerdict: failed ? 'failed' : cleanupPending ? 'cleanup-pending' : (outcome?.verdict === 'noop' ? 'noop' : 'applied'),
4521
4850
  storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
4522
- process.removeListener('exit', exitGuard);
4523
- await printConfirmation({ footprint: footprint?.after || null });
4851
+ return { exitCode, footprint, hostConvergence: host };
4524
4852
  }
4525
4853
 
4526
4854
  /**
@@ -4625,15 +4953,56 @@ const SPEND_GUARD_LABEL = 'com.ruvnet.spend-watchdog';
4625
4953
  const spendGuardScriptPath = () => path.join(os.homedir(), '.claude', 'scripts', 'api-spend-watchdog.mjs');
4626
4954
  const spendGuardPlistPath = () => path.join(os.homedir(), 'Library', 'LaunchAgents', `${SPEND_GUARD_LABEL}.plist`);
4627
4955
 
4628
- function enableSpendGuard() {
4956
+ /**
4957
+ * Every LaunchAgent that already runs an api-spend-watchdog.mjs, under ANY label (the owner's runs as
4958
+ * com.stuartkerr.api-spend-watchdog). Matched on ProgramArguments, so a second hourly job is never added beside
4959
+ * an existing one. Read-only; a binary plist is converted with plutil, an unreadable one is skipped.
4960
+ */
4961
+ export function spendGuardAgents({ agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents') } = {}) {
4962
+ let names = [];
4963
+ try { names = fs.readdirSync(agentsDir).filter((name) => name.endsWith('.plist')); } catch { return []; }
4964
+ const found = [];
4965
+ for (const name of names) {
4966
+ const file = path.join(agentsDir, name);
4967
+ let text;
4968
+ try {
4969
+ const bytes = fs.readFileSync(file);
4970
+ text = bytes.subarray(0, 6).toString('latin1') === 'bplist'
4971
+ ? spawnSync('plutil', ['-convert', 'xml1', '-o', '-', file], { encoding: 'utf8' }).stdout || ''
4972
+ : bytes.toString('utf8');
4973
+ } catch { continue; }
4974
+ const args = /<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1] || '';
4975
+ if (!/api-spend-watchdog\.mjs/.test(args)) continue;
4976
+ const label = /<key>\s*Label\s*<\/key>\s*<string>([^<]*)<\/string>/.exec(text)?.[1]?.trim() || name.replace(/\.plist$/, '');
4977
+ found.push({ label, plist: file });
4978
+ }
4979
+ return found;
4980
+ }
4981
+
4982
+ export function enableSpendGuard() {
4983
+ // A spend watchdog already scheduled under another label (the owner's own job, or an earlier hand install)
4984
+ // is the same safety net: say where it is and add nothing. Two hourly jobs mean two alerts per event.
4985
+ const foreign = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
4986
+ if (foreign.length) {
4987
+ ok(`spend watchdog already installed as ${c.bold(foreign[0].label)} (${foreign[0].plist}) — not adding a second hourly job`);
4988
+ return 'already-on';
4989
+ }
4629
4990
  // The npx checkout is ephemeral, so copy the bundled watchdog to a persistent home the launchd
4630
- // job can point at for good.
4991
+ // job can point at for good — but NEVER over a different file already there: it may be the user's own
4992
+ // fixed copy (the owner's 2026-10-25 flood fix was one interactive install away from being reverted).
4631
4993
  const src = path.join(__dirname, 'api-spend-watchdog.mjs');
4632
4994
  const dst = spendGuardScriptPath();
4633
4995
  if (!fs.existsSync(src)) { warn('spend-watchdog source missing from this bundle — skipping (non-fatal)'); return 'no-source'; }
4634
4996
  fs.mkdirSync(path.dirname(dst), { recursive: true });
4635
- fs.copyFileSync(src, dst);
4636
- ok(`installed the spend watchdog → ${c.bold(dst)}`);
4997
+ let existing = null;
4998
+ try { existing = fs.readFileSync(dst); } catch { /* absent */ }
4999
+ if (existing && !existing.equals(fs.readFileSync(src))) {
5000
+ warn(`kept your existing ${c.bold(dst)} — it differs from the bundled watchdog, so it was not overwritten`);
5001
+ info(`to use the bundled one instead: ${c.bold(`rm ${dst}`)} then ${c.bold('npx ruvnet-brain --enable-spend-guard')}`);
5002
+ } else if (!existing) {
5003
+ fs.copyFileSync(src, dst);
5004
+ ok(`installed the spend watchdog → ${c.bold(dst)}`);
5005
+ } else ok(`spend watchdog script already current → ${c.bold(dst)}`);
4637
5006
 
4638
5007
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
4639
5008
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -4686,6 +5055,8 @@ function disableSpendGuard() {
4686
5055
  const plistPath = spendGuardPlistPath();
4687
5056
  const scriptPath = spendGuardScriptPath();
4688
5057
  const existed = fs.existsSync(plistPath);
5058
+ // The script stays when another LaunchAgent runs it: that job is not ours to break.
5059
+ const sharedWith = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
4689
5060
  if (TEST_MODE) {
4690
5061
  warn('RUVNET_BRAIN_TEST=1 — skipping launchctl bootout (plist removal only)');
4691
5062
  } else {
@@ -4693,7 +5064,8 @@ function disableSpendGuard() {
4693
5064
  spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${SPEND_GUARD_LABEL}`], { stdio: 'ignore' });
4694
5065
  }
4695
5066
  let failed = false;
4696
- for (const p of [plistPath, scriptPath]) {
5067
+ if (sharedWith.length && fs.existsSync(scriptPath)) info(`kept ${scriptPath} — ${sharedWith[0].label} runs it`);
5068
+ for (const p of sharedWith.length ? [plistPath] : [plistPath, scriptPath]) {
4697
5069
  if (!fs.existsSync(p)) continue;
4698
5070
  try { fs.rmSync(p); } catch (e) {
4699
5071
  failed = true;
@@ -4748,10 +5120,10 @@ function footprintEvidence() {
4748
5120
 
4749
5121
  /** Enforce the footprint now: remove what must not exist (proof-gated), rotate over-cap logs, collect
4750
5122
  * lease-free plugin generations. Narrates every removal and every refusal; never throws. */
4751
- export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false } = {}) {
5123
+ export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false, archive = false } = {}) {
4752
5124
  let result;
4753
5125
  try {
4754
- result = sweepFootprint({ apply: true, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
5126
+ result = sweepFootprint({ apply: true, archive, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
4755
5127
  collectPluginGenerations: ({ registryPath, apply }) => prunePluginGenerations({ registryPath, apply }),
4756
5128
  pruneEvidence: pruneEvidence ? (args) => pruneLifecycleEvidence(args) : null });
4757
5129
  } catch (error) {
@@ -4810,7 +5182,10 @@ export function machineFootprint() {
4810
5182
  }
4811
5183
  if (process.platform === 'darwin') {
4812
5184
  add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
4813
- add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
5185
+ // A script another LaunchAgent runs is not ours to list for removal (disableSpendGuard keeps it).
5186
+ if (!spendGuardAgents().some((agent) => agent.label !== SPEND_GUARD_LABEL)) {
5187
+ add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
5188
+ }
4814
5189
  }
4815
5190
  const cmds = pluginCommandsDir();
4816
5191
  if (cmds) items.push({
@@ -4831,6 +5206,12 @@ export function machineFootprint() {
4831
5206
  items.push({ label: 'CLAUDE.md block (6 lines, between markers)', path: cmdPath, undo: 'npx ruvnet-brain --uninstall' });
4832
5207
  }
4833
5208
  } catch { /* unreadable */ }
5209
+ try {
5210
+ const grokAgents = grokPaths().agents;
5211
+ if (fs.existsSync(grokAgents) && fs.readFileSync(grokAgents, 'utf8').includes(CLAUDE_MD_START)) {
5212
+ items.push({ label: 'Grok AGENTS.md search-first line (between markers)', path: grokAgents, undo: 'npx ruvnet-brain --uninstall' });
5213
+ }
5214
+ } catch { /* unreadable */ }
4834
5215
  add('Usage-counts preference', telemetryConsentPath(), 'delete this file');
4835
5216
 
4836
5217
  // EVERYTHING ELSE THIS INSTALLER WRITES. The first version of this listed the KB bundle and
@@ -4935,8 +5316,7 @@ function showWhatsNew() {
4935
5316
  * Backed up and written atomically, same as when it was added — taking something away is at least
4936
5317
  * as sensitive as putting it there.
4937
5318
  */
4938
- function removeClaudeMdBlock() {
4939
- const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
5319
+ function removeClaudeMdBlock(p = path.join(os.homedir(), '.claude', 'CLAUDE.md')) {
4940
5320
  let src = '';
4941
5321
  try { src = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { return 'unreadable'; }
4942
5322
  // PAIR THE MARKERS PROPERLY. The first version took the FIRST start and the FIRST end, unpaired.
@@ -5015,6 +5395,7 @@ function uninstallAll() {
5015
5395
 
5016
5396
  const claudeMd = removeClaudeMdBlock();
5017
5397
  if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
5398
+ if (removeClaudeMdBlock(grokPaths().agents) === 'removed') ok('removed our line from ~/.grok/AGENTS.md (your content untouched, backup saved)');
5018
5399
 
5019
5400
  // GAP FIX: this used to be permanently "manual" — machineFootprint() treated ANY settings.json edit
5020
5401
  // as something only the user could safely touch. That blanket rule was right for edits we cannot
@@ -5085,9 +5466,16 @@ function uninstallAll() {
5085
5466
  // Exported (testable under RUVNET_BRAIN_IMPORT_ONLY=1, like offerNightly). Never throws — the caller
5086
5467
  // also guards, because a finished install must never be broken by an optional safety offer.
5087
5468
  export async function offerSpendGuard() {
5469
+ // Read-only detection first, so an existing guard under ANY label is reported as on (and never offered twice).
5470
+ if (process.platform === 'darwin') {
5471
+ const existing = spendGuardAgents();
5472
+ if (existing.length || fs.existsSync(spendGuardPlistPath())) {
5473
+ ok(`spend watchdog already installed${existing.length ? ` as ${existing[0].label}` : ''} — runaway API spend will alert you`);
5474
+ return 'already-on';
5475
+ }
5476
+ }
5088
5477
  if (FLAG_NO_NIGHTLY_PROMPT || TEST_MODE) return 'suppressed';
5089
5478
  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
5479
 
5092
5480
  step(
5093
5481
  'One more safety net — a spend watchdog',
@@ -6262,6 +6650,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6262
6650
  ` • the Codex host ${hostReady && pluginReady ? c.green('wired at user scope') : c.yellow('needs attention')} — search_ruvnet + native Brain skills`,
6263
6651
  );
6264
6652
  }
6653
+ const grokLine = grokStatusLine(currentGrokStatus());
6654
+ if (grokLine) console.log(` • ${grokLine}`);
6265
6655
  if (env) {
6266
6656
  const t = (okv) => (okv ? c.green('✓ present') : c.yellow('not added (optional)'));
6267
6657
  console.log(` • rUv build toolkit — Ruflo: ${t(env.ruflo)} · RuVector: ${t(env.ruvector)} ${c.dim('answers work without them')}`);
@@ -6276,8 +6666,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6276
6666
  console.log(`\n ${c.bold('Where it runs:')} ${c.bold(`everywhere you use ${hostLabel}`)} — CLI and supported editor/desktop surfaces.`);
6277
6667
  console.log(` It's ${c.bold('user-level (global)')}: open ANY repo or folder and it's already there. Nothing per project,`);
6278
6668
  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.`);
6669
+ console.log(`\n ${c.bold('How it runs:')} search_ruvnet, Brain skills and proof commands when you ask, plus the allowlisted`);
6670
+ console.log(` lifecycle hooks (${hookCountPhrase()}): grounding reminders, a write gate, and continuity snapshots.`);
6281
6671
  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
6672
  console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
6283
6673
 
@@ -6294,12 +6684,12 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6294
6684
  console.log(`\n ${c.bold('How you\'ll know it\'s working:')}`);
6295
6685
  console.log(` ${c.green('✓')} ${c.bold('search_ruvnet')} returns cited source paths when invoked.`);
6296
6686
  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.')}`);
6687
+ console.log(` ${c.green('✓')} Writing rUv-product code the Brain has not looked up is refused until search_ruvnet grounds it.`);
6298
6688
 
6299
6689
  // ── honest expectations ──
6300
6690
  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.`);
6691
+ console.log(` • Grounding reminders are added only to prompts that touch the rUv stack.`);
6692
+ console.log(` • Other work proceeds normally; the write gate is the only hook that refuses by default.`);
6303
6693
  console.log(` • Not sure it's on? Run ${c.bold('npx ruvnet-brain --doctor')} any time for a health check.
6304
6694
  • Want to see it answer, live, right now? ${c.bold('npx ruvnet-brain --demo')} — 2 real questions, real cited answers.`);
6305
6695
 
@@ -6325,7 +6715,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
6325
6715
  console.log(`\n ${c.bold('If it earns it:')} a GitHub star helps other people find the brain —`);
6326
6716
  console.log(` ${c.bold('https://github.com/stuinfla/ruvnet-brain')} ${c.dim('· feedback in one command: npx ruvnet-brain --feedback')}`);
6327
6717
 
6328
- console.log(`\n ${c.dim('The plugin is disable-able and installs no automatic lifecycle handlers.')}`);
6718
+ 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
6719
  console.log('');
6330
6720
  }
6331
6721
 
@@ -6345,11 +6735,15 @@ Usage:
6345
6735
  footprint within budget, no cruft. --doctor --json prints only that, as JSON.
6346
6736
  npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
6347
6737
  private files the live brain lacks — old installers, over-cap logs), then confirm.
6738
+ npx ruvnet-brain --clean --archive
6739
+ Same, and for a kept copy: copy only the files it alone holds into
6740
+ kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
6348
6741
  EXITS NON-ZERO when the install is genuinely broken, so it can gate a
6349
6742
  script: npx ruvnet-brain --doctor && ./deploy.sh
6350
6743
  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.
6744
+ Read-only proof that the shipped hook manifests register only the
6745
+ allowlisted continuity handlers (${hookCountPhrase()}).
6746
+ Executes no hook body.
6353
6747
  npx ruvnet-brain --demo Guided walkthrough — 2 real questions, real cited answers
6354
6748
  npx ruvnet-brain --feedback Tell us how it went — prefills a GitHub Discussion with your brain
6355
6749
  version, platform, and a 3-line health summary (you see exactly
@@ -6366,6 +6760,8 @@ Usage:
6366
6760
  block only — your own CLAUDE.md content is preserved and backed up)
6367
6761
  npx ruvnet-brain --enable-spend-guard Install the hourly runaway-agent spend alarm (alert-only)
6368
6762
  npx ruvnet-brain --disable-spend-guard Remove it (safe to run any time)
6763
+ npx ruvnet-brain --grok-agents-md Grok CLI only, opt-in: add one search-first line to ~/.grok/AGENTS.md
6764
+ (only if no search_ruvnet line is there; backed up; --uninstall removes it)
6369
6765
  npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
6370
6766
  (appended at the bottom between markers; your file is backed up first)
6371
6767
  (a default install RECOMMENDS nightly and asks, defaulting to yes)
@@ -6412,11 +6808,7 @@ the installer reports that boot-level declarations changed.
6412
6808
  // in a unit test ran a real install against the maintainer's HOME. Direct argv identity is the
6413
6809
  // authority boundary. The old IMPORT_ONLY flag may still be present in a long-lived test worker;
6414
6810
  // 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));
6811
+ const invokedDirectly = isDirectInvocation(import.meta.url);
6420
6812
  if (!invokedDirectly) return;
6421
6813
  if (FLAG_HELP) return showHelp();
6422
6814
  if (NAMED_RELEASE?.error) {
@@ -6461,7 +6853,7 @@ the installer reports that boot-level declarations changed.
6461
6853
  // remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
6462
6854
  if (FLAG_CLEAN) {
6463
6855
  if (!FLAG_JSON) printBanner('clean');
6464
- const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON });
6856
+ const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON, archive: FLAG_ARCHIVE });
6465
6857
  const result = await printConfirmation({ footprint: swept?.after || null, json: FLAG_JSON });
6466
6858
  process.exitCode = swept && result.footprint.kbCopies === 1 && result.footprint.cruft.length === 0 ? 0 : 1;
6467
6859
  return;
@@ -6489,6 +6881,7 @@ the installer reports that boot-level declarations changed.
6489
6881
  if (FLAG_ENABLE_SPEND_GUARD) { enableSpendGuard(); return; }
6490
6882
  if (FLAG_DISABLE_SPEND_GUARD) { disableSpendGuard(); return; }
6491
6883
  if (FLAG_UNINSTALL) { uninstallAll(); return; }
6884
+ if (FLAG_GROK_AGENTS_MD) { reportGrokAgentsLine(addGrokAgentsLine()); return; }
6492
6885
  if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
6493
6886
  if (FLAG_WHATS_NEW) { showWhatsNew(); return; }
6494
6887
 
@@ -6686,6 +7079,9 @@ the installer reports that boot-level declarations changed.
6686
7079
  codexHost = wireCodexHost();
6687
7080
  if (codexHost.host) {
6688
7081
  codexPlugin = wireCodexPlugin();
7082
+ const hostRefresh = await refreshCodexSharedHost(codexPlugin);
7083
+ const refreshNote = codexHostRefreshMessage(hostRefresh, codexPlugin);
7084
+ if (refreshNote) (refreshNote.level === 'warn' ? warn : info)(refreshNote.text);
6689
7085
  if (codexPlugin.available) printCodexLifecycle(await codexLifecycleStatus());
6690
7086
  }
6691
7087
  } catch (e) {
@@ -6711,6 +7107,7 @@ the installer reports that boot-level declarations changed.
6711
7107
  const env = detectEnvironment();
6712
7108
  try { await offerStack(env); } catch (e) { warn(`(toolkit check skipped: ${e && e.message})`); }
6713
7109
  try { await offerClaudeMd(); } catch { /* non-fatal — never let an offer break the install */ }
7110
+ try { await offerGrokAgentsMd(); } catch { /* non-fatal — never let an offer break the install */ }
6714
7111
  // Stuart's requirement: a default install must end by clearly recommending nightly auto-updates
6715
7112
  // and asking, DEFAULTING TO YES (TTY + macOS + not already on). Non-fatal like every other offer.
6716
7113
  let nightly = 'skipped';