ruvnet-brain 4.5.5 → 4.5.6

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 (30) 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/package.json +4 -3
  13. package/plugin/.claude-plugin/plugin.json +1 -1
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/mcp/managed-cli-interface.mjs +9 -4
  16. package/plugin/scripts/capability-claim-evidence.mjs +9 -2
  17. package/plugin/scripts/project-capture-queue.mjs +7 -1
  18. package/plugin/scripts/project-progression-hook.mjs +16 -7
  19. package/plugin/scripts/project-progression-outbox.mjs +96 -15
  20. package/plugin/scripts/project-progression-producer.mjs +12 -1
  21. package/plugin/scripts/project-progression-store.mjs +53 -1
  22. package/plugin/scripts/project-transition-hook.mjs +19 -8
  23. package/plugin/scripts/session-snapshot-hook.mjs +2 -1
  24. package/scripts/claude-terminal-mod.mjs +89 -0
  25. package/scripts/model-routing-gateway.mjs +47 -14
  26. package/scripts/model-routing-launchers.mjs +36 -1
  27. package/scripts/model-terminal-gateway.mjs +310 -0
  28. package/scripts/model-terminal-launchers.mjs +300 -0
  29. package/scripts/release-qualification-contract.mjs +10 -2
  30. 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.6 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.6-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.
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.6",
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.6",
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.6",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -253,9 +253,10 @@ export function normalizeManagedExecution(execution) {
253
253
  const output = [execution?.stdout, execution?.stderr].filter(Boolean)
254
254
  .join(execution?.stdout && execution?.stderr ? '\n' : '');
255
255
  const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
256
+ if (execution?.error) return { outcome: 'failure', output, contradictoryFailure };
256
257
  if (execution?.signal) return { outcome: 'interrupted', output, signal: execution.signal, contradictoryFailure };
