ruvnet-brain 4.0.36 → 4.2.2-dev

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 (68) hide show
  1. package/README.md +28 -5
  2. package/bin/install.mjs +405 -23
  3. package/data/model-catalog.json +104 -15
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +2 -2
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/brain-console.md +72 -9
  8. package/plugin/commands/configure.md +67 -21
  9. package/plugin/commands/rvcb.md +72 -9
  10. package/plugin/hooks/codex-hooks.json +40 -33
  11. package/plugin/hooks/hook-contracts.json +14 -24
  12. package/plugin/hooks/hooks.json +7 -42
  13. package/plugin/mcp/server.mjs +23 -6
  14. package/plugin/scripts/adr-currency-gate.mjs +150 -0
  15. package/plugin/scripts/capability-registry.mjs +10 -1
  16. package/plugin/scripts/codex-hook-adapter.mjs +121 -19
  17. package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
  18. package/plugin/scripts/continuation-gate.mjs +148 -8
  19. package/plugin/scripts/decision-gate.mjs +428 -0
  20. package/plugin/scripts/decision-outcomes.mjs +231 -0
  21. package/plugin/scripts/degradation-watch.mjs +271 -0
  22. package/plugin/scripts/ground-ruvnet.sh +51 -11
  23. package/plugin/scripts/hijack-ruvnet.sh +11 -3
  24. package/plugin/scripts/hook-registry.mjs +48 -3
  25. package/plugin/scripts/hook-shim.mjs +58 -3
  26. package/plugin/scripts/identifier-preflight.mjs +134 -0
  27. package/plugin/scripts/learn-capture.sh +50 -1
  28. package/plugin/scripts/learn-flush.mjs +5 -5
  29. package/plugin/scripts/lesson-bridge.mjs +343 -0
  30. package/plugin/scripts/lesson-hooks.sh +26 -0
  31. package/plugin/scripts/lesson-promote.mjs +50 -0
  32. package/plugin/scripts/lesson-store.mjs +6 -1
  33. package/plugin/scripts/mcp-readiness.mjs +107 -0
  34. package/plugin/scripts/protect-brain-state.sh +9 -0
  35. package/plugin/scripts/runtime-preferences.mjs +40 -0
  36. package/plugin/scripts/session-snapshot-hook.mjs +15 -6
  37. package/plugin/scripts/spend-guard.mjs +125 -0
  38. package/plugin/scripts/unprompted-runtime.mjs +12 -2
  39. package/plugin/scripts/update-apply.mjs +7 -2
  40. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
  41. package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
  42. package/scripts/brain-score.mjs +261 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +38 -4
  45. package/scripts/card-from-source.mjs +114 -0
  46. package/scripts/console-engine.mjs +1 -1
  47. package/scripts/corpus-candidate.mjs +294 -0
  48. package/scripts/corpus-reconcile.mjs +273 -0
  49. package/scripts/corpus-seed-publish.mjs +110 -0
  50. package/scripts/health-repair.mjs +11 -2
  51. package/scripts/ingest-new-repos.mjs +122 -0
  52. package/scripts/ingest-repo.mjs +66 -6
  53. package/scripts/learning-replay-cli.mjs +8 -3
  54. package/scripts/learning-replay-fixture.mjs +25 -6
  55. package/scripts/learning-replay-proof.mjs +38 -0
  56. package/scripts/nightly-wrapper.sh +55 -7
  57. package/scripts/onboarding-console.mjs +21 -1
  58. package/scripts/org-repo-count.mjs +119 -0
  59. package/scripts/rebuild-gists-from-receipts.mjs +246 -0
  60. package/scripts/release-transaction.mjs +30 -2
  61. package/scripts/release.mjs +147 -0
  62. package/scripts/repo-count-detector.mjs +62 -0
  63. package/scripts/restore-local-ingests.mjs +116 -0
  64. package/scripts/rvf-generation.mjs +44 -0
  65. package/scripts/selfcheck.mjs +9 -1
  66. package/scripts/stabilization-receipt.mjs +11 -1
  67. package/scripts/sync-census.mjs +0 -0
  68. package/scripts/sync-commands.mjs +117 -0
