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.
- package/README.md +1 -1
- package/bin/install.mjs +24 -6
- package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
- package/config/model-router/claude-terminal-mod/README.md +22 -0
- package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
- package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
- package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
- package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
- package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
- package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
- package/config/model-router/policy.default.mjs +4 -1
- package/package.json +4 -3
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +9 -4
- package/plugin/scripts/capability-claim-evidence.mjs +9 -2
- package/plugin/scripts/project-capture-queue.mjs +7 -1
- package/plugin/scripts/project-progression-hook.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +96 -15
- package/plugin/scripts/project-progression-producer.mjs +12 -1
- package/plugin/scripts/project-progression-store.mjs +53 -1
- package/plugin/scripts/project-transition-hook.mjs +19 -8
- package/plugin/scripts/session-snapshot-hook.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +89 -0
- package/scripts/model-routing-gateway.mjs +47 -14
- package/scripts/model-routing-launchers.mjs +36 -1
- package/scripts/model-terminal-gateway.mjs +310 -0
- package/scripts/model-terminal-launchers.mjs +300 -0
- package/scripts/release-qualification-contract.mjs +10 -2
- 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 — [](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
|
|
4939
|
-
const runtimeRelative
|
|
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,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,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.
|
|
4
|
-
"description": "One-command installer for RuvNet Brain
|
|
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.
|
|
4
|
+
"version": "4.5.6",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -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 (
|
|
258
|
-
return { outcome:
|
|
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 || (
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|