ruvnet-brain 4.7.6 → 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.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.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.6",
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.6",
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.6",
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"
@@ -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 };
@@ -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);