@@ -1,5 +1,5 @@
1
1
  {
2
- "_note": "OUT-OF-SHIM HOOK CONTRACTS (ADR-055 §6). The per-hook contract — event, matcher, layer, codeRoot, mode, offBehavior, failureBehavior, timeout, budgets, state, dependencies, reachesStrangers, owner — is carried NATIVELY by plugin/scripts/hook-shim.mjs's dispatch table for every shim-routed registration. Everything registered outside that table has nowhere to carry it, which is how the whole non-plugin two thirds of this machine's mesh ended up with no declared answer to 'what happens when the brain is off' (ADR-055 F14). This file is the other half: a checked-in contract for every registration this REPO owns but does not route through the shim. scripts/hook-registry.mjs reads both, and tests/unit/hook-registry-lint.test.mjs fails any repo-owned registration absent from both — so a new one cannot be added silently. SCOPE, deliberately: this file declares only registrations THIS repo owns (plugin/hooks/hooks.json and this project's .claude/settings.json). It does NOT declare contracts for the machine owner's ~/.claude/settings.json or for third-party plugins — those are not ours to specify, and inventing an offBehavior for someone else's hook would be exactly the fiction the lint exists to prevent. They stay visible as documented machine-local findings in the lint's expected-red block.",
2
+ "_note": "OUT-OF-SHIM HOOK CONTRACTS (ADR-055 \u00a76). The per-hook contract \u2014 event, matcher, layer, codeRoot, mode, offBehavior, failureBehavior, timeout, budgets, state, dependencies, reachesStrangers, owner \u2014 is carried NATIVELY by plugin/scripts/hook-shim.mjs's dispatch table for every shim-routed registration. Everything registered outside that table has nowhere to carry it, which is how the whole non-plugin two thirds of this machine's mesh ended up with no declared answer to 'what happens when the brain is off' (ADR-055 F14). This file is the other half: a checked-in contract for every registration this REPO owns but does not route through the shim. scripts/hook-registry.mjs reads both, and tests/unit/hook-registry-lint.test.mjs fails any repo-owned registration absent from both \u2014 so a new one cannot be added silently. SCOPE, deliberately: this file declares only registrations THIS repo owns (plugin/hooks/hooks.json and this project's .claude/settings.json). It does NOT declare contracts for the machine owner's ~/.claude/settings.json or for third-party plugins \u2014 those are not ours to specify, and inventing an offBehavior for someone else's hook would be exactly the fiction the lint exists to prevent. They stay visible as documented machine-local findings in the lint's expected-red block.",
3
3
  "_version": 1,
4
4
  "contracts": [
5
5
  {
@@ -10,44 +10,34 @@
10
10
  "commandIncludes": "version-bump-gate.sh",
11
11
  "mode": "blocking",
12
12
  "offBehavior": "run",
13
- "offReason": "ADR-054's discriminator, applied literally: this is an HONESTY wall, not a retrieval feature. It refuses a commit that changes behaviour without bumping the version — the signal every user's updater reads. Nothing about that becomes acceptable because the owner switched the retrieval brain off, and the gate reads git state only: no corpus, no network, no brain.",
14
- "failureBehavior": "fail-open — a crash or unparseable payload permits the command and records degraded health; malfunction is never a decision (ADR-055 §1.2)",
13
+ "offReason": "ADR-054's discriminator, applied literally: this is an HONESTY wall, not a retrieval feature. It refuses a commit that changes behaviour without bumping the version \u2014 the signal every user's updater reads. Nothing about that becomes acceptable because the owner switched the retrieval brain off, and the gate reads git state only: no corpus, no network, no brain.",
14
+ "failureBehavior": "fail-open \u2014 a crash or unparseable payload permits the command and records degraded health; malfunction is never a decision (ADR-055 \u00a71.2)",
15
15
  "timeoutSeconds": 5,
16
16
  "warmP95BudgetMs": 50,
17
17
  "coldBudgetMs": 500,
18
18
  "stdoutCapBytes": 4096,
19
- "stateRead": ["git HEAD", "plugin/.claude-plugin/plugin.json"],
19
+ "stateRead": [
20
+ "git HEAD",
21
+ "plugin/.claude-plugin/plugin.json"
22
+ ],
20
23
  "stateWritten": [],
21
- "dependencies": ["bash", "git"],
24
+ "dependencies": [
25
+ "bash",
26
+ "git"
27
+ ],
22
28
  "reachesStrangers": false,
23
- "owner": "ruvnet-brain (this repo only — ADR-053; it enforces a rule that is true here and nowhere else)",
29
+ "owner": "ruvnet-brain (this repo only \u2014 ADR-053; it enforces a rule that is true here and nowhere else)",
24
30
  "note": "Project-scoped on purpose: it lived in the GLOBAL settings until 2026-07-14, taxing every Bash call in 34+ projects with a rule true in one."
25
31
  }
26
32
  ],
27
33
  "matcherAllowlist": [
28
- {
29
- "layer": "plugin",
30
- "event": "PreToolUse",
31
- "matcher": "Write|Edit|Bash",
32
- "handler": "hijack-ruvnet.sh",
33
- "reason": "UNANCHORED, known (ADR-055 F4). Claude Code SEARCHES the tool name, so this also selects MultiEdit, NotebookEdit, TodoWrite and BashOutput — an advisory hook firing on four tools nobody chose. Advisory, so the blast radius is wasted latency rather than a wrong refusal.",
34
- "retiredBy": "ADR-055 build item 3 (four plane dispatchers) — registration changes are SHELL changes and item 2 (battery v2) must land first, so this is recorded, not fixed out of order."
35
- },
36
34
  {
37
35
  "layer": "plugin",
38
36
  "event": "PreToolUse",
39
37
  "matcher": "Bash",
40
38
  "handler": "verify-interface.sh",
41
- "reason": "UNANCHORED, known (ADR-055 F4/F12): also selects BashOutput. One of three blocking walls on the same event that each re-parse the same payload — the parse-once execution-guard is the real fix, not six anchor characters.",
42
- "retiredBy": "ADR-055 build item 3 (execution-guard.mjs — ONE registration, ONE parse)"
43
- },
44
- {
45
- "layer": "plugin",
46
- "event": "PreToolUse",
47
- "matcher": "Bash",
48
- "handler": "design-wall.sh",
49
- "reason": "UNANCHORED, known (ADR-055 F4/F12) — same pair as verify-interface above, same fix.",
50
- "retiredBy": "ADR-055 build item 3 (execution-guard.mjs)"
39
+ "reason": "UNANCHORED, known (ADR-055 F4/F12): also selects BashOutput. One of three blocking walls on the same event that each re-parse the same payload \u2014 the parse-once execution-guard is the real fix, not six anchor characters.",
40
+ "retiredBy": "ADR-055 build item 3 (execution-guard.mjs \u2014 ONE registration, ONE parse)"
51
41
  },
52
42
  {
53
43
  "layer": "plugin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "POSIX Stable Spine (ADR-023): every hook routes through hook-shim.mjs, which resolves the active generation per invocation. Advisory hooks fail open; blocking hooks preserve their exit-code contract.",
2
+ "description": "POSIX Stable Spine (ADR-023): every hook routes through hook-shim.mjs, which resolves the active generation per invocation. Advisory hooks fail open. ADR-067: exactly ONE hook may refuse a given tool call \u2014 decision-gate.mjs composes every policy into one decision. TIMEOUTS ARE A CONTRACT, NOT A GUESS: both decision-gate PreToolUse entries declare 5s, and the gate self-caps at DEFAULT_BUDGET_MS=2000 + MIN_HEADROOM_MS=3000, which is EXACTLY 5000ms \u2014 the relationship is the point, not the numbers. They ran at 10s for six days and that was earned at the time: an audit on 2026-08-13 timed 14 runs in unrelated projects and SIX exceeded 5s (up to 5700ms, including a plain `ls -la` at 5109ms), and because the host KILLS an over-timeout hook and renders it as a FAILED PreToolUse HOOK, the gate was producing the \"ton of hook errors\" this plugin was reported for while refusing nothing. RE-MEASURED 2026-08-19, after the adr-currency two-pass fix landed: the same full shim+gate path now runs 405-438ms in a stranger project and 412-438ms in this repo \u2014 about 10x faster \u2014 so the 10s ceiling was no longer earned by any measurement, and a ceiling nobody needs is charged to the USER, who waits behind it on every single Write and Bash. A budget is widened by evidence and must be narrowed again when the evidence goes away. The number is asserted, not asserted-to: decision-gate.mjs exports DEFAULT_BUDGET_MS + MIN_HEADROOM_MS and tests/unit/decision-gate.test.mjs reads the timeout out of THIS file and goes red if the two stop fitting. Comment keys (_note and friends) must NOT be added to this file: ADR-055 F17 records that Codex's plugin parser rejects them.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -59,36 +59,31 @@
59
59
  ],
60
60
  "PreToolUse": [
61
61
  {
62
- "matcher": "Write|Edit|Bash",
62
+ "matcher": "^(Write|Edit|MultiEdit|NotebookEdit)$",
63
63
  "hooks": [
64
64
  {
65
65
  "type": "command",
66
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" hijack-ruvnet || true",
66
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" decision-gate write",
67
67
  "timeout": 5
68
68
  }
69
69
  ]
70
70
  },
71
71
  {
72
- "matcher": "^(Task|Agent)$",
72
+ "matcher": "^Bash$",
73
73
  "hooks": [
74
74
  {
75
75
  "type": "command",
76
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" route-dispatch || true",
76
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" decision-gate bash",
77
77
  "timeout": 5
78
78
  }
79
79
  ]
80
80
  },
81
81
  {
82
- "matcher": "^(Write|Edit|MultiEdit|NotebookEdit)$",
82
+ "matcher": "^(Task|Agent)$",
83
83
  "hooks": [
84
84
  {
85
85
  "type": "command",
86
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" ground-before-write || true",
87
- "timeout": 5
88
- },
89
- {
90
- "type": "command",
91
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" protect-state",
86
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" route-dispatch || true",
92
87
  "timeout": 5
93
88
  }
94
89
  ]
@@ -102,36 +97,6 @@
102
97
  "timeout": 5
103
98
  }
104
99
  ]
105
- },
106
- {
107
- "matcher": "Bash",
108
- "hooks": [
109
- {
110
- "type": "command",
111
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" design-wall",
112
- "timeout": 5
113
- }
114
- ]
115
- },
116
- {
117
- "matcher": "^(Write|Edit|MultiEdit)$",
118
- "hooks": [
119
- {
120
- "type": "command",
121
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" unprompted-speech PreToolUse-write",
122
- "timeout": 5
123
- }
124
- ]
125
- },
126
- {
127
- "matcher": "^Bash$",
128
- "hooks": [
129
- {
130
- "type": "command",
131
- "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" unprompted-speech PreToolUse-bash",
132
- "timeout": 5
133
- }
134
- ]
135
100
  }
136
101
  ],
