ruvnet-brain 3.9.134-dev → 4.0.1

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 (57) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/README.md +2 -2
  3. package/bin/install.mjs +284 -33
  4. package/kb/zip-extract.mjs +53 -14
  5. package/package.json +7 -1
  6. package/plugin/.claude-plugin/marketplace.json +13 -0
  7. package/plugin/.claude-plugin/plugin.json +23 -0
  8. package/plugin/.codex-plugin/plugin.json +21 -0
  9. package/plugin/.mcp.json +8 -0
  10. package/plugin/commands/brain-console.md +16 -0
  11. package/plugin/commands/configure.md +32 -0
  12. package/plugin/commands/rvbc.md +78 -0
  13. package/plugin/commands/rvcb.md +16 -0
  14. package/plugin/commands/whats-new.md +57 -0
  15. package/plugin/hooks/codex-hooks.json +160 -0
  16. package/plugin/hooks/hook-contracts.json +77 -0
  17. package/plugin/hooks/hooks.json +203 -0
  18. package/plugin/mcp/server.mjs +35 -6
  19. package/plugin/scripts/anticipate.sh +534 -0
  20. package/plugin/scripts/codex-hook-adapter.mjs +96 -0
  21. package/plugin/scripts/continuation-gate.mjs +267 -0
  22. package/plugin/scripts/design-wall.sh +137 -0
  23. package/plugin/scripts/detach.mjs +168 -0
  24. package/plugin/scripts/finalize-token-meter.mjs +25 -0
  25. package/plugin/scripts/gate-receipt.sh +35 -0
  26. package/plugin/scripts/ground-before-write.sh +199 -0
  27. package/plugin/scripts/ground-ruvnet.sh +507 -0
  28. package/plugin/scripts/grounding-stamp.sh +113 -0
  29. package/plugin/scripts/grounding-substance.mjs +595 -0
  30. package/plugin/scripts/hijack-ruvnet.sh +81 -0
  31. package/plugin/scripts/hook-input.mjs +558 -0
  32. package/plugin/scripts/hook-shim-bash.mjs +55 -0
  33. package/plugin/scripts/hook-shim.mjs +303 -0
  34. package/plugin/scripts/host-update.mjs +58 -0
  35. package/plugin/scripts/kling-preflight.sh +146 -0
  36. package/plugin/scripts/learn-capture.sh +154 -0
  37. package/plugin/scripts/learn-flush.mjs +138 -0
  38. package/plugin/scripts/lesson-hooks.sh +213 -0
  39. package/plugin/scripts/md-stamp.mjs +219 -0
  40. package/plugin/scripts/protect-brain-state.sh +84 -0
  41. package/plugin/scripts/route-dispatch.sh +147 -0
  42. package/plugin/scripts/routing-outcome-capture.mjs +89 -0
  43. package/plugin/scripts/session-start.sh +868 -0
  44. package/plugin/scripts/signal-watch.mjs +193 -0
  45. package/plugin/scripts/unprompted-runtime.mjs +377 -0
  46. package/plugin/scripts/update-apply.mjs +419 -0
  47. package/plugin/scripts/verify-interface.sh +53 -0
  48. package/plugin/scripts/version-bump-gate.sh +112 -0
  49. package/plugin/skills/brain-build/SKILL.md +123 -0
  50. package/plugin/skills/brain-console/SKILL.md +20 -0
  51. package/plugin/skills/brain-prompt/SKILL.md +83 -0
  52. package/plugin/skills/brain-score/SKILL.md +101 -0
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +117 -0
  54. package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
  55. package/plugin/skills/rvbc/SKILL.md +20 -0
  56. package/plugin/skills/savings/SKILL.md +46 -0
  57. package/plugin/skills/whats-new/SKILL.md +22 -0
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ // plugin/scripts/md-stamp.mjs — PostToolUse (Write|Edit|MultiEdit). Refreshes an EXISTING doc
3
+ // stamp's date to today, in-place, whenever a touched .md file's stamp has gone stale.
4
+ //
5
+ // WHY. The owner: "I told you to update all .md docs with time/date stamps when touched. I don't
6
+ // want to have to remind you again in any repo." Today that convention lives only in the model's
7
+ // memory — it forgets, in this repo and in every other one. This makes it a mechanism instead:
8
+ // the date on a doc's own `Updated:` line (or an ADR/DDD frontmatter `updated:` key) is corrected
9
+ // by the harness itself, the instant the file is touched, with zero model involvement.
10
+ //
11
+ // SCOPE, DELIBERATELY NARROW. This does not invent a stamp format (a file with none is left alone —
12
+ // "only maintain existing stamps, never impose a format") and it does not implement ADR-034's larger
13
+ // doc-currency system (governs:, verified:, digests, a currency log) — that is a separate, heavier,
14
+ // push-time gate over docs/adr/ + docs/ddd/ (scripts/doc-currency.mjs). This hook is the small,
15
+ // always-on half: keep the date people actually read from going stale, on every .md file in the repo.
16
+ //
17
+ // CONTRACT (matches every other advisory hook body in this dir): dispatched via hook-shim.mjs
18
+ // (mode: advisory), fed the Claude Code tool-event JSON on stdin exactly like learn-capture.sh /
19
+ // continuation-gate.mjs. ALWAYS exits 0 — a hook that can block a turn over a markdown date is a
20
+ // hook that gets disabled within a day. On any parse/IO error: do nothing, exit 0, silently.
21
+ //
22
+ // IDEMPOTENCY IS THE WHOLE SAFETY ARGUMENT. A write here re-fires this same PostToolUse hook. If a
23
+ // file already carries today's date, this script MUST NOT touch it — no write, byte-for-byte
24
+ // identical — or every `Write`/`Edit` of an up-to-date doc would loop forever. Every code path below
25
+ // is built around that: compute the new content, and only call fs.writeFileSync if it actually
26
+ // differs from what's on disk.
27
+ //
28
+ // PURITY: node builtins only (fs, path, url) plus this dir's own hook-input.mjs (ADR-0021's shared,
29
+ // tested payload parser) — no npm dependency, no shelling out.
30
+
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { fileURLToPath } from 'node:url';
34
+ import { parseHookEvent, toolName, field, readStdinBounded } from './hook-input.mjs';
35
+
36
+ // ── date, from the system clock, formatted in the repo's standard timezone ─────────────────────────
37
+ // Same idiom as scripts/self-update.mjs's README badge stamp: Intl.DateTimeFormat is a Node builtin
38
+ // (ICU is bundled), never a guessed/hardcoded string.
39
+ // The date is computed in the MACHINE'S OWN timezone by default — this plugin ships to other
40
+ // machines, and a user in Tokyo editing their own doc should get Tokyo's date, not New York's.
41
+ // Overridable with RUVNET_MD_STAMP_TZ for anyone who wants their docs pinned to a fixed zone (e.g.
42
+ // a team standardising on ET). Name kept `todayNY` for import stability; the "NY" is now only the
43
+ // legacy default's ghost, not a hardcode. An invalid TZ value falls back to system-local, never throws.
44
+ export function todayNY(now = new Date(), tz = process.env.RUVNET_MD_STAMP_TZ) {
45
+ const opts = { year: 'numeric', month: '2-digit', day: '2-digit' };
46
+ if (tz) opts.timeZone = tz;
47
+ let parts;
48
+ try { parts = new Intl.DateTimeFormat('en-CA', opts).formatToParts(now); }
49
+ catch { parts = new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(now); }
50
+ const g = (t) => parts.find((p) => p.type === t)?.value || '';
51
+ return `${g('year')}-${g('month')}-${g('day')}`;
52
+ }
53
+
54
+ // ── the two known stamp conventions (grepped from this repo's real docs, not guessed) ──────────────
55
+ // 1. Plain docs (README/SPEC/docs/*.md): a line near the top reading
56
+ // Updated: 2026-07-22 01:40:00 EDT | Version 1.0.0
57
+ // (sometimes backtick-wrapped, sometimes date-only, sometimes a trailing comment instead of a
58
+ // version — the DATE is the only part every observed variant shares, so it's the only part
59
+ // touched: time/TZ/version/comment text survive byte-for-byte).
60
+ // 2. ADR/DDD YAML frontmatter: a bare `updated: 2026-07-22` key inside the leading `---` block.
61
+
62
+ const PLAIN_UPDATED_RE = /^([ \t]*`?Updated:[ \t]*)(\d{4}-\d{2}-\d{2})/m;
63
+ const FRONTMATTER_BLOCK_RE = /^---\r?\n[\s\S]*?\r?\n---[ \t]*\r?\n/;
64
+ const FRONTMATTER_UPDATED_RE = /^(updated:[ \t]*)(\d{4}-\d{2}-\d{2})([ \t]*)$/m;
65
+
66
+ // Real stamps in this repo sit on line 1-4 (README/SPEC/docs/*.md); 10 lines is generous headroom
67
+ // without wandering into prose that merely discusses the convention (which never contains a real
68
+ // date next to the literal capitalized word "Updated:", so the risk is already low — this is a
69
+ // second, structural guard on top of that).
70
+ const PLAIN_STAMP_MAX_LINES = 10;
71
+
72
+ function headSlice(content, maxLines) {
73
+ let idx = 0;
74
+ for (let line = 0; line < maxLines; line++) {
75
+ const nl = content.indexOf('\n', idx);
76
+ if (nl === -1) return content; // whole file is shorter than the window
77
+ idx = nl + 1;
78
+ }
79
+ return content.slice(0, idx);
80
+ }
81
+
82
+ /** Refresh a plain `Updated: <date>...` line near the top of the file. No-op if absent/current. */
83
+ function refreshPlainStamp(content, today) {
84
+ const head = headSlice(content, PLAIN_STAMP_MAX_LINES);
85
+ const m = head.match(PLAIN_UPDATED_RE);
86
+ if (!m || m[2] === today) return content;
87
+ const patchedHead = head.slice(0, m.index) + m[1] + today + head.slice(m.index + m[0].length);
88
+ return patchedHead + content.slice(head.length);
89
+ }
90
+
91
+ /** Refresh a bare `updated: <date>` key inside the leading YAML frontmatter block. No-op if absent/current. */
92
+ function refreshFrontmatterStamp(content, today) {
93
+ const block = content.match(FRONTMATTER_BLOCK_RE);
94
+ if (!block || block.index !== 0) return content;
95
+ const um = block[0].match(FRONTMATTER_UPDATED_RE);
96
+ if (!um || um[2] === today) return content;
97
+ const patchedBlock =
98
+ block[0].slice(0, um.index) + um[1] + today + um[3] + block[0].slice(um.index + um[0].length);
99
+ return patchedBlock + content.slice(block[0].length);
100
+ }
101
+
102
+ // ── ENSURE (ADR-056 §2/§3, 2026-07-27) ───────────────────────────────────────────────────────────
103
+ // Everything above only ever REFRESHES a stamp someone already wrote. That is deliberately half the
104
+ // job, and the duel proved it is the WRONG half: a hook that fires on edit "never reaches a stale
105
+ // file, by definition of stale" — the 166 unstamped files are unstamped precisely because nobody is
106
+ // editing them. So there is a second entry point, used by the one-time sweep
107
+ // (scripts/stamp-sweep.mjs) and available to the hook behind an explicit opt-in.
108
+ //
109
+ // PLACEMENT IS BY SHAPE, NEVER A LITERAL LINE 1. Five plugin/skills/*/SKILL.md files require YAML
110
+ // frontmatter at line 1 for Claude Code's skill loader; a blind line-1 insert stops them loading. And
111
+ // this ships to strangers, whose line 1 is load-bearing in ways this repo cannot enumerate.
112
+ //
113
+ // THE REFUSAL IS THE FEATURE. On any prologue we do not positively recognise, this returns the
114
+ // content UNCHANGED. Silence is the correct output for a shape we do not understand — an insertion
115
+ // that corrupts someone's document is far worse than a document without a date.
116
+
117
+ const H1_RE = /^(#[^\n]*\r?\n)/;
118
+ // A leading HTML comment, an MDX import/export, a Jekyll/Astro directive, a license banner: all
119
+ // prologue shapes whose first line is load-bearing. We recognise them only well enough to REFUSE.
120
+ const UNKNOWN_PROLOGUE_RE = /^\s*(<!--|<|import\s|export\s|\{\/\*|%%|\/\*|#!)/;
121
+
122
+ /** Is this document safe to insert into, and where? Returns null when the answer is "do not touch". */
123
+ export function stampInsertionPoint(content) {
124
+ if (FRONTMATTER_BLOCK_RE.test(content)) return { kind: 'frontmatter' };
125
+ if (UNKNOWN_PROLOGUE_RE.test(content)) return null; // refuse — shape not understood
126
+ const h1 = content.match(H1_RE);
127
+ // After a leading `# Title` is where every stamped document in this repo actually puts it
128
+ // (DDD-0008, SPEC.md, the primer). Matching the house shape beats a pedantic line 1.
129
+ if (h1) return { kind: 'after-h1', index: h1[0].length };
130
+ return { kind: 'top', index: 0 };
131
+ }
132
+
133
+ // A THIRD stamp shape, found 2026-07-27: README carries its date inside a shields.io badge
134
+ // ("version 3.9.85-dev — updated 2026-07-27 06:02 EDT"), maintained by self-update.mjs. Without
135
+ // this, README reported "prologue shape not recognised" — a refusal that was RIGHT IN OUTCOME and
136
+ // WRONG IN ITS REASON, which is precisely the class of accidental correctness this ADR exists to
137
+ // end. Recognising it makes the report say the true thing: already stamped, leave it alone.
138
+ // Deliberately narrow: the word `updated` adjacent to a date, inside a link/image, in the head.
139
+ const BADGE_UPDATED_RE = /!?\[[^\]]*updated[_\s-]+\d{4}-{1,2}\d{2}-{1,2}\d{2}/i;
140
+
141
+ /** Does this document already carry a stamp anywhere we would look? */
142
+ export function hasStamp(content) {
143
+ const fm = content.match(FRONTMATTER_BLOCK_RE);
144
+ if (fm && fm.index === 0 && FRONTMATTER_UPDATED_RE.test(fm[0])) return true;
145
+ const head = headSlice(content, PLAIN_STAMP_MAX_LINES);
146
+ return PLAIN_UPDATED_RE.test(head) || BADGE_UPDATED_RE.test(head);
147
+ }
148
+
149
+ /**
150
+ * Pure. Insert a stamp when — and only when — the document has none and its shape is understood.
151
+ * `updated` is REQUIRED and must be derived by the caller (git), never defaulted to today: stamping
152
+ * an untouched file with today's date is the "false freshness" failure DDD-0008 invariant 4 names.
153
+ */
154
+ export function ensureStamp(content, { updated, created } = {}) {
155
+ if (!updated || !/^\d{4}-\d{2}-\d{2}$/.test(updated)) return content; // no derived date ⇒ no stamp
156
+ if (hasStamp(content)) return content; // already stamped ⇒ never touch
157
+ const at = stampInsertionPoint(content);
158
+ if (!at) return content; // shape refused
159
+
160
+ if (at.kind === 'frontmatter') {
161
+ // Frontmatter with no `updated:` key — add it INSIDE the block, never above it.
162
+ const block = content.match(FRONTMATTER_BLOCK_RE)[0];
163
+ const closing = block.lastIndexOf('---');
164
+ const line = `updated: ${updated}\n`;
165
+ return block.slice(0, closing) + line + block.slice(closing) + content.slice(block.length);
166
+ }
167
+
168
+ const stamp = created && /^\d{4}-\d{2}-\d{2}$/.test(created) && created !== updated
169
+ ? `\nUpdated: ${updated}\nCreated: ${created}\n`
170
+ : `\nUpdated: ${updated}\n`;
171
+ return content.slice(0, at.index) + stamp + content.slice(at.index);
172
+ }
173
+
174
+ /** Pure: given a .md file's current bytes, return the bytes it should have. Identical in ⇒ identical out. */
175
+ export function computeStampedContent(content, today = todayNY()) {
176
+ return refreshPlainStamp(refreshFrontmatterStamp(content, today), today);
177
+ }
178
+
179
+ // ── the hook body ────────────────────────────────────────────────────────────────────────────────
180
+ async function readHookInput() {
181
+ // Never block waiting on stdin: a TTY (someone running this file by hand) has none to give.
182
+ if (process.stdin.isTTY) return null;
183
+ try { return parseHookEvent((await readStdinBounded()).toString('utf8')); } catch { return null; }
184
+ }
185
+
186
+ async function main() {
187
+ // THE OFF SWITCH. This hook writes to the user's own files, so it must be silenceable in one move
188
+ // — the same "nothing without you" bar anticipate.sh's RUVNET_ANTICIPATE=0 meets. Set
189
+ // RUVNET_MD_STAMP=0 (or =off) and it becomes a no-op. Absence = on (it only ever refreshes a stamp
190
+ // the user already put there, never invents one), but the escape hatch exists and is honoured first.
191
+ const sw = String(process.env.RUVNET_MD_STAMP ?? '').trim().toLowerCase();
192
+ if (sw === '0' || sw === 'off' || sw === 'false' || sw === 'no') return;
193
+
194
+ const ev = await readHookInput();
195
+ if (!['Write', 'Edit', 'MultiEdit'].includes(toolName(ev))) return; // wrong tool: do nothing
196
+
197
+ const filePath = field(ev, 'tool_input.file_path');
198
+ if (!filePath || path.extname(filePath).toLowerCase() !== '.md') return; // wrong file: do nothing
199
+
200
+ let original;
201
+ try { original = fs.readFileSync(filePath, 'utf8'); } catch { return; } // unreadable/gone: exit 0
202
+
203
+ let stamped;
204
+ try { stamped = computeStampedContent(original); } catch { return; } // malformed content: exit 0
205
+
206
+ if (stamped === original) return; // already current (or no stamp at all) — NEVER write; loop guard
207
+
208
+ try { fs.writeFileSync(filePath, stamped); } catch { /* advisory — a failed write is not our problem */ }
209
+ }
210
+
211
+ function isMain() {
212
+ try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
213
+ catch { return false; }
214
+ }
215
+
216
+ if (isMain()) {
217
+ try { await main(); } catch { /* fail open, always — see CONTRACT above */ }
218
+ process.exit(0);
219
+ }
@@ -0,0 +1,84 @@
1
+ #!/bin/bash
2
+ # protect-brain-state.sh — PreToolUse gate on Write|Edit|MultiEdit.
3
+ # AN AGENT MAY NOT EDIT THE USER'S OWN CONSENT RECORD.
4
+ #
5
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
6
+ # WHY (ADR-054 §3, Fable 5's single most pointed duel finding). The moment the brain can be switched
7
+ # off, the switch becomes a thing an agent can switch back on. Every plane of the off contract is
8
+ # built to keep the model from noticing the lever — search_ruvnet's soft answer deliberately omits
9
+ # the re-enable mechanism, the session-start line is one dim sentence with no instruction attached —
10
+ # but a prompt is advisory and this repo has learned twice over what advisory means. rUv states the
11
+ # rule himself (@claude-flow/guidance, ADR-G007): "The model can forget a rule; the gate does not."
12
+ #
13
+ # So the paths that record the user's choice are walled off from the tools an agent writes with:
14
+ # • the sentinel ~/.config/ruvnet-brain/brain-off — the switch itself
15
+ # • the settings mirror ~/.config/ruvnet-brain/settings.json, and its .bak-*/.lock/.tmp-* siblings
16
+ # (a restore-from-backup or a lock-file trick is the same edit by another route)
17
+ #
18
+ # WHAT THIS DOES NOT CLAIM. It guards Write/Edit/MultiEdit, which is where an agent writes files. It
19
+ # does NOT guard `rm` or `>` through Bash — that is a different matcher with a different parse and a
20
+ # much larger false-positive surface, and pretending otherwise would be a bigger lie than the gap.
21
+ # Stated plainly here rather than discovered later: this raises the cost of an accidental flip and
22
+ # of a careless one; it is not a sandbox.
23
+ #
24
+ # The refusal deliberately names no MODEL-EXECUTABLE remedy. Every other blocking gate in this repo
25
+ # teaches the way through, because there the way through is something the model should do. Here it
26
+ # is something only the user may do, so a helpful "run this to undo it" line would be the
27
+ # vulnerability rather than the fix. It says who owns the change, not how to perform it.
28
+ #
29
+ # CONTRACT: exit 0 = allow · exit 2 + stderr = BLOCK (stderr returns to the model as the reason).
30
+ # FAILS OPEN on anything unparseable — a blocking hook must never brick a session. Pure bash
31
+ # builtins, no node/jq/python, for the same reason as ground-before-write.sh: a wall that can
32
+ # fail-open because a tool went missing is not a wall.
33
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
34
+
35
+ set -uo pipefail
36
+
37
+ INPUT=""
38
+ # BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
39
+ # opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
40
+ # harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
41
+ # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
42
+ # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
43
+ # the whole thing back at once and a per-iteration cap never fires.
44
+ while IFS= read -r -t 2 _l; do
45
+ INPUT+="$_l"
46
+ [ ${#INPUT} -ge 65536 ] && break
47
+ done
48
+ [ -n "$_l" ] && INPUT+="$_l"
49
+ INPUT="${INPUT:0:65536}"
50
+ [ -n "$INPUT" ] || exit 0
51
+
52
+ field() { local re="\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\""; [[ $INPUT =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"; }
53
+
54
+ case "$(field tool_name)" in Write|Edit|MultiEdit|NotebookEdit) ;; *) exit 0 ;; esac
55
+
56
+ FILE_PATH=$(field file_path)
57
+ [ -n "$FILE_PATH" ] || exit 0
58
+
59
+ # The same two paths brain-state.mjs and user-settings.mjs compute, resolved the same way. The env
60
+ # overrides exist so this suite (and a second machine profile) can point them elsewhere; the literal
61
+ # defaults are matched TOO, so the guard protects a real user even when no override is set — a gate
62
+ # that only fires under its own test harness protects nobody.
63
+ STATE_DIR="${RUVNET_BRAIN_STATE_DIR:-$HOME/.config/ruvnet-brain}"
64
+ SETTINGS_FILE="${RUVNET_SETTINGS_FILE:-$HOME/.config/ruvnet-brain/settings.json}"
65
+
66
+ PROTECTED=""
67
+ case "$FILE_PATH" in
68
+ "$STATE_DIR"/brain-off|"$STATE_DIR"/brain-off.*) PROTECTED="the on/off switch" ;;
69
+ "$SETTINGS_FILE"|"$SETTINGS_FILE".*) PROTECTED="your saved settings" ;;
70
+ # Literal defaults, for any HOME/override combination that did not match above. The trailing `*`
71
+ # covers .bak-<stamp>, .lock and .tmp-<pid> — the same file by another name.
72
+ */.config/ruvnet-brain/brain-off|*/.config/ruvnet-brain/brain-off.*) PROTECTED="the on/off switch" ;;
73
+ */.config/ruvnet-brain/settings.json|*/.config/ruvnet-brain/settings.json.*) PROTECTED="your saved settings" ;;
74
+ esac
75
+ [ -n "$PROTECTED" ] || exit 0
76
+
77
+ cat >&2 <<EOF
78
+ ⛔ BLOCKED — that file is the user's own record of how they want this machine to behave ($PROTECTED).
79
+
80
+ It is changed by the person, from the RuvNet Brain console, and never by an agent editing the file.
81
+ Do not attempt this another way, and do not offer to. If the user's intent was to change one of
82
+ these settings, say so in plain words and let them make the change themselves.
83
+ EOF
84
+ exit 2
@@ -0,0 +1,147 @@
1
+ #!/bin/bash
2
+ # route-dispatch.sh — PreToolUse gate on subagent dispatch. Ends model-inheritance-by-omission.
3
+ #
4
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
5
+ # THE LEAK (2026-07-13). Stuart: "What happens when I'm right here in Opus 4.8 and it has 10 things
6
+ # to run? Is it going to just run them as Opus 4.8?" — YES:
7
+ #
8
+ # A SUBAGENT INHERITS THE MAIN-LOOP MODEL UNLESS `model` IS EXPLICITLY PASSED.
9
+ #
10
+ # Ten agents on a Fable session = ten agents at $10/$50 per Mtok, ~10x Haiku for identical mechanical
11
+ # work. The router existed; the rule to use it existed; the router's ENTIRE LIFETIME OUTPUT was 3 test
12
+ # pings and $0.018 saved — because the rule was ADVISORY. So this is a wall, not advice.
13
+ #
14
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
15
+ # THREE DEFECTS IN MY OWN FIRST VERSION, caught by asking the questions Stuart would have asked
16
+ # (2026-07-13, minutes after shipping it — the whole point of the adversarial pass):
17
+ #
18
+ # 1. IT BLOCKED EVERY USER. This hook ships to everyone who installs RuvNet Brain. Hard-blocking
19
+ # the Task tool for people who never asked for cost routing is hostile — I would have broken
20
+ # strangers' workflows to save Stuart money. Now it ENFORCES ONLY FOR USERS WHO OPTED IN
21
+ # (a model-router profile.json exists = they answered the two subscription questions). Everyone
22
+ # else gets NOTHING — not even a warning. Consent is the default.
23
+ # 2. IT REQUIRED python3. The other three plugin hooks are pure bash. A hard dependency inside a
24
+ # BLOCKING hook is how you brick someone's session. Now pure bash — no interpreters.
25
+ # 3. IT COULD FAIL CLOSED. A blocking hook that errors must never take the session with it. Every
26
+ # unparseable/ambiguous case now FAILS OPEN (exit 0). A gate that breaks your tools is worse
27
+ # than the leak it prevents.
28
+ #
29
+ # CONTRACT (verified against this machine's live hook config):
30
+ # exit 0 → allow
31
+ # exit 2 + stderr → BLOCK, and stderr comes back to the model as the reason (so it retries correctly)
32
+ # ─────────────────────────────────────────────────────────────────────────────────────────────────
33
+
34
+ set -uo pipefail
35
+
36
+ # Read stdin with a BASH BUILTIN, not `cat`. Break-testing on a bare PATH caught this: `INPUT=$(cat)`
37
+ # made the hook depend on an external binary, and when it was missing the gate silently allowed
38
+ # everything. Second hole found the same way as the first (the grep|sed parse). The rule this file
39
+ # now obeys absolutely: A HOOK THAT CAN BLOCK MUST DEPEND ON NOTHING IT CANNOT GUARANTEE.
40
+ INPUT=""
41
+ # BOUNDED READ (2026-07-27, ADR-055 F20): an unqualified `read` never returns on a stdin that is
42
+ # opened and never closed — measured across the mesh, 18 of 37 registered commands sat until the
43
+ # harness killed them. Real Claude Code writes and closes, so this costs no normal turn; that is
44
+ # exactly why a hook that CAN hang forever survives unnoticed. -t bounds the wait, and the string
45
+ # is truncated AFTER the loop because a hook payload is one line with no newline, so `read` hands
46
+ # the whole thing back at once and a per-iteration cap never fires.
47
+ while IFS= read -r -t 2 _line; do
48
+ INPUT+="$_line"
49
+ [ ${#INPUT} -ge 65536 ] && break
50
+ done
51
+ [ -n "$_line" ] && INPUT+="$_line"
52
+ INPUT="${INPUT:0:65536}"
53
+ [ -n "$INPUT" ] || exit 0 # nothing to inspect → never block
54
+
55
+ # ── OPT-IN GATE. No profile = this user never asked for cost routing = we do not touch their tools. ──
56
+ PROFILE="${MODEL_ROUTER_PROFILE:-$HOME/.claude/model-router/profile.json}"
57
+ [ -f "$PROFILE" ] || exit 0
58
+ # A non-interactive install records useful detection data with an explicit `assumed:` basis, but it
59
+ # never asked the consent questions. Treat that provenance as inert. Existing confirmed profiles
60
+ # that predate the basis field remain compatible; malformed/unreadable profiles fail open.
61
+ PROFILE_INPUT=""
62
+ while IFS= read -r _profile_line; do
63
+ PROFILE_INPUT+="$_profile_line"
64
+ [ ${#PROFILE_INPUT} -ge 65536 ] && break
65
+ done < "$PROFILE" 2>/dev/null || exit 0
66
+ [ -n "$_profile_line" ] && PROFILE_INPUT+="$_profile_line"
67
+ case "$PROFILE_INPUT" in *'"basis"'*'"assumed:'*) exit 0 ;; esac
68
+
69
+ # ── Deliberate escape hatch (must be used ON PURPOSE, never reached by omission). ──
70
+ [ "${RUVNET_ALLOW_INHERITED_MODEL:-0}" = "1" ] && exit 0
71
+
72
+ # ── JSON field reads via BASH'S OWN REGEX — no subprocess, no PATH, no locale, no interpreter.
73
+ # v1 used a `grep | head | sed` pipeline. Break-testing it on a bare environment (env -i, only
74
+ # coreutils on PATH) exposed the hole: the pipeline produced an EMPTY string, so the gate silently
75
+ # FAILED OPEN and blocked nothing. A wall with a hole is not a wall — and I would only have learned
76
+ # that from a user whose routing quietly never enforced. `[[ =~ ]]` is a bash builtin: it cannot be
77
+ # missing, cannot be shadowed by PATH, and cannot fail on a locale.
78
+ field() {
79
+ local re="\"$1\"[[:space:]]*:[[:space:]]*\"([^\"]*)\""
80
+ [[ $INPUT =~ $re ]] && printf '%s' "${BASH_REMATCH[1]}"
81
+ }
82
+
83
+ TOOL=$(field tool_name)
84
+ case "$TOOL" in Task|Agent) ;; *) exit 0 ;; esac # only subagent dispatches
85
+
86
+ SUBTYPE=$(field subagent_type)
87
+ [ "$SUBTYPE" = "fork" ] && exit 0 # a fork inherits the parent model BY DESIGN
88
+
89
+ MODEL=$(field model)
90
+ DESC=$(field description)
91
+ TOOL_USE_ID=$(field tool_use_id)
92
+ SESSION_ID=$(field session_id)
93
+ DESC="${DESC// /_}"; DESC="${DESC:0:40}" # builtin substitution — no `tr`, no `cut`
94
+
95
+ if [ -n "$MODEL" ]; then
96
+ # Declared. Log it so routing is AUDITABLE, not merely claimed — a growing ledger is evidence;
97
+ # a promise is not. (This log is how the $0.018-lifetime failure became visible in the first place.)
98
+ # `date` is the ONE external command left, and only on the ALLOW path — so its absence must be
99
+ # silent, not a stderr spew from a hook that just said "yes". (bash's printf %()T would avoid it
100
+ # entirely, but macOS still ships bash 3.2, which does not support it.)
101
+ # The ENTIRE logging block is stderr-silenced as one unit: if the mkdir fails, the append redirect
102
+ # fails too, and BASH ITSELF writes that error — a `2>/dev/null` on the printf does not catch it.
103
+ # A hook that just said "yes" must say nothing at all.
104
+ {
105
+ TS=$(date -u +%FT%TZ) || TS="unknown" # the one external command, and only on the allow path
106
+ mkdir -p "$HOME/.claude/metaharness"
107
+ printf '{"ts":"%s","event":"dispatch","model":"%s","agent":"%s","task":"%s","toolUseId":"%s","sessionId":"%s"}\n' \
108
+ "$TS" "$MODEL" "${SUBTYPE:-unknown}" "${DESC:-unlabeled}" "${TOOL_USE_ID:-}" "${SESSION_ID:-}" \
109
+ >> "$HOME/.claude/metaharness/dispatch-log.jsonl"
110
+ } 2>/dev/null || true
111
+ exit 0
112
+ fi
113
+
114
+ # ── BLOCKED: no model declared → it would silently inherit the session model. ──
115
+ # `read` + `printf` are BUILTINS. The original used `cat >&2 <<EOF`, which made the BLOCK path itself
116
+ # depend on an external binary — the third dependency hole found in my own hook in ten minutes.
117
+ read -r -d '' BLOCK_MSG <<'EOF' || true
118
+ ⛔ SUBAGENT DISPATCH BLOCKED — you did not declare a `model`.
119
+
120
+ An agent with no `model` INHERITS this session's model. On an Opus session that is an Opus agent;
121
+ on a Fable session it is $10/$50 per Mtok — up to 10x what the same work costs on Haiku.
122
+ Inheritance-by-omission is the biggest cost leak in this harness, and an advisory rule did not fix
123
+ it (the router's entire first life saved $0.018). Hence a wall.
124
+
125
+ Re-issue the SAME Agent call with an explicit `model`, chosen by what the task actually IS:
126
+
127
+ model: "haiku" mechanical — greps, file sweeps, log triage, mechanical edits, fixture rewrites
128
+ model: "sonnet" analytical — trace a bug across files, summarize a subsystem, draft tests
129
+ model: "opus" judgment — architecture, root cause, security, anything user-facing
130
+ (if it truly needs the main model's judgment, ask whether it should be a
131
+ subagent at all, or work you should do inline)
132
+
133
+ Not sure? Ask rUv's real router — it predicts each model's quality on THIS task and returns the
134
+ cheapest one that clears the bar, with your subscriptions priced at $0:
135
+
136
+ node ~/.claude/model-router/bin/model-router-engine.mjs --harness claude-code --prompt "<task>" --json
137
+
138
+ Then log the receipt when it returns, so the saving is visible instead of asserted:
139
+
140
+ node scripts/dispatch-receipt.mjs --model <m> --inherited <this session's model> \
141
+ --task "<what it did>" --total-tokens <the agent's reported total>
142
+
143
+ Deliberate exception (rare — and say WHY out loud): RUVNET_ALLOW_INHERITED_MODEL=1
144
+ EOF
145
+ 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
146
+ printf '%s\n' "$BLOCK_MSG" >&2
147
+ exit 2
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse Task/Agent observer. It records the host's terminal status after an explicitly routed
3
+ // dispatch. Host completion is an observation, not a quality grade, so these rows are always marked
4
+ // verified:false and intentionally contain neither embeddings nor scores.
5
+ import fs from 'node:fs';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { createHash } from 'node:crypto';
9
+ import { fileURLToPath } from 'node:url';
10
+ import {
11
+ parseHookEvent,
12
+ rawToolResponse,
13
+ readStdinBounded,
14
+ toolName,
15
+ } from './hook-input.mjs';
16
+
17
+ const SUCCESS = new Set(['completed', 'success', 'succeeded']);
18
+ const FAILURE = new Set(['failed', 'error', 'cancelled', 'canceled', 'timed_out', 'timeout']);
19
+
20
+ export function outcomesPath() {
21
+ return process.env.MODEL_ROUTER_OUTCOMES
22
+ || path.join(os.homedir(), '.claude', 'metaharness', 'routing-outcomes.jsonl');
23
+ }
24
+
25
+ export function dispatchLogPath() {
26
+ return process.env.MODEL_ROUTER_DISPATCH_LOG
27
+ || path.join(os.homedir(), '.claude', 'metaharness', 'dispatch-log.jsonl');
28
+ }
29
+
30
+ export function findDispatchDecision(toolUseId, file = dispatchLogPath()) {
31
+ if (!toolUseId) return null;
32
+ let lines;
33
+ try { lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean); }
34
+ catch { return null; }
35
+ for (let i = lines.length - 1; i >= 0; i--) {
36
+ let row;
37
+ try { row = JSON.parse(lines[i]); } catch { continue; }
38
+ if (row?.event === 'dispatch' && row.toolUseId === toolUseId) return row;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ export function observationFrom(event, now = new Date().toISOString(), decision = null) {
44
+ if (!['Task', 'Agent'].includes(toolName(event))) return null;
45
+ const model = event?.tool_input?.model;
46
+ if (typeof model !== 'string' || !model.trim()) return null;
47
+ const response = rawToolResponse(event);
48
+ const status = typeof response?.status === 'string' ? response.status.toLowerCase() : '';
49
+ if (!SUCCESS.has(status) && !FAILURE.has(status)) return null;
50
+ const task = String(event?.tool_input?.prompt || event?.tool_input?.description || '');
51
+ return {
52
+ schema: 'dispatch-observation-v1',
53
+ ts: now,
54
+ source: 'PostToolUse',
55
+ model,
56
+ success: SUCCESS.has(status),
57
+ hostStatus: status,
58
+ verified: false,
59
+ taskHash: createHash('sha256').update(task).digest('hex').slice(0, 24),
60
+ toolUseId: event.tool_use_id || null,
61
+ sessionId: event.session_id || null,
62
+ decisionLinked: !!decision,
63
+ decisionModel: typeof decision?.model === 'string' ? decision.model : null,
64
+ decisionModelMatch: decision ? decision.model === model : null,
65
+ };
66
+ }
67
+
68
+ export function appendObservation(row, file = outcomesPath()) {
69
+ if (!row) return false;
70
+ try {
71
+ fs.mkdirSync(path.dirname(file), { recursive: true });
72
+ fs.appendFileSync(file, JSON.stringify(row) + '\n');
73
+ return true;
74
+ } catch {
75
+ return false;
76
+ }
77
+ }
78
+
79
+ async function main() {
80
+ if (process.stdin.isTTY) return;
81
+ let event = null;
82
+ try { event = parseHookEvent((await readStdinBounded()).toString('utf8')); } catch { return; }
83
+ const decision = findDispatchDecision(event?.tool_use_id);
84
+ appendObservation(observationFrom(event, new Date().toISOString(), decision));
85
+ }
86
+
87
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
88
+ try { await main(); } catch { /* advisory observer: fail open */ }
89
+ }