ruvnet-brain 3.4.21-dev β†’ 3.5.1-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.
Files changed (3) hide show
  1. package/README.md +42 -4
  2. package/bin/install.mjs +625 -41
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -4,10 +4,26 @@
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.5.1-dev β€” updated 2026-07-21 06:00 EDT](https://img.shields.io/badge/version_3.5.1--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
 
11
+ </div>
12
+
13
+ > ## 🧭 North Star
14
+ >
15
+ > **rUv has built dozens of genuinely powerful capabilities that are effectively invisible β€” not undocumented, but _undiscovered_.** Ruflo's own docs call cross-project IPFS pattern transfer *"the substrate plugin's most underused capability."* The author knows people can't find his own work.
16
+ >
17
+ > **This project exists to close that gap** β€” a CTO on your shoulder that says *"you have this, it's off, here's what turning it on buys you."*
18
+ >
19
+ > **Retrieval was never the product. Proactive capability advocacy is the product.**
20
+ >
21
+ > The test we hold ourselves to: a developer who solves a hard problem with this tool and later learns they'd been sitting on a capability that would have made it trivial β€” that is a **failure of this project**, not of the user. Knowing which question to ask is the scarce thing; supplying that question is the job.
22
+ >
23
+ > Every feature is judged against this. If a surface can detect something useful and doesn't volunteer it, it is broken β€” see [ADR-027](docs/adr/0027-capability-advocacy-and-active-signals.md) and [DDD-0004](docs/ddd/0004-advocacy-context.md).
24
+
25
+ <div align="center">
26
+
11
27
  [![plugin version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fstuinfla%2Fruvnet-brain%2Fmain%2Fplugin%2F.claude-plugin%2Fplugin.json&query=%24.version&label=plugin&color=e8a13a&style=flat-square)](plugin/.claude-plugin/plugin.json)
12
28
  [![installer version](https://img.shields.io/npm/v/ruvnet-brain?label=installer%20%28npm%29&color=2e7d32&style=flat-square)](https://www.npmjs.com/package/ruvnet-brain)
13
29
  [![download](https://img.shields.io/badge/download-latest%20brain-2e7d32?style=flat-square)](https://github.com/stuinfla/ruvnet-brain/releases/latest)
@@ -40,7 +56,28 @@
40
56
 
41
57
  ---
42
58
 
43
- ## What's new in 3.4 β€” the invisible work, made visible (and readable)
59
+ ## What's new in 3.5 β€” it stopped waiting to be asked
60
+
61
+ **Shipped 2026-07-22.** For three weeks this thing had indexed rUv's entire learning stack β€” ReasoningBank, SONA, MoE, the ADR-174 distillation pipeline β€” and could have answered any question about any of it. It never once said the only sentence that mattered:
62
+
63
+ > *"Your learning system is installed, and it is switched off."*
64
+
65
+ Stuart found it himself. The measurement: **1,884 captured events sat undelivered** while the learner held 5 trajectories and hadn't trained in six days. Draining the queue took it to 412 in one command. Three weeks of learning had been sitting there, available for the asking, and nobody knew to ask.
66
+
67
+ **That gap is the product.** A brain that waits to be asked is a search box with good manners. 3.5 is the version where it speaks first.
68
+
69
+ - **It now tells you what you own and aren't using.** On the author's machine, unprompted, from a real scan: *"36 project stores are embedded but have never been distilled β€” 6,858 memories sitting in those stores, teaching nothing."* Every recommendation is built from what was actually **observed on your machine** β€” never a hardcoded list of cool features, which would rot the week rUv ships again.
70
+ - **Detection without a remedy is now structurally impossible.** The console used to detect a corrupt memory store, score it 49/100, render it into a card, and offer nothing. Stuart's verdict: *"the fact that it didn't recommend a fix is unconscionable."* Every recommendation now carries evidence, a cost, a plain-English impact, and a **real, tested undo** β€” enforced by a factory that throws, not by a code review that can be skipped.
71
+ - **It stopped lying in four specific ways.** It claimed `ruflo` wasn't installed to anyone whose npm prefix wasn't the author's. It answered *"nothing to undo"* about a database repair for which it held a backup. It reported a distillation that wrote 684 patterns as having done nothing (the check used a read-only connection that can't see another process's WAL). And its installer threw away the stderr that explained every crash β€” so a missing module and a slow download looked identical. All four found by running the thing rather than trusting it.
72
+ - **Every offered fix is provably runnable and reversible.** The remedy registry makes the id→executor→inverse binding a single value, and a closure test drives the real builders to prove nothing can be *offered* that cannot be *run* and *undone*. It found that this ADR's own headline recommendation had **no executor at all** — it rendered as a button that answered "Unknown recommendation id." Proven to fail on both known-bad cases before being trusted.
73
+ - **It stopped writing into your repos** ([#36](https://github.com/stuinfla/ruvnet-brain/issues/36)) and **stopped hiding the error that explains a failure** ([#37](https://github.com/stuinfla/ruvnet-brain/issues/37)) β€” both reported by users, both fixed at root cause, with an upstream report filed to rUv for the two bugs that turned out to be his.
74
+
75
+ The honest limit: this is **3.5, not 4.0**. The advocacy surface is live and the engine behind it is proven, but score-delta alarms aren't built and ADR-027 has not yet survived its own required adversarial review. A major version marks the release where the product becomes a different thing to the person using it. This is the release where it starts talking.
76
+
77
+ <details>
78
+ <summary><b>Earlier &#8212; what 3.4 shipped</b> &#183; the invisible work, made visible and readable. <i>Expand for the receipts.</i></summary>
79
+
80
+ ## 3.4 β€” the invisible work, made visible (and readable)
44
81
 
45
82
  **Shipped 2026-07-17.** 3.3 made every card lead with a point. Then Stuart looked at the two pages meant to *teach* the stack and scored them 55/100: the graphics were mediocre, the one page that should show how the pieces fit was a wall of text, and two diagrams were literally unreadable. 3.4 is the fix β€” the harness's invisible work, finally drawn, and drawn so you can actually read it.
46
83
 
@@ -230,6 +267,7 @@ claude plugin install ruvnet-brain@ruvnet-brain --scope user
230
267
 
231
268
  Registers the `search_ruvnet` MCP tool, the grounding skill, and the `UserPromptSubmit` enforcement hook β€” globally, at user scope. The plugin expects the brain at `~/.cache/ruvnet-brain/kb` (or point `RUVNET_BRAIN_KB` at your own copy). The first install may show a one-time trust prompt for the hook.
232
269
 
270
+ </details>
233
271
  </details>
234
272
 
235
273
  **Then just ask.** _β€œHow does Ruflo orchestrate agent swarms, and what implements it?”_ The hook grounds the turn, Claude calls `search_ruvnet`, and it answers from cited source β€” down to the function body.
@@ -271,7 +309,7 @@ Plus: the **β€œtake the wheel” behavioral pipeline** (below), a **4-level beha
271
309
 
272
310
  ## How it works
273
311
 
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.
312
+ 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,720 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
313
 
276
314
  ![RuvNet Brain architecture pipeline](assets/diagrams/architecture-pipeline.svg)
277
315
 
@@ -381,7 +419,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
381
419
 
382
420
  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
421
 
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.
422
+ - βœ… **The grounding brain is real and proven** β€” 54 public stores Β· 149,720 public source chunks (57 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
385
423
  - βœ… **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
424
  - βœ… **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
425
  - ⚠️ **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) ──────
@@ -598,7 +643,26 @@ async function smokeQuery(cacheDir) {
598
643
  const out = `${r.stdout || ''}`;
599
644
  if (r.status !== 0 || !out.trim()) {
600
645
  warn('no answer came back (first-run model download or offline) β€” the brain is installed; it\'ll warm on your first real question');
601
- return { ran: true, grounded: false, reason: 'no-answer' };
646
+ // SHOW THE ACTUAL ERROR (issue #37 bug 2, Agentist-Elder, 2026-07-21).
647
+ //
648
+ // This captured stderr and then threw it away, so every hard failure β€” a crash, a missing
649
+ // module, a bad model path β€” arrived looking identical to a slow first-run download. That is
650
+ // exactly how a total grounding outage (a static import of a module missing from the bundle,
651
+ // crashing before any model code ran) presented as the reassuring line above and cost the
652
+ // reporter a full debugging session to attribute. Their words, and they are right: this
653
+ // wrapper "will hide the *next* breakage too, whatever it is."
654
+ //
655
+ // A diagnostic that discards the diagnosis is worse than no diagnostic, because it reads as
656
+ // information. Print it. Truncated, because a stack trace is not a friendly install screen β€”
657
+ // but never hidden.
658
+ const err = `${r.stderr || ''}`.trim();
659
+ if (err) {
660
+ const lines = err.split('\n');
661
+ info(c.dim(' the reader reported:'));
662
+ for (const line of lines.slice(0, 12)) info(c.dim(` ${line.slice(0, 200)}`));
663
+ if (lines.length > 12) info(c.dim(` … ${lines.length - 12} more line(s)`));
664
+ }
665
+ return { ran: true, grounded: false, reason: 'no-answer', stderr: err.slice(0, 4000) };
602
666
  }
603
667
 
604
668
  const verifier = await loadCitationVerifier(cacheDir);
@@ -664,6 +728,12 @@ function runDemo() {
664
728
  const out = `${r.stdout || ''}`.trim();
665
729
  if (r.status !== 0 || !out) {
666
730
  warn(`no answer came back β€” the local model may still be warming up (run this again in a moment)`);
731
+ // Same discarded-diagnosis bug as smokeQuery() β€” see the note there (issue #37 bug 2).
732
+ const err = `${r.stderr || ''}`.trim();
733
+ if (err) {
734
+ info(c.dim(' the reader reported:'));
735
+ for (const line of err.split('\n').slice(0, 8)) info(c.dim(` ${line.slice(0, 200)}`));
736
+ }
667
737
  continue;
668
738
  }
669
739
  // Show the top hit's actual citation (repo/path/title + the start of its real cited text) β€”
@@ -687,14 +757,20 @@ function runDemo() {
687
757
  }
688
758
 
689
759
  // ── token meter one-liner for --doctor (ADR-0011 token_cost_efficiency) ──────────────────────────
690
- // The hooks + MCP server append one JSON line per fire to .ruvnet-brain/token-ledger.jsonl in the
691
- // project they run in (see scripts/token-report.mjs for the full breakdown). This summarizes what
692
- // was MEASURED yesterday+today in the cwd --doctor is run from β€” measured bytes, estimated tokens
693
- // (bytes/4, stated as an estimate). Fail-silent by design: a meter problem never reddens a checkup.
760
+ // The hooks + MCP server append one JSON line per fire to a SINGLE user-level ledger at
761
+ // ~/.cache/ruvnet-brain/token-ledger.jsonl (see scripts/token-report.mjs for the full breakdown).
762
+ // It used to be written per-CWD, which scattered hidden .ruvnet-brain/ directories through users'
763
+ // project trees and dirtied their git status β€” issue #36. Each line now carries a `cwd` field, so
764
+ // the per-project view survives without writing anything into a project.
765
+ // Fail-silent by design: a meter problem never reddens a checkup.
694
766
  function meterSummaryLine() {
695
767
  try {
696
- const ledger = path.join(process.cwd(), '.ruvnet-brain', 'token-ledger.jsonl');
697
- if (!fs.existsSync(ledger)) return 'meter: no data yet (this project has no .ruvnet-brain/token-ledger.jsonl β€” it appears after the first hook/MCP fire)';
768
+ const canonical = path.join(process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache'), 'ruvnet-brain', 'token-ledger.jsonl');
769
+ const legacy = path.join(process.cwd(), '.ruvnet-brain', 'token-ledger.jsonl');
770
+ // Read the legacy per-project ledger only if it exists and the canonical one does not β€” an
771
+ // existing user's measurements should not disappear the day the location changes.
772
+ const ledger = fs.existsSync(canonical) || !fs.existsSync(legacy) ? canonical : legacy;
773
+ if (!fs.existsSync(ledger)) return 'meter: no data yet (appears after the first hook/MCP fire)';
698
774
  const since = new Date();
699
775
  since.setHours(0, 0, 0, 0);
700
776
  since.setDate(since.getDate() - 1); // start of yesterday, local time
@@ -727,6 +803,9 @@ async function doctor() {
727
803
  have('node') ? ok('node present') : warn('node missing');
728
804
  have('npm') ? ok('npm present') : warn('npm missing');
729
805
  have('claude') ? ok('claude CLI present') : warn('claude CLI missing (plugin wiring needs it)');
806
+ // Two independent version streams (KB bundle vs plugin wrapper) β€” see checkVersionDrift()'s
807
+ // header comment for the full story. Silent unless they've genuinely diverged.
808
+ reportVersionDrift(cacheDir);
730
809
  have('unzip') || have('pwsh') || have('powershell')
731
810
  ? ok('zip extraction available (unzip or PowerShell Expand-Archive)')
732
811
  : warn('no zip tool found β€” unzip or PowerShell needed for re-install');
@@ -802,6 +881,33 @@ async function doctor() {
802
881
  // no env β€” every field is generic. The user still writes and posts the actual feedback themselves.
803
882
  const DISCUSSIONS_URL = `https://github.com/${REPO}/discussions`;
804
883
 
884
+ /**
885
+ * Compare two release tags. Returns >0 if a is newer, <0 if older, 0 if equal.
886
+ *
887
+ * Local to this file on purpose: bin/install.mjs is the ONLY file that runs before anything is
888
+ * installed, so it may not import from scripts/ (which isn't in the npm `files` list). Duplicating
889
+ * ~10 lines is the correct trade against an installer that cannot run.
890
+ *
891
+ * Prerelease handling matters here because every tag this project ships is `X.Y.Z-dev`. Numeric
892
+ * parts compare numerically (so 3.10.0 > 3.9.0, which a string compare gets backwards), and a
893
+ * release WITHOUT a prerelease suffix outranks the same numbers WITH one, per semver.
894
+ */
895
+ function cmpTag(a, b) {
896
+ const parse = (v) => {
897
+ const [core, pre = ''] = String(v).replace(/^v/, '').split('-');
898
+ return { nums: core.split('.').map((n) => parseInt(n, 10) || 0), pre };
899
+ };
900
+ const A = parse(a), B = parse(b);
901
+ for (let i = 0; i < Math.max(A.nums.length, B.nums.length); i++) {
902
+ const d = (A.nums[i] || 0) - (B.nums[i] || 0);
903
+ if (d !== 0) return d > 0 ? 1 : -1;
904
+ }
905
+ if (A.pre === B.pre) return 0;
906
+ if (!A.pre) return 1; // 3.5.0 is newer than 3.5.0-dev
907
+ if (!B.pre) return -1;
908
+ return A.pre > B.pre ? 1 : -1;
909
+ }
910
+
805
911
  function installedBrainVersion(cacheDir) {
806
912
  // Same read the telemetry ping uses: the bundle stamps its Release tag into SOURCE.json.
807
913
  // "unknown" is honest for a locally-built or pre-stamping bundle β€” never guess a tag.
@@ -813,6 +919,79 @@ function installedBrainVersion(cacheDir) {
813
919
  return 'unknown';
814
920
  }
815
921
 
922
+ // ── the OTHER version: the plugin WRAPPER's own plugin.json ──────────────────────────────────────
923
+ // installedBrainVersion() above answers "what KB is on disk". This answers "what PLUGIN WRAPPER is
924
+ // on disk" β€” a genuinely different artifact (hooks, skills, slash commands), updated on a genuinely
925
+ // different schedule (see the drift note at checkVersionDrift() below). Reuses pluginCommandsDir()
926
+ // β€” the ONE locator for "is the plugin really here" β€” instead of growing a second one: plugin.json
927
+ // always lives one directory above commands/, in both layouts that function checks.
928
+ function wrapperVersion() {
929
+ const commandsDir = pluginCommandsDir();
930
+ if (!commandsDir) return null; // plugin not installed β€” nothing to read, nothing to compare
931
+ try {
932
+ const p = path.join(path.dirname(commandsDir), '.claude-plugin', 'plugin.json');
933
+ const v = String(JSON.parse(fs.readFileSync(p, 'utf8')).version || '');
934
+ return /^[A-Za-z0-9._-]{1,32}$/.test(v) ? v : null; // present-but-unparsable = "don't know"
935
+ } catch { return null; }
936
+ }
937
+
938
+ /**
939
+ * The brain ships as TWO independently-versioned artifacts: the KB content bundle (self-updates
940
+ * nightly via forge-update.mjs + GitHub Releases) and the Claude Code PLUGIN WRAPPER (hooks,
941
+ * skills, slash commands), which updates ONLY when Claude Code itself pulls the marketplace git
942
+ * clone at ~/.claude/plugins/marketplaces/ruvnet-brain β€” NOT AT ALL if a user's
943
+ * ~/.claude/settings.json has "autoUpdate": false for that marketplace. They drift silently, and β€”
944
+ * this is the damaging part β€” the version a user is SHOWN always comes from the frozen wrapper,
945
+ * never the brain. Verified live on this machine 2026-07-20: KB SOURCE.json built today, wrapper
946
+ * plugin.json still 3.4.18-dev, nine commits behind origin/main, because autoUpdate was false. A
947
+ * user (Dr. Mark Allen) hit exactly this: KB current, wrapper still the June v0.5.0-dev build, and
948
+ * nothing anywhere told him the two had diverged.
949
+ *
950
+ * NEVER invent or guess a version β€” this project's hardest rule. Either side unresolved β†’ null, and
951
+ * null is NEVER treated as drift: a locally-built or pre-stamping KB legitimately has no releaseTag
952
+ * (see installedBrainVersion's own comment above), and a plugin that simply isn't installed yet is
953
+ * a DIFFERENT, already-reported situation (wirePlugin / verifyInstall), not a version mismatch.
954
+ * Drift is reported ONLY when BOTH sides resolved to a real value AND those values differ.
955
+ *
956
+ * @returns {{wrapper: string|null, kb: string|null, drift: boolean}}
957
+ */
958
+ function checkVersionDrift(cacheDir) {
959
+ const wrapper = wrapperVersion();
960
+ const kbRaw = installedBrainVersion(cacheDir); // already honest β€” 'unknown' rather than a guess
961
+ const kb = kbRaw === 'unknown' ? null : kbRaw;
962
+ // COMPARE NUMBERS, NOT NAMESPACES. The two sides are written by different writers in different
963
+ // formats: build-bundle stamps SOURCE.json.releaseTag as a git TAG (v-prefixed) while
964
+ // sync-version writes plugin.json.version as a bare SEMVER (no prefix). A raw !== is therefore
965
+ // ALWAYS true, so the first version of this check told every perfectly healthy user their install
966
+ // had drifted, and handed them a fix command that could never clear it. Caught by adversarial
967
+ // review, not by the tests β€” the test fixture used a v-prefixed plugin version that sync-version
968
+ // never produces, so an impossible input was green-lighting a false claim.
969
+ // Duplicated deliberately from scripts/version.mjs's stripTag(): this installer ships standalone
970
+ // on npm (package.json `files` excludes scripts/version.mjs) and imports node builtins ONLY, so
971
+ // it cannot import the canonical one. Same one-line rule, kept identical on purpose.
972
+ const stripV = (v) => String(v).replace(/^v/, '');
973
+ const drift = Boolean(wrapper && kb && stripV(wrapper) !== stripV(kb));
974
+ return { wrapper, kb, drift };
975
+ }
976
+
977
+ /**
978
+ * Shared narration for --doctor and --what-changed (via printFootprint). Silent whenever there is
979
+ * nothing actionable to say β€” matched versions, or either side not comparable β€” so this never adds
980
+ * noise to a healthy machine or a not-yet-fully-installed one. Speaks up only when the two
981
+ * artifacts have genuinely diverged, in plain, warm, non-alarming language (neither artifact is
982
+ * broken β€” they just update on different schedules), and always hands over the exact command to
983
+ * fix it β€” verified live against `claude plugin marketplace --help` (2026-07-20) before ever being
984
+ * printed here.
985
+ */
986
+ function reportVersionDrift(cacheDir) {
987
+ const state = checkVersionDrift(cacheDir);
988
+ if (!state.drift) return state;
989
+ warn(`the brain (${c.bold(state.kb)}) and the Claude Code plugin (${c.bold(state.wrapper)}) have drifted apart β€”`);
990
+ info(`that's normal (they update on separate schedules) and neither one is broken. To bring the`);
991
+ info(`plugin up to date: ${c.bold('claude plugin marketplace update ruvnet-brain')} ${c.dim('(then restart Claude Code)')}`);
992
+ return state;
993
+ }
994
+
816
995
  function installAgeLine(cacheDir) {
817
996
  // SOURCE.json's mtime is when the bundle last landed here (install or self-update) β€” say which.
818
997
  for (const f of ['SOURCE.json', 'forge-mcp-all.mjs']) {
@@ -831,7 +1010,7 @@ function feedbackHealthLines(cacheDir) {
831
1010
  const env = detectEnvironment();
832
1011
  const allGreen = s.repos > 0 && s.reader && s.mcp;
833
1012
  return [
834
- `${s.repos} repo stores on disk Β· reader ${s.reader ? 'ok' : 'MISSING'} Β· search_ruvnet ${s.mcp ? 'ok' : 'MISSING'}`,
1013
+ `${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
1014
  `toolkit: Ruflo ${env.ruflo ? 'present' : 'not found'} Β· RuVector ${env.ruvector ? 'present' : 'not found'} Β· claude CLI ${env.claude ? 'present' : 'not found'}`,
836
1015
  allGreen ? 'verdict: Healthy β€” installed and reachable' : 'verdict: Needs attention β€” re-run npx ruvnet-brain',
837
1016
  ];
@@ -1132,6 +1311,247 @@ function enableSpendGuard() {
1132
1311
  return 'enabled';
1133
1312
  }
1134
1313
 
1314
+ /**
1315
+ * Remove the spend watchdog. Mirrors disableNightly() exactly.
1316
+ *
1317
+ * This did not exist until 2026-07-20, which meant the watchdog was the one thing this installer
1318
+ * could put on a machine with no supported way to take it back off. "Reversible" has to be a
1319
+ * command someone can run, not a paragraph telling them which files to delete by hand β€” a user
1320
+ * asking how to undo our changes should never need us to answer.
1321
+ */
1322
+ function disableSpendGuard() {
1323
+ printBanner('disable spend watchdog');
1324
+ if (process.platform !== 'darwin') {
1325
+ info('The spend-watchdog LaunchAgent is macOS-only, so nothing was scheduled here by this tool.');
1326
+ return;
1327
+ }
1328
+ const plistPath = spendGuardPlistPath();
1329
+ const scriptPath = spendGuardScriptPath();
1330
+ const existed = fs.existsSync(plistPath);
1331
+ if (TEST_MODE) {
1332
+ warn('RUVNET_BRAIN_TEST=1 β€” skipping launchctl bootout (plist removal only)');
1333
+ } else {
1334
+ // Ignore failure: "not loaded" is the state we want anyway.
1335
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${SPEND_GUARD_LABEL}`], { stdio: 'ignore' });
1336
+ }
1337
+ let failed = false;
1338
+ for (const p of [plistPath, scriptPath]) {
1339
+ if (!fs.existsSync(p)) continue;
1340
+ try { fs.rmSync(p); } catch (e) {
1341
+ failed = true;
1342
+ console.error(`\n${c.red("βœ— couldn't remove:")} ${p} β€” ${e.message}`);
1343
+ console.error(` Remove it yourself: rm ${p}`);
1344
+ }
1345
+ }
1346
+ if (failed) process.exit(1);
1347
+ if (existed) ok(`spend watchdog disabled β€” removed ${plistPath}`);
1348
+ else ok('spend watchdog was already off β€” nothing to remove (safe to run any time)');
1349
+ }
1350
+
1351
+ /**
1352
+ * Everything this installer can leave on a machine, DERIVED from disk β€” never asserted.
1353
+ *
1354
+ * A user who wants out should not have to ask us which files to delete. That was the actual
1355
+ * position the corporate-machine report left someone in: they had to reverse-engineer our
1356
+ * footprint from a bug report. Anything listed here has a real undo next to it.
1357
+ *
1358
+ * @returns {{label:string, path:string, undo:string}[]}
1359
+ */
1360
+ export function machineFootprint() {
1361
+ const items = [];
1362
+ const add = (label, p, undo) => { try { if (p && fs.existsSync(p)) items.push({ label, path: p, undo }); } catch { /* unreadable β†’ not ours to claim */ } };
1363
+
1364
+ add('Brain bundle (knowledge base)', resolvedKbDir(), 'npx ruvnet-brain --uninstall');
1365
+ if (process.platform === 'darwin') {
1366
+ add('Nightly updater (LaunchAgent)', nightlyPlistPath(), 'npx ruvnet-brain --disable-nightly');
1367
+ add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
1368
+ add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
1369
+ }
1370
+ const cmds = pluginCommandsDir();
1371
+ if (cmds) items.push({
1372
+ label: 'Claude Code plugin',
1373
+ path: path.dirname(cmds),
1374
+ undo: 'claude plugin uninstall ruvnet-brain@ruvnet-brain',
1375
+ });
1376
+ const cmdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1377
+ try {
1378
+ if (fs.existsSync(cmdPath) && fs.readFileSync(cmdPath, 'utf8').includes(CLAUDE_MD_START)) {
1379
+ items.push({ label: 'CLAUDE.md block (6 lines, between markers)', path: cmdPath, undo: 'npx ruvnet-brain --uninstall' });
1380
+ }
1381
+ } catch { /* unreadable */ }
1382
+ add('Usage-counts preference', telemetryConsentPath(), 'delete this file');
1383
+
1384
+ // EVERYTHING ELSE THIS INSTALLER WRITES. The first version of this listed the KB bundle and
1385
+ // little else β€” one artifact out of six β€” while the help text promised "exactly what RuvNet Brain
1386
+ // has put on this machine" and uninstallAll() went on to print "Verified clean". That is the same
1387
+ // position the corporate-machine reporter was left in, reproduced by the very feature written to
1388
+ // prevent it. It also explains a user seeing ~5 GB used for a ~2 GB knowledge base: most of the
1389
+ // footprint was never reported. Anything this installer can create belongs here, or the summary
1390
+ // is a comfortable fiction.
1391
+ add('Status-bar version script', path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ruvnet-brain-statusline.cjs'),
1392
+ 'remove the statusLine entry in ~/.claude/settings.json, then delete this file');
1393
+ add('Status-bar preference', path.join(telemetryStateDir(), '.statusline-pref'), 'delete this file');
1394
+ add('Model-router files', path.join(os.homedir(), '.claude', 'model-router'),
1395
+ 'rm -rf ~/.claude/model-router');
1396
+ // Config entries live INSIDE files the user owns, so they are reported as edits to review rather
1397
+ // than as paths to delete β€” deleting someone's settings.json over one key would be indefensible.
1398
+ try {
1399
+ const settings = path.join(os.homedir(), '.claude', 'settings.json');
1400
+ if (fs.existsSync(settings) && fs.readFileSync(settings, 'utf8').includes('ruvnet-brain')) {
1401
+ items.push({ label: 'A statusLine entry in your settings.json', path: settings, undo: 'remove the "statusLine" entry that points at ruvnet-brain' });
1402
+ }
1403
+ } catch { /* unreadable β€” do not claim it */ }
1404
+ try {
1405
+ const claudeJson = path.join(os.homedir(), '.claude.json');
1406
+ if (fs.existsSync(claudeJson) && fs.readFileSync(claudeJson, 'utf8').includes('ruvnet-brain')) {
1407
+ items.push({ label: 'The search_ruvnet MCP server registration', path: claudeJson, undo: 'claude mcp remove ruvnet-brain --scope user' });
1408
+ }
1409
+ } catch { /* unreadable β€” do not claim it */ }
1410
+
1411
+ return items;
1412
+ }
1413
+
1414
+ /** Print the footprint. Called at the end of an install so nobody is ever surprised later. */
1415
+ function printFootprint({ heading = 'What this put on your machine' } = {}) {
1416
+ const items = machineFootprint();
1417
+ if (!items.length) { info('Nothing from RuvNet Brain is currently installed.'); return items; }
1418
+ console.log(`\n ${c.bold(heading)}`);
1419
+ for (const it of items) {
1420
+ console.log(` β€’ ${it.label}`);
1421
+ console.log(` ${c.dim(it.path.replace(os.homedir(), '~'))}`);
1422
+ console.log(` ${c.dim(`undo: ${it.undo}`)}`);
1423
+ }
1424
+ console.log(`\n ${c.dim('Remove all of it at once:')} ${c.bold('npx ruvnet-brain --uninstall')}`);
1425
+ // Same "two artifacts, one machine" story as --doctor β€” surfaced here too, since a footprint
1426
+ // listing is exactly where a user would otherwise reasonably assume one version covers both.
1427
+ // Silent unless they've genuinely diverged.
1428
+ reportVersionDrift(resolvedKbDir());
1429
+ return items;
1430
+ }
1431
+
1432
+ /**
1433
+ * Surgically remove ONLY our block from CLAUDE.md, leaving every other line exactly as it was.
1434
+ * Backed up and written atomically, same as when it was added β€” taking something away is at least
1435
+ * as sensitive as putting it there.
1436
+ */
1437
+ function removeClaudeMdBlock() {
1438
+ const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1439
+ let src = '';
1440
+ try { src = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { return 'unreadable'; }
1441
+ // PAIR THE MARKERS PROPERLY. The first version took the FIRST start and the FIRST end, unpaired.
1442
+ // Adversarial review proved that eats the user's file: a CLAUDE.md that merely MENTIONS the start
1443
+ // marker in prose (entirely plausible β€” we print both marker strings to the console when adding
1444
+ // the block) above a real installed block causes everything between the prose mention and the real
1445
+ // block's end marker to be deleted. Their rules, silently gone, under a message saying "your
1446
+ // content untouched".
1447
+ //
1448
+ // Take the LAST start that has an end after it, and the FIRST end after that start β€” the real
1449
+ // block is the innermost well-formed pair. Anything we cannot pair confidently is left alone:
1450
+ // refusing to edit is always better than removing the wrong span from a file we do not own.
1451
+ const end = src.indexOf(CLAUDE_MD_END);
1452
+ if (end === -1) return 'absent';
1453
+ const start = src.lastIndexOf(CLAUDE_MD_START, end);
1454
+ if (start === -1) return 'absent';
1455
+
1456
+ // Splice ONLY the block. The previous version also ran .replace(/\n{3,}/g,'\n\n') and stripped
1457
+ // leading whitespace across the WHOLE document, which silently reformatted unrelated content β€”
1458
+ // including collapsing blank lines inside fenced code blocks β€” while the docstring promised
1459
+ // "every other line exactly as it was". Normalize only at the seam we actually cut.
1460
+ const before = src.slice(0, start).replace(/\n{3,}$/, '\n\n');
1461
+ const after = src.slice(end + CLAUDE_MD_END.length).replace(/^\n{3,}/, '\n\n');
1462
+ const next = `${before}${after}`;
1463
+ try {
1464
+ const backup = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
1465
+ fs.copyFileSync(p, backup);
1466
+ const tmp = `${p}.ruvnet-tmp`;
1467
+ fs.writeFileSync(tmp, next);
1468
+ fs.renameSync(tmp, p);
1469
+ return 'removed';
1470
+ } catch { return 'failed'; }
1471
+ }
1472
+
1473
+ /** Reverse everything, and PROVE it rather than claiming it. */
1474
+ function uninstallAll() {
1475
+ printBanner('uninstall RuvNet Brain');
1476
+ const before = machineFootprint();
1477
+ if (!before.length) { ok('Nothing to remove β€” RuvNet Brain is not installed here.'); return; }
1478
+
1479
+ // SPLIT WHAT WE REMOVE FROM WHAT WE CANNOT. The first version printed one flat "This will remove:"
1480
+ // list built from the whole footprint β€” including the Claude Code plugin and edits inside files
1481
+ // the user owns, none of which this function touches. It then closed by admitting those same
1482
+ // items still needed a manual step. One command contradicting itself inside a single run is
1483
+ // exactly the sloppiness that makes people stop believing any of our output, so the promise is now
1484
+ // scoped to what actually happens.
1485
+ // Ours-by-construction directories and files are removed; things that live INSIDE a file the user
1486
+ // owns (settings.json entries, the MCP registration) and the Claude Code plugin itself are not
1487
+ // ours to delete, so they are handed over as commands.
1488
+ const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)',
1489
+ 'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
1490
+ 'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference']);
1491
+ const willRemove = before.filter((it) => AUTO.has(it.label));
1492
+ const manual = before.filter((it) => !AUTO.has(it.label));
1493
+
1494
+ console.log(` This will remove:`);
1495
+ for (const it of willRemove) console.log(` β€’ ${it.label} ${c.dim(it.path.replace(os.homedir(), '~'))}`);
1496
+ if (!willRemove.length) console.log(` ${c.dim('(nothing that this command removes automatically)')}`);
1497
+ if (manual.length) {
1498
+ console.log(`\n ${c.bold('It will NOT remove these')} β€” they are not ours to delete, so you get the command instead:`);
1499
+ for (const it of manual) console.log(` β€’ ${it.label}\n ${c.dim(it.undo)}`);
1500
+ }
1501
+ console.log(`\n ${c.dim('Your own CLAUDE.md content is preserved β€” only our marked block is taken out,')}`);
1502
+ console.log(` ${c.dim('and the file is backed up first.')}\n`);
1503
+
1504
+ if (process.platform === 'darwin') { disableNightly(); disableSpendGuard(); }
1505
+
1506
+ const claudeMd = removeClaudeMdBlock();
1507
+ if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
1508
+
1509
+ // NEVER rm -rf A PATH WE HAVE NOT PROVEN IS OURS. resolvedKbDir() honours $RUVNET_BRAIN_KB, which
1510
+ // the docs encourage for custom install locations β€” so `RUVNET_BRAIN_KB=$HOME npx ruvnet-brain
1511
+ // --uninstall` would have recursively deleted the user's home directory. Found by adversarial
1512
+ // review. The asymmetry was already visible in this same file: runUpdate() refuses to act unless
1513
+ // forge-update.mjs is present, precisely so it can never surprise someone. Uninstall skipped it.
1514
+ //
1515
+ // Proof-of-ownership: the directory must actually contain the reader we install. That is cheap,
1516
+ // unspoofable in practice, and fails CLOSED β€” if we cannot prove it is a brain, we do not touch it
1517
+ // and we say why.
1518
+ const kb = resolvedKbDir();
1519
+ const looksLikeBrain = kb && ['forge-ask.mjs', 'forge-mcp.mjs', 'SOURCE.json']
1520
+ .some((marker) => { try { return fs.existsSync(path.join(kb, marker)); } catch { return false; } });
1521
+ if (kb && fs.existsSync(kb) && !looksLikeBrain) {
1522
+ warn(`refusing to delete ${kb.replace(os.homedir(), '~')} β€” it does not look like a brain bundle.`);
1523
+ info(` (no forge-ask.mjs / forge-mcp.mjs / SOURCE.json found there). Nothing was removed.`);
1524
+ info(` If that really is your brain, remove it yourself: rm -rf ${kb.replace(os.homedir(), '~')}`);
1525
+ } else if (kb && fs.existsSync(kb)) {
1526
+ try { fs.rmSync(kb, { recursive: true, force: true }); ok(`removed the brain bundle (${kb.replace(os.homedir(), '~')})`); }
1527
+ catch (e) { warn(`couldn't remove ${kb}: ${e.message}`); }
1528
+ }
1529
+
1530
+ // The rest of what is ours by construction. Leaving these behind is how an "uninstalled" machine
1531
+ // still shows gigabytes of us β€” 8 executables under ~/.claude/model-router, a statusline script
1532
+ // that settings.json is still pointing at, and preference files. Each is removed only if it is
1533
+ // inside a directory this installer creates, never a path the user chose.
1534
+ for (const [label, target] of [
1535
+ ['model-router files', path.join(os.homedir(), '.claude', 'model-router')],
1536
+ ['status-bar script', path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ruvnet-brain-statusline.cjs')],
1537
+ ['status-bar preference', path.join(telemetryStateDir(), '.statusline-pref')],
1538
+ ['usage-counts preference', telemetryConsentPath()],
1539
+ ]) {
1540
+ if (!fs.existsSync(target)) continue;
1541
+ try { fs.rmSync(target, { recursive: true, force: true }); ok(`removed the ${label}`); }
1542
+ catch (e) { warn(`couldn't remove ${target.replace(os.homedir(), '~')}: ${e.message}`); }
1543
+ }
1544
+
1545
+ // PROOF, not a claim β€” re-derive the footprint and show what (if anything) survived.
1546
+ const after = machineFootprint();
1547
+ console.log('');
1548
+ if (!after.length) { ok('Verified clean β€” nothing from RuvNet Brain remains.'); }
1549
+ else {
1550
+ warn('These need one more step (they are not ours to remove automatically):');
1551
+ for (const it of after) console.log(` β€’ ${it.label} β€” ${c.bold(it.undo)}`);
1552
+ }
1553
+ }
1554
+
1135
1555
  // Exported (testable under RUVNET_BRAIN_IMPORT_ONLY=1, like offerNightly). Never throws β€” the caller
1136
1556
  // also guards, because a finished install must never be broken by an optional safety offer.
1137
1557
  export async function offerSpendGuard() {
@@ -1143,12 +1563,16 @@ export async function offerSpendGuard() {
1143
1563
  'One more safety net β€” a spend watchdog',
1144
1564
  'agentic tools can bill the paid API in the background; this alarm catches a runaway before it drains your card',
1145
1565
  );
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'; }
1566
+ info(`${c.green('Recommended')} β€” an hourly check that alerts you the moment an automated agent fleet`);
1567
+ info(`floods a project. That pattern has quietly burned real money. ${c.bold('Your call, and easy to undo.')}`);
1568
+ info(`${c.dim('What it sets up:')} a small background job (a macOS LaunchAgent) that watches for the burst`);
1569
+ info(`${c.dim(' ')} pattern. ${c.bold('Alert-only β€” it never spends, and never changes your billing.')}`);
1570
+ info(`${c.dim('If you skip:')} nothing changes; add it later with ${c.bold('npx ruvnet-brain --enable-spend-guard')}`);
1571
+
1572
+ // NOT gated on FLAG_YES β€” second launchd job, same rule as the nightly updater above.
1573
+ 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
1574
  let yes = true;
1151
- if (!FLAG_YES) {
1575
+ if (!FLAG_ENABLE_SPEND_GUARD) {
1152
1576
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1153
1577
  const answer = await new Promise((resolve) => rl.question(` ${c.cyan('?')} Install the spend watchdog? ${c.dim('[Y/n]')} `, resolve));
1154
1578
  rl.close();
@@ -1293,15 +1717,28 @@ export async function offerNightly() {
1293
1717
 
1294
1718
  info(`${c.bold('Recommended:')} your brain updates itself while you sleep β€” new repos, new gists, zero effort.`);
1295
1719
 
1296
- if (!process.stdin.isTTY && !FLAG_YES) {
1720
+ // Recommend it, mean it, and still make declining feel completely fine. The goal is a user who
1721
+ // understands what they're agreeing to β€” not one who is either scared off by a wall of caveats or
1722
+ // nudged past a decision they'd have made differently. Both failures cost trust; only one is loud.
1723
+ info(`${c.green('Recommended')} β€” rUv ships constantly, and this is how fixes reach you without you`);
1724
+ info(`thinking about it. ${c.bold('Entirely your call, though')}, and easy to undo.`);
1725
+ info(`${c.dim('What it sets up:')} a small background job (a macOS LaunchAgent) that checks each night`);
1726
+ info(`${c.dim(' ')} and downloads a fresher brain β€” signature-verified before anything is applied.`);
1727
+ info(`${c.dim('If you skip:')} nothing changes; update whenever you like with ${c.bold('npx ruvnet-brain --update')}`);
1728
+ info(`${c.dim('Turn it off:')} ${c.bold('npx ruvnet-brain --disable-nightly')} ${c.dim('(any time, no reinstall)')}`);
1729
+
1730
+ // NOT gated on FLAG_YES β€” see the high-impact consent note on ask(). A blanket `-y` means nobody is
1731
+ // present to READ the explanation above, and an explanation nobody read is not consent. It takes the
1732
+ // explicit --enable-nightly, or a human answering in a terminal.
1733
+ if (!process.stdin.isTTY && !FLAG_ENABLE_NIGHTLY) {
1297
1734
  // No terminal to ask on (CI / piped install) β€” recommend clearly instead of prompting.
1298
1735
  info(`No interactive terminal here, so I won't prompt. Enable it any time with one command:`);
1299
1736
  info(` ${c.bold('npx ruvnet-brain --enable-nightly')}`);
1300
1737
  return 'recommended';
1301
1738
  }
1302
1739
 
1303
- let yes = true; // --yes accepts every optional offer, this one included
1304
- if (!FLAG_YES) {
1740
+ let yes = true;
1741
+ if (!FLAG_ENABLE_NIGHTLY) {
1305
1742
  // Not ask(): its parser treats anything but y/yes as no. Here the DEFAULT is yes β€” only an
1306
1743
  // explicit n/no declines (parseNightlyAnswer holds that contract, and the tests hold it there).
1307
1744
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -1411,8 +1848,28 @@ export async function offerTelemetry(cacheDir) {
1411
1848
  }
1412
1849
 
1413
1850
  // ── 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);
1851
+ /**
1852
+ * @param {string} question
1853
+ * @param {boolean} def answer used when there is no terminal to ask on
1854
+ * @param {{blanketYes?: boolean}} opts blanketYes:false means --yes does NOT answer this one
1855
+ *
1856
+ * HIGH-IMPACT CONSENT (2026-07-20). `--yes` is documented as "accept every optional offer", and it
1857
+ * used to include the two changes nobody would call optional: installing a persistent LaunchAgent
1858
+ * that pulls code from GitHub on a schedule, and editing a global config file. Reported by a user on
1859
+ * a CORPORATE machine whose enterprise policy correctly blocked the plugin/MCP install but had no
1860
+ * rule covering a launchd job β€” so the one thing that survived was the background daemon.
1861
+ *
1862
+ * It almost certainly arrived via an AI agent: hit an interactive prompt, cannot answer it, re-run
1863
+ * with `-y`. Entirely reasonable behaviour, and with blanket consent it silently authorizes a
1864
+ * daemon. rUv's own ADR-302 already says why this is wrong β€” "accepting the enrollment screen is
1865
+ * not blanket authorization... four distinct decisions, each with its own consent, its own prompt
1866
+ * moment, and its own record." We were violating his design inside our own installer.
1867
+ *
1868
+ * So: persistent background jobs and global-config edits require their OWN explicit flag. There is
1869
+ * no combination of `-y` alone that installs a daemon.
1870
+ */
1871
+ function ask(question, def = false, { blanketYes = true } = {}) {
1872
+ if (FLAG_YES && blanketYes) return Promise.resolve(true);
1416
1873
  if (!process.stdin.isTTY) return Promise.resolve(def);
1417
1874
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
1418
1875
  const suffix = def ? c.dim('[Y/n]') : c.dim('[y/N]');
@@ -1552,31 +2009,65 @@ Prefer rUv-native primitives (RVF over Pinecone/pgvector, Ruflo over LangChain,
1552
2009
  Not sure it's active? Run \`npx ruvnet-brain --doctor\`.
1553
2010
  ${CLAUDE_MD_END}`;
1554
2011
 
1555
- async function offerClaudeMd() {
2012
+ export async function offerClaudeMd() {
1556
2013
  if (FLAG_NO_ENHANCE) return;
1557
2014
  const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1558
2015
  let existing = '';
1559
2016
  try { existing = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { /* ignore */ }
1560
- if (existing.includes(CLAUDE_MD_START)) { return; } // already enhanced β€” idempotent, stay silent
2017
+ if (existing.includes(CLAUDE_MD_START)) { return; } // already there β€” idempotent, stay silent
2018
+
2019
+ // DON'T ASK WHEN IT ADDS NOTHING. If the plugin is installed, its hooks already enforce grounding
2020
+ // on every turn and this block is pure duplication β€” the old skip-branch message said exactly that
2021
+ // out loud. Asking to edit the most sensitive file we touch, for a benefit the user already has,
2022
+ // is how a helpful tool starts feeling invasive. So the question is only worth someone's attention
2023
+ // when the plugin is absent, which is a real population (see the "Unknown command: /rvbc" report)
2024
+ // but not the common one.
2025
+ if (pluginCommandsDir()) return;
1561
2026
 
1562
2027
  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',
2028
+ 'Optional β€” a note in your global CLAUDE.md',
2029
+ "so Claude leans on the brain in projects where the plugin's hooks aren't running",
1565
2030
  );
2031
+ // Say precisely what changes on their disk, in their words, BEFORE asking. "Enhance your CLAUDE.md"
2032
+ // is the kind of phrasing that makes a careful person assume the worst β€” and on a managed machine
2033
+ // that file may be governed. Six lines at the bottom, markers, reversible, backed up: all of that
2034
+ // is far less alarming than the vague version, and it happens to be the whole truth.
2035
+ info(`Adds ${c.bold('6 lines to the BOTTOM')} of ${c.bold('~/.claude/CLAUDE.md')}, wrapped in`);
2036
+ info(` ${c.dim('<!-- ruvnet-brain:start -->')} … ${c.dim('<!-- ruvnet-brain:end -->')}`);
2037
+ info(`Nothing already in the file is changed or removed. Delete the block any time.`);
2038
+ info(`${c.green("We back the file up first")}, and re-running never adds it twice.`);
2039
+ info(c.dim(`Honestly: if you install the plugin, you don't need this β€” its hooks already do it.`));
2040
+
1566
2041
  const yes =
1567
2042
  FLAG_ENHANCE_CLAUDE_MD ||
1568
- (await ask(`Add a short RuvNet-Brain section to ${existing ? 'your' : 'a new'} ~/.claude/CLAUDE.md?`, false));
2043
+ // blanketYes:false β€” editing a file THEY own is not something a blanket `-y` gets to decide.
2044
+ (await ask('Add it?', false, { blanketYes: false }));
1569
2045
  if (!yes) {
1570
- info('skipped β€” the plugin hooks already enforce grounding every turn; this was just extra reinforcement');
2046
+ info(`No problem, skipped β€” add it any time with ${c.bold('npx ruvnet-brain --enhance-claude-md')}`);
1571
2047
  return;
1572
2048
  }
1573
2049
  try {
1574
2050
  fs.mkdirSync(path.dirname(p), { recursive: true });
2051
+ // Back up before touching it β€” the same courtesy this installer already extends to settings.json,
2052
+ // and this is the more sensitive file of the two.
2053
+ let backup = null;
2054
+ if (existing) {
2055
+ backup = `${p}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
2056
+ fs.copyFileSync(p, backup);
2057
+ }
1575
2058
  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)')}`);
2059
+ // ATOMIC write β€” temp sibling then rename. The content was always a pure append, but the old
2060
+ // code rewrote the whole file in place, so an interruption (disk full, power loss) could leave
2061
+ // a TRUNCATED CLAUDE.md. Fine 999 times out of 1000 and unforgivable the other time. This is the
2062
+ // discipline rUv already applies to credential files (cognitum-seed cloud_key.rs: tmp β†’ rename).
2063
+ const tmp = `${p}.ruvnet-tmp`;
2064
+ fs.writeFileSync(tmp, next);
2065
+ fs.renameSync(tmp, p);
2066
+ ok(`added 6 lines to the bottom of ${p}`);
2067
+ if (backup) info(c.dim(` your original is saved at ${backup}`));
2068
+ info(c.dim(` to remove: delete the block between the two ruvnet-brain markers`));
1578
2069
  } catch (e) {
1579
- warn(`couldn't update CLAUDE.md (${e.message}) β€” not important; the plugin hooks still enforce grounding`);
2070
+ warn(`couldn't update CLAUDE.md (${e.message}) β€” your file is untouched, and this was optional anyway`);
1580
2071
  }
1581
2072
  }
1582
2073
 
@@ -1728,7 +2219,14 @@ export async function offerStatusline() {
1728
2219
  info(`Adds a small ${c.bold('"RuvNet Brain vX.Y.Z"')} segment, read live from your installed brain β€” it`);
1729
2220
  info(`updates itself the moment the brain updates. ${c.bold('Never overwrites an existing status line.')}`);
1730
2221
 
1731
- const interactive = process.stdin.isTTY || FLAG_YES || FLAG_STATUSLINE;
2222
+ // FLAG_YES deliberately does NOT appear here. This writes ~/.claude/settings.json β€” a file the
2223
+ // USER owns β€” and installs a script Claude Code then executes on EVERY PROMPT. An adversarial
2224
+ // review caught that a blanket `-y` on a non-TTY still did both, which made the security fix in
2225
+ // 9ad02f5 ("`-y` can no longer install a daemon or edit a global config file") FALSE as written:
2226
+ // the two functions that commit gated were the two I happened to be thinking about, and this
2227
+ // third one β€” higher-frequency persistent execution than the LaunchAgent β€” was never checked.
2228
+ // Same footprint rule as everywhere else: their config, their explicit yes.
2229
+ const interactive = process.stdin.isTTY || FLAG_STATUSLINE;
1732
2230
  if (!interactive) {
1733
2231
  // No terminal to ask on, and no explicit flag either β€” skip WITHOUT recording an answer, so a
1734
2232
  // future interactive (or flagged) run still gets a real chance to ask.
@@ -1737,7 +2235,7 @@ export async function offerStatusline() {
1737
2235
  return 'not-asked';
1738
2236
  }
1739
2237
 
1740
- const yes = FLAG_STATUSLINE || (await ask('Add a RuvNet Brain version segment to your Claude Code status bar?', false));
2238
+ const yes = FLAG_STATUSLINE || (await ask('Add a RuvNet Brain version segment to your Claude Code status bar?', false, { blanketYes: false }));
1741
2239
 
1742
2240
  try {
1743
2241
  fs.mkdirSync(telemetryStateDir(), { recursive: true });
@@ -1853,6 +2351,14 @@ Usage:
1853
2351
  npx ruvnet-brain --enable-nightly Schedule that update nightly at 03:47 β€” macOS LaunchAgent;
1854
2352
  other platforms get the documented cron line. OFF by default.
1855
2353
  npx ruvnet-brain --disable-nightly Remove the nightly schedule (safe to run any time)
2354
+ npx ruvnet-brain --what-changed Show exactly what RuvNet Brain has put on this machine,
2355
+ with the undo command for each piece
2356
+ npx ruvnet-brain --uninstall Remove all of it (bundle, LaunchAgents, and our CLAUDE.md
2357
+ block only β€” your own CLAUDE.md content is preserved and backed up)
2358
+ npx ruvnet-brain --enable-spend-guard Install the hourly runaway-agent spend alarm (alert-only)
2359
+ npx ruvnet-brain --disable-spend-guard Remove it (safe to run any time)
2360
+ npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
2361
+ (appended at the bottom between markers; your file is backed up first)
1856
2362
  (a default install RECOMMENDS nightly and asks, defaulting to yes)
1857
2363
  node bin/install.mjs --no-nightly-prompt Don't offer nightly auto-updates at the end of the install
1858
2364
  node bin/install.mjs --no-telemetry Decline anonymous usage counts without being asked
@@ -1891,6 +2397,14 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
1891
2397
  if (FLAG_UPDATE) return runUpdate();
1892
2398
  if (FLAG_ENABLE_NIGHTLY) return enableNightly();
1893
2399
  if (FLAG_DISABLE_NIGHTLY) return disableNightly();
2400
+ // Standalone, like the nightly pair above. Without these, the flags existed only as a way to
2401
+ // pre-answer a prompt DURING a full install β€” so the copy telling someone to "add it later with
2402
+ // npx ruvnet-brain --enable-spend-guard" would have kicked off an entire reinstall instead of the
2403
+ // small targeted action they asked for. Promised in the UI, therefore real here.
2404
+ if (FLAG_ENABLE_SPEND_GUARD) { enableSpendGuard(); return; }
2405
+ if (FLAG_DISABLE_SPEND_GUARD) { disableSpendGuard(); return; }
2406
+ if (FLAG_UNINSTALL) { uninstallAll(); return; }
2407
+ if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
1894
2408
 
1895
2409
  printBanner('installer');
1896
2410
  console.log(c.dim("I'll set up the brain and the Claude Code plugin, explaining each step as I go.\n"));
@@ -1915,19 +2429,83 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
1915
2429
 
1916
2430
  const { cacheDir, isCustom } = resolveCacheDir();
1917
2431
 
2432
+ // ── "ALREADY PRESENT" IS THE WRONG QUESTION β€” ask "already CURRENT" ──────────────────────────
2433
+ //
2434
+ // THE STALE-INSTALL TRAP, and the root cause of "users are still on 0.5". This used to skip the
2435
+ // download whenever forge-mcp-all.mjs merely EXISTED β€” a pure file-existence check, no version
2436
+ // anywhere in it. So a June v0.5 brain made `alreadyInstalled` true, the download was skipped,
2437
+ // and the installer went on to print its success banner. Re-running the installer β€” the fix we
2438
+ // ADVERTISE in recovery messages β€” refreshed the reader and the plugin wiring and left the actual
2439
+ // brain untouched, forever. A closed trap: the advertised escape hatch was the thing that failed.
2440
+ //
2441
+ // The honest question is whether the installed brain is CURRENT, so that is what we now ask.
2442
+ // Fail-safe by design: if the version cannot be resolved (offline, API rate limit) we keep the
2443
+ // old skip behaviour rather than force a 2 GB download on someone with no network β€” but we SAY
2444
+ // that is what happened, instead of implying everything is up to date.
1918
2445
  const alreadyInstalled = fs.existsSync(path.join(cacheDir, 'forge-mcp-all.mjs'));
2446
+ let staleSkip = false;
2447
+ let ahead = false;
2448
+ let installedTag = null;
2449
+ let latestTag = null;
2450
+ let resolvedRelease = null; // reused below so the release is resolved at most once
1919
2451
  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}`);
2452
+ installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
2453
+ try {
2454
+ resolvedRelease = await resolveRelease();
2455
+ // ONLY a genuine `latest` lookup counts as "what current means". resolveRelease() does NOT
2456
+ // throw when the GitHub API fails β€” it returns the hardcoded known-good pin with
2457
+ // source:'fallback'. Treating that as latest inverts this whole fix: a rate-limited lookup
2458
+ // would report installed v3.4.21-dev "β†’ latest v2.9.0" and DOWNGRADE a perfectly current
2459
+ // machine. (Caught by exercising the failure path against a 404 repo β€” the first version of
2460
+ // this fix did exactly that.) A pinned/forced resolution is likewise the operator's explicit
2461
+ // choice, not a staleness verdict, so neither drives this comparison.
2462
+ latestTag = resolvedRelease && resolvedRelease.source === 'latest'
2463
+ ? (resolvedRelease.tag_name || resolvedRelease.tag || null)
2464
+ : null;
2465
+ } catch { latestTag = null; }
2466
+ const norm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
2467
+ const a = norm(installedTag), b = norm(latestTag);
2468
+ // BEHIND => download. SAME => skip. AHEAD => skip, and say so honestly.
2469
+ //
2470
+ // This was a bare `a !== b`, which treats "newer than the latest release" as staleness. Anyone
2471
+ // running a pre-release or dev build β€” or who simply updated in the window before a release was
2472
+ // cut β€” was told "Brain is out of date" and pushed through a 2 GB download that would DOWNGRADE
2473
+ // them. Found 2026-07-22 the moment this repo's own version moved to 3.5.0-dev ahead of the
2474
+ // 3.4.22-dev release: the installer immediately declared its own newest brain stale.
2475
+ //
2476
+ // stack-sync.mjs has modelled AHEAD as legal from the start ("AHEAD is legal and produces NO
2477
+ // recommendation β€” that modelling choice is what makes the alpha-vs-latest downgrade war
2478
+ // structurally impossible"). The installer never learned the same lesson. It has now.
2479
+ ahead = Boolean(a && b && cmpTag(a, b) > 0);
2480
+ staleSkip = Boolean(b && (a === null || (a !== b && !ahead)));
2481
+ }
2482
+
2483
+ if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
2484
+ if (latestTag && ahead) {
2485
+ // Never silently imply equality when the user is AHEAD β€” that would be a small lie, and it is
2486
+ // the one that hides a downgrade. Skipping is right; misdescribing why is not.
2487
+ step('Brain already current β€” skipping the download', `installed ${installedTag} is NEWER than the latest release (${latestTag})`);
2488
+ ok(`found an up-to-date brain at ${cacheDir} β€” nothing to download, and we will never downgrade you`);
2489
+ } else if (latestTag) {
2490
+ step('Brain already current β€” skipping the download', `installed ${installedTag} matches the latest release`);
2491
+ ok(`found an up-to-date brain at ${cacheDir}`);
2492
+ } else {
2493
+ // Could not check. Say so plainly rather than letting silence imply "current".
2494
+ step('Brain present β€” could not check for a newer one', 'the release lookup failed (offline or rate-limited)');
2495
+ warn(`skipping the download WITHOUT verifying it is current. Installed: ${installedTag || 'unknown'}.`);
2496
+ info(`When you have a connection: ${c.bold('npx ruvnet-brain --update')} ${c.dim('(or --force to refetch now)')}`);
2497
+ }
1925
2498
  } else {
2499
+ if (staleSkip) {
2500
+ step('Brain is out of date β€” fetching the current release', `installed ${installedTag || 'unknown'} β†’ latest ${latestTag}`);
2501
+ info(`${c.dim('(the old installer skipped this whenever any brain was present, which is why stale installs never moved)')}`);
2502
+ }
1926
2503
  // Resolve which Release to fetch BEFORE downloading. Skipped entirely on the --local path
1927
2504
  // (obtainBundle short-circuits to the repo's dist/ zip and never touches the network).
1928
2505
  const localZipPresent =
1929
2506
  FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
1930
- const release = localZipPresent ? null : await resolveRelease();
2507
+ // Reuse the staleness check's resolution when it already ran β€” one network round-trip, not two.
2508
+ const release = localZipPresent ? null : (resolvedRelease || await resolveRelease());
1931
2509
  const { zipPath, tmpDir, downloaded } = await obtainBundle(release);
1932
2510
  // Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
1933
2511
  // (SEC-0010 #6 β€” trust root = keys/ruvnet-brain-signing.pub.pem shipped inside this package).
@@ -1986,6 +2564,12 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
1986
2564
  try { await offerStatusline(); } catch { /* non-fatal β€” a status-bar nicety must never break the install */ }
1987
2565
 
1988
2566
  success({ cacheDir, isCustom, plugin, env, nightly });
2567
+
2568
+ // Close every install by stating, in one place, exactly what is now on their machine and how to
2569
+ // take each piece back off. Someone reading this should never have to file a bug report to find
2570
+ // out what we did β€” which is precisely the position the 2026-07-20 corporate-machine reporter was
2571
+ // left in. Derived from disk, so it can only ever describe what is actually there.
2572
+ try { printFootprint(); } catch { /* a summary must never break a finished install */ }
1989
2573
  })().catch((e) => {
1990
2574
  die(e && e.message ? e.message : String(e));
1991
2575
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "3.4.21-dev",
3
+ "version": "3.5.1-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": {