ruvnet-brain 4.5.5 → 4.5.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.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +24 -6
  3. package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
  4. package/config/model-router/claude-terminal-mod/README.md +22 -0
  5. package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
  6. package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
  7. package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
  8. package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
  9. package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
  10. package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
  11. package/config/model-router/policy.default.mjs +4 -1
  12. package/kb/forge-update.mjs +50 -9
  13. package/package.json +4 -3
  14. package/plugin/.claude-plugin/plugin.json +1 -1
  15. package/plugin/.codex-plugin/plugin.json +1 -1
  16. package/plugin/commands/configure.md +13 -1
  17. package/plugin/hooks/hook-contracts.json +1 -1
  18. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  19. package/plugin/mcp/managed-cli-interface.mjs +45 -16
  20. package/plugin/mcp/server.mjs +3 -2
  21. package/plugin/scripts/capability-claim-evidence.mjs +9 -2
  22. package/plugin/scripts/continuation-gate.mjs +3 -1
  23. package/plugin/scripts/continuity-journal.mjs +11 -11
  24. package/plugin/scripts/hook-shim.mjs +2 -1
  25. package/plugin/scripts/kb-copy-proof.mjs +4 -3
  26. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  27. package/plugin/scripts/project-capture-queue.mjs +23 -8
  28. package/plugin/scripts/project-progression-hook.mjs +16 -7
  29. package/plugin/scripts/project-progression-outbox.mjs +116 -19
  30. package/plugin/scripts/project-progression-producer.mjs +12 -1
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-store.mjs +53 -1
  33. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  34. package/plugin/scripts/project-transition-hook.mjs +44 -16
  35. package/plugin/scripts/session-snapshot-hook.mjs +16 -8
  36. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  37. package/scripts/claude-terminal-mod.mjs +89 -0
  38. package/scripts/model-routing-gateway.mjs +47 -14
  39. package/scripts/model-routing-launchers.mjs +36 -1
  40. package/scripts/model-terminal-gateway.mjs +310 -0
  41. package/scripts/model-terminal-launchers.mjs +300 -0
  42. package/scripts/release-qualification-contract.mjs +37 -6
  43. package/scripts/release-qualification.mjs +9 -6
