ruvnet-brain 3.9.134-dev → 4.0.2
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/.claude-plugin/marketplace.json +14 -0
- package/README.md +5 -5
- package/bin/install.mjs +382 -36
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/kb/zip-extract.mjs +53 -14
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +14 -22
- package/plugin/.claude-plugin/marketplace.json +14 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/.codex-plugin/plugin.json +21 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/brain-console.md +16 -0
- package/plugin/commands/configure.md +33 -0
- package/plugin/commands/rvbc.md +79 -0
- package/plugin/commands/rvcb.md +16 -0
- package/plugin/commands/whats-new.md +57 -0
- package/plugin/hooks/codex-hooks.json +160 -0
- package/plugin/hooks/hook-contracts.json +77 -0
- package/plugin/hooks/hooks.json +202 -0
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +56 -6
- package/plugin/scripts/anticipate.sh +534 -0
- package/plugin/scripts/codex-hook-adapter.mjs +96 -0
- package/plugin/scripts/continuation-gate.mjs +267 -0
- package/plugin/scripts/design-wall.sh +137 -0
- package/plugin/scripts/detach.mjs +182 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/gate-receipt.sh +35 -0
- package/plugin/scripts/ground-before-write.sh +199 -0
- package/plugin/scripts/ground-ruvnet.sh +517 -0
- package/plugin/scripts/grounding-stamp.sh +113 -0
- package/plugin/scripts/grounding-substance.mjs +595 -0
- package/plugin/scripts/hijack-ruvnet.sh +81 -0
- package/plugin/scripts/hook-input.mjs +558 -0
- package/plugin/scripts/hook-shim-bash.mjs +55 -0
- package/plugin/scripts/hook-shim.mjs +303 -0
- package/plugin/scripts/host-update.mjs +58 -0
- package/plugin/scripts/kling-preflight.sh +146 -0
- package/plugin/scripts/learn-capture.sh +173 -0
- package/plugin/scripts/learn-flush.mjs +155 -0
- package/plugin/scripts/lesson-hooks.sh +213 -0
- package/plugin/scripts/md-stamp.mjs +219 -0
- package/plugin/scripts/protect-brain-state.sh +84 -0
- package/plugin/scripts/route-dispatch.sh +147 -0
- package/plugin/scripts/routing-outcome-capture.mjs +89 -0
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +13 -0
- package/plugin/scripts/signal-watch.mjs +193 -0
- package/plugin/scripts/unprompted-runtime.mjs +377 -0
- package/plugin/scripts/update-apply.mjs +419 -0
- package/plugin/scripts/verify-interface.sh +53 -0
- package/plugin/scripts/version-bump-gate.sh +112 -0
- package/plugin/skills/brain-build/SKILL.md +123 -0
- package/plugin/skills/brain-console/SKILL.md +22 -0
- package/plugin/skills/brain-prompt/SKILL.md +83 -0
- package/plugin/skills/brain-score/SKILL.md +101 -0
- package/plugin/skills/release-proof/SKILL.md +81 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
- package/plugin/skills/rvbc/SKILL.md +23 -0
- package/plugin/skills/savings/SKILL.md +46 -0
- package/plugin/skills/whats-new/SKILL.md +22 -0
- package/scripts/adr-backfill.mjs +107 -0
- package/scripts/advocacy-outcomes.mjs +808 -0
- package/scripts/agentdb-context.mjs +216 -0
- package/scripts/agentdb-fleet-doctor.mjs +101 -0
- package/scripts/ascii-drift.mjs +236 -0
- package/scripts/behavioral-l1-l4.mjs +210 -0
- package/scripts/brain-capability-check.mjs +72 -0
- package/scripts/brain-grade-groundtruth.mjs +100 -0
- package/scripts/brain-latency-50.mjs +227 -0
- package/scripts/brain-novice-50.mjs +189 -0
- package/scripts/brain-stamp.mjs +94 -0
- package/scripts/brain-state.mjs +212 -0
- package/scripts/build-bundle.mjs +522 -0
- package/scripts/build-concepts.mjs +132 -0
- package/scripts/build-l2.mjs +71 -0
- package/scripts/build-primer.mjs +73 -0
- package/scripts/build-symbols.mjs +68 -0
- package/scripts/calibrate-router.mjs +97 -0
- package/scripts/capability-audit.mjs +321 -0
- package/scripts/capability-registry.mjs +876 -0
- package/scripts/check-indexation.mjs +108 -0
- package/scripts/check-legibility.mjs +189 -0
- package/scripts/ci/build-fixture-kb.mjs +67 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
- package/scripts/ci/learning-replay-recorder.mjs +59 -0
- package/scripts/ci/mutate-hook-timeout.mjs +70 -0
- package/scripts/ci/stranger-fixture-stage.mjs +17 -0
- package/scripts/ci/stranger-scenario.mjs +228 -0
- package/scripts/ci/stranger-timeout.mjs +25 -0
- package/scripts/ci-verdict.mjs +29 -0
- package/scripts/claims-verify.mjs +710 -0
- package/scripts/clear-claude-tmp.sh +31 -0
- package/scripts/console-engine.mjs +434 -0
- package/scripts/console-engine.test.mjs +125 -0
- package/scripts/corpus-qa.mjs +250 -0
- package/scripts/correction-detect-embed.mjs +346 -0
- package/scripts/correction-detect-measure.mjs +270 -0
- package/scripts/correction-detect.mjs +686 -0
- package/scripts/count-chunks.mjs +54 -0
- package/scripts/described-questions.json +30 -0
- package/scripts/design-grade.mjs +58 -0
- package/scripts/dev-plugin-link.sh +105 -0
- package/scripts/distill-project.mjs +200 -0
- package/scripts/doc-currency.mjs +801 -0
- package/scripts/eval-brain.mjs +244 -0
- package/scripts/fix-metaharness-memretrieve.mjs +121 -0
- package/scripts/full-hints.mjs +87 -0
- package/scripts/gate.sh +39 -0
- package/scripts/gates.mjs +146 -0
- package/scripts/gen-console-images.mjs +54 -0
- package/scripts/gen-images.mjs +47 -0
- package/scripts/git-clone-refresh.mjs +52 -0
- package/scripts/git-hooks/pre-push +126 -0
- package/scripts/goal-match.mjs +398 -0
- package/scripts/goldie-research.mjs +223 -0
- package/scripts/goldie-weekly.sh +67 -0
- package/scripts/health-repair.mjs +250 -0
- package/scripts/helix-scenario-questions.json +10 -0
- package/scripts/ingest-gists.mjs +230 -0
- package/scripts/ingest-meeting.mjs +115 -0
- package/scripts/ingest-repo.mjs +79 -0
- package/scripts/install-npx-witness.sh +49 -0
- package/scripts/issue-fix.mjs +639 -0
- package/scripts/issue-watch.mjs +276 -0
- package/scripts/issue4-close-note.md +31 -0
- package/scripts/key-canary.mjs +91 -0
- package/scripts/latency-to-surface.mjs +233 -0
- package/scripts/learning-enable.mjs +380 -0
- package/scripts/learning-replay.mjs +1570 -0
- package/scripts/learnings.mjs +62 -0
- package/scripts/lesson-gate.mjs +680 -0
- package/scripts/lesson-lifecycle.mjs +449 -0
- package/scripts/lesson-promote.mjs +262 -0
- package/scripts/lesson-ratify.mjs +98 -0
- package/scripts/lesson-seed.mjs +252 -0
- package/scripts/lesson-store.mjs +447 -0
- package/scripts/loop-checkpoint.mjs +86 -0
- package/scripts/memdb-health.sh +14 -0
- package/scripts/memory-doctor.mjs +271 -0
- package/scripts/model-catalog.mjs +79 -0
- package/scripts/nightly-controller.mjs +66 -0
- package/scripts/nightly-gists.sh +72 -0
- package/scripts/nightly-wrapper.sh +180 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2749 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/prove.mjs +95 -0
- package/scripts/proxy/claude-proxied.sh +57 -0
- package/scripts/proxy/proxy-revert.sh +59 -0
- package/scripts/proxy/proxy-up.sh +60 -0
- package/scripts/proxy/proxy-verify.mjs +142 -0
- package/scripts/published-surface-probe.mjs +241 -0
- package/scripts/qe/card-lane-gate.mjs +162 -0
- package/scripts/qe/session-start-gate.mjs +229 -0
- package/scripts/qe/ux-suite.mjs +323 -0
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/record-lesson.mjs +113 -0
- package/scripts/refresh-model-catalog.mjs +99 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +395 -0
- package/scripts/remedy-registry.mjs +247 -0
- package/scripts/rerank-cap-eval.mjs +265 -0
- package/scripts/rerank-cap-warm-ab.mjs +129 -0
- package/scripts/route-cheap.mjs +20 -15
- package/scripts/router-utilization.mjs +182 -0
- package/scripts/routing-flywheel.mjs +596 -0
- package/scripts/rvf-generation.mjs +104 -0
- package/scripts/rvf-index-audit.mjs +138 -0
- package/scripts/self-update.mjs +508 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stack-sync.mjs +469 -0
- package/scripts/stamp-existing-rvf-generations.mjs +53 -0
- package/scripts/stamp-sweep.mjs +144 -0
- package/scripts/status-honesty.mjs +102 -0
- package/scripts/sync-version.mjs +217 -0
- package/scripts/token-report.mjs +102 -0
- package/scripts/top100-benchmark.mjs +479 -0
- package/scripts/top100-corpus.mjs +112 -0
- package/scripts/top100-semantic-assertions.mjs +449 -0
- package/scripts/update-apply.mjs +9 -0
- package/scripts/upgrade-notice.mjs +14 -0
- package/scripts/verify-bundle.mjs +51 -0
- package/scripts/verify-channels.mjs +184 -0
- package/scripts/verify-model-catalog.mjs +104 -0
- package/scripts/verify-nightly-close-issue4.sh +31 -0
- package/scripts/version.mjs +40 -0
- package/scripts/wired-check.mjs +864 -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
|
+
}
|