ruvnet-brain 4.0.35 → 4.0.90-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 (61) hide show
  1. package/README.md +4 -4
  2. package/bin/install.mjs +283 -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 +0 -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 +252 -0
  43. package/scripts/brain-stamp.mjs +5 -1
  44. package/scripts/build-bundle.mjs +25 -1
  45. package/scripts/console-engine.mjs +1 -1
  46. package/scripts/health-repair.mjs +11 -2
  47. package/scripts/host-install-matrix.mjs +21 -0
  48. package/scripts/ingest-repo.mjs +66 -6
  49. package/scripts/learning-replay-cli.mjs +8 -3
  50. package/scripts/learning-replay-fixture.mjs +25 -6
  51. package/scripts/learning-replay-proof.mjs +38 -0
  52. package/scripts/nightly-wrapper.sh +13 -0
  53. package/scripts/onboarding-console.mjs +21 -1
  54. package/scripts/org-repo-count.mjs +119 -0
  55. package/scripts/publication-receipt.mjs +7 -3
  56. package/scripts/repo-count-detector.mjs +62 -0
  57. package/scripts/restore-local-ingests.mjs +116 -0
  58. package/scripts/selfcheck.mjs +9 -1
  59. package/scripts/stabilization-receipt.mjs +11 -1
  60. package/scripts/sync-census.mjs +0 -0
  61. package/scripts/sync-commands.mjs +117 -0
@@ -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;
@@ -2,13 +2,18 @@
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { spawnSync } from 'node:child_process';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
6
 
7
7
  const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
8
8
  const brainHome = process.env.RUVNET_BRAIN_HOME
9
9
  || path.join(path.dirname(codexHome), '.cache', 'ruvnet-brain');
10
10
  const versions = path.join(brainHome, 'versions');
11
11
  const blockingHooks = new Set([
12
+ // decision-gate composes every refusal policy into ONE verdict and speaks the same exit-2 +
13
+ // stderr contract Codex documents ("PreToolUse hook exited with code 2 but did not write a
14
+ // blocking reason to stderr"). Absent from this set it would be wired and toothless: the exit 2
15
+ // would be swallowed here and every refusal would silently become an allow.
16
+ 'decision-gate',
12
17
  'route-dispatch',
13
18
  'hijack-ruvnet', // ADR-063: opt-in managed-memory refusal; exits 0 at the default
14
19
  'ground-before-write',
@@ -17,10 +22,38 @@ const blockingHooks = new Set([
17
22
  'unprompted-speech',
18
23
  ]);
19
24
 
25
+ /**
26
+ * HOOKS WHOSE WORK OUTLIVES ANY BUDGET THE HOST WILL GRANT, so waiting for them is not an option.
27
+ *
28
+ * MEASURED 2026-08-14 on a live Codex 0.147.0 session — the host prints it itself:
29
+ *
30
+ * warning: clamping SessionEnd hook timeout to 3s in .../hooks.json
31
+ *
32
+ * SessionEnd is HARD-CAPPED at 3 seconds and no registration can ask for more. learn-flush needs
33
+ * 18s: measured with a real 10-entry queue and the real `ruflo`, 18342 / 18347 / 18384 ms, because
34
+ * one `ruflo hooks` call alone costs seconds and it self-bounds at LEARN_FLUSH_DEADLINE_MS.
35
+ *
36
+ * So the arithmetic never closes, and what shipped was the worst version of that: this wrapper
37
+ * budgeted 2250ms, SIGKILLed the flush, and exited 0 with `status === null` — measured end to end,
38
+ * exit 0, 2760ms, ZERO bytes of stdout, ZERO bytes of stderr, and a 10-line queue still 10 lines
39
+ * afterwards. Codex lessons never flushed, on every session, and nothing on any surface said so.
40
+ *
41
+ * The host caps how long it will WAIT, not how long work may take. Detaching honours the cap
42
+ * exactly — the hook returns immediately — while the flush runs to its own deadline and does its
43
+ * own write-back. Raising the number instead would have been a fiction the host silently clamps.
44
+ */
45
+ const DETACHED_HOOKS = new Set(['learn-flush']);
46
+
47
+ /** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
20
48
  function timeoutFor(hookId) {
21
49
  const override = Number(process.env.RUVNET_CODEX_HOOK_TIMEOUT_MS);
22
50
  if (Number.isFinite(override) && override > 0) return override;
23
- if (hookId === 'learn-flush') return 2_250;
51
+ // decision-gate's own internal budget is 4000ms (RUVNET_DECISION_BUDGET_MS) and it is allowed to
52
+ // spend all of it: measured 986–4015ms across ten runs in a project this plugin does not own. A
53
+ // budget at or below the gate's own would make the gate's slowest legitimate refusal indis-
54
+ // tinguishable from a crash; 6000ms covers the gate's cap plus this chain's spawn overhead
55
+ // (measured 773–1145ms end-to-end warm, so ~150–400ms of that is the wrapper/adapter/shim).
56
+ if (hookId === 'decision-gate') return 6_000;
24
57
  if (hookId === 'ground-ruvnet' || hookId === 'unprompted-speech' || hookId === 'continuation-gate') {
25
58
  return 8_500;
26
59
  }
@@ -119,11 +152,35 @@ const adapter = root && path.join(root, 'scripts', 'codex-hook-adapter.mjs');
119
152
  if (!adapter || !fs.existsSync(adapter)) process.exit(0);
120
153
 
121
154
  const hookId = process.argv[2] || '';
155
+
156
+ if (DETACHED_HOOKS.has(hookId)) {
157
+ // stdio ignored on purpose: a detached child outlives this process, so anything it wrote would
158
+ // arrive at a host that has already moved on — and stderr from a hook is what a host renders as a
159
+ // hook error. It reports through its own channels or not at all.
160
+ const child = spawn(process.execPath, [adapter, ...process.argv.slice(2)], {
161
+ detached: true, stdio: ['pipe', 'ignore', 'ignore'], env: process.env,
162
+ });
163
+ child.unref();
164
+ child.stdin.on('error', () => { /* a dead optional child must never surface to the host */ });
165
+ // AWAIT THE FLUSH, don't assume it. `process.exit()` on the line after `end()` truncates the pipe
166
+ // and the detached child reads an empty payload — which is the same silent no-op this whole change
167
+ // exists to remove, reintroduced one layer down. Bounded so a stuck pipe cannot hold the host.
168
+ await new Promise((resolve) => {
169
+ try { child.stdin.end(input, resolve); } catch { resolve(); }
170
+ setTimeout(resolve, 250);
171
+ });
172
+ process.exit(0);
173
+ }
174
+
175
+ const budgetMs = timeoutFor(hookId);
122
176
  const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
123
177
  input,
124
178
  encoding: 'utf8',
125
- env: process.env,
126
- timeout: timeoutFor(hookId),
179
+ // The adapter may fan one multi-file patch out into several body runs, and only this process knows
180
+ // when the axe falls. Hand the budget down so it can stop and fail open instead of being killed
181
+ // mid-loop with nothing written — a SIGKILL here is invisible to the host and to the user.
182
+ env: { ...process.env, RUVNET_CODEX_BUDGET_MS: String(budgetMs) },
183
+ timeout: budgetMs,
127
184
  killSignal: 'SIGKILL',
128
185
  });
129
186