ruvnet-brain 3.9.76-dev โ†’ 3.9.81-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # ๐Ÿง  RuvNet Brain
6
6
 
7
- ### ๐Ÿง  RuvNet Brain โ€” [![RuvNet Brain version 3.9.76-dev โ€” updated 2026-07-26 03:16 EDT](https://img.shields.io/badge/version_3.9.76--dev-updated_2026--07--26_03:16_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
7
+ ### ๐Ÿง  RuvNet Brain โ€” [![RuvNet Brain version 3.9.81-dev โ€” updated 2026-07-24 16:09 EDT](https://img.shields.io/badge/version_3.9.81--dev-updated_2026--07--24_16:09_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
 
@@ -30,7 +30,7 @@
30
30
  [![explainer](https://img.shields.io/badge/โ–ถ%20see%20it%20live-isovision.ai%2Fruvnet--brain-e8a13a?style=flat-square)](https://isovision.ai/ruvnet-brain/)
31
31
  [![license](https://img.shields.io/badge/license-MIT-8ecae6?style=flat-square)](LICENSE)
32
32
  [![grounded](https://img.shields.io/badge/answers-cited%20rUv%20source-333?style=flat-square)](#testing--proof)
33
- [![coverage](https://img.shields.io/badge/coverage-14%25%20of%20ALL%20source%20ยท%20honest-b58900?style=flat-square)](#testing--proof)
33
+ [![coverage](https://img.shields.io/badge/coverage-26%25%20of%20ALL%20source%20ยท%20honest-b58900?style=flat-square)](#testing--proof)
34
34
 
35
35
  > **Three independent things version separately here โ€” by design, not drift. Headline claims are regenerated and checked by the claims ledger (`scripts/claims-verify.mjs`); other numbers below are hand-stamped and dated:**
36
36
  > - **`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.
@@ -484,7 +484,7 @@ node plugin/test/run-tests.mjs # full plugin QA over real JSO
484
484
  | **L1โ€“L4 behavioral harness** | **all pass** | route ยท deep-recall (returns _code_) ยท implement (cites the API) ยท orchestrate (the hook drives the full pipeline) |
485
485
  | **Plugin QA** | **26 / 26** | manifests, hook firing, MCP `initialize`/`tools/list`, capability battery |
486
486
  | **Clean-room install** | **3 / 3** | download the published bundle fresh โ†’ unzip โ†’ query โ†’ grounded, cited answers |
487
- | **Unit tests** | **548 passing, 169 todo** ยท 14% of ALL source covered | `npm run test:cov` regenerates both โ€” the coverage floor fails CI if it slips (`claims:verify` re-derives the %, it is not a hand-typed badge). 14% is the honest number over every shipped file; the previous "75%" measured a hand-picked 8-file subset |
487
+ | **Unit tests** | **548 passing, 169 todo** ยท 26% of ALL source covered | `npm run test:cov` regenerates both โ€” the coverage floor fails CI if it slips (`claims:verify` re-derives the %, it is not a hand-typed badge). 26% is the honest number over every shipped file; the previous "75%" measured a hand-picked 8-file subset |
488
488
  | **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** |
489
489
  | **Held-out eval** | **grounded 100/100** ยท routed 63/80 | `npm run eval` โ€” 120 frozen, hash-pinned questions across 5 strata, never used for tuning, graded on ground truth, never by a model |
490
490
 
package/bin/install.mjs CHANGED
@@ -614,6 +614,32 @@ export function codexStatus({ configPath = codexConfigPath(), codexDir = codexHo
614
614
  return { host: true, wired: Boolean(serverPath) && serverExists, serverPath, serverExists };
615
615
  }
616
616
 
617
+ // Atomic file replacement: produce the new bytes BESIDE the target, then rename() over it. Against
618
+ // process interruption (the issue #43 scenario) either the old file or the complete new file
619
+ // exists โ€” never a torn write. (Power-loss durability โ€” fsync of file and directory โ€” is
620
+ // deliberately out of scope for a config write.) On failure the temp is removed and the target is
621
+ // untouched; same-directory rename keeps it on one filesystem.
622
+ //
623
+ // rename() swaps INODES, so two properties of the old write-through path must be carried over
624
+ // explicitly: a symlinked target (a dotfiles-managed ~/.codex/config.toml) is resolved to its real
625
+ // file so the link survives and the bytes land where the user keeps them, and the target's mode is
626
+ // re-applied so a chmod-600 config never comes back world-readable.
627
+ function atomicReplace(targetPath, writeTmp) {
628
+ let target = targetPath;
629
+ try { target = fs.realpathSync(targetPath); } catch { /* target doesn't exist yet */ }
630
+ let mode = null;
631
+ try { mode = fs.statSync(target).mode & 0o777; } catch { /* first write โ€” default mode is fine */ }
632
+ const tmp = `${target}.tmp-${process.pid}`;
633
+ try {
634
+ writeTmp(tmp);
635
+ if (mode !== null) fs.chmodSync(tmp, mode);
636
+ fs.renameSync(tmp, target);
637
+ } catch (e) {
638
+ try { fs.rmSync(tmp, { force: true, recursive: true }); } catch { /* best effort */ }
639
+ throw e;
640
+ }
641
+ }
642
+
617
643
  export function wireCodexHost({
618
644
  codexDir = codexHomeDir(),
619
645
  configPath = path.join(codexDir, 'config.toml'),
@@ -635,7 +661,10 @@ export function wireCodexHost({
635
661
  }
636
662
  const serverPath = path.join(serverDir, 'server.mjs');
637
663
  fs.mkdirSync(serverDir, { recursive: true });
638
- fs.copyFileSync(source, serverPath);
664
+ // Write-beside-then-rename, both here and for the config below (issue #43): an interrupted plain
665
+ // copy leaves a TORN server.mjs at the exact path a prior install's config already points at, so
666
+ // Codex spawns half a file. rename() over the target is atomic; a failure leaves the old bytes.
667
+ atomicReplace(serverPath, (tmp) => fs.copyFileSync(source, tmp));
639
668
 
640
669
  let before = '';
641
670
  try { before = fs.readFileSync(configPath, 'utf8'); } catch { /* first run โ€” no config yet */ }
@@ -649,7 +678,7 @@ export function wireCodexHost({
649
678
  }
650
679
  if (text !== before) {
651
680
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
652
- fs.writeFileSync(configPath, text);
681
+ atomicReplace(configPath, (tmp) => fs.writeFileSync(tmp, text));
653
682
  }
654
683
  if (announce) {
655
684
  ok(`search_ruvnet registered for Codex โ†’ ${c.bold(configPath)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "3.9.76-dev",
3
+ "version": "3.9.81-dev",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,8 @@
37
37
  },
38
38
  "files": [
39
39
  "bin/install.mjs",
40
+ "plugin/mcp/server.mjs",
41
+ "!plugin/README.md",
40
42
  "README.md",
41
43
  "LICENSE",
42
44
  "config/model-router/",
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ // ruvnet-brain MCP server โ€” the Stable Spine's protocol shell (ADR-023 ยง4, v2 post-red-team).
3
+ //
4
+ // v1 was a launcher: spawn forge-mcp-all.mjs with stdio:'inherit' and get out of the way. That froze
5
+ // search_ruvnet's behavior at session start. v2 makes THIS process the stable protocol owner:
6
+ //
7
+ // โ€ข The CLIENT connection (Claude Code โ‡„ this process) is never proxied, dropped, or replayed.
8
+ // This file answers initialize / ping / tools/list itself and owns every client id.
9
+ // โ€ข The BRAIN runs in a warm CHILD (the KB's own forge-mcp-all.mjs, unchanged) that this process
10
+ // supervises over a PRIVATE handshake: parent sends its OWN initialize with parent-allocated
11
+ // ids and forwards tools/call with id remapping โ€” the client's handshake is never replayed to
12
+ // anyone (red-team findings 7/8: no external-handshake replay, ids remapped both directions).
13
+ // โ€ข HOT SWAP: between requests (never mid-flight โ€” finding 9: swap only when pendingCount === 0),
14
+ // the child is respawned when the spine generation changes (active.json) or the brain's own
15
+ // code file changed on disk (KB-track update). The NEXT call answers from the new brain; a call
16
+ // in flight completes on the old one. Claude Code notices nothing.
17
+ // โ€ข LEASE (finding 23): this process leases the generation it serves (leases/mcp-<pid>.json,
18
+ // refreshed per call) so update-apply.mjs --gc never collects a tree still being served.
19
+ // โ€ข Child death: pending calls get a JSON-RPC error (never a parent exit); the child respawns
20
+ // lazily on the next call. If the brain is absent entirely, tools/call returns an honest
21
+ // soft-error tool result with install guidance โ€” the tool stays registered, never vanishes.
22
+ //
23
+ // Brain location resolution (unchanged from v1):
24
+ // 1) $RUVNET_BRAIN_KB 2) $RUVNET_BRAIN_HOME/kb 3) ~/.cache/ruvnet-brain/kb
25
+ // Model cache: $KB_MODEL_CACHE, else <home>/models.
26
+ //
27
+ // SHELL CONTRACT: this file is boot-frozen (CC spawns it once per session). Changing IT โ€” or the
28
+ // tool's declared name/schema โ€” is a shell change, honestly flagged requiresRestart by the release
29
+ // classifier. Everything it delegates to updates hot.
30
+
31
+ import { spawn } from 'node:child_process';
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+ import os from 'node:os';
35
+ import readline from 'node:readline';
36
+
37
+ const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
38
+ const KB = process.env.RUVNET_BRAIN_KB || path.join(BRAIN_HOME, 'kb');
39
+ const CHILD_MCP = path.join(KB, 'forge-mcp-all.mjs');
40
+ const ACTIVE = path.join(BRAIN_HOME, 'active.json');
41
+ const LEASES = path.join(BRAIN_HOME, 'leases');
42
+ const LEASE = path.join(LEASES, `mcp-${process.pid}.json`);
43
+
44
+ const PROTOCOL_VERSION = '2024-11-05';
45
+ const SERVER_INFO = { name: 'ruvnet-brain', version: '2.0.0' };
46
+ // Static fallback tool declaration โ€” same name + inputSchema the brain declares (the SCHEMA is the
47
+ // frozen contract; the description is data and is refreshed from the live child when one is up).
48
+ const FALLBACK_TOOLS = [{
49
+ name: 'search_ruvnet',
50
+ description: 'Source-grounded knowledge base for the RuvNet ecosystem. (Brain bundle not installed on this machine โ€” calls will return install guidance.)',
51
+ inputSchema: {
52
+ type: 'object',
53
+ properties: {
54
+ query: { type: 'string', description: 'Natural-language question or keywords about any part of RuvNet.' },
55
+ k: { type: 'integer', description: 'Number of documents to return (default 6).', default: 6 },
56
+ },
57
+ required: ['query'],
58
+ },
59
+ }];
60
+
61
+ const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
62
+ const clientOk = (id, result) => out({ jsonrpc: '2.0', id, result });
63
+ const clientErr = (id, code, message) => out({ jsonrpc: '2.0', id, error: { code, message } });
64
+ const readJSON = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
65
+
66
+ function currentGeneration() {
67
+ const a = readJSON(ACTIVE);
68
+ let brainMtime = 0;
69
+ try { brainMtime = fs.statSync(CHILD_MCP).mtimeMs; } catch { /* brain absent */ }
70
+ return `${a?.generation ?? 0}:${brainMtime}`;
71
+ }
72
+ function refreshLease() {
73
+ try {
74
+ fs.mkdirSync(LEASES, { recursive: true });
75
+ fs.writeFileSync(LEASE, JSON.stringify({ pid: process.pid, version: readJSON(ACTIVE)?.version ?? null, at: new Date().toISOString() }));
76
+ } catch { /* lease is best-effort */ }
77
+ }
78
+ process.on('exit', () => { try { fs.rmSync(LEASE, { force: true }); } catch { /* gone */ } });
79
+
80
+ // โ”€โ”€ the warm child + private protocol โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
81
+ let child = null; // { proc, generation, nextId, pending: Map<childId, {resolve, reject}> }
82
+ let pendingCount = 0; // client tools/call requests currently in flight (drain gate for swaps)
83
+
84
+ function killChild(reason) {
85
+ if (!child) return;
86
+ const c = child; child = null;
87
+ for (const [, p] of c.pending) p.reject(new Error(`brain worker ${reason}`));
88
+ c.pending.clear();
89
+ try { c.proc.kill('SIGTERM'); } catch { /* already dead */ }
90
+ }
91
+
92
+ async function ensureChild() {
93
+ const gen = currentGeneration();
94
+ if (child && child.generation !== gen && pendingCount === 0) killChild('superseded by a newer generation');
95
+ if (child) return child;
96
+ if (!fs.existsSync(CHILD_MCP)) return null;
97
+
98
+ const env = { ...process.env, KB_DIR: KB };
99
+ if (!env.KB_MODEL_CACHE) env.KB_MODEL_CACHE = path.join(BRAIN_HOME, 'models');
100
+ const proc = spawn(process.execPath, [CHILD_MCP], { stdio: ['pipe', 'pipe', 'inherit'], env });
101
+ const c = { proc, generation: gen, nextId: 1, pending: new Map() };
102
+ const rl = readline.createInterface({ input: proc.stdout });
103
+ rl.on('line', (line) => {
104
+ let msg; try { msg = JSON.parse(line); } catch { return; }
105
+ const waiter = c.pending.get(msg.id);
106
+ if (waiter) { c.pending.delete(msg.id); waiter.resolve(msg); }
107
+ });
108
+ proc.on('exit', () => { if (child === c) child = null; for (const [, p] of c.pending) p.reject(new Error('brain worker exited')); c.pending.clear(); });
109
+ proc.on('error', () => { if (child === c) child = null; });
110
+ child = c;
111
+
112
+ // PRIVATE handshake โ€” parent-owned id, never the client's (finding 8).
113
+ try { await childRequest(c, 'initialize', { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'ruvnet-brain-shell', version: SERVER_INFO.version } }, 10_000); }
114
+ catch { killChild('failed initialize'); return null; }
115
+ return c;
116
+ }
117
+
118
+ function childRequest(c, method, params, timeoutMs = 120_000) {
119
+ return new Promise((resolve, reject) => {
120
+ const id = c.nextId++;
121
+ const timer = setTimeout(() => { c.pending.delete(id); reject(new Error(`brain worker timeout on ${method}`)); }, timeoutMs);
122
+ c.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolve(m); }, reject: (e) => { clearTimeout(timer); reject(e); } });
123
+ try { c.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); }
124
+ catch (e) { clearTimeout(timer); c.pending.delete(id); reject(e); }
125
+ });
126
+ }
127
+
128
+ // โ”€โ”€ client protocol โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
129
+ async function handleClient(msg) {
130
+ const { id, method, params } = msg;
131
+ if (id === undefined || id === null) return; // notifications need no answer
132
+ switch (method) {
133
+ case 'initialize':
134
+ return clientOk(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO });
135
+ case 'ping':
136
+ return clientOk(id, {});
137
+ case 'tools/list': {
138
+ const c = await ensureChild();
139
+ if (c) {
140
+ try { const r = await childRequest(c, 'tools/list', {}, 15_000); if (r.result?.tools?.length) return clientOk(id, r.result); }
141
+ catch { /* fall through to the static declaration */ }
142
+ }
143
+ return clientOk(id, { tools: FALLBACK_TOOLS });
144
+ }
145
+ case 'tools/call': {
146
+ if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
147
+ refreshLease();
148
+ const c = await ensureChild();
149
+ if (!c) {
150
+ return clientOk(id, { content: [{ type: 'text', text: `search_ruvnet error: the brain bundle is not installed at ${KB}. Install it with: npx github:stuinfla/ruvnet-brain (or set RUVNET_BRAIN_KB to your brain's kb dir).` }], isError: true });
151
+ }
152
+ pendingCount++;
153
+ try {
154
+ const r = await childRequest(c, 'tools/call', params);
155
+ if (r.error) return clientErr(id, r.error.code ?? -32603, r.error.message ?? 'brain worker error');
156
+ return clientOk(id, r.result);
157
+ } catch (e) {
158
+ return clientOk(id, { content: [{ type: 'text', text: `search_ruvnet error: ${e.message}` }], isError: true });
159
+ } finally {
160
+ pendingCount--;
161
+ }
162
+ }
163
+ default:
164
+ return clientErr(id, -32601, `unknown method: ${method}`);
165
+ }
166
+ }
167
+
168
+ const clientRl = readline.createInterface({ input: process.stdin });
169
+ clientRl.on('line', (line) => {
170
+ let msg; try { msg = JSON.parse(line); } catch { return; } // malformed line: ignore, never crash
171
+ handleClient(msg).catch((e) => { if (msg.id !== undefined && msg.id !== null) clientErr(msg.id, -32603, e.message); });
172
+ });
173
+ clientRl.on('close', () => { killChild('client disconnected'); process.exit(0); });