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.
- package/README.md +4 -4
- package/bin/install.mjs +283 -23
- package/data/model-catalog.json +104 -15
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +72 -9
- package/plugin/commands/configure.md +67 -21
- package/plugin/commands/rvcb.md +72 -9
- package/plugin/hooks/codex-hooks.json +40 -33
- package/plugin/hooks/hook-contracts.json +14 -24
- package/plugin/hooks/hooks.json +7 -42
- package/plugin/mcp/server.mjs +23 -6
- package/plugin/scripts/adr-currency-gate.mjs +150 -0
- package/plugin/scripts/capability-registry.mjs +10 -1
- package/plugin/scripts/codex-hook-adapter.mjs +121 -19
- package/plugin/scripts/codex-hook-wrapper.mjs +61 -4
- package/plugin/scripts/continuation-gate.mjs +148 -8
- package/plugin/scripts/decision-gate.mjs +428 -0
- package/plugin/scripts/decision-outcomes.mjs +0 -0
- package/plugin/scripts/degradation-watch.mjs +271 -0
- package/plugin/scripts/ground-ruvnet.sh +51 -11
- package/plugin/scripts/hijack-ruvnet.sh +11 -3
- package/plugin/scripts/hook-registry.mjs +48 -3
- package/plugin/scripts/hook-shim.mjs +58 -3
- package/plugin/scripts/identifier-preflight.mjs +134 -0
- package/plugin/scripts/learn-capture.sh +50 -1
- package/plugin/scripts/learn-flush.mjs +5 -5
- package/plugin/scripts/lesson-bridge.mjs +343 -0
- package/plugin/scripts/lesson-hooks.sh +26 -0
- package/plugin/scripts/lesson-promote.mjs +50 -0
- package/plugin/scripts/lesson-store.mjs +6 -1
- package/plugin/scripts/mcp-readiness.mjs +107 -0
- package/plugin/scripts/protect-brain-state.sh +9 -0
- package/plugin/scripts/runtime-preferences.mjs +40 -0
- package/plugin/scripts/session-snapshot-hook.mjs +15 -6
- package/plugin/scripts/spend-guard.mjs +125 -0
- package/plugin/scripts/unprompted-runtime.mjs +12 -2
- package/plugin/scripts/update-apply.mjs +7 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +20 -5
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -3
- package/scripts/brain-score.mjs +252 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +25 -1
- package/scripts/console-engine.mjs +1 -1
- package/scripts/health-repair.mjs +11 -2
- package/scripts/host-install-matrix.mjs +21 -0
- package/scripts/ingest-repo.mjs +66 -6
- package/scripts/learning-replay-cli.mjs +8 -3
- package/scripts/learning-replay-fixture.mjs +25 -6
- package/scripts/learning-replay-proof.mjs +38 -0
- package/scripts/nightly-wrapper.sh +13 -0
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/publication-receipt.mjs +7 -3
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/selfcheck.mjs +9 -1
- package/scripts/stabilization-receipt.mjs +11 -1
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-commands.mjs +117 -0
package/plugin/mcp/server.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
files = patchFiles(patch);
|
|
28
75
|
input.tool_name = 'Edit';
|
|
29
76
|
input.tool_input = {
|
|
30
77
|
...(input.tool_input || {}),
|
|
31
|
-
...(
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
64
|
-
|
|
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 { /*
|
|
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 (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|