ruvnet-brain 4.7.6 → 4.7.8

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.6-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.8-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.6",
3
+ "version": "4.7.8",
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.6",
4
+ "version": "4.7.8",
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.6",
3
+ "version": "4.7.8",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -319,6 +319,18 @@ async function consequentialRecall({ prompt, root, store, bin, scratch, env, dea
319
319
  records: selected.map(({ category, namespace, key, storePath, valueDigest }) => ({ category, namespace, key, storePath, valueDigest })), authority: false } };
320
320
  }
321
321
 
322
+ /**
323
+ * Quiet by default (2026-10-15): a recall that found nothing because it timed out or was unavailable says
324
+ * nothing — "Search timed out; no records verified" on every prompt under load is noise, not information.
325
+ * RUVNET_STOP_NUDGES=full keeps the honest status line.
326
+ */
327
+ export function recallIsWorthSurfacing(r, env = process.env) {
328
+ if (!r?.block) return false;
329
+ if (String(env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full') return true;
330
+ if (Array.isArray(r.picks) && r.picks.length) return true;
331
+ return !/\b(timed[- ]out|unavailable)\b/i.test(String(r.outcome || r.status || r.block));
332
+ }
333
+
322
334
  export async function recall({ prompt, projectDir = process.cwd(), env = process.env, deadlineMs, ruflo, scratch,
323
335
  binding = {}, signal, absoluteDeadline, consequential = false, enumerateKeys = enumerateCanonicalKeys } = {}) {
324
336
  const started = Date.now();
@@ -451,6 +463,7 @@ async function main() {
451
463
  const projectDir = typeof ev.cwd === 'string' && ev.cwd ? ev.cwd : process.cwd();
452
464
  const r = await recall({ prompt, projectDir, deadlineMs: Math.max(1, DEFAULT_DEADLINE_MS - (Date.now() - started)) });
453
465
  if (!r.block) return;
466
+ if (!recallIsWorthSurfacing(r)) return;
454
467
  const hash = crypto.createHash('sha256').update(r.block).digest('hex').slice(0, 12);
455
468
  // Hash only, never raw memory values, enters the per-session dedupe markers.
456
469
  process.stdout.write(`${hash}\n${r.block}\n`);
@@ -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() };
@@ -31,6 +31,8 @@ import { append as appendOutcome, actionKey, recordRefusal, resolve as resolveOu
31
31
  // see a host-native spelling — the 4.4 measurement was a Grok write silently allowed. hook-input.mjs.
32
32
  import { normalizePayloadText } from './hook-input.mjs';
33
33
 
34
+ const QUIET_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
35
+
34
36
  const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
35
37
  const EVENT = process.argv[2] || '';
36
38
 
@@ -415,7 +417,10 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs, refused
415
417
  try {
416
418
  appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
417
419
  } catch { /* a ledger must never break a tool call */ }
418
- process.stderr.write(
420
+ // Quiet by default (2026-10-15): the outcome is already in the ledger above. On an ALLOW, a stderr line only
421
+ // makes the host show a hook "error" for a decision that was made. It is kept when the action was refused
422
+ // and under RUVNET_STOP_NUDGES=full.
423
+ if (refused || QUIET_FULL) process.stderr.write(
419
424
  `[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
420
425
  + `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
421
426
  );
@@ -424,7 +429,7 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs, refused
424
429
  function reportSelfSkipped({ session, selfSkipped }) {
425
430
  for (const r of selfSkipped) {
426
431
  try { appendOutcome({ kind: 'policy-skipped', event: EVENT, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
427
- process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
432
+ if (QUIET_FULL) process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
428
433
  }
429
434
  }
430
435
  /**
@@ -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);
@@ -209,6 +209,15 @@ if (!entry) {
209
209
  // forgets to declare one fails toward the pre-ADR-054 behaviour rather than toward silent death.
210
210
  if (BRAIN_OFF && entry.offBehavior === 'silence') process.exit(0);
211
211
 
212
+ // QUIET BY DEFAULT (owner, 2026-10-15). These advisory hooks only ever add text to a turn (advocacy offers,
213
+ // "ADRs are living plans", MetaHarness/QE offers, parallel-work sizing, the answered-without-searching
214
+ // nudge). None of them blocks or protects anything, and together they were the constant noise on every
215
+ // prompt. They stay registered and run only when RUVNET_STOP_NUDGES=full, so nothing is deleted and the
216
+ // tests still exercise them. The blocking hooks (decision-gate), the grounding directive and the silent
217
+ // capture hooks are unaffected.
218
+ const QUIET_BY_DEFAULT = new Set(['unprompted-speech', 'capacity-aware-parallel-work', 'grounding-turn-gate']);
219
+ if (QUIET_BY_DEFAULT.has(hookId) && String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() !== 'full') process.exit(0);
220
+
212
221
  // Read a bounded payload before dispatch. The ground body owns the quiet-prompt
213
222
  // decision because it resolves canonical memory beyond the hook process's cwd.
214
223
  let hookInput = null;
@@ -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 };
@@ -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);