ruvnet-brain 4.0.6 → 4.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/bin/install.mjs +112 -7
- package/config/model-router/catalog.template.json +17 -2
- package/console/app.js +32 -14
- package/console/architecture.html +1 -1
- package/data/model-catalog.json +54 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +34 -1
- package/plugin/scripts/ground-ruvnet.sh +2 -1
- package/plugin/scripts/hook-shim.mjs +13 -6
- package/plugin/scripts/lesson-provenance.mjs +21 -0
- package/plugin/scripts/lesson-store.mjs +33 -3
- package/plugin/scripts/route-dispatch.sh +23 -49
- package/plugin/scripts/session-snapshot-contract.mjs +100 -0
- package/plugin/scripts/session-snapshot-hook.mjs +34 -0
- package/plugin/scripts/session-start-core.mjs +25 -7
- package/plugin/scripts/swarm-slot-recycler.mjs +89 -0
- package/plugin/skills/release-proof/SKILL.md +4 -4
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +19 -4
- package/scripts/capability-registry.mjs +3 -3
- package/scripts/lesson-lifecycle.mjs +2 -1
- package/scripts/lesson-ratify.mjs +6 -3
- package/scripts/lesson-seed.mjs +21 -16
- package/scripts/model-router-catalog.mjs +16 -0
- package/scripts/model-router-engine.mjs +4 -1
- package/scripts/onboarding-console.mjs +52 -14
- package/scripts/provider-availability.mjs +12 -0
- package/scripts/release-authority.mjs +14 -2
- package/scripts/release-transaction-provider.mjs +236 -0
- package/scripts/release-transaction.mjs +214 -0
- package/scripts/release.mjs +67 -279
- package/scripts/session-snapshot-contract.mjs +1 -0
- package/scripts/staged-host-verifier.mjs +106 -0
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
// hook-shim-bash.mjs (bash-interpreter resolution, issue #38) — colocated in this same
|
|
14
14
|
// boot-frozen scripts/ dir, never resolved from the spine. No imports from the hook BODY.
|
|
15
15
|
// • Typed dispatch table (red-team findings 15/16/30): each hook declares its file, interpreter,
|
|
16
|
-
// and mode. `blocking` hooks propagate their exact exit code
|
|
17
|
-
//
|
|
16
|
+
// and mode. `blocking` hooks propagate their exact exit code; `advisory` hooks can never block
|
|
17
|
+
// a turn — any failure,
|
|
18
18
|
// including a missing file, exits 0.
|
|
19
19
|
// • Containment (finding 13): a codeRoot is honored ONLY if it resolves under
|
|
20
20
|
// ~/.cache/ruvnet-brain/versions/ — or is the explicit dev-mode checkout declared in
|
|
@@ -71,9 +71,9 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
|
|
|
71
71
|
//
|
|
72
72
|
// 'silence' — this hook exists to advertise, ground, or learn. Off means it does not run at all
|
|
73
73
|
// and writes ZERO bytes. Nothing downstream can tell it apart from not being installed.
|
|
74
|
-
// 'run' — this
|
|
75
|
-
//
|
|
76
|
-
//
|
|
74
|
+
// 'run' — this protection/audit remains relevant without retrieval. route-dispatch records
|
|
75
|
+
// inherited-model fan-out; design-wall stops an ungraded surface shipping;
|
|
76
|
+
// protect-state guards the user's own consent record.
|
|
77
77
|
// None becomes acceptable because retrieval is off.
|
|
78
78
|
// 'partial' — the hook splits INTERNALLY. session-start-core still runs the auto-updater heartbeat, the
|
|
79
79
|
// GONG health alarm and the SLA banner (an off machine must still receive fixes,
|
|
@@ -84,7 +84,9 @@ const TABLE = {
|
|
|
84
84
|
'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
|
|
85
85
|
'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
|
|
86
86
|
'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
|
|
87
|
-
|
|
87
|
+
// Claude Code 2.1.220 consumes Agent/Task PreToolUse results after tool_dispatch_end (#84).
|
|
88
|
+
// A refusal here would be late and therefore false enforcement; retain only bounded audit.
|
|
89
|
+
'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
|
|
88
90
|
'ground-before-write': { file: 'ground-before-write.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
|
|
89
91
|
'grounding-stamp': { file: 'grounding-stamp.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
|
|
90
92
|
'verify-interface': { file: 'verify-interface.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
|
|
@@ -94,6 +96,7 @@ const TABLE = {
|
|
|
94
96
|
'protect-state': { file: 'protect-brain-state.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
|
|
95
97
|
'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
|
|
96
98
|
'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
|
|
99
|
+
'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 },
|
|
97
100
|
'md-stamp': { file: 'md-stamp.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
|
|
98
101
|
// THE EXTERNAL-SIGNAL WATCH PLANE, W1 OBSERVED (ADR-058 §D3; DDD-0013 Context 2). PostToolUse,
|
|
99
102
|
// matcher ^Bash$ (anchored — an unanchored matcher is F3/F4). Classifies gh/vercel/netlify/npm
|
|
@@ -105,6 +108,10 @@ const TABLE = {
|
|
|
105
108
|
// session-start-core.mjs, and that surfacing already lives under SessionStart's 'partial' contract.
|
|
106
109
|
'signal-watch': { file: 'signal-watch.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
|
|
107
110
|
'routing-outcome': { file: 'routing-outcome-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run' },
|
|
111
|
+
// Claude Code's synchronous TeammateIdle boundary can prevent a worker slot from going unused
|
|
112
|
+
// while its host-owned shared ledger has ready work. The body is read-only and fail-open; exit 2
|
|
113
|
+
// is reserved for one proved, unassigned, dependency-ready task. Codex has no equivalent event.
|
|
114
|
+
'swarm-slot-recycler': { file: 'swarm-slot-recycler.mjs', interpreter: 'node', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
|
|
108
115
|
// The unprompted-speech chokepoint (ADR-040 / DDD-0004). ONE runtime is the sole writer of
|
|
109
116
|
// user-facing bytes for every unprompted hook: it spawns the real producers (anticipate, lesson)
|
|
110
117
|
// in candidate mode, applies the per-channel policy, and writes the final envelope itself. `channel`
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const SOURCE_CLASS = Object.freeze({
|
|
2
|
+
CURRENT_USER: 'current-user',
|
|
3
|
+
IMPORTED_OWNER: 'imported-owner',
|
|
4
|
+
MODEL_INFERRED: 'model-inferred',
|
|
5
|
+
DEMONSTRATION: 'demonstration',
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
export const BUNDLED_OWNER_SEED_IDS = new Set([
|
|
9
|
+
'L01-verify-with-a-capable-channel',
|
|
10
|
+
'L02-check-before-you-assert',
|
|
11
|
+
'L03-research-before-recommending',
|
|
12
|
+
'L04-never-relay-a-number',
|
|
13
|
+
'L05-version-is-the-update-signal',
|
|
14
|
+
'L06-use-the-real-tool',
|
|
15
|
+
'L07-blast-radius-not-social-comfort',
|
|
16
|
+
'L08-status-is-a-table',
|
|
17
|
+
'L09-gradeable-is-not-valuable',
|
|
18
|
+
'L10-under-enumeration-is-a-tell',
|
|
19
|
+
'L11-retrieval-without-volition-is-broken',
|
|
20
|
+
'L12-efficiency-seeking-is-the-tell',
|
|
21
|
+
]);
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
import fs from 'node:fs';
|
|
24
24
|
import os from 'node:os';
|
|
25
25
|
import path from 'node:path';
|
|
26
|
+
import { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
|
|
27
|
+
|
|
28
|
+
export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs';
|
|
26
29
|
|
|
27
30
|
/** Resolve fixture/plugin configuration without mutating the child process account HOME. */
|
|
28
31
|
export function resolveConfigRoot(env = process.env, home = os.homedir()) {
|
|
@@ -99,6 +102,8 @@ export const ORIGIN = Object.freeze({
|
|
|
99
102
|
});
|
|
100
103
|
const ORIGIN_VALUES = new Set(Object.values(ORIGIN));
|
|
101
104
|
|
|
105
|
+
const SOURCE_CLASS_VALUES = new Set(Object.values(SOURCE_CLASS));
|
|
106
|
+
|
|
102
107
|
/**
|
|
103
108
|
* STATUS — the ratification ladder. A lesson does not become policy by existing.
|
|
104
109
|
* candidate → ratified (a human agreed) → active (in force at its trigger).
|
|
@@ -128,6 +133,11 @@ export function makeLesson(spec) {
|
|
|
128
133
|
severity = 'normal', // 'normal' | 'high' — see weightOf()
|
|
129
134
|
intendedEnforcement = null, // what it should become once a human ratifies it
|
|
130
135
|
ratifiedBy = null,
|
|
136
|
+
sourceClass = origin === ORIGIN.USER_STATED
|
|
137
|
+
? SOURCE_CLASS.CURRENT_USER
|
|
138
|
+
: origin === ORIGIN.MODEL_INFERRED
|
|
139
|
+
? SOURCE_CLASS.MODEL_INFERRED
|
|
140
|
+
: SOURCE_CLASS.IMPORTED_OWNER,
|
|
131
141
|
} = spec;
|
|
132
142
|
const err = (m) => { throw new Error(`Lesson "${id ?? '?'}" invalid: ${m}`); };
|
|
133
143
|
|
|
@@ -151,6 +161,10 @@ export function makeLesson(spec) {
|
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
if (!ORIGIN_VALUES.has(origin)) err(`origin must be one of: ${[...ORIGIN_VALUES].join(', ')}`);
|
|
164
|
+
if (!SOURCE_CLASS_VALUES.has(sourceClass)) err(`sourceClass must be one of: ${[...SOURCE_CLASS_VALUES].join(', ')}`);
|
|
165
|
+
if (sourceClass === SOURCE_CLASS.CURRENT_USER && origin !== ORIGIN.USER_STATED) {
|
|
166
|
+
err('sourceClass:current-user requires origin:user-stated');
|
|
167
|
+
}
|
|
154
168
|
if (!STATUS_VALUES.has(status)) err(`status must be one of: ${[...STATUS_VALUES].join(', ')}`);
|
|
155
169
|
|
|
156
170
|
// THE TRUST BOUNDARY. A lesson the model wrote about itself, or one imported from a repo, cannot
|
|
@@ -166,7 +180,7 @@ export function makeLesson(spec) {
|
|
|
166
180
|
|
|
167
181
|
return Object.freeze({
|
|
168
182
|
id, statement, trigger, enforcement, evidence,
|
|
169
|
-
surface, origin, status, severity,
|
|
183
|
+
surface, origin, sourceClass, status, severity,
|
|
170
184
|
intendedEnforcement: intendedEnforcement ?? null,
|
|
171
185
|
ratifiedBy: ratifiedBy ?? null,
|
|
172
186
|
projects: [...projects],
|
|
@@ -255,7 +269,12 @@ export function loadLessons(file = STORE_PATH) {
|
|
|
255
269
|
// to edit and delete these); a malformed entry must be dropped loudly rather than acted upon.
|
|
256
270
|
const out = [];
|
|
257
271
|
const dropped = [];
|
|
258
|
-
|
|
272
|
+
const rows = Array.isArray(raw.lessons) ? raw.lessons : [];
|
|
273
|
+
const ids = new Set(rows.map((lesson) => lesson?.id));
|
|
274
|
+
const legacyOwnerSeed = rows.length === BUNDLED_OWNER_SEED_IDS.size
|
|
275
|
+
&& ids.size === BUNDLED_OWNER_SEED_IDS.size
|
|
276
|
+
&& [...BUNDLED_OWNER_SEED_IDS].every((id) => ids.has(id));
|
|
277
|
+
for (const stored of rows) {
|
|
259
278
|
// SKIP THE BAD ROW, BUT NEVER SILENTLY. An adversarial review proved that a schema change
|
|
260
279
|
// (ADR-035 proposes new enforcement values the current enum rejects) would take this store
|
|
261
280
|
// from 16 lessons to 0 with NO error and exit 0 — output indistinguishable from "no lessons
|
|
@@ -264,6 +283,14 @@ export function loadLessons(file = STORE_PATH) {
|
|
|
264
283
|
//
|
|
265
284
|
// A store that empties itself quietly is the worst possible failure here, because the whole
|
|
266
285
|
// product promise is "you should never have to tell me twice."
|
|
286
|
+
const l = legacyOwnerSeed ? {
|
|
287
|
+
...stored,
|
|
288
|
+
origin: ORIGIN.IMPORTED,
|
|
289
|
+
sourceClass: SOURCE_CLASS.IMPORTED_OWNER,
|
|
290
|
+
status: STATUS.CANDIDATE,
|
|
291
|
+
demoted: true,
|
|
292
|
+
ratifiedBy: null,
|
|
293
|
+
} : stored;
|
|
267
294
|
try { out.push(makeLesson(l)); } catch (e) {
|
|
268
295
|
dropped.push({ id: l && l.id, why: String(e && e.message || e) });
|
|
269
296
|
}
|
|
@@ -435,6 +462,7 @@ export function restore(id, lessons) {
|
|
|
435
462
|
export function ratify(id, lessons, { by = 'user' } = {}) {
|
|
436
463
|
return lessons.map((l) => {
|
|
437
464
|
if (l.id !== id) return l;
|
|
465
|
+
if (l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION) return l;
|
|
438
466
|
const target = l.intendedEnforcement || l.enforcement;
|
|
439
467
|
const canBlock = l.origin === ORIGIN.USER_STATED;
|
|
440
468
|
return makeLesson({
|
|
@@ -448,5 +476,7 @@ export function ratify(id, lessons, { by = 'user' } = {}) {
|
|
|
448
476
|
|
|
449
477
|
/** Lessons awaiting a human decision — what the management surface must show first. */
|
|
450
478
|
export function pending(lessons) {
|
|
451
|
-
return lessons.filter((l) => l.status === STATUS.CANDIDATE && !l.demoted
|
|
479
|
+
return lessons.filter((l) => l.status === STATUS.CANDIDATE && !l.demoted
|
|
480
|
+
&& l.sourceClass !== SOURCE_CLASS.IMPORTED_OWNER
|
|
481
|
+
&& l.sourceClass !== SOURCE_CLASS.DEMONSTRATION);
|
|
452
482
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
# route-dispatch.sh — PreToolUse
|
|
2
|
+
# route-dispatch.sh — bounded PreToolUse audit of subagent model selection.
|
|
3
3
|
#
|
|
4
4
|
# ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
5
5
|
# THE LEAK (2026-07-13). Stuart: "What happens when I'm right here in Opus 4.8 and it has 10 things
|
|
@@ -8,8 +8,12 @@
|
|
|
8
8
|
# A SUBAGENT INHERITS THE MAIN-LOOP MODEL UNLESS `model` IS EXPLICITLY PASSED.
|
|
9
9
|
#
|
|
10
10
|
# Ten agents on a Fable session = ten agents at $10/$50 per Mtok, ~10x Haiku for identical mechanical
|
|
11
|
-
# work.
|
|
12
|
-
#
|
|
11
|
+
# work. This hook records declared and inherited dispatches so the leak remains measurable.
|
|
12
|
+
#
|
|
13
|
+
# HOST LIMITATION (#84): Claude Code 2.1.220 registers Agent/Task PreToolUse hooks asynchronously,
|
|
14
|
+
# completes the subagent dispatch, and only then consumes the hook result. An exit-2 refusal is
|
|
15
|
+
# therefore too late to block and must not be represented as enforcement. The hook is intentionally
|
|
16
|
+
# silent and advisory until the host provides a synchronous pre-dispatch decision boundary.
|
|
13
17
|
#
|
|
14
18
|
# ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
19
|
# THREE DEFECTS IN MY OWN FIRST VERSION, caught by asking the questions Stuart would have asked
|
|
@@ -21,14 +25,10 @@
|
|
|
21
25
|
# (a model-router profile.json exists = they answered the two subscription questions). Everyone
|
|
22
26
|
# else gets NOTHING — not even a warning. Consent is the default.
|
|
23
27
|
# 2. IT REQUIRED python3. The other three plugin hooks are pure bash. A hard dependency inside a
|
|
24
|
-
#
|
|
25
|
-
# 3. IT COULD FAIL CLOSED.
|
|
26
|
-
# unparseable/ambiguous case now FAILS OPEN (exit 0). A gate that breaks your tools is worse
|
|
27
|
-
# than the leak it prevents.
|
|
28
|
+
# hook is how you brick someone's session. Now pure bash — no interpreters.
|
|
29
|
+
# 3. IT COULD FAIL CLOSED. Every unparseable/ambiguous case fails open (exit 0).
|
|
28
30
|
#
|
|
29
|
-
# CONTRACT
|
|
30
|
-
# exit 0 → allow
|
|
31
|
-
# exit 2 + stderr → BLOCK, and stderr comes back to the model as the reason (so it retries correctly)
|
|
31
|
+
# CONTRACT: always exit 0 and emit no user-facing bytes. Audit receipts are best-effort only.
|
|
32
32
|
# ─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
33
33
|
|
|
34
34
|
set -uo pipefail
|
|
@@ -93,9 +93,10 @@ TOOL_USE_ID=$(field tool_use_id)
|
|
|
93
93
|
SESSION_ID=$(field session_id)
|
|
94
94
|
DESC="${DESC// /_}"; DESC="${DESC:0:40}" # builtin substitution — no `tr`, no `cut`
|
|
95
95
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
96
|
+
log_dispatch() {
|
|
97
|
+
local selected_model="$1"
|
|
98
|
+
local enforcement="$2"
|
|
99
|
+
# Log so routing is AUDITABLE, not merely claimed — a growing ledger is evidence; a promise is not.
|
|
99
100
|
# `date` is the ONE external command left, and only on the ALLOW path — so its absence must be
|
|
100
101
|
# silent, not a stderr spew from a hook that just said "yes". (bash's printf %()T would avoid it
|
|
101
102
|
# entirely, but macOS still ships bash 3.2, which does not support it.)
|
|
@@ -105,44 +106,17 @@ if [ -n "$MODEL" ]; then
|
|
|
105
106
|
{
|
|
106
107
|
TS=$(date -u +%FT%TZ) || TS="unknown" # the one external command, and only on the allow path
|
|
107
108
|
mkdir -p "$HOME/.claude/metaharness"
|
|
108
|
-
printf '{"ts":"%s","event":"dispatch","model":"%s","agent":"%s","task":"%s","toolUseId":"%s","sessionId":"%s"}\n' \
|
|
109
|
-
"$TS" "$
|
|
109
|
+
printf '{"ts":"%s","event":"dispatch","model":"%s","enforcement":"%s","agent":"%s","task":"%s","toolUseId":"%s","sessionId":"%s"}\n' \
|
|
110
|
+
"$TS" "$selected_model" "$enforcement" "${SUBTYPE:-unknown}" "${DESC:-unlabeled}" "${TOOL_USE_ID:-}" "${SESSION_ID:-}" \
|
|
110
111
|
>> "$HOME/.claude/metaharness/dispatch-log.jsonl"
|
|
111
112
|
} 2>/dev/null || true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if [ -n "$MODEL" ]; then
|
|
116
|
+
log_dispatch "$MODEL" "declared"
|
|
112
117
|
exit 0
|
|
113
118
|
fi
|
|
114
119
|
|
|
115
|
-
#
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
read -r -d '' BLOCK_MSG <<'EOF' || true
|
|
119
|
-
⛔ SUBAGENT DISPATCH BLOCKED — you did not declare a `model`.
|
|
120
|
-
|
|
121
|
-
An agent with no `model` INHERITS this session's model. On an Opus session that is an Opus agent;
|
|
122
|
-
on a Fable session it is $10/$50 per Mtok — up to 10x what the same work costs on Haiku.
|
|
123
|
-
Inheritance-by-omission is the biggest cost leak in this harness, and an advisory rule did not fix
|
|
124
|
-
it (the router's entire first life saved $0.018). Hence a wall.
|
|
125
|
-
|
|
126
|
-
Re-issue the SAME Agent call with an explicit `model`, chosen by what the task actually IS:
|
|
127
|
-
|
|
128
|
-
model: "haiku" mechanical — greps, file sweeps, log triage, mechanical edits, fixture rewrites
|
|
129
|
-
model: "sonnet" analytical — trace a bug across files, summarize a subsystem, draft tests
|
|
130
|
-
model: "opus" judgment — architecture, root cause, security, anything user-facing
|
|
131
|
-
(if it truly needs the main model's judgment, ask whether it should be a
|
|
132
|
-
subagent at all, or work you should do inline)
|
|
133
|
-
|
|
134
|
-
Not sure? Ask rUv's real router — it predicts each model's quality on THIS task and returns the
|
|
135
|
-
cheapest one that clears the bar, with your subscriptions priced at $0:
|
|
136
|
-
|
|
137
|
-
node ~/.claude/model-router/bin/model-router-engine.mjs --harness claude-code --prompt "<task>" --json
|
|
138
|
-
|
|
139
|
-
Then log the receipt when it returns, so the saving is visible instead of asserted:
|
|
140
|
-
|
|
141
|
-
node scripts/dispatch-receipt.mjs --model <m> --inherited <this session's model> \
|
|
142
|
-
--task "<what it did>" --total-tokens <the agent's reported total>
|
|
143
|
-
|
|
144
|
-
Deliberate exception (rare — and say WHY out loud): RUVNET_ALLOW_INHERITED_MODEL=1
|
|
145
|
-
EOF
|
|
146
|
-
bash "$(dirname "${BASH_SOURCE[0]}")/gate-receipt.sh" route-dispatch "subagent" "would inherit the session model instead of routing to a cheaper one" 2>/dev/null || true
|
|
147
|
-
printf '%s\n' "$BLOCK_MSG" >&2
|
|
148
|
-
exit 2
|
|
120
|
+
# Missing model: record the inheritance leak, but do not emit a late refusal the host cannot enforce.
|
|
121
|
+
log_dispatch "inherited" "advisory-host-timing"
|
|
122
|
+
exit 0
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const SNAPSHOT_SCHEMA = 'ruvnet-brain.session-snapshot';
|
|
5
|
+
export const SNAPSHOT_VERSION = 1;
|
|
6
|
+
export const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
const EVENTS = new Set(['PreCompact', 'PostCompact', 'SessionEnd']);
|
|
9
|
+
|
|
10
|
+
export function createSessionSnapshot({ event, capturedAt = new Date().toISOString() }) {
|
|
11
|
+
if (!EVENTS.has(event)) throw new Error(`unsupported snapshot event: ${event}`);
|
|
12
|
+
if (!Number.isFinite(Date.parse(capturedAt))) throw new Error('capturedAt must be an ISO-8601 timestamp');
|
|
13
|
+
return {
|
|
14
|
+
schema: SNAPSHOT_SCHEMA,
|
|
15
|
+
version: SNAPSHOT_VERSION,
|
|
16
|
+
capturedAt,
|
|
17
|
+
boundary: { event },
|
|
18
|
+
privacy: { rawTranscriptStored: false, credentialValuesStored: false },
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function validateSessionSnapshot(value) {
|
|
23
|
+
return Boolean(value)
|
|
24
|
+
&& typeof value === 'object'
|
|
25
|
+
&& !Array.isArray(value)
|
|
26
|
+
&& value.schema === SNAPSHOT_SCHEMA
|
|
27
|
+
&& value.version === SNAPSHOT_VERSION
|
|
28
|
+
&& EVENTS.has(value.boundary?.event)
|
|
29
|
+
&& Number.isFinite(Date.parse(value.capturedAt))
|
|
30
|
+
&& value.privacy?.rawTranscriptStored === false
|
|
31
|
+
&& value.privacy?.credentialValuesStored === false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function freshness(capturedAt, now) {
|
|
35
|
+
const age = now - Date.parse(capturedAt);
|
|
36
|
+
return age >= 0 && age <= SNAPSHOT_MAX_AGE_MS;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function canonical(projectDir, now) {
|
|
40
|
+
const file = path.join(projectDir, '.swarm', 'agentdb-sessions.jsonl');
|
|
41
|
+
if (!fs.existsSync(file)) return { values: [], malformed: false };
|
|
42
|
+
try {
|
|
43
|
+
const stat = fs.lstatSync(file);
|
|
44
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) {
|
|
45
|
+
return { values: [], malformed: true };
|
|
46
|
+
}
|
|
47
|
+
const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).slice(-128);
|
|
48
|
+
const values = [];
|
|
49
|
+
let malformed = false;
|
|
50
|
+
for (const line of lines) {
|
|
51
|
+
try {
|
|
52
|
+
const value = JSON.parse(line);
|
|
53
|
+
if (validateSessionSnapshot(value)) values.push({ kind: 'canonical', fresh: freshness(value.capturedAt, now), capturedAt: value.capturedAt });
|
|
54
|
+
else malformed = true;
|
|
55
|
+
} catch { malformed = true; }
|
|
56
|
+
}
|
|
57
|
+
return { values, malformed };
|
|
58
|
+
} catch { return { values: [], malformed: true }; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function legacy(projectDir, now) {
|
|
62
|
+
const values = [];
|
|
63
|
+
let malformed = false;
|
|
64
|
+
for (const root of ['.claude', '.claude-flow']) {
|
|
65
|
+
const directory = path.join(projectDir, root, 'sessions');
|
|
66
|
+
if (!fs.existsSync(directory)) continue;
|
|
67
|
+
let names;
|
|
68
|
+
try {
|
|
69
|
+
const stat = fs.lstatSync(directory);
|
|
70
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return { values: [], malformed: true };
|
|
71
|
+
names = fs.readdirSync(directory).filter((name) => /^session-.*\.json$/.test(name)).slice(-64);
|
|
72
|
+
} catch { malformed = true; continue; }
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
try {
|
|
75
|
+
const file = path.join(directory, name);
|
|
76
|
+
const stat = fs.lstatSync(file);
|
|
77
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) { malformed = true; continue; }
|
|
78
|
+
const value = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
79
|
+
const capturedAt = value.endedAt || value.startedAt;
|
|
80
|
+
if (typeof value.id !== 'string' || !value.id || !value.context || !value.metrics || !Number.isFinite(Date.parse(capturedAt))) {
|
|
81
|
+
malformed = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
values.push({ kind: 'legacy', fresh: freshness(capturedAt, now), capturedAt });
|
|
85
|
+
} catch { malformed = true; }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { values, malformed };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function inspectSessionSnapshots(projectDir, { now = Date.now() } = {}) {
|
|
92
|
+
const results = [canonical(projectDir, now), legacy(projectDir, now)];
|
|
93
|
+
const values = results.flatMap((result) => result.values);
|
|
94
|
+
const priority = { canonical: 0, legacy: 1 };
|
|
95
|
+
const fresh = values.filter((value) => value.fresh).sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
|
|
96
|
+
if (fresh) return fresh;
|
|
97
|
+
if (values.length) return values.sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0];
|
|
98
|
+
if (results.some((result) => result.malformed)) return { kind: 'malformed', fresh: false };
|
|
99
|
+
return { kind: 'absent', fresh: false };
|
|
100
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createSessionSnapshot } from './session-snapshot-contract.mjs';
|
|
4
|
+
|
|
5
|
+
function regularOrAbsent(file) {
|
|
6
|
+
try {
|
|
7
|
+
const stat = fs.lstatSync(file);
|
|
8
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
9
|
+
} catch (error) {
|
|
10
|
+
return error?.code === 'ENOENT';
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function writeSessionSnapshot(projectDir, event) {
|
|
15
|
+
const swarm = path.join(projectDir, '.swarm');
|
|
16
|
+
const target = path.join(swarm, 'agentdb-sessions.jsonl');
|
|
17
|
+
try {
|
|
18
|
+
if (fs.existsSync(swarm)) {
|
|
19
|
+
const stat = fs.lstatSync(swarm);
|
|
20
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
21
|
+
} else {
|
|
22
|
+
fs.mkdirSync(swarm, { recursive: false, mode: 0o700 });
|
|
23
|
+
}
|
|
24
|
+
if (!regularOrAbsent(target)) return false;
|
|
25
|
+
fs.appendFileSync(target, `${JSON.stringify(createSessionSnapshot({ event }))}\n`, { mode: 0o600 });
|
|
26
|
+
return true;
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
|
|
33
|
+
writeSessionSnapshot(process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2] || 'SessionEnd');
|
|
34
|
+
}
|
|
@@ -62,9 +62,30 @@ const dispatchDetached = (hookDir, ttl, log, command, args = [], env = process.e
|
|
|
62
62
|
String(ttl), log, command, ...args,
|
|
63
63
|
], { env, stdio: 'ignore', timeout: 2000 })?.status === 0;
|
|
64
64
|
|
|
65
|
-
const
|
|
65
|
+
export const maintainerIssueEntitlement = (env, home, repo, platform = process.platform) => {
|
|
66
|
+
const file = env.RUVNET_BRAIN_MAINTAINER_ISSUES_FILE
|
|
67
|
+
|| path.join(home, '.config', 'ruvnet-brain', 'maintainer-issues.json');
|
|
68
|
+
// Windows ACL ownership is not available through this dependency-free hot path. Fail closed
|
|
69
|
+
// instead of weakening an owner-only promise into "any local user who can write the file".
|
|
70
|
+
if (platform === 'win32') return false;
|
|
71
|
+
let stat;
|
|
72
|
+
try { stat = fs.lstatSync(file); } catch { return false; }
|
|
73
|
+
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
74
|
+
// This is maintainer-only operational data. Refuse group/world-readable opt-ins on POSIX so a
|
|
75
|
+
// shared machine cannot turn a private maintainer signal into a terminal banner for other users.
|
|
76
|
+
if ((stat.mode & 0o077) !== 0) return false;
|
|
77
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return false;
|
|
78
|
+
const entitlement = json(file);
|
|
79
|
+
return entitlement?.enabled === true
|
|
80
|
+
&& Array.isArray(entitlement.repos)
|
|
81
|
+
&& entitlement.repos.includes(repo);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const surfaceIssues = (stateDir, emit, now, env, home, platform) => {
|
|
66
85
|
const status = json(path.join(stateDir, 'open-issues.json'));
|
|
67
|
-
|
|
86
|
+
const observedAt = Date.parse(status?.at || '');
|
|
87
|
+
if (!Number.isFinite(observedAt) || observedAt > now + 5 * 60_000 || now - observedAt > 6 * 3600_000) return;
|
|
88
|
+
if (!maintainerIssueEntitlement(env, home, status.repo, platform)) return;
|
|
68
89
|
const open = Array.isArray(status.issues) ? status.issues : [];
|
|
69
90
|
if (!open.length) return;
|
|
70
91
|
const breaches = open.filter((issue) => issue.breach).sort((a, b) => b.ageHours - a.ageHours);
|
|
@@ -383,7 +404,7 @@ export async function runSessionStart({
|
|
|
383
404
|
emit(`Offer ONCE: "Want to see your whole RuvNet stack on one page?" — installed parts, learned project knowledge and reversible fixes, read-only until clicked; later it's ${consoleInvoke}. On yes invoke ${consoleInvoke}; on no, don't re-offer.`);
|
|
384
405
|
}
|
|
385
406
|
|
|
386
|
-
surfaceIssues(stateDir, emit, now);
|
|
407
|
+
surfaceIssues(stateDir, emit, now, env, home, platform);
|
|
387
408
|
surfaceSignals({ env, cwd, stateDir, hookDir, emit, now });
|
|
388
409
|
|
|
389
410
|
const routerProfile = path.join(home, '.claude', 'model-router', 'profile.json');
|
|
@@ -476,10 +497,7 @@ export async function runSessionStart({
|
|
|
476
497
|
|
|
477
498
|
const playbook = `${hookDir}${path.sep}..${path.sep}skills${path.sep}ruvnet-brain${path.sep}PLAYBOOK.md`;
|
|
478
499
|
emit('[RuvNet Brain — standing build playbook for this session (referenced by later turns as THE PLAYBOOK)]');
|
|
479
|
-
emit(`
|
|
480
|
-
emit('Every build/change request: take the wheel. FIRST, silently — read the files this touches in THEIR repo; search_ruvnet what the feature technically DOES; check project memory.');
|
|
481
|
-
emit('⛔ NO SILENT SUBSTITUTION (#1 trust-killer): never hand-roll, or aim a generic Task subagent at, work a RuvNet tool owns — QE=agentic-qe, swarms=ruflo, routing=agentic-flow, vectors=RuVector, memory=AgentDB, red/blue=@metaharness/redblue. Use the real one; if absent offer the exact install; if unusable say so out loud, every time. Never give your own code its name.');
|
|
482
|
-
emit('Beats A-D are in that file, in full. In short: A RESPOND in one voice (hear them; THE ATTACK as one lettered plan over their real files; why it holds; what you checked; "Build it now?") · B ON A YES EXECUTE END-TO-END (SPARC with a QA gate per phase, DDD, ADRs, PARALLEL Ruflo swarm work, AgentDB persistence, frontend-design + real image generation, a PROVEN result scored to >=98, ONE ask for a missing API key) · C TAKE OVER what you do well · D keep them oriented. RUN THE PROCESS.');
|
|
500
|
+
emit(`Read ${playbook} before the first build response. It requires source inspection, search_ruvnet grounding, project-memory recall, and the real owning rUv tool—never a silent hand-roll or generic substitute.`);
|
|
483
501
|
}
|
|
484
502
|
} catch (error) {
|
|
485
503
|
if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// swarm-slot-recycler.mjs — Claude Code TeammateIdle recycling boundary.
|
|
3
|
+
//
|
|
4
|
+
// Claude owns the shared task files and their claim locks. This hook never edits them. It reads the
|
|
5
|
+
// team ledger at the synchronous TeammateIdle boundary and refuses idling only when it can prove an
|
|
6
|
+
// unassigned pending task is ready. Claude then performs the normal locked TaskUpdate claim. Missing,
|
|
7
|
+
// malformed, dependency-blocked, or ambiguous state fails open: a scheduler must not invent work.
|
|
8
|
+
//
|
|
9
|
+
// Host boundary: Claude Code exposes TeammateIdle; Codex 0.146.0 exposes SubagentStop but no
|
|
10
|
+
// TeammateIdle/TaskCompleted event or equivalent shared-task ledger. Do not register this body in the
|
|
11
|
+
// Codex manifest and do not claim Codex recycling is hook-enforced.
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { readStdinBounded } from './hook-input.mjs';
|
|
17
|
+
|
|
18
|
+
const MAX_INPUT_BYTES = 64 * 1024;
|
|
19
|
+
const MAX_TASK_BYTES = 256 * 1024;
|
|
20
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
21
|
+
|
|
22
|
+
function allowIdle() { process.exit(0); }
|
|
23
|
+
|
|
24
|
+
let raw = Buffer.alloc(0);
|
|
25
|
+
try {
|
|
26
|
+
raw = await readStdinBounded({ maxBytes: MAX_INPUT_BYTES, idleMs: 50, emptyMs: 250 });
|
|
27
|
+
} catch { allowIdle(); }
|
|
28
|
+
|
|
29
|
+
let event;
|
|
30
|
+
try {
|
|
31
|
+
event = JSON.parse(raw.toString('utf8'));
|
|
32
|
+
} catch { allowIdle(); }
|
|
33
|
+
|
|
34
|
+
if (!event || typeof event !== 'object' || Array.isArray(event)) allowIdle();
|
|
35
|
+
if (event.hook_event_name !== 'TeammateIdle') allowIdle();
|
|
36
|
+
|
|
37
|
+
const teamName = String(event.team_name || '');
|
|
38
|
+
const teammateName = String(event.teammate_name || '');
|
|
39
|
+
if (!SAFE_NAME.test(teamName) || !SAFE_NAME.test(teammateName)) allowIdle();
|
|
40
|
+
|
|
41
|
+
const tasksRoot = process.env.RUVNET_CLAUDE_TASKS_DIR
|
|
42
|
+
|| path.join(os.homedir(), '.claude', 'tasks');
|
|
43
|
+
const teamDir = path.resolve(tasksRoot, teamName);
|
|
44
|
+
const root = path.resolve(tasksRoot);
|
|
45
|
+
if (path.dirname(teamDir) !== root) allowIdle();
|
|
46
|
+
|
|
47
|
+
let files;
|
|
48
|
+
try {
|
|
49
|
+
files = fs.readdirSync(teamDir, { withFileTypes: true })
|
|
50
|
+
.filter((entry) => entry.isFile() && /^\d+\.json$/.test(entry.name))
|
|
51
|
+
.map((entry) => entry.name);
|
|
52
|
+
} catch { allowIdle(); }
|
|
53
|
+
|
|
54
|
+
const tasks = new Map();
|
|
55
|
+
for (const file of files) {
|
|
56
|
+
try {
|
|
57
|
+
const taskPath = path.join(teamDir, file);
|
|
58
|
+
const stat = fs.statSync(taskPath);
|
|
59
|
+
if (!stat.isFile() || stat.size > MAX_TASK_BYTES) continue;
|
|
60
|
+
const task = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
|
61
|
+
if (!task || typeof task !== 'object' || Array.isArray(task)) continue;
|
|
62
|
+
const id = String(task.id || path.basename(file, '.json'));
|
|
63
|
+
if (!/^\d+$/.test(id)) continue;
|
|
64
|
+
tasks.set(id, task);
|
|
65
|
+
} catch { /* one bad task cannot make another look ready */ }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const completed = new Set(
|
|
69
|
+
[...tasks].filter(([, task]) => task.status === 'completed').map(([id]) => id),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const ready = [...tasks]
|
|
73
|
+
.filter(([, task]) => task.status === 'pending')
|
|
74
|
+
.filter(([, task]) => !String(task.owner || '').trim())
|
|
75
|
+
.filter(([, task]) => {
|
|
76
|
+
if (!Array.isArray(task.blockedBy)) return false;
|
|
77
|
+
return task.blockedBy.every((id) => completed.has(String(id)));
|
|
78
|
+
})
|
|
79
|
+
.sort(([left], [right]) => Number(left) - Number(right));
|
|
80
|
+
|
|
81
|
+
if (!ready.length) allowIdle();
|
|
82
|
+
|
|
83
|
+
const [id, task] = ready[0];
|
|
84
|
+
const subject = String(task.subject || 'untitled task').replace(/[\r\n]+/g, ' ').slice(0, 160);
|
|
85
|
+
process.stderr.write(
|
|
86
|
+
`Ready work remains. Claim task ${id} (${subject}) now with TaskUpdate owner=${teammateName} `
|
|
87
|
+
+ 'and status=in_progress, then execute it. Do not go idle while an unassigned, unblocked pending task exists.\n',
|
|
88
|
+
);
|
|
89
|
+
process.exit(2);
|
|
@@ -30,10 +30,10 @@ green.
|
|
|
30
30
|
## Candidate seal
|
|
31
31
|
|
|
32
32
|
Generate the receipt from commands in the protected candidate workflow. Do not hand-author it.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
creating its
|
|
33
|
+
Dispatch `.github/workflows/protected-release.yml` only with the full candidate SHA, the exact
|
|
34
|
+
current version, and the successful exact-SHA CI run ID whose named `release-qe` job produced
|
|
35
|
+
`release-evidence-<sha>`. The workflow derives the artifact digest from those sealed bytes and checks
|
|
36
|
+
every binding before creating its handoff and again at the Production boundary. Missing artifacts,
|
|
37
37
|
pending/red jobs, malformed inputs, version splits, and byte mismatches stop before the publisher.
|
|
38
38
|
Validate it from the repository with:
|
|
39
39
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# THE PLAYBOOK — the standing build playbook, in full
|
|
2
2
|
|
|
3
|
-
Updated: 2026-
|
|
3
|
+
Updated: 2026-08-02 | Version 1.1.0
|
|
4
4
|
Created: 2026-07-27
|
|
5
5
|
|
|
6
6
|
**Read this before your first build response in a session.** `plugin/scripts/session-start.sh`
|
|
@@ -87,10 +87,25 @@ rule-compliance, cite a source the tools didn't return, or claim a check that di
|
|
|
87
87
|
Completion, with a QA gate between phases.
|
|
88
88
|
- For a non-trivial domain, model it first (DDD: bounded contexts, aggregates, domain events) and
|
|
89
89
|
capture key decisions as ADRs — design before code.
|
|
90
|
-
-
|
|
90
|
+
- **Parallel-by-default state machine for multi-part work.** Before the first spawn, decompose the
|
|
91
|
+
whole request, create the complete shared task list/ledger, record dependencies and give every
|
|
92
|
+
writing task its own worktree. Ruflo coordinates roles and state; the native host executes. Fill
|
|
93
|
+
available executor slots with independent ready work immediately, without asking, up to the
|
|
94
|
+
host's configured capacity; never oversubscribe and never put more than one writer in a worktree.
|
|
95
|
+
A completion moves that task to completed, unblocks its dependents, and the freed slot claims the
|
|
96
|
+
first unassigned, unblocked pending task immediately. Only allow a slot to idle when no such task
|
|
97
|
+
exists. Keep dependent integration with the designated integration owner.
|
|
98
|
+
- **Claude Code:** its shared task ledger and `TeammateIdle` hook make recycling enforceable: the
|
|
99
|
+
shipped recycler refuses idle while a ready unassigned task exists, then Claude's locked
|
|
100
|
+
`TaskUpdate` claim performs the transition.
|
|
101
|
+
- **Codex:** Codex 0.146.0 exposes no `TeammateIdle` or `TaskCompleted` hook and no equivalent
|
|
102
|
+
shared-task hook ledger. Initial fan-out and completion-notification recycling are guidance,
|
|
103
|
+
not hook enforcement: the lead must immediately dispatch the next ready ledger item when a
|
|
104
|
+
collaboration slot completes. State this degraded boundary if it affects the run; never call it
|
|
105
|
+
enforced.
|
|
91
106
|
If Ruflo / RuVector MCP tools aren't available in this environment, DON'T block or stall — degrade
|
|
92
|
-
gracefully to
|
|
93
|
-
|
|
107
|
+
gracefully to the native host's agents and local .rvf, and briefly note the tool that would make
|
|
108
|
+
it better + how to add it. Never demand a tool the user doesn't have.
|
|
94
109
|
- Persist decisions + state to AgentDB memory so nothing is lost across sessions or compaction.
|
|
95
110
|
- If it has a UI, treat design as a BUILD STEP, not a coat of paint: apply the frontend-design
|
|
96
111
|
discipline and GENERATE the visuals (AI image generation for UI mockups / diagrams / the explainer
|
|
@@ -515,9 +515,9 @@ export const CAPABILITIES = [
|
|
|
515
515
|
// router was in fact never being consulted at all. A quiet week and a severed wire look identical
|
|
516
516
|
// from the receipt file, so read the wire directly.
|
|
517
517
|
//
|
|
518
|
-
// Two things must both be true for
|
|
519
|
-
//
|
|
520
|
-
//
|
|
518
|
+
// Two things must both be true for the host-limited dispatch audit to record anything: a
|
|
519
|
+
// PreToolUse hook on subagent dispatch and the opt-in profile it refuses to act without
|
|
520
|
+
// (route-dispatch.sh exits 0 when
|
|
521
521
|
// profile.json is absent). Either missing ⇒ the router cannot fire, regardless of how healthy
|
|
522
522
|
// the receipt ledger looks.
|
|
523
523
|
const profile = fs.existsSync(path.join(HOME, '.claude/model-router/profile.json'));
|