ruvnet-brain 3.4.21-dev β†’ 3.4.22-dev

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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # 🧠 RuvNet Brain
6
6
 
7
- ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 3.4.21-dev β€” updated 2026-07-20 03:20 EDT](https://img.shields.io/badge/version_3.4.21--dev-updated_2026--07--20_03:20_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
7
+ ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 3.4.22-dev β€” updated 2026-07-21 06:00 EDT](https://img.shields.io/badge/version_3.4.22--dev-updated_2026--07--21_06:00_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
8
8
 
9
9
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β€” delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
10
10
 
@@ -271,7 +271,7 @@ Plus: the **β€œtake the wheel” behavioral pipeline** (below), a **4-level beha
271
271
 
272
272
  ## How it works
273
273
 
274
- The expensive work happens **once, at build time**: every covered repo is deep-walked (whole files, full function bodies, plus a symbol index), embedded into **two** vector variants (MiniLM-384 for edge/portability, bge-768 for depth) stored on-disk in **RVF / HNSW**, and distilled into a concepts + capability layer of per-repo primers and cards. That's **149,664 source chunks**. At **query time**, `search_ruvnet` searches every repo's store at once, pools the hits, and runs them through **one cross-encoder rerank** on a common scale β€” so the truly relevant file wins regardless of which repo it lives in β€” then returns whole source files, each labeled by repo and path.
274
+ The expensive work happens **once, at build time**: every covered repo is deep-walked (whole files, full function bodies, plus a symbol index), embedded into **two** vector variants (MiniLM-384 for edge/portability, bge-768 for depth) stored on-disk in **RVF / HNSW**, and distilled into a concepts + capability layer of per-repo primers and cards. That's **149,691 source chunks**. At **query time**, `search_ruvnet` searches every repo's store at once, pools the hits, and runs them through **one cross-encoder rerank** on a common scale β€” so the truly relevant file wins regardless of which repo it lives in β€” then returns whole source files, each labeled by repo and path.
275
275
 
276
276
  ![RuvNet Brain architecture pipeline](assets/diagrams/architecture-pipeline.svg)
277
277
 
@@ -381,7 +381,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
381
381
 
382
382
  This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β€” we don't claim β€œdone,” β€œcomplete,” or β€œzero hallucinations.” Where it stands:
383
383
 
384
- - βœ… **The grounding brain is real and proven** β€” 54 public stores Β· 149,664 public source chunks (57 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
384
+ - βœ… **The grounding brain is real and proven** β€” 54 public stores Β· 149,691 public source chunks (57 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
385
385
  - βœ… **Code-level depth** β€” the code-rich repos are indexed to full function bodies; β€œhow is it implemented?” returns the implementation. Verified in the shipped bundle (clean-room 3/3).
386
386
  - βœ… **Routing holds** β€” named 47/48, described 26/28, scenario 7/8; behavioral L1–L4 all pass; private stores fenced out of the public bundle (zero-leak verified).
387
387
  - ⚠️ **Two routing residuals** (above) β€” surfaced, not hidden.
package/bin/install.mjs CHANGED
@@ -73,6 +73,11 @@ const FLAG_ENABLE_NIGHTLY = argv.includes('--enable-nightly'); // schedule that
73
73
  const FLAG_DISABLE_NIGHTLY = argv.includes('--disable-nightly'); // remove the nightly schedule
74
74
  const FLAG_NO_NIGHTLY_PROMPT = argv.includes('--no-nightly-prompt'); // don't offer nightly auto-updates at the end of an install
75
75
  const FLAG_NO_TELEMETRY = argv.includes('--no-telemetry'); // decline anonymous usage counts without being asked
76
+ // High-impact, so it needs its OWN flag β€” `-y` cannot install a launchd job (see ask()'s note).
77
+ const FLAG_ENABLE_SPEND_GUARD = argv.includes('--enable-spend-guard');
78
+ const FLAG_DISABLE_SPEND_GUARD = argv.includes('--disable-spend-guard'); // the missing undo
79
+ const FLAG_UNINSTALL = argv.includes('--uninstall'); // reverse everything, in one command
80
+ const FLAG_WHAT_CHANGED = argv.includes('--what-changed'); // show our footprint on this machine
76
81
  // ── onboarding-experience flags (all optional; every offer is safe to decline) ──
77
82
  const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
78
83
  const FLAG_WITH_STACK = argv.includes('--with-stack'); // add missing Ruflo/RuVector without prompting
@@ -459,6 +464,28 @@ function installReader(cacheDir) {
459
464
  ok('reader installed');
460
465
  }
461
466
 
467
+ // ── plugin presence: the ONLY reliable proof the slash commands will exist ───────────────────────
468
+ // Reported by a user on 3.4.21-dev whose install was otherwise healthy: `/rvbc` returned
469
+ // "Unknown command: /rvbc. Did you mean /rvf?". search_ruvnet worked, the KB was current β€” the
470
+ // plugin had simply never landed, and the installer had said everything was fine.
471
+ //
472
+ // The brain ships as TWO independent artifacts and this is the one people lose:
473
+ // β€’ KB + search_ruvnet β€” installed by this script into ~/.cache/ruvnet-brain
474
+ // β€’ the Claude Code plugin β€” slash commands, the Console, the grounding hook
475
+ // Checking the commands directory on disk is what distinguishes them; a `claude plugin install`
476
+ // exit code does not.
477
+ /** @returns {string|null} the commands dir if the plugin is really installed, else null */
478
+ function pluginCommandsDir() {
479
+ const candidates = [
480
+ path.join(os.homedir(), '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'plugin', 'commands'),
481
+ path.join(os.homedir(), '.claude', 'plugins', 'ruvnet-brain', 'commands'),
482
+ ];
483
+ for (const dir of candidates) {
484
+ try { if (fs.existsSync(path.join(dir, 'rvbc.md'))) return dir; } catch { /* unreadable β€” treat as absent */ }
485
+ }
486
+ return null;
487
+ }
488
+
462
489
  // ── step: wire the Claude Code plugin ────────────────────────────────────────────────────────────
463
490
  function wirePlugin() {
464
491
  step(
@@ -480,15 +507,30 @@ function wirePlugin() {
480
507
  }
481
508
 
482
509
  const addedMarket = tryRun('claude', ['plugin', 'marketplace', 'add', 'stuinfla/ruvnet-brain']);
483
- if (!addedMarket) warn(`couldn't add the marketplace automatically (it may already be added β€” that's fine).`);
484
-
485
- const installed = tryRun('claude', ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
486
- if (installed) {
510
+ // Deliberately NOT reassuring here. This used to say "it may already be added β€” that's fine",
511
+ // which is a GUESS about someone else's machine, and when it was wrong the user finished the
512
+ // install with a working search_ruvnet, no slash commands, and a message telling them all was
513
+ // well. The real state is checked below; nothing is declared fine until it has been looked at.
514
+ if (!addedMarket) info(`marketplace add didn't report success β€” checking what actually landed…`);
515
+
516
+ tryRun('claude', ['plugin', 'install', 'ruvnet-brain@ruvnet-brain', '--scope', 'user']);
517
+
518
+ // NEVER take "installed" on faith β€” same discipline verifyInstall() applies to the KB. An exit
519
+ // code says the command ran, not that the plugin is usable; the commands either exist on disk or
520
+ // they do not. This is the difference between `/rvbc` working and "Unknown command: /rvbc".
521
+ const commandsDir = pluginCommandsDir();
522
+ if (commandsDir) {
487
523
  ok('plugin installed at user scope (global, alongside Ruflo / RuVector)');
524
+ info(` commands available after a restart: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
488
525
  return { wired: true, manualMarketplace, manualInstall };
489
526
  }
490
527
 
491
- warn(`couldn't install the plugin automatically. Run these two commands yourself:`);
528
+ // The honest failure. The brain still WORKS β€” this is the difference between a broken install and
529
+ // a partial one, and the user is told exactly which they have instead of being congratulated.
530
+ warn(`the plugin did NOT land β€” so slash commands like ${c.bold('/rvbc')} will not exist yet.`);
531
+ info(`${c.green('Your brain still works')}: search_ruvnet is wired and Claude will ground answers with it.`);
532
+ info(`Only the plugin extras (slash commands, the Console, the grounding hook) are missing.`);
533
+ info(`Run these two yourself to finish:`);
492
534
  info(` ${c.bold(manualMarketplace)}`);
493
535
  info(` ${c.bold(manualInstall)}`);
494
536
  return { wired: false, manualMarketplace, manualInstall };
@@ -532,7 +574,10 @@ function verifyInstall(cacheDir) {
532
574
  if (mcp) ok('search_ruvnet server present (this is what Claude calls to ground answers)');
533
575
  else warn('forge-mcp-all.mjs missing β€” the brain unpacked incompletely');
534
576
 
535
- return { repos, reader, mcp };
577
+ // Plugin presence is part of "what is really on disk" β€” it is the difference between `/rvbc`
578
+ // working and "Unknown command". A user whose plugin never landed had no way to see that.
579
+ const plugin = pluginCommandsDir() !== null;
580
+ return { repos, reader, mcp, plugin };
536
581
  }
537
582
 
538
583
  // ── step: warm the model + prove grounding with one real question (best-effort, never fatal) ──────
@@ -727,6 +772,9 @@ async function doctor() {
727
772
  have('node') ? ok('node present') : warn('node missing');
728
773
  have('npm') ? ok('npm present') : warn('npm missing');
729
774
  have('claude') ? ok('claude CLI present') : warn('claude CLI missing (plugin wiring needs it)');
775
+ // Two independent version streams (KB bundle vs plugin wrapper) β€” see checkVersionDrift()'s
776
+ // header comment for the full story. Silent unless they've genuinely diverged.
777
+ reportVersionDrift(cacheDir);
730
778
  have('unzip') || have('pwsh') || have('powershell')
731
779
  ? ok('zip extraction available (unzip or PowerShell Expand-Archive)')
732
780
  : warn('no zip tool found β€” unzip or PowerShell needed for re-install');
@@ -813,6 +861,79 @@ function installedBrainVersion(cacheDir) {
813
861
  return 'unknown';
814
862
  }
815
863
 
864
+ // ── the OTHER version: the plugin WRAPPER's own plugin.json ──────────────────────────────────────
865
+ // installedBrainVersion() above answers "what KB is on disk". This answers "what PLUGIN WRAPPER is
866
+ // on disk" β€” a genuinely different artifact (hooks, skills, slash commands), updated on a genuinely
867
+ // different schedule (see the drift note at checkVersionDrift() below). Reuses pluginCommandsDir()
868
+ // β€” the ONE locator for "is the plugin really here" β€” instead of growing a second one: plugin.json
869
+ // always lives one directory above commands/, in both layouts that function checks.
870
+ function wrapperVersion() {
871
+ const commandsDir = pluginCommandsDir();
872
+ if (!commandsDir) return null; // plugin not installed β€” nothing to read, nothing to compare
873
+ try {
874
+ const p = path.join(path.dirname(commandsDir), '.claude-plugin', 'plugin.json');
875
+ const v = String(JSON.parse(fs.readFileSync(p, 'utf8')).version || '');
876
+ return /^[A-Za-z0-9._-]{1,32}$/.test(v) ? v : null; // present-but-unparsable = "don't know"
877
+ } catch { return null; }
878
+ }
879
+
880
+ /**
881
+ * The brain ships as TWO independently-versioned artifacts: the KB content bundle (self-updates
882
+ * nightly via forge-update.mjs + GitHub Releases) and the Claude Code PLUGIN WRAPPER (hooks,
883
+ * skills, slash commands), which updates ONLY when Claude Code itself pulls the marketplace git
884
+ * clone at ~/.claude/plugins/marketplaces/ruvnet-brain β€” NOT AT ALL if a user's
885
+ * ~/.claude/settings.json has "autoUpdate": false for that marketplace. They drift silently, and β€”
886
+ * this is the damaging part β€” the version a user is SHOWN always comes from the frozen wrapper,
887
+ * never the brain. Verified live on this machine 2026-07-20: KB SOURCE.json built today, wrapper
888
+ * plugin.json still 3.4.18-dev, nine commits behind origin/main, because autoUpdate was false. A
889
+ * user (Dr. Mark Allen) hit exactly this: KB current, wrapper still the June v0.5.0-dev build, and
890
+ * nothing anywhere told him the two had diverged.
891
+ *
892
+ * NEVER invent or guess a version β€” this project's hardest rule. Either side unresolved β†’ null, and
893
+ * null is NEVER treated as drift: a locally-built or pre-stamping KB legitimately has no releaseTag
894
+ * (see installedBrainVersion's own comment above), and a plugin that simply isn't installed yet is
895
+ * a DIFFERENT, already-reported situation (wirePlugin / verifyInstall), not a version mismatch.
896
+ * Drift is reported ONLY when BOTH sides resolved to a real value AND those values differ.
897
+ *
898
+ * @returns {{wrapper: string|null, kb: string|null, drift: boolean}}
899
+ */
900
+ function checkVersionDrift(cacheDir) {
901
+ const wrapper = wrapperVersion();
902
+ const kbRaw = installedBrainVersion(cacheDir); // already honest β€” 'unknown' rather than a guess
903
+ const kb = kbRaw === 'unknown' ? null : kbRaw;
904
+ // COMPARE NUMBERS, NOT NAMESPACES. The two sides are written by different writers in different
905
+ // formats: build-bundle stamps SOURCE.json.releaseTag as a git TAG (v-prefixed) while
906
+ // sync-version writes plugin.json.version as a bare SEMVER (no prefix). A raw !== is therefore
907
+ // ALWAYS true, so the first version of this check told every perfectly healthy user their install
908
+ // had drifted, and handed them a fix command that could never clear it. Caught by adversarial
909
+ // review, not by the tests β€” the test fixture used a v-prefixed plugin version that sync-version
910
+ // never produces, so an impossible input was green-lighting a false claim.
911
+ // Duplicated deliberately from scripts/version.mjs's stripTag(): this installer ships standalone
912
+ // on npm (package.json `files` excludes scripts/version.mjs) and imports node builtins ONLY, so
913
+ // it cannot import the canonical one. Same one-line rule, kept identical on purpose.
914
+ const stripV = (v) => String(v).replace(/^v/, '');
915
+ const drift = Boolean(wrapper && kb && stripV(wrapper) !== stripV(kb));
916
+ return { wrapper, kb, drift };
917
+ }
918
+
919
+ /**
920
+ * Shared narration for --doctor and --what-changed (via printFootprint). Silent whenever there is
921
+ * nothing actionable to say β€” matched versions, or either side not comparable β€” so this never adds
922
+ * noise to a healthy machine or a not-yet-fully-installed one. Speaks up only when the two
923
+ * artifacts have genuinely diverged, in plain, warm, non-alarming language (neither artifact is
924
+ * broken β€” they just update on different schedules), and always hands over the exact command to
925
+ * fix it β€” verified live against `claude plugin marketplace --help` (2026-07-20) before ever being
926
+ * printed here.
927
+ */
928
+ function reportVersionDrift(cacheDir) {
929
+ const state = checkVersionDrift(cacheDir);
930
+ if (!state.drift) return state;
931
+ warn(`the brain (${c.bold(state.kb)}) and the Claude Code plugin (${c.bold(state.wrapper)}) have drifted apart β€”`);
932
+ info(`that's normal (they update on separate schedules) and neither one is broken. To bring the`);
933
+ info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(then restart Claude Code)')}`);
934
+ return state;
935
+ }
936
+
816
937
  function installAgeLine(cacheDir) {
817
938
  // SOURCE.json's mtime is when the bundle last landed here (install or self-update) β€” say which.
818
939
  for (const f of ['SOURCE.json', 'forge-mcp-all.mjs']) {
@@ -831,7 +952,7 @@ function feedbackHealthLines(cacheDir) {
831
952
  const env = detectEnvironment();
832
953
  const allGreen = s.repos > 0 && s.reader && s.mcp;
833
954
  return [
834
- `${s.repos} repo stores on disk Β· reader ${s.reader ? 'ok' : 'MISSING'} Β· search_ruvnet ${s.mcp ? 'ok' : 'MISSING'}`,
955
+ `${s.repos} repo stores on disk Β· reader ${s.reader ? 'ok' : 'MISSING'} Β· search_ruvnet ${s.mcp ? 'ok' : 'MISSING'} Β· plugin ${s.plugin ? 'ok' : 'NOT INSTALLED (no /rvbc)'}`,
835
956
  `toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} Β· RuVector ${env.ruvector ? 'present' : 'not found'} Β· claude CLI ${env.claude ? 'present' : 'not found'}`,
836
957
  allGreen ? 'verdict: Healthy β€” installed and reachable' : 'verdict: Needs attention β€” re-run npx ruvnet-brain',
837
958
  ];
@@ -1132,6 +1253,247 @@ function enableSpendGuard() {
1132
1253
  return 'enabled';
1133
1254
  }
1134
1255
 
1256
+ /**
1257
+ * Remove the spend watchdog. Mirrors disableNightly() exactly.
1258
+ *
1259
+ * This did not exist until 2026-07-20, which meant the watchdog was the one thing this installer
1260
+ * could put on a machine with no supported way to take it back off. "Reversible" has to be a
1261
+ * command someone can run, not a paragraph telling them which files to delete by hand β€” a user
1262
+ * asking how to undo our changes should never need us to answer.
1263
+ */
1264
+ function disableSpendGuard() {
1265
+ printBanner('disable spend watchdog');
1266
+ if (process.platform !== 'darwin') {
1267
+ info('The spend-watchdog LaunchAgent is macOS-only, so nothing was scheduled here by this tool.');
1268
+ return;
1269
+ }
1270
+ const plistPath = spendGuardPlistPath();
1271
+ const scriptPath = spendGuardScriptPath();
1272
+ const existed = fs.existsSync(plistPath);
1273
+ if (TEST_MODE) {
1274
+ warn('RUVNET_BRAIN_TEST=1 β€” skipping launchctl bootout (plist removal only)');
1275
+ } else {
1276
+ // Ignore failure: "not loaded" is the state we want anyway.
1277
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${SPEND_GUARD_LABEL}`], { stdio: 'ignore' });
1278
+ }
1279
+ let failed = false;
1280
+ for (const p of [plistPath, scriptPath]) {
1281
+ if (!fs.existsSync(p)) continue;
1282
+ try { fs.rmSync(p); } catch (e) {
1283
+ failed = true;
1284
+ console.error(`\n${c.red("βœ— couldn't remove:")} ${p} β€” ${e.message}`);
1285
+ console.error(` Remove it yourself: rm ${p}`);
1286
+ }
1287
+ }
1288
+ if (failed) process.exit(1);
1289
+ if (existed) ok(`spend watchdog disabled β€” removed ${plistPath}`);
1290
+ else ok('spend watchdog was already off β€” nothing to remove (safe to run any time)');
1291
+ }
1292
+
1293
+ /**
1294
+ * Everything this installer can leave on a machine, DERIVED from disk β€” never asserted.
1295
+ *
1296
+ * A user who wants out should not have to ask us which files to delete. That was the actual
1297
+ * position the corporate-machine report left someone in: they had to reverse-engineer our
1298
+ * footprint from a bug report. Anything listed here has a real undo next to it.
1299
+ *
1300
+ * @returns {{label:string, path:string, undo:string}[]}
1301
+ */
1302
+ export function machineFootprint() {
1303
+ const items = [];
1304
+ const add = (label, p, undo) => { try { if (p && fs.existsSync(p)) items.push({ label, path: p, undo }); } catch { /* unreadable β†’ not ours to claim */ } };
1305
+
1306
+ add('Brain bundle (knowledge base)', resolvedKbDir(), 'npx ruvnet-brain --uninstall');
1307
+ if (process.platform === 'darwin') {
1308
+ add('Nightly updater (LaunchAgent)', nightlyPlistPath(), 'npx ruvnet-brain --disable-nightly');
1309
+ add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
1310
+ add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
1311
+ }
1312
+ const cmds = pluginCommandsDir();
1313
+ if (cmds) items.push({
1314
+ label: 'Claude Code plugin',
1315
+ path: path.dirname(cmds),
1316
+ undo: 'claude plugin uninstall ruvnet-brain@ruvnet-brain',
1317
+ });
1318
+ const cmdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1319
+ try {
1320
+ if (fs.existsSync(cmdPath) && fs.readFileSync(cmdPath, 'utf8').includes(CLAUDE_MD_START)) {
1321
+ items.push({ label: 'CLAUDE.md block (6 lines, between markers)', path: cmdPath, undo: 'npx ruvnet-brain --uninstall' });
1322
+ }
1323
+ } catch { /* unreadable */ }
1324
+ add('Usage-counts preference', telemetryConsentPath(), 'delete this file');
1325
+
1326
+ // EVERYTHING ELSE THIS INSTALLER WRITES. The first version of this listed the KB bundle and
1327
+ // little else β€” one artifact out of six β€” while the help text promised "exactly what RuvNet Brain
1328
+ // has put on this machine" and uninstallAll() went on to print "Verified clean". That is the same
1329
+ // position the corporate-machine reporter was left in, reproduced by the very feature written to
1330
+ // prevent it. It also explains a user seeing ~5 GB used for a ~2 GB knowledge base: most of the
1331
+ // footprint was never reported. Anything this installer can create belongs here, or the summary
1332
+ // is a comfortable fiction.
1333
+ add('Status-bar version script', path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ruvnet-brain-statusline.cjs'),
1334
+ 'remove the statusLine entry in ~/.claude/settings.json, then delete this file');
1335
+ add('Status-bar preference', path.join(telemetryStateDir(), '.statusline-pref'), 'delete this file');
1336
+ add('Model-router files', path.join(os.homedir(), '.claude', 'model-router'),
1337
+ 'rm -rf ~/.claude/model-router');
1338
+ // Config entries live INSIDE files the user owns, so they are reported as edits to review rather
1339
+ // than as paths to delete β€” deleting someone's settings.json over one key would be indefensible.
1340
+ try {
1341
+ const settings = path.join(os.homedir(), '.claude', 'settings.json');
1342
+ if (fs.existsSync(settings) && fs.readFileSync(settings, 'utf8').includes('ruvnet-brain')) {
1343
+ items.push({ label: 'A statusLine entry in your settings.json', path: settings, undo: 'remove the "statusLine" entry that points at ruvnet-brain' });
1344
+ }
1345
+ } catch { /* unreadable β€” do not claim it */ }
1346
+ try {
1347
+ const claudeJson = path.join(os.homedir(), '.claude.json');
1348
+ if (fs.existsSync(claudeJson) && fs.readFileSync(claudeJson, 'utf8').includes('ruvnet-brain')) {
1349
+ items.push({ label: 'The search_ruvnet MCP server registration', path: claudeJson, undo: 'claude mcp remove ruvnet-brain --scope user' });
1350
+ }
1351
+ } catch { /* unreadable β€” do not claim it */ }
1352
+
1353
+ return items;
1354
+ }
1355
+
1356
+ /** Print the footprint. Called at the end of an install so nobody is ever surprised later. */
1357
+ function printFootprint({ heading = 'What this put on your machine' } = {}) {
1358
+ const items = machineFootprint();
1359
+ if (!items.length) { info('Nothing from RuvNet Brain is currently installed.'); return items; }
1360
+ console.log(`\n ${c.bold(heading)}`);
1361
+ for (const it of items) {
1362
+ console.log(` β€’ ${it.label}`);
1363
+ console.log(` ${c.dim(it.path.replace(os.homedir(), '~'))}`);
1364
+ console.log(` ${c.dim(`undo: ${it.undo}`)}`);
1365
+ }
1366
+ console.log(`\n ${c.dim('Remove all of it at once:')} ${c.bold('npx ruvnet-brain --uninstall')}`);
1367
+ // Same "two artifacts, one machine" story as --doctor β€” surfaced here too, since a footprint
1368
+ // listing is exactly where a user would otherwise reasonably assume one version covers both.
1369
+ // Silent unless they've genuinely diverged.
1370
+ reportVersionDrift(resolvedKbDir());
1371
+ return items;
1372
+ }
1373
+
1374
+ /**
1375
+ * Surgically remove ONLY our block from CLAUDE.md, leaving every other line exactly as it was.
1376
+ * Backed up and written atomically, same as when it was added β€” taking something away is at least
1377
+ * as sensitive as putting it there.
1378
+ */
1379
+ function removeClaudeMdBlock() {
1380
+ const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1381
+ let src = '';
1382
+ try { src = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { return 'unreadable'; }
1383
+ // PAIR THE MARKERS PROPERLY. The first version took the FIRST start and the FIRST end, unpaired.
1384
+ // Adversarial review proved that eats the user's file: a CLAUDE.md that merely MENTIONS the start
1385
+ // marker in prose (entirely plausible β€” we print both marker strings to the console when adding
1386
+ // the block) above a real installed block causes everything between the prose mention and the real
1387
+ // block's end marker to be deleted. Their rules, silently gone, under a message saying "your
1388
+ // content untouched".
1389
+ //
1390
+ // Take the LAST start that has an end after it, and the FIRST end after that start β€” the real
1391
+ // block is the innermost well-formed pair. Anything we cannot pair confidently is left alone:
1392
+ // refusing to edit is always better than removing the wrong span from a file we do not own.
1393
+ const end = src.indexOf(CLAUDE_MD_END);
1394
+ if (end === -1) return 'absent';
1395
+ const start = src.lastIndexOf(CLAUDE_MD_START, end);
1396
+ if (start === -1) return 'absent';
1397
+
1398
+ // Splice ONLY the block. The previous version also ran .replace(/\n{3,}/g,'\n\n') and stripped
1399
+ // leading whitespace across the WHOLE document, which silently reformatted unrelated content β€”
1400
+ // including collapsing blank lines inside fenced code blocks β€” while the docstring promised
1401
+ // "every other line exactly as it was". Normalize only at the seam we actually cut.
1402
+ const before = src.slice(0, start).replace(/\n{3,}$/, '\n\n');
1403
+ const after = src.slice(end + CLAUDE_MD_END.length).replace(/^\n{3,}/, '\n\n');
1404
+ const next = `${before}${after}`;
1405
+ try {
1406
+ const backup = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
1407
+ fs.copyFileSync(p, backup);
1408
+ const tmp = `${p}.ruvnet-tmp`;
1409
+ fs.writeFileSync(tmp, next);
1410
+ fs.renameSync(tmp, p);
1411
+ return 'removed';
1412
+ } catch { return 'failed'; }
1413
+ }
1414
+
1415
+ /** Reverse everything, and PROVE it rather than claiming it. */
1416
+ function uninstallAll() {
1417
+ printBanner('uninstall RuvNet Brain');
1418
+ const before = machineFootprint();
1419
+ if (!before.length) { ok('Nothing to remove β€” RuvNet Brain is not installed here.'); return; }
1420
+
1421
+ // SPLIT WHAT WE REMOVE FROM WHAT WE CANNOT. The first version printed one flat "This will remove:"
1422
+ // list built from the whole footprint β€” including the Claude Code plugin and edits inside files
1423
+ // the user owns, none of which this function touches. It then closed by admitting those same
1424
+ // items still needed a manual step. One command contradicting itself inside a single run is
1425
+ // exactly the sloppiness that makes people stop believing any of our output, so the promise is now
1426
+ // scoped to what actually happens.
1427
+ // Ours-by-construction directories and files are removed; things that live INSIDE a file the user
1428
+ // owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
1429
+ // ours to delete, so they are handed over as commands.
1430
+ const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)',
1431
+ 'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
1432
+ 'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference']);
1433
+ const willRemove = before.filter((it) => AUTO.has(it.label));
1434
+ const manual = before.filter((it) => !AUTO.has(it.label));
1435
+
1436
+ console.log(` This will remove:`);
1437
+ for (const it of willRemove) console.log(` β€’ ${it.label} ${c.dim(it.path.replace(os.homedir(), '~'))}`);
1438
+ if (!willRemove.length) console.log(` ${c.dim('(nothing that this command removes automatically)')}`);
1439
+ if (manual.length) {
1440
+ console.log(`\n ${c.bold('It will NOT remove these')} β€” they are not ours to delete, so you get the command instead:`);
1441
+ for (const it of manual) console.log(` β€’ ${it.label}\n ${c.dim(it.undo)}`);
1442
+ }
1443
+ console.log(`\n ${c.dim('Your own CLAUDE.md content is preserved β€” only our marked block is taken out,')}`);
1444
+ console.log(` ${c.dim('and the file is backed up first.')}\n`);
1445
+
1446
+ if (process.platform === 'darwin') { disableNightly(); disableSpendGuard(); }
1447
+
1448
+ const claudeMd = removeClaudeMdBlock();
1449
+ if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
1450
+
1451
+ // NEVER rm -rf A PATH WE HAVE NOT PROVEN IS OURS. resolvedKbDir() honours $RUVNET_BRAIN_KB, which
1452
+ // the docs encourage for custom install locations β€” so `RUVNET_BRAIN_KB=$HOME npx ruvnet-brain
1453
+ // --uninstall` would have recursively deleted the user's home directory. Found by adversarial
1454
+ // review. The asymmetry was already visible in this same file: runUpdate() refuses to act unless
1455
+ // forge-update.mjs is present, precisely so it can never surprise someone. Uninstall skipped it.
1456
+ //
1457
+ // Proof-of-ownership: the directory must actually contain the reader we install. That is cheap,
1458
+ // unspoofable in practice, and fails CLOSED β€” if we cannot prove it is a brain, we do not touch it
1459
+ // and we say why.
1460
+ const kb = resolvedKbDir();
1461
+ const looksLikeBrain = kb && ['forge-ask.mjs', 'forge-mcp.mjs', 'SOURCE.json']
1462
+ .some((marker) => { try { return fs.existsSync(path.join(kb, marker)); } catch { return false; } });
1463
+ if (kb && fs.existsSync(kb) && !looksLikeBrain) {
1464
+ warn(`refusing to delete ${kb.replace(os.homedir(), '~')} β€” it does not look like a brain bundle.`);
1465
+ info(` (no forge-ask.mjs / forge-mcp.mjs / SOURCE.json found there). Nothing was removed.`);
1466
+ info(` If that really is your brain, remove it yourself: rm -rf ${kb.replace(os.homedir(), '~')}`);
1467
+ } else if (kb && fs.existsSync(kb)) {
1468
+ try { fs.rmSync(kb, { recursive: true, force: true }); ok(`removed the brain bundle (${kb.replace(os.homedir(), '~')})`); }
1469
+ catch (e) { warn(`couldn't remove ${kb}: ${e.message}`); }
1470
+ }
1471
+
1472
+ // The rest of what is ours by construction. Leaving these behind is how an "uninstalled" machine
1473
+ // still shows gigabytes of us β€” 8 executables under ~/.claude/model-router, a statusline script
1474
+ // that settings.json is still pointing at, and preference files. Each is removed only if it is
1475
+ // inside a directory this installer creates, never a path the user chose.
1476
+ for (const [label, target] of [
1477
+ ['model-router files', path.join(os.homedir(), '.claude', 'model-router')],
1478
+ ['status-bar script', path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ruvnet-brain-statusline.cjs')],
1479
+ ['status-bar preference', path.join(telemetryStateDir(), '.statusline-pref')],
1480
+ ['usage-counts preference', telemetryConsentPath()],
1481
+ ]) {
1482
+ if (!fs.existsSync(target)) continue;
1483
+ try { fs.rmSync(target, { recursive: true, force: true }); ok(`removed the ${label}`); }
1484
+ catch (e) { warn(`couldn't remove ${target.replace(os.homedir(), '~')}: ${e.message}`); }
1485
+ }
1486
+
1487
+ // PROOF, not a claim β€” re-derive the footprint and show what (if anything) survived.
1488
+ const after = machineFootprint();
1489
+ console.log('');
1490
+ if (!after.length) { ok('Verified clean β€” nothing from RuvNet Brain remains.'); }
1491
+ else {
1492
+ warn('These need one more step (they are not ours to remove automatically):');
1493
+ for (const it of after) console.log(` β€’ ${it.label} β€” ${c.bold(it.undo)}`);
1494
+ }
1495
+ }
1496
+
1135
1497
  // Exported (testable under RUVNET_BRAIN_IMPORT_ONLY=1, like offerNightly). Never throws β€” the caller
1136
1498
  // also guards, because a finished install must never be broken by an optional safety offer.
1137
1499
  export async function offerSpendGuard() {
@@ -1143,12 +1505,16 @@ export async function offerSpendGuard() {
1143
1505
  'One more safety net β€” a spend watchdog',
1144
1506
  'agentic tools can bill the paid API in the background; this alarm catches a runaway before it drains your card',
1145
1507
  );
1146
- info(`${c.bold('Strongly recommended:')} an hourly check that alerts you the moment an automated agent`);
1147
- info('fleet floods a project β€” the pattern that has quietly burned real money. Alert-only, never spends.');
1148
-
1149
- if (!process.stdin.isTTY && !FLAG_YES) { info(`No terminal to prompt on β€” install it any time by re-running ${c.bold('npx ruvnet-brain')}`); return 'recommended'; }
1508
+ info(`${c.green('Recommended')} β€” an hourly check that alerts you the moment an automated agent fleet`);
1509
+ info(`floods a project. That pattern has quietly burned real money. ${c.bold('Your call, and easy to undo.')}`);
1510
+ info(`${c.dim('What it sets up:')} a small background job (a macOS LaunchAgent) that watches for the burst`);
1511
+ info(`${c.dim(' ')} pattern. ${c.bold('Alert-only β€” it never spends, and never changes your billing.')}`);
1512
+ info(`${c.dim('If you skip:')} nothing changes; add it later with ${c.bold('npx ruvnet-brain --enable-spend-guard')}`);
1513
+
1514
+ // NOT gated on FLAG_YES β€” second launchd job, same rule as the nightly updater above.
1515
+ if (!process.stdin.isTTY && !FLAG_ENABLE_SPEND_GUARD) { info(`No terminal to prompt on β€” install it any time with ${c.bold('npx ruvnet-brain --enable-spend-guard')}`); return 'recommended'; }
1150
1516
  let yes = true;
1151
- if (!FLAG_YES) {
1517
+ if (!FLAG_ENABLE_SPEND_GUARD) {
1152
1518
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1153
1519
  const answer = await new Promise((resolve) => rl.question(` ${c.cyan('?')} Install the spend watchdog? ${c.dim('[Y/n]')} `, resolve));
1154
1520
  rl.close();
@@ -1293,15 +1659,28 @@ export async function offerNightly() {
1293
1659
 
1294
1660
  info(`${c.bold('Recommended:')} your brain updates itself while you sleep β€” new repos, new gists, zero effort.`);
1295
1661
 
1296
- if (!process.stdin.isTTY && !FLAG_YES) {
1662
+ // Recommend it, mean it, and still make declining feel completely fine. The goal is a user who
1663
+ // understands what they're agreeing to β€” not one who is either scared off by a wall of caveats or
1664
+ // nudged past a decision they'd have made differently. Both failures cost trust; only one is loud.
1665
+ info(`${c.green('Recommended')} β€” rUv ships constantly, and this is how fixes reach you without you`);
1666
+ info(`thinking about it. ${c.bold('Entirely your call, though')}, and easy to undo.`);
1667
+ info(`${c.dim('What it sets up:')} a small background job (a macOS LaunchAgent) that checks each night`);
1668
+ info(`${c.dim(' ')} and downloads a fresher brain β€” signature-verified before anything is applied.`);
1669
+ info(`${c.dim('If you skip:')} nothing changes; update whenever you like with ${c.bold('npx ruvnet-brain --update')}`);
1670
+ info(`${c.dim('Turn it off:')} ${c.bold('npx ruvnet-brain --disable-nightly')} ${c.dim('(any time, no reinstall)')}`);
1671
+
1672
+ // NOT gated on FLAG_YES β€” see the high-impact consent note on ask(). A blanket `-y` means nobody is
1673
+ // present to READ the explanation above, and an explanation nobody read is not consent. It takes the
1674
+ // explicit --enable-nightly, or a human answering in a terminal.
1675
+ if (!process.stdin.isTTY && !FLAG_ENABLE_NIGHTLY) {
1297
1676
  // No terminal to ask on (CI / piped install) β€” recommend clearly instead of prompting.
1298
1677
  info(`No interactive terminal here, so I won't prompt. Enable it any time with one command:`);
1299
1678
  info(` ${c.bold('npx ruvnet-brain --enable-nightly')}`);
1300
1679
  return 'recommended';
1301
1680
  }
1302
1681
 
1303
- let yes = true; // --yes accepts every optional offer, this one included
1304
- if (!FLAG_YES) {
1682
+ let yes = true;
1683
+ if (!FLAG_ENABLE_NIGHTLY) {
1305
1684
  // Not ask(): its parser treats anything but y/yes as no. Here the DEFAULT is yes β€” only an
1306
1685
  // explicit n/no declines (parseNightlyAnswer holds that contract, and the tests hold it there).
1307
1686
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -1411,8 +1790,28 @@ export async function offerTelemetry(cacheDir) {
1411
1790
  }
1412
1791
 
1413
1792
  // ── tiny interactive yes/no β€” SAFE in non-TTY (returns the default; never blocks a piped install) ──
1414
- function ask(question, def = false) {
1415
- if (FLAG_YES) return Promise.resolve(true);
1793
+ /**
1794
+ * @param {string} question
1795
+ * @param {boolean} def answer used when there is no terminal to ask on
1796
+ * @param {{blanketYes?: boolean}} opts blanketYes:false means --yes does NOT answer this one
1797
+ *
1798
+ * HIGH-IMPACT CONSENT (2026-07-20). `--yes` is documented as "accept every optional offer", and it
1799
+ * used to include the two changes nobody would call optional: installing a persistent LaunchAgent
1800
+ * that pulls code from GitHub on a schedule, and editing a global config file. Reported by a user on
1801
+ * a CORPORATE machine whose enterprise policy correctly blocked the plugin/MCP install but had no
1802
+ * rule covering a launchd job β€” so the one thing that survived was the background daemon.
1803
+ *
1804
+ * It almost certainly arrived via an AI agent: hit an interactive prompt, cannot answer it, re-run
1805
+ * with `-y`. Entirely reasonable behaviour, and with blanket consent it silently authorizes a
1806
+ * daemon. rUv's own ADR-302 already says why this is wrong β€” "accepting the enrollment screen is
1807
+ * not blanket authorization... four distinct decisions, each with its own consent, its own prompt
1808
+ * moment, and its own record." We were violating his design inside our own installer.
1809
+ *
1810
+ * So: persistent background jobs and global-config edits require their OWN explicit flag. There is
1811
+ * no combination of `-y` alone that installs a daemon.
1812
+ */
1813
+ function ask(question, def = false, { blanketYes = true } = {}) {
1814
+ if (FLAG_YES && blanketYes) return Promise.resolve(true);
1416
1815
  if (!process.stdin.isTTY) return Promise.resolve(def);
1417
1816
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1418
1817
  const suffix = def ? c.dim('[Y/n]') : c.dim('[y/N]');
@@ -1552,31 +1951,65 @@ Prefer rUv-native primitives (RVF over Pinecone/pgvector, Ruflo over LangChain,
1552
1951
  Not sure it's active? Run \`npx ruvnet-brain --doctor\`.
1553
1952
  ${CLAUDE_MD_END}`;
1554
1953
 
1555
- async function offerClaudeMd() {
1954
+ export async function offerClaudeMd() {
1556
1955
  if (FLAG_NO_ENHANCE) return;
1557
1956
  const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1558
1957
  let existing = '';
1559
1958
  try { existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { /* ignore */ }
1560
- if (existing.includes(CLAUDE_MD_START)) { return; } // already enhanced β€” idempotent, stay silent
1959
+ if (existing.includes(CLAUDE_MD_START)) { return; } // already there β€” idempotent, stay silent
1960
+
1961
+ // DON'T ASK WHEN IT ADDS NOTHING. If the plugin is installed, its hooks already enforce grounding
1962
+ // on every turn and this block is pure duplication β€” the old skip-branch message said exactly that
1963
+ // out loud. Asking to edit the most sensitive file we touch, for a benefit the user already has,
1964
+ // is how a helpful tool starts feeling invasive. So the question is only worth someone's attention
1965
+ // when the plugin is absent, which is a real population (see the "Unknown command: /rvbc" report)
1966
+ // but not the common one.
1967
+ if (pluginCommandsDir()) return;
1561
1968
 
1562
1969
  step(
1563
- 'Teaching your Claude to lean on the brain',
1564
- 'a short note in your global CLAUDE.md so every session β€” in any project β€” knows to use it',
1970
+ 'Optional β€” a note in your global CLAUDE.md',
1971
+ "so Claude leans on the brain in projects where the plugin's hooks aren't running",
1565
1972
  );
1973
+ // Say precisely what changes on their disk, in their words, BEFORE asking. "Enhance your CLAUDE.md"
1974
+ // is the kind of phrasing that makes a careful person assume the worst β€” and on a managed machine
1975
+ // that file may be governed. Six lines at the bottom, markers, reversible, backed up: all of that
1976
+ // is far less alarming than the vague version, and it happens to be the whole truth.
1977
+ info(`Adds ${c.bold('6 lines to the BOTTOM')} of ${c.bold('~/.claude/CLAUDE.md')}, wrapped in`);
1978
+ info(` ${c.dim('<!-- ruvnet-brain:start -->')} … ${c.dim('<!-- ruvnet-brain:end -->')}`);
1979
+ info(`Nothing already in the file is changed or removed. Delete the block any time.`);
1980
+ info(`${c.green("We back the file up first")}, and re-running never adds it twice.`);
1981
+ info(c.dim(`Honestly: if you install the plugin, you don't need this β€” its hooks already do it.`));
1982
+
1566
1983
  const yes =
1567
1984
  FLAG_ENHANCE_CLAUDE_MD ||
1568
- (await ask(`Add a short RuvNet-Brain section to ${existing ? 'your' : 'a new'} ~/.claude/CLAUDE.md?`, false));
1985
+ // blanketYes:false β€” editing a file THEY own is not something a blanket `-y` gets to decide.
1986
+ (await ask('Add it?', false, { blanketYes: false }));
1569
1987
  if (!yes) {
1570
- info('skipped β€” the plugin hooks already enforce grounding every turn; this was just extra reinforcement');
1988
+ info(`No problem, skipped β€” add it any time with ${c.bold('npx ruvnet-brain --enhance-claude-md')}`);
1571
1989
  return;
1572
1990
  }
1573
1991
  try {
1574
1992
  fs.mkdirSync(path.dirname(p), { recursive: true });
1993
+ // Back up before touching it β€” the same courtesy this installer already extends to settings.json,
1994
+ // and this is the more sensitive file of the two.
1995
+ let backup = null;
1996
+ if (existing) {
1997
+ backup = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
1998
+ fs.copyFileSync(p, backup);
1999
+ }
1575
2000
  const next = existing ? `${existing.replace(/\s*$/, '')}\n\n${CLAUDE_MD_BLOCK}\n` : `${CLAUDE_MD_BLOCK}\n`;
1576
- fs.writeFileSync(p, next);
1577
- ok(`added a RuvNet-Brain section to ${p} ${c.dim('(marker-guarded β€” safe to re-run)')}`);
2001
+ // ATOMIC write β€” temp sibling then rename. The content was always a pure append, but the old
2002
+ // code rewrote the whole file in place, so an interruption (disk full, power loss) could leave
2003
+ // a TRUNCATED CLAUDE.md. Fine 999 times out of 1000 and unforgivable the other time. This is the
2004
+ // discipline rUv already applies to credential files (cognitum-seed cloud_key.rs: tmp β†’ rename).
2005
+ const tmp = `${p}.ruvnet-tmp`;
2006
+ fs.writeFileSync(tmp, next);
2007
+ fs.renameSync(tmp, p);
2008
+ ok(`added 6 lines to the bottom of ${p}`);
2009
+ if (backup) info(c.dim(` your original is saved at ${backup}`));
2010
+ info(c.dim(` to remove: delete the block between the two ruvnet-brain markers`));
1578
2011
  } catch (e) {
1579
- warn(`couldn't update CLAUDE.md (${e.message}) β€” not important; the plugin hooks still enforce grounding`);
2012
+ warn(`couldn't update CLAUDE.md (${e.message}) β€” your file is untouched, and this was optional anyway`);
1580
2013
  }
1581
2014
  }
1582
2015
 
@@ -1728,7 +2161,14 @@ export async function offerStatusline() {
1728
2161
  info(`Adds a small ${c.bold('"RuvNet Brain vX.Y.Z"')} segment, read live from your installed brain β€” it`);
1729
2162
  info(`updates itself the moment the brain updates. ${c.bold('Never overwrites an existing status line.')}`);
1730
2163
 
1731
- const interactive = process.stdin.isTTY || FLAG_YES || FLAG_STATUSLINE;
2164
+ // FLAG_YES deliberately does NOT appear here. This writes ~/.claude/settings.json β€” a file the
2165
+ // USER owns β€” and installs a script Claude Code then executes on EVERY PROMPT. An adversarial
2166
+ // review caught that a blanket `-y` on a non-TTY still did both, which made the security fix in
2167
+ // 9ad02f5 ("`-y` can no longer install a daemon or edit a global config file") FALSE as written:
2168
+ // the two functions that commit gated were the two I happened to be thinking about, and this
2169
+ // third one β€” higher-frequency persistent execution than the LaunchAgent β€” was never checked.
2170
+ // Same footprint rule as everywhere else: their config, their explicit yes.
2171
+ const interactive = process.stdin.isTTY || FLAG_STATUSLINE;
1732
2172
  if (!interactive) {
1733
2173
  // No terminal to ask on, and no explicit flag either β€” skip WITHOUT recording an answer, so a
1734
2174
  // future interactive (or flagged) run still gets a real chance to ask.
@@ -1737,7 +2177,7 @@ export async function offerStatusline() {
1737
2177
  return 'not-asked';
1738
2178
  }
1739
2179
 
1740
- const yes = FLAG_STATUSLINE || (await ask('Add a RuvNet Brain version segment to your Claude Code status bar?', false));
2180
+ const yes = FLAG_STATUSLINE || (await ask('Add a RuvNet Brain version segment to your Claude Code status bar?', false, { blanketYes: false }));
1741
2181
 
1742
2182
  try {
1743
2183
  fs.mkdirSync(telemetryStateDir(), { recursive: true });
@@ -1853,6 +2293,14 @@ Usage:
1853
2293
  npx ruvnet-brain --enable-nightly Schedule that update nightly at 03:47 β€” macOS LaunchAgent;
1854
2294
  other platforms get the documented cron line. OFF by default.
1855
2295
  npx ruvnet-brain --disable-nightly Remove the nightly schedule (safe to run any time)
2296
+ npx ruvnet-brain --what-changed Show exactly what RuvNet Brain has put on this machine,
2297
+ with the undo command for each piece
2298
+ npx ruvnet-brain --uninstall Remove all of it (bundle, LaunchAgents, and our CLAUDE.md
2299
+ block only β€” your own CLAUDE.md content is preserved and backed up)
2300
+ npx ruvnet-brain --enable-spend-guard Install the hourly runaway-agent spend alarm (alert-only)
2301
+ npx ruvnet-brain --disable-spend-guard Remove it (safe to run any time)
2302
+ npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
2303
+ (appended at the bottom between markers; your file is backed up first)
1856
2304
  (a default install RECOMMENDS nightly and asks, defaulting to yes)
1857
2305
  node bin/install.mjs --no-nightly-prompt Don't offer nightly auto-updates at the end of the install
1858
2306
  node bin/install.mjs --no-telemetry Decline anonymous usage counts without being asked
@@ -1891,6 +2339,14 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
1891
2339
  if (FLAG_UPDATE) return runUpdate();
1892
2340
  if (FLAG_ENABLE_NIGHTLY) return enableNightly();
1893
2341
  if (FLAG_DISABLE_NIGHTLY) return disableNightly();
2342
+ // Standalone, like the nightly pair above. Without these, the flags existed only as a way to
2343
+ // pre-answer a prompt DURING a full install β€” so the copy telling someone to "add it later with
2344
+ // npx ruvnet-brain --enable-spend-guard" would have kicked off an entire reinstall instead of the
2345
+ // small targeted action they asked for. Promised in the UI, therefore real here.
2346
+ if (FLAG_ENABLE_SPEND_GUARD) { enableSpendGuard(); return; }
2347
+ if (FLAG_DISABLE_SPEND_GUARD) { disableSpendGuard(); return; }
2348
+ if (FLAG_UNINSTALL) { uninstallAll(); return; }
2349
+ if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
1894
2350
 
1895
2351
  printBanner('installer');
1896
2352
  console.log(c.dim("I'll set up the brain and the Claude Code plugin, explaining each step as I go.\n"));
@@ -1915,19 +2371,66 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
1915
2371
 
1916
2372
  const { cacheDir, isCustom } = resolveCacheDir();
1917
2373
 
2374
+ // ── "ALREADY PRESENT" IS THE WRONG QUESTION β€” ask "already CURRENT" ──────────────────────────
2375
+ //
2376
+ // THE STALE-INSTALL TRAP, and the root cause of "users are still on 0.5". This used to skip the
2377
+ // download whenever forge-mcp-all.mjs merely EXISTED β€” a pure file-existence check, no version
2378
+ // anywhere in it. So a June v0.5 brain made `alreadyInstalled` true, the download was skipped,
2379
+ // and the installer went on to print its success banner. Re-running the installer β€” the fix we
2380
+ // ADVERTISE in recovery messages β€” refreshed the reader and the plugin wiring and left the actual
2381
+ // brain untouched, forever. A closed trap: the advertised escape hatch was the thing that failed.
2382
+ //
2383
+ // The honest question is whether the installed brain is CURRENT, so that is what we now ask.
2384
+ // Fail-safe by design: if the version cannot be resolved (offline, API rate limit) we keep the
2385
+ // old skip behaviour rather than force a 2 GB download on someone with no network β€” but we SAY
2386
+ // that is what happened, instead of implying everything is up to date.
1918
2387
  const alreadyInstalled = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
2388
+ let staleSkip = false;
2389
+ let installedTag = null;
2390
+ let latestTag = null;
2391
+ let resolvedRelease = null; // reused below so the release is resolved at most once
1919
2392
  if (alreadyInstalled && !FLAG_FORCE) {
1920
- step(
1921
- 'Brain already present β€” skipping the download',
1922
- "it's already unpacked here; I'll just make sure the reader and plugin are wired (use --force to refetch)",
1923
- );
1924
- ok(`found an existing brain at ${cacheDir}`);
2393
+ installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
2394
+ try {
2395
+ resolvedRelease = await resolveRelease();
2396
+ // ONLY a genuine `latest` lookup counts as "what current means". resolveRelease() does NOT
2397
+ // throw when the GitHub API fails β€” it returns the hardcoded known-good pin with
2398
+ // source:'fallback'. Treating that as latest inverts this whole fix: a rate-limited lookup
2399
+ // would report installed v3.4.21-dev "β†’ latest v2.9.0" and DOWNGRADE a perfectly current
2400
+ // machine. (Caught by exercising the failure path against a 404 repo β€” the first version of
2401
+ // this fix did exactly that.) A pinned/forced resolution is likewise the operator's explicit
2402
+ // choice, not a staleness verdict, so neither drives this comparison.
2403
+ latestTag = resolvedRelease && resolvedRelease.source === 'latest'
2404
+ ? (resolvedRelease.tag_name || resolvedRelease.tag || null)
2405
+ : null;
2406
+ } catch { latestTag = null; }
2407
+ const norm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
2408
+ const a = norm(installedTag), b = norm(latestTag);
2409
+ // Different (or unknowable-installed) => it is NOT current => download. Same => genuinely skip.
2410
+ staleSkip = Boolean(b && (a === null || a !== b));
2411
+ }
2412
+
2413
+ if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
2414
+ if (latestTag) {
2415
+ step('Brain already current β€” skipping the download', `installed ${installedTag} matches the latest release`);
2416
+ ok(`found an up-to-date brain at ${cacheDir}`);
2417
+ } else {
2418
+ // Could not check. Say so plainly rather than letting silence imply "current".
2419
+ step('Brain present β€” could not check for a newer one', 'the release lookup failed (offline or rate-limited)');
2420
+ warn(`skipping the download WITHOUT verifying it is current. Installed: ${installedTag || 'unknown'}.`);
2421
+ info(`When you have a connection: ${c.bold('npx ruvnet-brain --update')} ${c.dim('(or --force to refetch now)')}`);
2422
+ }
1925
2423
  } else {
2424
+ if (staleSkip) {
2425
+ step('Brain is out of date β€” fetching the current release', `installed ${installedTag || 'unknown'} β†’ latest ${latestTag}`);
2426
+ info(`${c.dim('(the old installer skipped this whenever any brain was present, which is why stale installs never moved)')}`);
2427
+ }
1926
2428
  // Resolve which Release to fetch BEFORE downloading. Skipped entirely on the --local path
1927
2429
  // (obtainBundle short-circuits to the repo's dist/ zip and never touches the network).
1928
2430
  const localZipPresent =
1929
2431
  FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
1930
- const release = localZipPresent ? null : await resolveRelease();
2432
+ // Reuse the staleness check's resolution when it already ran β€” one network round-trip, not two.
2433
+ const release = localZipPresent ? null : (resolvedRelease || await resolveRelease());
1931
2434
  const { zipPath, tmpDir, downloaded } = await obtainBundle(release);
1932
2435
  // Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
1933
2436
  // (SEC-0010 #6 β€” trust root = keys/ruvnet-brain-signing.pub.pem shipped inside this package).
@@ -1986,6 +2489,12 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
1986
2489
  try { await offerStatusline(); } catch { /* non-fatal β€” a status-bar nicety must never break the install */ }
1987
2490
 
1988
2491
  success({ cacheDir, isCustom, plugin, env, nightly });
2492
+
2493
+ // Close every install by stating, in one place, exactly what is now on their machine and how to
2494
+ // take each piece back off. Someone reading this should never have to file a bug report to find
2495
+ // out what we did β€” which is precisely the position the 2026-07-20 corporate-machine reporter was
2496
+ // left in. Derived from disk, so it can only ever describe what is actually there.
2497
+ try { printFootprint(); } catch { /* a summary must never break a finished install */ }
1989
2498
  })().catch((e) => {
1990
2499
  die(e && e.message ? e.message : String(e));
1991
2500
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "3.4.21-dev",
3
+ "version": "3.4.22-dev",
4
4
  "description": "One-command installer for RuvNet Brain β€” a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {