ruvnet-brain 4.7.4 → 4.7.6

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
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
9
9
 
10
10
  ### Discover the stack. Read the source. Build with confidence.
11
11
 
12
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.4-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.6-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
13
13
 
14
14
  **A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
15
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.4",
3
+ "version": "4.7.6",
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": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.7.4",
4
+ "version": "4.7.6",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.4",
3
+ "version": "4.7.6",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -3,11 +3,12 @@
3
3
  * so a stalled CLI is retired at the recall deadline. Output contract unchanged: {ok, value}.
4
4
  */
5
5
  import { execFile } from 'node:child_process';
6
- import { resolveRuflo } from './ruflo-bin.mjs';
6
+ import { resolveRuflo, rufloInvocationOrPlain } from './ruflo-bin.mjs';
7
7
 
8
8
  const call = (bin, args, deadline) => new Promise((resolve) => {
9
9
  const timeout = Math.max(1, deadline - Date.now());
10
- execFile(bin, args, { timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } },
10
+ const inv = rufloInvocationOrPlain(bin, args, { env: process.env });
11
+ execFile(inv.executable, inv.args, { timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } },
11
12
  (error, stdout) => resolve(error ? null : String(stdout || '')));
12
13
  });
13
14
 
@@ -36,14 +36,90 @@
36
36
  * Rule 21 is untouched by this: still ONE ruflo, still the global one, never `npx ruflo@latest`. This
37
37
  * resolves WHERE that one global binary is rather than assuming everyone's prefix matches the owner's.
38
38
  */
39
+ import { spawnSync } from 'node:child_process';
39
40
  import fs from 'node:fs';
40
41
  import os from 'node:os';
41
42
  import path from 'node:path';
42
43
 
