ruvnet-brain 4.4.0 → 4.5.0

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.
Files changed (105) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +679 -121
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +258 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +14 -2
  58. package/plugin/scripts/project-progression-store.mjs +153 -6
  59. package/plugin/scripts/protect-brain-state.sh +4 -1
  60. package/plugin/scripts/session-snapshot-hook.mjs +225 -41
  61. package/plugin/scripts/session-start-budget.mjs +1 -0
  62. package/plugin/scripts/session-start-core.mjs +34 -4
  63. package/plugin/scripts/session-start-health.mjs +7 -1
  64. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  65. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  66. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  67. package/plugin/skills/brain-console/SKILL.md +3 -3
  68. package/plugin/skills/rnbc/SKILL.md +24 -0
  69. package/plugin/skills/rvbc/SKILL.md +2 -2
  70. package/scripts/approved-runtime.mjs +2 -2
  71. package/scripts/ci/warm-brain-models.mjs +28 -0
  72. package/scripts/codex-hook-trust.mjs +94 -0
  73. package/scripts/console-instances.mjs +70 -12
  74. package/scripts/console-runtime-identity.mjs +5 -0
  75. package/scripts/corpus-canary.mjs +46 -6
  76. package/scripts/corpus-dispatch-decision.mjs +2 -2
  77. package/scripts/corpus-promotion.mjs +1 -1
  78. package/scripts/full-suite-gate.mjs +9 -2
  79. package/scripts/hook-qualify-hosts.mjs +15 -3
  80. package/scripts/host-install-matrix.mjs +63 -2
  81. package/scripts/human-approval-phrases.mjs +46 -0
  82. package/scripts/installed-brain-health.mjs +53 -0
  83. package/scripts/move-brain.mjs +310 -0
  84. package/scripts/onboarding-console.mjs +93 -10
  85. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  86. package/scripts/oracle/abstain-trace.mjs +139 -0
  87. package/scripts/oracle/doc2query-generate.mjs +162 -0
  88. package/scripts/oracle/doc2query-reach.mjs +110 -0
  89. package/scripts/oracle/judge-train.mjs +158 -0
  90. package/scripts/oracle/need-set-split.mjs +48 -0
  91. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  92. package/scripts/package-cards.mjs +374 -0
  93. package/scripts/publication-receipt.mjs +37 -9
  94. package/scripts/recommendation-e2e.mjs +110 -0
  95. package/scripts/recommendation-eval.mjs +105 -0
  96. package/scripts/recommendation-floor.mjs +56 -0
  97. package/scripts/recommendation-judge-score.mjs +74 -0
  98. package/scripts/recommendation-latency.mjs +95 -0
  99. package/scripts/recommendation-real-host-score.mjs +76 -0
  100. package/scripts/recommendation-real-host.mjs +137 -0
  101. package/scripts/release-channel-kind.mjs +1 -1
  102. package/scripts/release-environment-policy.mjs +33 -0
  103. package/scripts/single-source-check.mjs +15 -10
  104. package/scripts/sync-commands.mjs +5 -2
  105. package/scripts/wired-check.mjs +17 -2
@@ -26,14 +26,15 @@ import fs from 'node:fs';
26
26
  import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
- import { restoreProgressionForSession } from './project-progression-session-start.mjs';
29
+ import { restoreWithBrief } from './continuity-brief.mjs';
30
30
  import {
31
31
  read, json, exists, mkdir, write, runNode,
32
32
  } from './session-start-fsutil.mjs';
33
33
  import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
34
34
  import { surfaceSignals } from './session-start-signals.mjs';
35
35
  import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
36
- import { stableSpine, heartbeat, knowledgeAutoUpdate } from './session-start-update-plane.mjs';
36
+ import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
37
+ import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
37
38
  import { describeLifecycleHooks, readHookContracts } from './session-start-hook-description.mjs';
38
39
  import { createStageTracer } from './session-start-trace.mjs';
39
40
 
@@ -59,13 +60,33 @@ const meter = ({ env, cwd, stateDir, output }) => {
59
60
  } catch { /* metering never blocks */ }
60
61
  };
61
62
 
