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.
Files changed (218) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/README.md +5 -5
  3. package/bin/install.mjs +382 -36
  4. package/console/CONTRACT.md +172 -0
  5. package/console/activity.js +753 -0
  6. package/console/app.js +4189 -0
  7. package/console/architecture.html +1221 -0
  8. package/console/assets/depth-1.webp +0 -0
  9. package/console/assets/depth-2.webp +0 -0
  10. package/console/assets/depth-3.webp +0 -0
  11. package/console/assets/harness-vs-plain.svg +259 -0
  12. package/console/assets/hero.webp +0 -0
  13. package/console/assets/memory.webp +0 -0
  14. package/console/assets/metaharness.svg +247 -0
  15. package/console/index.html +777 -0
  16. package/console/install-architecture.html +162 -0
  17. package/console/install-mockup.html +543 -0
  18. package/console/style.css +2144 -0
  19. package/console/tips.css +926 -0
  20. package/console/tips.html +858 -0
  21. package/console/tips.js +128 -0
  22. package/docs/RELEASE-NOTES-4.0.md +88 -0
  23. package/kb/model-requirements.mjs +37 -6
  24. package/kb/zip-extract.mjs +53 -14
  25. package/keys/ruvnet-brain-signing.pub.pem +3 -0
  26. package/package.json +14 -22
  27. package/plugin/.claude-plugin/marketplace.json +14 -0
  28. package/plugin/.claude-plugin/plugin.json +22 -0
  29. package/plugin/.codex-plugin/plugin.json +21 -0
  30. package/plugin/.mcp.json +8 -0
  31. package/plugin/commands/brain-console.md +16 -0
  32. package/plugin/commands/configure.md +33 -0
  33. package/plugin/commands/rvbc.md +79 -0
  34. package/plugin/commands/rvcb.md +16 -0
  35. package/plugin/commands/whats-new.md +57 -0
  36. package/plugin/hooks/codex-hooks.json +160 -0
  37. package/plugin/hooks/hook-contracts.json +77 -0
  38. package/plugin/hooks/hooks.json +202 -0
  39. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  40. package/plugin/mcp/server.mjs +56 -6
  41. package/plugin/scripts/anticipate.sh +534 -0
  42. package/plugin/scripts/codex-hook-adapter.mjs +96 -0
  43. package/plugin/scripts/continuation-gate.mjs +267 -0
  44. package/plugin/scripts/design-wall.sh +137 -0
  45. package/plugin/scripts/detach.mjs +182 -0
  46. package/plugin/scripts/first-session-worker.mjs +38 -0
  47. package/plugin/scripts/gate-receipt.sh +35 -0
  48. package/plugin/scripts/ground-before-write.sh +199 -0
  49. package/plugin/scripts/ground-ruvnet.sh +517 -0
  50. package/plugin/scripts/grounding-stamp.sh +113 -0
  51. package/plugin/scripts/grounding-substance.mjs +595 -0
  52. package/plugin/scripts/hijack-ruvnet.sh +81 -0
  53. package/plugin/scripts/hook-input.mjs +558 -0
  54. package/plugin/scripts/hook-shim-bash.mjs +55 -0
  55. package/plugin/scripts/hook-shim.mjs +303 -0
  56. package/plugin/scripts/host-update.mjs +58 -0
  57. package/plugin/scripts/kling-preflight.sh +146 -0
  58. package/plugin/scripts/learn-capture.sh +173 -0
  59. package/plugin/scripts/learn-flush.mjs +155 -0
  60. package/plugin/scripts/lesson-hooks.sh +213 -0
  61. package/plugin/scripts/md-stamp.mjs +219 -0
  62. package/plugin/scripts/protect-brain-state.sh +84 -0
  63. package/plugin/scripts/route-dispatch.sh +147 -0
  64. package/plugin/scripts/routing-outcome-capture.mjs +89 -0
  65. package/plugin/scripts/runtime-preferences.mjs +269 -0
  66. package/plugin/scripts/session-start-core.mjs +477 -0
  67. package/plugin/scripts/session-start.sh +13 -0
  68. package/plugin/scripts/signal-watch.mjs +193 -0
  69. package/plugin/scripts/unprompted-runtime.mjs +377 -0
  70. package/plugin/scripts/update-apply.mjs +419 -0
  71. package/plugin/scripts/verify-interface.sh +53 -0
  72. package/plugin/scripts/version-bump-gate.sh +112 -0
  73. package/plugin/skills/brain-build/SKILL.md +123 -0
  74. package/plugin/skills/brain-console/SKILL.md +22 -0
  75. package/plugin/skills/brain-prompt/SKILL.md +83 -0
  76. package/plugin/skills/brain-score/SKILL.md +101 -0
  77. package/plugin/skills/release-proof/SKILL.md +81 -0
  78. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  79. package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
  80. package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
  81. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
  82. package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
  83. package/plugin/skills/rvbc/SKILL.md +23 -0
  84. package/plugin/skills/savings/SKILL.md +46 -0
  85. package/plugin/skills/whats-new/SKILL.md +22 -0
  86. package/scripts/adr-backfill.mjs +107 -0
  87. package/scripts/advocacy-outcomes.mjs +808 -0
  88. package/scripts/agentdb-context.mjs +216 -0
  89. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  90. package/scripts/ascii-drift.mjs +236 -0
  91. package/scripts/behavioral-l1-l4.mjs +210 -0
  92. package/scripts/brain-capability-check.mjs +72 -0
  93. package/scripts/brain-grade-groundtruth.mjs +100 -0
  94. package/scripts/brain-latency-50.mjs +227 -0
  95. package/scripts/brain-novice-50.mjs +189 -0
  96. package/scripts/brain-stamp.mjs +94 -0
  97. package/scripts/brain-state.mjs +212 -0
  98. package/scripts/build-bundle.mjs +522 -0
  99. package/scripts/build-concepts.mjs +132 -0
  100. package/scripts/build-l2.mjs +71 -0
  101. package/scripts/build-primer.mjs +73 -0
  102. package/scripts/build-symbols.mjs +68 -0
  103. package/scripts/calibrate-router.mjs +97 -0
  104. package/scripts/capability-audit.mjs +321 -0
  105. package/scripts/capability-registry.mjs +876 -0
  106. package/scripts/check-indexation.mjs +108 -0
  107. package/scripts/check-legibility.mjs +189 -0
  108. package/scripts/ci/build-fixture-kb.mjs +67 -0
  109. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  110. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  111. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  112. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  113. package/scripts/ci/stranger-scenario.mjs +228 -0
  114. package/scripts/ci/stranger-timeout.mjs +25 -0
  115. package/scripts/ci-verdict.mjs +29 -0
  116. package/scripts/claims-verify.mjs +710 -0
  117. package/scripts/clear-claude-tmp.sh +31 -0
  118. package/scripts/console-engine.mjs +434 -0
  119. package/scripts/console-engine.test.mjs +125 -0
  120. package/scripts/corpus-qa.mjs +250 -0
  121. package/scripts/correction-detect-embed.mjs +346 -0
  122. package/scripts/correction-detect-measure.mjs +270 -0
  123. package/scripts/correction-detect.mjs +686 -0
  124. package/scripts/count-chunks.mjs +54 -0
  125. package/scripts/described-questions.json +30 -0
  126. package/scripts/design-grade.mjs +58 -0
  127. package/scripts/dev-plugin-link.sh +105 -0
  128. package/scripts/distill-project.mjs +200 -0
  129. package/scripts/doc-currency.mjs +801 -0
  130. package/scripts/eval-brain.mjs +244 -0
  131. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  132. package/scripts/full-hints.mjs +87 -0
  133. package/scripts/gate.sh +39 -0
  134. package/scripts/gates.mjs +146 -0
  135. package/scripts/gen-console-images.mjs +54 -0
  136. package/scripts/gen-images.mjs +47 -0
  137. package/scripts/git-clone-refresh.mjs +52 -0
  138. package/scripts/git-hooks/pre-push +126 -0
  139. package/scripts/goal-match.mjs +398 -0
  140. package/scripts/goldie-research.mjs +223 -0
  141. package/scripts/goldie-weekly.sh +67 -0
  142. package/scripts/health-repair.mjs +250 -0
  143. package/scripts/helix-scenario-questions.json +10 -0
  144. package/scripts/ingest-gists.mjs +230 -0
  145. package/scripts/ingest-meeting.mjs +115 -0
  146. package/scripts/ingest-repo.mjs +79 -0
  147. package/scripts/install-npx-witness.sh +49 -0
  148. package/scripts/issue-fix.mjs +639 -0
  149. package/scripts/issue-watch.mjs +276 -0
  150. package/scripts/issue4-close-note.md +31 -0
  151. package/scripts/key-canary.mjs +91 -0
  152. package/scripts/latency-to-surface.mjs +233 -0
  153. package/scripts/learning-enable.mjs +380 -0
  154. package/scripts/learning-replay.mjs +1570 -0
  155. package/scripts/learnings.mjs +62 -0
  156. package/scripts/lesson-gate.mjs +680 -0
  157. package/scripts/lesson-lifecycle.mjs +449 -0
  158. package/scripts/lesson-promote.mjs +262 -0
  159. package/scripts/lesson-ratify.mjs +98 -0
  160. package/scripts/lesson-seed.mjs +252 -0
  161. package/scripts/lesson-store.mjs +447 -0
  162. package/scripts/loop-checkpoint.mjs +86 -0
  163. package/scripts/memdb-health.sh +14 -0
  164. package/scripts/memory-doctor.mjs +271 -0
  165. package/scripts/model-catalog.mjs +79 -0
  166. package/scripts/nightly-controller.mjs +66 -0
  167. package/scripts/nightly-gists.sh +72 -0
  168. package/scripts/nightly-wrapper.sh +180 -0
  169. package/scripts/notify.sh +12 -0
  170. package/scripts/npx-witness.sh +56 -0
  171. package/scripts/onboarding-console.mjs +2749 -0
  172. package/scripts/private-fence.mjs +69 -0
  173. package/scripts/proactivity-metrics.mjs +118 -0
  174. package/scripts/proof-questions.json +56 -0
  175. package/scripts/prove.mjs +95 -0
  176. package/scripts/proxy/claude-proxied.sh +57 -0
  177. package/scripts/proxy/proxy-revert.sh +59 -0
  178. package/scripts/proxy/proxy-up.sh +60 -0
  179. package/scripts/proxy/proxy-verify.mjs +142 -0
  180. package/scripts/published-surface-probe.mjs +241 -0
  181. package/scripts/qe/card-lane-gate.mjs +162 -0
  182. package/scripts/qe/session-start-gate.mjs +229 -0
  183. package/scripts/qe/ux-suite.mjs +323 -0
  184. package/scripts/reconcile-project.mjs +0 -0
  185. package/scripts/record-lesson.mjs +113 -0
  186. package/scripts/refresh-model-catalog.mjs +99 -0
  187. package/scripts/release-proof.mjs +9 -0
  188. package/scripts/release-vector.mjs +281 -0
  189. package/scripts/release.mjs +395 -0
  190. package/scripts/remedy-registry.mjs +247 -0
  191. package/scripts/rerank-cap-eval.mjs +265 -0
  192. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  193. package/scripts/route-cheap.mjs +20 -15
  194. package/scripts/router-utilization.mjs +182 -0
  195. package/scripts/routing-flywheel.mjs +596 -0
  196. package/scripts/rvf-generation.mjs +104 -0
  197. package/scripts/rvf-index-audit.mjs +138 -0
  198. package/scripts/self-update.mjs +508 -0
  199. package/scripts/selfcheck.mjs +7 -1
  200. package/scripts/sign-bundle.mjs +69 -0
  201. package/scripts/signal-watch.mjs +171 -0
  202. package/scripts/stack-sync.mjs +469 -0
  203. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  204. package/scripts/stamp-sweep.mjs +144 -0
  205. package/scripts/status-honesty.mjs +102 -0
  206. package/scripts/sync-version.mjs +217 -0
  207. package/scripts/token-report.mjs +102 -0
  208. package/scripts/top100-benchmark.mjs +479 -0
  209. package/scripts/top100-corpus.mjs +112 -0
  210. package/scripts/top100-semantic-assertions.mjs +449 -0
  211. package/scripts/update-apply.mjs +9 -0
  212. package/scripts/upgrade-notice.mjs +14 -0
  213. package/scripts/verify-bundle.mjs +51 -0
  214. package/scripts/verify-channels.mjs +184 -0
  215. package/scripts/verify-model-catalog.mjs +104 -0
  216. package/scripts/verify-nightly-close-issue4.sh +31 -0
  217. package/scripts/version.mjs +40 -0
  218. package/scripts/wired-check.mjs +864 -0
