ruvnet-brain 4.7.5 → 4.7.7

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.5-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.7-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.5",
3
+ "version": "4.7.7",
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.5",
4
+ "version": "4.7.7",
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.5",
3
+ "version": "4.7.7",
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
 
@@ -390,7 +390,9 @@ const completion = (() => {
390
390
  * can never close must never be written. Per-turn and total caps; dedupe against open items by
391
391
  * normalized text; project + worktree scoped exactly like --commit-to's objective. Fails open.
392
392
  */
393
+ const STOP_NUDGES_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
393
394
  function promiseBookkeeping() {
395
+ if (!STOP_NUDGES_FULL) return;
394
396
  if (HOST !== 'claude' || !hookInput.session_id || hookInput.session_id === '*') return;
395
397
  try {
396
398
  const pid = projectIdentity.projectId;
@@ -728,13 +730,22 @@ const decisionWork = (() => {
728
730
  })();
729
731
  const promiseWork = HOST === 'claude'
730
732
  ? authorizedPromiseItems(led.items, hookInput, projectIdentity)
731
- .map((i) => ({ text: `you said you would: ${i.text}`, at: i.at, kind: PROMISE_KIND }))
733
+ .map((i) => ({ text: `you said you would: ${i.text}`, at: i.at, kind: PROMISE_KIND, key: i.key }))
732
734
  : [];
733
735
  const deferralWork = deferralState?.v.verdict === 'DEFERRAL' && deferralState.forced < MAX_DEFERRAL_FORCES ? [{
734
736
  text: `your answer ends by handing authorized work back ("${deferralState.v.deferral.phrase}") although ${deferralState.v.authorization} authorizes it and no blocker is named. Do that work now`,
735
737
  at: new Date(nowMs).toISOString(), derived: true, kind: 'no-deferral' }] : [];
738
+ /**
739
+ * QUIET BY DEFAULT (owner, 2026-10-15): a Stop hook that talks on ordinary turns teaches people to ignore or
740
+ * disable the whole product. Unless RUVNET_STOP_NUDGES=full, this gate speaks ONLY for (1) an objective the
741
+ * user explicitly committed it to, and (2) a RuvNet capability claim contradicted by typed evidence. Promise
742
+ * nudges, completion-claim corrections, decision-record reminders, deferral nags and "unknown" claims are
743
+ * dropped. Promises are not even captured in quiet mode: capturing work that is never nudged is only ledger
744
+ * bloat. `full` restores every nudge (tests and anyone who wants them).
745
+ */
736
746
  const open = [...capabilityClaimWork(), ...completionWork, ...decisionWork, ...deferralWork,
737
- ...(objective ? [{ text: objective.text, at: objective.at }] : []), ...promiseWork];
747
+ ...(objective ? [{ text: objective.text, at: objective.at }] : []), ...promiseWork]
748
+ .filter((i) => STOP_NUDGES_FULL || !i.kind || (i.kind === 'capability-claim-integrity' && / contradicts /.test(i.text)));
738
749
  if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
739
750
 
740
751
  /**
@@ -765,7 +776,7 @@ if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is
765
776
  // how old it is, which is information the reader needs rather than a reason to withhold. If a ledger
766
777
  // is genuinely abandoned, the honest fix is to mark its items done — not to let a clock silently
767
778
  // decide the commitment expired.
768
- const forceable = open.filter((i) => Number.isFinite(Date.parse(i.at)));
779
+ let forceable = open.filter((i) => Number.isFinite(Date.parse(i.at)));
769
780
  if (!forceable.length) process.exit(EXIT_ALLOW);
770
781
 
771
782
  /**
@@ -806,6 +817,49 @@ function claimCooldown(now, windowMs) {
806
817
  catch { return false; } // lost the race / cannot persist → fail closed
807
818
  }
808
819
  if (!claimCooldown(nowMs, COOLDOWN_MS)) process.exit(EXIT_ALLOW);
820
+
821
+ /**
822
+ * BOUNDED FORCING FOR PROMISES (2026-10-15). A captured promise closes only on a verified completion
823
+ * claim, and that verdict can be OBSERVED_CHECK or UNKNOWN but never PASS (64b0b7ee), so an assistant
824
+ * promise had NO route to closure and re-forced every Stop forever — measured: 36 open promises across
825
+ * 7 ledgers, many garbage fragments. A gate with no way out is not rigor, it is a trap that teaches the
826
+ * model to stop answering.
827
+ *
828
+ * So each promise is forced at most PROMISE_MAX_FORCES times (default 3). After that it is RETAINED as
829
+ * state 'deferred' with the reason recorded — never marked done, never deleted — and stops forcing.
830
+ * The count is per promise, not a clock, so old open work is still nagged until it has had its chances.
831
+ * User objectives and derived (observed) items are untouched.
832
+ */
833
+ const PROMISE_MAX_FORCES = Number(process.env.RUVNET_PROMISE_MAX_FORCES ?? 3);
834
+ // Counts live BESIDE the ledger, not on its rows: a forcing stop must leave the ledger byte-identical
835
+ // (continuation-commitment-ownership.test.mjs), and only the retirement itself may write a row.
836
+ try {
837
+ const FORCES = LEDGER + '.forces.json';
838
+ let counts = {};
839
+ try { counts = JSON.parse(fs.readFileSync(FORCES, 'utf8')) || {}; } catch { /* first force */ }
840
+ const retired = [];
841
+ let touched = false;
842
+ for (const f of forceable) {
843
+ if (f.kind !== PROMISE_KIND) continue;
844
+ const row = led.items.find((i) => i.kind === PROMISE_KIND && i.key === f.key && !i.done
845
+ && (i.state === undefined || i.state === 'active'));
846
+ if (!row) continue;
847
+ const id = crypto.createHash('sha256').update(JSON.stringify([row.projectId, row.sessionIds, row.key])).digest('hex').slice(0, 24);
848
+ const n = (counts[id]?.n || 0) + 1;
849
+ counts[id] = { n, at: new Date(nowMs).toISOString() };
850
+ touched = true;
851
+ if (n > PROMISE_MAX_FORCES) {
852
+ Object.assign(row, { state: 'deferred', stateAt: new Date(nowMs).toISOString(),
853
+ stateReason: `forced ${PROMISE_MAX_FORCES} times with no verified completion claim; retained, not completed` });
854
+ retired.push(f);
855
+ delete counts[id];
856
+ }
857
+ }
858
+ for (const [k, v] of Object.entries(counts)) if (nowMs - Date.parse(v?.at) > 14 * 86_400_000) delete counts[k];
859
+ if (touched) fs.writeFileSync(FORCES, JSON.stringify(counts));
860
+ if (retired.length) { save(led); forceable = forceable.filter((i) => !retired.includes(i)); }
861
+ } catch { /* bookkeeping only: never break a turn */ }
862
+ if (!forceable.length) process.exit(EXIT_ALLOW);
809
863
  if (deferralState && forceable.some((i) => i.kind === 'no-deferral')) {
810
864
  try { for (const [k, v] of Object.entries(deferralState.counts)) if (nowMs - Date.parse(v?.at) > 7 * 86_400_000) delete deferralState.counts[k];
811
865
  deferralState.counts[deferralState.chain] = { n: deferralState.forced + 1, at: new Date(nowMs).toISOString() };
@@ -206,6 +206,9 @@ export function decide({ hookInput, marker, markerMs, searchEvidence = null, own
206
206
  async function main() {
207
207
  const hookInput = await readStopHookInput();
208
208
  if (hookInput.__source !== 'stdin') process.exit(EXIT_ALLOW);
209
+ // Quiet by default (2026-10-15): the "answered without searching" correction is a nudge, not a safety stop.
210
+ // RUVNET_STOP_NUDGES=full restores it.
211
+ if (String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() !== 'full') process.exit(EXIT_ALLOW);
209
212
  if (hookInput.stop_hook_active && !hookInput.interrupted && !hookInput.cancelled) process.exit(EXIT_ALLOW);
210
213
  if (hookInput.hook_event_name !== 'Stop') {
211
214
  process.exit(EXIT_ALLOW);
@@ -8,6 +8,7 @@ import { resolveProjectStore } from './project-store-resolver.mjs';
8
8
  import { rufloInvocation, rufloRunDir, resolveRuflo } from './ruflo-bin.mjs';
9
9
  import { loadRuntimePreferences } from './runtime-preferences.mjs';
10
10
  import { learningContext } from './runtime-preferences.mjs';
11
+ import { captureFailureReason } from './turn-capture-privacy.mjs';
11
12
 
12
13
  export const LEARNING_NAMESPACE = 'learning-observations';
13
14
  export function learningTarget(context, { env = process.env, explicitLegacyApply = false } = {}) {
@@ -56,7 +57,7 @@ export function recordLearningObservation(binary, context, { tool, action, sessi
56
57
  // A refused store whose key already holds these exact bytes is the dedupe working, not a failure.
57
58
  if (stored.error || stored.status !== 0) {
58
59
  if (exact) return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, alreadyStored: true };
59
- throw new Error(`ruflo memory store refused the observation: ${String(stored.stderr || stored.stdout || stored.error?.message || '').trim().slice(0, 160)}`);
60
+ throw new Error(`ruflo memory store refused the observation: ${captureFailureReason(stored, stored.status)}`);
60
61
  }
61
62
  if (!exact) throw new Error('canonical exact CLI readback failed');
62
63
  return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
@@ -68,6 +68,16 @@ function defaultNodeCandidates(env) {
68
68
  }
69
69
 
70
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
+ }
71
81
 
72
82
  /**
73
83
  * The node that can load ruflo's native better-sqlite3, or null when the caller's own PATH node
@@ -90,8 +100,12 @@ export function nativeNodeFor(binary, { env = process.env, candidates } = {}) {
90
100
 
91
101
  const probe = `new (require(${JSON.stringify(addon)}))(':memory:').close()`;
92
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();
93
106
  for (let i = 0; i < list.length; i += 1) {
94
- const r = spawnSync(list[i], ['-e', probe], { stdio: 'ignore', timeout: 8000, windowsHide: true });
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 });
95
109
  if (r.status === 0) { chosen = i === 0 ? null : { node: list[i], entry }; break; }
96
110
  }
97
111
  nativeNodeCache.set(cacheKey, chosen);
@@ -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
  }
@@ -229,6 +229,17 @@ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatche
229
229
  dispatchDetached(hookDir, 10, versionLog, process.execPath,
230
230
  [path.join(hookDir, 'host-update.mjs'), '--check'], env);
231
231
  if (!running || !remoteVersion || remoteVersion === running) return;
232
+ // `running` is the version this session BOOTED with. After the Stable Spine flips, the active generation can
233
+ // already be the remote version (or newer): "vX is downloading" beside "vX active" is a false statement
234
+ // (D5, 2026-10-15). Only announce a download when the remote is strictly newer than what is active now.
235
+ try {
236
+ const active = JSON.parse(read(path.join(home, '.cache', 'ruvnet-brain', 'active.json')) || '{}').version;
237
+ const parts = (v) => String(v || '').split(/[.-]/).map((x) => (/^\d+$/.test(x) ? Number(x) : 0));
238
+ const [a, r] = [parts(active), parts(remoteVersion)];
239
+ let newer = false;
240
+ for (let i = 0; i < 3 && active; i += 1) { if ((r[i] || 0) !== (a[i] || 0)) { newer = (r[i] || 0) > (a[i] || 0); break; } }
241
+ if (active && !newer) return;
242
+ } catch { /* unknown active generation: keep the existing announcement */ }
232
243
  if (pref === 'yes' && exists(path.join(hookDir, 'host-update.mjs'))) {
233
244
  dispatchDetached(hookDir, 600, path.join(stateDir, '.last-auto-update.log'),
234
245
  process.execPath, [path.join(hookDir, 'host-update.mjs')], env);
@@ -222,7 +222,7 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
222
222
  },
223
223
  {
224
224
  "id": "ruflo-runs-under-a-node-that-loads-its-binding",
225
- "reason": "Hooks inherit whatever node is first on their PATH; ruflo is run under the first node that can load its own better-sqlite3 (a healthy machine is untouched, and no candidate means no change), so a native memory write is not refused for a node-ABI mismatch",
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
226
  "files": [],
227
227
  "platformFiles": {
228
228
  "linux": ["tests/unit/ruflo-native-node.test.mjs"],