63
+ /**
64
+ * ground-ruvnet.sh sends each long block once per session and a one-line form after that (4.5). A
65
+ * SessionStart — startup, resume, clear, or compact — is exactly when the earlier full text may have
66
+ * left the model's context, so touching injected/.reset makes every block go out in full once more.
67
+ * No session id reaches this hook, so the reset is global: another window re-receives its blocks once,
68
+ * which errs toward delivering. Per-session marker dirs older than 3 days are pruned. Never throws.
69
+ */
70
+ export function resetInjectionDedupe(stateDir, now = Date.now()) {
71
+ const base = path.join(stateDir, 'injected');
72
+ try {
73
+ fs.mkdirSync(base, { recursive: true });
74
+ fs.writeFileSync(path.join(base, '.reset'), `${new Date(now).toISOString()}\n`);
75
+ for (const name of fs.readdirSync(base)) {
76
+ if (name.startsWith('.')) continue;
77
+ const dir = path.join(base, name);
78
+ try { if (now - fs.statSync(dir).mtimeMs > 3 * 86_400_000) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* raced */ }
79
+ }
80
+ } catch { /* an unwritable cache only costs dedupe, never the session */ }
81
+ }
82
+
62
83
  export async function runSessionStart({
63
84
  env = process.env,
64
85
  cwd = process.cwd(),
65
86
  stdout = process.stdout,
66
87
  stderr = process.stderr,
67
88
  platform = process.platform,
68
- restoreContinuity = restoreProgressionForSession,
89
+ restoreContinuity = restoreWithBrief,
69
90
  runHeartbeat = true,
70
91
  } = {}) {
71
92
  const lines = [];
@@ -80,6 +101,7 @@ export async function runSessionStart({
80
101
  || s.startsWith('[RuvNet Brain — INSTALL ALARM')
81
102
  || s.startsWith('[RuvNet Brain — NIGHTLY FAILED')
82
103
  || s.startsWith(KNOWLEDGE_LINE_PREFIX)
104
+ || s.startsWith(FOOTPRINT_LINE_PREFIX)
83
105
  || s.startsWith('[RuvNet Brain — OPEN ISSUES')
84
106
  || /\bopen issue\(s\)/i.test(s)
85
107
  || /^\[RuvNet Brain — external signal/i.test(s)
@@ -100,6 +122,7 @@ export async function runSessionStart({
100
122
  || s.startsWith('[ASCII→SVG]')
101
123
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
102
124
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
125
+ || s.startsWith('[RuvNet Brain — COME UP TO SPEED')
103
126
  || s.startsWith('[RuvNet Brain — MAINTAINER ONLY:');
104
127
  };
105
128
  // An alarm's HEADER line always matches isSafeStatus on its own dedicated prefix (above); its
@@ -132,9 +155,10 @@ export async function runSessionStart({
132
155
  };
133
156
  const home = env.HOME || env.USERPROFILE || os.homedir();
134
157
  const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
158
+ resetInjectionDedupe(stateDir, Date.now());
135
159
  const hookDir = path.dirname(fileURLToPath(import.meta.url));
136
160
  const now = Date.now();
137
- const consoleInvoke = env.RUVNET_HOOK_HOST === 'codex' ? '$ruvnet-brain:rvbc' : '/rvbc';
161
+ const consoleInvoke = env.RUVNET_HOOK_HOST === 'codex' ? '$ruvnet-brain:rnbc' : '/rnbc';
138
162
  const brain = brainState(env, home);
139
163
  const pluginRoot = env.CLAUDE_PLUGIN_ROOT || path.resolve(hookDir, '..');
140
164
  const manifest = json(path.join(pluginRoot, '.claude-plugin', 'plugin.json'), {});
@@ -218,6 +242,12 @@ export async function runSessionStart({
218
242
  const line = knowledgeCurrency({ env, home, now });
219
243
  if (line) emit(line);
220
244
  });
245
+ // ONE line only when the footprint is wrong (a second KB copy, cruft, a worker on another copy);
246
+ // a bounded detached sweep follows at most every 6h (ADR-0098). Silent on a clean machine.
247
+ tracer.stage('footprint', () => {
248
+ if (brain.off || state.problem) return;
249
+ footprintCheck({ env, home, now, hookDir, emit });
250
+ });
221
251
 
222
252
  tracer.stage('misc', () => {
223
253
  const consoleOffered = path.join(stateDir, '.console-offered');
@@ -11,6 +11,7 @@
11
11
  import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { json, exists, mtimeMs, read } from './session-start-fsutil.mjs';
14
+ import { unmountedNotice } from './brain-location.mjs';
14
15
  import {
15
16
  describeFailedRefreshRun, readNightlyRegistration, refreshHistory, updateOwnedByAgenticKit,
16
17
  } from './nightly-scheduler.mjs';
@@ -33,9 +34,14 @@ export const brainState = (env, home) => {
33
34
  };
34
35
 
35
36
  export const health = (home, off) => {
37
+ // Moved to another disk that is not plugged in: say exactly that — not "MISSING, reinstall", which
38
+ // would re-create a fresh brain in ~/.cache over the link. A problem also stands the self-heal down.
39
+ const unmounted = unmountedNotice({ home });
40
+ if (unmounted) return { problem: unmounted, absentByChoice: false };
36
41
  const kb = path.join(home, '.cache', 'ruvnet-brain', 'kb');
37
42
  let rvf = false;
38
- try { rvf = fs.readdirSync(kb).some((name) => name.endsWith('.rvf') && exists(path.join(kb, name))); }
43
+ // `._x.rvf` is macOS AppleDouble from an exFAT/FAT volume, not a store.
44
+ try { rvf = fs.readdirSync(kb).some((name) => name.endsWith('.rvf') && !name.startsWith('._') && exists(path.join(kb, name))); }
39
45
  catch { /* absent */ }
40
46
  const absentByChoice = off && (!exists(kb) || !rvf);
41
47
  if (absentByChoice) return { problem: '', absentByChoice };
@@ -9,6 +9,8 @@ import path from 'node:path';
9
9
  import { spawn } from 'node:child_process';
10
10
  import { read, write, exists, json, firstVersion, dispatchDetached, mtimeMs } from './session-start-fsutil.mjs';
11
11
  import { KNOWLEDGE_LINE_PREFIX, AUTO_UPDATE_LOCK_STALE_MS, autoUpdateOptOut, knowledgeFacts } from './session-start-health.mjs';
12
+ import { inventoryFootprint } from './brain-footprint.mjs';
13
+ import { confirm, footprintAlarm } from './brain-confirmation.mjs';
12
14
 
13
15
  /**
14
16
  * KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old). The
@@ -77,6 +79,39 @@ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {},
77
79
  return { launched: true, why: 'stale' };
78
80
  };
79
81
 
82
+ /**
83
+ * FOOTPRINT (ADR-0098): a cheap, read-only classification (names and sizes of single files only, no
84
+ * hashing, no tree walks) on every session. Silent when the machine is clean. When it is not: ONE line,
85
+ * and — at most once per 6h, never in test mode, never while a refresh holds the lock — a detached
86
+ * `brain-footprint.mjs --sweep --apply` that removes only what the proof allows (private-unique copies are
87
+ * kept). Self-heal installs and updates enforce the same sweep inside bin/install.mjs.
88
+ */
89
+ export const FOOTPRINT_SWEEP_HOURS = 6;
90
+ export const footprintCheck = ({ env, home, now, hookDir, emit = () => {}, dispatch = dispatchDetached }) => {
91
+ const footprint = inventoryFootprint({ env, home, now, measure: false });
92
+ const line = footprintAlarm(confirm({ footprint, env, home, now }));
93
+ if (!line) return { clean: true, dispatched: false };
94
+ const stamp = path.join(footprint.roots.brainHome, '.footprint-sweep');
95
+ const removable = footprint.cruft.some((i) => ['remove', 'remove-if-proven', 'rotate', 'truncate'].includes(i.action));
96
+ // Nothing a sweep could remove (e.g. a copy kept for the private data it holds — review S7): say it ONCE,
97
+ // until the line changes, instead of at every session; and dispatch no sweep that could only keep it again.
98
+ if (!removable && !footprint.roots.dangling) { // an unplugged brain disk is told every session, and nothing is written beside it
99
+ const notice = path.join(footprint.roots.brainHome, '.footprint-kept-notice');
100
+ if (read(notice) === line) return { clean: false, dispatched: false, repeated: true };
101
+ write(notice, line);
102
+ }
103
+ const due = !(now - mtimeMs(stamp) < FOOTPRINT_SWEEP_HOURS * 3_600_000);
104
+ const testMode = env.RUVNET_BRAIN_TEST === '1' && String(env.RUVNET_FOOTPRINT_SWEEP || '').toLowerCase() !== 'on';
105
+ let dispatched = false;
106
+ if (removable && due && !testMode && !footprint.lockHeld) {
107
+ write(stamp, `${new Date(now).toISOString()}\n`);
108
+ dispatched = dispatch(hookDir, 600, path.join(footprint.roots.brainHome, '.last-footprint-sweep.log'),
109
+ process.execPath, [path.join(hookDir, 'brain-footprint.mjs'), '--sweep', '--apply', '--json'], env);
110
+ }
111
+ emit(dispatched ? line.replace(/\. Fix: /, ' — cleaning it up in the background now. Fix: ') : line);
112
+ return { clean: false, dispatched };
113
+ };
114
+
80
115
  export const compareVersions = (a, b) => {
81
116
  const left = String(a).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
82
117
  const right = String(b).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
@@ -43,6 +43,7 @@ import crypto from 'node:crypto';
43
43
  import { spawn, spawnSync } from 'node:child_process';
44
44
  import { fileURLToPath } from 'node:url';
45
45
  import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
46
+ import { userLevelAgentdbHooks } from './continuity-events.mjs';
46
47
 
47
48
  export const TURN_NAMESPACE = 'turns';
48
49
  export const MIN_OUTCOME_CHARS = 200;
@@ -201,7 +202,17 @@ export function captureTurnOutcome({
201
202
  const project = projectName(projectDir);
202
203
  const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
203
204
 
204
- if (event === 'Stop') {
205
+ // ONE WRITER PER TURN (ADR-100 §3). Measured 2026-10-01 on this repo's real store: 624 `turns` rows
206
+ // in five days for 323 distinct outcomes — this writer (host-tagged) and the owner's user-level
207
+ // ~/.claude/hooks/agentdb-turn-capture.mjs both recorded every Claude turn. The user-level hook is
208
+ // the owner's and is never edited by the product, so where it is registered the product DEFERS
209
+ // (RUVNET_TURN_CAPTURE=force keeps both). Codex turns are not seen by that Claude-only hook.
210
+ const deferTo = event === 'Stop' && host === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force'
211
+ && userLevelAgentdbHooks({ home }).turnCapture;
212
+ if (deferTo) {
213
+ report.skipped = 'deferred: the user-level ~/.claude/hooks/agentdb-turn-capture.mjs records this turn (one writer per turn)';
214
+ report.deferredToUserLevel = true;
215
+ } else if (event === 'Stop') {
205
216
  const sessionKey = String(payload.session_id || payload.transcript_path || '');
206
217
  const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
207
218
  let turn = { finalText: '', files: [], actions: [] };
@@ -112,8 +112,8 @@ const SCRIPTS_DIR = path.dirname(SELF); // the payload's scr
112
112
  // The CC event name the shim forwarded. No event → nothing to run; stay silent.
113
113
  const EVENT = process.argv[2] || '';
114
114
 
115
- // Bound the whole runtime well under this hook's DECLARED timeout (hooks.json / codex-hooks.json:
116
- // 'unprompted-speech' is 3s, not the 5s this comment used to assume) — producers run sequentially and
115
+ // Bound the whole runtime well under this hook's DECLARED timeout (hooks.json: 'unprompted-speech' is
116
+ // 9s; codex-hooks.json: 10s host, 9000ms outer — it was 3s when this budget was first set) — producers run sequentially and
117
117
  // each has its own internal watchdog, but a backstop timeout here means a wedged producer can never
118
118
  // hang the turn. Found live 2026-09-27: the old 4000ms default left NO real margin under a 3000ms
119
119
  // declared timeout once Node startup + per-producer spawnSync overhead is counted, and slower
@@ -1,12 +1,12 @@
1
1
  ---
2
2
  name: brain-console
3
- description: Open the RuvNet Brain Console for "/rvbc", "/rvcb", "/brain-console", or "/ruvnet-brain:configure". Use when the user asks to open, configure, inspect, or view the Brain Console. It opens the live local page in the background; the page is read-only until the user clicks a clearly explained, reversible action.
4
- updated: 2026-07-28
3
+ description: Open the RuvNet Brain Console (RNBC) for "/rnbc", "/rnb", "/rvbc", "/rvcb", "/brain-console", or "/ruvnet-brain:configure". Use when the user asks to open, configure, inspect, or view the Brain Console. It opens the live local page in the background; the page is read-only until the user clicks a clearly explained, reversible action.
4
+ updated: 2026-10-01
5
5
  ---
6
6
 
7
7
  # Brain Console
8
8
 
9
- Treat `/rvbc`, `/rvcb`, `/brain-console`, and `/ruvnet-brain:configure` as equally valid names.
9
+ Treat `/rnbc` (the name), `/rnb`, `/rvbc`, `/rvcb`, `/brain-console`, and `/ruvnet-brain:configure` as equally valid names.
10
10
  Never correct the user's spelling.
11
11
 
12
12
  1. Say one short sentence: "Opening it now; it scans live while you watch."
@@ -0,0 +1,24 @@
1
+ ---
2
+ name: rnbc
3
+ description: Open the RNBC — RuvNet Brain Console — in Claude Code and Codex when the user says "rnbc", "rnb", "Configure RuvNet Brain", asks for the Brain Console, or wants to configure or inspect RuvNet Brain. Claude Code supports /rnbc (and /rnb, /rvbc); Codex invokes the native $ruvnet-brain:rnbc skill.
4
+ updated: 2026-10-01
5
+ ---
6
+
7
+ # RNBC — RuvNet Brain Console
8
+
9
+ RuvNet Brain abbreviates as RNB, so its console is the RNBC. Claude Code opens it with `/rnbc`
10
+ (also `/rnb`, `/rvbc`, `/rvcb`, `/brain-console`, `/ruvnet-brain:configure`); Codex invokes this
11
+ skill as `$ruvnet-brain:rnbc` because custom plugin slash commands are not part of the Codex CLI
12
+ slash-command surface. Never correct the user's spelling.
13
+
14
+ 1. Say one short sentence: "Opening it now; it scans live while you watch."
15
+ 2. Resolve the installed runtime at
16
+ `${RUVNET_BRAIN_KB:-$HOME/.cache/ruvnet-brain/kb}/.console-runtime/scripts/onboarding-console.mjs`.
17
+ A current-repository `scripts/onboarding-console.mjs` is allowed only for an explicit developer
18
+ checkout. Never fall back to a guessed `~/Code` path.
19
+ 3. Run `node <resolved-script> --serve --open` in the background.
20
+ 4. Give the URL immediately. Do not promise a duration; the page reports its own scan progress.
21
+
22
+ An already-running server is success. The Console is read-only until the user chooses an action;
23
+ every change must be explained and reversible. If the script cannot be located or the server fails,
24
+ report the exact failure plainly instead of claiming the Console opened.
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: rvbc
3
- description: Open the RuvNet Brain Console in Claude Code and Codex when the user says "Configure RuvNet Brain", mentions "rvbc", asks for the Brain Console, or wants to configure or inspect RuvNet Brain. Claude Code supports /rvbc; Codex invokes the native $ruvnet-brain:rvbc skill.
4
- updated: 2026-07-30
3
+ description: Open the RuvNet Brain Console in Claude Code and Codex when the user says "Configure RuvNet Brain", mentions "rvbc" or "rnbc", asks for the Brain Console, or wants to configure or inspect RuvNet Brain. Claude Code supports /rnbc and /rvbc; Codex invokes the native $ruvnet-brain:rvbc skill (or $ruvnet-brain:rnbc).
4
+ updated: 2026-10-01
5
5
  ---
6
6
 
7
7
  # RuvNet Brain Console
@@ -14,7 +14,7 @@
14
14
  // "Pinning survives only through enforced equality to the approved shipped runtime and its
15
15
  // executable hashes. Copying current-main package.json or preserving a version string alone is
16
16
  // insufficient." So this compares every executable/runtime file's sha256 AND byte length against a
17
- // inventory produced from the owner-approved shipped code artifact — and, in the other
17
+ // inventory produced from the install-verified shipped code artifact — and, in the other
18
18
  // direction, refuses any executable-shaped file in the archive that the inventory does not cover, so
19
19
  // a NEW unpinned executable cannot ride along.
20
20
  //
@@ -405,7 +405,7 @@ async function main() {
405
405
  catch (error) { console.error(`[approved-runtime] ${error.message}`); return 1; }
406
406
  const result = verifyApprovedRuntime({ manifest, pin });
407
407
  if (result.verdict !== 'PASS') {
408
- console.error('[approved-runtime] FAIL: archive runtime is not the owner-approved shipped code artifact');
408
+ console.error('[approved-runtime] FAIL: archive runtime is not the install-verified shipped code artifact');
409
409
  for (const failure of result.failures) console.error(` - ${failure}`);
410
410
  return 1;
411
411
  }
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // scripts/ci/warm-brain-models.mjs — download every embedder the Brain at $RUVNET_BRAIN_KB requires into
3
+ // $KB_MODEL_CACHE, resolving @xenova/transformers from that Brain's own node_modules (the reader that
4
+ // will run). Used by ci.yml's warm-brain job (seed corpus) and release-qe's capability battery (the
5
+ // exact assembled candidate), so both warm the cache the same way.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { fileURLToPath, pathToFileURL } from 'node:url';
10
+ import { requiredEmbedderModels } from '../../plugin/test/model-cache.mjs';
11
+
12
+ async function main() {
13
+ const modelCache = process.env.KB_MODEL_CACHE;
14
+ const kb = process.env.RUVNET_BRAIN_KB;
15
+ if (!modelCache || !kb) throw new Error('RUVNET_BRAIN_KB and KB_MODEL_CACHE are required');
16
+ fs.mkdirSync(modelCache, { recursive: true });
17
+ const requireFromKb = createRequire(path.join(kb, 'package.json'));
18
+ const T = await import(pathToFileURL(requireFromKb.resolve('@xenova/transformers')).href);
19
+ T.env.localModelPath = modelCache;
20
+ T.env.cacheDir = modelCache;
21
+ T.env.allowRemoteModels = true;
22
+ for (const model of requiredEmbedderModels(kb)) {
23
+ await T.pipeline('feature-extraction', model, { quantized: true });
24
+ console.log(`[warm-brain-models] ${model}`);
25
+ }
26
+ }
27
+
28
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,94 @@
1
+ // codex-hook-trust.mjs — which Brain hooks will Codex refuse to run after this update until the user
2
+ // re-reviews them? Answered OFFLINE, from the two hooks files, with Codex's own trust identity.
3
+ //
4
+ // THE MECHANISM (source, not inference — openai/codex at tag rust-v0.159.3, the installed codex-cli):
5
+ // codex-rs/hooks/src/engine/discovery.rs
6
+ // · key = hook_key("<plugin_id>:<relative hooks file>", event, group_index, handler_index)
7
+ // e.g. "ruvnet-brain@ruvnet-brain:hooks/codex-hooks.json:user_prompt_submit:0:1"
8
+ // · hash = hook_hash(): sha256 over the canonical JSON of the NORMALISED identity
9
+ // { event_name, matcher (tool/session events only), hooks: [normalised handler] } —
10
+ // command, timeout (normalised), async, statusMessage; "so equivalent hooks from config
11
+ // TOML and hooks.json converge on the same trust identity".
12
+ // · hook_trust_status(): trusted_hash == current hash -> Trusted; a different stored hash ->
13
+ // Modified; none -> Untrusted. Only Managed|Trusted handlers are pushed to run (unless
14
+ // bypass_hook_trust). So a release that edits a command's text, its timeout or its
15
+ // matcher, or that shifts a hook to another group/handler index, silently stops that
16
+ // hook on every machine until the user trusts it again.
17
+ // codex-rs/config/src/fingerprint.rs version_for_toml(): "sha256:" + hex(sha256(serde_json::to_vec(
18
+ // canonical_json(value)))) — keys sorted recursively, compact.
19
+ // codex-rs/tui/src/startup_hooks_review.rs: the interactive prompt "Hooks need review" offers
20
+ // "Review hooks" / "Trust all and continue" / "Continue without trusting (hooks won't run)".
21
+ // MEASURED 2026-10-01 in an isolated CODEX_HOME (codex-cli 0.159.3, no credentials): fresh install ->
22
+ // all 11 Brain hooks `untrusted`; trusted_hash written -> `trusted`; one command's text changed ->
23
+ // that one hook `modified`, the other ten still `trusted`. currentHash from `hooks/list` equals
24
+ // codexHookHash() below for every hook (tests/unit/codex-hook-trust.test.mjs pins the real values).
25
+ import crypto from 'node:crypto';
26
+
27
+ const EVENT_LABEL = Object.freeze({
28
+ PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use',
29
+ PreCompact: 'pre_compact', PostCompact: 'post_compact', SessionStart: 'session_start', SessionEnd: 'session_end',
30
+ UserPromptSubmit: 'user_prompt_submit', SubagentStart: 'subagent_start', SubagentStop: 'subagent_stop',
31
+ Stop: 'stop', Interrupt: 'interrupt',
32
+ });
33
+ const NO_MATCHER = new Set(['UserPromptSubmit', 'Stop', 'Interrupt']);
34
+ const CONTEXT_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'SubagentStart']);
35
+ const DEFAULT_CONTEXT_LIMIT = 2500;
36
+
37
+ function canonical(v) {
38
+ if (Array.isArray(v)) return v.map(canonical);
39
+ if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]));
40
+ return v;
41
+ }
42
+
43
+ /** discovery.rs normalize_command_hook(): SessionEnd/Interrupt default 1s, clamped 1..3; others default 600, min 1. */
44
+ function normalisedTimeout(event, t) {
45
+ const n = Number.isInteger(t) && t >= 0 ? t : null;
46
+ if (event === 'SessionEnd' || event === 'Interrupt') return Math.min(3, Math.max(1, n ?? 1));
47
+ return Math.max(1, n ?? 600);
48
+ }
49
+
50
+ /** The exact `currentHash` Codex computes for one command handler, or null for a handler Codex would skip. */
51
+ export function codexHookHash(event, group, handler) {
52
+ if (!EVENT_LABEL[event] || handler?.type !== 'command' || typeof handler.command !== 'string' || !handler.command.trim()) return null;
53
+ const h = { type: 'command', command: handler.command, timeout: normalisedTimeout(event, handler.timeout), async: handler.async === true };
54
+ if (typeof handler.statusMessage === 'string') h.statusMessage = handler.statusMessage;
55
+ const limit = handler.additionalContextLimit;
56
+ if (CONTEXT_EVENTS.has(event) && Number.isInteger(limit) && limit !== DEFAULT_CONTEXT_LIMIT) h.additionalContextLimit = limit;
57
+ const identity = { event_name: EVENT_LABEL[event], hooks: [h] };
58
+ if (!NO_MATCHER.has(event) && typeof group?.matcher === 'string') identity.matcher = group.matcher;
59
+ return `sha256:${crypto.createHash('sha256').update(JSON.stringify(canonical(identity))).digest('hex')}`;
60
+ }
61
+
62
+ /** key -> hash for every handler in a Codex hooks file ({ hooks: { Event: [groups] } }). */
63
+ export function codexHookIdentities(hooksFile, keySource = 'ruvnet-brain@ruvnet-brain:hooks/codex-hooks.json') {
64
+ const out = new Map();
65
+ for (const [event, groups] of Object.entries(hooksFile?.hooks || {})) {
66
+ if (!EVENT_LABEL[event] || !Array.isArray(groups)) continue;
67
+ groups.forEach((group, gi) => (group?.hooks || []).forEach((handler, hi) => {
68
+ const hash = codexHookHash(event, group, handler);
69
+ if (hash) out.set(`${keySource}:${EVENT_LABEL[event]}:${gi}:${hi}`, hash);
70
+ }));
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * The Brain hooks an update leaves needing review: every key of the NEW file whose hash differs from the
77
+ * OLD file's hash at the same key ('modified') or that did not exist before ('untrusted'). A hook the user
78
+ * had already trusted at the old hash stops running until reviewed; that is the whole point of the list.
79
+ */
80
+ export function codexTrustChanges(prevHooksFile, nextHooksFile, keySource) {
81
+ const prev = codexHookIdentities(prevHooksFile, keySource);
82
+ const next = codexHookIdentities(nextHooksFile, keySource);
83
+ const changes = [];
84
+ for (const [key, hash] of next) {
85
+ if (!prev.has(key)) changes.push({ key, status: 'untrusted' });
86
+ else if (prev.get(key) !== hash) changes.push({ key, status: 'modified' });
87
+ }
88
+ return changes;
89
+ }
90
+
91
+ /** The exact instruction for a user whose Brain hooks Codex is holding back. */
92
+ export const CODEX_TRUST_ACTION = 'Start Codex (interactive, in any project). At the "Hooks need review" prompt choose'
93
+ + ' "Trust all and continue" — or type /hooks and trust each RuvNet Brain hook. Until then Codex skips them'
94
+ + ' (codex exec never asks). Then re-run npx ruvnet-brain --doctor.';
@@ -37,12 +37,60 @@ export function probeRuntimeSync(port, timeoutMs = 1500) {
37
37
 
38
38
  export const sameIdentity = (left, right) => IDENTITY_KEYS.every((key) => left?.[key] === right?.[key]);
39
39
 
40
+ const MONTHS = 'JanFebMarAprMayJunJulAugSepOctNovDec';
41
+ /** When process `pid` started (epoch ms, UTC, 1 s resolution), or null when it cannot be read. */
42
+ let clockTicks = null;
40
43
  /**
41
- * Console receipts in `receiptDir`, with dead ones pruned. A receipt is dead only when BOTH hold:
42
- * its pid is not alive, and its port does not answer /api/runtime with the receipt's identity.
43
- * A receipt with no integer pid cannot be proven dead and is kept (counted as before).
44
+ * Linux: boot-relative start (/proc/<pid>/stat field 22, clock ticks since boot) anchored at the kernel's
45
+ * boot time (/proc/stat btime) — not `ps lstart`, which a forward wall-clock jump would push later.
44
46
  */
45
- export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = probeRuntimeSync } = {}) {
47
+ export function linuxProcessStartMs(pid, { readFile = fs.readFileSync, spawn = spawnSync } = {}) {
48
+ try {
49
+ const stat = String(readFile(`/proc/${pid}/stat`, 'utf8'));
50
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); // field 3 onward (comm may hold spaces)
51
+ const startTicks = Number(fields[22 - 3]);
52
+ const btime = Number(String(readFile('/proc/stat', 'utf8')).match(/^btime\s+(\d+)/m)?.[1]);
53
+ if (clockTicks === null) clockTicks = Number(String(spawn('getconf', ['CLK_TCK'], { encoding: 'utf8' })?.stdout || '').trim()) || 100;
54
+ if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) return null;
55
+ return btime * 1000 + Math.round((startTicks * 1000) / clockTicks);
56
+ } catch { return null; }
57
+ }
58
+
59
+ export function processStartMs(pid, { spawn = spawnSync, platform = process.platform, readFile } = {}) {
60
+ if (!Number.isInteger(pid) || pid <= 0) return null;
61
+ if (platform === 'linux') {
62
+ const linux = linuxProcessStartMs(pid, { spawn, ...(readFile ? { readFile } : {}) });
63
+ if (linux !== null) return linux;
64
+ }
65
+ const run = process.platform === 'win32'
66
+ ? spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
67
+ `$p=Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().ToString('ddd MMM d HH:mm:ss yyyy',[Globalization.CultureInfo]::InvariantCulture)`],
68
+ { encoding: 'utf8', windowsHide: true })
69
+ : spawn('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
70
+ if (run?.status !== 0) return null;
71
+ const m = String(run.stdout || '').trim().match(/^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/);
72
+ if (!m || MONTHS.indexOf(m[1]) % 3 !== 0) return null;
73
+ return Date.UTC(+m[6], MONTHS.indexOf(m[1]) / 3, +m[2], +m[3], +m[4], +m[5]);
74
+ }
75
+
76
+ /**
77
+ * A live pid that is NOT the receipt's Console: the process now holding that pid started after the
78
+ * Console wrote its receipt (a Console's process always starts before it records startedAt). Two seconds
79
+ * of slack cover the 1 s resolution of the start time. Unreadable start time = not provably reused.
80
+ */
81
+ export function pidReused(receipt, { startMs = processStartMs } = {}) {
82
+ const recorded = Date.parse(receipt?.startedAt || '');
83
+ const started = startMs(receipt?.pid);
84
+ return Number.isFinite(recorded) && Number.isFinite(started) && started > recorded + 2_000;
85
+ }
86
+
87
+ /**
88
+ * Console receipts in `receiptDir`, with dead ones pruned. A receipt is dead when its port does not
89
+ * answer /api/runtime with the receipt's identity AND either its pid is not alive or that pid now
90
+ * belongs to a process that started after the receipt was written (pid reuse). A busy live Console
91
+ * (pid alive, same process) is never pruned. A receipt with no integer pid cannot be proven dead.
92
+ */
93
+ export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = probeRuntimeSync, startMs = processStartMs } = {}) {
46
94
  const live = [];
47
95
  const pruned = [];
48
96
  let names = [];
@@ -53,12 +101,17 @@ export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = prob
53
101
  let receipt;
54
102
  try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
55
103
  if (receipt?.product !== PRODUCT || receipt.schema !== 1) continue;
56
- if (Number.isInteger(receipt.pid) && receipt.pid > 0 && !alive(receipt.pid)) {
57
- const answer = probe(receipt.port);
58
- if (!answer || !sameIdentity(answer, receipt)) {
59
- try { fs.unlinkSync(file); } catch { /* raced or read-only: still not a running Console */ }
60
- pruned.push({ file, pid: receipt.pid, port: receipt.port ?? null, startedAt: receipt.startedAt ?? null });
61
- continue;
104
+ if (Number.isInteger(receipt.pid) && receipt.pid > 0) {
105
+ const dead = !alive(receipt.pid);
106
+ const reused = !dead && pidReused(receipt, { startMs });
107
+ if (dead || reused) {
108
+ const answer = probe(receipt.port);
109
+ if (!answer || !sameIdentity(answer, receipt)) {
110
+ try { fs.unlinkSync(file); } catch { /* raced or read-only: still not a running Console */ }
111
+ pruned.push({ file, pid: receipt.pid, port: receipt.port ?? null, startedAt: receipt.startedAt ?? null,
112
+ reason: dead ? 'pid not alive' : 'pid reused by a process started after the receipt' });
113
+ continue;
114
+ }
62
115
  }
63
116
  }
64
117
  live.push({ file, receipt });
@@ -73,8 +126,13 @@ export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = prob
73
126
  // process plumbing. A denylist, so nothing the Console legitimately reads is silently lost.
74
127
  const CONSOLE_ENV_DENY = new Set(['RUVNET_NIGHTLY', 'RUVNET_BRAIN_TEST', 'RUVNET_BRAIN_TEST_LATEST_TAG', 'RUVNET_BRAIN_SCHEDULER_TEST',
75
128
  'RUVNET_BRAIN_IMPORT_ONLY', 'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_STRICT_INSTALL', 'RUVNET_REFRESH_RUN_TOKEN',
76
- 'RUVNET_REFRESH_RECEIPT', 'RUVNET_UPGRADE_NOTICE_FILE', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'INIT_CWD', 'CONSOLE_PORT']);
77
- const CONSOLE_ENV_DENY_PREFIX = /^(?:RUVNET_NIGHTLY_|npm_|VITEST)/;
129
+ 'RUVNET_REFRESH_RECEIPT', 'RUVNET_UPGRADE_NOTICE_FILE', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'INIT_CWD', 'CONSOLE_PORT',
130
+ // The launching host session's own identity (the spine version it booted, the hook host, its Node).
131
+ 'RUVNET_BRAIN_ACTIVE_VERSION', 'RUVNET_HOOK_HOST', 'RUVNET_NODE_BIN']);
132
+ // CLAUDE* describes the Claude Code SESSION that ran the installer (CLAUDECODE, CLAUDE_PROJECT_DIR,
133
+ // CLAUDE_PLUGIN_ROOT, CLAUDE_SESSION_ID, CLAUDE_CODE_*): stale for a long-lived Console. CLAUDE_CONFIG_DIR
134
+ // is the user's configuration location, not session state, and is kept (host-update.mjs forwards it too).
135
+ const CONSOLE_ENV_DENY_PREFIX = /^(?:RUVNET_NIGHTLY_|npm_|VITEST|CLAUDE(?!_CONFIG_DIR$))/;
78
136
  export function consoleEnv(env = process.env, port) {
79
137
  const clean = Object.fromEntries(Object.entries(env).filter(([key, value]) => value != null
80
138
  && !CONSOLE_ENV_DENY.has(key) && !CONSOLE_ENV_DENY_PREFIX.test(key)));
@@ -51,6 +51,11 @@ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
51
51
  // ERR_MODULE_NOT_FOUND on import, before a single line runs.
52
52
  'kb/corpus-release-identity.mjs',
53
53
  'bin/install.mjs',
54
+ // The Console's Settings → Nightly switch runs the copied installer's --enable-nightly, which installs
55
+ // THIS file (beside it, REPO_ROOT/bin) as the scheduler's immutable runner. Without it every installed
56
+ // customer got "nightly runner source is missing" (RNBC review 2026-10-01). It imports only node:
57
+ // built-ins, so nothing further travels with it.
58
+ 'bin/nightly-refresh.mjs',
54
59
  'package.json',
55
60
  ]);
56
61