ruvnet-brain 0.5.1-dev β†’ 1.14.0-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 +52 -13
  2. package/bin/install.mjs +164 -23
  3. package/package.json +20 -2
package/README.md CHANGED
@@ -4,21 +4,30 @@
4
4
 
5
5
  # 🧠 RuvNet Brain
6
6
 
7
+ ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 1.14.0-dev β€” updated 2026-07-08 07:21 EDT](https://img.shields.io/badge/version_1.14.0--dev-updated_2026--07--08_07:21_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
8
+
7
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.**
8
10
 
9
- [![version](https://img.shields.io/badge/version-v0.5.0--dev-e8a13a?style=flat-square)](https://github.com/stuinfla/ruvnet-brain/releases/tag/v0.5.0-dev)
10
- [![download](https://img.shields.io/badge/download-512MB%20brain-2e7d32?style=flat-square)](https://github.com/stuinfla/ruvnet-brain/releases/tag/v0.5.0-dev)
11
- [![explainer](https://img.shields.io/badge/β–Ά%20see%20it%20live-ruvnet--brain.vercel.app-e8a13a?style=flat-square)](https://ruvnet-brain.vercel.app)
11
+ [![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
+ [![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
+ [![download](https://img.shields.io/badge/download-512MB%20brain-2e7d32?style=flat-square)](https://github.com/stuinfla/ruvnet-brain/releases/latest)
14
+ [![explainer](https://img.shields.io/badge/β–Ά%20see%20it%20live-isovision.ai%2Fruvnet--brain-e8a13a?style=flat-square)](https://isovision.ai/ruvnet-brain/)
12
15
  [![license](https://img.shields.io/badge/license-MIT-8ecae6?style=flat-square)](LICENSE)
13
16
  [![grounded](https://img.shields.io/badge/answers-cited%20rUv%20source-333?style=flat-square)](#testing--proof)
17
+ [![coverage](https://img.shields.io/badge/coverage-75%25%20lines%20Β·%2072%25%20stmts-2e7d32?style=flat-square)](#testing--proof)
18
+
19
+ > **Three independent things version separately here β€” by design, not drift. Every number below is live (read straight from its real source, never hand-typed), so none of them can go stale:**
20
+ > - **`plugin`** (badge above) β€” the Claude Code plugin itself: SKILL.md, the grounding hooks, the MCP server. Read live from [`plugin/.claude-plugin/plugin.json`](plugin/.claude-plugin/plugin.json). Updates often β€” this is where behavior fixes land.
21
+ > - **`installer (npm)`** (badge above) β€” the `npx ruvnet-brain` setup script. Read live from the [npm registry](https://www.npmjs.com/package/ruvnet-brain). Only moves when the installer script itself changes β€” rare.
22
+ > - **Brain Release** (the downloadable 512MB knowledge bundle, linked from the "download" badge above) β€” always resolves to [`releases/latest`](https://github.com/stuinfla/ruvnet-brain/releases/latest), currently `v0.5.0-dev`. Only moves when the underlying knowledge base is rebuilt β€” separate again from the two above.
14
23
 
15
24
  <sub>Built by **[Stuart Kerr](https://isovision.ai)** at [Isovision.ai](https://isovision.ai) Β· free & fair use, to help everyone leverage the high end of agentic coding.</sub>
16
25
 
17
- ### [β–Ά Come see it visually explained](https://ruvnet-brain.vercel.app)
26
+ ### [β–Ά Come see it visually explained](https://isovision.ai/ruvnet-brain/)
18
27
 
19
28
  <sub>An interactive, animated walkthrough of what you can actually build β€” **click the preview** to open it.</sub>
20
29
 
21
- [![The RuvNet Brain interactive explainer β€” click to open the visual walkthrough](assets/explainer-preview.png)](https://ruvnet-brain.vercel.app)
30
+ [![The RuvNet Brain interactive explainer β€” click to open the visual walkthrough](assets/explainer-preview.png)](https://isovision.ai/ruvnet-brain/)
22
31
 
23
32
  </div>
24
33
 
@@ -32,7 +41,7 @@ But **Claude was trained on _classical_ software development.** Point it at rUv'
32
41
 
33
42
  > **RuvNet Brain is the missing instruction manual.** It reads rUv's real source, hands Claude the _answer key_, and removes Claude's permission to make things up about the stack. Install it once, aim it at any repo, and a newcomer can build ~9 months ahead β€” without being rUv.
34
43
 
35
- The novelty is **enforcement, not retrieval.** Plain RAG only decides what to _add_ to context; it never stops a model from overriding good context with a stronger prior. This ships a `UserPromptSubmit` hook that injects a grounding directive on every RuvNet-relevant turn, consumed structurally by the harness β€” so grounding is **non-optional**. **RAG decides what to add; this decides what the model isn't allowed to make up.**
44
+ The novelty is **structural grounding, not plain retrieval.** Plain RAG only decides what to _add_ to context. This ships a `UserPromptSubmit` hook that injects a grounding directive on **every** RuvNet-relevant turn β€” the harness consumes that stdout structurally, so the directive is _always present_, not a decline-able suggestion. It's a **strong, always-on nudge** β€” Claude is pointed at the real source and told to ground before asserting on every relevant turn β€” not a hard block on the model's output. **RAG decides what to add; this makes grounding the default the model has to actively argue its way out of.**
36
45
 
37
46
  ---
38
47
 
@@ -71,7 +80,7 @@ WITHOUT the brain β€” drift | WITH RuvNet Brain β€” grounded
71
80
  npx ruvnet-brain
72
81
  ```
73
82
 
74
- That single command runs the whole setup, narrating _what it's doing and why_ at each step: it downloads the brain (~512 MB) from the [latest GitHub Release](https://github.com/stuinfla/ruvnet-brain/releases/latest), unpacks it to `~/.cache/ruvnet-brain/kb`, installs its local reader (no cloud calls, no API keys), and wires the Claude Code plugin β€” the `search_ruvnet` MCP tool + the `UserPromptSubmit` grounding hook β€” at user scope. Works the same on **macOS, Linux, and Windows**. It's safe to re-run, and the brain itself always fetches the current Release regardless of which install path you use β€” you install once; you don't keep re-downloading.
83
+ That single command runs the whole setup, narrating _what it's doing and why_ at each step: it downloads the brain (~512 MB) from the [latest GitHub Release](https://github.com/stuinfla/ruvnet-brain/releases/latest), unpacks it to `~/.cache/ruvnet-brain/kb`, installs its local reader (no cloud calls, no API keys), and wires the Claude Code plugin β€” the `search_ruvnet` MCP tool + the `UserPromptSubmit` grounding hook β€” at user scope. The installer and the `search_ruvnet` tool run on **macOS, Linux, and Windows**; the grounding/enforcement **hooks are POSIX shell**, so they fire on macOS, Linux, and Windows-via-WSL/Git-Bash (on native Windows without WSL the search tool still works, but the auto-grounding hooks don't fire β€” a Node port of the hooks is on the roadmap). It's safe to re-run, and the brain itself always fetches the current Release regardless of which install path you use β€” you install once; you don't keep re-downloading.
75
84
 
76
85
  > Want the bleeding-edge installer, even ahead of the last npm publish? `npx github:stuinfla/ruvnet-brain` always runs straight off the latest GitHub commit.
77
86
 
@@ -92,9 +101,21 @@ Registers the `search_ruvnet` MCP tool, the grounding skill, and the `UserPrompt
92
101
 
93
102
  ---
94
103
 
95
- ## ✨ What's new in v0.5.0-dev β€” it now knows the stack _down to the code_
104
+ ## Staying current β€” how updates work
105
+
106
+ You install once. After that, three mechanisms keep you on the current brain without you having to remember an update command.
107
+
108
+ - **Consent-gated auto-update heartbeat** (the `SessionStart` hook, `plugin/scripts/session-start.sh`). The **first** time the plugin runs on a machine it asks you **once** whether it may keep itself updated in the background β€” a security-conscious opt-in, because self-update can change the model's own instructions. Your answer is remembered (`~/.cache/ruvnet-brain/.auto-update-pref`) and never asked again. On each session start it does a rate-limited (~15 min) 3s-capped check of the live GitHub `plugin.json`. If a newer plugin version exists **and** you opted in, it downloads it in the background through Claude Code's own trusted marketplace path β€” but the new version is **staged, not active**: Claude Code only loads plugins at process start, so **this session keeps running the version it started with** until you restart (`claude --continue` brings your conversation right back on the new version). If you declined, it just tells you the command to run. The 512 MB knowledge bundle is handled more conservatively β€” **detect + notify only**, never auto-applied, because the bundle isn't cryptographically signed yet and applying it would overwrite executable tool files (SEC-0010 #6).
109
+
110
+ - **Stack watchdog status footer** (the `UserPromptSubmit` hook's always-on Gate 0, `plugin/scripts/ground-ruvnet.sh`). Every response ends with one dim status line β€” e.g. `🧠 RuvNet Brain v1.11.0-dev Β· Ruflo: yes Β· AgentDB memory: on` β€” read from **filesystem ground truth**, not impressions. The version shown is always the one **actually loaded in memory** for this session; if a newer version is staged awaiting a restart, the line says so plainly (`… vX staged, restart to load`). So you never have to wonder whether the brain is on, which version is acting, or whether project memory is wired.
111
+
112
+ - **Nightly publish β†’ `releases/latest` chain** (`scripts/self-update.mjs --publish`, run by the `deploy/com.ruvnet.brain-nightly.plist` LaunchAgent at 03:15). The nightly rebuilds only the repos whose upstream changed, and **if anything was rebuilt** it bumps the product version, cuts a GitHub Release, and advances [`releases/latest`](https://github.com/stuinfla/ruvnet-brain/releases/latest). Plugin and knowledge bundle move under **one** version number, so the heartbeat above picks up both automatically. (The LaunchAgent is not auto-installed β€” enabling a system scheduler needs explicit owner approval.)
113
+
114
+ ---
115
+
116
+ ## ✨ What the knowledge bundle knows β€” the stack _down to the code_
96
117
 
97
- Earlier versions knew the **docs and architecture**. v0.5.0-dev re-indexes the code-rich repos to **full function bodies**, against each repo's real source layout β€” so β€œhow is this actually implemented?” returns the implementation, not a summary:
118
+ Earlier bundles knew the **docs and architecture**. The current bundle re-indexes the code-rich repos to **full function bodies**, against each repo's real source layout β€” so β€œhow is this actually implemented?” returns the implementation, not a summary:
98
119
 
99
120
  | Repo | Full-body code passages | |
100
121
  |---|---:|---|
@@ -122,7 +143,7 @@ The expensive work happens **once, at build time**: every covered repo is deep-w
122
143
 
123
144
  ## How it changes Claude's behavior β€” it takes the wheel
124
145
 
125
- Grounding is **enforced, not suggested.** On a RuvNet-relevant prompt the `UserPromptSubmit` hook injects a directive into context; Claude calls `search_ruvnet`, gets whole source files labeled by repo and path, and answers _from_ them. Because the hook's stdout is consumed by the harness every turn, it's structural β€” Claude can't quietly skip it.
146
+ Grounding is **injected every turn, not left to chance.** On a RuvNet-relevant prompt the `UserPromptSubmit` hook injects a directive into context; Claude calls `search_ruvnet`, gets whole source files labeled by repo and path, and answers _from_ them. Because the hook's stdout is consumed by the harness every turn, the directive is _always there_ β€” a strong grounding nudge on every relevant turn. (It's a nudge, not a hard gate: the hook can't rewrite Claude's output, so it steers rather than blocks β€” see [ADR-0005](docs/adr/0005-behavioral-grounding-not-lock.md) for exactly what ships.)
126
147
 
127
148
  ![The grounding flow: prompt to cited answer](assets/diagrams/grounding-flow.svg)
128
149
 
@@ -183,9 +204,27 @@ node plugin/test/run-tests.mjs # full plugin QA over real JSO
183
204
  | **L1–L4 behavioral harness** | **all pass** | route Β· deep-recall (returns _code_) Β· implement (cites the API) Β· orchestrate (the hook drives the full pipeline) |
184
205
  | **Plugin QA** | **26 / 26** | manifests, hook firing, MCP `initialize`/`tools/list`, capability battery |
185
206
  | **Clean-room install** | **3 / 3** | download the published 512 MB bundle fresh β†’ unzip β†’ query β†’ grounded, cited answers |
207
+ | **Unit tests** | **154 passing** Β· 75% lines | `npm run test:cov` β€” a CI floor fails the build if coverage slips |
208
+ | **Grounding proof** | `npx ruvnet-brain --doctor` | asks a real question, then checks the cited path really exists in the on-disk store; a citation that doesn't resolve is reported as **NOT grounded** |
209
+ | **Held-out eval** | **grounded 12/12** Β· routed 10/12 | `npm run eval` β€” 12 frozen questions never used for tuning, graded on ground truth, never by a model |
210
+
211
+ <sub>The suite also carries **181 `it.todo` stubs** β€” a written backlog, each naming an untested behavior and what it would take to cover. They are deliberately **not** counted as tests: a stub proves nothing, and a number that flatters is worse than no number.</sub>
186
212
 
187
213
  Two honest residuals, not hidden: one described question (_β€œroute to cheaper models to cut cost”_) still leans `ruflo` over `agentic-flow` (orchestration/cost overlap); one unnamed _β€œmethodology”_ question routes to `synthlang` instead of `sparc`. Proof reports land in [`PROOF.md`](PROOF.md), [`DESCRIBED-PROOF.md`](DESCRIBED-PROOF.md), and [`HELIX-DEMO-NOHELIX.md`](HELIX-DEMO-NOHELIX.md).
188
214
 
215
+ ### The eval flywheel
216
+
217
+ Most of the numbers above were tuned against. [`evals/held-out.json`](evals/held-out.json) was not: twelve questions phrased the way a newcomer would ask, each with the owning repo chosen **from first principles before the brain ever saw them**. `npm run eval` scores two things, both without asking a model's opinion:
218
+
219
+ - **grounded** β€” the cited passage genuinely exists in the on-disk store. A path that doesn't resolve is a *fabricated* citation, and is counted as a failure however plausible it reads.
220
+ - **routed** β€” the citation came from a repo that actually owns the capability.
221
+
222
+ `npm run eval:gate` fails the build (exit 1) if either score drops below [`evals/baseline.json`](evals/baseline.json) β€” and also if there is **no baseline at all**, since you cannot promote against nothing. Baselines are only ever written deliberately, with `npm run eval:record`; a baseline that silently follows the code is a ratchet with no teeth.
223
+
224
+ **Why not let an LLM grade it?** Because on this very repo an LLM panel scored a **zero-citation answer 98/100**. Model-as-judge is blind to the one failure that matters here.
225
+
226
+ Current: **grounded 12/12, routed 10/12.** The two routing misses are recorded, not tuned away β€” `"generate tests and find coverage gaps"` cites agentic-qe's real test generator, which lives *vendored inside the ruflo repo*, so the answer is right and the repo label is a corpus-attribution artifact; `"spend less money on model calls"` cites a genuine `agentic-qe/docs/guides/cheaper-model-eval-lanes.md`, the same cost/orchestration overlap noted above.
227
+
189
228
  **Query it directly (CLI):**
190
229
 
191
230
  ```bash
@@ -200,7 +239,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
200
239
 
201
240
  ## Honest status
202
241
 
203
- This is **`v0.5.0-dev`** β€” we don't claim β€œdone,” β€œcomplete,” or β€œzero hallucinations.” Where it stands:
242
+ This is a **`-dev`** project (see the live version 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:
204
243
 
205
244
  - βœ… **The grounding brain is real and proven** β€” ~21 building-block repos, 90,842 chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
206
245
  - βœ… **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).
@@ -217,7 +256,7 @@ This is **`v0.5.0-dev`** β€” we don't claim β€œdone,” β€œcomplete,” or β€œze
217
256
  - `plugin/` β€” the Claude Code plugin (MCP server, grounding skill, `UserPromptSubmit` enforcement hook, marketplace manifest, test suite).
218
257
  - `scripts/` β€” `gate.sh` (routing gate), `behavioral-l1-l4.mjs` (behavioral harness), `prove.mjs`, `build-bundle.mjs`, `brain-stamp.mjs`.
219
258
  - `docs/` β€” [`VISION.md`](docs/VISION.md) (the why), [`adr/`](docs/adr/) (locked decisions incl. ADR-0008), [`DDD.md`](docs/DDD.md).
220
- - `explainer/` β€” the source of the [live explainer](https://ruvnet-brain.vercel.app).
259
+ - `explainer/` β€” the source of the [live explainer](https://isovision.ai/ruvnet-brain/).
221
260
  - `SPEC.md` Β· `PROGRESS.md` β€” the master spec and the living, timestamped build log.
222
261
 
223
262
  The brain binaries ship via the [Release](https://github.com/stuinfla/ruvnet-brain/releases/tag/v0.5.0-dev), not git β€” a fresh clone is lightweight; `npx` fetches the 512 MB bundle.
@@ -226,7 +265,7 @@ The brain binaries ship via the [Release](https://github.com/stuinfla/ruvnet-bra
226
265
 
227
266
  ## Links
228
267
 
229
- - **β–Ά Live explainer:** https://ruvnet-brain.vercel.app
268
+ - **β–Ά Live explainer:** https://isovision.ai/ruvnet-brain/
230
269
  - **Download / Release:** https://github.com/stuinfla/ruvnet-brain/releases/tag/v0.5.0-dev
231
270
  - **rUv's RuvNet org:** https://github.com/ruvnet
232
271
  - **Built by:** [Stuart Kerr β€” Isovision.ai](https://isovision.ai)
package/bin/install.mjs CHANGED
@@ -16,8 +16,27 @@ import fs from 'node:fs';
16
16
  import os from 'node:os';
17
17
  import path from 'node:path';
18
18
  import { spawnSync } from 'node:child_process';
19
- import { fileURLToPath } from 'node:url';
19
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
20
  import readline from 'node:readline';
21
+ import crypto from 'node:crypto';
22
+
23
+ // SEC-0010 #6 β€” the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
24
+ // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
25
+ // cannot also swap the key the installer checks it against. Rotate via `node scripts/sign-bundle.mjs
26
+ // --gen-key` and paste the new keys/…pub.pem here. Verify logic mirrors scripts/verify-bundle.mjs.
27
+ const SIGNING_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
28
+ MCowBQYDK2VwAyEAgse9TAtehXUvUfTrJFY2CCHiCbmelR8yCgS//sen5/w=
29
+ -----END PUBLIC KEY-----`;
30
+ function verifyBundle(bundlePath, sigPath) {
31
+ try {
32
+ if (!fs.existsSync(bundlePath)) return { ok: false, reason: `bundle not found: ${bundlePath}` };
33
+ if (!fs.existsSync(sigPath)) return { ok: false, reason: `signature missing (fail-closed)` };
34
+ const digest = crypto.createHash('sha256').update(fs.readFileSync(bundlePath)).digest('hex');
35
+ const pub = crypto.createPublicKey(SIGNING_PUBKEY_PEM);
36
+ const ok = crypto.verify(null, Buffer.from(digest, 'hex'), pub, fs.readFileSync(sigPath));
37
+ return ok ? { ok: true, reason: `signature valid (sha256 ${digest.slice(0, 12)}…)` } : { ok: false, reason: 'signature does NOT match β€” bundle may be tampered' };
38
+ } catch (e) { return { ok: false, reason: `verify error: ${e.message}` }; }
39
+ }
21
40
 
22
41
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
23
42
  const REPO_ROOT = path.resolve(__dirname, '..');
@@ -25,9 +44,16 @@ const REPO_ROOT = path.resolve(__dirname, '..');
25
44
  const REPO = 'stuinfla/ruvnet-brain';
26
45
  const RELEASE_API = `https://api.github.com/repos/${REPO}/releases/latest`;
27
46
  const ASSET_NAME = 'ruvnet-brain.zip';
28
- // Known-good fallback used when we can't reach GitHub (offline / rate-limited / no releases).
29
- // Default behavior is "get the latest"; this is only the safety net.
30
- const RELEASE_VERSION = 'v0.5.0-dev';
47
+ // Known-good BUNDLE tag, used when we can't reach GitHub (offline / rate-limited / no releases),
48
+ // and by --pin. Default behavior is "get the latest Release"; this is only the safety net.
49
+ //
50
+ // This MUST NOT be derived from this package's own version. The installer and the brain bundle are
51
+ // two independent version streams (README: "Three independent things version separately here β€” by
52
+ // design"). Reading it from package.json produced a tag that has never existed β€” installer 1.14.0-dev
53
+ // asking for releases/download/v1.14.0-dev/ruvnet-brain.zip, which 404s, while the newest bundle
54
+ // Release is v0.5.0-dev. Verified live: v1.14.0-dev β†’ HTTP 404, v0.5.0-dev β†’ HTTP 200. The safety net
55
+ // was broken in exactly the situation it exists for. Bump this by hand when a new bundle ships.
56
+ const RELEASE_VERSION = 'v0.5.0-dev'; // sync-version-ignore: the BUNDLE Release tag, not this package's version
31
57
  const fallbackUrl = (tag) => `https://github.com/${REPO}/releases/download/${tag}/${ASSET_NAME}`;
32
58
  const APPROX_SIZE = '~512MB';
33
59
 
@@ -288,19 +314,31 @@ async function obtainBundle(release) {
288
314
  );
289
315
  info(`version: ${c.bold((release && release.tag) || RELEASE_VERSION)}`);
290
316
  info(`from: ${downloadUrl}`);
291
- const tmp = path.join(os.tmpdir(), `ruvnet-brain-${process.pid}.zip`);
317
+ // Download into a PRIVATE, per-run temp DIR β€” never a predictable os.tmpdir()/ruvnet-brain-<pid>.zip
318
+ // filename (CWE-377: a guessable path invites a pre-created or symlinked file at that location to be
319
+ // clobbered, or the extraction target to be hijacked). mkdtempSync creates a fresh, unguessable,
320
+ // owner-only (0700) directory; we write the zip inside it. The exit handler guarantees the whole dir
321
+ // is removed on ANY exit β€” success, thrown error, or die()β†’process.exit β€” and the caller also removes
322
+ // it immediately on success so ~512MB isn't held for the rest of the install.
323
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-brain-'));
324
+ process.on('exit', () => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } });
325
+ const tmp = path.join(tmpDir, ASSET_NAME);
292
326
  try {
293
327
  console.log(` downloading the brain (${APPROX_SIZE})…`);
294
328
  await download(downloadUrl, tmp);
295
329
  } catch (e) {
296
- try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
330
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
297
331
  die(
298
332
  `couldn't download the brain (${e.message}).`,
299
333
  `Check your connection, then re-run. Or, if you have a repo clone, build the bundle locally\n(${c.bold('node scripts/build-bundle.mjs')}) and run ${c.bold('node bin/install.mjs --local')}.`,
300
334
  );
301
335
  }
302
336
  ok(`downloaded to ${tmp}`);
303
- return { zipPath: tmp, downloaded: true };
337
+ // Best-effort fetch of the detached Ed25519 signature published alongside the asset (SEC-0010 #6).
338
+ // If present we verify it before extracting; if absent (a pre-signing release) we warn but proceed
339
+ // (transitional β€” see SIGNING_REQUIRED at the verify gate).
340
+ try { await download(`${downloadUrl}.sig`, `${tmp}.sig`); } catch { /* no published sig yet */ }
341
+ return { zipPath: tmp, tmpDir, downloaded: true };
304
342
  }
305
343
 
306
344
  // ── step: unzip into the cache dir (flattening the top-level ruvnet-brain/ folder) ───────────────
@@ -379,14 +417,34 @@ function installReader(cacheDir) {
379
417
  die(`\`npm\` isn't available, but the brain needs it for its reader.`, `Install Node.js (which includes npm) and re-run.`);
380
418
  }
381
419
  info('installing the local reader…');
420
+ // Prefer `npm ci` for a PINNED, reproducible install when the bundle shipped its lockfile
421
+ // (SEC-0010 #8 β€” otherwise every install did a fully unpinned resolve). Fall back to `npm i`
422
+ // for older bundles that predate the shipped lockfile.
423
+ const hasLock = fs.existsSync(path.join(cacheDir, 'package-lock.json'));
424
+ const npmArgs = hasLock
425
+ ? ['ci', '--no-audit', '--no-fund', '--loglevel=error']
426
+ : ['i', '--no-audit', '--no-fund', '--loglevel=error'];
382
427
  try {
383
- run('npm', ['i', '--no-audit', '--no-fund', '--loglevel=error'], {
428
+ run('npm', npmArgs, {
384
429
  cwd: cacheDir,
385
430
  // silence npm's "new version available" update-notifier so the narration stays clean
386
431
  env: { ...process.env, npm_config_update_notifier: 'false', npm_config_fund: 'false' },
387
432
  });
388
433
  } catch (e) {
389
- die(`the reader install failed (${e.message}).`, `Re-run after checking your network / npm setup.`);
434
+ // `npm ci` is strict (fails if lock and package.json disagree); fall back to `npm i` once
435
+ // rather than hard-failing a user's install on a lockfile mismatch.
436
+ if (hasLock) {
437
+ warn(`pinned install (npm ci) failed (${e.message}); retrying with npm i`);
438
+ try {
439
+ run('npm', ['i', '--no-audit', '--no-fund', '--loglevel=error'], {
440
+ cwd: cacheDir, env: { ...process.env, npm_config_update_notifier: 'false', npm_config_fund: 'false' },
441
+ });
442
+ } catch (e2) {
443
+ die(`the reader install failed (${e2.message}).`, `Re-run after checking your network / npm setup.`);
444
+ }
445
+ } else {
446
+ die(`the reader install failed (${e.message}).`, `Re-run after checking your network / npm setup.`);
447
+ }
390
448
  }
391
449
  ok('reader installed');
392
450
  }
@@ -455,7 +513,37 @@ function verifyInstall(cacheDir) {
455
513
  }
456
514
 
457
515
  // ── step: warm the model + prove grounding with one real question (best-effort, never fatal) ──────
458
- function smokeQuery(cacheDir) {
516
+ // ── BEGIN GENERATED: verify-citation.mjs (node scripts/embed-verifier.mjs) ──
517
+ const VERIFY_CITATION_B64 = 'IyEvdXNyL2Jpbi9lbnYgbm9kZQovLyB2ZXJpZnktY2l0YXRpb24ubWpzIOKAlCBkZWNpZGUgd2hldGhlciBhbiBhbnN3ZXIgaXMgR1JPVU5ERUQsIGJ5IGdyb3VuZCB0cnV0aCByYXRoZXIgdGhhbiBieSB2aWJlcy4KLy8KLy8gV0hZIFRISVMgRVhJU1RTCi8vIC0tLS0tLS0tLS0tLS0tLQovLyBUaGUgb2xkIGdyb3VuZGluZyBjaGVjayBhc2tlZDogZG9lcyB0aGUgYW5zd2VyIGNvbnRhaW4gdGhlIHN0cmluZyAicnZmIiBvciAicnV2ZWN0b3IiPyBBIG1vZGVsCi8vIHRoYXQgaGFsbHVjaW5hdGVkICJqdXN0IHVzZSBSVkYhIiB3aXRoIHplcm8gc291cmNlcyBwYXNzZWQuIFNvIGRpZCBhbiBhbnN3ZXIgY2l0aW5nIGEgZmlsZSB0aGF0Ci8vIGRvZXMgbm90IGV4aXN0LiBLZXl3b3JkIHByZXNlbmNlIGlzIG5vdCBldmlkZW5jZSDigJQgYW4gTExNIHBhbmVsIG9uY2Ugc2NvcmVkIGEgemVyby1jaXRhdGlvbgovLyBhbnN3ZXIgOTgvMTAwIG9uIHRoaXMgcmVwby4KLy8KLy8gQSBjaXRhdGlvbiBpcyBvbmx5IHJlYWwgaWYgaXQgUkVTT0xWRVM6IHRoZSByZXBvIG11c3QgYmUgYW4gaW5kZXhlZCBzdG9yZSBvbiBkaXNrLCBhbmQgdGhlIGNpdGVkCi8vIGRvY3VtZW50IHBhdGggbXVzdCBhcHBlYXIgYXMgdGhlIGBwYXRoYCBvZiBhbiBhY3R1YWwgcGFzc2FnZSBpbnNpZGUgdGhhdCBzdG9yZSdzIHBhc3NhZ2VzIGZpbGUuCi8vIFRoYXQgaXMgY2hlY2thYmxlIHdpdGhvdXQgYSBtb2RlbCwgd2l0aG91dCB0aGUgbmV0d29yaywgYW5kIHdpdGhvdXQgdHJ1c3RpbmcgYW55dGhpbmcgdGhlIG1vZGVsCi8vIHNhaWQuIFRoaXMgbW9kdWxlIGRvZXMgZXhhY3RseSB0aGF0IGFuZCBub3RoaW5nIGVsc2UuCi8vCi8vIFRoZSByZWFkZXIgKGBmb3JnZS1hc2stYWxsLm1qc2ApIHByaW50cyBlYWNoIGhpdCBhczoKLy8gICAgICMxICByZXBvPWNvbmNlcHRzICBjZT0wLjIwMSAgdmVjPTAuODY4NiAga2luZD1kb2MKLy8gICAgIHBhdGggOiBjb25jZXB0cy9ydXZlY3Rvci9DQVJEL3J1dmVjdG9yLWNhcmQKLy8gICAgIHRpdGxlOiBydXZlY3RvciDigJQgQ2FwYWJpbGl0eQovLyBOb3RlIHRoZSBwcmludGVkIHBhdGggaXMgYDxyZXBvPi88ZG9jUGF0aD5gOyBpbnNpZGUgYGNvbmNlcHRzLnBhc3NhZ2VzLmpzb25sYCB0aGUgc3RvcmVkIGBwYXRoYAovLyBpcyBqdXN0IGBydXZlY3Rvci9DQVJEL3J1dmVjdG9yLWNhcmRgIChvcHRpb25hbGx5IHN1ZmZpeGVkIGAjMGAsIGAjMWAsIOKApiB3aGVuIGNodW5rZWQpLgoKaW1wb3J0IGZzIGZyb20gJ25vZGU6ZnMnOwppbXBvcnQgcGF0aCBmcm9tICdub2RlOnBhdGgnOwppbXBvcnQgcmVhZGxpbmUgZnJvbSAnbm9kZTpyZWFkbGluZSc7CgovKiogUGFyc2UgdGhlIHJlYWRlcidzIHN0ZG91dCBpbnRvIHN0cnVjdHVyZWQgY2l0YXRpb25zLiBOZXZlciB0aHJvd3M7IHVucGFyc2VhYmxlIGlucHV0IOKGkiBbXS4gKi8KZXhwb3J0IGZ1bmN0aW9uIHBhcnNlQ2l0YXRpb25zKHN0ZG91dCkgewogIGNvbnN0IG91dCA9IFtdOwogIGNvbnN0IHRleHQgPSBTdHJpbmcoc3Rkb3V0ID8/ICcnKTsKICBjb25zdCBibG9ja1JlID0gL14jKFxkKylccytyZXBvPShcUyspKD86XHMrY2U9KC0/W1xkLl0rKSk/KD86XHMrdmVjPSgtP1tcZC5dKykpPyg/OlxzK2tpbmQ9KFxTKykpPy9nbTsKICBsZXQgbTsKICB3aGlsZSAoKG0gPSBibG9ja1JlLmV4ZWModGV4dCkpICE9PSBudWxsKSB7CiAgICBjb25zdCByZXN0ID0gdGV4dC5zbGljZShtLmluZGV4KTsKICAgIGNvbnN0IHBhdGhNID0gL15wYXRoXHMqOlxzKiguKykkL20uZXhlYyhyZXN0KTsKICAgIGNvbnN0IHRpdGxlTSA9IC9edGl0bGVccyo6XHMqKC4rKSQvbS5leGVjKHJlc3QpOwogICAgaWYgKCFwYXRoTSkgY29udGludWU7CiAgICBjb25zdCByZXBvID0gbVsyXTsKICAgIGNvbnN0IGZ1bGxQYXRoID0gcGF0aE1bMV0udHJpbSgpOwogICAgLy8gU3RyaXAgdGhlIHJlcG8gcHJlZml4IHRoZSByZWFkZXIgYWRkcywgc28gdGhlIHJlbWFpbmRlciBjYW4gYmUgbWF0Y2hlZCBhZ2FpbnN0IHRoZSBzdG9yZS4KICAgIGNvbnN0IGRvY1BhdGggPSBmdWxsUGF0aC5zdGFydHNXaXRoKGAke3JlcG99L2ApID8gZnVsbFBhdGguc2xpY2UocmVwby5sZW5ndGggKyAxKSA6IGZ1bGxQYXRoOwogICAgb3V0LnB1c2goewogICAgICByYW5rOiBOdW1iZXIobVsxXSksCiAgICAgIHJlcG8sCiAgICAgIGNlOiBtWzNdICE9PSB1bmRlZmluZWQgPyBOdW1iZXIobVszXSkgOiBudWxsLAogICAgICB2ZWM6IG1bNF0gIT09IHVuZGVmaW5lZCA/IE51bWJlcihtWzRdKSA6IG51bGwsCiAgICAgIGtpbmQ6IG1bNV0gPz8gbnVsbCwKICAgICAgZnVsbFBhdGgsCiAgICAgIGRvY1BhdGgsCiAgICAgIHRpdGxlOiB0aXRsZU0gPyB0aXRsZU1bMV0udHJpbSgpIDogbnVsbCwKICAgIH0pOwogIH0KICByZXR1cm4gb3V0Owp9CgovKiogVGhlIHBhc3NhZ2VzIGZpbGVzIHRoYXQgY291bGQgaG9sZCBhIHJlcG8ncyBkb2N1bWVudHMg4oCUIHRoZSBzbGltIHN0b3JlIGFuZCB0aGUgZGVlcCBgLmJpZ2Agb25lLiAqLwpleHBvcnQgZnVuY3Rpb24gcGFzc2FnZXNGaWxlc0ZvcihyZXBvLCBrYkRpcikgewogIHJldHVybiBbcGF0aC5qb2luKGtiRGlyLCBgJHtyZXBvfS5wYXNzYWdlcy5qc29ubGApLCBwYXRoLmpvaW4oa2JEaXIsIGAke3JlcG99LmJpZy5wYXNzYWdlcy5qc29ubGApXQogICAgLmZpbHRlcigocCkgPT4gZnMuZXhpc3RzU3luYyhwKSk7Cn0KCi8qKiBUcnVlIHdoZW4gYHN0b3JlZGAgaXMgdGhlIGNpdGVkIGRvYywgYWxsb3dpbmcgZm9yIHRoZSBgI05gIGNodW5rIHN1ZmZpeCB0aGUgYnVpbGRlciBhcHBlbmRzLiAqLwpmdW5jdGlvbiBzYW1lUGF0aChzdG9yZWQsIGRvY1BhdGgpIHsKICByZXR1cm4gc3RvcmVkID09PSBkb2NQYXRoIHx8IHN0b3JlZC5zdGFydHNXaXRoKGAke2RvY1BhdGh9I2ApOwp9CgovKioKICogRG9lcyB0aGlzIGNpdGF0aW9uIHBvaW50IGF0IGEgcGFzc2FnZSB0aGF0IHJlYWxseSBleGlzdHMgb24gZGlzaz8KICogU3RyZWFtcyB0aGUgZmlsZSBhbmQgc3RvcHMgYXQgdGhlIGZpcnN0IG1hdGNoLCBzbyBhIDUwME1CIGAuYmlnYCBzdG9yZSBjb3N0cyBvbmx5IGFzIG11Y2ggYXMgaXQKICogdGFrZXMgdG8gcmVhY2ggdGhlIGhpdC4gQSBtYWxmb3JtZWQgSlNPTiBsaW5lIGlzIHNraXBwZWQsIG5ldmVyIGZhdGFsLgogKi8KZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGNpdGF0aW9uUmVzb2x2ZXMoY2l0YXRpb24sIGtiRGlyKSB7CiAgY29uc3QgZmlsZXMgPSBwYXNzYWdlc0ZpbGVzRm9yKGNpdGF0aW9uLnJlcG8sIGtiRGlyKTsKICBpZiAoIWZpbGVzLmxlbmd0aCkgcmV0dXJuIHsgcmVzb2x2ZWQ6IGZhbHNlLCByZWFzb246ICduby1zdG9yZScsIGZpbGU6IG51bGwsIHN0b3JlZFBhdGg6IG51bGwgfTsKICBmb3IgKGNvbnN0IGZpbGUgb2YgZmlsZXMpIHsKICAgIGNvbnN0IHJsID0gcmVhZGxpbmUuY3JlYXRlSW50ZXJmYWNlKHsgaW5wdXQ6IGZzLmNyZWF0ZVJlYWRTdHJlYW0oZmlsZSksIGNybGZEZWxheTogSW5maW5pdHkgfSk7CiAgICB0cnkgewogICAgICBmb3IgYXdhaXQgKGNvbnN0IGxpbmUgb2YgcmwpIHsKICAgICAgICBpZiAoIWxpbmUpIGNvbnRpbnVlOwogICAgICAgIGxldCByZWM7CiAgICAgICAgdHJ5IHsgcmVjID0gSlNPTi5wYXJzZShsaW5lKTsgfSBjYXRjaCB7IGNvbnRpbnVlOyB9CiAgICAgICAgaWYgKHR5cGVvZiByZWM/LnBhdGggPT09ICdzdHJpbmcnICYmIHNhbWVQYXRoKHJlYy5wYXRoLCBjaXRhdGlvbi5kb2NQYXRoKSkgewogICAgICAgICAgcmV0dXJuIHsgcmVzb2x2ZWQ6IHRydWUsIHJlYXNvbjogJ29rJywgZmlsZTogcGF0aC5iYXNlbmFtZShmaWxlKSwgc3RvcmVkUGF0aDogcmVjLnBhdGggfTsKICAgICAgICB9CiAgICAgIH0KICAgIH0gZmluYWxseSB7CiAgICAgIHJsLmNsb3NlKCk7CiAgICB9CiAgfQogIHJldHVybiB7IHJlc29sdmVkOiBmYWxzZSwgcmVhc29uOiAncGF0aC1ub3QtaW4tc3RvcmUnLCBmaWxlOiBudWxsLCBzdG9yZWRQYXRoOiBudWxsIH07Cn0KCi8qKgogKiBUaGUgZ2F0ZS4gQW4gYW5zd2VyIGlzIGdyb3VuZGVkIG9ubHkgd2hlbiBpdCBjaXRlcyBhdCBsZWFzdCBvbmUgcGFzc2FnZSB0aGF0IHJlc29sdmVzIG9uIGRpc2suCiAqIFJldHVybnMgdGhlIHJlY2VpcHQgc28gYSBjYWxsZXIgY2FuIFBSSU5UIHRoZSBldmlkZW5jZSBpbnN0ZWFkIG9mIGFzc2VydGluZyBhIGNvbmNsdXNpb24uCiAqLwpleHBvcnQgYXN5bmMgZnVuY3Rpb24gdmVyaWZ5R3JvdW5kaW5nKHN0ZG91dCwga2JEaXIpIHsKICBjb25zdCBjaXRhdGlvbnMgPSBwYXJzZUNpdGF0aW9ucyhzdGRvdXQpOwogIGlmICghY2l0YXRpb25zLmxlbmd0aCkgewogICAgcmV0dXJuIHsgZ3JvdW5kZWQ6IGZhbHNlLCByZWFzb246ICduby1jaXRhdGlvbnMnLCBjaXRhdGlvbnM6IFtdLCByZWNlaXB0OiBudWxsIH07CiAgfQogIGZvciAoY29uc3QgY2l0YXRpb24gb2YgY2l0YXRpb25zKSB7CiAgICBjb25zdCByID0gYXdhaXQgY2l0YXRpb25SZXNvbHZlcyhjaXRhdGlvbiwga2JEaXIpOwogICAgaWYgKHIucmVzb2x2ZWQpIHsKICAgICAgcmV0dXJuIHsKICAgICAgICBncm91bmRlZDogdHJ1ZSwKICAgICAgICByZWFzb246ICdvaycsCiAgICAgICAgY2l0YXRpb25zLAogICAgICAgIHJlY2VpcHQ6IHsgcmVwbzogY2l0YXRpb24ucmVwbywgcGF0aDogY2l0YXRpb24uZnVsbFBhdGgsIHRpdGxlOiBjaXRhdGlvbi50aXRsZSwgZmlsZTogci5maWxlLCBzdG9yZWRQYXRoOiByLnN0b3JlZFBhdGggfSwKICAgICAgfTsKICAgIH0KICB9CiAgcmV0dXJuIHsgZ3JvdW5kZWQ6IGZhbHNlLCByZWFzb246ICdjaXRhdGlvbnMtZG8tbm90LXJlc29sdmUnLCBjaXRhdGlvbnMsIHJlY2VpcHQ6IG51bGwgfTsKfQo=';
518
+ // ── END GENERATED ──
519
+
520
+ // The verifier belongs next to the data it verifies, so it lives in the KB. But every bundle
521
+ // published before 2026-07-09 predates it, and telling those users "grounding not verifiable β€”
522
+ // re-run the installer" would send them in a circle, because re-running fetches the same bundle.
523
+ // So the installer CARRIES the verifier and writes it in when it's missing. A newer bundle's copy
524
+ // always wins: we never overwrite a file the bundle shipped.
525
+ function ensureVerifier(cacheDir) {
526
+ const p = path.join(cacheDir, 'verify-citation.mjs');
527
+ if (fs.existsSync(p)) return 'from-bundle';
528
+ if (!VERIFY_CITATION_B64) return 'unavailable';
529
+ try {
530
+ fs.writeFileSync(p, Buffer.from(VERIFY_CITATION_B64, 'base64').toString('utf8'), 'utf8');
531
+ return 'installed';
532
+ } catch { return 'unavailable'; }
533
+ }
534
+
535
+ async function loadCitationVerifier(cacheDir) {
536
+ ensureVerifier(cacheDir);
537
+ const p = path.join(cacheDir, 'verify-citation.mjs');
538
+ if (!fs.existsSync(p)) return null;
539
+ try { return await import(pathToFileURL(p).href); } catch { return null; }
540
+ }
541
+
542
+ // Proving grounding means proving the answer's CITATION RESOLVES β€” that the file it points at is a
543
+ // real, indexed passage on this disk. The old check here tested `/rvf|ruvector|hnsw/` against the
544
+ // answer text, which a hallucinated "just use RVF!" passes with zero sources. Keyword presence is
545
+ // not evidence. We now print the cited path as a receipt, so you can go look at it yourself.
546
+ async function smokeQuery(cacheDir) {
459
547
  const ask = path.join(cacheDir, 'forge-ask-all.mjs');
460
548
  if (!fs.existsSync(ask)) return { ran: false };
461
549
  step(
@@ -465,6 +553,7 @@ function smokeQuery(cacheDir) {
465
553
  const Q = 'How should I store embeddings in this project without running a server?';
466
554
  info(`Q: ${c.cyan(`"${Q}"`)}`);
467
555
  info(c.dim('(first run downloads a small local model once β€” this can take a minute)'));
556
+ const started = Date.now();
468
557
  let r;
469
558
  try {
470
559
  // Relative filename + matching cwd (NOT the absolute `ask` path) β€” forge-ask-all.mjs only runs
@@ -472,7 +561,7 @@ function smokeQuery(cacheDir) {
472
561
  // absolute path via spawnSync (no shell involved) that identity check silently fails on this
473
562
  // machine, so main() never runs β€” exit 0, zero stdout, zero stderr, no exception. Looks like a
474
563
  // clean success; is actually a total no-op. Verified: switching to a relative name + cwd fixes it.
475
- r = spawnSync('node', ['forge-ask-all.mjs', '--dir', cacheDir, '--q', Q, '--k', '1'], {
564
+ r = spawnSync('node', ['forge-ask-all.mjs', '--dir', cacheDir, '--q', Q, '--k', '3'], {
476
565
  cwd: cacheDir,
477
566
  encoding: 'utf8',
478
567
  timeout: 240000,
@@ -482,15 +571,34 @@ function smokeQuery(cacheDir) {
482
571
  warn("skipped the live test (couldn't launch the reader) β€” it'll warm on your first real question");
483
572
  return { ran: false };
484
573
  }
574
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
485
575
  const out = `${r.stdout || ''}`;
486
- if (r.status === 0 && /\brvf\b|ruvector|hnsw|single[- ]file|no server/i.test(out)) {
487
- ok("the brain answered from rUv's real source β€” grounding confirmed ✦");
488
- return { ran: true, grounded: true };
576
+ if (r.status !== 0 || !out.trim()) {
577
+ warn('no answer came back (first-run model download or offline) β€” the brain is installed; it\'ll warm on your first real question');
578
+ return { ran: true, grounded: false, reason: 'no-answer' };
579
+ }
580
+
581
+ const verifier = await loadCitationVerifier(cacheDir);
582
+ if (!verifier) {
583
+ info(`the brain answered in ${secs}s, but this bundle predates the citation verifier β€”`);
584
+ info(c.dim(' re-run `npx ruvnet-brain` to refresh it, and grounding will be PROVEN, not assumed'));
585
+ return { ran: true, grounded: null, reason: 'verifier-missing' };
586
+ }
587
+
588
+ const v = await verifier.verifyGrounding(out, cacheDir);
589
+ if (v.grounded) {
590
+ ok(`grounded in rUv's real source β€” verified in ${secs}s, not guessed ✦`);
591
+ console.log(` ${c.dim('cited:')} ${c.bold(v.receipt.path)}`);
592
+ if (v.receipt.title) console.log(` ${c.dim('title:')} ${v.receipt.title}`);
593
+ console.log(` ${c.dim('verified:')} that passage really exists in ${c.bold(v.receipt.file)}`);
594
+ return { ran: true, grounded: true, receipt: v.receipt, secs };
489
595
  }
490
596
  warn(
491
- "skipped the live test (first-run model download or offline) β€” the brain is installed; it'll warm on your first real question",
597
+ v.reason === 'no-citations'
598
+ ? 'the answer cited no source at all β€” NOT grounded (re-run the installer to repair the KB)'
599
+ : "the answer's citations don't resolve to any indexed passage β€” NOT grounded (KB may be corrupt; re-run the installer)",
492
600
  );
493
- return { ran: true, grounded: false };
601
+ return { ran: true, grounded: false, reason: v.reason };
494
602
  }
495
603
 
496
604
  // ── `--demo`: a guided, REAL walkthrough β€” proves grounding live, never fabricates output ─────────
@@ -556,7 +664,7 @@ function runDemo() {
556
664
  }
557
665
 
558
666
  // ── `--doctor`: a standalone health check the user can run any time ───────────────────────────────
559
- function doctor() {
667
+ async function doctor() {
560
668
  printBanner('doctor');
561
669
  console.log(c.dim('Checking every part of the install and reporting green/red.\n'));
562
670
  const cacheDir = process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
@@ -583,13 +691,25 @@ function doctor() {
583
691
  ? ok('RuVector present β€” vector CLI / MCP available')
584
692
  : warn('RuVector not found β€” answers still work. To add: claude mcp add ruvector --scope user -- npx -y ruvector mcp start');
585
693
  const v = verifyInstall(cacheDir);
586
- smokeQuery(cacheDir);
694
+ const smoke = await smokeQuery(cacheDir);
587
695
  const allGreen = v.repos > 0 && v.reader && v.mcp;
588
696
  console.log(
589
697
  `\n ${allGreen ? c.green('βœ“ Healthy.') : c.yellow('! Needs attention.')} ${
590
698
  allGreen ? 'The brain is installed and reachable.' : 'Re-run the installer to fix the warnings above.'
591
699
  }`,
592
700
  );
701
+ // Installed-and-reachable and actually-grounded are different claims. Keep them separate, so a
702
+ // healthy install can never be mistaken for proven grounding.
703
+ if (smoke.grounded === true) {
704
+ console.log(` ${c.green('βœ“ Grounding PROVEN.')} It answered from ${c.bold(smoke.receipt.path)} β€” a passage that`);
705
+ console.log(` really exists in your local KB. Checked in ${smoke.secs}s, no cloud, no API key.`);
706
+ } else if (smoke.grounded === false) {
707
+ console.log(` ${c.yellow('! Grounding NOT proven')} (${smoke.reason}). The install is present but the brain did not`);
708
+ console.log(' answer from a verifiable source. Re-run npx ruvnet-brain to repair the KB.');
709
+ } else if (smoke.grounded === null) {
710
+ console.log(` ${c.yellow('! Grounding not verifiable')} on this bundle β€” it predates the citation verifier.`);
711
+ console.log(' Re-run npx ruvnet-brain to refresh, then --doctor will prove it.');
712
+ }
593
713
  if (allGreen) {
594
714
  console.log(`\n ${c.bold('What this means for you:')}`);
595
715
  console.log(` β€’ ${c.bold('It works in EVERY project')} β€” user-level (global). Open Claude Code in any repo or VS Code`);
@@ -870,7 +990,7 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
870
990
  // ── main ─────────────────────────────────────────────────────────────────────────────────────────
871
991
  (async () => {
872
992
  if (FLAG_HELP) return showHelp();
873
- if (FLAG_DOCTOR) return doctor();
993
+ if (FLAG_DOCTOR) return await doctor();
874
994
  if (FLAG_DEMO) return runDemo();
875
995
 
876
996
  printBanner('installer');
@@ -909,10 +1029,31 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
909
1029
  const localZipPresent =
910
1030
  FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
911
1031
  const release = localZipPresent ? null : await resolveRelease();
912
- const { zipPath, downloaded } = await obtainBundle(release);
1032
+ const { zipPath, tmpDir, downloaded } = await obtainBundle(release);
1033
+ // Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
1034
+ // (SEC-0010 #6 β€” trust root = keys/ruvnet-brain-signing.pub.pem shipped inside this package).
1035
+ // SIGNING_REQUIRED is transitional: while releases predate signing, a MISSING sig warns-and-proceeds
1036
+ // but a PRESENT-but-INVALID sig ALWAYS fails closed. Flip to true once every release is signed.
1037
+ const SIGNING_REQUIRED = false;
1038
+ if (downloaded && !FLAG_NO_VERIFY) {
1039
+ const sigPath = `${zipPath}.sig`;
1040
+ const hasSig = fs.existsSync(sigPath);
1041
+ if (!hasSig && !SIGNING_REQUIRED) {
1042
+ warn('this release is not signed yet β€” proceeding (bundle integrity not cryptographically verified)');
1043
+ } else {
1044
+ step('Verifying the bundle signature', 'so a tampered or MITM-swapped download can never be extracted');
1045
+ const { ok: valid, reason } = verifyBundle(zipPath, sigPath);
1046
+ if (!valid) {
1047
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
1048
+ die(`bundle signature check FAILED β€” ${reason}`,
1049
+ `Refusing to extract an unverified bundle. Re-run to fetch a fresh copy; if it persists, the\nrelease may be tampered β€” report it. (Override at your own risk with ${c.bold('--no-verify')}.)`);
1050
+ }
1051
+ ok(reason);
1052
+ }
1053
+ }
913
1054
  unzipInto(zipPath, cacheDir);
914
- if (downloaded) {
915
- try { fs.rmSync(zipPath, { force: true }); } catch { /* leave temp behind, not fatal */ }
1055
+ if (downloaded && tmpDir) {
1056
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* leave temp behind, not fatal */ }
916
1057
  }
917
1058
  }
918
1059
 
@@ -920,7 +1061,7 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
920
1061
  const plugin = wirePlugin();
921
1062
  if (!FLAG_NO_VERIFY) {
922
1063
  verifyInstall(cacheDir);
923
- smokeQuery(cacheDir);
1064
+ await smokeQuery(cacheDir);
924
1065
  }
925
1066
 
926
1067
  // ── onboarding: detect the toolkit + make offers (all optional, all non-fatal) ──
package/package.json CHANGED
@@ -1,11 +1,25 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "0.5.1-dev",
3
+ "version": "1.14.0-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": {
7
7
  "ruvnet-brain": "bin/install.mjs"
8
8
  },
9
+ "scripts": {
10
+ "test": "node plugin/test/run-tests.mjs",
11
+ "version:check": "node scripts/sync-version.mjs --check",
12
+ "version:sync": "node scripts/sync-version.mjs",
13
+ "test:unit": "vitest run",
14
+ "test:cov": "vitest run --coverage",
15
+ "metaharness:fix": "node scripts/fix-metaharness-memretrieve.mjs --apply",
16
+ "metaharness:check": "node scripts/fix-metaharness-memretrieve.mjs --check",
17
+ "eval": "node scripts/eval-brain.mjs",
18
+ "eval:gate": "node scripts/eval-brain.mjs --gate",
19
+ "eval:record": "node scripts/eval-brain.mjs --record",
20
+ "embed:verifier": "node scripts/embed-verifier.mjs",
21
+ "embed:check": "node scripts/embed-verifier.mjs --check"
22
+ },
9
23
  "files": [
10
24
  "bin/install.mjs",
11
25
  "README.md",
@@ -34,5 +48,9 @@
34
48
  },
35
49
  "homepage": "https://github.com/stuinfla/ruvnet-brain#readme",
36
50
  "bugs": "https://github.com/stuinfla/ruvnet-brain/issues",
37
- "author": "Stuart Kerr"
51
+ "author": "Stuart Kerr",
52
+ "devDependencies": {
53
+ "@vitest/coverage-v8": "^4.1.10",
54
+ "vitest": "^4.1.10"
55
+ }
38
56
  }