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.
- package/README.md +42 -4
- package/bin/install.mjs +625 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,10 +4,26 @@
|
|
|
4
4
|
|
|
5
5
|
# π§ RuvNet Brain
|
|
6
6
|
|
|
7
|
-
### π§ RuvNet Brain β [](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/.claude-plugin/plugin.json)
|
|
12
28
|
[](https://www.npmjs.com/package/ruvnet-brain)
|
|
13
29
|
[](https://github.com/stuinfla/ruvnet-brain/releases/latest)
|
|
@@ -40,7 +56,28 @@
|
|
|
40
56
|
|
|
41
57
|
---
|
|
42
58
|
|
|
43
|
-
## What's new in 3.
|
|
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 — what 3.4 shipped</b> · 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,
|
|
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
|

|
|
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,
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
691
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
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
|
|
697
|
-
|
|
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.
|
|
1147
|
-
info(
|
|
1148
|
-
|
|
1149
|
-
|
|
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 (!
|
|
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
|
-
|
|
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;
|
|
1304
|
-
if (!
|
|
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
|
-
|
|
1415
|
-
|
|
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
|
|
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
|
-
'
|
|
1564
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1577
|
-
|
|
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}) β
|
|
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
|
-
|
|
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
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
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
|
-
|
|
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.
|
|
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": {
|