257
- if (execution?.error || contradictoryFailure || execution?.code === null || execution?.code === undefined) {
258
- return { outcome: execution?.error || contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
258
+ if (contradictoryFailure || execution?.code === null || execution?.code === undefined) {
259
+ return { outcome: contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
259
260
  }
260
261
  return { outcome: execution.code === 0 ? 'success' : 'failure', output, contradictoryFailure };
261
262
  }
@@ -296,15 +297,19 @@ function managedProgressionCapture({ executable, argv, projectRoot, env, event,
296
297
  function resultOf(executable, argv, result) {
297
298
  const output = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? '\n' : '');
298
299
  const normalized = normalizeManagedExecution(result);
300
+ const terminal = { outcome: normalized.outcome, code: result.code ?? null, signal: result.signal ?? null,
301
+ error: result.error ?? null, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
299
302
  if (normalized.outcome !== 'success') {
300
- const reason = result.error || (normalized.contradictoryFailure ? 'fatal output despite exit 0' : result.code == null ? 'no terminal exit status' : `exit ${result.code}`);
303
+ const reason = result.error || (result.signal ? `signal ${result.signal}` : result.code != null && result.code !== 0 ? `exit ${result.code}` : normalized.contradictoryFailure ? 'fatal output without successful completion' : 'no terminal exit status');
301
304
  return {
302
- content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} failed: ${reason}` }],
305
+ content: [{ type: 'text', text: [output, `${executable} ${argv.join(' ')} failed: ${reason}`].filter(Boolean).join('\n') }],
306
+ structuredContent: terminal,
303
307
  isError: true,
304
308
  };
305
309
  }
306
310
  return {
307
311
  content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} completed successfully` }],
312
+ structuredContent: terminal,
308
313
  isError: false,
309
314
  };
310
315
  }
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { redactText } from './continuity-events.mjs';
6
7
 
7
8
  const HEX40 = /^[a-f0-9]{40}$/;
8
9
  const HEX64 = /^[a-f0-9]{64}$/;
@@ -157,7 +158,13 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
157
158
  observedAt = new Date().toISOString() } = {}) {
158
159
  try {
159
160
  const output = [execution?.stdout, execution?.stderr].filter(Boolean).join('\n');
160
- const reachable = execution?.code === 0 && !execution?.error;
161
+ const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
162
+ const reachable = execution?.code === 0 && !execution?.error && !execution?.signal && !contradictoryFailure;
163
+ const outcome = execution?.error ? 'failure' : execution?.signal ? 'interrupted' : contradictoryFailure ? 'failure'
164
+ : Number.isSafeInteger(execution?.code) ? execution.code === 0 ? 'success' : 'failure' : 'unknown';
165
+ const terminal = { outcome, exitCode: Number.isSafeInteger(execution?.code) ? execution.code : null,
166
+ error: typeof execution?.error === 'string' ? redactText(execution.error).slice(0, 4096) : null,
167
+ signal: typeof execution?.signal === 'string' ? redactText(execution.signal).slice(0, 100) : null };
161
168
  const version = VERSION.exec(output)?.[1] || null;
162
169
  let observationClass = null;
163
170
  let fields = {};
@@ -169,7 +176,7 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
169
176
  observationClass = 'health';
170
177
  const negative = /\b(?:critical|degraded|error|failed|failure|unhealthy)\b/i.test(output);
171
178
  const positive = /\b(?:all checks passed|healthy|overall\s*[:=-]?\s*(?:ok|pass)|status\s*[:=-]\s*(?:ok|pass|healthy))\b/i.test(output);
172
- fields = { healthVerdict: negative ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable };
179
+ fields = { healthVerdict: negative || ['failure', 'interrupted'].includes(outcome) ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable, terminal };
173
180
  }
174
181
  if (!observationClass) return null;
175
182
  const payload = {
@@ -6,6 +6,7 @@ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { developmentHooksSuspended } from './development-maintenance.mjs';
8
8
  import { resolveTurnDb } from './turn-outcome-capture.mjs';
9
+ import { enrichStateWithObservation } from './project-progression-hook.mjs';
9
10
  import { buildProjectProgression } from './project-progression-producer.mjs';
10
11
  import { resolveProjectStore } from './project-store-resolver.mjs';
11
12
  import { redactProgression } from './project-progression-contract.mjs';
@@ -57,6 +58,10 @@ export function queueCapture({ projectDir, originProjectDir = projectDir, event,
57
58
  if (!progression && !payload?.normalizedTransition) {
58
59
  try { progression = buildProjectProgression({ resolution: resolveProjectStore({ projectDir: originProjectDir }), projectDir: originProjectDir, payload, host, trigger: event }).projectProgression; } catch { return null; }
59
60
  }
61
+ // Freeze the bounded native observation before discarding raw host input. Replay receives no
62
+ // tool fields, so the native writer cannot append it twice.
63
+ if (progression && (payload?.tool_name || payload?.tool_input)) progression = { ...progression,
64
+ completeProjectState: enrichStateWithObservation(progression.completeProjectState, { ...payload, hook_event_name: event }) };
60
65
  // This queue is durable: never serialize arbitrary host prompts, tool input or output.
61
66
  const minimized = { session_id: payload?.session_id, hook_event_name: event,
62
67
  ...(progression ? { projectProgression: progression } : {}),
@@ -273,7 +278,8 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
273
278
  for (const snapshot of store.outbox.pendingSnapshots()) {
274
279
  if (now() >= deadlineAt) return replayed;
275
280
  if (!refreshReplayLock(projectDir, held)) return replayed;
276
- store.outbox.markCommitted(store.appendExact(snapshot));
281
+ store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
282
+ // captureFrozen commits only the verified key, retaining conflicting original history.
277
283
  replayed += 1;
278
284
  }
279
285
  for (const file of queuedCaptures(projectDir)) {
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { createProgressionSnapshot } from './project-progression-contract.mjs';
5
5
  import { ProjectProgressionStore } from './project-progression-store.mjs';
6
+ import { redactText } from './continuity-events.mjs';
6
7
 
7
8
  const HOSTS = new Set(['claude', 'codex']);
8
9
  const CAPTURE_TRIGGERS = new Set([
@@ -105,15 +106,17 @@ function toolAction(payload) {
105
106
  const exitCode = Number.isSafeInteger(explicitCode)
106
107
  ? explicitCode
107
108
  : textualCode ? Number(textualCode[1]) : undefined;
108
- const interrupted = responseRecord?.interrupted === true || responseRecord?.signal === 'SIGINT';
109
+ const error = boundedText(typeof responseRecord?.error === 'string' ? redactText(responseRecord.error) : undefined);
110
+ const signal = boundedText(typeof responseRecord?.signal === 'string' ? redactText(responseRecord.signal) : undefined);
111
+ const interrupted = responseRecord?.interrupted === true || Boolean(signal);
109
112
  const explicitError = responseRecord?.isError === true || payload.is_error === true;
110
113
  const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(responseRecord?.outcome)
111
114
  ? responseRecord.outcome : null;
112
- const failed = explicitError || (Number.isSafeInteger(exitCode) && exitCode !== 0);
115
+ const failed = explicitError || Boolean(error) || (Number.isSafeInteger(exitCode) && exitCode !== 0);
113
116
  const terminal = failed || Number.isSafeInteger(exitCode)
114
117
  || responseRecord?.success === true || responseRecord?.ok === true;
115
118
  const outcome = payload.hook_event_name === 'PostToolUse'
116
- ? (interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
119
+ ? (error ? 'failure' : interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
117
120
  : 'pending';
118
121
  const observation = {
119
122
  trigger: payload.hook_event_name,
@@ -121,7 +124,9 @@ function toolAction(payload) {
121
124
  ...(command ? { command } : {}),
122
125
  ...(filePath && !command ? { filePath } : {}),
123
126
  outcome,
124
- ...(Number.isSafeInteger(exitCode) ? { exitCode } : {}),
127
+ ...(Number.isSafeInteger(exitCode) ? { exitCode } : responseRecord?.exit_code === null || responseRecord?.exitCode === null ? { exitCode: null } : {}),
128
+ ...(error ? { error } : {}),
129
+ ...(signal ? { signal } : {}),
125
130
  ...(interrupted ? { interrupted: true } : {}),
126
131
  ...(explicitError ? { isError: true } : {}),
127
132
  };
@@ -137,7 +142,7 @@ function toolAction(payload) {
137
142
  return observation;
138
143
  }
139
144
 
140
- function enrichStateWithObservation(state, payload) {
145
+ export function enrichStateWithObservation(state, payload) {
141
146
  requireRecord(state, 'completeProjectState');
142
147
  const observation = toolAction(payload);
143
148
  if (!observation) return state;
@@ -164,6 +169,8 @@ export function captureProjectTransition({
164
169
  payload,
165
170
  projectDir,
166
171
  adapterVersion = readProgressionAdapterVersion(),
172
+ recoverFrozen = false,
173
+ canCommit,
167
174
  storeFactory = (options) => new ProjectProgressionStore(options),
168
175
  } = {}) {
169
176
  const normalizedHost = requireString(host, 'host').toLowerCase();
@@ -191,7 +198,7 @@ export function captureProjectTransition({
191
198
  aliased(progression, 'sourceIdentity', 'source_identity'),
192
199
  store.resolution.checkoutRoot, projectDir,
193
200
  );
194
- const snapshot = createProgressionSnapshot({
201
+ let snapshot = createProgressionSnapshot({
195
202
  projectIdentity: store.resolution.projectIdentity,
196
203
  sourceIdentity,
197
204
  hostIdentity: { host: normalizedHost, adapterVersion },
@@ -206,7 +213,9 @@ export function captureProjectTransition({
206
213
  payload,
207
214
  ),
208
215
  });
209
- const receipt = store.capture(snapshot);
216
+ let receipt;
217
+ if (recoverFrozen) ({ snapshot, receipt } = store.captureFrozen(snapshot, { canCommit }));
218
+ else receipt = store.capture(snapshot);
210
219
  verifyReceipt(snapshot, receipt);
211
220
  return { snapshot, receipt };
212
221
  }