@@ -0,0 +1,596 @@
1
+ #!/usr/bin/env node
2
+ // scripts/routing-flywheel.mjs — the MetaHarness FLYWHEEL over model routing.
3
+ //
4
+ // WHAT THIS IS. rUv's @metaharness/flywheel (verified on npm, 0.1.7, published by ruvnet) driving
5
+ // run → measure → mutate → verify → promote over THE EXECUTOR POLICY of our model router — the
6
+ // k-NN routing levers (qualityBar, k, escalation stance) that @metaharness/router consults on every
7
+ // routed prompt. ADR-226's measured lesson (a read-only advisor added ZERO resolves at 5.4× cost)
8
+ // says the winning lever is evolving the executor policy and promoting only proven changes — this
9
+ // file is exactly that, and nothing else.
10
+ //
11
+ // THE DISCIPLINE (docs/research/metaharness/ruv-doctrine-2026-07-16.md — non-negotiable):
12
+ // • The gate is FROZEN: `meetsPromotionRule` from @metaharness/flywheel, never injectable here.
13
+ // Its sha256 fingerprint is stamped on every receipt so an outsider can prove it never moved.
14
+ // • The ANCHOR suite is never optimized against — a deterministic hash split of the labelled
15
+ // routing outcomes; a holdout winner that regresses the anchor is REJECTED (engine-enforced).
16
+ // • Injected seams: the Evaluator replays labelled rows at $0 (pure math, no model calls); the
17
+ // Proposer is a real cheap model ONLY under --live with OPENROUTER_API_KEY — otherwise a mock,
18
+ // and the whole run is labeled SYNTHETIC. A synthetic result is never dressed up as live.
19
+ // • HARD caps (rUv's self-DDoS warning, from his own burned credits): ≤6 generations, ≤$0.50
20
+ // proposer spend per run, ≤10 min wall clock. All three clamped, none overridable upward.
21
+ // • NEVER ships an advisor, never touches the live path: a promoted policy is written to
22
+ // policy.candidate.mjs — writing policy.mjs itself is refused in code. A HUMAN promotes.
23
+ //
24
+ // SEAM-LEVEL HONESTY NOTES (where this adapts the parent spec to the package's REAL API — verified
25
+ // against @metaharness/flywheel@0.1.7 dist/*.d.ts + run.js, not guessed):
26
+ // • Score.regressed here = "evaluation hard-failed" (policy threw / no row evaluable). Anchor
27
+ // degradation is enforced by the ENGINE's separate anchor-survival check (run.js line ~87),
28
+ // not via the Score flag — the Evaluator scores one suite at a time and cannot see the anchor.
29
+ // • The engine checks the budget at GENERATION boundaries only, so the live proposer ALSO
30
+ // self-enforces per call: at/over cap (or past the wall clock) it returns the base lever
31
+ // unchanged — a $0 no-op the frozen gate then rejects (noopRate must strictly improve).
32
+ //
33
+ // Usage:
34
+ // node scripts/routing-flywheel.mjs [--dry-run|--synthetic|--live]
35
+ // [--rows <jsonl>] [--cap <usd≤0.50>] [--max-generations <n≤6>]
36
+ // [--out <candidate.mjs>] [--receipts <jsonl>] [--json]
37
+ // --dry-run (default) split + baseline scores of the live root policy. Reads only.
38
+ // --synthetic full loop with the MOCK proposer — $0, labeled SYNTHETIC.
39
+ // --live real cheap proposer via OpenRouter. Requires OPENROUTER_API_KEY. Prints the spend
40
+ // cap before the first call. Never run by automation — a human starts live runs.
41
+
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
+ import os from 'node:os';
45
+ import { createHash } from 'node:crypto';
46
+ import { pathToFileURL } from 'node:url';
47
+ import {
48
+ runFlywheelGenerations,
49
+ meetsPromotionRule,
50
+ gateFingerprint,
51
+ makeSigner,
52
+ verifyReplayBundle,
53
+ canon,
54
+ } from '@metaharness/flywheel';
55
+ import { effectivePrices, loadLabelledRows, OUTCOMES } from './metaharness-router.mjs';
56
+ import { loadCatalog, applyProfile, loadProfile } from './model-router-engine.mjs';
57
+
58
+ // ─── HARD CAPS — the self-DDoS fence. Clamped, never raised by flags. ─────────────────────────────
59
+ export const HARD_MAX_GENERATIONS = 6;
60
+ export const HARD_CAP_USD = 0.5;
61
+ export const HARD_WALL_MS = 10 * 60 * 1000;
62
+
63
+ // ─── FROZEN evaluation constants. Not levers — a lever here would let the loop Goodhart the truth. ─
64
+ export const TRUTH_BAR = 0.7; // a labelled outcome ≥ this = that model genuinely sufficed
65
+ export const MIN_SUITE_ROWS = 3; // below this a "suite" is an anecdote, not evidence
66
+ const TIER_RANK = { cheap: 0, mid: 1, frontier: 2 };
67
+
68
+ // Gen-0 root = the engine's CURRENT live defaults (metaharness-router.mjs route(): qualityBar 0.7,
69
+ // k 5; escalate-to-frontier when nothing clears). The flywheel evolves FROM what actually runs.
70
+ export const ROOT_POLICY = Object.freeze({
71
+ qualityBar: '0.70',
72
+ k: '5',
73
+ escalation: 'fallback=frontier margin=0.00',
74
+ });
75
+
76
+ export const CANDIDATE_PATH_DEFAULT = path.join(os.homedir(), '.claude', 'model-router', 'policy.candidate.mjs');
77
+ export const RECEIPTS_PATH_DEFAULT = path.join(os.homedir(), '.claude', 'metaharness', 'flywheel-receipts.jsonl');
78
+ // Cheapest tracked tool-capable model (catalog, price verified 2026-07-13 via OpenRouter API).
79
+ export const LIVE_PROPOSER_MODEL = 'deepseek/deepseek-v4-flash';
80
+
81
+ // ─── lever schema — the proposer's output is FILTERED on return ("a policy value can NEVER carry
82
+ // anything else", metaharness evals house rule). Unparseable proposals become $0 no-ops. ──────────
83
+ const LEVER_SCHEMA = {
84
+ qualityBar: { kind: 'float', min: 0.5, max: 0.95, hint: 'min predicted quality (0.50–0.95) a candidate must clear to be picked' },
85
+ k: { kind: 'int', min: 1, max: 15, hint: 'k-NN neighbours (1–15) used to predict per-model quality' },
86
+ escalation: { kind: 'menu', hint: 'tokens only: "fallback=frontier|cheapest" (what to do when nothing clears the bar) and "margin=<0..0.2>" (extra quality headroom required)' },
87
+ };
88
+
89
+ export function parseEscalation(str) {
90
+ const s = String(str ?? '');
91
+ const fb = /fallback=(frontier|cheapest)/.exec(s);
92
+ const mg = /margin=(\d+(?:\.\d+)?)/.exec(s);
93
+ const margin = Math.max(0, Math.min(0.2, mg ? parseFloat(mg[1]) : 0));
94
+ return { fallback: fb ? fb[1] : 'frontier', margin };
95
+ }
96
+
97
+ /** Clamp a proposed lever value to its schema. Menu levers are rebuilt from recognized tokens only —
98
+ * free prose never executes. On any failure the BASE value returns (an honest no-op, not a guess). */
99
+ export function clampLever(target, raw, baseValue) {
100
+ const schema = LEVER_SCHEMA[target];
101
+ if (!schema) return baseValue;
102
+ const text = String(raw ?? '').trim();
103
+ if (schema.kind === 'float' || schema.kind === 'int') {
104
+ const m = /-?\d+(?:\.\d+)?/.exec(text);
105
+ if (!m) return baseValue;
106
+ let n = parseFloat(m[0]);
107
+ if (!Number.isFinite(n)) return baseValue;
108
+ n = Math.max(schema.min, Math.min(schema.max, n));
109
+ return schema.kind === 'int' ? String(Math.round(n)) : n.toFixed(2);
110
+ }
111
+ // menu: extract only the tokens we execute; canonical form, nothing else survives.
112
+ const e = parseEscalation(text);
113
+ return `fallback=${e.fallback} margin=${e.margin.toFixed(2)}`;
114
+ }
115
+
116
+ // ─── the FROZEN deterministic split: holdout vs anchor, by content hash. No row is ever moved by a
117
+ // human or an optimizer; the anchor is defined by arithmetic, not by choice. ───────────────────────
118
+ export function splitRows(rows) {
119
+ const holdout = [];
120
+ const anchor = [];
121
+ for (const row of rows) {
122
+ const h = createHash('sha256').update(canon({ embedding: row.embedding, scores: row.scores })).digest('hex');
123
+ (parseInt(h.slice(0, 8), 16) % 3 === 0 ? anchor : holdout).push(row);
124
+ }
125
+ return { holdout, anchor };
126
+ }
127
+
128
+ // ─── model-id resolution between outcome-row score keys and catalog ids (e.g. row "claude-haiku-4.5"
129
+ // vs catalog "claude-haiku-4-5-20251001"). Normalized prefix match — the same bug class as the
130
+ // {in,out} price-blend miss of 2026-07-16, killed here explicitly. ─────────────────────────────────
131
+ export function normalizeId(id) {
132
+ return String(id).toLowerCase().replace(/\./g, '-');
133
+ }
134
+ export function idMatches(a, b) {
135
+ const na = normalizeId(a);
136
+ const nb = normalizeId(b);
137
+ return na === nb || na.startsWith(nb + '-') || nb.startsWith(na + '-');
138
+ }
139
+
140
+ function cosine(a, b) {
141
+ const n = Math.min(a.length, b.length);
142
+ let dot = 0;
143
+ let ma = 0;
144
+ let mb = 0;
145
+ for (let i = 0; i < n; i++) {
146
+ dot += a[i] * b[i];
147
+ ma += a[i] * a[i];
148
+ mb += b[i] * b[i];
149
+ }
150
+ return ma && mb ? dot / (Math.sqrt(ma) * Math.sqrt(mb)) : 0;
151
+ }
152
+
153
+ /**
154
+ * THE EVALUATOR SEAM — pure, deterministic, $0. Replays a suite of labelled routing outcomes
155
+ * ({embedding, scores}) against a candidate policy:
156
+ * predict per-model quality by leave-one-out k-NN WITHIN the suite (holdout never sees anchor
157
+ * rows and vice versa — the anchor stays fully isolated),
158
+ * pick the cheapest (effective price: subscription ⇒ $0) candidate clearing bar+margin,
159
+ * else the escalation fallback,
160
+ * judge against the row's own labels: correct tier = tier of the cheapest model whose LABELLED
161
+ * quality ≥ TRUTH_BAR (frozen), frontier if none sufficed.
162
+ * Score axes (projected per @metaharness/flywheel's Score):
163
+ * primary = fraction of correct-tier picks
164
+ * noopRate = fraction routed to frontier unnecessarily (the wasted-capacity signal)
165
+ * costPerWin = Σ effective blended $/Mtok of picks ÷ wins — 999 sentinel on zero wins, so a
166
+ * policy that wins nothing can never look "cheap"
167
+ * regressed = evaluation hard-failure only (anchor degradation is the engine's separate check)
168
+ */
169
+ export function makeEvaluator({ catalog, profile }) {
170
+ const pool = applyProfile(catalog, profile).filter(
171
+ (c) => (c.harness || []).includes('claude-code') && TIER_RANK[c.tier] !== undefined
172
+ );
173
+ const prices = effectivePrices(pool, profile);
174
+ const priceOf = (id) => (Number.isFinite(prices[id]) ? prices[id] : 999); // unpriced = penalized, never free
175
+
176
+ const scoreFor = (scores, cand) => {
177
+ for (const key of Object.keys(scores)) if (idMatches(key, cand.id)) return scores[key];
178
+ return null;
179
+ };
180
+
181
+ return async function evaluate(policy, suite) {
182
+ const rows = suite.items;
183
+ const bar = Math.max(0.5, Math.min(0.95, parseFloat(policy.qualityBar) || 0.7));
184
+ const k = Math.max(1, Math.min(15, Math.round(parseFloat(policy.k) || 5)));
185
+ const esc = parseEscalation(policy.escalation);
186
+
187
+ let evaluated = 0;
188
+ let correct = 0;
189
+ let unnecessaryFrontier = 0;
190
+ let costSum = 0;
191
+ let errors = 0;
192
+
193
+ for (let i = 0; i < rows.length; i++) {
194
+ try {
195
+ const row = rows[i];
196
+ // ground truth: which tier did the labels prove sufficient?
197
+ const sufficient = pool.filter((c) => {
198
+ const s = scoreFor(row.scores, c);
199
+ return typeof s === 'number' && s >= TRUTH_BAR;
200
+ });
201
+ const anyResolvable = pool.some((c) => scoreFor(row.scores, c) !== null);
202
+ if (!anyResolvable) continue; // no resolvable labels ⇒ no ground truth ⇒ excluded, not faked
203
+ const correctTier = sufficient.length
204
+ ? Object.keys(TIER_RANK).find((t) => TIER_RANK[t] === Math.min(...sufficient.map((c) => TIER_RANK[c.tier])))
205
+ : 'frontier';
206
+
207
+ // predict: leave-one-out k-NN within THIS suite (index-asc tiebreak ⇒ fully deterministic)
208
+ const neighbours = rows
209
+ .map((r, j) => ({ r, j, sim: j === i ? -Infinity : cosine(row.embedding, r.embedding) }))
210
+ .filter((x) => x.j !== i)
211
+ .sort((a, b) => b.sim - a.sim || a.j - b.j)
212
+ .slice(0, k);
213
+ const predicted = pool
214
+ .map((c) => {
215
+ const vals = neighbours.map((n) => scoreFor(n.r.scores, c)).filter((v) => typeof v === 'number');
216
+ return vals.length ? { c, q: vals.reduce((s, v) => s + v, 0) / vals.length } : null;
217
+ })
218
+ .filter(Boolean);
219
+
220
+ // pick: cheapest clearing candidate, else the escalation fallback
221
+ const clearing = predicted
222
+ .filter((p) => p.q >= bar + esc.margin)
223
+ .sort((a, b) => priceOf(a.c.id) - priceOf(b.c.id) || TIER_RANK[a.c.tier] - TIER_RANK[b.c.tier] || a.c.id.localeCompare(b.c.id));
224
+ let pick;
225
+ if (clearing.length) pick = clearing[0].c;
226
+ else if (esc.fallback === 'cheapest') {
227
+ pick = pool.slice().sort((a, b) => priceOf(a.id) - priceOf(b.id) || TIER_RANK[a.tier] - TIER_RANK[b.tier])[0];
228
+ } else {
229
+ pick = pool
230
+ .filter((c) => c.tier === 'frontier')
231
+ .sort((a, b) => priceOf(a.id) - priceOf(b.id))[0] || pool[0];
232
+ }
233
+ if (!pick) throw new Error('empty candidate pool');
234
+
235
+ evaluated++;
236
+ if (pick.tier === correctTier) correct++;
237
+ if (pick.tier === 'frontier' && correctTier !== 'frontier') unnecessaryFrontier++;
238
+ costSum += priceOf(pick.id);
239
+ } catch {
240
+ errors++;
241
+ }
242
+ }
243
+
244
+ if (evaluated === 0) return { primary: 0, noopRate: 1, costPerWin: 999, regressed: true };
245
+ return {
246
+ primary: correct / evaluated,
247
+ noopRate: unnecessaryFrontier / evaluated,
248
+ costPerWin: correct === 0 ? 999 : +(costSum / correct).toFixed(6),
249
+ regressed: errors > 0,
250
+ };
251
+ };
252
+ }
253
+
254
+ // ─── THE PROPOSER SEAMS ────────────────────────────────────────────────────────────────────────────
255
+
256
+ /** Mock proposer — deterministic scripted lever values, schema-clamped. $0. Any run driven by this
257
+ * is labeled SYNTHETIC: it proves the machinery, it is NOT evidence a live loop would find lift. */
258
+ export function makeMockProposer(script) {
259
+ const DEFAULT_SCRIPT = {
260
+ qualityBar: ['0.75', '0.72', '0.68', '0.80', '0.65', '0.70'],
261
+ k: ['7', '3', '9', '5', '2', '11'],
262
+ escalation: [
263
+ 'fallback=cheapest margin=0.00',
264
+ 'fallback=frontier margin=0.05',
265
+ 'fallback=cheapest margin=0.02',
266
+ 'fallback=frontier margin=0.10',
267
+ 'fallback=cheapest margin=0.05',
268
+ 'fallback=frontier margin=0.00',
269
+ ],
270
+ };
271
+ const seen = {};
272
+ return async (base, target) => {
273
+ const seq = (script && script[target]) || DEFAULT_SCRIPT[target] || [];
274
+ const i = (seen[target] = (seen[target] ?? -1) + 1);
275
+ const raw = seq[i % Math.max(1, seq.length)] ?? base.policy[target];
276
+ return clampLever(target, raw, base.policy[target]);
277
+ };
278
+ }
279
+
280
+ /** Live proposer — ONE cheap model call per (lever, generation) via OpenRouter. Self-enforcing:
281
+ * at/over the cap or past the wall clock it returns the base value unchanged ($0 no-op) because the
282
+ * engine only checks the budget at generation boundaries. Spend = real usage tokens × verified
283
+ * catalog price; a response without usage is counted by chars/4 estimate, never as free. */
284
+ export function makeLiveProposer({ apiKey, model, price, spend, capUSD, deadline, clock = Date.now, fetchImpl = fetch }) {
285
+ if (!price || typeof price.in !== 'number' || typeof price.out !== 'number') {
286
+ throw new Error(`live proposer refused: no verified price for ${model} — an unpriced spend loop is the self-DDoS rUv warned about`);
287
+ }
288
+ return async (base, target) => {
289
+ if (spend.usd >= capUSD || clock() >= deadline) {
290
+ spend.aborted = true;
291
+ return base.policy[target]; // $0 no-op — the frozen gate will reject the unchanged candidate
292
+ }
293
+ const schema = LEVER_SCHEMA[target];
294
+ const res = await fetchImpl('https://openrouter.ai/api/v1/chat/completions', {
295
+ method: 'POST',
296
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
297
+ body: JSON.stringify({
298
+ model,
299
+ max_tokens: 60,
300
+ messages: [
301
+ {
302
+ role: 'system',
303
+ content: 'You tune ONE lever of a cost-optimal model-routing policy. Goal: more correct-tier picks, fewer unnecessary frontier escalations, lower cost per win. Reply with the new lever value ONLY — no prose.',
304
+ },
305
+ {
306
+ role: 'user',
307
+ content: `Lever "${target}" (${schema.hint}). Current policy: ${JSON.stringify(base.policy)}. Propose a better value for "${target}".`,
308
+ },
309
+ ],
310
+ }),
311
+ });
312
+ const j = await res.json().catch(() => ({}));
313
+ const pt = j?.usage?.prompt_tokens ?? 200; // conservative fallback — never count a call as free
314
+ const ct = j?.usage?.completion_tokens ?? 60;
315
+ spend.usd += (pt * price.in + ct * price.out) / 1e6;
316
+ spend.calls++;
317
+ if (spend.usd >= capUSD) spend.aborted = true;
318
+ const text = j?.choices?.[0]?.message?.content ?? '';
319
+ return clampLever(target, text, base.policy[target]);
320
+ };
321
+ }
322
+
323
+ // ─── candidate artifact — NEVER the live policy. A human promotes. ─────────────────────────────────
324
+
325
+ export function renderCandidateModule({ rootPolicy, finalPolicy, provenance }) {
326
+ const params = {
327
+ qualityBar: parseFloat(finalPolicy.qualityBar),
328
+ k: Math.round(parseFloat(finalPolicy.k)),
329
+ escalation: parseEscalation(finalPolicy.escalation),
330
+ };
331
+ const diff = Object.keys(rootPolicy)
332
+ .map((t) => `// ${t}: ${JSON.stringify(rootPolicy[t])}${rootPolicy[t] === finalPolicy[t] ? ' (unchanged)' : ` -> ${JSON.stringify(finalPolicy[t])}`}`)
333
+ .join('\n');
334
+ return `// ~/.claude/model-router/policy.candidate.mjs — FLYWHEEL-PROMOTED CANDIDATE. NOT LIVE.
335
+ // Written by scripts/routing-flywheel.mjs (ruvnet-brain). The flywheel NEVER writes policy.mjs —
336
+ // a HUMAN promotes, after reviewing this diff and the signed receipt:
337
+ // review, then: cp ~/.claude/model-router/policy.candidate.mjs ~/.claude/model-router/policy.mjs
338
+ //
339
+ // Diff vs gen-0 root (the engine's live defaults):
340
+ ${diff}
341
+ //
342
+ // PROVENANCE (frozen gate ${provenance.gate_fingerprint.slice(0, 16)}…, data_source=${provenance.data_source}):
343
+ // ${JSON.stringify({ ts: provenance.ts, lift_curve: provenance.lift_curve, chain: provenance.chain, signer_public_key: provenance.signer_public_key })}
344
+ //
345
+ // routerParams = the EVOLVED executor levers for the @metaharness/router call (metaharness-router.mjs
346
+ // route() currently uses its own defaults; wiring it to read a promoted policy's routerParams is the
347
+ // human's promotion step, alongside the cp above).
348
+ export const levers = ${JSON.stringify(finalPolicy, null, 2)};
349
+ export const routerParams = ${JSON.stringify(params, null, 2)};
350
+ export const provenance = ${JSON.stringify(provenance, null, 2)};
351
+ // The cold-start fallback heuristic is deliberately UNCHANGED — the flywheel evolved the k-NN
352
+ // routing levers above, not the prose heuristic. Same-directory re-export keeps it byte-identical.
353
+ export { choose } from './policy.default.mjs';
354
+ `;
355
+ }
356
+
357
+ export function writeCandidatePolicy(filePath, content) {
358
+ const base = path.basename(filePath);
359
+ if (base === 'policy.mjs' || base === 'policy.default.mjs') {
360
+ throw new Error(`refusing to write ${base} — the flywheel never touches the live policy; candidates go to policy.candidate.mjs and a HUMAN promotes`);
361
+ }
362
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
363
+ fs.writeFileSync(filePath, content);
364
+ return filePath;
365
+ }
366
+
367
+ function appendReceipt(file, receipt) {
368
+ fs.mkdirSync(path.dirname(file), { recursive: true });
369
+ fs.appendFileSync(file, JSON.stringify(receipt) + '\n');
370
+ }
371
+
372
+ // ─── THE RUN ───────────────────────────────────────────────────────────────────────────────────────
373
+
374
+ export async function runRoutingFlywheel(opts = {}) {
375
+ const mode = opts.mode === 'live' ? 'live' : 'synthetic';
376
+ // HARD caps — Math.min means a flag can lower them, never raise them.
377
+ const capUSD = Math.min(opts.capUSD ?? HARD_CAP_USD, HARD_CAP_USD);
378
+ const maxGenerations = Math.min(opts.maxGenerations ?? HARD_MAX_GENERATIONS, HARD_MAX_GENERATIONS);
379
+ const wallMs = Math.min(opts.wallMs ?? HARD_WALL_MS, HARD_WALL_MS);
380
+ const clock = opts.clock ?? Date.now;
381
+ const t0 = clock();
382
+ const deadline = t0 + wallMs;
383
+
384
+ const loaded = opts.rows ? { rows: opts.rows, unusable: 0 } : loadLabelledRows(opts.rowsFile || OUTCOMES);
385
+ const { holdout, anchor } = splitRows(loaded.rows);
386
+ if (holdout.length < MIN_SUITE_ROWS || anchor.length < MIN_SUITE_ROWS) {
387
+ throw new Error(
388
+ `not enough labelled routing outcomes to run honestly: holdout=${holdout.length}, anchor=${anchor.length} (need ≥${MIN_SUITE_ROWS} each, from ${loaded.rows.length} usable rows). ` +
389
+ `Every routed task appends a label (metaharness-router recordOutcome) — this unblocks with use, not with synthetic padding.`
390
+ );
391
+ }
392
+
393
+ const catalog = opts.catalog ?? loadCatalog();
394
+ const profile = opts.profile !== undefined ? opts.profile : loadProfile();
395
+ const evaluator = opts.evaluator ?? makeEvaluator({ catalog, profile });
396
+
397
+ const spend = opts.spendState ?? { usd: 0, calls: 0, aborted: false };
398
+ let proposer;
399
+ let dataSource;
400
+ if (mode === 'live') {
401
+ if (!opts.apiKey) throw new Error('--live requires OPENROUTER_API_KEY');
402
+ const model = opts.proposerModel ?? LIVE_PROPOSER_MODEL;
403
+ const entry = catalog.find((c) => c.id === model);
404
+ proposer = makeLiveProposer({
405
+ apiKey: opts.apiKey,
406
+ model,
407
+ price: entry?.costPerMTok,
408
+ spend,
409
+ capUSD,
410
+ deadline,
411
+ clock,
412
+ fetchImpl: opts.fetchImpl,
413
+ });
414
+ dataSource = 'LIVE';
415
+ } else {
416
+ proposer = opts.proposer ?? makeMockProposer(opts.proposerScript);
417
+ dataSource = 'SYNTHETIC'; // any mock- or injected-proposer run is synthetic, full stop
418
+ }
419
+
420
+ const signer = makeSigner();
421
+ const rootPolicy = { ...(opts.rootPolicy ?? ROOT_POLICY) };
422
+ const runIso = new Date().toISOString();
423
+
424
+ const result = await runFlywheelGenerations({
425
+ rootPolicy,
426
+ proposer,
427
+ evaluator,
428
+ // THE FROZEN GATE. Deliberately NOT read from opts — there is no seam to soften it.
429
+ promotionRule: meetsPromotionRule,
430
+ holdout: { id: 'routing-holdout', items: holdout },
431
+ anchor: { id: 'routing-anchor', items: anchor },
432
+ maxGenerations,
433
+ signer,
434
+ // wall-clock folded into spent(): past the deadline the budget reads as exhausted.
435
+ budget: { total: capUSD, spent: () => spend.usd + (clock() >= deadline ? capUSD : 0) },
436
+ now: (g) => `${runIso}#gen${g}`,
437
+ dataSource,
438
+ });
439
+
440
+ const fp = gateFingerprint(meetsPromotionRule);
441
+ const verdict = verifyReplayBundle(result.replayBundle, { pinnedGateFingerprint: fp, promotionRule: meetsPromotionRule });
442
+ const promoted = result.promotions.filter((c) => c.verdict === 'PROMOTED');
443
+
444
+ let candidatePath = null;
445
+ if (promoted.length) {
446
+ const provenance = {
447
+ ts: runIso,
448
+ mode,
449
+ data_source: dataSource,
450
+ gate_fingerprint: fp,
451
+ lift_curve: result.replayBundle.lift_curve,
452
+ chain: promoted.map((c) => ({ id: c.id, target: c.mutation?.target, primaryDelta: c.primaryDelta, anchorScore: c.anchorScore, receipt: c.receipt })),
453
+ signer_public_key: signer.publicKey(),
454
+ bundle_verified: verdict.pass,
455
+ rows: { usable: loaded.rows.length, holdout: holdout.length, anchor: anchor.length },
456
+ };
457
+ candidatePath = writeCandidatePolicy(
458
+ opts.candidatePath ?? CANDIDATE_PATH_DEFAULT,
459
+ renderCandidateModule({ rootPolicy, finalPolicy: result.finalPolicy, provenance })
460
+ );
461
+ }
462
+
463
+ const receipt = {
464
+ kind: 'flywheel-run',
465
+ ts: runIso,
466
+ mode,
467
+ data_source: dataSource,
468
+ gate_fingerprint: fp,
469
+ bundle_verified: verdict.pass,
470
+ bundle_checks: verdict.checks,
471
+ rows: { usable: loaded.rows.length, unusable: loaded.unusable, holdout: holdout.length, anchor: anchor.length },
472
+ root_policy: rootPolicy,
473
+ final_policy: result.finalPolicy,
474
+ generations_run: result.generationsRun,
475
+ max_generations: maxGenerations,
476
+ cap_usd: capUSD,
477
+ wall_ms: clock() - t0,
478
+ spent_usd: +spend.usd.toFixed(6),
479
+ proposer_calls: spend.calls,
480
+ budget_aborted: spend.aborted || spend.usd + (clock() >= deadline ? capUSD : 0) >= capUSD,
481
+ promotions: promoted.map((c) => ({ id: c.id, target: c.mutation?.target, primaryDelta: +c.primaryDelta.toFixed(4), anchorScore: c.anchorScore })),
482
+ rejected: result.replayBundle.all_commits.filter((c) => c.verdict === 'REJECTED').length,
483
+ lift_curve: result.replayBundle.lift_curve,
484
+ milestone_reached: result.milestoneReached,
485
+ candidate_path: candidatePath,
486
+ signer_public_key: signer.publicKey(),
487
+ };
488
+ appendReceipt(opts.receiptsFile ?? RECEIPTS_PATH_DEFAULT, receipt);
489
+
490
+ return { result, receipt, verdict, candidatePath, suites: { holdout: holdout.length, anchor: anchor.length } };
491
+ }
492
+
493
+ // ─── CLI ───────────────────────────────────────────────────────────────────────────────────────────
494
+
495
+ function parseArgs(argv) {
496
+ const a = { mode: 'dry-run', json: false };
497
+ for (let i = 0; i < argv.length; i++) {
498
+ const k = argv[i];
499
+ if (k === '--dry-run') a.mode = 'dry-run';
500
+ else if (k === '--synthetic') a.mode = 'synthetic';
501
+ else if (k === '--live') a.mode = 'live';
502
+ else if (k === '--rows') a.rowsFile = argv[++i];
503
+ else if (k === '--cap') a.capUSD = parseFloat(argv[++i]);
504
+ else if (k === '--max-generations') a.maxGenerations = parseInt(argv[++i], 10);
505
+ else if (k === '--out') a.candidatePath = argv[++i];
506
+ else if (k === '--receipts') a.receiptsFile = argv[++i];
507
+ else if (k === '--json') a.json = true;
508
+ else if (k === '--help' || k === '-h') a.help = true;
509
+ }
510
+ return a;
511
+ }
512
+
513
+ function printLeverDiff(root, final) {
514
+ for (const t of Object.keys(root)) {
515
+ const changed = root[t] !== final[t];
516
+ process.stdout.write(` ${t}: ${root[t]}${changed ? ` -> ${final[t]} (CHANGED)` : ' (unchanged)'}\n`);
517
+ }
518
+ }
519
+
520
+ async function main() {
521
+ const args = parseArgs(process.argv.slice(2));
522
+ if (args.help) {
523
+ // lines 33–42 = the "// Usage:" block in this file's header comment
524
+ process.stdout.write(fs.readFileSync(new URL(import.meta.url), 'utf8').split('\n').slice(32, 40).join('\n') + '\n');
525
+ return;
526
+ }
527
+
528
+ if (args.mode === 'dry-run') {
529
+ const loaded = loadLabelledRows(args.rowsFile || OUTCOMES);
530
+ const { holdout, anchor } = splitRows(loaded.rows);
531
+ const out = {
532
+ mode: 'dry-run',
533
+ rows: { usable: loaded.rows.length, unusable: loaded.unusable, holdout: holdout.length, anchor: anchor.length },
534
+ root_policy: ROOT_POLICY,
535
+ gate_fingerprint: gateFingerprint(meetsPromotionRule),
536
+ baseline: null,
537
+ runnable: holdout.length >= MIN_SUITE_ROWS && anchor.length >= MIN_SUITE_ROWS,
538
+ };
539
+ if (out.runnable) {
540
+ const evaluate = makeEvaluator({ catalog: loadCatalog(), profile: loadProfile() });
541
+ out.baseline = {
542
+ holdout: await evaluate(ROOT_POLICY, { id: 'routing-holdout', items: holdout }),
543
+ anchor: await evaluate(ROOT_POLICY, { id: 'routing-anchor', items: anchor }),
544
+ };
545
+ } else {
546
+ out.note = `need ≥${MIN_SUITE_ROWS} rows in BOTH suites before a run is honest — every routed task appends a label`;
547
+ }
548
+ process.stdout.write(JSON.stringify(out, null, 2) + '\n');
549
+ return;
550
+ }
551
+
552
+ const opts = { ...args, mode: args.mode };
553
+ if (args.mode === 'live') {
554
+ const apiKey = process.env.OPENROUTER_API_KEY;
555
+ if (!apiKey) {
556
+ process.stderr.write('routing-flywheel: --live requires OPENROUTER_API_KEY in the environment. Refusing to start.\n');
557
+ process.exit(2);
558
+ }
559
+ opts.apiKey = apiKey;
560
+ const cap = Math.min(args.capUSD ?? HARD_CAP_USD, HARD_CAP_USD);
561
+ // The cap banner PRINTS BEFORE the first paid call — rUv's self-DDoS lesson, stated up front.
562
+ process.stdout.write(
563
+ `LIVE flywheel run — HARD caps: proposer spend ≤ $${cap.toFixed(2)}, wall clock ≤ ${HARD_WALL_MS / 60000} min, ` +
564
+ `generations ≤ ${Math.min(args.maxGenerations ?? HARD_MAX_GENERATIONS, HARD_MAX_GENERATIONS)}. ` +
565
+ `Proposer: ${LIVE_PROPOSER_MODEL} via OpenRouter. At the cap the proposer becomes a $0 no-op and the run winds down.\n`
566
+ );
567
+ }
568
+
569
+ const { receipt, candidatePath } = await runRoutingFlywheel(opts);
570
+
571
+ if (args.json) {
572
+ process.stdout.write(JSON.stringify(receipt, null, 2) + '\n');
573
+ } else {
574
+ process.stdout.write(`flywheel run (${receipt.data_source}) — ${receipt.generations_run} generation(s), ${receipt.promotions.length} promotion(s), ${receipt.rejected} rejection(s)\n`);
575
+ process.stdout.write(`gate ${receipt.gate_fingerprint.slice(0, 16)}… frozen; replay bundle verified: ${receipt.bundle_verified}\n`);
576
+ process.stdout.write(`suites: holdout=${receipt.rows.holdout} anchor=${receipt.rows.anchor} (from ${receipt.rows.usable} labelled rows)\n`);
577
+ process.stdout.write(`spend: $${receipt.spent_usd} of $${receipt.cap_usd} cap, ${receipt.proposer_calls} proposer call(s), ${receipt.wall_ms}ms\n`);
578
+ process.stdout.write('levers (gen-0 -> final):\n');
579
+ printLeverDiff(receipt.root_policy, receipt.final_policy);
580
+ if (candidatePath) {
581
+ process.stdout.write(`\nPROMOTED CANDIDATE written to ${candidatePath} — NOT live.\n`);
582
+ process.stdout.write(`A HUMAN promotes: review the file, then cp ${candidatePath} ${path.join(path.dirname(candidatePath), 'policy.mjs')}\n`);
583
+ process.stdout.write(`signed head receipt: ${JSON.stringify(receipt.promotions[0])}\n`);
584
+ } else {
585
+ process.stdout.write('\nNo candidate cleared the frozen gate — nothing was written. That is the gate working, not a failure (ADR-226: assume no improvement loop helps until the gate says it did).\n');
586
+ }
587
+ process.stdout.write(`receipt appended: ${args.receiptsFile ?? RECEIPTS_PATH_DEFAULT}\n`);
588
+ }
589
+ }
590
+
591
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
592
+ main().catch((e) => {
593
+ process.stderr.write(`routing-flywheel: ${e.stack || e.message}\n`);
594
+ process.exit(1);
595
+ });
596
+ }