137
102
  "PostToolUse": [
@@ -33,6 +33,7 @@ import fs from 'node:fs';
33
33
  import path from 'node:path';
34
34
  import os from 'node:os';
35
35
  import readline from 'node:readline';
36
+ import { writeOwn as writeOwnReadiness } from '../scripts/mcp-readiness.mjs';
36
37
  import { callManagedCli, MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
37
38
 
38
39
  const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
@@ -66,12 +67,16 @@ const clientOk = (id, result) => out({ jsonrpc: '2.0', id, result });
66
67
  const clientErr = (id, code, message) => out({ jsonrpc: '2.0', id, error: { code, message } });
67
68
  const readJSON = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
68
69
 
70
+ /**
71
+ * ISSUE #133 (second half). This used to write ONE shared file, last writer wins — so a second MCP
72
+ * shell's `degraded` overwrote this one's `ready`, and `--doctor` reported the state of whichever
73
+ * process wrote last as the state of the machine. A process now owns only its own record; the
74
+ * machine-level answer is DERIVED from the live ones by mcp-readiness.mjs. Same fix as everything
75
+ * else closed today: one producer per fact.
76
+ */
69
77
  function writeReadiness(value) {
70
78
  try {
71
- fs.mkdirSync(BRAIN_HOME, { recursive: true });
72
- const tmp = `${READINESS}.${process.pid}.${Date.now()}.tmp`;
73
- fs.writeFileSync(tmp, `${JSON.stringify({ ...value, pid: process.pid, at: new Date().toISOString() })}\n`, { mode: 0o600 });
74
- fs.renameSync(tmp, READINESS);
79
+ writeOwnReadiness(BRAIN_HOME, value);
75
80
  } catch (error) {
76
81
  console.error(`[ruvnet-brain] could not persist MCP readiness: ${error.message}`);
77
82
  }
@@ -194,11 +199,23 @@ async function ensureChild() {
194
199
  const waiter = c.pending.get(msg.id);
195
200
  if (waiter) { c.pending.delete(msg.id); waiter.resolve(msg); }
196
201
  });
197
- proc.on('exit', () => {
202
+ proc.on('exit', (code, signal) => {
198
203
  if (child === c) child = null;
199
204
  for (const [, p] of c.pending) p.reject(new Error('brain worker exited'));
200
205
  c.pending.clear();
201
- if (!c.intentionalStop) {
206
+ // A CLEAN EXIT IS NOT A CRASH (issue #133, the sibling of the #122 idle-exit fix).
207
+ //
208
+ // #122 gave the worker a 15-minute idle retirement so a quiet session stops holding 3-15GB.
209
+ // It exits deliberately, with process.exit(0). This handler only knew about
210
+ // `intentionalStop`, which the PARENT sets when IT kills the child — so the worker retiring
211
+ // ITSELF was recorded as `degraded / worker-exit … exited unexpectedly`, and --doctor then
212
+ // exited 1 on a machine where nothing was wrong and the next search would respawn it by
213
+ // design. A fix that frees memory should not make the product report itself broken.
214
+ //
215
+ // Exit 0 with no signal is a deliberate stop — the idle retirement, or stdin EOF when the
216
+ // host goes away. A crash carries a non-zero code or a signal, and still degrades.
217
+ const cleanExit = code === 0 && !signal;
218
+ if (!c.intentionalStop && !cleanExit) {
202
219
  writeReadiness({
203
220
  state: 'degraded', phase: 'worker-exit', generation: c.generation,
204
221
  elapsedMs: Date.now() - startedAt, retryable: true,
@@ -0,0 +1,150 @@
1
+ /**
2
+ * adr-currency-gate.mjs — the ADR check, moved from the LAST possible moment to the FIRST.
3
+ *
4
+ * WHAT HAPPENED, 2026-08-13. Three commits touched code governed by ADR-055, 065, 066 and 067. All
5
+ * four ADRs were left describing a world the code had left. The pre-push gate caught it and refused
6
+ * — correctly, and it is a good gate. But look at WHEN: after the files were written, after three
7
+ * commits, after I had moved on. By then the work read as a toll booth, and my own words for it were
8
+ * "real work I skipped". The owner quoted that line back at me as the exhibit, and he was right to.
9
+ *
10
+ * A gate at push time cannot shape the work; it can only penalise it afterwards. Worse, it TRAINS
11
+ * the behaviour it exists to stop — if the wall is at the end, the cheap move is always to run at it
12
+ * and let it sort you out. The instinct the owner keeps asking for is not something exhortation can
13
+ * install; it is what you get when the right action is the only action available at the moment of
14
+ * acting.
15
+ *
16
+ * SO THIS FIRES ON THE EDIT, AND IT REFUSES DEBT RATHER THAN CHANGE. It does NOT ask you to document
17
+ * an edit you have not made yet — that would be incoherent. It refuses to let you write MORE code
18
+ * governed by a document that is ALREADY stale from your last round. One unreconciled ADR is a
19
+ * conversation; four is the mess that shipped today.
20
+ *
21
+ * REUSED, NOT REIMPLEMENTED. Every verdict comes from `scripts/doc-currency.mjs` — the same
22
+ * `evaluateDoc`/`resolveGoverned` the pre-push gate calls. A second implementation of "is this ADR
23
+ * current" would be one fact restated in two places, which is the defect this repo has paid for at
24
+ * least five times (orgTotalApprox in two producers, seven store-root expressions, a hand-listed
25
+ * import graph in four fixtures, two ship-command definitions shipped disagreeing on day one).
26
+ *
27
+ * FAIL OPEN, ALWAYS. Not a git repo, unreadable doc, git unavailable, anything unexpected: ALLOW,
28
+ * silently. An adversarial review earlier today found a sibling hook turning a missing `sqlite3`
29
+ * into a confident claim that the memory store was corrupt. A gate that fabricates a reason is worse
30
+ * than no gate, because it spends the credibility every other gate is drawing on.
31
+ */
32
+ import fs from 'node:fs';
33
+ import path from 'node:path';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+
36
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
37
+ const REPO = path.resolve(HERE, '..', '..');
38
+
39
+ /** States that mean "the code moved and nobody has reconciled the document since". */
40
+ const STALE = new Set(['presumed-stale']);
41
+
42
+ /**
43
+ * Which documents govern this file, and are they current?
44
+ *
45
+ * Only the ADRs that actually govern the edited path are evaluated — `evaluate()` walks 83 documents
46
+ * with git calls per document, which is fine for a pre-push gate and far too slow for something on
47
+ * the Write path. Same logic, narrower question.
48
+ */
49
+ /**
50
+ * doc-currency lives OUTSIDE the payload, and that is correct rather than an oversight worked
51
+ * around. `plugin/` is what reaches a user (marketplace.json `"source": "./plugin"`), a user's
52
+ * install has no `docs/adr/`, and a gate about THIS repo's ADRs has nothing to say there. A static
53
+ * `import('../../scripts/doc-currency.mjs')` would have shipped a specifier that resolves only in a
54
+ * checkout and throws ERR_MODULE_NOT_FOUND on every real install — the exact defect ADR-065 exists
55
+ * to stop, which `payload-self-contained.test.mjs` caught in the very commit citing ADR-065.
56
+ *
57
+ * So the dependency is resolved at runtime and its ABSENCE IS A CLEAN SKIP, not an error: outside a
58
+ * checkout this gate simply has no opinion.
59
+ */
60
+ async function loadDocCurrency(root) {
61
+ const p = path.join(root, 'scripts', 'doc-currency.mjs');
62
+ if (!fs.existsSync(p)) return null;
63
+ try { return await import(pathToFileURL(p).href); } catch { return null; }
64
+ }
65
+
66
+ export async function staleGovernorsOf(relPath, { root = REPO, docCurrency = null, readFile = null } = {}) {
67
+ const mod = docCurrency ?? await loadDocCurrency(root);
68
+ if (!mod) return [];
69
+ const { listDocs, evaluateDoc, parseFrontmatter, DEFAULT_DIRS, isGitRepo } = mod;
70
+ // `readFile` is injectable for one reason, and it is not tidiness: with `fs.readFileSync` hardcoded
71
+ // here, a test that injects `listDocs`/`parseFrontmatter` never reaches them — the read throws on a
72
+ // fixture path that does not exist, the loop `continue`s, and NO CANDIDATE IS EVER FOUND. The first
73
+ // mutation run of this file reported "STALE ADR -> DID NOT FIRE", while all three allow-cases
74
+ // passed. A suite of only allow-cases would have shipped this green and unfireable, which is the
75
+ // exact defect class this repo has now hit four times in one day.
76
+ const read = readFile ?? ((p) => fs.readFileSync(p, 'utf8'));
77
+ if (!isGitRepo(root)) return [];
78
+
79
+ // TWO PASSES, AND THE ORDER IS THE WHOLE DESIGN. Measured before writing it, not after:
80
+ // `evaluateDoc` costs ~190-240ms because it shells out to git, and there are 83 documents — a
81
+ // naive loop is ~19 SECONDS on the Write path, which would make this the gate everybody disables.
82
+ // Reading frontmatter for all 83 costs 40ms total. So: cheap pass to find WHICH documents govern
83
+ // this file (usually one), expensive pass on only those. ~250ms typical, and exactly 40ms for the
84
+ // overwhelmingly common case of a file no ADR governs.
85
+ const candidates = [];
86
+ for (const docRel of listDocs(root, DEFAULT_DIRS)) {
87
+ let fm;
88
+ try { fm = parseFrontmatter(read(path.join(root, docRel))); } catch { continue; }
89
+ const governs = fm?.keys?.governs;
90
+ if (!Array.isArray(governs)) continue;
91
+ // Entries are exact paths or directory prefixes, read the way the pre-push gate reads them.
92
+ const governsThis = governs.some((p) => typeof p === 'string'
93
+ && (relPath === p || relPath.startsWith(p.endsWith('/') ? p : `${p}/`)));
94
+ if (governsThis) candidates.push({ docRel, id: fm?.keys?.id ?? path.basename(docRel) });
95
+ }
96
+ if (!candidates.length) return [];
97
+
98
+ const out = [];
99
+ for (const c of candidates) {
100
+ let doc;
101
+ try { doc = evaluateDoc(root, c.docRel); } catch { continue; }
102
+ if (STALE.has(doc?.drift?.state)) out.push({ doc: c.docRel, id: c.id, why: doc?.drift?.why ?? '' });
103
+ }
104
+ return out;
105
+ }
106
+
107
+ export function refusalText(relPath, stale) {
108
+ const names = stale.map((s) => `${s.id} (${s.doc})`).join('\n ');
109
+ return `⛔ BLOCKED — ${relPath} is governed by a document that is ALREADY stale.
110
+
111
+ stale: ${names}
112
+
113
+ Reconcile it BEFORE writing more of what it governs. Not because the rule says so, but because
114
+ this is the moment the reconciliation is cheap: you still remember what changed and why. On
115
+ 2026-08-13 four ADRs went stale together and the pre-push gate caught them after three commits,
116
+ when the work read as a toll booth and got called "real work I skipped".
117
+
118
+ 1. add a Currency-log row to the document: what changed, and why, with referents
119
+ 2. \`node scripts/doc-currency.mjs --fix\` backfills only the dates git can prove
120
+ 3. status, and every claim in the row, is yours to make — no script may write it
121
+
122
+ An ADR describing a world the code left is worse than no ADR: the next reader trusts it.`;
123
+ }
124
+
125
+ const isMain = (() => {
126
+ try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
127
+ catch { return false; }
128
+ })();
129
+
130
+ if (isMain) {
131
+ // Every failure path below ALLOWS. This gate may never be the reason work cannot proceed for a
132
+ // reason it cannot explain.
133
+ let allow = 0;
134
+ try {
135
+ const payload = fs.readFileSync(0, 'utf8');
136
+ const input = JSON.parse(payload)?.tool_input ?? {};
137
+ const file = input.file_path || input.path || '';
138
+ if (!file) process.exit(allow);
139
+ const rel = path.relative(REPO, path.resolve(file));
140
+ // Edits OUTSIDE the repo, and edits to the documents themselves, are never blocked — the second
141
+ // exemption is essential: reconciling a stale ADR must not be refused by the staleness it fixes.
142
+ if (rel.startsWith('..') || rel.startsWith('docs/')) process.exit(allow);
143
+ const stale = await staleGovernorsOf(rel);
144
+ if (!stale.length) process.exit(allow);
145
+ process.stderr.write(`${refusalText(rel, stale)}\n`);
146
+ process.exit(2);
147
+ } catch {
148
+ process.exit(allow);
149
+ }
150
+ }
@@ -244,13 +244,22 @@ function lineCount(file) {
244
244
  */
245
245
  export function dispatchGateWiring({ repo = REPO, home = HOME } = {}) {
246
246
  const PREIMAGE = new Set(['plugin', 'marketplace-clone']);
247
+ // 'codex' joined hook-registry.mjs's mesh 2026-08-20 (Dream Cycle cross-host-conformance) so M1/
248
+ // M3/M5/M6 could see codex-hooks.json — but this function asks a CLAUDE CODE question ("is a
249
+ // PreToolUse gate on subagent dispatch wired to the cheap-model router" for THIS session), and
250
+ // Claude Code never loads codex-hooks.json under any circumstance. Without this exclusion, a
251
+ // resolved codex route-dispatch registration (now correctly matched to route-dispatch.sh via the
252
+ // shared shim table) reported `wired: true` on a machine that had never installed Codex at all —
253
+ // caught by re-running this function before/after the hook-registry.mjs change.
254
+ const NOT_CLAUDE_CODE = new Set(['codex']);
247
255
  let records;
248
256
  try { records = buildRegistry({ repo, home }).records; }
249
257
  catch { return { wired: false, layer: null, unreadable: true }; }
250
258
  const hits = records.filter((r) => r.event === 'PreToolUse'
251
259
  && r.handler === 'route-dispatch.sh'
252
260
  && r.tools.some((t) => t === 'Task' || t === 'Agent' || t === '*')
253
- && !PREIMAGE.has(r.layer));
261
+ && !PREIMAGE.has(r.layer)
262
+ && !NOT_CLAUDE_CODE.has(r.layer));
254
263
  const external = hits.find((r) => r.layer !== 'plugin-installed');
255
264
  if (external) return { wired: true, layer: external.layer, unreadable: false };
256
265
  const enabled = Object.entries(readJSON(path.join(home, '.claude/settings.json')).value?.enabledPlugins || {})
@@ -1,4 +1,32 @@
1
1
  #!/usr/bin/env node
2
+ /**
3
+ * codex-hook-adapter.mjs — the host boundary. Codex payloads in, shared Brain hook bodies out, and
4
+ * Codex-VALID output back.
5
+ *
6
+ * THE OUTPUT CONTRACT IS NOT GUESSED. Every rule below was read out of the real host: the JSON
7
+ * schemas Codex 0.147.0 carries inside its own binary — `<event>.command.output`, extracted
8
+ * 2026-08-14 with `strings` from
9
+ * `~/.codex/packages/standalone/releases/0.147.0-aarch64-apple-darwin/bin/codex` — plus the host's
10
+ * own error strings. The three that shaped this file:
11
+ *
12
+ * · "hook returned invalid post-tool-use JSON output" — PostToolUse stdout is PARSED. Plain text
13
+ * is a host error, not a message. signal-watch.mjs prints one advisory LINE on a failed
14
+ * gh/vercel/npm command, which reached Codex as invalid JSON on every such command, and nothing
15
+ * in this file wrapped it: the old envelope branch covered SessionStart and UserPromptSubmit
16
+ * only. Measured before the fix: raw text passed straight through.
17
+ * · The events that may carry `hookSpecificOutput.additionalContext` are exactly the ones with a
18
+ * *HookSpecificOutputWire definition — PreToolUse, PostToolUse, PermissionRequest, SessionStart,
19
+ * SubagentStart, UserPromptSubmit. `session-end.command.output` DOES NOT EXIST, and
20
+ * pre-compact/post-compact/stop/subagent-stop have no additionalContext at all. So for those,
21
+ * unparseable stdout is DROPPED. Wrapping it would trade a silent no-op for a host error.
22
+ * · "PreToolUse hook returned unsupported permissionDecision:allow" / ":ask" — the wire enum has
23
+ * three values and Codex accepts exactly one of them, `deny`. Anything else is stripped, which
24
+ * is why the pre-existing `defer` strip is now a general rule rather than one special case.
25
+ *
26
+ * Exit-2-plus-stderr is the refusal channel on every blocking event ("PreToolUse hook exited with
27
+ * code 2 but did not write a blocking reason to stderr" is the host's complaint when the stderr half
28
+ * is missing), so a non-zero status is forwarded verbatim and never reinterpreted.
29
+ */
2
30
  import fs from 'node:fs';
3
31
  import path from 'node:path';
4
32
  import { spawnSync } from 'node:child_process';
@@ -13,8 +41,27 @@ const event = String(input.hook_event_name || '');
13
41
  let adapted = false;
14
42
  const codexToolName = String(input.tool_name).toLowerCase();
15
43
 
44
+ /**
45
+ * Events whose output schema defines a *HookSpecificOutputWire with `additionalContext`. Only these
46
+ * may carry a hook's prose back to the model.
47
+ */
48
+ const CONTEXT_EVENTS = new Set([
49
+ 'PreToolUse', 'PostToolUse', 'PermissionRequest', 'SessionStart', 'SubagentStart', 'UserPromptSubmit',
50
+ ]);
51
+
52
+ /** Every file an apply_patch touches, in patch order. Codex patches are routinely multi-file. */
53
+ export function patchFiles(patch) {
54
+ const out = [];
55
+ for (const m of String(patch || '').matchAll(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/gm)) {
56
+ const f = m[1].trim();
57
+ if (f && !out.includes(f)) out.push(f);
58
+ }
59
+ return out;
60
+ }
61
+
16
62
  // Codex names these tools differently from the shared Claude hook contracts. Normalize at the
17
63
  // host boundary once so every existing safety/learning body sees the same typed event.
64
+ let files = [];
18
65
  if (['exec_command', 'functions.exec_command', 'functions__exec_command'].includes(codexToolName)) {
19
66
  input.tool_name = 'Bash';
20
67
  input.tool_input = {
@@ -24,11 +71,11 @@ if (['exec_command', 'functions.exec_command', 'functions__exec_command'].includ
24
71
  adapted = true;
25
72
  } else if (codexToolName === 'apply_patch') {
26
73
  const patch = typeof input.tool_input?.command === 'string' ? input.tool_input.command : '';
27
- const filePath = patch.match(/^\*\*\* (?:Add|Update|Delete) File: (.+)$/m)?.[1]?.trim() || '';
74
+ files = patchFiles(patch);
28
75
  input.tool_name = 'Edit';
29
76
  input.tool_input = {
30
77
  ...(input.tool_input || {}),
31
- ...(filePath ? { file_path: filePath } : {}),
78
+ ...(files[0] ? { file_path: files[0] } : {}),
32
79
  new_string: patch,
33
80
  };
34
81
  adapted = true;
@@ -51,40 +98,95 @@ const env = {
51
98
  CLAUDE_PROJECT_DIR: String(input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd()),
52
99
  RUVNET_HOOK_HOST: 'codex',
53
100
  };
54
- const result = spawnSync(process.execPath, [shim, hookId, ...process.argv.slice(3)], {
55
- input: hookInput,
56
- encoding: 'utf8',
57
- env,
101
+
102
+ const runShim = (payload) => spawnSync(process.execPath, [shim, hookId, ...process.argv.slice(3)], {
103
+ input: payload, encoding: 'utf8', env,
58
104
  });
59
105
 
60
- if (result.status && result.stderr) process.stderr.write(result.stderr);
61
- if (result.status) process.exit(result.status);
106
+ /**
107
+ * ONE PAYLOAD PER FILE for a multi-file patch.
108
+ *
109
+ * Every write policy on both hosts reads a single `tool_input.file_path` (protect-brain-state.sh
110
+ * line 56, ground-before-write.sh line 109, adr-currency-gate.mjs line 137). A Codex `apply_patch`
111
+ * carries N files in one call, so exposing only the first meant files 2..N were never shown to any
112
+ * wall — the same shape as every other defect in this area: the check exists and points one surface
113
+ * away from the failure.
114
+ *
115
+ * BOUNDED, because the wrapper SIGKILLs this process at its own budget and a kill is invisible. The
116
+ * wrapper hands its budget down; iteration stops at 75% of it and ALLOWS, which is decision-gate's
117
+ * own rule for a blown budget ("a blown budget ALLOWS and says nothing"): the gate's slowness must
118
+ * never be indistinguishable from the user doing something wrong.
119
+ */
120
+ const BUDGET_MS = Number(process.env.RUVNET_CODEX_BUDGET_MS) || 0;
121
+ const started = Date.now();
122
+ const spent = () => Date.now() - started;
123
+
124
+ const payloads = files.length > 1
125
+ ? files.map((file) => JSON.stringify({
126
+ ...input,
127
+ tool_input: { ...input.tool_input, file_path: file },
128
+ }))
129
+ : [hookInput];
62
130
 
63
- const stdout = result.stdout || '';
64
- if (!stdout) process.exit(0);
131
+ const stdouts = [];
132
+ for (const payload of payloads) {
133
+ const r = runShim(payload);
134
+ // A refusal (or any error) from ANY file is the decision for the whole patch, forwarded verbatim
135
+ // and immediately — there is nothing to compose once one wall has said no.
136
+ if (r.status && r.stderr) process.stderr.write(r.stderr);
137
+ if (r.status) process.exit(r.status);
138
+ if (r.stdout) stdouts.push(r.stdout);
139
+ if (BUDGET_MS && spent() > BUDGET_MS * 0.75) break;
140
+ }
141
+
142
+ if (!stdouts.length) process.exit(0);
143
+
144
+ /** Merge N bodies' output into ONE value. Envelopes join by context; anything else joins as text. */
145
+ function merge(outs) {
146
+ if (outs.length === 1) return outs[0];
147
+ const parsedAll = outs.map((s) => { try { return JSON.parse(s); } catch { return null; } });
148
+ const contexts = parsedAll.map((p) => p?.hookSpecificOutput?.additionalContext);
149
+ if (parsedAll.every((p) => p) && contexts.every((c) => typeof c === 'string')) {
150
+ const first = parsedAll[0];
151
+ return JSON.stringify({
152
+ ...first,
153
+ hookSpecificOutput: { ...first.hookSpecificOutput, additionalContext: contexts.join('\n') },
154
+ });
155
+ }
156
+ return outs.map((s) => s.trim()).filter(Boolean).join('\n');
157
+ }
158
+
159
+ const stdout = merge(stdouts);
65
160
 
66
161
  let parsed = null;
67
- try { parsed = JSON.parse(stdout); } catch { /* plain text is valid for some Codex events */ }
162
+ try { parsed = JSON.parse(stdout); } catch { /* a shared body may legitimately print prose */ }
68
163
 
69
164
  if (event === 'Stop') {
70
165
  const reason = parsed?.hookSpecificOutput?.additionalContext
71
166
  || parsed?.reason
72
167
  || parsed?.stopReason;
168
+ // `stop.command.output` has no hookSpecificOutput; `decision: "block"` REQUIRES a non-empty
169
+ // `reason` ("Stop hook returned decision:block without a non-empty reason"). No reason ⇒ say
170
+ // nothing at all, which is the allow.
73
171
  if (reason) process.stdout.write(JSON.stringify({ decision: 'block', reason }));
74
172
  process.exit(0);
75
173
  }
76
174
 
77
- if ((event === 'SessionStart' || event === 'UserPromptSubmit') && !parsed) {
78
- process.stdout.write(JSON.stringify({
79
- hookSpecificOutput: {
80
- hookEventName: event,
81
- additionalContext: stdout,
82
- },
83
- }));
175
+ if (!parsed) {
176
+ // Prose from a shared body. It is only deliverable on an event whose schema has somewhere to put
177
+ // it; everywhere else it is dropped rather than emitted as output the host will reject.
178
+ if (CONTEXT_EVENTS.has(event)) {
179
+ process.stdout.write(JSON.stringify({
180
+ hookSpecificOutput: { hookEventName: event, additionalContext: stdout },
181
+ }));
182
+ }
84
183
  process.exit(0);
85
184
  }
86
185
 
87
- if (parsed?.hookSpecificOutput?.permissionDecision === 'defer') {
186
+ // `deny` is the only permissionDecision Codex accepts; `allow`, `ask` and the shared bodies' own
187
+ // `defer` are all rejected by name. Strip, then drop an envelope that has nothing left to say.
188
+ const decision = parsed?.hookSpecificOutput?.permissionDecision;
189
+ if (decision && decision !== 'deny') {
88
190
  delete parsed.hookSpecificOutput.permissionDecision;
89
191
  if (Object.keys(parsed.hookSpecificOutput).length === 1 && parsed.hookSpecificOutput.hookEventName) {
90
192
  delete parsed.hookSpecificOutput;