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 +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/agentdb-recall-reader.mjs +3 -2
- package/plugin/scripts/continuation-gate.mjs +57 -3
- package/plugin/scripts/grounding-turn-gate.mjs +3 -0
- package/plugin/scripts/learning-store.mjs +2 -1
- package/plugin/scripts/ruflo-bin.mjs +15 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +7 -4
- package/plugin/scripts/session-start-update-plane.mjs +11 -0
- package/scripts/release-qualification-contract.mjs +1 -1
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
|
-
[](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.
|
|
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.
|
|
4
|
+
"version": "4.7.7",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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"],
|