44
+ // WHICH NODE RUNS RUFLO (measured 2026-10-14, ruflo 3.56.9 / better-sqlite3 12.11.1). `ruflo` is
45
+ // `#!/usr/bin/env node`, so the interpreter is whatever is FIRST on the CALLER's PATH — and an
46
+ // installed better-sqlite3 holds ONE native ABI (this machine's was built for node 24). A hook
47
+ // launched with PATH=/opt/homebrew/bin:… got node 26, and `memory store` was refused with
48
+ // "Native memory writer required; refusing whole-image sql.js fallback": 67 failed captures in a day,
49
+ // each one a lost turn record. Same command, only PATH changed: node 26 first → refused, node 24 first
50
+ // → stored. Upstream's guard (ruflo#2219) covers shipping prebuilds for Node 20–26, not one install
51
+ // being run under several nodes, so the choice belongs here, in the one resolver every caller shares.
52
+ //
53
+ // The test is the load itself — open an in-memory database through the SAME better-sqlite3 ruflo
54
+ // will use — never a version string. Healthy machines are untouched: when the PATH node loads the
55
+ // binding, this returns null and the invocation stays exactly `ruflo …`.
56
+ function defaultNodeCandidates(env) {
57
+ const out = [];
58
+ for (const dir of String(env.PATH || '').split(path.delimiter)) if (dir) out.push(path.join(dir, 'node'));
59
+ out.push(process.execPath);
60
+ try {
61
+ const opt = '/opt/homebrew/opt';
62
+ for (const name of fs.readdirSync(opt).filter((n) => /^node(@\d+)?$/.test(n)).sort().reverse()) {
63
+ out.push(path.join(opt, name, 'bin', 'node'));
64
+ }
65
+ } catch { /* no Homebrew opt dir */ }
66
+ out.push('/opt/homebrew/bin/node', '/usr/local/bin/node');
67
+ return [...new Set(out)].filter((c) => { try { return fs.statSync(c).isFile(); } catch { return false; } });
68
+ }
69
+
70
+ const nativeNodeCache = new Map();
71
+ const PROBE_TIMEOUT_MS = 2000;
72
+ const PROBE_BUDGET_MS = 3000;
73
+
74
+ /**
75
+ * rufloInvocation for callers that must never throw: an unrecognised shim keeps the plain `bin args` they
76
+ * always ran, so routing a call site through node selection can only add the correct interpreter.
77
+ */
78
+ export function rufloInvocationOrPlain(binary, args, opts) {
79
+ try { return rufloInvocation(binary, args, opts); } catch { return { executable: binary, args }; }
80
+ }
81
+
82
+ /**
83
+ * The node that can load ruflo's native better-sqlite3, or null when the caller's own PATH node
84
+ * already can, when ruflo is not a node package we can inspect, or when no candidate can.
85
+ * Null always means "leave the invocation exactly as it was" — this never makes anything worse.
86
+ *
87
+ * @param {string} binary path to the ruflo bin (symlink or script)
88
+ * @param {{ env?: Record<string,string|undefined>, candidates?: string[] }} [opts]
89
+ * @returns {{ node: string, entry: string }|null}
90
+ */
91
+ export function nativeNodeFor(binary, { env = process.env, candidates } = {}) {
92
+ let entry;
93
+ try { entry = fs.realpathSync(binary); } catch { return null; }
94
+ const addon = path.join(path.dirname(path.dirname(entry)), 'node_modules', 'better-sqlite3');
95
+ try { if (!fs.statSync(addon).isDirectory()) return null; } catch { return null; }
96
+
97
+ const list = candidates ?? defaultNodeCandidates(env);
98
+ const cacheKey = `${entry}\0${list.join('\0')}`;
99
+ if (nativeNodeCache.has(cacheKey)) return nativeNodeCache.get(cacheKey);
100
+
101
+ const probe = `new (require(${JSON.stringify(addon)}))(':memory:').close()`;
102
+ let chosen = null;
103
+ // Bounded: this runs inside hooks that have their own deadline (SessionStart is stopped 1.5 s early), so a
104
+ // wedged candidate must cost seconds at most and then leave the invocation exactly as it was.
105
+ const started = Date.now();
106
+ for (let i = 0; i < list.length; i += 1) {
107
+ if (Date.now() - started > PROBE_BUDGET_MS) break;
108
+ const r = spawnSync(list[i], ['-e', probe], { stdio: 'ignore', timeout: PROBE_TIMEOUT_MS, windowsHide: true });
109
+ if (r.status === 0) { chosen = i === 0 ? null : { node: list[i], entry }; break; }
110
+ }
111
+ nativeNodeCache.set(cacheKey, chosen);
112
+ return chosen;
113
+ }
114
+
43
115
  // npm's Windows .cmd shim cannot be spawned without a shell. Resolve the declared entry
44
116
  // inside that same managed installation and pass argv directly to Node instead.
