ruvnet-brain 1.6.2-dev β†’ 1.16.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 +46 -12
  2. package/bin/install.mjs +341 -26
  3. package/package.json +27 -3
package/README.md CHANGED
@@ -4,29 +4,33 @@
4
4
 
5
5
  # 🧠 RuvNet Brain
6
6
 
7
- ### RuvNet Brain version 1.6.2-dev β€” updated 2026-07-05
7
+ ### 🧠 RuvNet Brain β€” [![RuvNet Brain version 1.16.0-dev β€” updated 2026-07-09 04:53 EDT](https://img.shields.io/badge/version_1.16.0--dev-updated_2026--07--09_04:53_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
8
8
 
9
9
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β€” delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
10
10
 
11
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
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
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-ruvnet--brain.vercel.app-e8a13a?style=flat-square)](https://ruvnet-brain.vercel.app)
14
+ [![explainer](https://img.shields.io/badge/β–Ά%20see%20it%20live-isovision.ai%2Fruvnet--brain-e8a13a?style=flat-square)](https://isovision.ai/ruvnet-brain/)
15
15
  [![license](https://img.shields.io/badge/license-MIT-8ecae6?style=flat-square)](LICENSE)
16
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-10%25%20of%20ALL%20source%20Β·%20honest-b58900?style=flat-square)](#testing--proof)
17
18
 
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:**
19
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.
20
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.
21
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.
23
+ > - **Update an installed brain once:** `npx ruvnet-brain --update` β€” runs the bundle's own self-updater (backs up first, re-verifies, fails loud instead of half-applying).
24
+ > - **Nightly auto-update is OFF by default:** `npx ruvnet-brain --enable-nightly` schedules it (macOS LaunchAgent, 03:47); `npx ruvnet-brain --disable-nightly` removes it; Linux/Windows get the cron line documented in the bundle's `forge-update.mjs`.
25
+ > - **Either way, your copy only advances when a new Release is published** β€” the updater pulls `releases/latest`, so running it between releases is a safe no-op.
22
26
 
23
27
  <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>
24
28
 
25
- ### [β–Ά Come see it visually explained](https://ruvnet-brain.vercel.app)
29
+ ### [β–Ά Come see it visually explained](https://isovision.ai/ruvnet-brain/)
26
30
 
27
31
  <sub>An interactive, animated walkthrough of what you can actually build β€” **click the preview** to open it.</sub>
28
32
 
29
- [![The RuvNet Brain interactive explainer β€” click to open the visual walkthrough](assets/explainer-preview.png)](https://ruvnet-brain.vercel.app)
33
+ [![The RuvNet Brain interactive explainer β€” click to open the visual walkthrough](assets/explainer-preview.png)](https://isovision.ai/ruvnet-brain/)
30
34
 
31
35
  </div>
32
36
 
@@ -40,7 +44,7 @@ But **Claude was trained on _classical_ software development.** Point it at rUv'
40
44
 
41
45
  > **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.
42
46
 
43
- 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.**
47
+ 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.**
44
48
 
45
49
  ---
46
50
 
@@ -79,7 +83,7 @@ WITHOUT the brain β€” drift | WITH RuvNet Brain β€” grounded
79
83
  npx ruvnet-brain
80
84
  ```
81
85
 
82
- 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.
86
+ 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.
83
87
 
84
88
  > 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.
85
89
 
@@ -100,9 +104,21 @@ Registers the `search_ruvnet` MCP tool, the grounding skill, and the `UserPrompt
100
104
 
101
105
  ---
102
106
 
103
- ## ✨ What's new in v0.5.0-dev β€” it now knows the stack _down to the code_
107
+ ## Staying current β€” how updates work
104
108
 
105
- 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:
109
+ You install once. After that, three mechanisms keep you on the current brain without you having to remember an update command.
110
+
111
+ - **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).
112
+
113
+ - **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.
114
+
115
+ - **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.)
116
+
117
+ ---
118
+
119
+ ## ✨ What the knowledge bundle knows β€” the stack _down to the code_
120
+
121
+ 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:
106
122
 
107
123
  | Repo | Full-body code passages | |
108
124
  |---|---:|---|
@@ -130,7 +146,7 @@ The expensive work happens **once, at build time**: every covered repo is deep-w
130
146
 
131
147
  ## How it changes Claude's behavior β€” it takes the wheel
132
148
 
133
- 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.
149
+ 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.)
134
150
 
135
151
  ![The grounding flow: prompt to cited answer](assets/diagrams/grounding-flow.svg)
136
152
 
@@ -191,9 +207,27 @@ node plugin/test/run-tests.mjs # full plugin QA over real JSO
191
207
  | **L1–L4 behavioral harness** | **all pass** | route Β· deep-recall (returns _code_) Β· implement (cites the API) Β· orchestrate (the hook drives the full pipeline) |
192
208
  | **Plugin QA** | **26 / 26** | manifests, hook firing, MCP `initialize`/`tools/list`, capability battery |
193
209
  | **Clean-room install** | **3 / 3** | download the published 512 MB bundle fresh β†’ unzip β†’ query β†’ grounded, cited answers |
210
+ | **Unit tests** | **208 passing** Β· 10% of ALL source covered | `npm run test:cov` β€” the floor fails CI if it slips. 10% is the honest number over every shipped file; the previous "75%" measured a hand-picked 8-file subset |
211
+ | **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** |
212
+ | **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 |
213
+
214
+ <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>
194
215
 
195
216
  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).
196
217
 
218
+ ### The eval flywheel
219
+
220
+ 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:
221
+
222
+ - **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.
223
+ - **routed** β€” the citation came from a repo that actually owns the capability.
224
+
225
+ `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.
226
+
227
+ **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.
228
+
229
+ 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.
230
+
197
231
  **Query it directly (CLI):**
198
232
 
199
233
  ```bash
@@ -208,7 +242,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
208
242
 
209
243
  ## Honest status
210
244
 
211
- This is **`v0.5.0-dev`** β€” we don't claim β€œdone,” β€œcomplete,” or β€œzero hallucinations.” Where it stands:
245
+ 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:
212
246
 
213
247
  - βœ… **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.
214
248
  - βœ… **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).
@@ -225,7 +259,7 @@ This is **`v0.5.0-dev`** β€” we don't claim β€œdone,” β€œcomplete,” or β€œze
225
259
  - `plugin/` β€” the Claude Code plugin (MCP server, grounding skill, `UserPromptSubmit` enforcement hook, marketplace manifest, test suite).
226
260
  - `scripts/` β€” `gate.sh` (routing gate), `behavioral-l1-l4.mjs` (behavioral harness), `prove.mjs`, `build-bundle.mjs`, `brain-stamp.mjs`.
227
261
  - `docs/` β€” [`VISION.md`](docs/VISION.md) (the why), [`adr/`](docs/adr/) (locked decisions incl. ADR-0008), [`DDD.md`](docs/DDD.md).
228
- - `explainer/` β€” the source of the [live explainer](https://ruvnet-brain.vercel.app).
262
+ - `explainer/` β€” the source of the [live explainer](https://isovision.ai/ruvnet-brain/).
229
263
  - `SPEC.md` Β· `PROGRESS.md` β€” the master spec and the living, timestamped build log.
230
264
 
231
265
  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.
@@ -234,7 +268,7 @@ The brain binaries ship via the [Release](https://github.com/stuinfla/ruvnet-bra
234
268
 
235
269
  ## Links
236
270
 
237
- - **β–Ά Live explainer:** https://ruvnet-brain.vercel.app
271
+ - **β–Ά Live explainer:** https://isovision.ai/ruvnet-brain/
238
272
  - **Download / Release:** https://github.com/stuinfla/ruvnet-brain/releases/tag/v0.5.0-dev
239
273
  - **rUv's RuvNet org:** https://github.com/ruvnet
240
274
  - **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
 
@@ -39,6 +65,10 @@ const FLAG_DOCTOR = argv.includes('--doctor');
39
65
  const FLAG_NO_VERIFY = argv.includes('--no-verify');
40
66
  const FLAG_PIN = argv.includes('--pin'); // skip the latest-check, use the bundled default
41
67
  const FLAG_DEMO = argv.includes('--demo'); // guided, real (non-fabricated) walkthrough of the brain in action
68
+ // ── freshness flags β€” invoke/schedule the SELF-UPDATER the bundle already ships (kb/forge-update.mjs) ──
69
+ const FLAG_UPDATE = argv.includes('--update'); // one-shot: pull the latest Release bundle into the installed brain now
70
+ const FLAG_ENABLE_NIGHTLY = argv.includes('--enable-nightly'); // schedule that update nightly (macOS LaunchAgent)
71
+ const FLAG_DISABLE_NIGHTLY = argv.includes('--disable-nightly'); // remove the nightly schedule
42
72
  // ── onboarding-experience flags (all optional; every offer is safe to decline) ──
43
73
  const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
44
74
  const FLAG_WITH_STACK = argv.includes('--with-stack'); // add missing Ruflo/RuVector without prompting
@@ -288,19 +318,31 @@ async function obtainBundle(release) {
288
318
  );
289
319
  info(`version: ${c.bold((release && release.tag) || RELEASE_VERSION)}`);
290
320
  info(`from: ${downloadUrl}`);
291
- const tmp = path.join(os.tmpdir(), `ruvnet-brain-${process.pid}.zip`);
321
+ // Download into a PRIVATE, per-run temp DIR β€” never a predictable os.tmpdir()/ruvnet-brain-<pid>.zip
322
+ // filename (CWE-377: a guessable path invites a pre-created or symlinked file at that location to be
323
+ // clobbered, or the extraction target to be hijacked). mkdtempSync creates a fresh, unguessable,
324
+ // owner-only (0700) directory; we write the zip inside it. The exit handler guarantees the whole dir
325
+ // is removed on ANY exit β€” success, thrown error, or die()β†’process.exit β€” and the caller also removes
326
+ // it immediately on success so ~512MB isn't held for the rest of the install.
327
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-brain-'));
328
+ process.on('exit', () => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ } });
329
+ const tmp = path.join(tmpDir, ASSET_NAME);
292
330
  try {
293
331
  console.log(` downloading the brain (${APPROX_SIZE})…`);
294
332
  await download(downloadUrl, tmp);
295
333
  } catch (e) {
296
- try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ }
334
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
297
335
  die(
298
336
  `couldn't download the brain (${e.message}).`,
299
337
  `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
338
  );
301
339
  }
302
340
  ok(`downloaded to ${tmp}`);
303
- return { zipPath: tmp, downloaded: true };
341
+ // Best-effort fetch of the detached Ed25519 signature published alongside the asset (SEC-0010 #6).
342
+ // If present we verify it before extracting; if absent (a pre-signing release) we warn but proceed
343
+ // (transitional β€” see SIGNING_REQUIRED at the verify gate).
344
+ try { await download(`${downloadUrl}.sig`, `${tmp}.sig`); } catch { /* no published sig yet */ }
345
+ return { zipPath: tmp, tmpDir, downloaded: true };
304
346
  }
305
347
 
306
348
  // ── step: unzip into the cache dir (flattening the top-level ruvnet-brain/ folder) ───────────────
@@ -379,14 +421,34 @@ function installReader(cacheDir) {
379
421
  die(`\`npm\` isn't available, but the brain needs it for its reader.`, `Install Node.js (which includes npm) and re-run.`);
380
422
  }
381
423
  info('installing the local reader…');
424
+ // Prefer `npm ci` for a PINNED, reproducible install when the bundle shipped its lockfile
425
+ // (SEC-0010 #8 β€” otherwise every install did a fully unpinned resolve). Fall back to `npm i`
426
+ // for older bundles that predate the shipped lockfile.
427
+ const hasLock = fs.existsSync(path.join(cacheDir, 'package-lock.json'));
428
+ const npmArgs = hasLock
429
+ ? ['ci', '--no-audit', '--no-fund', '--loglevel=error']
430
+ : ['i', '--no-audit', '--no-fund', '--loglevel=error'];
382
431
  try {
383
- run('npm', ['i', '--no-audit', '--no-fund', '--loglevel=error'], {
432
+ run('npm', npmArgs, {
384
433
  cwd: cacheDir,
385
434
  // silence npm's "new version available" update-notifier so the narration stays clean
386
435
  env: { ...process.env, npm_config_update_notifier: 'false', npm_config_fund: 'false' },
387
436
  });
388
437
  } catch (e) {
389
- die(`the reader install failed (${e.message}).`, `Re-run after checking your network / npm setup.`);
438
+ // `npm ci` is strict (fails if lock and package.json disagree); fall back to `npm i` once
439
+ // rather than hard-failing a user's install on a lockfile mismatch.
440
+ if (hasLock) {
441
+ warn(`pinned install (npm ci) failed (${e.message}); retrying with npm i`);
442
+ try {
443
+ run('npm', ['i', '--no-audit', '--no-fund', '--loglevel=error'], {
444
+ cwd: cacheDir, env: { ...process.env, npm_config_update_notifier: 'false', npm_config_fund: 'false' },
445
+ });
446
+ } catch (e2) {
447
+ die(`the reader install failed (${e2.message}).`, `Re-run after checking your network / npm setup.`);
448
+ }
449
+ } else {
450
+ die(`the reader install failed (${e.message}).`, `Re-run after checking your network / npm setup.`);
451
+ }
390
452
  }
391
453
  ok('reader installed');
392
454
  }
@@ -455,7 +517,37 @@ function verifyInstall(cacheDir) {
455
517
  }
456
518
 
457
519
  // ── step: warm the model + prove grounding with one real question (best-effort, never fatal) ──────
458
- function smokeQuery(cacheDir) {
520
+ // ── BEGIN GENERATED: verify-citation.mjs (node scripts/embed-verifier.mjs) ──
521
+ const VERIFY_CITATION_B64 = 'IyEvdXNyL2Jpbi9lbnYgbm9kZQovLyB2ZXJpZnktY2l0YXRpb24ubWpzIOKAlCBkZWNpZGUgd2hldGhlciBhbiBhbnN3ZXIgaXMgR1JPVU5ERUQsIGJ5IGdyb3VuZCB0cnV0aCByYXRoZXIgdGhhbiBieSB2aWJlcy4KLy8KLy8gV0hZIFRISVMgRVhJU1RTCi8vIC0tLS0tLS0tLS0tLS0tLQovLyBUaGUgb2xkIGdyb3VuZGluZyBjaGVjayBhc2tlZDogZG9lcyB0aGUgYW5zd2VyIGNvbnRhaW4gdGhlIHN0cmluZyAicnZmIiBvciAicnV2ZWN0b3IiPyBBIG1vZGVsCi8vIHRoYXQgaGFsbHVjaW5hdGVkICJqdXN0IHVzZSBSVkYhIiB3aXRoIHplcm8gc291cmNlcyBwYXNzZWQuIFNvIGRpZCBhbiBhbnN3ZXIgY2l0aW5nIGEgZmlsZSB0aGF0Ci8vIGRvZXMgbm90IGV4aXN0LiBLZXl3b3JkIHByZXNlbmNlIGlzIG5vdCBldmlkZW5jZSDigJQgYW4gTExNIHBhbmVsIG9uY2Ugc2NvcmVkIGEgemVyby1jaXRhdGlvbgovLyBhbnN3ZXIgOTgvMTAwIG9uIHRoaXMgcmVwby4KLy8KLy8gQSBjaXRhdGlvbiBpcyBvbmx5IHJlYWwgaWYgaXQgUkVTT0xWRVM6IHRoZSByZXBvIG11c3QgYmUgYW4gaW5kZXhlZCBzdG9yZSBvbiBkaXNrLCBhbmQgdGhlIGNpdGVkCi8vIGRvY3VtZW50IHBhdGggbXVzdCBhcHBlYXIgYXMgdGhlIGBwYXRoYCBvZiBhbiBhY3R1YWwgcGFzc2FnZSBpbnNpZGUgdGhhdCBzdG9yZSdzIHBhc3NhZ2VzIGZpbGUuCi8vIFRoYXQgaXMgY2hlY2thYmxlIHdpdGhvdXQgYSBtb2RlbCwgd2l0aG91dCB0aGUgbmV0d29yaywgYW5kIHdpdGhvdXQgdHJ1c3RpbmcgYW55dGhpbmcgdGhlIG1vZGVsCi8vIHNhaWQuIFRoaXMgbW9kdWxlIGRvZXMgZXhhY3RseSB0aGF0IGFuZCBub3RoaW5nIGVsc2UuCi8vCi8vIFRoZSByZWFkZXIgKGBmb3JnZS1hc2stYWxsLm1qc2ApIHByaW50cyBlYWNoIGhpdCBhczoKLy8gICAgICMxICByZXBvPWNvbmNlcHRzICBjZT0wLjIwMSAgdmVjPTAuODY4NiAga2luZD1kb2MKLy8gICAgIHBhdGggOiBjb25jZXB0cy9ydXZlY3Rvci9DQVJEL3J1dmVjdG9yLWNhcmQKLy8gICAgIHRpdGxlOiBydXZlY3RvciDigJQgQ2FwYWJpbGl0eQovLyBOb3RlIHRoZSBwcmludGVkIHBhdGggaXMgYDxyZXBvPi88ZG9jUGF0aD5gOyBpbnNpZGUgYGNvbmNlcHRzLnBhc3NhZ2VzLmpzb25sYCB0aGUgc3RvcmVkIGBwYXRoYAovLyBpcyBqdXN0IGBydXZlY3Rvci9DQVJEL3J1dmVjdG9yLWNhcmRgIChvcHRpb25hbGx5IHN1ZmZpeGVkIGAjMGAsIGAjMWAsIOKApiB3aGVuIGNodW5rZWQpLgoKaW1wb3J0IGZzIGZyb20gJ25vZGU6ZnMnOwppbXBvcnQgcGF0aCBmcm9tICdub2RlOnBhdGgnOwppbXBvcnQgcmVhZGxpbmUgZnJvbSAnbm9kZTpyZWFkbGluZSc7CgovKiogUGFyc2UgdGhlIHJlYWRlcidzIHN0ZG91dCBpbnRvIHN0cnVjdHVyZWQgY2l0YXRpb25zLiBOZXZlciB0aHJvd3M7IHVucGFyc2VhYmxlIGlucHV0IOKGkiBbXS4gKi8KZXhwb3J0IGZ1bmN0aW9uIHBhcnNlQ2l0YXRpb25zKHN0ZG91dCkgewogIGNvbnN0IG91dCA9IFtdOwogIGNvbnN0IHRleHQgPSBTdHJpbmcoc3Rkb3V0ID8/ICcnKTsKICBjb25zdCBibG9ja1JlID0gL14jKFxkKylccytyZXBvPShcUyspKD86XHMrY2U9KC0/W1xkLl0rKSk/KD86XHMrdmVjPSgtP1tcZC5dKykpPyg/OlxzK2tpbmQ9KFxTKykpPy9nbTsKICBsZXQgbTsKICB3aGlsZSAoKG0gPSBibG9ja1JlLmV4ZWModGV4dCkpICE9PSBudWxsKSB7CiAgICBjb25zdCByZXN0ID0gdGV4dC5zbGljZShtLmluZGV4KTsKICAgIGNvbnN0IHBhdGhNID0gL15wYXRoXHMqOlxzKiguKykkL20uZXhlYyhyZXN0KTsKICAgIGNvbnN0IHRpdGxlTSA9IC9edGl0bGVccyo6XHMqKC4rKSQvbS5leGVjKHJlc3QpOwogICAgaWYgKCFwYXRoTSkgY29udGludWU7CiAgICBjb25zdCByZXBvID0gbVsyXTsKICAgIGNvbnN0IGZ1bGxQYXRoID0gcGF0aE1bMV0udHJpbSgpOwogICAgLy8gU3RyaXAgdGhlIHJlcG8gcHJlZml4IHRoZSByZWFkZXIgYWRkcywgc28gdGhlIHJlbWFpbmRlciBjYW4gYmUgbWF0Y2hlZCBhZ2FpbnN0IHRoZSBzdG9yZS4KICAgIGNvbnN0IGRvY1BhdGggPSBmdWxsUGF0aC5zdGFydHNXaXRoKGAke3JlcG99L2ApID8gZnVsbFBhdGguc2xpY2UocmVwby5sZW5ndGggKyAxKSA6IGZ1bGxQYXRoOwogICAgb3V0LnB1c2goewogICAgICByYW5rOiBOdW1iZXIobVsxXSksCiAgICAgIHJlcG8sCiAgICAgIGNlOiBtWzNdICE9PSB1bmRlZmluZWQgPyBOdW1iZXIobVszXSkgOiBudWxsLAogICAgICB2ZWM6IG1bNF0gIT09IHVuZGVmaW5lZCA/IE51bWJlcihtWzRdKSA6IG51bGwsCiAgICAgIGtpbmQ6IG1bNV0gPz8gbnVsbCwKICAgICAgZnVsbFBhdGgsCiAgICAgIGRvY1BhdGgsCiAgICAgIHRpdGxlOiB0aXRsZU0gPyB0aXRsZU1bMV0udHJpbSgpIDogbnVsbCwKICAgIH0pOwogIH0KICByZXR1cm4gb3V0Owp9CgovKiogVGhlIHBhc3NhZ2VzIGZpbGVzIHRoYXQgY291bGQgaG9sZCBhIHJlcG8ncyBkb2N1bWVudHMg4oCUIHRoZSBzbGltIHN0b3JlIGFuZCB0aGUgZGVlcCBgLmJpZ2Agb25lLiAqLwpleHBvcnQgZnVuY3Rpb24gcGFzc2FnZXNGaWxlc0ZvcihyZXBvLCBrYkRpcikgewogIHJldHVybiBbcGF0aC5qb2luKGtiRGlyLCBgJHtyZXBvfS5wYXNzYWdlcy5qc29ubGApLCBwYXRoLmpvaW4oa2JEaXIsIGAke3JlcG99LmJpZy5wYXNzYWdlcy5qc29ubGApXQogICAgLmZpbHRlcigocCkgPT4gZnMuZXhpc3RzU3luYyhwKSk7Cn0KCi8qKiBUcnVlIHdoZW4gYHN0b3JlZGAgaXMgdGhlIGNpdGVkIGRvYywgYWxsb3dpbmcgZm9yIHRoZSBgI05gIGNodW5rIHN1ZmZpeCB0aGUgYnVpbGRlciBhcHBlbmRzLiAqLwpmdW5jdGlvbiBzYW1lUGF0aChzdG9yZWQsIGRvY1BhdGgpIHsKICByZXR1cm4gc3RvcmVkID09PSBkb2NQYXRoIHx8IHN0b3JlZC5zdGFydHNXaXRoKGAke2RvY1BhdGh9I2ApOwp9CgovKioKICogRG9lcyB0aGlzIGNpdGF0aW9uIHBvaW50IGF0IGEgcGFzc2FnZSB0aGF0IHJlYWxseSBleGlzdHMgb24gZGlzaz8KICogU3RyZWFtcyB0aGUgZmlsZSBhbmQgc3RvcHMgYXQgdGhlIGZpcnN0IG1hdGNoLCBzbyBhIDUwME1CIGAuYmlnYCBzdG9yZSBjb3N0cyBvbmx5IGFzIG11Y2ggYXMgaXQKICogdGFrZXMgdG8gcmVhY2ggdGhlIGhpdC4gQSBtYWxmb3JtZWQgSlNPTiBsaW5lIGlzIHNraXBwZWQsIG5ldmVyIGZhdGFsLgogKi8KZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGNpdGF0aW9uUmVzb2x2ZXMoY2l0YXRpb24sIGtiRGlyKSB7CiAgY29uc3QgZmlsZXMgPSBwYXNzYWdlc0ZpbGVzRm9yKGNpdGF0aW9uLnJlcG8sIGtiRGlyKTsKICBpZiAoIWZpbGVzLmxlbmd0aCkgcmV0dXJuIHsgcmVzb2x2ZWQ6IGZhbHNlLCByZWFzb246ICduby1zdG9yZScsIGZpbGU6IG51bGwsIHN0b3JlZFBhdGg6IG51bGwgfTsKICBmb3IgKGNvbnN0IGZpbGUgb2YgZmlsZXMpIHsKICAgIGNvbnN0IHJsID0gcmVhZGxpbmUuY3JlYXRlSW50ZXJmYWNlKHsgaW5wdXQ6IGZzLmNyZWF0ZVJlYWRTdHJlYW0oZmlsZSksIGNybGZEZWxheTogSW5maW5pdHkgfSk7CiAgICB0cnkgewogICAgICBmb3IgYXdhaXQgKGNvbnN0IGxpbmUgb2YgcmwpIHsKICAgICAgICBpZiAoIWxpbmUpIGNvbnRpbnVlOwogICAgICAgIGxldCByZWM7CiAgICAgICAgdHJ5IHsgcmVjID0gSlNPTi5wYXJzZShsaW5lKTsgfSBjYXRjaCB7IGNvbnRpbnVlOyB9CiAgICAgICAgaWYgKHR5cGVvZiByZWM/LnBhdGggPT09ICdzdHJpbmcnICYmIHNhbWVQYXRoKHJlYy5wYXRoLCBjaXRhdGlvbi5kb2NQYXRoKSkgewogICAgICAgICAgcmV0dXJuIHsgcmVzb2x2ZWQ6IHRydWUsIHJlYXNvbjogJ29rJywgZmlsZTogcGF0aC5iYXNlbmFtZShmaWxlKSwgc3RvcmVkUGF0aDogcmVjLnBhdGggfTsKICAgICAgICB9CiAgICAgIH0KICAgIH0gZmluYWxseSB7CiAgICAgIHJsLmNsb3NlKCk7CiAgICB9CiAgfQogIHJldHVybiB7IHJlc29sdmVkOiBmYWxzZSwgcmVhc29uOiAncGF0aC1ub3QtaW4tc3RvcmUnLCBmaWxlOiBudWxsLCBzdG9yZWRQYXRoOiBudWxsIH07Cn0KCi8qKgogKiBUaGUgZ2F0ZS4gQW4gYW5zd2VyIGlzIGdyb3VuZGVkIG9ubHkgd2hlbiBpdCBjaXRlcyBhdCBsZWFzdCBvbmUgcGFzc2FnZSB0aGF0IHJlc29sdmVzIG9uIGRpc2suCiAqIFJldHVybnMgdGhlIHJlY2VpcHQgc28gYSBjYWxsZXIgY2FuIFBSSU5UIHRoZSBldmlkZW5jZSBpbnN0ZWFkIG9mIGFzc2VydGluZyBhIGNvbmNsdXNpb24uCiAqLwpleHBvcnQgYXN5bmMgZnVuY3Rpb24gdmVyaWZ5R3JvdW5kaW5nKHN0ZG91dCwga2JEaXIpIHsKICBjb25zdCBjaXRhdGlvbnMgPSBwYXJzZUNpdGF0aW9ucyhzdGRvdXQpOwogIGlmICghY2l0YXRpb25zLmxlbmd0aCkgewogICAgcmV0dXJuIHsgZ3JvdW5kZWQ6IGZhbHNlLCByZWFzb246ICduby1jaXRhdGlvbnMnLCBjaXRhdGlvbnM6IFtdLCByZWNlaXB0OiBudWxsIH07CiAgfQogIGZvciAoY29uc3QgY2l0YXRpb24gb2YgY2l0YXRpb25zKSB7CiAgICBjb25zdCByID0gYXdhaXQgY2l0YXRpb25SZXNvbHZlcyhjaXRhdGlvbiwga2JEaXIpOwogICAgaWYgKHIucmVzb2x2ZWQpIHsKICAgICAgcmV0dXJuIHsKICAgICAgICBncm91bmRlZDogdHJ1ZSwKICAgICAgICByZWFzb246ICdvaycsCiAgICAgICAgY2l0YXRpb25zLAogICAgICAgIHJlY2VpcHQ6IHsgcmVwbzogY2l0YXRpb24ucmVwbywgcGF0aDogY2l0YXRpb24uZnVsbFBhdGgsIHRpdGxlOiBjaXRhdGlvbi50aXRsZSwgZmlsZTogci5maWxlLCBzdG9yZWRQYXRoOiByLnN0b3JlZFBhdGggfSwKICAgICAgfTsKICAgIH0KICB9CiAgcmV0dXJuIHsgZ3JvdW5kZWQ6IGZhbHNlLCByZWFzb246ICdjaXRhdGlvbnMtZG8tbm90LXJlc29sdmUnLCBjaXRhdGlvbnMsIHJlY2VpcHQ6IG51bGwgfTsKfQo=';
522
+ // ── END GENERATED ──
523
+
524
+ // The verifier belongs next to the data it verifies, so it lives in the KB. But every bundle
525
+ // published before 2026-07-09 predates it, and telling those users "grounding not verifiable β€”
526
+ // re-run the installer" would send them in a circle, because re-running fetches the same bundle.
527
+ // So the installer CARRIES the verifier and writes it in when it's missing. A newer bundle's copy
528
+ // always wins: we never overwrite a file the bundle shipped.
529
+ function ensureVerifier(cacheDir) {
530
+ const p = path.join(cacheDir, 'verify-citation.mjs');
531
+ if (fs.existsSync(p)) return 'from-bundle';
532
+ if (!VERIFY_CITATION_B64) return 'unavailable';
533
+ try {
534
+ fs.writeFileSync(p, Buffer.from(VERIFY_CITATION_B64, 'base64').toString('utf8'), 'utf8');
535
+ return 'installed';
536
+ } catch { return 'unavailable'; }
537
+ }
538
+
539
+ async function loadCitationVerifier(cacheDir) {
540
+ ensureVerifier(cacheDir);
541
+ const p = path.join(cacheDir, 'verify-citation.mjs');
542
+ if (!fs.existsSync(p)) return null;
543
+ try { return await import(pathToFileURL(p).href); } catch { return null; }
544
+ }
545
+
546
+ // Proving grounding means proving the answer's CITATION RESOLVES β€” that the file it points at is a
547
+ // real, indexed passage on this disk. The old check here tested `/rvf|ruvector|hnsw/` against the
548
+ // answer text, which a hallucinated "just use RVF!" passes with zero sources. Keyword presence is
549
+ // not evidence. We now print the cited path as a receipt, so you can go look at it yourself.
550
+ async function smokeQuery(cacheDir) {
459
551
  const ask = path.join(cacheDir, 'forge-ask-all.mjs');
460
552
  if (!fs.existsSync(ask)) return { ran: false };
461
553
  step(
@@ -465,6 +557,7 @@ function smokeQuery(cacheDir) {
465
557
  const Q = 'How should I store embeddings in this project without running a server?';
466
558
  info(`Q: ${c.cyan(`"${Q}"`)}`);
467
559
  info(c.dim('(first run downloads a small local model once β€” this can take a minute)'));
560
+ const started = Date.now();
468
561
  let r;
469
562
  try {
470
563
  // Relative filename + matching cwd (NOT the absolute `ask` path) β€” forge-ask-all.mjs only runs
@@ -472,7 +565,7 @@ function smokeQuery(cacheDir) {
472
565
  // absolute path via spawnSync (no shell involved) that identity check silently fails on this
473
566
  // machine, so main() never runs β€” exit 0, zero stdout, zero stderr, no exception. Looks like a
474
567
  // 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'], {
568
+ r = spawnSync('node', ['forge-ask-all.mjs', '--dir', cacheDir, '--q', Q, '--k', '3'], {
476
569
  cwd: cacheDir,
477
570
  encoding: 'utf8',
478
571
  timeout: 240000,
@@ -482,15 +575,34 @@ function smokeQuery(cacheDir) {
482
575
  warn("skipped the live test (couldn't launch the reader) β€” it'll warm on your first real question");
483
576
  return { ran: false };
484
577
  }
578
+ const secs = ((Date.now() - started) / 1000).toFixed(1);
485
579
  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 };
580
+ if (r.status !== 0 || !out.trim()) {
581
+ warn('no answer came back (first-run model download or offline) β€” the brain is installed; it\'ll warm on your first real question');
582
+ return { ran: true, grounded: false, reason: 'no-answer' };
583
+ }
584
+
585
+ const verifier = await loadCitationVerifier(cacheDir);
586
+ if (!verifier) {
587
+ info(`the brain answered in ${secs}s, but this bundle predates the citation verifier β€”`);
588
+ info(c.dim(' re-run `npx ruvnet-brain` to refresh it, and grounding will be PROVEN, not assumed'));
589
+ return { ran: true, grounded: null, reason: 'verifier-missing' };
590
+ }
591
+
592
+ const v = await verifier.verifyGrounding(out, cacheDir);
593
+ if (v.grounded) {
594
+ ok(`grounded in rUv's real source β€” verified in ${secs}s, not guessed ✦`);
595
+ console.log(` ${c.dim('cited:')} ${c.bold(v.receipt.path)}`);
596
+ if (v.receipt.title) console.log(` ${c.dim('title:')} ${v.receipt.title}`);
597
+ console.log(` ${c.dim('verified:')} that passage really exists in ${c.bold(v.receipt.file)}`);
598
+ return { ran: true, grounded: true, receipt: v.receipt, secs };
489
599
  }
490
600
  warn(
491
- "skipped the live test (first-run model download or offline) β€” the brain is installed; it'll warm on your first real question",
601
+ v.reason === 'no-citations'
602
+ ? 'the answer cited no source at all β€” NOT grounded (re-run the installer to repair the KB)'
603
+ : "the answer's citations don't resolve to any indexed passage β€” NOT grounded (KB may be corrupt; re-run the installer)",
492
604
  );
493
- return { ran: true, grounded: false };
605
+ return { ran: true, grounded: false, reason: v.reason };
494
606
  }
495
607
 
496
608
  // ── `--demo`: a guided, REAL walkthrough β€” proves grounding live, never fabricates output ─────────
@@ -556,7 +668,7 @@ function runDemo() {
556
668
  }
557
669
 
558
670
  // ── `--doctor`: a standalone health check the user can run any time ───────────────────────────────
559
- function doctor() {
671
+ async function doctor() {
560
672
  printBanner('doctor');
561
673
  console.log(c.dim('Checking every part of the install and reporting green/red.\n'));
562
674
  const cacheDir = process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
@@ -583,13 +695,25 @@ function doctor() {
583
695
  ? ok('RuVector present β€” vector CLI / MCP available')
584
696
  : warn('RuVector not found β€” answers still work. To add: claude mcp add ruvector --scope user -- npx -y ruvector mcp start');
585
697
  const v = verifyInstall(cacheDir);
586
- smokeQuery(cacheDir);
698
+ const smoke = await smokeQuery(cacheDir);
587
699
  const allGreen = v.repos > 0 && v.reader && v.mcp;
588
700
  console.log(
589
701
  `\n ${allGreen ? c.green('βœ“ Healthy.') : c.yellow('! Needs attention.')} ${
590
702
  allGreen ? 'The brain is installed and reachable.' : 'Re-run the installer to fix the warnings above.'
591
703
  }`,
592
704
  );
705
+ // Installed-and-reachable and actually-grounded are different claims. Keep them separate, so a
706
+ // healthy install can never be mistaken for proven grounding.
707
+ if (smoke.grounded === true) {
708
+ console.log(` ${c.green('βœ“ Grounding PROVEN.')} It answered from ${c.bold(smoke.receipt.path)} β€” a passage that`);
709
+ console.log(` really exists in your local KB. Checked in ${smoke.secs}s, no cloud, no API key.`);
710
+ } else if (smoke.grounded === false) {
711
+ console.log(` ${c.yellow('! Grounding NOT proven')} (${smoke.reason}). The install is present but the brain did not`);
712
+ console.log(' answer from a verifiable source. Re-run npx ruvnet-brain to repair the KB.');
713
+ } else if (smoke.grounded === null) {
714
+ console.log(` ${c.yellow('! Grounding not verifiable')} on this bundle β€” it predates the citation verifier.`);
715
+ console.log(' Re-run npx ruvnet-brain to refresh, then --doctor will prove it.');
716
+ }
593
717
  if (allGreen) {
594
718
  console.log(`\n ${c.bold('What this means for you:')}`);
595
719
  console.log(` β€’ ${c.bold('It works in EVERY project')} β€” user-level (global). Open Claude Code in any repo or VS Code`);
@@ -605,6 +729,163 @@ function doctor() {
605
729
  );
606
730
  }
607
731
 
732
+ // ── `--update` / `--enable-nightly` / `--disable-nightly`: end-user freshness controls ────────────
733
+ // The brain bundle SHIPS its own self-updater (forge-update.mjs, right in the KB dir): it pulls the
734
+ // canonical Release bundle, backs the current copy up, extracts, and re-verifies with forge-guard β€”
735
+ // failing loud with no partial clobber. These flags never reimplement any of that; they only INVOKE
736
+ // it once (--update) or SCHEDULE it per-user (--enable-nightly). Nothing here ever publishes.
737
+ const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
738
+ const resolvedKbDir = () =>
739
+ process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
740
+ const nightlyPlistPath = () =>
741
+ path.join(os.homedir(), 'Library', 'LaunchAgents', `${NIGHTLY_LABEL}.plist`);
742
+ // RUVNET_BRAIN_TEST=1 β†’ write/remove the plist but NEVER call launchctl. Tests point HOME at a temp
743
+ // dir; bootstrapping a temp-dir plist into the user's real gui domain would mutate exactly the
744
+ // system state the tests promise not to touch.
745
+ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
746
+ const xmlEscape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
747
+
748
+ // The same nightly command, cron-flavored β€” the pattern forge-update.mjs documents in its header.
749
+ // 03:47 on purpose: an off-hour minute, so it never piles onto the :00 cron rush.
750
+ const cronExample = (kbDir) =>
751
+ `47 3 * * * cd ${kbDir} && ${process.execPath} forge-update.mjs --apply >> ${kbDir}/update.log 2>&1`;
752
+
753
+ function missingUpdaterHelp(kbDir) {
754
+ console.error(`\n${c.red('βœ— can\'t update:')} ${c.bold('forge-update.mjs')} is missing from ${kbDir}.`);
755
+ console.error(` Either no brain is installed there, or the bundle predates the self-updater.`);
756
+ console.error(` Fix: re-run the installer β€” ${c.bold('npx ruvnet-brain')} ${c.dim('(add --force if a brain is already present)')}`);
757
+ console.error(` β€” the current bundle ships forge-update.mjs; then this command will work.`);
758
+ }
759
+
760
+ function runUpdate() {
761
+ printBanner('update');
762
+ const kbDir = resolvedKbDir();
763
+ info(`brain dir: ${c.bold(kbDir)}`);
764
+ if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
765
+ missingUpdaterHelp(kbDir);
766
+ process.exit(1);
767
+ }
768
+ info(c.dim("running the bundle's own self-updater (backs up first, re-verifies, never half-applies)…\n"));
769
+ // Relative filename + matching cwd β€” same launch convention as smokeQuery(); stdio:'inherit'
770
+ // streams the updater's narration live and unedited.
771
+ const r = spawnSync(process.execPath, ['forge-update.mjs', '--apply'], { cwd: kbDir, stdio: 'inherit' });
772
+ if (r.error) {
773
+ console.error(`\n${c.red('βœ— update failed to launch:')} ${r.error.message}`);
774
+ process.exit(1);
775
+ }
776
+ process.exit(r.status === null ? 1 : r.status); // exit with the updater's own verdict
777
+ }
778
+
779
+ function enableNightly() {
780
+ printBanner('enable nightly updates');
781
+ const kbDir = resolvedKbDir();
782
+
783
+ if (process.platform !== 'darwin') {
784
+ info('The LaunchAgent scheduler is macOS-only (for now).');
785
+ info("On this platform, schedule the bundle's self-updater with cron β€” the pattern");
786
+ info('forge-update.mjs itself documents:');
787
+ console.log(`\n ${c.bold(cronExample(kbDir))}\n`);
788
+ info(`(${c.bold('crontab -e')}, paste the line, save. Remove the line to disable.)`);
789
+ return; // exit 0 β€” the user got the working recipe
790
+ }
791
+
792
+ info(`brain dir: ${c.bold(kbDir)}`);
793
+ if (!fs.existsSync(path.join(kbDir, 'forge-update.mjs'))) {
794
+ // Refuse to schedule a job that is guaranteed to fail every night β€” fail loud NOW instead.
795
+ missingUpdaterHelp(kbDir);
796
+ process.exit(1);
797
+ }
798
+
799
+ // Template the plist to THIS user's kb dir + node binary. Quotes guard paths with spaces;
800
+ // xmlEscape guards the XML (>> and && must survive as shell operators after plist parsing).
801
+ const logPath = path.join(kbDir, 'update.log');
802
+ const shellCmd = `cd "${kbDir}" && "${process.execPath}" forge-update.mjs --apply >> "${logPath}" 2>&1`;
803
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
804
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
805
+ <plist version="1.0">
806
+ <dict>
807
+ <key>Label</key>
808
+ <string>${NIGHTLY_LABEL}</string>
809
+ <key>ProgramArguments</key>
810
+ <array>
811
+ <string>/bin/sh</string>
812
+ <string>-c</string>
813
+ <string>${xmlEscape(shellCmd)}</string>
814
+ </array>
815
+ <key>StartCalendarInterval</key>
816
+ <dict>
817
+ <key>Hour</key>
818
+ <integer>3</integer>
819
+ <key>Minute</key>
820
+ <integer>47</integer>
821
+ </dict>
822
+ <key>RunAtLoad</key>
823
+ <false/>
824
+ </dict>
825
+ </plist>
826
+ `;
827
+ const plistPath = nightlyPlistPath();
828
+ try {
829
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
830
+ fs.writeFileSync(plistPath, plist);
831
+ } catch (e) {
832
+ console.error(`\n${c.red('βœ— couldn\'t write the LaunchAgent:')} ${e.message}`);
833
+ process.exit(1);
834
+ }
835
+ ok(`wrote ${c.bold(plistPath)}`);
836
+ info(c.dim('runs nightly at 03:47 β€” an off-hour minute, so it never lands on the :00 rush'));
837
+
838
+ if (TEST_MODE) {
839
+ warn('RUVNET_BRAIN_TEST=1 β€” skipping launchctl bootout/bootstrap (plist written only)');
840
+ } else {
841
+ const uid = process.getuid();
842
+ // bootout first so re-running replaces the loaded job cleanly; failure just means "wasn't loaded".
843
+ spawnSync('launchctl', ['bootout', `gui/${uid}/${NIGHTLY_LABEL}`], { stdio: 'ignore' });
844
+ const boot = spawnSync('launchctl', ['bootstrap', `gui/${uid}`, plistPath], { encoding: 'utf8' });
845
+ if (boot.status === 0) ok('LaunchAgent loaded β€” your brain now updates while you sleep');
846
+ else {
847
+ warn(`launchctl bootstrap failed (${(boot.stderr || '').trim() || `exit ${boot.status}`}) β€” the plist is in place;`);
848
+ info(`load it yourself: ${c.bold(`launchctl bootstrap gui/${uid} ${plistPath}`)}`);
849
+ }
850
+ }
851
+
852
+ console.log(`\n ${c.bold('Verify it:')} launchctl list | grep ${NIGHTLY_LABEL}`);
853
+ console.log(` ${c.bold('Watch it:')} tail ${logPath} ${c.dim('(appears after the first nightly run)')}`);
854
+ console.log(` ${c.bold('Disable it:')} npx ruvnet-brain --disable-nightly`);
855
+ console.log(`\n ${c.dim('It only ever PULLS the published Release bundle (backup + re-verify built in) β€” it never publishes,')}`);
856
+ console.log(` ${c.dim('and a night with no new Release is a clean no-op.')}\n`);
857
+ }
858
+
859
+ function disableNightly() {
860
+ printBanner('disable nightly updates');
861
+ if (process.platform !== 'darwin') {
862
+ info('The LaunchAgent nightly is macOS-only, so nothing was scheduled here by this tool.');
863
+ info(`If you added the cron line yourself, remove it with: ${c.bold('crontab -e')}`);
864
+ return;
865
+ }
866
+ const plistPath = nightlyPlistPath();
867
+ const existed = fs.existsSync(plistPath);
868
+ if (TEST_MODE) {
869
+ warn('RUVNET_BRAIN_TEST=1 β€” skipping launchctl bootout (plist removal only)');
870
+ } else {
871
+ // Ignore failure: "not loaded" is exactly the state we want anyway.
872
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${NIGHTLY_LABEL}`], { stdio: 'ignore' });
873
+ }
874
+ if (existed) {
875
+ try {
876
+ fs.rmSync(plistPath);
877
+ } catch (e) {
878
+ console.error(`\n${c.red('βœ— couldn\'t remove the LaunchAgent:')} ${e.message}`);
879
+ console.error(` Remove it yourself: rm ${plistPath}`);
880
+ process.exit(1);
881
+ }
882
+ ok(`nightly updates disabled β€” removed ${plistPath}`);
883
+ } else {
884
+ ok('nightly updates were already off β€” nothing to remove (safe to run any time)');
885
+ }
886
+ info(`re-enable any time: ${c.bold('npx ruvnet-brain --enable-nightly')}`);
887
+ }
888
+
608
889
  // ── tiny interactive yes/no β€” SAFE in non-TTY (returns the default; never blocks a piped install) ──
609
890
  function ask(question, def = false) {
610
891
  if (FLAG_YES) return Promise.resolve(true);
@@ -829,9 +1110,14 @@ function success({ cacheDir, isCustom, plugin, env }) {
829
1110
  β€’ Want to see it answer, live, right now? ${c.bold('npx ruvnet-brain --demo')} β€” 2 real questions, real cited answers.`);
830
1111
 
831
1112
  console.log(`\n ${c.bold('Set it up your way:')} this default is ${c.bold('global')} β€” live in every VS Code project automatically,`);
832
- console.log(` which is what most people want. Want it different (project-only, moved, with the build stack added,`);
833
- console.log(` auto-updating nightly)? ${c.bold('Just tell Claude')} once it's on β€” the brain is smart enough to reconfigure`);
834
- console.log(` itself. You never have to learn its internals.`);
1113
+ console.log(` which is what most people want. Want it different (project-only, moved, with the build stack`);
1114
+ console.log(` added)? ${c.bold('Just tell Claude')} once it's on. You never have to learn its internals.`);
1115
+
1116
+ console.log(`\n ${c.bold('Staying current:')} nightly updates are ${c.bold('OFF by default')} β€” nothing runs on your machine unasked.`);
1117
+ console.log(` β€’ one-shot check + update now: ${c.bold('npx ruvnet-brain --update')}`);
1118
+ console.log(` β€’ nightly at 03:47 (macOS): ${c.bold('npx ruvnet-brain --enable-nightly')} ${c.dim('Β· off again: --disable-nightly')}`);
1119
+ console.log(` β€’ Linux/Windows: the cron line documented in the brain's own ${c.bold('forge-update.mjs')}`);
1120
+ console.log(` ${c.dim('Either way, your copy only advances when a new Release is actually published.')}`);
835
1121
 
836
1122
  console.log(`\n ${c.dim('You can\'t break anything β€” the plugin is disable-able and only acts on RuvNet-shaped work.')}`);
837
1123
  console.log('');
@@ -849,6 +1135,11 @@ Usage:
849
1135
  npx github:stuinfla/ruvnet-brain Same, but from the bleeding-edge GitHub commit
850
1136
  npx ruvnet-brain --doctor Health-check an existing install (green/red per part)
851
1137
  npx ruvnet-brain --demo Guided walkthrough β€” 2 real questions, real cited answers
1138
+ npx ruvnet-brain --update One-shot: pull the latest Release bundle into your installed brain
1139
+ (runs the bundle's own forge-update.mjs --apply: backup + re-verify)
1140
+ npx ruvnet-brain --enable-nightly Schedule that update nightly at 03:47 β€” macOS LaunchAgent;
1141
+ other platforms get the documented cron line. OFF by default.
1142
+ npx ruvnet-brain --disable-nightly Remove the nightly schedule (safe to run any time)
852
1143
  node bin/install.mjs --version <tag> Install a specific Release tag (e.g. --version v0.4.0-dev)
853
1144
  node bin/install.mjs --pin Skip the latest-check; use the bundled known-good version
854
1145
  node bin/install.mjs --local Install from a repo clone's dist/ruvnet-brain.zip
@@ -870,8 +1161,11 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
870
1161
  // ── main ─────────────────────────────────────────────────────────────────────────────────────────
871
1162
  (async () => {
872
1163
  if (FLAG_HELP) return showHelp();
873
- if (FLAG_DOCTOR) return doctor();
1164
+ if (FLAG_DOCTOR) return await doctor();
874
1165
  if (FLAG_DEMO) return runDemo();
1166
+ if (FLAG_UPDATE) return runUpdate();
1167
+ if (FLAG_ENABLE_NIGHTLY) return enableNightly();
1168
+ if (FLAG_DISABLE_NIGHTLY) return disableNightly();
875
1169
 
876
1170
  printBanner('installer');
877
1171
  console.log(c.dim("I'll set up the brain and the Claude Code plugin, explaining each step as I go.\n"));
@@ -909,10 +1203,31 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
909
1203
  const localZipPresent =
910
1204
  FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
911
1205
  const release = localZipPresent ? null : await resolveRelease();
912
- const { zipPath, downloaded } = await obtainBundle(release);
1206
+ const { zipPath, tmpDir, downloaded } = await obtainBundle(release);
1207
+ // Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
1208
+ // (SEC-0010 #6 β€” trust root = keys/ruvnet-brain-signing.pub.pem shipped inside this package).
1209
+ // SIGNING_REQUIRED is transitional: while releases predate signing, a MISSING sig warns-and-proceeds
1210
+ // but a PRESENT-but-INVALID sig ALWAYS fails closed. Flip to true once every release is signed.
1211
+ const SIGNING_REQUIRED = false;
1212
+ if (downloaded && !FLAG_NO_VERIFY) {
1213
+ const sigPath = `${zipPath}.sig`;
1214
+ const hasSig = fs.existsSync(sigPath);
1215
+ if (!hasSig && !SIGNING_REQUIRED) {
1216
+ warn('this release is not signed yet β€” proceeding (bundle integrity not cryptographically verified)');
1217
+ } else {
1218
+ step('Verifying the bundle signature', 'so a tampered or MITM-swapped download can never be extracted');
1219
+ const { ok: valid, reason } = verifyBundle(zipPath, sigPath);
1220
+ if (!valid) {
1221
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
1222
+ die(`bundle signature check FAILED β€” ${reason}`,
1223
+ `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')}.)`);
1224
+ }
1225
+ ok(reason);
1226
+ }
1227
+ }
913
1228
  unzipInto(zipPath, cacheDir);
914
- if (downloaded) {
915
- try { fs.rmSync(zipPath, { force: true }); } catch { /* leave temp behind, not fatal */ }
1229
+ if (downloaded && tmpDir) {
1230
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* leave temp behind, not fatal */ }
916
1231
  }
917
1232
  }
918
1233
 
@@ -920,7 +1235,7 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
920
1235
  const plugin = wirePlugin();
921
1236
  if (!FLAG_NO_VERIFY) {
922
1237
  verifyInstall(cacheDir);
923
- smokeQuery(cacheDir);
1238
+ await smokeQuery(cacheDir);
924
1239
  }
925
1240
 
926
1241
  // ── onboarding: detect the toolkit + make offers (all optional, all non-fatal) ──
package/package.json CHANGED
@@ -1,11 +1,30 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "1.6.2-dev",
4
- "description": "One-command installer for RuvNet Brain \u2014 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.",
3
+ "version": "1.16.0-dev",
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
+ "claims:verify": "node scripts/claims-verify.mjs",
13
+ "version:sync": "node scripts/sync-version.mjs",
14
+ "test:unit": "vitest run tests/unit",
15
+ "test:cov": "vitest run tests/unit --coverage",
16
+ "test:all": "npm run test:unit && npm run test:integration && npm test",
17
+ "metaharness:fix": "node scripts/fix-metaharness-memretrieve.mjs --apply",
18
+ "metaharness:check": "node scripts/fix-metaharness-memretrieve.mjs --check",
19
+ "eval": "node scripts/eval-brain.mjs",
20
+ "eval:gate": "node scripts/eval-brain.mjs --gate",
21
+ "eval:record": "node scripts/eval-brain.mjs --record",
22
+ "embed:verifier": "node scripts/embed-verifier.mjs",
23
+ "embed:check": "node scripts/embed-verifier.mjs --check",
24
+ "gists:index": "node scripts/ingest-gists.mjs --index-only",
25
+ "gists:sync": "node scripts/ingest-gists.mjs && node kb/forge-big.mjs both --dir kb --name ruv-gists",
26
+ "test:integration": "vitest run tests/integration"
27
+ },
9
28
  "files": [
10
29
  "bin/install.mjs",
11
30
  "README.md",
@@ -34,5 +53,10 @@
34
53
  },
35
54
  "homepage": "https://github.com/stuinfla/ruvnet-brain#readme",
36
55
  "bugs": "https://github.com/stuinfla/ruvnet-brain/issues",
37
- "author": "Stuart Kerr"
56
+ "author": "Stuart Kerr",
57
+ "devDependencies": {
58
+ "@metaharness/darwin": "~0.8.0",
59
+ "@vitest/coverage-v8": "^4.1.10",
60
+ "vitest": "^4.1.10"
61
+ }
38
62
  }