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.
- package/README.md +28 -5
- package/bin/install.mjs +405 -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 +231 -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 +261 -0
- package/scripts/brain-stamp.mjs +5 -1
- package/scripts/build-bundle.mjs +38 -4
- package/scripts/card-from-source.mjs +114 -0
- package/scripts/console-engine.mjs +1 -1
- package/scripts/corpus-candidate.mjs +294 -0
- package/scripts/corpus-reconcile.mjs +273 -0
- package/scripts/corpus-seed-publish.mjs +110 -0
- package/scripts/health-repair.mjs +11 -2
- package/scripts/ingest-new-repos.mjs +122 -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 +55 -7
- package/scripts/onboarding-console.mjs +21 -1
- package/scripts/org-repo-count.mjs +119 -0
- package/scripts/rebuild-gists-from-receipts.mjs +246 -0
- package/scripts/release-transaction.mjs +30 -2
- package/scripts/release.mjs +147 -0
- package/scripts/repo-count-detector.mjs +62 -0
- package/scripts/restore-local-ingests.mjs +116 -0
- package/scripts/rvf-generation.mjs +44 -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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_note": "OUT-OF-SHIM HOOK CONTRACTS (ADR-055
|
|
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
|
|
14
|
-
"failureBehavior": "fail-open
|
|
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": [
|
|
19
|
+
"stateRead": [
|
|
20
|
+
"git HEAD",
|
|
21
|
+
"plugin/.claude-plugin/plugin.json"
|
|
22
|
+
],
|
|
20
23
|
"stateWritten": [],
|
|
21
|
-
"dependencies": [
|
|
24
|
+
"dependencies": [
|
|
25
|
+
"bash",
|
|
26
|
+
"git"
|
|
27
|
+
],
|
|
22
28
|
"reachesStrangers": false,
|
|
23
|
-
"owner": "ruvnet-brain (this repo only
|
|
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
|
|
42
|
-
"retiredBy": "ADR-055 build item 3 (execution-guard.mjs
|
|
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",
|
package/plugin/hooks/hooks.json
CHANGED
|
@@ -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
|
|
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|
|
|
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\"
|
|
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": "^
|
|
72
|
+
"matcher": "^Bash$",
|
|
73
73
|
"hooks": [
|
|
74
74
|
{
|
|
75
75
|
"type": "command",
|
|
76
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\"
|
|
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": "^(
|
|
82
|
+
"matcher": "^(Task|Agent)$",
|
|
83
83
|
"hooks": [
|
|
84
84
|
{
|
|
85
85
|
"type": "command",
|
|
86
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\"
|
|
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": [
|
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;
|