package/README.md CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.5 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.5-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.7 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.7-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
package/bin/install.mjs CHANGED
@@ -80,7 +80,8 @@ import {
80
80
  import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
81
81
  import { codexTrustChanges, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
82
82
  import { repairSecurityGuidance } from '../scripts/security-guidance-codex-compat.mjs';
83
- import { installNativeLaunchers } from '../scripts/model-routing-launchers.mjs';
83
+ import { installNativeLaunchers, installRoutingRuntime } from '../scripts/model-routing-launchers.mjs';
84
+ import { installTerminalLaunchers } from '../scripts/model-terminal-launchers.mjs';
84
85
  import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
85
86
  import { moveBrain, MoveRefused } from '../scripts/move-brain.mjs';
86
87
  import { brainLocation } from '../plugin/scripts/brain-location.mjs';
@@ -1631,6 +1632,7 @@ function codexManagedBlock(serverPath) {
1631
1632
  'command = "node"',
1632
1633
  `args = [${JSON.stringify(serverPath)}]`,
1633
1634
  'startup_timeout_sec = 30',
1635
+ 'env = { RUVNET_HOOK_HOST = "codex" }',
1634
1636
  CODEX_BLOCK_END,
1635
1637
  ].join('\n');
1636
1638
  }
@@ -1837,6 +1839,10 @@ export function wireCodexHost({
1837
1839
  // land where the server will look for them, and the walk is transitive because a dependency's own
1838
1840
  // dependency is no less required.
1839
1841
  const deps = serverDependencies(source);
1842
+ // Progression capture reads its adapter version from this runtime resource, not an import.
1843
+ // Keep it beside the copied plugin scripts, bound to the same source as the MCP shell.
1844
+ deps.push({ spec: '../.claude-plugin/plugin.json',
1845
+ from: path.resolve(path.dirname(source), '..', '.claude-plugin', 'plugin.json') });
1840
1846
  const missing = deps.filter((d) => !fs.existsSync(d.from));
1841
1847
  if (missing.length) {
1842
1848
  if (announce) warn(`MCP server dependency missing from this bundle (${missing.map((d) => d.spec).join(', ')}) — Codex left untouched (non-fatal)`);
@@ -4913,18 +4919,28 @@ export function syncManagedRouterDefault({ routerDir = path.join(os.homedir(), '
4913
4919
  }
4914
4920
 
4915
4921
  export function refreshInstalledModelLaunchers({ sourceRoot = REPO_ROOT, home = os.homedir() } = {}) {
4922
+ const terminalFile = path.join(home, '.cache', 'ruvnet-brain', 'model-routing', 'terminal-launcher-config.json');
4923
+ let terminal = { action: 'not-installed' };
4924
+ if (fs.existsSync(terminalFile)) {
4925
+ const previous = JSON.parse(fs.readFileSync(terminalFile, 'utf8'));
4926
+ const runtime = installRoutingRuntime({ sourceRoot, home, apply: true });
4927
+ const receipt = installTerminalLaunchers({ home, ...runtime, nodeBinary: previous.nodeBinary,
4928
+ realCodex: previous.realCodex, realClaude: previous.realClaude, apply: true });
4929
+ terminal = { action: previous.runtimeDigest === runtime.runtimeDigest ? 'unchanged' : 'updated',
4930
+ runtimeDigest: runtime.runtimeDigest, shellConflicts: receipt.shellConflicts };
4931
+ }
4916
4932
  const file = path.join(home, '.cache', 'ruvnet-brain', 'model-routing', 'launcher-config.json');
4917
- if (!fs.existsSync(file)) return { action: 'not-installed' };
4933
+ if (!fs.existsSync(file)) return { action: 'not-installed', terminal };
4918
4934
  const previous = JSON.parse(fs.readFileSync(file, 'utf8'));
4919
4935
  const receipt = installNativeLaunchers({ sourceRoot, home, extensionsRoot: previous.extensionsRoot, nodeBinary: previous.nodeBinary, apply: true });
4920
- return { action: receipt.config.runtimeDigest === previous.runtimeDigest ? 'unchanged' : 'updated', runtimeDigest: receipt.config.runtimeDigest };
4936
+ return { action: receipt.config.runtimeDigest === previous.runtimeDigest ? 'unchanged' : 'updated', runtimeDigest: receipt.config.runtimeDigest, terminal };
4921
4937
  }
4922
4938
 
4923
4939
  export function syncManagedRouterTools({ routerDir = path.join(os.homedir(), '.claude', 'model-router'), packageRoot = REPO_ROOT } = {}) {
4924
4940
  const destination = path.join(routerDir, 'bin');
4925
4941
  fs.mkdirSync(destination, { recursive: true });
4926
4942
  let copied = 0;
4927
- for (const name of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'subscription-hosts.mjs', 'review-model-defaults.mjs', 'dual-host-deliberation.mjs', 'dual-host-suggest.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'metaharness-router.mjs', 'model-router-dispatch.mjs', 'model-currency.mjs', 'model-currency-evidence.mjs', 'user-model-prompt-hook.mjs', 'model-router-agent-hook.mjs', 'model-weekly-assessment.mjs', 'model-weekly-analyst.mjs', 'model-weekly-cycle.mjs', 'model-weekly-qualification.mjs', 'model-native-qualification.mjs', 'model-routing-launchers.mjs', 'model-native-catalog.mjs', 'model-analyst-sandbox.mjs', 'model-routing-policy-promotion.mjs', 'model-routing-gateway.mjs', 'native-subscription-usage.mjs', 'codex-routed.sh', 'goldie-weekly.sh']) {
4943
+ for (const name of ['model-router-engine.mjs', 'model-router-setup.mjs', 'model-router-status.mjs', 'model-router-outcome.mjs', 'subscription-hosts.mjs', 'review-model-defaults.mjs', 'dual-host-deliberation.mjs', 'dual-host-suggest.mjs', 'route-cheap.mjs', 'dispatch-receipt.mjs', 'metaharness-receipts.mjs', 'metaharness-router.mjs', 'model-router-dispatch.mjs', 'model-currency.mjs', 'model-currency-evidence.mjs', 'user-model-prompt-hook.mjs', 'model-router-agent-hook.mjs', 'model-weekly-assessment.mjs', 'model-weekly-analyst.mjs', 'model-weekly-cycle.mjs', 'model-weekly-qualification.mjs', 'model-native-qualification.mjs', 'model-routing-launchers.mjs', 'model-terminal-gateway.mjs', 'model-terminal-launchers.mjs', 'claude-terminal-mod.mjs', 'model-native-catalog.mjs', 'model-analyst-sandbox.mjs', 'model-routing-policy-promotion.mjs', 'model-routing-gateway.mjs', 'native-subscription-usage.mjs', 'codex-routed.sh', 'goldie-weekly.sh']) {
4928
4944
  const source = path.join(packageRoot, 'scripts', name);
4929
4945
  if (!fs.existsSync(source)) throw new Error(`Packaged router tool missing: ${name}`);
4930
4946
  const target = path.join(destination, name);
@@ -4935,8 +4951,9 @@ export function syncManagedRouterTools({ routerDir = path.join(os.homedir(), '.c
4935
4951
  finally { fs.rmSync(temporary, { force: true }); }
4936
4952
  copied++;
4937
4953
  }
4938
- // Preserve the package-relative import used by the optional legacy routing helper.
4939
- const runtimeRelative = path.join('plugin', 'scripts', 'runtime-preferences.mjs');
4954
+ // Preserve package-relative imports without replacing user policy.mjs overrides.
4955
+ for (const runtimeRelative of [path.join('plugin', 'scripts', 'runtime-preferences.mjs'),
4956
+ path.join('config', 'model-router', 'policy.default.mjs')]) {
4940
4957
  const runtimeTarget = path.join(routerDir, runtimeRelative);
4941
4958
  const runtimeBytes = fs.readFileSync(path.join(packageRoot, runtimeRelative));
4942
4959
  if (!fs.existsSync(runtimeTarget) || !fs.readFileSync(runtimeTarget).equals(runtimeBytes)) {
@@ -4946,6 +4963,7 @@ export function syncManagedRouterTools({ routerDir = path.join(os.homedir(), '.c
4946
4963
  finally { fs.rmSync(temporary, { force: true }); }
4947
4964
  copied++;
4948
4965
  }
4966
+ }
4949
4967
  const qualificationSource = path.join(packageRoot, 'config', 'model-router', 'qualification-contract.json');
4950
4968
  const qualificationTarget = path.join(routerDir, 'qualification-contract.json');
4951
4969
  const qualificationBytes = fs.readFileSync(qualificationSource);
@@ -0,0 +1 @@
1
+ {"name":"rnb-terminal-routing","version":"0.1.0","description":"Reviewed per-user native terminal routing"}
@@ -0,0 +1,22 @@
1
+ Updated: 2026-10-04 16:10:00 EDT | Version 0.1.1
2
+ Created: 2026-10-04 16:10:00 EDT
3
+
4
+ # Native terminal routing prototype
5
+
6
+ Tested against the installed Claude Code 2.1.289 native binary and its generated mod declarations. Mods require 2.1.287 or later. This routes the native host's own `turn.step` middleware: it passes model and effort to `yield* next(...)`, preserving the native streaming result and TUI. It does not call a separate inference API or inject slash commands.
7
+
8
+ `prepareClaudeTerminalMod({ destination })` in `scripts/claude-terminal-mod.mjs` materializes a private plugin directory, generates absolute Node/helper/engine paths, and copies the approved pure classifier into the mod. The source directory is deliberately unprepared. No Node APIs run inside sandboxed mod code. `$.process.run` invokes the bridge with prompt JSON on stdin, never in arguments. The existing policy engine validates current per-user catalog, profile, reviewed model/effort allocation and subscription eligibility. Review age remains evidence for the weekly refresh and does not revoke the owner-approved allocation; it supplies no native/default/metered fallback.
9
+
10
+ A launcher must provide `RNB_CLAUDE_MOD_NONCE` (64 lowercase hexadecimal characters) and `RNB_CLAUDE_MOD_RECEIPT` (absolute receipt filename in an already prepared private directory). `session.start` calls the bridge to write an atomic, mode-0600 activation receipt. The launcher must compare nonce, exact real plugin root, native version, source digest, timestamp and live process state before accepting startup. `prepareClaudeTerminalMod` returns `modDigest`; `terminalModDigest` recomputes it. The receipt's `pid` is the bridge parent's mod worker, not an asserted main CLI PID. `--health` supports the same bounded receipt schema; it does not invent a native crash guard.
11
+
12
+ `prompt.submit` approves the prompt once. Its optional `turnId` denotes a running turn, not the upcoming idle turn. A bounded FIFO stores pending text and decisions in RAM only. `turn.start` binds the next approved prompt to the newly minted exact turn ID. If native settings hooks settled different text, the bridge reclassifies that text using the original task class as a minimum. Image-only submissions use a conservative hard-review classification while passing original attachments and text unchanged. Every backend step keeps the bound model/effort and exact turn ID/index. Completion and session end remove decisions. Queue/active maps cap at 32 entries; pending prompts expire after five minutes. Direct mid-turn deliveries (a running `turnId` without `wait`) refuse because no new authoritative `turn.start` exists; queue the prompt to start its own turn. Subagent steps and unbound continuations refuse instead of borrowing the main turn's decision.
13
+
14
+ A prompt/helper failure returns `{ drop }` without `next`. A step failure returns an immediate native refusal result without `next`, retaining turn ID/index. Every consequential hook has a `.catch` handler whose refusal does no process, filesystem or model work. Missing activation refuses subsequent prompts and steps.
15
+
16
+ ## Failure boundary
17
+
18
+ Claude Code can skip all hooks if the mod fails to load, the native mod worker crashes, hooks are disabled, another guard refuses registration, or a hook and its catch handler both fail. Other mods later in the chain can rewrite model/effort. A startup receipt proves that the activation hook ran for that nonce and source digest; it is not a perpetual guarantee that routing hooks remain active. This prototype cannot claim universal fail-closed enforcement. The root CLI wrapper owns the startup guard and any further runtime monitoring. Native model inference and rendered TUI acceptance remain separate checks; plugin tests stub the engine boundary and incur no inference.
19
+
20
+ ## Checks
21
+
22
+ Run `claude plugin validate config/model-router/claude-terminal-mod`, `claude plugin test config/model-router/claude-terminal-mod`, and `vitest run tests/unit/claude-terminal-mod.test.mjs`. The native tests exercise the installed mod runtime, including two different turn allocations, high coding effort, final-text class floors, missing decisions and helper exceptions. Unit tests call the actual policy engine against isolated catalog/profile/policy fixtures, including stale allocation, invalid policy, absent hard model and disabled subscription states.
@@ -0,0 +1 @@
1
+ {"modules":["./register.js"]}
@@ -0,0 +1,2 @@
1
+ // Source-only adapter. Materialization copies the canonical classifier into the sandbox.
2
+ export { classify } from '../../policy.default.mjs';
@@ -0,0 +1,66 @@
1
+ import runtime from './runtime.js';
2
+ import { createTurnCache, inspectDecision, classificationText, refusalStream, REFUSAL } from './routing.js';
3
+ export function register(on) {
4
+ const cache = createTurnCache();
5
+ let ready = false;
6
+ on('session.start', async ($, e, next) => {
7
+ ready = false; cache.clear();
8
+ const nonce = await $.env.get('RNB_CLAUDE_MOD_NONCE');
9
+ const receiptPath = await $.env.get('RNB_CLAUDE_MOD_RECEIPT');
10
+ const version = (await $.session.version()).version;
11
+ const sessionId = await $.session.id();
12
+ const pluginRoot = $.plugin.root;
13
+ const result = await $.process.run([runtime.nodePath, runtime.helperPath, '--ready'], {
14
+ stdin: JSON.stringify({ nonce, receiptPath, version, sessionId, pluginRoot }), timeoutMs: 8000,
15
+ });
16
+ if (result.exitCode !== 0) throw new Error(REFUSAL);
17
+ const receipt = JSON.parse(result.stdout);
18
+ if (receipt.nonce !== nonce || receipt.status !== 'ready') throw new Error(REFUSAL);
19
+ ready = true; return next(e);
20
+ }).catch(($, e) => { ready = false; return { cwd: e.cwd }; });
21
+ on('prompt.submit', async ($, e, next) => {
22
+ // Direct delivery into a running turn has no authoritative new turn.start boundary.
23
+ if (e.turnId && !e.wait) return { drop: REFUSAL };
24
+ if (!ready || typeof e.text !== 'string' || (!e.text.trim() && !e.attachments?.length) || e.text.length > 200000) return { drop: REFUSAL };
25
+ const result = await $.process.run([runtime.nodePath, runtime.helperPath, '--decision'], {
26
+ stdin: JSON.stringify({ prompt: classificationText(e.text, e.attachments), enginePath: runtime.enginePath, policyPath: runtime.policyPath }), timeoutMs: 8000,
27
+ });
28
+ if (result.exitCode !== 0) return { drop: REFUSAL };
29
+ const now = await $.clock.now();
30
+ const decision = inspectDecision(JSON.parse(result.stdout), classificationText(e.text, e.attachments), now);
31
+ const entry = cache.enqueue(e.text, decision, now);
32
+ try {
33
+ const answer = await next(e);
34
+ if ('drop' in answer) cache.remove(entry);
35
+ return answer;
36
+ } catch (error) { cache.remove(entry); throw error; }
37
+ }).catch(() => ({ drop: REFUSAL }));
38
+ on('turn.start', async ($, e, next) => {
39
+ if (!ready) throw new Error(REFUSAL);
40
+ const now = await $.clock.now();
41
+ const pending = cache.pending();
42
+ if (!pending) throw new Error(REFUSAL);
43
+ if (pending?.text === e.text) cache.bind(e, now);
44
+ else {
45
+ // Settings hooks may settle a different prompt. Reassess with the original class floor.
46
+ const minimumClass = pending?.decision.taskClass;
47
+ const text = classificationText(e.text, pending ? [{}] : undefined);
48
+ const result = await $.process.run([runtime.nodePath, runtime.helperPath, '--decision'], {
49
+ stdin: JSON.stringify({ prompt: text, minimumClass, enginePath: runtime.enginePath, policyPath: runtime.policyPath }), timeoutMs: 8000,
50
+ });
51
+ if (result.exitCode !== 0) throw new Error(REFUSAL);
52
+ const decision = inspectDecision(JSON.parse(result.stdout), text, now, minimumClass);
53
+ cache.bind(e, now, decision);
54
+ }
55
+ return next(e);
56
+ }).catch(($, e) => ({ turnId: e.turnId }));
57
+ on('turn.step', async function* ($, e, next) {
58
+ if (!ready) return yield* refusalStream(e);
59
+ const decision = cache.get(e, await $.clock.now());
60
+ return yield* next({ ...e, model: decision.model, effort: decision.effort });
61
+ }).catch(async function* ($, e) { return yield* refusalStream(e); });
62
+ on('turn.complete', async ($, e, next) => { cache.complete(e.turnId); return next(e); })
63
+ .catch(($, e) => { cache.complete(e.turnId); return { text: REFUSAL }; });
64
+ on('session.end', async ($, e, next) => { ready = false; cache.clear(); return next(e); })
65
+ .catch(($, e) => { ready = false; cache.clear(); return { sessionId: e.sessionId }; });
66
+ }
@@ -0,0 +1,55 @@
1
+ import { classify } from './policy.default.mjs';
2
+ export const REFUSAL = 'Reviewed terminal routing unavailable; request refused without model fallback.';
3
+ export function refusal(e) {
4
+ return { turnId: e.turnId, index: e.index, answer: REFUSAL, toolUses: [], stopReason: 'refusal', usage: null };
5
+ }
6
+ export async function* refusalStream(e) {
7
+ yield { kind: 'text', index: 0, text: REFUSAL };
8
+ yield { kind: 'stop', stopReason: 'refusal', usage: null };
9
+ return refusal(e);
10
+ }
11
+ export function classificationText(text, attachments) {
12
+ return text.trim() ? text : attachments?.length ? 'final substantive review of supplied attachments' : text;
13
+ }
14
+ export function inspectDecision(value, text, now, minimumClass) {
15
+ if (value?.schemaVersion !== 1 || value.subscriptionCovered !== true ||
16
+ !/^claude-[a-z0-9][a-z0-9.-]*$/.test(value.model || '') ||
17
+ !['low', 'medium', 'high', 'xhigh', 'max'].includes(value.effort) ||
18
+ !Number.isFinite(value.expiresAt) ||
19
+ !/^[a-f0-9]{64}$/.test(value.routeDigest || '')) throw new Error(REFUSAL);
20
+ text = minimumClass === 'hard' ? 'final substantive review\n' + text : minimumClass === 'medium' ? 'review task\n' + text : text;
21
+ const codeFences = Math.floor((text.match(/```/g) || []).length / 2);
22
+ const hasCode = codeFences > 0 || /\b(function|const|let|def|class|import|=>|SELECT|async)\b/.test(text) || /[{};]\s*$/m.test(text);
23
+ const rank = { fast: 0, medium: 1, hard: 2 };
24
+ const floor = classify({ taskHints: text, hasCode }, 'claude-code');
25
+ if (!Object.hasOwn(rank, value.taskClass) || rank[value.taskClass] < rank[floor]) throw new Error(REFUSAL);
26
+ return Object.freeze({ model: value.model, effort: value.effort, taskClass: value.taskClass, expiresAt: value.expiresAt });
27
+ }
28
+ // Prompt text exists only in this bounded in-memory queue; Claude mints turnId later.
29
+ export function createTurnCache() {
30
+ const pending = [];
31
+ const active = new Map();
32
+ return {
33
+ enqueue(text, decision, now) {
34
+ while (pending.length && now - pending[0].createdAt > 300000) pending.shift();
35
+ if (pending.length >= 32) throw new Error(REFUSAL);
36
+ const entry = { text, decision, createdAt: now }; pending.push(entry); return entry;
37
+ },
38
+ remove(entry) { const index = pending.indexOf(entry); if (index >= 0) pending.splice(index, 1); },
39
+ pending() { return pending[0]; },
40
+ bind(e, now, replacement) {
41
+ // Never substitute the most recent prompt or the model shown in the TUI.
42
+ const entry = pending[0];
43
+ if ((!entry && !replacement) || (!replacement && entry.text !== e.text) || (entry && now - entry.createdAt > 300000) ||
44
+ active.has(e.turnId) || active.size >= 32) throw new Error(REFUSAL);
45
+ if (entry) pending.shift(); active.set(e.turnId, replacement || entry.decision);
46
+ },
47
+ get(e, now) {
48
+ const decision = active.get(e.turnId);
49
+ if (!decision || e.agentId || !Number.isSafeInteger(e.index) || e.index < 0) throw new Error(REFUSAL);
50
+ return decision;
51
+ },
52
+ complete(turnId) { active.delete(turnId); },
53
+ clear() { pending.length = 0; active.clear(); },
54
+ };
55
+ }
@@ -0,0 +1,2 @@
1
+ // This source directory refuses activation until prepareClaudeTerminalMod generates real paths.
2
+ export default { nodePath: 'node', helperPath: '/unprepared-rnb-terminal-mod/helper.mjs', enginePath: '', policyPath: '' };
@@ -0,0 +1,86 @@
1
+ import type { On, HookStream, TurnStepChunk, TurnStepResult } from 'claude-code';
2
+ import { expect, test } from 'claude-code/testing';
3
+ const nonce = 'a'.repeat(64);
4
+ const digest = 'b'.repeat(64);
5
+ const now = 1791120000000;
6
+ function stubs(on: On, decisions: Record<string, unknown>[], captures: Record<string, unknown>[], fail = false) {
7
+ on('clock.now', () => ({ value: now }));
8
+ on('env.get', ($, e) => ({ value: e.name === 'RNB_CLAUDE_MOD_NONCE' ? nonce : '/tmp/receipt.json' }));
9
+ on('session.version', () => ({ value: { version: '2.1.289' } }));
10
+ on('session.id', () => ({ value: 'native-fixture-session' }));
11
+ on('process.run', ($, e) => {
12
+ if (e.argv.includes('--ready')) return { value: { exitCode: 0, stdout: JSON.stringify({nonce,status:'ready'}), stderr:'',isStdoutTruncated:false,isStderrTruncated:false } };
13
+ captures.push(JSON.parse(e.init!.stdin!));
14
+ const decision = decisions.shift();
15
+ if (fail) throw new Error('helper failed');
16
+ return { value: {exitCode:0, stdout:JSON.stringify({schemaVersion:1,subscriptionCovered:true,expiresAt:now+60000,routeDigest:digest,...decision}),stderr:'',isStdoutTruncated:false,isStderrTruncated:false} };
17
+ });
18
+ on('session.start', ($, e) => ({cwd:e.cwd}));
19
+ on('prompt.submit', ($, e) => { if(e.attachments) captures.push({passedAttachments:e.attachments});return {text:e.text}; });
20
+ on('turn.start', ($, e) => ({turnId:e.turnId}));
21
+ on('turn.step', async function* ($, e) {
22
+ captures.push({model:e.model,effort:e.effort,turnId:e.turnId,index:e.index});
23
+ yield {kind:'text',index:0,text:'native stream preserved'};
24
+ return {turnId:e.turnId,index:e.index,answer:'native stream preserved',toolUses:[],stopReason:'end_turn',usage:null};
25
+ });
26
+ }
27
+ async function resultOf(stream: HookStream<TurnStepChunk, TurnStepResult>) { let result; for (;;) {const item=await stream.next();if(item.done){result=item.value;break;}} return result; }
28
+ test('two user turns route different models and efforts while streaming native results', async ($, on) => {
29
+ const captures: Record<string, unknown>[]=[];
30
+ stubs(on,[{model:'claude-sonnet-fixture',effort:'low',taskClass:'fast'},{model:'claude-opus-fixture',effort:'high',taskClass:'hard'}],captures);
31
+ await $.session.start({cwd:'/tmp',surface:'terminal',isInteractive:true});
32
+ for (const [turnId,text] of [['one','summarize these supplied notes'],['two','perform a security audit and prove correctness']] as const) {
33
+ await $.prompt.submit({text});
34
+ await $.turn.start({text,turnId});
35
+ const result=await resultOf($.turn.step({turnId,index:0,model:'wrong-default',effort:'medium',messageCount:1}));
36
+ expect(result.answer).toBe('native stream preserved');
37
+ }
38
+ expect(captures.filter(x=>x.turnId)).toEqual([
39
+ {model:'claude-sonnet-fixture',effort:'low',turnId:'one',index:0},
40
+ {model:'claude-opus-fixture',effort:'high',turnId:'two',index:0},
41
+ ]);
42
+ });
43
+ test('unbound model step refuses without passing to core', async ($, on) => {
44
+ const captures: Record<string, unknown>[]=[];stubs(on,[],captures);
45
+ await $.session.start({cwd:'/tmp',surface:'terminal',isInteractive:true});
46
+ const result=await resultOf($.turn.step({turnId:'missing',index:7,model:'wrong',messageCount:1}));
47
+ expect(result.stopReason).toBe('refusal');expect(result.turnId).toBe('missing');expect(result.index).toBe(7);
48
+ expect(captures.length).toBe(0);
49
+ });
50
+ test('helper exception drops prompt through immediate catch', async ($, on) => {
51
+ const captures: Record<string, unknown>[]=[];stubs(on,[],captures,true);
52
+ await $.session.start({cwd:'/tmp',surface:'terminal',isInteractive:true});
53
+ expect('drop' in await $.prompt.submit({text:'summarize notes'})).toBe(true);
54
+ });
55
+ test('coding uses high effort and final hook rewrite keeps original hard floor', async ($, on) => {
56
+ const captures: Record<string, unknown>[]=[];
57
+ stubs(on,[{model:'claude-sonnet-fixture',effort:'high',taskClass:'medium'},
58
+ {model:'claude-opus-fixture',effort:'high',taskClass:'hard'},
59
+ {model:'claude-opus-fixture',effort:'high',taskClass:'hard'}],captures);
60
+ await $.session.start({cwd:'/tmp',surface:'terminal',isInteractive:true});
61
+ await $.prompt.submit({text:'implement this module'});await $.turn.start({text:'implement this module',turnId:'code'});
62
+ await resultOf($.turn.step({turnId:'code',index:1,model:'wrong',messageCount:2}));
63
+ await $.prompt.submit({text:'security audit'});await $.turn.start({text:'summarize notes',turnId:'changed'});
64
+ await resultOf($.turn.step({turnId:'changed',index:0,model:'wrong',messageCount:2}));
65
+ expect(captures.some(x=>x.minimumClass==='hard')).toBe(true);
66
+ expect(captures.filter(x=>x.turnId).map(x=>x.effort)).toEqual(['high','high']);
67
+ });
68
+
69
+ test('invalid or non-subscription bridge replies drop before prompt reaches core', async ($, on) => {
70
+ const captures: Record<string, unknown>[]=[];
71
+ stubs(on,[{model:'claude-sonnet-fixture',effort:'low',taskClass:'fast',subscriptionCovered:false},
72
+ {model:'claude-sonnet-fixture',effort:'low',taskClass:'fast',routeDigest:'invalid'}],captures);
73
+ await $.session.start({cwd:'/tmp',surface:'terminal',isInteractive:true});
74
+ expect('drop' in await $.prompt.submit({text:'summarize notes'})).toBe(true);
75
+ expect('drop' in await $.prompt.submit({text:'summarize notes'})).toBe(true);
76
+ });
77
+ test('image-only user prompt retains attachments and binds a conservative hard allocation', async ($, on) => {
78
+ const captures: Record<string, unknown>[]=[];
79
+ stubs(on,[{model:'claude-opus-fixture',effort:'high',taskClass:'hard'}],captures);
80
+ await $.session.start({cwd:'/tmp',surface:'terminal',isInteractive:true});
81
+ await $.prompt.submit({text:'',attachments:[{type:'image',mediaType:'image/png',filename:'fixture.png'}]});
82
+ await $.turn.start({text:'',turnId:'image'});
83
+ await resultOf($.turn.step({turnId:'image',index:0,model:'wrong',messageCount:1}));
84
+ expect(captures.find(x=>x.passedAttachments)?.passedAttachments).toEqual([{type:'image',mediaType:'image/png',filename:'fixture.png'}]);
85
+ expect(captures.filter(x=>x.turnId)[0]?.model).toBe('claude-opus-fixture');
86
+ });
@@ -57,10 +57,13 @@ export function classify(features, harness = features.harness || 'codex') {
57
57
  const text = String(features.taskHints || '');
58
58
  const coding = features.hasCode || CODING.test(text);
59
59
  const assessedText = assessmentText(text);
60
+ const securityActions = assessedText.replace(/\bdo not (?:assess|recommend)[^.;\n]*/gi, '');
61
+ const securityReview = /\b(security|risks?)\b/i.test(securityActions) &&
62
+ /\b(review|assess(?:ment)?|audit|evaluat\w*|analy[sz]\w*|identify|determine)\b/i.test(securityActions);
60
63
  const consequential = /\b(consequential planning|substantive planning|substantive review|final substantive review|plan (?:a |the )?new system|design (?:a |the )?new architecture|ambiguous architecture|architecture ambiguity|architectur\w* tradeoff|tightly coupled uncertain implementation|uncertain tightly coupled implementation)\b/i.test(text);
61
64
  const architectureAmbiguity = /architectur\w*/i.test(text) && /\b(ambiguous|ambiguity|unresolved|uncertain|trade[- ]?off)\b/i.test(text);
62
65
  const coupledUncertainty = /tightly coupled/i.test(text) && /implementation|coding/i.test(text) && /uncertain|unresolved|ambiguous/i.test(text);
63
- const hardText = HARD.test(assessedText) || consequenceFloor(assessedText) || consequential || architectureAmbiguity || coupledUncertainty;
66
+ const hardText = HARD.test(assessedText) || securityReview || consequenceFloor(assessedText) || consequential || architectureAmbiguity || coupledUncertainty;
64
67
  const substantialText = /\b(substantial (?:implementation|coding|feature|task)|cross-module (?:implementation|feature|refactor)|multi-file (?:implementation|feature|refactor)|end-to-end implementation|broad refactor)\b/i.test(text);
65
68
  const facts = validateTaskFacts(features.taskFacts);
66
69
  // Partial caller metadata supplements the assessment; it cannot lower explicit high-consequence text.
@@ -1405,6 +1405,35 @@ export function bundleIdentity(src) {
1405
1405
  return parts.some(Boolean) ? parts.join('|') : null;
1406
1406
  }
1407
1407
 
1408
+ const downloadedArchiveVerifiers = new WeakSet();
1409
+
1410
+ function archiveVerifierFromDigests(actualDigests, bytes) {
1411
+ const verifier = Object.freeze({
1412
+ bytes,
1413
+ digest(algorithm = 'sha256') {
1414
+ if (!Object.hasOwn(actualDigests, algorithm)) throw new Error(`archive algorithm ${algorithm} was not captured`);
1415
+ return actualDigests[algorithm];
1416
+ },
1417
+ });
1418
+ downloadedArchiveVerifiers.add(verifier);
1419
+ return verifier;
1420
+ }
1421
+
1422
+ // Capture the closed transaction algorithm inventory synchronously. Never retain mutable bytes:
1423
+ // the capability attests only to this creation-time snapshot, not a later caller-owned Buffer.
1424
+ export function createDownloadedArchiveVerifier(downloadedBuffer, expectedDigests = []) {
1425
+ if (!Buffer.isBuffer(downloadedBuffer) || !Array.isArray(expectedDigests)) throw new TypeError('archive bytes and digest inventory required');
1426
+ const algorithms = new Set(['sha256']);
1427
+ for (const expected of expectedDigests) {
1428
+ if (expected == null) continue;
1429
+ if (typeof expected !== 'string' || !expected) throw new TypeError('invalid archive digest');
1430
+ algorithms.add(expected.includes(':') ? expected.split(':')[0] : 'sha256');
1431
+ }
1432
+ const actualDigests = Object.freeze(Object.fromEntries([...algorithms].map(algorithm =>
1433
+ [algorithm, createHash(algorithm).update(downloadedBuffer).digest('hex')])));
1434
+ return archiveVerifierFromDigests(actualDigests, downloadedBuffer.length);
1435
+ }
1436
+
1408
1437
  /**
1409
1438
  * Confirm the download+extraction actually changed what is on disk (issue #35 item 2, Dr. Mark
1410
1439
  * Allen / @mamd69).
@@ -1448,8 +1477,11 @@ export function bundleIdentity(src) {
1448
1477
  * @returns {{ok: boolean, reason: string|null, landed: object|null, kind: 'noop'|'damaged'|null,
1449
1478
  * storeUnchanged: boolean, bundleChanged: boolean|null}}
1450
1479
  */
1451
- export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expectedDigest = null, downloadedBuffer = null }) {
1480
+ export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expectedDigest = null, downloadedBuffer = null, archiveVerifier = null }) {
1452
1481
  const damaged = (reason, landed = null) => ({ ok: false, kind: 'damaged', reason, landed, storeUnchanged: false, bundleChanged: null });
1482
+ if (archiveVerifier !== null && (!downloadedArchiveVerifiers.has(archiveVerifier) || downloadedBuffer !== null)) {
1483
+ return damaged('archive verification capability is invalid or mixed with mutable archive bytes');
1484
+ }
1453
1485
  const p = path.join(kbDir, 'SOURCE.json');
1454
1486
  if (!fs.existsSync(p)) {
1455
1487
  return damaged(`no SOURCE.json found at ${p} after extraction — cannot confirm what actually landed`);
@@ -1487,9 +1519,11 @@ export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expec
1487
1519
 
1488
1520
  // Bytes that do not match what the release declared are a HARD failure whatever the identities
1489
1521
  // say, and the copy now in place is suspect — so this is checked before anything else.
1490
- if (expectedDigest && downloadedBuffer) {
1522
+ if (expectedDigest && (downloadedBuffer || archiveVerifier)) {
1491
1523
  const algo = expectedDigest.includes(':') ? expectedDigest.split(':')[0] : 'sha256';
1492
- const actual = `${algo}:${createHash(algo).update(downloadedBuffer).digest('hex')}`;
1524
+ let actual;
1525
+ try { actual = `${algo}:${archiveVerifier ? archiveVerifier.digest(algo) : createHash(algo).update(downloadedBuffer).digest('hex')}`; }
1526
+ catch (error) { return damaged(`downloaded bundle digest verification failed: ${error.message}`, landed); }
1493
1527
  if (actual !== expectedDigest) {
1494
1528
  return damaged(`downloaded bundle digest ${actual} does not match the release-declared digest ${expectedDigest}`, landed);
1495
1529
  }
@@ -1705,7 +1739,7 @@ async function main() {
1705
1739
  const originLabel = resolved.origin === 'latest-release-asset' ? `live release asset "${resolved.assetName}"`
1706
1740
  : resolved.origin === 'live-manifest' ? 'live manifest' : 'PINNED FALLBACK (see warning above)';
1707
1741
  console.log(`\n[${behindStores.length} store(s)] downloading ${resolved.url}\n (source: ${originLabel}) ...`);
1708
- const buf = await fetchBuffer(resolved.url);
1742
+ let buf = await fetchBuffer(resolved.url);
1709
1743
  const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
1710
1744
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-update-release-'));
1711
1745
  const zipPath = path.join(tmp, 'bundle.zip');
@@ -1718,6 +1752,14 @@ async function main() {
1718
1752
  const signature = verifyDownloadedBundle(zipPath, sigPath);
1719
1753
  if (!signature.ok) { fs.rmSync(tmp, { recursive: true, force: true }); die(`✗ SIGNATURE VERIFICATION FAILED: ${signature.reason}`, 4); }
1720
1754
  console.log(` ✓ signature verified — ${signature.reason}`);
1755
+ let archiveVerifier;
1756
+ try { archiveVerifier = createDownloadedArchiveVerifier(buf, resolvedTargets.map(({ resolved: target }) => target.digest)); }
1757
+ catch (error) {
1758
+ fs.rmSync(tmp, { recursive: true, force: true });
1759
+ die(`archive digest snapshot failed: ${error.message} — local files untouched.`, 4);
1760
+ }
1761
+ const bundleSha256 = archiveVerifier.digest();
1762
+ buf = null; // Snapshot owns only immutable digest strings; release the downloaded bytes before validation.
1721
1763
  // DISK-SPACE PREFLIGHT, before a single byte is unpacked: the bundle unpacks in temp, then a whole
1722
1764
  // candidate generation is built beside the live one: the bundle, plus what prepareCandidate carries into
1723
1765
  // it from live — node_modules, and every private/local-ingest store file (restorePrivateFilesIntoCandidate
@@ -1801,7 +1843,7 @@ async function main() {
1801
1843
  for (const { local, resolved: storeResolution } of landingTargets) {
1802
1844
  execFileSync(process.execPath, [guard, '--dir', dir, '--name', local.kbName], { cwd: dir, stdio: 'pipe' });
1803
1845
  const verified = verifyLanded({ kbDir: dir, kbName: local.kbName, before: local, beforeBundle: source,
1804
- expectedDigest: storeResolution.digest, downloadedBuffer: buf });
1846
+ expectedDigest: storeResolution.digest, archiveVerifier });
1805
1847
  if (!verified.ok && verified.kind !== 'noop') return { valid: false, failures: [verified.reason] };
1806
1848
  if (phase === 'live') finalVerificationByStore.set(local.kbName, verified);
1807
1849
  }
@@ -1872,7 +1914,6 @@ async function main() {
1872
1914
  fs.rmSync(tmp, { recursive: true, force: true });
1873
1915
  console.log(` storage transaction: ${transaction.terminalVerdict}`);
1874
1916
  if (transaction.terminalVerdict === 'cleanup-pending') {
1875
- const bundleSha256 = createHash('sha256').update(buf).digest('hex');
1876
1917
  const cleanupOutcome = writeUpdateOutcome({ terminalVerdict: 'cleanup-pending', storageDelta: transaction.storageDelta,
1877
1918
  transactionReceipts: transaction.paths.receipts, bundleSha256,
1878
1919
  phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'cleanup-pending', bundleSha256,
@@ -1886,7 +1927,7 @@ async function main() {
1886
1927
  for (const { local, resolved: storeResolution } of landingTargets) {
1887
1928
  const verified = finalVerificationByStore.get(local.kbName)
1888
1929
  || verifyLanded({ kbDir: KB_DIR, kbName: local.kbName, before: local, beforeBundle: source,
1889
- expectedDigest: storeResolution.digest, downloadedBuffer: buf });
1930
+ expectedDigest: storeResolution.digest, archiveVerifier });
1890
1931
  if (verified.storeUnchanged || verified.kind === 'noop') unchangedStores.push(local.kbName);
1891
1932
  landedByStore.set(local.kbName, { landed: verified.landed, origin: storeResolution.origin, assetName: storeResolution.assetName });
1892
1933
  }
@@ -1947,10 +1988,10 @@ async function main() {
1947
1988
  currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1948
1989
  storageDelta: transaction.storageDelta,
1949
1990
  transactionReceipts: transaction.paths.receipts,
1950
- bundleSha256: createHash('sha256').update(buf).digest('hex'),
1991
+ bundleSha256,
1951
1992
  coverageSha256: sha256File(path.join(KB_DIR, 'COVERAGE.json')),
1952
1993
  phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: transaction.terminalVerdict,
1953
- bundleSha256: createHash('sha256').update(buf).digest('hex'),
1994
+ bundleSha256,
1954
1995
  transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
1955
1996
  storageDelta: transaction.storageDelta }) });
1956
1997
  if (finalOutcome?.terminalVerdict === 'recovery-required') die(finalOutcome.reason);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.5",
4
- "description": "One-command installer for RuvNet Brain \u2014 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.",
3
+ "version": "4.5.7",
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": {
7
7
  "ruvnet-brain": "bin/install.mjs"
@@ -146,6 +146,7 @@
146
146
  },
147
147
  "dependencies": {
148
148
  "@metaharness/flywheel": "^0.1.7",
149
- "@metaharness/router": "^0.4.0"
149
+ "@metaharness/router": "^0.4.0",
150
+ "ws": "^8.22.0"
150
151
  }
151
152
  }
@@ -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.5.5",
4
+ "version": "4.5.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.5.5",
3
+ "version": "4.5.7",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Open the RuvNet Brain Console (/rnbc) — a local web page that mirrors YOUR machine's RuvNet setup, explains it in plain English, and lets you safely configure and fix it. Read-only until you click; every machine change is explained first and is reversible.
3
- updated: 2026-10-01
3
+ updated: 2026-10-04
4
+ version: 1.0.1
4
5
  ---
5
6
 
6
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -64,6 +65,17 @@ header chip says how fresh the reading is and re-measures if they click it.
64
65
  Report it plainly — the real error, not a guess — and offer to fix it. Never leave them staring at
65
66
  a dead tab wondering whether it is still loading.
66
67
 
68
+ ## Promise capture preference
69
+
70
+ If the user asks to stop automatic promise capture, explain the supported setting:
71
+ `RUVNET_PROMISE_CAPTURE=off` in the hook process environment. It can be supplied for the machine or
72
+ one project through the host's environment configuration. The exact value `off` stops capturing new
73
+ "I'll …" promises; unset it or set `on` to resume capture. This preference is separate from the Brain
74
+ on/off switch and is not currently a Console toggle. Existing promises remain visible and forceable,
75
+ and close only on verified evidence. Capability and completion integrity checks keep running. Do not
76
+ disable the Stop hook or edit the work ledger to apply this preference. Reopen the host if its current
77
+ session has not picked up the changed environment.
78
+
67
79
  ---
68
80
 
69
81
  ## Things NOT to do before the page is open
@@ -297,7 +297,7 @@
297
297
  "codex"
298
298
  ],
299
299
  "mode": "advisory",
300
- "offBehavior": "run",
300
+ "offBehavior": "silence",
301
301
  "matcher": "*",
302
302
  "timeout": 6
303
303
  },