ruvnet-brain 4.3.9 → 4.3.10

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 (83) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +11 -2
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +472 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. package/scripts/wired-check.mjs +31 -2
package/bin/install.mjs CHANGED
@@ -21,12 +21,27 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
21
21
  import readline from 'node:readline';
22
22
  import crypto from 'node:crypto';
23
23
  import { applyBrainProfile, readBrainProfile } from '../kb/brain-profile.mjs';
24
+ import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, recordRefreshAdvisory,
25
+ recordRefreshPhase, settleRefreshRun, UPDATE_REFRESH_PHASES } from '../kb/refresh-run.mjs';
26
+ import { pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
24
27
  import {
25
28
  requiredEmbedderModels,
26
29
  missingEmbedderModels,
27
30
  } from '../kb/model-requirements.mjs';
28
31
  import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
29
32
  import { cmpVersion } from '../scripts/stack-sync.mjs';
33
+ import { validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
34
+ import {
35
+ NIGHTLY_LABEL,
36
+ installNightlyRunner,
37
+ installScheduler,
38
+ nightlyArtifact,
39
+ removeScheduler,
40
+ readNightlyRegistration,
41
+ resolveNightlyProofBundle,
42
+ schedulerStatus,
43
+ verifyNightlyExecutionIdentity,
44
+ } from '../plugin/scripts/nightly-scheduler.mjs';
30
45
 
31
46
  // ISSUE #123 — AN INSTALL THAT IS AHEAD OF THE PUBLISHED RELEASE IS NOT A BROKEN INSTALL.
32
47
  // Host convergence compared installed === PACKAGE_VERSION, so 4.0.29-dev against a published
@@ -94,10 +109,8 @@ const FLAG_LOCAL = argv.includes('--local');
94
109
  const FLAG_FORCE = argv.includes('--force');
95
110
  const FLAG_HELP = argv.includes('--help') || argv.includes('-h');
96
111
  const FLAG_DOCTOR = argv.includes('--doctor');
97
- // `--doctor --hooks`: the post-install hook battery (ADR-053 §2 / ADR-055 build item 2). Fires every
98
- // registration in the INSTALLED hooks.json through the real shim under four stdin regimes with an
99
- // external process-group watchdog. Separate flag because it spawns real hooks — the plain --doctor
100
- // stays a pure read.
112
+ // Backward-compatible spelling for the retirement check. It never executes a hook: the only healthy
113
+ // state is zero Brain-owned registrations in both shipped host manifests.
101
114
  const FLAG_HOOKS = argv.includes('--hooks');
102
115
  const FLAG_NO_VERIFY = argv.includes('--no-verify');
103
116
  // Escape hatch for the installer's closing self-check ONLY (it never disables --doctor's verdict).
@@ -164,11 +177,13 @@ function printBanner(subtitle) {
164
177
  console.log(`${c.cyan(line)}`);
165
178
  }
166
179
 
167
- function die(msg, hint) {
180
+ function die(msg, hint, recoveryRequired = false) {
168
181
  console.error(`\n${c.red('✗ install stopped:')} ${msg}`);
169
182
  if (hint) console.error(`\n${hint}`);
170
183
  console.error(
171
- `\nNothing is left half-installed — fix the above and re-run the same command (it's safe to re-run).`,
184
+ recoveryRequired
185
+ ? '\nRecovery requires inspection of the retained directories before retrying.'
186
+ : `\nNothing is left half-installed — fix the above and re-run the same command (it's safe to re-run).`,
172
187
  );
173
188
  process.exit(1);
174
189
  }
@@ -356,6 +371,15 @@ function resolveCacheDir() {
356
371
 
357
372
  // ── step: obtain the bundle (local or download) ──────────────────────────────────────────────────
358
373
  async function obtainBundle(release) {
374
+ const proofBundle = resolveNightlyProofBundle({
375
+ brainHome: process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'),
376
+ env: process.env,
377
+ });
378
+ if (proofBundle) {
379
+ step('Using the registered proof bundle', 'the native scheduler proof binds these exact bytes');
380
+ info(`source: ${proofBundle.spec}`);
381
+ return { zipPath: proofBundle.spec, downloaded: false };
382
+ }
359
383
  const localZip = path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip');
360
384
  const localDir = path.join(REPO_ROOT, 'dist', 'ruvnet-brain');
361
385
  const haveLocal = fs.existsSync(localZip);
@@ -455,21 +479,23 @@ export function copyLocalBundleInto(sourceDir, cacheDir) {
455
479
  return copied;
456
480
  }
457
481
 
458
- async function unzipInto(zipPath, cacheDir, sourceDir = null) {
482
+ export async function unzipInto(zipPath, cacheDir, sourceDir = null) {
459
483
  step(
460
484
  'Unpacking the brain into place',
461
485
  'so the plugin finds forge-mcp-all.mjs and the vector stores right where it looks',
462
486
  );
463
487
 
464
- const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, cacheDir)} top-level entries`;
488
+ fs.mkdirSync(path.dirname(cacheDir), { recursive: true });
489
+ const stageDir = fs.mkdtempSync(path.join(path.dirname(cacheDir), `.${path.basename(cacheDir)}.install-stage-`));
490
+ const localCopy = async () => `local directory copy — ${copyLocalBundleInto(sourceDir, stageDir)} top-level entries`;
465
491
  const nodeExtract = async () => {
466
492
  const { extractZip } = await import(new URL('../kb/zip-extract.mjs', import.meta.url).href);
467
- const r = await extractZip(zipPath, cacheDir);
493
+ const r = await extractZip(zipPath, stageDir);
468
494
  return `node:zlib — ${r.files} files, ${(r.bytes / 1e6).toFixed(1)}MB${r.crcChecked ? ', CRC verified' : ''}`;
469
495
  };
470
496
  const unzipExtract = async () => {
471
497
  if (!have('unzip')) throw new Error('`unzip` is not on PATH');
472
- run('unzip', ['-q', '-o', zipPath, '-d', cacheDir]);
498
+ run('unzip', ['-q', '-o', zipPath, '-d', stageDir]);
473
499
  return 'unzip';
474
500
  };
475
501
  const psExtract = async () => {
@@ -484,7 +510,7 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
484
510
  if (!psExe) throw new Error('neither `pwsh` nor `powershell` is on PATH');
485
511
  run(psExe, [
486
512
  '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
487
- `Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${cacheDir}" -Force`,
513
+ `Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${stageDir}" -Force`,
488
514
  ], { shell: false });
489
515
  return `${psExe} Expand-Archive`;
490
516
  };
@@ -504,6 +530,7 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
504
530
  catch (e) { failures.push(` • ${label}: ${(e && e.message) || e}`); }
505
531
  }
506
532
  if (!extractedBy) {
533
+ fs.rmSync(stageDir, { recursive: true, force: true });
507
534
  die(
508
535
  `extraction failed — every available method was tried and each one is reported below.\n${failures.join('\n')}`,
509
536
  [
@@ -516,34 +543,109 @@ async function unzipInto(zipPath, cacheDir, sourceDir = null) {
516
543
  }
517
544
  if (failures.length) warn(`extracted via ${extractedBy} after ${failures.length} method(s) failed:\n${failures.join('\n')}`);
518
545
 
519
- const nested = path.join(cacheDir, 'ruvnet-brain');
546
+ const nested = path.join(stageDir, 'ruvnet-brain');
520
547
  if (fs.existsSync(path.join(nested, 'forge-mcp-all.mjs'))) {
521
548
  for (const entry of fs.readdirSync(nested)) {
522
549
  const from = path.join(nested, entry);
523
- const to = path.join(cacheDir, entry);
524
- fs.rmSync(to, { recursive: true, force: true }); // idempotent overwrite
550
+ const to = path.join(stageDir, entry);
525
551
  fs.renameSync(from, to); // same filesystem → cheap rename
526
552
  }
527
553
  fs.rmdirSync(nested);
528
554
  }
529
555
 
530
- // An update is a replacement, not an overlay. Before this prune existed, installing a public
531
- // bundle over a KB that once contained private stores left every omitted `.rvf` and passages file
532
- // behind. discoverRepos() then served those stale stores as if they were part of the new bundle.
533
- // Only repo-artifact families are touched; reader deps, local logs, preferences, and unrelated
534
- // files remain byte-for-byte.
535
- const pruned = pruneUnlistedStores(cacheDir);
536
- if (pruned.length) {
537
- warn(`pruned ${pruned.length} stale repo artifact file(s) omitted by this bundle: ${[...new Set(pruned.map((p) => p.repo))].join(', ')}`);
538
- }
539
-
540
- if (!fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'))) {
556
+ if (!fs.existsSync(path.join(stageDir, 'forge-mcp-all.mjs'))) {
557
+ fs.rmSync(stageDir, { recursive: true, force: true });
541
558
  die(
542
- `the brain unpacked but forge-mcp-all.mjs is missing from ${cacheDir}.`,
559
+ `the staged brain is missing forge-mcp-all.mjs.`,
543
560
  `The archive layout may have changed. Re-run, or report this at https://github.com/stuinfla/ruvnet-brain/issues`,
544
561
  );
545
562
  }
563
+ const stagedCoverage = validateCoverageDirectory(stageDir, { expectedVersion: PACKAGE_VERSION });
564
+ if (!stagedCoverage.valid) {
565
+ fs.rmSync(stageDir, { recursive: true, force: true });
566
+ die(`staged ReleaseCoverage failed integrity — ${stagedCoverage.failures.join('; ')}`,
567
+ 'The live brain was not touched. Fetch a complete current release and retry.');
568
+ }
569
+
570
+ // Activation is an exact directory generation swap. A malformed candidate never reaches this
571
+ // point, retired public files cannot survive as overlay debris, and a failed rename restores the
572
+ // prior generation. Existing private overlays are update-owned and must never be stripped by the
573
+ // fresh-install fallback.
574
+ if (fs.existsSync(path.join(cacheDir, 'SOURCE.json'))) {
575
+ try {
576
+ const current = JSON.parse(fs.readFileSync(path.join(cacheDir, 'SOURCE.json'), 'utf8'));
577
+ const stores = Array.isArray(current.stores) ? current.stores : Object.values(current.stores || {});
578
+ if (stores.some((store) => store?.updateManaged === false)) {
579
+ fs.rmSync(stageDir, { recursive: true, force: true });
580
+ die('fresh-install activation refused because this brain contains a private overlay',
581
+ `Run ${c.bold('npx ruvnet-brain --update')} so the bundle updater preserves those private stores.`);
582
+ }
583
+ } catch (error) {
584
+ fs.rmSync(stageDir, { recursive: true, force: true });
585
+ die(`existing SOURCE.json is unreadable — refusing an exact-tree replacement (${error.message})`);
586
+ }
587
+ }
588
+ const parent = path.dirname(cacheDir);
589
+ const priorPrefix = `${path.basename(cacheDir)}.install-prior-`;
590
+ const unresolved = fs.readdirSync(parent).filter((name) => name.startsWith(priorPrefix));
591
+ if (unresolved.length) {
592
+ fs.rmSync(stageDir, { recursive: true, force: true });
593
+ die(`unresolved installer rollback state exists: ${unresolved.join(', ')}`,
594
+ 'Restore or remove that retained generation after inspection, then retry.');
595
+ }
596
+ const priorDir = `${cacheDir}.install-prior-${Date.now()}-${process.pid}`;
597
+ const preservedDir = `${cacheDir}.install-preserved-${path.basename(stageDir).split('.install-stage-').pop()}`;
598
+ const hadPrior = fs.existsSync(cacheDir);
599
+ const stageIdentity = fs.lstatSync(stageDir);
600
+ let priorMoved = false;
601
+ let activated = false;
602
+ try {
603
+ if (hadPrior) {
604
+ fs.renameSync(cacheDir, priorDir);
605
+ priorMoved = true;
606
+ }
607
+ fs.renameSync(stageDir, cacheDir);
608
+ activated = true;
609
+ const landedCoverage = validateCoverageDirectory(cacheDir, { expectedVersion: PACKAGE_VERSION });
610
+ if (!landedCoverage.valid) throw new Error(`landed ReleaseCoverage failed integrity: ${landedCoverage.failures.join('; ')}`);
611
+ if (hadPrior) {
612
+ // SOURCE only identifies declared stores; ReleaseCoverage does not establish ownership of
613
+ // every old code/custom file. Preserve the ENTIRE old tree, including symlinks without
614
+ // following them. This is unclassified user-data retention, NOT a managed cleanup backup.
615
+ if (fs.existsSync(preservedDir)) throw new Error(`preservation path already exists: ${preservedDir}`);
616
+ fs.renameSync(priorDir, preservedDir);
617
+ }
618
+ } catch (error) {
619
+ try {
620
+ // Never remove the live path merely because activation was attempted. In particular,
621
+ // a failed first rename leaves the ORIGINAL generation there. Retain a failed candidate
622
+ // for inspection, and refuse to move a replacement directory we did not activate.
623
+ if (activated) {
624
+ const landedIdentity = fs.lstatSync(cacheDir);
625
+ if (landedIdentity.dev !== stageIdentity.dev || landedIdentity.ino !== stageIdentity.ino ||
626
+ !landedIdentity.isDirectory() || fs.existsSync(stageDir)) {
627
+ throw new Error('activated directory identity changed; refusing rollback mutation');
628
+ }
629
+ fs.renameSync(cacheDir, stageDir);
630
+ }
631
+ if (priorMoved) {
632
+ if (fs.existsSync(cacheDir)) throw new Error('live path is occupied; refusing to replace it during rollback');
633
+ fs.renameSync(priorDir, cacheDir);
634
+ }
635
+ } catch (rollbackError) {
636
+ die(`activation failed (${error.message}); rollback also failed (${rollbackError.message})`,
637
+ `Recovery paths: prior=${priorDir}, candidate=${stageDir}, live=${cacheDir}.`, true);
638
+ }
639
+ die(`activation failed (${error.message}); ${priorMoved ? 'restored the prior brain generation' :
640
+ hadPrior ? 'the prior brain generation was not moved' : 'no prior brain generation existed'}`,
641
+ `Candidate retained for inspection at ${stageDir}.`);
642
+ }
643
+ if (hadPrior) warn(`PRESERVED_UNCLASSIFIED: prior generation retained at ${preservedDir}. ` +
644
+ 'Not eligible for automatic cleanup; repeated installs can grow disk usage. Inspect manually before removal.');
546
645
  ok(`brain unpacked to ${cacheDir}`);
646
+ return { status: 'ACTIVATED', priorGeneration: hadPrior
647
+ ? { status: 'PRESERVED_UNCLASSIFIED', path: preservedDir, automaticCleanupEligible: false }
648
+ : null };
547
649
  }
548
650
 
549
651
  /**
@@ -1022,7 +1124,7 @@ export function consoleRestartState(identity, {
1022
1124
  //
1023
1125
  // The brain ships as TWO independent artifacts and this is the one people lose:
1024
1126
  // • KB + search_ruvnet — installed by this script into ~/.cache/ruvnet-brain
1025
- // • the Claude Code plugin — slash commands, the Console, the grounding hook
1127
+ // • the Claude Code plugin — slash commands, the Console, skills, and MCP declaration
1026
1128
  // Checking the commands directory on disk is what distinguishes them; a `claude plugin install`
1027
1129
  // exit code does not.
1028
1130
  /** @returns {string|null} the commands dir if the plugin is really installed, else null */
@@ -1074,7 +1176,7 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
1074
1176
  function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
1075
1177
  step(
1076
1178
  'Wiring the Claude Code plugin',
1077
- 'this registers search_ruvnet + the grounding hook so Claude uses the brain automatically',
1179
+ 'this registers search_ruvnet, native skills, and slash commands without automatic lifecycle hooks',
1078
1180
  );
1079
1181
  const marketplaceSource = process.env.RUVNET_CLAUDE_MARKETPLACE_SOURCE || 'stuinfla/ruvnet-brain';
1080
1182
  const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
@@ -1110,7 +1212,8 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1110
1212
  // code says the command ran, not that the plugin is usable; the commands either exist on disk or
1111
1213
  // they do not. This is the difference between `/rvbc` working and "Unknown command: /rvbc".
1112
1214
  const installed = claudePluginStatus();
1113
- if (installed.installed && versionSatisfies(installed.version, expectedVersion)) {
1215
+ const installedHookRetirement = claudeInstalledHookRetirementStatus({ plugin: installed });
1216
+ if (installed.installed && versionSatisfies(installed.version, expectedVersion) && installedHookRetirement.ok) {
1114
1217
  ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) — exact version ${installed.version}`);
1115
1218
  info(` commands available after a restart: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
1116
1219
  return { host: true, wired: true, version: installed.version, manualMarketplace, manualInstall };
@@ -1119,18 +1222,21 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
1119
1222
  // The honest failure. The brain still WORKS — this is the difference between a broken install and
1120
1223
  // a partial one, and the user is told exactly which they have instead of being congratulated.
1121
1224
  const mismatch = installed.installed && !versionSatisfies(installed.version, expectedVersion);
1122
- warn(mismatch
1123
- ? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
1124
- : `the plugin did NOT land — so slash commands like ${c.bold('/rvbc')} will not exist yet.`);
1225
+ const staleHooks = installed.installed && !installedHookRetirement.ok;
1226
+ warn(staleHooks
1227
+ ? `Claude installed the plugin with ${installedHookRetirement.registrations.length} retired lifecycle registration(s); refusing to call this host converged.`
1228
+ : mismatch
1229
+ ? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
1230
+ : `the plugin did NOT land — so slash commands like ${c.bold('/rvbc')} will not exist yet.`);
1125
1231
  info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
1126
- info(`Only the plugin extras (slash commands, the Console, the grounding hook) are missing.`);
1232
+ info(`Only the plugin extras (slash commands, the Console, skills, and MCP declaration) are missing.`);
1127
1233
  info(`Run these two yourself to finish:`);
1128
1234
  info(` ${c.bold(manualMarketplace)}`);
1129
1235
  info(` ${c.bold(manualInstall)}`);
1130
1236
  return {
1131
1237
  host: true,
1132
1238
  wired: false,
1133
- action: mismatch ? 'verification-failed' : 'install-failed',
1239
+ action: staleHooks ? 'unexpected-runtime-hooks' : mismatch ? 'verification-failed' : 'install-failed',
1134
1240
  expectedVersion,
1135
1241
  version: installed.version,
1136
1242
  manualMarketplace,
@@ -1350,8 +1456,6 @@ export function wireCodexHost({
1350
1456
  configPath = path.join(codexDir, 'config.toml'),
1351
1457
  serverDir = codexServerDir(),
1352
1458
  source = path.join(__dirname, '..', 'plugin', 'mcp', 'server.mjs'),
1353
- hookWrapperSource = path.join(__dirname, '..', 'plugin', 'scripts', 'codex-hook-wrapper.mjs'),
1354
- runtimePreferencesSource = path.join(__dirname, '..', 'plugin', 'scripts', 'runtime-preferences.mjs'),
1355
1459
  hookWrapperPath = codexHookWrapperPath(codexDir),
1356
1460
  announce = true,
1357
1461
  } = {}) {
@@ -1408,9 +1512,26 @@ export function wireCodexHost({
1408
1512
  atomicReplace(target, (tmp) => fs.copyFileSync(dep.from, tmp));
1409
1513
  }
1410
1514
  atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
1411
- if (fs.existsSync(hookWrapperSource)) {
1412
- fs.mkdirSync(path.dirname(hookWrapperPath), { recursive: true });
1413
- atomicReplace(hookWrapperPath, (tmp) => fs.copyFileSync(hookWrapperSource, tmp));
1515
+ retireManagedHookRegistrations({ home: path.dirname(codexDir), codexDir, wrapperPath: hookWrapperPath });
1516
+ // Versions through 4.3.10 copied a durable hook bridge outside the versioned plugin cache. An
1517
+ // already-running Codex session may still hold old registrations, so removing this exact
1518
+ // installer-owned file makes those frozen callbacks fail open immediately. The source adapter is
1519
+ // retained in the package for audit and possible explicit tooling; it is never installed here.
1520
+ let retiredHookWrapper = false;
1521
+ try {
1522
+ let wrapperStat = null;
1523
+ try { wrapperStat = fs.lstatSync(hookWrapperPath); }
1524
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
1525
+ if (wrapperStat) {
1526
+ if (!wrapperStat.isFile() && !wrapperStat.isSymbolicLink()) {
1527
+ throw new Error('installer-owned wrapper path is not a regular file or symlink');
1528
+ }
1529
+ fs.unlinkSync(hookWrapperPath);
1530
+ retiredHookWrapper = true;
1531
+ }
1532
+ } catch (error) {
1533
+ if (announce) warn(`could not retire the legacy Codex hook wrapper at ${hookWrapperPath}: ${error.message}`);
1534
+ return { host: true, action: 'legacy-hook-retirement-failed', error: error.message };
1414
1535
  }
1415
1536
 
1416
1537
  let before = '';
@@ -1421,7 +1542,7 @@ export function wireCodexHost({
1421
1542
  ok('Codex already declares ruvnet-brain in your own config — left exactly as you wrote it');
1422
1543
  info(` to hand it to us instead, delete that ${c.bold('[mcp_servers.ruvnet-brain]')} block and re-run this installer`);
1423
1544
  }
1424
- return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, hookWrapperPath };
1545
+ return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, retiredHookWrapper };
1425
1546
  }
1426
1547
  if (text !== before) {
1427
1548
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
@@ -1432,7 +1553,7 @@ export function wireCodexHost({
1432
1553
  info(` server: ${serverPath} ${c.dim('(persistent copy — the npx dir vanishes)')}`);
1433
1554
  info(` ${c.dim('only our marked block is written; every other section is byte-preserved')}`);
1434
1555
  }
1435
- return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, hookWrapperPath, changed: text !== before };
1556
+ return { host: true, action, serverPath, managedCliPath, runtimePreferencesPath, retiredHookWrapper, changed: text !== before };
1436
1557
  }
1437
1558
 
1438
1559
  const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain';
@@ -1668,12 +1789,11 @@ export function classifyCodexLifecycle(plugin, listed = null) {
1668
1789
  const hooks = groups.flatMap((group) => Array.isArray(group?.hooks) ? group.hooks : [])
1669
1790
  .filter((hook) => hook?.pluginId === CODEX_PLUGIN_ID);
1670
1791
  const errors = groups.flatMap((group) => Array.isArray(group?.errors) ? group.errors : []);
1671
- if (errors.length || hooks.length === 0) {
1792
+ if (errors.length) {
1672
1793
  return { state: 'missing-runtime-hooks', plugin, hooks, errors };
1673
1794
  }
1674
- if (hooks.some((hook) => hook.enabled === false)) return { state: 'disabled', plugin, hooks, errors };
1675
- if (hooks.every((hook) => hook.trustStatus === 'trusted')) return { state: 'active', plugin, hooks, errors };
1676
- return { state: 'pending-trust', plugin, hooks, errors };
1795
+ if (hooks.length === 0) return { state: 'inactive-by-design', plugin, hooks, errors };
1796
+ return { state: 'unexpected-runtime-hooks', plugin, hooks, errors };
1677
1797
  }
1678
1798
 
1679
1799
  export async function codexLifecycleStatus(options = {}) {
@@ -1687,37 +1807,37 @@ export async function codexLifecycleStatus(options = {}) {
1687
1807
  export function codexLifecycleGuidance(status) {
1688
1808
  const hookCount = Array.isArray(status?.hooks) ? status.hooks.length : 0;
1689
1809
  switch (status?.state) {
1690
- case 'active':
1810
+ case 'unexpected-runtime-hooks':
1691
1811
  return {
1692
- healthy: true,
1812
+ healthy: false,
1693
1813
  intentional: false,
1694
- summary: `Codex lifecycle active (${hookCount} Brain hook${hookCount === 1 ? '' : 's'} enabled and trusted).`,
1695
- detail: 'Proactive grounding, routing, learning capture, and session continuity are on.',
1696
- action: null,
1814
+ summary: `Codex still exposes ${hookCount} retired Brain lifecycle hook${hookCount === 1 ? '' : 's'}.`,
1815
+ detail: 'Any Brain-owned runtime registration is stale and must not be trusted or executed.',
1816
+ action: `Upgrade ${CODEX_PLUGIN_ID}, then start a fresh Codex session and re-run --doctor.`,
1697
1817
  };
1698
- case 'pending-trust':
1818
+ case 'inactive-by-design':
1699
1819
  return {
1700
- healthy: false,
1701
- intentional: false,
1702
- summary: `Codex installed the Brain, but ${hookCount || 'its'} lifecycle hook${hookCount === 1 ? '' : 's'} await review.`,
1703
- detail: 'Search works now; proactive interventions start after Codex records hook trust.',
1704
- action: `Start a fresh Codex session, run /hooks, and trust only ${CODEX_PLUGIN_ID}.`,
1820
+ healthy: true,
1821
+ intentional: true,
1822
+ summary: 'Codex Brain automatic lifecycle hooks are inactive by design.',
1823
+ detail: 'MCP search, skills, slash commands, and explicit proof workflows remain available.',
1824
+ action: null,
1705
1825
  };
1706
1826
  case 'disabled':
1707
1827
  return {
1708
1828
  healthy: false,
1709
1829
  intentional: true,
1710
- summary: 'Codex Brain lifecycle is disabled.',
1711
- detail: 'The installer preserved your explicit disabled state instead of silently overriding it.',
1830
+ summary: 'Codex Brain plugin is disabled.',
1831
+ detail: 'The installer preserved your explicit disabled state instead of silently overriding it; no Brain lifecycle hook can run.',
1712
1832
  action: null,
1713
1833
  };
1714
1834
  case 'not-installed':
1715
1835
  return {
1716
1836
  healthy: false,
1717
1837
  intentional: false,
1718
- summary: 'Codex can reach the Brain MCP, but the proactive lifecycle plugin is not installed.',
1719
- detail: 'Questions can still be grounded; automatic routing, learning, and session guidance are inactive.',
1720
- action: 'Run npx ruvnet-brain to install and verify the Codex lifecycle plugin.',
1838
+ summary: 'Codex can reach the Brain MCP, but the Brain plugin is not installed.',
1839
+ detail: 'MCP search remains available; skills and explicit plugin workflows are not installed.',
1840
+ action: 'Run npx ruvnet-brain to install and verify the Codex plugin.',
1721
1841
  };
1722
1842
  case 'missing-runtime-hooks':
1723
1843
  return {
@@ -1740,6 +1860,168 @@ export function codexLifecycleGuidance(status) {
1740
1860
  }
1741
1861
  }
1742
1862
 
1863
+ // Remove only explicit Brain ownership or the exact installer-owned durable bridge.
1864
+ // Foreign commands, mixed command strings, unknown shapes, and their surrounding settings survive.
1865
+ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir = codexHomeDir(),
1866
+ files = [path.join(home, '.claude', 'settings.json'), path.join(home, '.claude', 'settings.local.json'),
1867
+ path.join(codexDir, 'hooks.json')], wrapperPath = codexHookWrapperPath(codexDir) } = {}) {
1868
+ let removed = 0;
1869
+ const changedFiles = [];
1870
+ const owns = (hook) => {
1871
+ if (hook?.pluginId === 'ruvnet-brain@ruvnet-brain') return true;
1872
+ const command = hook?.command;
1873
+ if (typeof command !== 'string' || /[;&|`\n\r]/.test(command)) return false;
1874
+ const tokens = command.match(/"[^"]*"|'[^']*'|[^\s]+/g) || [];
1875
+ const values = tokens.map((token) => token.replace(/^["']|["']$/g, ''));
1876
+ return values.length >= 2 && /(?:^|[/\\])node(?:\.exe)?$/.test(values[0])
1877
+ && values[1] === wrapperPath;
1878
+ };
1879
+ for (const file of files) {
1880
+ let before;
1881
+ try { before = fs.readFileSync(file, 'utf8'); } catch (error) {
1882
+ if (error.code === 'ENOENT') continue;
1883
+ throw error;
1884
+ }
1885
+ const document = JSON.parse(before);
1886
+ if (!document.hooks || typeof document.hooks !== 'object' || Array.isArray(document.hooks)) continue;
1887
+ let count = 0;
1888
+ for (const [event, groups] of Object.entries(document.hooks)) {
1889
+ if (!Array.isArray(groups)) continue;
1890
+ document.hooks[event] = groups.flatMap((group) => {
1891
+ if (!Array.isArray(group?.hooks)) return [group];
1892
+ const hooks = group.hooks.filter((hook) => { if (!owns(hook)) return true; count++; return false; });
1893
+ return hooks.length || group.hooks.length === 0 ? [{ ...group, hooks }] : [];
1894
+ });
1895
+ if (groups.length && document.hooks[event].length === 0) delete document.hooks[event];
1896
+ }
1897
+ if (!count) continue;
1898
+ atomicReplace(file, (temp) => fs.writeFileSync(temp, JSON.stringify(document, null, 2) + '\n'));
1899
+ removed += count;
1900
+ changedFiles.push(file);
1901
+ }
1902
+ return { removed, changedFiles };
1903
+ }
1904
+
1905
+ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'source' } = {}) {
1906
+ if (!['source', 'installed'].includes(scope)) throw new Error('invalid hook retirement scope');
1907
+ const files = [
1908
+ 'plugin/hooks/hooks.json',
1909
+ 'plugin/hooks/codex-hooks.json',
1910
+ ...(scope === 'source' ? ['.claude/settings.json', '.codex/hooks.json'] : []),
1911
+ ];
1912
+ const pointerContracts = [
1913
+ ['plugin/.codex-plugin/plugin.json', 'hooks', './hooks/codex-hooks.json'],
1914
+ ['plugin/host-adapters/codex.json', 'hooks', 'plugin/hooks/codex-hooks.json'],
1915
+ ['plugin/host-adapters/claude.json', 'hooks', 'plugin/hooks/hooks.json'],
1916
+ ];
1917
+ const registrations = [];
1918
+ const errors = [];
1919
+ const checkedFiles = [...files];
1920
+ for (const relative of files) {
1921
+ try {
1922
+ const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
1923
+ if (!doc?.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
1924
+ errors.push(`${relative}: hooks must be an object`);
1925
+ continue;
1926
+ }
1927
+ for (const [event, groups] of Object.entries(doc.hooks)) {
1928
+ if (!Array.isArray(groups)) {
1929
+ errors.push(`${relative}: ${event} must be an array`);
1930
+ continue;
1931
+ }
1932
+ errors.push(`${relative}: retired registry must not declare event ${event}`);
1933
+ for (const [groupIndex, group] of groups.entries()) {
1934
+ if (!group || typeof group !== 'object' || Array.isArray(group) || !Array.isArray(group.hooks)) {
1935
+ errors.push(`${relative}: ${event}[${groupIndex}].hooks must be an array`);
1936
+ continue;
1937
+ }
1938
+ for (const hook of group.hooks) {
1939
+ registrations.push({ file: relative, event, command: String(hook?.command || '') });
1940
+ }
1941
+ }
1942
+ }
1943
+ } catch (error) {
1944
+ errors.push(`${relative}: ${error.message}`);
1945
+ }
1946
+ }
1947
+ try {
1948
+ const contractsFile = 'plugin/hooks/hook-contracts.json';
1949
+ const contracts = JSON.parse(fs.readFileSync(path.join(root, contractsFile), 'utf8'));
1950
+ checkedFiles.push(contractsFile);
1951
+ if (!Array.isArray(contracts.contracts) || contracts.contracts.length !== 0) {
1952
+ errors.push(`${contractsFile}: contracts must be an empty array`);
1953
+ }
1954
+ if (!Array.isArray(contracts.matcherAllowlist) || contracts.matcherAllowlist.length !== 0) {
1955
+ errors.push(`${contractsFile}: matcherAllowlist must be an empty array`);
1956
+ }
1957
+ } catch (error) {
1958
+ errors.push(`plugin/hooks/hook-contracts.json: ${error.message}`);
1959
+ }
1960
+ for (const [relative, field, expected] of pointerContracts) {
1961
+ try {
1962
+ const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
1963
+ checkedFiles.push(relative);
1964
+ if (doc?.[field] !== expected) errors.push(`${relative}: ${field} must equal ${expected}`);
1965
+ } catch (error) {
1966
+ errors.push(`${relative}: ${error.message}`);
1967
+ }
1968
+ }
1969
+ return { ok: errors.length === 0 && registrations.length === 0, files: checkedFiles, registrations, errors };
1970
+ }
1971
+
1972
+ export function claudeInstalledHookRetirementStatus({ home = os.homedir(), plugin: suppliedPlugin = null } = {}) {
1973
+ const plugin = suppliedPlugin ?? claudePluginStatus({ home });
1974
+ if (!plugin.managed && !plugin.installed) {
1975
+ return { ok: true, state: 'not-installed', plugin, file: null, registrations: [], errors: [] };
1976
+ }
1977
+ if (!plugin.installed || !plugin.installPath) {
1978
+ return {
1979
+ ok: false,
1980
+ state: 'installed-state-invalid',
1981
+ plugin,
1982
+ file: null,
1983
+ registrations: [],
1984
+ errors: [plugin.error || 'Claude Brain plugin registry record does not resolve to a managed install'],
1985
+ };
1986
+ }
1987
+ const file = path.join(plugin.installPath, 'hooks', 'hooks.json');
1988
+ const registrations = [];
1989
+ const errors = [];
1990
+ try {
1991
+ const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
1992
+ if (!doc?.hooks || typeof doc.hooks !== 'object' || Array.isArray(doc.hooks)) {
1993
+ errors.push('installed Claude hooks must be an object');
1994
+ } else {
1995
+ for (const [event, groups] of Object.entries(doc.hooks)) {
1996
+ errors.push(`installed Claude registry must not declare event ${event}`);
1997
+ if (!Array.isArray(groups)) {
1998
+ errors.push(`installed Claude ${event} must be an array`);
1999
+ continue;
2000
+ }
2001
+ for (const [groupIndex, group] of groups.entries()) {
2002
+ if (!group || typeof group !== 'object' || Array.isArray(group) || !Array.isArray(group.hooks)) {
2003
+ errors.push(`installed Claude ${event}[${groupIndex}].hooks must be an array`);
2004
+ continue;
2005
+ }
2006
+ for (const hook of group.hooks) {
2007
+ registrations.push({ file, event, command: String(hook?.command || '') });
2008
+ }
2009
+ }
2010
+ }
2011
+ }
2012
+ } catch (error) {
2013
+ errors.push(`installed Claude hooks: ${error.message}`);
2014
+ }
2015
+ return {
2016
+ ok: errors.length === 0 && registrations.length === 0,
2017
+ state: errors.length === 0 && registrations.length === 0 ? 'inactive-by-design' : 'unexpected-runtime-hooks',
2018
+ plugin,
2019
+ file,
2020
+ registrations,
2021
+ errors,
2022
+ };
2023
+ }
2024
+
1743
2025
  function printCodexLifecycle(status) {
1744
2026
  const guidance = codexLifecycleGuidance(status);
1745
2027
  console.log(` ${guidance.healthy ? c.green('✓') : guidance.intentional ? c.dim('○') : c.yellow('!')} ${guidance.summary}`);
@@ -2024,7 +2306,7 @@ async function runDemo() {
2024
2306
  } catch { /* informational only */ }
2025
2307
 
2026
2308
  console.log(`\n Now try it for real: open Claude Code in any project and ask it something about`);
2027
- console.log(` RuVector, Ruflo, AgentDB, or SPARC — it'll ground the same way, automatically.`);
2309
+ console.log(` RuVector, Ruflo, AgentDB, or SPARC — use ${c.bold('search_ruvnet')} when you need source grounding.`);
2028
2310
  console.log(` Run this demo again any time: ${c.bold('npx ruvnet-brain --demo')}`);
2029
2311
  console.log(` Full health check: ${c.bold('npx ruvnet-brain --doctor')}\n`);
2030
2312
  }
@@ -2098,6 +2380,18 @@ async function doctor() {
2098
2380
  warn(`host convergence receipt is invalid: ${error.message}`);
2099
2381
  }
2100
2382
  }
2383
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
2384
+ const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
2385
+ brainHome, kbDir: cacheDir });
2386
+ if (nightlyHealth.state === 'on') {
2387
+ ok(`nightly scheduler: ${nightlyHealth.evidence}`);
2388
+ if (nightlyHealth.runHealth?.state === 'ok' || nightlyHealth.runHealth?.state === 'running') {
2389
+ ok(`nightly execution: ${nightlyHealth.runHealth.evidence}`);
2390
+ } else warn(`nightly execution unproven: ${nightlyHealth.runHealth?.evidence || 'no run receipt'}`);
2391
+ }
2392
+ else if (nightlyHealth.state === 'degraded') warn(`nightly scheduler degraded: ${nightlyHealth.evidence}`);
2393
+ else if (nightlyHealth.state === 'off') info('nightly scheduler is off (optional; enable with --enable-nightly)');
2394
+ else info(`nightly scheduler status unavailable: ${nightlyHealth.evidence}`);
2101
2395
  have('node') ? ok('node present') : warn('node missing');
2102
2396
  have('npm') ? ok('npm present') : warn('npm missing');
2103
2397
  have('claude') ? ok('claude CLI present') : warn('claude CLI missing (plugin wiring needs it)');
@@ -2226,23 +2520,33 @@ async function doctor() {
2226
2520
  console.log(` window and it's there. ${c.bold('No reinstall per project. No second download.')} One brain, shared.`);
2227
2521
  }
2228
2522
  console.log(` • ${c.bold('Nothing to git-ignore')} in your projects — it drops zero files into your working repos.`);
2229
- console.log(` • ${c.bold('To use it:')} just ask Claude about rUv's stack (RuVector, Ruflo, AgentDB, SPARC…) — it`);
2230
- console.log(` grounds the answer automatically and takes the lead on builds. You don't invoke anything.`);
2231
- console.log(` • ${c.bold("To know it's on:")} a fresh session greets you with "🧠 RuvNet Brain active". Or run this`);
2232
- console.log(` ${c.bold('--doctor')} command any time.`);
2523
+ console.log(` • ${c.bold('To use it:')} invoke ${c.bold('search_ruvnet')} or a Brain skill when source-grounded`);
2524
+ console.log(' RuvNet context is needed. Nothing is injected into an ordinary turn automatically.');
2525
+ console.log(` • ${c.bold("To know it's available:")} run this ${c.bold('--doctor')} command any time; no Brain lifecycle`);
2526
+ console.log(` hook should fire automatically.`);
2233
2527
  }
2234
2528
  console.log(
2235
2529
  c.dim('\n Heads-up: a window that was ALREADY open when you installed needs a restart to pick it up;\n newly-opened windows are fine.\n'),
2236
2530
  );
2237
2531
 
2238
2532
  // ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
2239
- // `--hooks` additionally fires every registration in the INSTALLED hooks.json through the real
2240
- // shim under the four stdin regimes. Opt-in because it spawns real hooks; plain --doctor stays a
2241
- // pure read that anyone can run without side effects.
2533
+ // `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
2534
+ // never execute dormant hook bodies.
2242
2535
  let hookResult = null;
2243
2536
  if (FLAG_HOOKS) {
2244
- console.log(` ${c.dim('── hook battery (installed hooks.json, four stdin regimes, external watchdog) ──')}`);
2245
- hookResult = await runSelfCheck({ installState: { repos: v.repos, reader: v.reader, mcp: v.mcp } });
2537
+ const retirement = automaticHookRetirementStatus(REPO_ROOT, { scope: 'installed' });
2538
+ const installedClaude = claudeInstalledHookRetirementStatus();
2539
+ const hookOk = retirement.ok && installedClaude.ok;
2540
+ hookResult = { exitCode: hookOk ? 0 : 1 };
2541
+ console.log(` ${hookOk ? c.green('✓') : c.red('✗')} automatic Brain hook retirement: ${retirement.registrations.length + installedClaude.registrations.length} registration(s), ${retirement.errors.length + installedClaude.errors.length} manifest error(s)`);
2542
+ for (const error of retirement.errors) console.log(` ${error}`);
2543
+ for (const error of installedClaude.errors) console.log(` ${error}`);
2544
+ for (const registration of retirement.registrations) {
2545
+ console.log(` ${registration.file} ${registration.event}: ${registration.command || '(empty command)'}`);
2546
+ }
2547
+ for (const registration of installedClaude.registrations) {
2548
+ console.log(` ${registration.file} ${registration.event}: ${registration.command || '(empty command)'}`);
2549
+ }
2246
2550
  }
2247
2551
 
2248
2552
  // ── THE PERSISTED GROUNDING VERDICT (ADR-058 §D8) — synchronize stronger live proof first ───────
@@ -2282,6 +2586,8 @@ async function doctor() {
2282
2586
  || groundingUnprovenPersisted
2283
2587
  || (codexLifecycleFailed && !codexTrustBypassed)
2284
2588
  || codexWiringFailed
2589
+ || nightlyHealth.state === 'degraded'
2590
+ || (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state))
2285
2591
  || codexReadinessFailed
2286
2592
  || !hostConvergence.healthy
2287
2593
  || Boolean(rufloOperational && !rufloOperational.healthy);
@@ -2313,7 +2619,7 @@ async function runSelfCheck({ installState = null, quiet = false } = {}) {
2313
2619
  mod = await import(new URL('../scripts/selfcheck.mjs', import.meta.url).href);
2314
2620
  } catch (e) {
2315
2621
  warn(`self-check could not run (${e && e.message}) — this install has NOT been verified end to end`);
2316
- return { exitCode: FLAG_DOCTOR_HOOKS ? 1 : 0, violations: [], lines: [], unavailable: true };
2622
+ return { exitCode: FLAG_HOOKS ? 1 : 0, violations: [], lines: [], unavailable: true };
2317
2623
  }
2318
2624
  const result = await mod.selfCheck({ installState, security: true });
2319
2625
  if (!quiet || result.violations.length) console.log(mod.formatVerdict(result, { color: c }));
@@ -2518,11 +2824,8 @@ function runFeedback() {
2518
2824
  // canonical Release bundle, backs the current copy up, extracts, and re-verifies with forge-guard —
2519
2825
  // failing loud with no partial clobber. These flags never reimplement any of that; they only INVOKE
2520
2826
  // it once (--update) or SCHEDULE it per-user (--enable-nightly). Nothing here ever publishes.
2521
- const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
2522
2827
  const resolvedKbDir = () =>
2523
2828
  process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
2524
- const nightlyPlistPath = () =>
2525
- path.join(os.homedir(), 'Library', 'LaunchAgents', `${NIGHTLY_LABEL}.plist`);
2526
2829
  // RUVNET_BRAIN_TEST=1 → write/remove the plist but NEVER call launchctl. Tests point HOME at a temp
2527
2830
  // dir; bootstrapping a temp-dir plist into the user's real gui domain would mutate exactly the
2528
2831
  // system state the tests promise not to touch.
@@ -2533,112 +2836,16 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
2533
2836
  // (see smokeQuery's launch note) — and a silently-skipped installer main is the worst possible
2534
2837
  // failure mode for a stranger's first contact. With the variable unset, behavior is unchanged.
2535
2838
  const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
2536
- const xmlEscape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2537
-
2538
- /**
2539
- * THE ONE SCHEDULED-UPDATE COMMAND (issue #129). Every scheduler — cron, systemd, LaunchAgent —
2540
- * runs THIS, and it is byte-for-byte the entrypoint plugin/scripts/host-update.mjs already uses on
2541
- * SessionStart.
2542
- *
2543
- * It used to be `forge-update.mjs --apply`, which advances the KB BYTES ONLY. That path never
2544
- * reaches host convergence, so a scheduled run could move the corpus forward while the Stable Spine,
2545
- * the Claude and Codex plugin payloads, the Console runtime and `host-convergence.json` all stayed
2546
- * behind — silently, on a schedule, with the update log reporting success. A machine updating itself
2547
- * into a split state overnight is worse than one that never updates, because nothing looks wrong.
2548
- *
2549
- * Two entrypoints for one job is the defect; naming it once here is the fix. 03:47 on purpose: an
2550
- * off-hour minute, so it never piles onto the :00 cron rush.
2551
- */
2552
- const NIGHTLY_ARGV = ['--yes', 'ruvnet-brain@latest', '--update', '--host-sync-only', '--no-nightly-prompt'];
2553
- const nightlyCommand = () => `npx ${NIGHTLY_ARGV.join(' ')}`;
2554
2839
 
2555
- /**
2556
- * Absolute path to npx, resolved at install time — a scheduler has no shell profile, so a bare name
2557
- * would resolve only on installs whose npx happens to sit in launchd's minimal PATH. Falls back to
2558
- * the bare name rather than refusing: a wrong-but-present command is fixable by the user, whereas a
2559
- * refusal to install any schedule leaves them with nothing.
2560
- */
2561
- function npxPath() {
2562
- const local = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npx.cmd' : 'npx');
2563
- if (fs.existsSync(local)) return local;
2564
- try {
2565
- const r = spawnSync(process.platform === 'win32' ? 'where' : 'which', ['npx'], { encoding: 'utf8' });
2566
- const found = String(r.stdout || '').split('\n')[0].trim();
2567
- if (found && fs.existsSync(found)) return found;
2568
- } catch { /* not on PATH here either — fall through */ }
2569
- return process.platform === 'win32' ? 'npx.cmd' : 'npx';
2570
- }
2571
- /**
2572
- * Add the nightly line to the user's crontab, idempotently (issue #129).
2573
- *
2574
- * Read-modify-write through `crontab -l` / `crontab -` because that is the only portable interface;
2575
- * writing /var/spool/cron directly needs root and bypasses the daemon's reload. Every failure mode
2576
- * returns a REASON rather than a boolean: "no crontab binary" and "the write was rejected" need
2577
- * different things from the user, and collapsing them into "failed" is how a person ends up
2578
- * re-running a command that can never work.
2579
- */
2580
- export function installCronEntry(line, { run = spawnSync } = {}) {
2581
- const marker = 'ruvnet-brain@latest';
2582
- const list = run('crontab', ['-l'], { encoding: 'utf8' });
2583
- if (list.error) return { ok: false, why: 'no crontab command was found on this system' };
2584
- // Exit 1 with no output is the documented "this user has no crontab yet" case, not an error.
2585
- const current = String(list.stdout || '');
2586
- if (current.split('\n').some((l) => l.includes(marker) && !l.trim().startsWith('#'))) {
2587
- return { ok: true, already: true };
2588
- }
2589
- const next = `${current.replace(/\n*$/, '')}\n${line}\n`.replace(/^\n+/, '');
2590
- const write = run('crontab', ['-'], { input: next, encoding: 'utf8' });
2591
- if (write.error) return { ok: false, why: `crontab could not be written (${write.error.message})` };
2592
- if (write.status !== 0) {
2593
- return { ok: false, why: `crontab rejected the entry (exit ${write.status})${String(write.stderr || '').trim() ? `: ${String(write.stderr).trim()}` : ''}` };
2594
- }
2595
- return { ok: true, already: false };
2596
- }
2597
-
2598
- /**
2599
- * launchd's minimal PATH plus the user-level executable homes used by the supported hosts.
2600
- *
2601
- * The updater does more than run npx: --host-sync-only executes the installed Claude and Codex
2602
- * doors. A real 2026-08-22 launchd run found npx but then failed with `claude unavailable` and
2603
- * `spawnSync codex ENOENT`; interactive shells had supplied ~/.npm-global/bin and ~/.local/bin,
2604
- * launchd had not. Derive these from HOME so the fix is portable rather than pinned to one user.
2605
- */
2606
- const launchdPath = () => [...new Set([
2607
- path.dirname(process.execPath), path.dirname(npxPath()),
2608
- path.join(os.homedir(), '.npm-global', 'bin'), path.join(os.homedir(), '.local', 'bin'),
2609
- ...(process.platform === 'darwin' ? ['/Applications/Codex.app/Contents/Resources'] : []),
2610
- '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin',
2611
- ])].filter(Boolean).join(':');
2612
- const cronExample = (kbDir) =>
2613
- `47 3 * * * ${nightlyCommand()} >> ${kbDir}/update.log 2>&1`;
2614
-
2615
- /**
2616
- * kb/forge-update.mjs exit 11 = "the download completed but the bundle on disk did NOT change"
2617
- * (its `EXIT_NOT_LANDED`). Duplicated as a literal on purpose: the updater lives in the KB BUNDLE,
2618
- * which versions and ships independently of this installer, so there is no import to share. The
2619
- * two are pinned together by tests/unit/update-not-landed-exit.test.mjs, which reads the constant
2620
- * out of kb/forge-update.mjs and fails if they ever drift.
2621
- */
2622
- export const UPDATE_NOT_LANDED = 11;
2623
-
2624
- /**
2625
- * What `--update` does with the KB updater's exit code (issue #106).
2626
- *
2627
- * `--update` used to convert an honest failure into a reported success. The updater detected the
2628
- * problem itself and said so — "UPDATE MISMATCH: SOURCE.json on disk is IDENTICAL to before the
2629
- * update … REFUSING to report success" — and then the fresh-install FALLBACK below ran, succeeded
2630
- * at re-installing the very same bytes, and its exit 0 became the exit code of the whole command.
2631
- * A user's scheduled job read that as success while the corpus had not moved.
2632
- *
2633
- * The fallback exists for ONE thing: an old bundle whose canonical manifest URL 404s, where a fresh
2634
- * install genuinely rescues the user. "Nothing landed" is not that case — it is a TRUE verdict
2635
- * about an intact KB, and re-downloading the same bundle cannot change it. So that verdict is
2636
- * terminal and keeps its own exit code; every other failure keeps today's fallback behaviour.
2637
- */
2638
- export function classifyUpdaterExit(status, { fallbackAllowed = true } = {}) {
2639
- if (status === 0) return { verdict: 'updated', fallback: false, exitCode: 0 };
2640
- if (status === UPDATE_NOT_LANDED) {
2641
- return { verdict: 'not-landed', fallback: false, exitCode: UPDATE_NOT_LANDED };
2840
+ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = null, requireResult = false } = {}) {
2841
+ if (status === 12 && result?.terminalVerdict === 'cleanup-pending') {
2842
+ return { verdict: 'cleanup-pending', fallback: false, exitCode: 12 };
2843
+ }
2844
+ if (status === 0) {
2845
+ if (result?.terminalVerdict === 'applied') return { verdict: 'applied', fallback: false, exitCode: 0 };
2846
+ if (result?.terminalVerdict === 'noop') return { verdict: 'noop', fallback: false, exitCode: 0 };
2847
+ if (!requireResult) return { verdict: 'legacy-success', fallback: false, exitCode: 0 };
2848
+ return { verdict: 'invalid-result', fallback: false, exitCode: 1 };
2642
2849
  }
2643
2850
  return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
2644
2851
  }
@@ -2676,12 +2883,16 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
2676
2883
  };
2677
2884
 
2678
2885
  try {
2886
+ if (wireClaude === wirePlugin) results.hookRetirement = retireManagedHookRegistrations();
2679
2887
  results.claude = wireClaude === wirePlugin
2680
2888
  ? wirePlugin({ expectedVersion: PACKAGE_VERSION, requireManaged: true })
2681
2889
  : wireClaude({ expectedVersion: PACKAGE_VERSION, requireManaged: true });
2682
2890
  if (results.claude.host && !results.claude.wired) return fail();
2683
2891
  results.codexHost = detectCodexHost();
2684
2892
  if (results.codexHost.host) {
2893
+ if (!['added', 'rewritten', 'unchanged', 'user-owned'].includes(results.codexHost.action)) {
2894
+ return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
2895
+ }
2685
2896
  results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
2686
2897
  if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
2687
2898
  return fail();
@@ -2697,17 +2908,20 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
2697
2908
  const applied = runStableSpine(apply);
2698
2909
  const okApplied = !applied.error && applied.status === 0;
2699
2910
  if (okApplied) {
2700
- // ISSUE #153 — running Claude sessions freeze their plugin root. Collect only generations whose
2701
- // leases prove that exact PID incarnation dead, or legacy roots past the compatibility grace.
2911
+ // ISSUE #153 — a running host may freeze an old plugin root. Reclaim only generations whose
2912
+ // modern leases prove no live consumer, or legacy generations past the compatibility grace.
2702
2913
  try {
2703
2914
  const gens = prunePluginGenerations({ apply: true });
2915
+ if (gens.retired?.length) {
2916
+ ok(`retired ${gens.retired.length} stale plugin generation(s) to compatibility shells: ${gens.retired.join(', ')}`);
2917
+ }
2704
2918
  if (gens.removed.length) {
2705
2919
  ok(`pruned ${gens.removed.length} stale plugin generation(s), freed ${(gens.bytes / 1048576).toFixed(1)} MB: ${gens.removed.join(', ')}`);
2706
2920
  }
2707
2921
  if (gens.cleanupBlocked?.length) {
2708
- warn(`plugin cleanup retained ${gens.cleanupBlocked.length} generation(s): ${gens.cleanupBlocked.map((item) => `${item.version} (${item.reason})`).join(', ')}`);
2709
- } else if (!gens.removed.length && gens.why) {
2710
- warn(`stale plugin generations were not pruned: ${gens.why}`);
2922
+ warn(`plugin cleanup blocked for ${gens.cleanupBlocked.length} generation(s): ${gens.cleanupBlocked.map((item) => `${item.version} (${item.reason})`).join(', ')}`);
2923
+ } else if (!gens.removed.length && !gens.retired?.length && gens.why) {
2924
+ info(`plugin generation reconciliation: ${gens.why}`);
2711
2925
  }
2712
2926
  } catch (e) {
2713
2927
  warn(`stale plugin generations were not pruned (${e.message}); nothing was removed`);
@@ -2745,7 +2959,8 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
2745
2959
  },
2746
2960
  consoleRuntime: results.consoleRuntime,
2747
2961
  });
2748
- return { ok: true, convergence, results, applyStatus: applied.status };
2962
+ return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
2963
+ error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
2749
2964
  }
2750
2965
 
2751
2966
  export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) {
@@ -2768,6 +2983,76 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
2768
2983
  function runUpdate() {
2769
2984
  printBanner('update');
2770
2985
  const kbDir = resolvedKbDir();
2986
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
2987
+ if (FLAG_HOST_SYNC_ONLY) {
2988
+ info(c.dim('repairing host shells and managed model catalog without mutating KB bytes…\n'));
2989
+ const convergence = syncHostsAfterUpdate(kbDir);
2990
+ if (!convergence.ok) {
2991
+ warn(`host synchronization is incomplete${convergence.error ? ` (${convergence.error})` : ''}`);
2992
+ process.exitCode = 1;
2993
+ return;
2994
+ }
2995
+ try {
2996
+ const managed = applyManagedCatalogUpdate({
2997
+ routerDir: path.join(os.homedir(), '.claude', 'model-router'),
2998
+ packageRoot: REPO_ROOT,
2999
+ });
3000
+ if (managed.action === 'merged' && managed.added.length) {
3001
+ ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
3002
+ }
3003
+ } catch (error) {
3004
+ warn(`managed model additions were not merged (${error.message}); your catalog was left unchanged`);
3005
+ }
3006
+ process.exitCode = 0;
3007
+ return;
3008
+ }
3009
+ let refreshLock;
3010
+ let refreshReceipt;
3011
+ let refreshSettled = false;
3012
+ const nightlyExecution = process.env.RUVNET_NIGHTLY === '1'
3013
+ ? verifyNightlyExecutionIdentity({ brainHome, env: process.env })
3014
+ : null;
3015
+ if (nightlyExecution && !nightlyExecution.ok) {
3016
+ console.error(`\n${c.red('✗ nightly refresh identity is invalid:')} ${nightlyExecution.why}`);
3017
+ process.exitCode = 1;
3018
+ return;
3019
+ }
3020
+ try {
3021
+ const refreshAction = process.env.RUVNET_NIGHTLY === '1' ? 'nightly' : 'update';
3022
+ const refreshExecutableIdentity = nightlyExecution?.identity
3023
+ || { path: fileURLToPath(import.meta.url), version: PACKAGE_VERSION };
3024
+ refreshLock = acquireRefreshLock({ kbDir, brainHome, action: refreshAction, desiredVersion: PACKAGE_VERSION,
3025
+ schedulerIdentity: nightlyExecution?.identity.schedulerIdentity || null,
3026
+ executableIdentity: refreshExecutableIdentity });
3027
+ refreshReceipt = openRefreshReceipt({ brainHome, lock: refreshLock,
3028
+ action: refreshAction, desiredVersion: PACKAGE_VERSION,
3029
+ schedulerIdentity: nightlyExecution?.identity.schedulerIdentity || null,
3030
+ executableIdentity: refreshExecutableIdentity });
3031
+ } catch (error) {
3032
+ console.error(`\n${c.red('✗ refresh transaction could not start:')} ${error.message}`);
3033
+ process.exitCode = 1;
3034
+ return;
3035
+ }
3036
+ const refreshEnv = { ...process.env, RUVNET_REFRESH_RUN_TOKEN: refreshLock.token,
3037
+ RUVNET_REFRESH_RECEIPT: refreshReceipt.file };
3038
+ const settleRefresh = (code, detail) => {
3039
+ if (!refreshSettled) {
3040
+ try {
3041
+ settleRefreshRun({ handle: refreshReceipt, lock: refreshLock,
3042
+ status: code === 0 ? 'SUCCEEDED' : 'FAILED', detail });
3043
+ } catch (error) {
3044
+ code = 1;
3045
+ }
3046
+ refreshSettled = true;
3047
+ }
3048
+ process.exitCode = code;
3049
+ };
3050
+ const exitGuard = () => {
3051
+ if (refreshSettled) return;
3052
+ try { settleRefreshRun({ handle: refreshReceipt, lock: refreshLock, status: 'FAILED',
3053
+ detail: { reason: 'process exited before terminal settlement' } }); } catch { /* retained lock and receipt are recovery evidence */ }
3054
+ };
3055
+ process.once('exit', exitGuard);
2771
3056
  info(`brain dir: ${c.bold(kbDir)}`);
2772
3057
  let updateStatus = 1;
2773
3058
  // NO updater at all = no brain installed here (or a pre-self-updater bundle). That is a USER
@@ -2779,19 +3064,29 @@ function runUpdate() {
2779
3064
  // private KB stores a surprise fresh PUBLIC install is exactly the store-stripping hazard the
2780
3065
  // project docs warn about. The fallback's own comment scopes it to an updater that EXISTS but
2781
3066
  // is broken — this branch enforces that scope.)
2782
- if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs')) && !FLAG_HOST_SYNC_ONLY) {
3067
+ if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
2783
3068
  missingUpdaterHelp(kbDir);
2784
- process.exit(1);
3069
+ recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { reason: 'forge-update.mjs is missing' });
3070
+ settleRefresh(1, { phase: 'source-enumeration' });
3071
+ return;
2785
3072
  }
2786
- if (FLAG_HOST_SYNC_ONLY && !fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
2787
- warn('KB updater is absent — continuing with host-shell repair only; the knowledge bundle remains unchanged.');
2788
- updateStatus = 0;
2789
- } else {
2790
3073
  info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
2791
3074
  // Relative filename + matching cwd — same launch convention as smokeQuery(); stdio:'inherit'
2792
3075
  // streams the updater's narration live and unedited.
2793
- const r = spawnSync(process.execPath, ['forge-update.mjs', '--apply'], { cwd: kbDir, stdio: 'inherit' });
3076
+ const updaterSource = fs.readFileSync(path.join(kbDir, 'forge-update.mjs'), 'utf8');
3077
+ const supportsResultReceipt = updaterSource.includes("'--result-file'");
3078
+ const updaterResultDir = supportsResultReceipt ? fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-update-result-')) : null;
3079
+ const updaterResultFile = updaterResultDir ? path.join(updaterResultDir, 'result.json') : null;
3080
+ const updaterArgs = ['forge-update.mjs', '--apply', ...(updaterResultFile ? ['--result-file', updaterResultFile] : [])];
3081
+ const r = spawnSync(process.execPath, updaterArgs, {
3082
+ cwd: kbDir, stdio: 'inherit', env: refreshEnv,
3083
+ });
2794
3084
  updateStatus = r.error ? 1 : (r.status === null ? 1 : r.status);
3085
+ const updaterExit = updateStatus;
3086
+ let updaterResult = null;
3087
+ try { if (updaterResultFile && fs.existsSync(updaterResultFile)) updaterResult = JSON.parse(fs.readFileSync(updaterResultFile, 'utf8')); }
3088
+ catch { updaterResult = null; }
3089
+ if (updaterResultDir) fs.rmSync(updaterResultDir, { recursive: true, force: true });
2795
3090
  // FALLBACK (2026-07-17), scoped 2026-07-18 to exists-but-FAILED only. An OLDER bundle whose
2796
3091
  // canonicalManifestUrl points at the dead main/kb/.last-built.json path and 404s (the exact break a
2797
3092
  // real user, Jan Lafko, hit). NEVER leave the user stranded at a 404: re-run THIS installer as a
@@ -2803,15 +3098,9 @@ function runUpdate() {
2803
3098
  // updater's own correct refusal into a reported success.
2804
3099
  const outcome = classifyUpdaterExit(updateStatus, {
2805
3100
  fallbackAllowed: !process.env.RUVNET_BRAIN_NO_UPDATE_FALLBACK,
3101
+ result: updaterResult,
3102
+ requireResult: supportsResultReceipt,
2806
3103
  });
2807
- if (outcome.verdict === 'not-landed') {
2808
- console.error(`\n ${c.red('✗ the knowledge bundle did not change.')} The updater downloaded a bundle and refused to`);
2809
- console.error(` call it an update because the copy on disk is identical to the one it replaced.`);
2810
- console.error(` Nothing is broken and nothing was lost — but this run is ${c.bold('not')} a success, so it exits`);
2811
- console.error(` ${c.bold(String(UPDATE_NOT_LANDED))} rather than 0 and a scheduled job will see the failure (issue #106).`);
2812
- console.error(` If you believe a newer build exists, check: ${c.bold('node forge-update.mjs --check')} in ${kbDir}`);
2813
- process.exit(outcome.exitCode);
2814
- }
2815
3104
  if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
2816
3105
  // Host synchronization has a narrower contract than a full update: it must converge the
2817
3106
  // executable plugin/spine to the published package even when an optional large KB asset is
@@ -2825,11 +3114,47 @@ function runUpdate() {
2825
3114
  warn("\nthe bundle's own updater couldn't complete — falling back to a fresh install of the latest Release (this always works)…\n");
2826
3115
  const self = fileURLToPath(import.meta.url);
2827
3116
  const fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
2828
- env: { ...process.env, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
3117
+ env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
2829
3118
  updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
2830
3119
  } else {
2831
3120
  updateStatus = outcome.exitCode;
2832
3121
  }
3122
+ const cleanupPending = outcome.verdict === 'cleanup-pending';
3123
+ if (updateStatus !== 0 && !cleanupPending) {
3124
+ recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', {
3125
+ updaterExit, finalExit: updateStatus, fallback: outcome.fallback, verdict: outcome.verdict,
3126
+ updateResult: updaterResult,
3127
+ });
3128
+ settleRefresh(updateStatus, { phase: 'source-enumeration', terminalVerdict: 'failed' });
3129
+ process.removeListener('exit', exitGuard);
3130
+ return;
3131
+ }
3132
+ if (cleanupPending) updateStatus = 0;
3133
+ let phaseEvidence = updaterResult?.phaseEvidence || null;
3134
+ if (!phaseEvidence) {
3135
+ const installed = validateCoverageDirectory(kbDir, { expectedVersion: PACKAGE_VERSION });
3136
+ if (!installed.valid) {
3137
+ recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { failures: installed.failures,
3138
+ reason: 'legacy updater returned success without a valid installed ReleaseCoverage projection' });
3139
+ settleRefresh(1, { phase: 'source-enumeration', terminalVerdict: 'failed' });
3140
+ process.removeListener('exit', exitGuard);
3141
+ return;
3142
+ }
3143
+ phaseEvidence = Object.fromEntries(UPDATE_REFRESH_PHASES.map((phase) => [phase, {
3144
+ compatibility: 'validated-legacy-updater', coverageSha256: installed.coverageSha256,
3145
+ releaseCoverageGeneration: installed.coverage.releaseCoverageGeneration,
3146
+ execution: { kind: 'validated-legacy', sourceSnapshot: installed.coverage.releaseIdentity?.sourceSnapshot || null,
3147
+ upstreamFreshness: 'UNKNOWN' },
3148
+ }]));
3149
+ }
3150
+ for (const phase of UPDATE_REFRESH_PHASES) {
3151
+ if (!phaseEvidence[phase]) {
3152
+ recordRefreshPhase(refreshReceipt, phase, 'FAIL', { reason: `updater omitted ${phase} evidence` });
3153
+ settleRefresh(1, { phase, terminalVerdict: 'failed' });
3154
+ process.removeListener('exit', exitGuard);
3155
+ return;
3156
+ }
3157
+ recordRefreshPhase(refreshReceipt, phase, 'PASS', phaseEvidence[phase]);
2833
3158
  }
2834
3159
  if (updateStatus === 0) {
2835
3160
  info(c.dim('\nsynchronizing every detected host to this exact published version…\n'));
@@ -2838,6 +3163,10 @@ function runUpdate() {
2838
3163
  warn(`host synchronization is incomplete — runtime stays on the prior verified generation${convergence.error ? ` (${convergence.error})` : ''}`);
2839
3164
  updateStatus = 1;
2840
3165
  }
3166
+ recordRefreshPhase(refreshReceipt, 'host-convergence', convergence.ok && convergence.convergence?.healthy === true ? 'PASS' : 'FAIL', {
3167
+ state: convergence.convergence?.state || null, error: convergence.error || null,
3168
+ execution: { kind: 'executed', runId: refreshReceipt.runId },
3169
+ });
2841
3170
  }
2842
3171
  // Issue #87: a normal lifecycle update must also carry MANAGED MODEL additions to an existing
2843
3172
  // user. This call used to live only in offerRouterProfile() — the fresh-install path — so someone
@@ -2854,8 +3183,10 @@ function runUpdate() {
2854
3183
  if (managed.action === 'merged' && managed.added.length) {
2855
3184
  ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
2856
3185
  }
3186
+ recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'PASS', { action: managed.action, added: managed.added || [] });
2857
3187
  } catch (e) {
2858
3188
  warn(`managed model additions were not merged (${e.message}); your catalog was left unchanged`);
3189
+ recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'SKIP', { reason: e.message });
2859
3190
  }
2860
3191
  }
2861
3192
  // `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
@@ -2865,152 +3196,96 @@ function runUpdate() {
2865
3196
  info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
2866
3197
  enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
2867
3198
  }
2868
- process.exit(updateStatus); // exit with the updater's own verdict
3199
+ let retention;
3200
+ try {
3201
+ retention = pruneLifecycleEvidence({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
3202
+ preserveTransactionPaths: updaterResult?.transactionReceipts ? [updaterResult.transactionReceipts] : [] });
3203
+ } catch (error) {
3204
+ retention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
3205
+ withinBudget: false, unsafe: [{ path: brainHome, reason: error.message }] };
3206
+ }
3207
+ const retentionFailed = retention.withinBudget !== true;
3208
+ const cleanupFailed = cleanupPending || retentionFailed;
3209
+ recordRefreshPhase(refreshReceipt, 'cleanup', cleanupFailed ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
3210
+ reason: cleanupPending ? 'verified live generation has redundant rollback cleanup pending'
3211
+ : retentionFailed ? 'lifecycle evidence exceeds its fixed retention safety budget'
3212
+ : (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
3213
+ storageDelta: updaterResult?.storageDelta || null,
3214
+ lifecycleRetention: retention,
3215
+ execution: { kind: 'executed', runId: refreshReceipt.runId },
3216
+ required: cleanupFailed || updateStatus === 0,
3217
+ });
3218
+ settleRefresh(cleanupPending ? 12 : (retentionFailed ? 1 : updateStatus), {
3219
+ phase: cleanupFailed ? 'cleanup' : (updateStatus === 0 ? 'complete' : 'failed'),
3220
+ terminalVerdict: cleanupPending ? 'cleanup-pending' : retentionFailed ? 'recovery-required'
3221
+ : (outcome.verdict === 'noop' ? 'noop' : 'applied'),
3222
+ storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
3223
+ process.removeListener('exit', exitGuard);
2869
3224
  }
2870
3225
 
2871
3226
  function enableNightly() {
2872
3227
  printBanner('enable nightly updates');
2873
3228
  const kbDir = resolvedKbDir();
2874
-
2875
- if (process.platform !== 'darwin') {
2876
- // ISSUE #129 — A COMMAND NAMED `--enable-nightly` MAY NOT EXIT 0 WITHOUT ENABLING ANYTHING.
2877
- //
2878
- // This printed a cron recipe and returned success. Every automated check of "is Evergreen on?"
2879
- // — a provisioning script, a CI step, a user reading the exit code — was told yes on a machine
2880
- // where no schedule existed. So it now INSTALLS the entry, and when it cannot, it says so and
2881
- // exits non-zero rather than dressing a manual instruction up as a completed action.
2882
- const line = cronExample(kbDir);
2883
- const installed = installCronEntry(line);
2884
- if (installed.ok) {
2885
- ok(`installed a nightly crontab entry (03:47 local):\n\n ${c.bold(line)}\n`);
2886
- info(`(${c.bold('crontab -l')} to see it, ${c.bold('crontab -e')} to remove it.)`);
2887
- return;
2888
- }
2889
- console.error(`\n${c.red('✗ nightly updates were NOT enabled:')} ${installed.why}`);
2890
- info('\nAdd this line by hand and nightly updates will work exactly as they do on macOS:');
2891
- console.log(`\n ${c.bold(line)}\n`);
2892
- info(`(${c.bold('crontab -e')}, paste the line, save. Remove the line to disable.)`);
2893
- process.exit(1); // the caller asked for a schedule and does not have one
2894
- }
2895
-
2896
3229
  info(`brain dir: ${c.bold(kbDir)}`);
2897
3230
  if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
2898
- // Refuse to schedule a job that is guaranteed to fail every night — fail loud NOW instead.
2899
3231
  missingUpdaterHelp(kbDir);
2900
- process.exit(1);
3232
+ process.exitCode = 1;
3233
+ return;
2901
3234
  }
2902
-
2903
- // Template the plist to THIS user's kb dir + node binary.
2904
- //
2905
- // No `/bin/sh -c` (ADR-038): a LaunchAgent whose ProgramArguments invoke a shell is the standard
2906
- // macOS persistence pattern, and EDR persistence monitors score it well above a plist that execs a
2907
- // binary directly. launchd provides everything the shell was doing here natively —
2908
- // WorkingDirectory replaces `cd`, StandardOutPath/StandardErrorPath replace `>>` and `2>&1` — so
2909
- // dropping the shell costs nothing and removes both a shell parse of interpolated paths and the
2910
- // signature. Same schedule, same command, same log.
2911
- const logPath = path.join(kbDir, 'update.log');
2912
- const plist = `<?xml version="1.0" encoding="UTF-8"?>
2913
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2914
- <plist version="1.0">
2915
- <dict>
2916
- <key>Label</key>
2917
- <string>${NIGHTLY_LABEL}</string>
2918
- <!-- Issue #129: the SAME host-convergent entrypoint the session updater runs, not the KB-only
2919
- forge-update.mjs it used to schedule. See NIGHTLY_ARGV. Still no shell wrapper (ADR-038) —
2920
- this execs npx directly. -->
2921
- <key>ProgramArguments</key>
2922
- <array>
2923
- <string>${xmlEscape(npxPath())}</string>
2924
- ${NIGHTLY_ARGV.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n')}
2925
- </array>
2926
- <!-- launchd starts agents with a MINIMAL PATH (/usr/bin:/bin:/usr/sbin:/sbin) - it does not read
2927
- the user's shell profile. npx resolved by bare name would therefore fail to launch on every
2928
- Homebrew/nvm/Volta install, which is most of them, and the only symptom would be a nightly
2929
- that silently never ran. The absolute path above is resolved at install time; PATH is also set
2930
- so npx can find node and the package's own bin shims once it starts. -->
2931
- <key>EnvironmentVariables</key>
2932
- <dict>
2933
- <key>PATH</key>
2934
- <string>${xmlEscape(launchdPath())}</string>
2935
- </dict>
2936
- <key>WorkingDirectory</key>
2937
- <string>${xmlEscape(kbDir)}</string>
2938
- <key>StandardOutPath</key>
2939
- <string>${xmlEscape(logPath)}</string>
2940
- <key>StandardErrorPath</key>
2941
- <string>${xmlEscape(logPath)}</string>
2942
- <key>StartCalendarInterval</key>
2943
- <dict>
2944
- <key>Hour</key>
2945
- <integer>3</integer>
2946
- <key>Minute</key>
2947
- <integer>47</integer>
2948
- </dict>
2949
- <key>RunAtLoad</key>
2950
- <false/>
2951
- </dict>
2952
- </plist>
2953
- `;
2954
- const plistPath = nightlyPlistPath();
2955
3235
  try {
2956
- fs.mkdirSync(path.dirname(plistPath), { recursive: true });
2957
- fs.writeFileSync(plistPath, plist);
2958
- } catch (e) {
2959
- console.error(`\n${c.red('✗ couldn\'t write the LaunchAgent:')} ${e.message}`);
2960
- process.exit(1);
2961
- }
2962
- ok(`wrote ${c.bold(plistPath)}`);
2963
- info(c.dim('runs nightly at 03:47 — an off-hour minute, so it never lands on the :00 rush'));
2964
-
2965
- if (TEST_MODE) {
2966
- warn('RUVNET_BRAIN_TEST=1 — skipping launchctl bootout/bootstrap (plist written only)');
2967
- } else {
2968
- const uid = process.getuid();
2969
- // bootout first so re-running replaces the loaded job cleanly; failure just means "wasn't loaded".
2970
- spawnSync('launchctl', ['bootout', `gui/${uid}/${NIGHTLY_LABEL}`], { stdio: 'ignore' });
2971
- const boot = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8' });
2972
- if (boot.status === 0) ok('LaunchAgent loaded — your brain now updates while you sleep');
2973
- else {
2974
- warn(`launchctl bootstrap failed (${(boot.stderr || '').trim() || `exit ${boot.status}`}) — the plist is in place;`);
2975
- info(`load it yourself: ${c.bold(`launchctl bootstrap gui/${uid} ${plistPath}`)}`);
3236
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
3237
+ const registration = installNightlyRunner({ brainHome, env: { ...process.env, RUVNET_BRAIN_HOME: brainHome, RUVNET_BRAIN_KB: kbDir },
3238
+ source: path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs') });
3239
+ const installed = installScheduler(registration, {
3240
+ platform: process.platform, env: process.env, kbDir, testMode: TEST_MODE,
3241
+ pathValue: [...new Set([path.dirname(process.execPath), ...(process.env.PATH || '').split(path.delimiter)])].filter(Boolean).join(path.delimiter),
3242
+ });
3243
+ if (!installed.ok) throw new Error(installed.why);
3244
+ const status = schedulerStatus({ platform: process.platform, env: process.env, brainHome, kbDir,
3245
+ testMode: TEST_MODE });
3246
+ if (status.state !== 'on') throw new Error(status.evidence);
3247
+ ok(`nightly updates enabled — ${status.evidence}`);
3248
+ info(c.dim(`identity ${NIGHTLY_LABEL}; immutable runner ${registration.runnerSha256.slice(0, 16)}…; 03:47 local`));
3249
+ if (TEST_MODE) warn('RUVNET_BRAIN_TEST=1 — scheduler registration was written but OS activation was safely simulated');
3250
+ } catch (error) {
3251
+ console.error(`\n${c.red('✗ nightly updates were NOT enabled:')} ${error.message}`);
3252
+ process.exitCode = 1;
3253
+ return;
2976
3254
  }
2977
- }
2978
-
2979
- console.log(`\n ${c.bold('Verify it:')} launchctl list | grep ${NIGHTLY_LABEL}`);
2980
- console.log(` ${c.bold('Watch it:')} tail ${logPath} ${c.dim('(appears after the first nightly run)')}`);
2981
3255
  console.log(` ${c.bold('Disable it:')} npx ruvnet-brain --disable-nightly`);
2982
- console.log(`\n ${c.dim('It only ever PULLS the published Release bundle (backup + re-verify built in) — it never publishes,')}`);
2983
- console.log(` ${c.dim('and a night with no new Release is a clean no-op.')}\n`);
2984
3256
  }
2985
3257
 
2986
3258
  function disableNightly() {
2987
3259
  printBanner('disable nightly updates');
2988
- if (process.platform !== 'darwin') {
2989
- info('The LaunchAgent nightly is macOS-only, so nothing was scheduled here by this tool.');
2990
- info(`If you added the cron line yourself, remove it with: ${c.bold('crontab -e')}`);
2991
- return;
3260
+ const kbDir = resolvedKbDir();
3261
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
3262
+ const registration = readNightlyRegistration({ brainHome });
3263
+ const removed = removeScheduler({ platform: process.platform, env: process.env, testMode: TEST_MODE });
3264
+ if (!removed.ok) {
3265
+ console.error(`\n${c.red('✗ nightly updates were NOT disabled:')} ${removed.why}`);
3266
+ process.exitCode = 1;
3267
+ return false;
2992
3268
  }
2993
- const plistPath = nightlyPlistPath();
2994
- const existed = fs.existsSync(plistPath);
2995
- if (TEST_MODE) {
2996
- warn('RUVNET_BRAIN_TEST=1 — skipping launchctl bootout (plist removal only)');
2997
- } else {
2998
- // Ignore failure: "not loaded" is exactly the state we want anyway.
2999
- spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${NIGHTLY_LABEL}`], { stdio: 'ignore' });
3269
+ const status = schedulerStatus({ platform: process.platform, env: process.env, brainHome, kbDir,
3270
+ testMode: TEST_MODE });
3271
+ if (!['off', 'unsupported'].includes(status.state)) {
3272
+ console.error(`\n${c.red('✗ nightly disable could not be verified:')} ${status.evidence}`);
3273
+ process.exitCode = 1;
3274
+ return false;
3000
3275
  }
3001
- if (existed) {
3002
- try {
3003
- fs.rmSync(plistPath);
3004
- } catch (e) {
3005
- console.error(`\n${c.red('✗ couldn\'t remove the LaunchAgent:')} ${e.message}`);
3006
- console.error(` Remove it yourself: rm ${plistPath}`);
3007
- process.exit(1);
3276
+ if (registration.ok) {
3277
+ fs.rmSync(registration.record.recordPath, { force: true });
3278
+ const dir = path.dirname(registration.record.recordPath);
3279
+ const otherRegistrations = fs.readdirSync(dir).filter(name => /^registration.*\.json$/.test(name));
3280
+ // A proof registration may share the immutable runner; retain it if any registration remains.
3281
+ if (!otherRegistrations.length && path.dirname(registration.record.runnerPath) === dir
3282
+ && path.basename(registration.record.runnerPath) === `nightly-refresh-${registration.record.runnerSha256}.mjs`) {
3283
+ fs.rmSync(registration.record.runnerPath, { force: true });
3008
3284
  }
3009
- ok(`nightly updates disabled — removed ${plistPath}`);
3010
- } else {
3011
- ok('nightly updates were already off — nothing to remove (safe to run any time)');
3012
3285
  }
3286
+ ok(removed.already ? 'nightly updates were already off — nothing to remove' : 'nightly updates disabled and absence verified');
3013
3287
  info(`re-enable any time: ${c.bold('npx ruvnet-brain --enable-nightly')}`);
3288
+ return true;
3014
3289
  }
3015
3290
 
3016
3291
  // ── spend guard: the alarm that catches a runaway agentic fleet BEFORE it drains a card ───────────
@@ -3137,8 +3412,12 @@ export function machineFootprint() {
3137
3412
  const add = (label, p, undo) => { try { if (p && fs.existsSync(p)) items.push({ label, path: p, undo }); } catch { /* unreadable → not ours to claim */ } };
3138
3413
 
3139
3414
  add('Brain bundle (knowledge base)', resolvedKbDir(), 'npx ruvnet-brain --uninstall');
3415
+ {
3416
+ const artifact = nightlyArtifact({ platform: process.platform, env: process.env });
3417
+ if (artifact.kind === 'launchd') add('Nightly updater (LaunchAgent)', artifact.path, 'npx ruvnet-brain --disable-nightly');
3418
+ add('Nightly scheduler registration', path.join(process.env.RUVNET_BRAIN_HOME || path.dirname(resolvedKbDir()), 'scheduler', 'registration.json'), 'npx ruvnet-brain --disable-nightly');
3419
+ }
3140
3420
  if (process.platform === 'darwin') {
3141
- add('Nightly updater (LaunchAgent)', nightlyPlistPath(), 'npx ruvnet-brain --disable-nightly');
3142
3421
  add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
3143
3422
  add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
3144
3423
  }
@@ -3207,8 +3486,8 @@ export function machineFootprint() {
3207
3486
  undo: 'npx ruvnet-brain --uninstall (removes only the managed block)',
3208
3487
  });
3209
3488
  }
3210
- add('Codex lifecycle hook wrapper', codexHookWrapperPath(),
3211
- 'npx ruvnet-brain --uninstall');
3489
+ add('Legacy Codex lifecycle hook wrapper', codexHookWrapperPath(),
3490
+ 're-run npx ruvnet-brain to retire it, or use --uninstall');
3212
3491
  add('Codex MCP server files', codexServerDir(), 'npx ruvnet-brain --uninstall');
3213
3492
  const codexPlugin = codexPluginStatus();
3214
3493
  if (codexPlugin.installed) {
@@ -3316,13 +3595,13 @@ function uninstallAll() {
3316
3595
  // Ours-by-construction directories and files are removed; things that live INSIDE a file the user
3317
3596
  // owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
3318
3597
  // ours to delete, so they are handed over as commands.
3319
- const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)',
3598
+ const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)', 'Nightly scheduler registration',
3320
3599
  'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
3321
3600
  'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference',
3322
3601
  // Two gaps closed here: the statusLine KEY is now removable in place (we know exactly what we
3323
3602
  // wrote — see removeSettingsStatusLine()), and the upgrade-notice tracker is a file we fully own.
3324
3603
  'The statusLine entry in settings.json', 'Upgrade-notice state (release-notice tracking)',
3325
- 'The Codex search_ruvnet MCP registration', 'Codex lifecycle hook wrapper',
3604
+ 'The Codex search_ruvnet MCP registration', 'Legacy Codex lifecycle hook wrapper',
3326
3605
  'Codex MCP server files']);
3327
3606
  const willRemove = before.filter((it) => AUTO.has(it.label));
3328
3607
  const manual = before.filter((it) => !AUTO.has(it.label));
@@ -3337,7 +3616,10 @@ function uninstallAll() {
3337
3616
  console.log(`\n ${c.dim('Your own CLAUDE.md content is preserved — only our marked block is taken out,')}`);
3338
3617
  console.log(` ${c.dim('and the file is backed up first.')}\n`);
3339
3618
 
3340
- if (process.platform === 'darwin') { disableNightly(); disableSpendGuard(); }
3619
+ if (nightlyArtifact({ platform: process.platform, env: process.env }).supported) {
3620
+ if (disableNightly() === false) return;
3621
+ }
3622
+ if (process.platform === 'darwin') disableSpendGuard();
3341
3623
 
3342
3624
  const claudeMd = removeClaudeMdBlock();
3343
3625
  if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
@@ -3389,7 +3671,7 @@ function uninstallAll() {
3389
3671
  ['upgrade-notice state', upgradeNoticeStatePath()],
3390
3672
  // ADR-054 §5: a reinstall must never inherit an invisible OFF. See brainOffSentinelPath().
3391
3673
  ['brain on/off switch', brainOffSentinelPath()],
3392
- ['Codex lifecycle hook wrapper', codexHookWrapperPath()],
3674
+ ['legacy Codex lifecycle hook wrapper', codexHookWrapperPath()],
3393
3675
  ['Codex MCP server files', codexServerDir()],
3394
3676
  ]) {
3395
3677
  if (!fs.existsSync(target)) continue;
@@ -3571,14 +3853,15 @@ export async function offerNightly() {
3571
3853
  'rUv ships constantly; a brain that updates itself stays current with zero effort from you',
3572
3854
  );
3573
3855
 
3574
- if (process.platform !== 'darwin') {
3575
- info('The LaunchAgent scheduler is macOS-only (for now).');
3576
- info(`Update manually any time with: ${c.bold('npx ruvnet-brain --update')}`);
3577
- info(`(or schedule it yourself with the cron line documented in the brain's own forge-update.mjs)`);
3856
+ const artifact = nightlyArtifact({ platform: process.platform, env: process.env });
3857
+ if (!artifact.supported) {
3858
+ info(`No reversible scheduler adapter is available for ${process.platform}.`);
3578
3859
  return 'unsupported';
3579
3860
  }
3580
3861
 
3581
- if (fs.existsSync(nightlyPlistPath())) {
3862
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
3863
+ if (schedulerStatus({ platform: process.platform, env: process.env, brainHome, kbDir,
3864
+ testMode: process.env.RUVNET_BRAIN_SCHEDULER_TEST === '1' }).state === 'on') {
3582
3865
  ok('nightly auto-updates are already on — new repos and gists arrive while you sleep');
3583
3866
  return 'already-on';
3584
3867
  }
@@ -3590,7 +3873,7 @@ export async function offerNightly() {
3590
3873
  // nudged past a decision they'd have made differently. Both failures cost trust; only one is loud.
3591
3874
  info(`${c.green('Recommended')} — rUv ships constantly, and this is how fixes reach you without you`);
3592
3875
  info(`thinking about it. ${c.bold('Entirely your call, though')}, and easy to undo.`);
3593
- info(`${c.dim('What it sets up:')} a small background job (a macOS LaunchAgent) that checks each night`);
3876
+ info(`${c.dim('What it sets up:')} a small background job (${artifact.kind}) that checks each night`);
3594
3877
  info(`${c.dim(' ')} and downloads a fresher brain — signature-verified before anything is applied.`);
3595
3878
  info(`${c.dim('If you skip:')} nothing changes; update whenever you like with ${c.bold('npx ruvnet-brain --update')}`);
3596
3879
  info(`${c.dim('Turn it off:')} ${c.bold('npx ruvnet-brain --disable-nightly')} ${c.dim('(any time, no reinstall)')}`);
@@ -4401,7 +4684,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
4401
4684
  console.log(` • the brain (embedded source of ${installedRepoCount(cacheDir)} RuvNet repos) at:`);
4402
4685
  console.log(` ${c.bold(cacheDir)}`);
4403
4686
  console.log(
4404
- ` • the Claude Code plugin ${plugin.wired ? c.green('wired at user scope') : c.yellow('(finish the 2 commands above)')} — search_ruvnet + grounding hook`,
4687
+ ` • the Claude Code plugin ${plugin.wired ? c.green('wired at user scope') : c.yellow('(finish the 2 commands above)')} — search_ruvnet + skills + commands`,
4405
4688
  );
4406
4689
  if (codexHost?.host) {
4407
4690
  const hostReady = ['added', 'rewritten', 'user-owned'].includes(codexHost.action);
@@ -4424,14 +4707,15 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
4424
4707
  console.log(`\n ${c.bold('Where it runs:')} ${c.bold(`everywhere you use ${hostLabel}`)} — CLI and supported editor/desktop surfaces.`);
4425
4708
  console.log(` It's ${c.bold('user-level (global)')}: open ANY repo or folder and it's already there. Nothing per project,`);
4426
4709
  console.log(` nothing to copy in, nothing to git-ignore. ${c.dim('(Runs locally — it is not active in the claude.ai web app.)')}`);
4427
- console.log(`\n ${c.bold('How it runs:')} ${c.bold('automatically')} — you never call or configure anything. Ask normally; on rUv-stack work it`);
4428
- console.log(` grounds in real source and cites it, and if you drift to a classical default it steps in with the rUv option.`);
4710
+ console.log(`\n ${c.bold('How it runs:')} ${c.bold('explicitly')} — use search_ruvnet, a Brain skill, or a proof command when you want`);
4711
+ console.log(` source-grounded RuvNet context. Brain code does not run on host lifecycle events.`);
4429
4712
  console.log(`\n ${c.bold('Keep it fresh:')} re-run ${c.bold('npx ruvnet-brain')} any time — the brain itself always pulls the latest`);
4430
4713
  console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
4431
4714
 
4432
- // ── one important expectation: the hook activates on the NEXT session ──
4433
- console.log(`\n ${c.yellow(c.bold('One thing to know:'))} lifecycle hooks turn on in your ${c.bold('next')} host session.`);
4434
- console.log(` ${c.dim(`If ${hostLabel} is open right now, quit and reopen it so the new plugin snapshot loads.`)}`);
4715
+ // Hosts cache plugin registries for the lifetime of a session. A restart is therefore required to
4716
+ // unload callbacks from an older hook-bearing generation even though this version registers none.
4717
+ console.log(`\n ${c.yellow(c.bold('One required cleanup step:'))} restart any ${hostLabel} window that was open before this update.`);
4718
+ console.log(` ${c.dim('That unloads the old in-memory hook registry; new sessions load the intentional empty registry.')}`);
4435
4719
 
4436
4720
  // ── what to do now ──
4437
4721
  console.log(`\n ${c.bold('What to do now:')}`);
@@ -4441,19 +4725,19 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
4441
4725
 
4442
4726
  // ── how you'll KNOW it's working ──
4443
4727
  console.log(`\n ${c.bold('How you\'ll know it\'s working:')}`);
4444
- console.log(` ${c.green('✓')} Claude reaches for ${c.bold('RuVector / RVF, Ruflo, AgentDB')} instead of Pinecone / pgvector / LangChain.`);
4445
- console.log(` ${c.green('✓')} It ${c.bold('cites real source paths')} (it calls ${c.bold('search_ruvnet')}) instead of guessing.`);
4446
- console.log(` ${c.green('✓')} If you start to drift to a generic default, the brain ${c.bold('steps in')} and points you back.`);
4728
+ console.log(` ${c.green('✓')} ${c.bold('search_ruvnet')} returns cited source paths when invoked.`);
4729
+ console.log(` ${c.green('✓')} Brain skills and slash commands remain available.`);
4730
+ console.log(` ${c.green('✓')} ${c.bold('No Brain hook message or tool-call refusal appears by itself.')}`);
4447
4731
 
4448
4732
  // ── honest expectations ──
4449
4733
  console.log(`\n ${c.bold('What to expect (honestly):')}`);
4450
- console.log(` • On rUv-stack work (vectors, swarms, agent memory, SPARC) it grounds ${c.bold('every time')}.`);
4451
- console.log(` • On unrelated work, it stays quiet — Claude behaves normally. It only speaks up when it should.`);
4734
+ console.log(` • Grounding is available on demand; it is no longer injected into every turn.`);
4735
+ console.log(` • Host work proceeds normally unless you explicitly invoke a Brain command or skill.`);
4452
4736
  console.log(` • Not sure it's on? Run ${c.bold('npx ruvnet-brain --doctor')} any time for a health check.
4453
4737
  • Want to see it answer, live, right now? ${c.bold('npx ruvnet-brain --demo')} — 2 real questions, real cited answers.`);
4454
4738
 
4455
- console.log(`\n ${c.bold('Set it up your way:')} this default is ${c.bold('global')} — live in every VS Code project automatically,`);
4456
- console.log(` which is what most people want. Want it different (project-only, moved, with the build stack`);
4739
+ console.log(`\n ${c.bold('Set it up your way:')} this default is ${c.bold('global')} — MCP, skills, and commands are available in every project,`);
4740
+ console.log(` but they run only when invoked. Want it different (project-only, moved, with the build stack`);
4457
4741
  console.log(` added)? ${c.bold('Just tell Claude')} once it's on. You never have to learn its internals.`);
4458
4742
 
4459
4743
  if (nightly === 'enabled' || nightly === 'already-on') {
@@ -4474,7 +4758,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
4474
4758
  console.log(`\n ${c.bold('If it earns it:')} a GitHub star helps other people find the brain —`);
4475
4759
  console.log(` ${c.bold('https://github.com/stuinfla/ruvnet-brain')} ${c.dim('· feedback in one command: npx ruvnet-brain --feedback')}`);
4476
4760
 
4477
- console.log(`\n ${c.dim('You can\'t break anything — the plugin is disable-able and only acts on RuvNet-shaped work.')}`);
4761
+ console.log(`\n ${c.dim('The plugin is disable-able and installs no automatic lifecycle handlers.')}`);
4478
4762
  console.log('');
4479
4763
  }
4480
4764
 
@@ -4492,12 +4776,8 @@ Usage:
4492
4776
  EXITS NON-ZERO when the install is genuinely broken, so it can gate a
4493
4777
  script: npx ruvnet-brain --doctor && ./deploy.sh
4494
4778
  npx ruvnet-brain --doctor --hooks
4495
- …and additionally fire every hook this plugin registered on THIS
4496
- machine through the real shim under four stdin regimes (valid event
4497
- JSON, empty EOF, 1MB garbage, and stdin held open past budget), with an
4498
- external process-group watchdog. Asserts each hook's declared exit
4499
- codes, a 4KB stdout cap, its declared timeout with margin, and zero
4500
- surviving descendants. Your own hooks are listed, never executed.
4779
+ Backward-compatible, read-only proof that both shipped Brain hook
4780
+ manifests contain zero automatic registrations. Executes no hook body.
4501
4781
  npx ruvnet-brain --demo Guided walkthrough — 2 real questions, real cited answers
4502
4782
  npx ruvnet-brain --feedback Tell us how it went — prefills a GitHub Discussion with your brain
4503
4783
  version, platform, and a 3-line health summary (you see exactly
@@ -4505,7 +4785,7 @@ Usage:
4505
4785
  npx ruvnet-brain --update One-shot: pull the latest Release bundle into your installed brain
4506
4786
  (runs the bundle's own forge-update.mjs --apply: backup + re-verify)
4507
4787
  npx ruvnet-brain --enable-nightly Schedule that update nightly at 03:47 — macOS LaunchAgent;
4508
- other platforms get the documented cron line. OFF by default.
4788
+ Linux uses cron; Windows uses Task Scheduler. OFF by default.
4509
4789
  npx ruvnet-brain --disable-nightly Remove the nightly schedule (safe to run any time)
4510
4790
  npx ruvnet-brain --what-changed Show exactly what RuvNet Brain has put on this machine,
4511
4791
  with the undo command for each piece
@@ -4543,7 +4823,8 @@ Env:
4543
4823
  download or an air-gapped machine is not a broken install). Only ever set
4544
4824
  this for a locked-down environment where you want to know immediately.
4545
4825
 
4546
- It is safe to re-run at any time. After installing, restart Claude Code so the grounding hook loads.
4826
+ It is safe to re-run at any time. After updating from a hook-bearing version, restart open hosts so
4827
+ their old in-memory hook registries unload.
4547
4828
  `);
4548
4829
  }
4549
4830
 
@@ -4733,6 +5014,7 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
4733
5014
  `The knowledge base is present, but /rvbc would be broken. Re-run the installer from a complete package.`,
4734
5015
  );
4735
5016
  }
5017
+ retireManagedHookRegistrations();
4736
5018
  const plugin = wirePlugin();
4737
5019
  // Codex hosts got nothing before this (issue #42): shipped, never registered. Non-fatal like every
4738
5020
  // other wiring step — a second host we cannot reach must never break the one we can.
@@ -4839,16 +5121,14 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
4839
5121
  //
4840
5122
  // "Never block a healthy install" is honoured literally: a healthy install still exits 0 and
4841
5123
  // nothing above this point is undone. What changes is that a genuinely broken install can no
4842
- // longer masquerade as a successful one to a script. The user's OWN hooks and third-party
4843
- // plugins are enumerated and reported but NEVER charged against them — only registrations this
4844
- // package ships can make this fail.
5124
+ // longer masquerade as a successful one to a script. No Brain automatic hook is executed.
4845
5125
  if (!FLAG_NO_SELFCHECK) {
4846
5126
  const selfcheck = await runSelfCheck({
4847
5127
  installState: verified ? { repos: verified.repos, reader: verified.reader, mcp: verified.mcp } : null,
4848
5128
  quiet: true,
4849
5129
  });
4850
5130
  if (selfcheck.exitCode !== 0) {
4851
- console.log(`\n ${c.dim(`Re-run ${c.bold('npx ruvnet-brain --doctor --hooks')} after fixing, to re-check.`)}`);
5131
+ console.log(`\n ${c.dim(`Re-run ${c.bold('npx ruvnet-brain --doctor')} after fixing, to re-check.`)}`);
4852
5132
  process.exitCode = selfcheck.exitCode;
4853
5133
  }
4854
5134
  }