45
- export function rufloInvocation(binary, args, { platform = process.platform } = {}) {
46
- if (platform !== 'win32' || !/\.cmd$/i.test(binary)) return { executable: binary, args };
117
+ export function rufloInvocation(binary, args, { platform = process.platform, env = process.env, nodeCandidates } = {}) {
118
+ if (platform !== 'win32') {
119
+ const native = nativeNodeFor(binary, { env, candidates: nodeCandidates });
120
+ return native ? { executable: native.node, args: [native.entry, ...args] } : { executable: binary, args };
121
+ }
122
+ if (!/\.cmd$/i.test(binary)) return { executable: binary, args };
47
123
  if (path.basename(binary).toLowerCase() !== 'ruflo.cmd') throw new Error('unsupported managed Ruflo shim');
48
124
  const packageRoot = fs.realpathSync(path.join(path.dirname(fs.realpathSync(binary)), 'node_modules', 'ruflo'));
49
125
  const manifest = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
@@ -18,7 +18,7 @@ import crypto from 'node:crypto';
18
18
  import path from 'node:path';
19
19
  import { execFile, execFileSync, spawnSync } from 'node:child_process';
20
20
  import os from 'node:os';
21
- import { resolveRuflo, rufloRunDir, rufloScratchRoot } from './ruflo-bin.mjs';
21
+ import { resolveRuflo, rufloInvocationOrPlain, rufloRunDir, rufloScratchRoot } from './ruflo-bin.mjs';
22
22
  import { resolveProjectStore } from './project-store-resolver.mjs';
23
23
  import { checkpointNamespaces, newestOfFamily } from './checkpoint-keys.mjs';
24
24
 
@@ -57,7 +57,8 @@ export function rufloMemory(args, { db, env = process.env, bin = resolveRuflo({
57
57
  return new Promise((resolve) => {
58
58
  if (!bin) return resolve({ ok: false, stdout: '', error: 'global Ruflo unavailable' });
59
59
  const spawnAt = rufloSpawnOptions(db, env);
60
- execFile(bin, ['memory', ...args, '--path', db], { cwd: spawnAt.cwd, env: spawnAt.env, timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
60
+ const inv = rufloInvocationOrPlain(bin, ['memory', ...args, '--path', db], { env: spawnAt.env });
61
+ execFile(inv.executable, inv.args, { cwd: spawnAt.cwd, env: spawnAt.env, timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 8 * 1024 * 1024 },
61
62
  // The ledger gets a fixed reason, never execFile's message (it embeds the whole argv: value, db path).
62
63
  (error, stdout) => { spawnAt.cleanup(); resolve({ ok: !error, stdout: String(stdout || ''), error: error ? (error.killed ? 'timed out' : `ruflo memory ${args[0]} exited ${error.code ?? 'abnormally'}`) : null }); });
63
64
  });
@@ -194,11 +195,13 @@ export function storeExact({ db, namespace, key, value, env = process.env, bin =
194
195
  const spawnAt = rufloSpawnOptions(db, env);
195
196
  const options = { encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 8 * 1024 * 1024, cwd: spawnAt.cwd, env: spawnAt.env };
196
197
  try {
197
- const stored = run(bin, ['memory', 'store', '--key', key, '--value', value, '--namespace', namespace, '--path', db], options);
198
+ const storeInv = rufloInvocationOrPlain(bin, ['memory', 'store', '--key', key, '--value', value, '--namespace', namespace, '--path', db], { env: spawnAt.env });
199
+ const stored = run(storeInv.executable, storeInv.args, options);
198
200
  if (stored.error || stored.status !== 0) {
199
201
  return { stored: false, verified: false, error: String(stored.stderr || stored.stdout || stored.error?.message || 'store failed').trim().slice(0, 200) };
200
202
  }
201
- const read = run(bin, ['memory', 'retrieve', '--key', key, '--namespace', namespace, '--value-only', '--path', db], options);
203
+ const readInv = rufloInvocationOrPlain(bin, ['memory', 'retrieve', '--key', key, '--namespace', namespace, '--value-only', '--path', db], { env: spawnAt.env });
204
+ const read = run(readInv.executable, readInv.args, options);
202
205
  return { stored: true, verified: !read.error && read.status === 0 && String(read.stdout || '').trim() === value, key };
203
206
  } finally { spawnAt.cleanup(); }
204
207
  }
@@ -220,6 +220,15 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
220
220
  "tests/unit/project-store-resolver.test.mjs"
221
221
  ]
222
222
  },
223
+ {
224
+ "id": "ruflo-runs-under-a-node-that-loads-its-binding",
225
+ "reason": "Hooks inherit whatever node is first on their PATH; every direct ruflo launch (turn and learning capture, the SessionEnd/PreCompact checkpoint writer, the SessionStart memory reader and the recall reader) runs ruflo under the first node that can load its own better-sqlite3 within a bounded probe (a healthy machine is untouched, and no candidate means no change), so a native memory write is not refused for a node-ABI mismatch",
226
+ "files": [],
227
+ "platformFiles": {
228
+ "linux": ["tests/unit/ruflo-native-node.test.mjs"],
229
+ "macos": ["tests/unit/ruflo-native-node.test.mjs"]
230
+ }
231
+ },
223
232
  {
224
233
  "id": "lossless-lifecycle-retention",
225
234
  "reason": "Evidence compaction preserves tokens, private state and atomic transaction recovery",