ruvnet-brain 4.5.2 β 4.5.4
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 +2 -2
- package/bin/install.mjs +9 -4
- package/kb/lifecycle-evidence-retention.mjs +56 -2
- package/kb/verify-citation.mjs +48 -27
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +40 -3
- package/plugin/hooks/hook-contracts.json +218 -19
- package/plugin/hooks/hooks.json +51 -2
- package/plugin/scripts/agentdb-recall.mjs +297 -0
- package/plugin/scripts/continuity-hook-policy.mjs +9 -0
- package/plugin/scripts/continuity-journal.mjs +51 -52
- package/plugin/scripts/ground-ruvnet.sh +17 -3
- package/plugin/scripts/hook-shim.mjs +4 -30
- package/plugin/scripts/project-capture-queue.mjs +333 -0
- package/plugin/scripts/project-progression-contract.mjs +1 -1
- package/plugin/scripts/project-progression-hook.mjs +3 -3
- package/plugin/scripts/project-progression-outbox.mjs +19 -4
- package/plugin/scripts/project-progression-producer.mjs +53 -28
- package/plugin/scripts/project-progression-session-start.mjs +44 -4
- package/plugin/scripts/project-progression-store.mjs +14 -0
- package/plugin/scripts/project-store-resolver.mjs +16 -7
- package/plugin/scripts/project-transition-hook.mjs +204 -0
- package/plugin/scripts/session-snapshot-hook.mjs +46 -274
- package/plugin/scripts/session-start-budget.mjs +2 -2
- package/plugin/scripts/session-start-core.mjs +5 -0
- package/plugin/scripts/turn-outcome-capture.mjs +246 -70
- package/plugin/scripts/turn-transport-journal.mjs +106 -0
- package/scripts/learning-replay-cli.mjs +15 -0
- package/scripts/learning-replay.mjs +1 -1
- package/scripts/nightly-wrapper.sh +5 -5
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +10 -1
- package/scripts/wired-check.mjs +1 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
|
|
|
7
7
|
|
|
8
8
|
# π§ RuvNet Brain
|
|
9
9
|
|
|
10
|
-
### π§ RuvNet Brain β [](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
|
|
11
11
|
|
|
12
12
|
**A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack β delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
|
|
13
13
|
|
|
@@ -562,7 +562,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
|
|
|
562
562
|
|
|
563
563
|
This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β we don't claim βdone,β βcomplete,β or βzero hallucinations.β Where it stands:
|
|
564
564
|
|
|
565
|
-
- β
**The grounding brain is real and proven** β 199 public stores Β·
|
|
565
|
+
- β
**The grounding brain is real and proven** β 199 public stores Β· 162,439 public source chunks, dual embeddings, cross-encoder rerank, plugin (MCP tool + explicit skills; automatic hooks retired), all re-runnable.
|
|
566
566
|
- β
**Code-level depth** β the code-rich repos are indexed to full function bodies; βhow is it implemented?β returns the implementation. Verified in the shipped bundle (clean-room 3/3).
|
|
567
567
|
- β
**Routing holds** β named 47/48, described 26/28, scenario 7/8; behavioral L1βL3 all pass (**L4 downgraded β it measures that the brain spoke, not that anything listened**); private stores fenced out of the public bundle (zero-leak verified).
|
|
568
568
|
- β οΈ **Two routing residuals** (above) β surfaced, not hidden.
|
package/bin/install.mjs
CHANGED
|
@@ -2443,11 +2443,11 @@ export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'sourc
|
|
|
2443
2443
|
if (pairs.length !== expectedPairs.length || expectedPairs.some((pair) => !pairs.includes(pair))) {
|
|
2444
2444
|
errors.push(`${contractsFile}: contracts must list exactly the continuity handlers (${expectedPairs.join(', ')})`);
|
|
2445
2445
|
}
|
|
2446
|
-
//
|
|
2447
|
-
|
|
2448
|
-
|
|
2446
|
+
// Matchers belong to a host registry: one host's exception grants no authority to another.
|
|
2447
|
+
const expectedMatchers = [['plugin', 'claude'], ['codex', 'codex']].flatMap(([layer, host]) =>
|
|
2448
|
+
[...new Set(continuityRegistrations(host).map((spec) => `${layer}:${spec.event}:${spec.matcher}`))]);
|
|
2449
2449
|
const declaredMatchers = Array.isArray(contracts.matcherAllowlist)
|
|
2450
|
-
? contracts.matcherAllowlist.map((row) => `${row?.event}:${row?.matcher}`) : [];
|
|
2450
|
+
? contracts.matcherAllowlist.map((row) => `${row?.layer}:${row?.event}:${row?.matcher}`) : [];
|
|
2451
2451
|
if (declaredMatchers.length !== expectedMatchers.length
|
|
2452
2452
|
|| expectedMatchers.some((entry) => !declaredMatchers.includes(entry))) {
|
|
2453
2453
|
errors.push(`${contractsFile}: matcherAllowlist must list exactly the continuity matchers (${expectedMatchers.join(', ')})`);
|
|
@@ -3214,6 +3214,10 @@ async function doctorRun({ json }) {
|
|
|
3214
3214
|
agentdbLine = { id: 'agentdb', label: 'AgentDB', state: 'unknown', detail: `recording status unavailable: ${error.message}`, fix: null };
|
|
3215
3215
|
}
|
|
3216
3216
|
|
|
3217
|
+
const { turnRecordingStatus } = await import('../plugin/scripts/turn-outcome-capture.mjs');
|
|
3218
|
+
const turnStatus = turnRecordingStatus({ projectDir: process.cwd() });
|
|
3219
|
+
const turnLine = { id: 'turn-recording', label: 'Turn recording', state: turnStatus.state, detail: turnStatus.line, fix: null };
|
|
3220
|
+
|
|
3217
3221
|
// ββ THE MECHANICAL VERDICT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
3218
3222
|
// `--hooks` is retained as a compatibility alias for a read-only zero-registration proof. It must
|
|
3219
3223
|
// never execute dormant hook bodies.
|
|
@@ -3293,6 +3297,7 @@ async function doctorRun({ json }) {
|
|
|
3293
3297
|
check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
|
|
3294
3298
|
...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
|
|
3295
3299
|
...(agentdbLine ? [agentdbLine] : []),
|
|
3300
|
+
turnLine,
|
|
3296
3301
|
];
|
|
3297
3302
|
// THE ONE VERDICT. Text, --json and the exit code are all read from this object; nothing else decides.
|
|
3298
3303
|
const verdict = doctorVerdict(confirmation, checks);
|
|
@@ -27,7 +27,10 @@ function rootsFor({ brainHome, kbDir }) {
|
|
|
27
27
|
function regularJson(file) {
|
|
28
28
|
const stat = fs.lstatSync(file);
|
|
29
29
|
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a trusted regular file');
|
|
30
|
-
|
|
30
|
+
const bytes = fs.readFileSync(file);
|
|
31
|
+
const text = bytes.toString('utf8');
|
|
32
|
+
if (!Buffer.from(text, 'utf8').equals(bytes)) throw new Error('evidence is not valid UTF-8');
|
|
33
|
+
return { value: JSON.parse(text), bytes: stat.size };
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
function treeBytes(root) {
|
|
@@ -88,6 +91,11 @@ function scanTransactions(root, unsafe) {
|
|
|
88
91
|
if (!names.length || names.some((file) => !PHASE_FILE.test(file))) throw new Error('transaction phase inventory is malformed');
|
|
89
92
|
const phases = names.map((file) => regularJson(path.join(dir, file)).value);
|
|
90
93
|
const latest = phases.at(-1);
|
|
94
|
+
if (phases.some((phase, index) => phase?.schemaVersion !== 1
|
|
95
|
+
|| phase.kind !== 'ruvnet-brain-storage-transaction-phase' || phase.transactionId !== name
|
|
96
|
+
|| phase.sequence !== index + 1 || names[index] !== `${String(index + 1).padStart(3, '0')}-${phase.state}.json`)) {
|
|
97
|
+
throw new Error('transaction phase identity is malformed');
|
|
98
|
+
}
|
|
91
99
|
if (latest?.kind !== 'ruvnet-brain-storage-transaction-phase' || latest.transactionId !== name
|
|
92
100
|
|| latest.sequence !== names.length || typeof latest.state !== 'string') {
|
|
93
101
|
throw new Error('transaction phase identity is malformed');
|
|
@@ -210,6 +218,45 @@ function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
|
|
|
210
218
|
removeEntry(quarantine);
|
|
211
219
|
}
|
|
212
220
|
|
|
221
|
+
// Keep every JSON token (including numeric precision and escaped string spelling).
|
|
222
|
+
// Only formatting whitespace is redundant; phase files remain readable by recovery.
|
|
223
|
+
function compactTerminalPhases(row) {
|
|
224
|
+
const compacted = [];
|
|
225
|
+
for (const name of fs.readdirSync(row.path).sort()) {
|
|
226
|
+
const file = path.join(row.path, name);
|
|
227
|
+
const stat = fs.lstatSync(file);
|
|
228
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('phase is not an exclusive regular file');
|
|
229
|
+
const originalBytes = fs.readFileSync(file);
|
|
230
|
+
const original = originalBytes.toString('utf8');
|
|
231
|
+
if (!Buffer.from(original, 'utf8').equals(originalBytes)) throw new Error('phase is not valid UTF-8');
|
|
232
|
+
const compact = original.replace(/("(?:\\[\s\S]|[^"\\])*")|[ \t\r\n]+/g, (token, string) => string || '');
|
|
233
|
+
if (Buffer.byteLength(compact) >= stat.size) continue;
|
|
234
|
+
if (JSON.stringify(JSON.parse(original)) !== JSON.stringify(JSON.parse(compact))) throw new Error('phase compaction changed evidence');
|
|
235
|
+
const temporary = `${file}.compact-${crypto.randomBytes(8).toString('hex')}`;
|
|
236
|
+
let descriptor;
|
|
237
|
+
try {
|
|
238
|
+
descriptor = fs.openSync(temporary, 'wx', stat.mode & 0o777);
|
|
239
|
+
const replacementStat = fs.fstatSync(descriptor);
|
|
240
|
+
if (replacementStat.uid !== stat.uid || replacementStat.gid !== stat.gid) fs.fchownSync(descriptor, stat.uid, stat.gid);
|
|
241
|
+
fs.fchmodSync(descriptor, stat.mode & 0o777);
|
|
242
|
+
fs.writeFileSync(descriptor, compact);
|
|
243
|
+
fs.futimesSync(descriptor, stat.atimeMs / 1000, stat.mtimeMs / 1000);
|
|
244
|
+
fs.fsyncSync(descriptor);
|
|
245
|
+
fs.closeSync(descriptor); descriptor = undefined;
|
|
246
|
+
const current = fs.lstatSync(file);
|
|
247
|
+
if (!current.isFile() || current.isSymbolicLink() || current.ino !== stat.ino || current.dev !== stat.dev
|
|
248
|
+
|| current.mode !== stat.mode || current.uid !== stat.uid || current.gid !== stat.gid || current.nlink !== 1
|
|
249
|
+
|| !fs.readFileSync(file).equals(originalBytes)) throw new Error('phase changed during compaction');
|
|
250
|
+
fs.renameSync(temporary, file);
|
|
251
|
+
compacted.push(file);
|
|
252
|
+
} finally {
|
|
253
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
254
|
+
fs.rmSync(temporary, { force: true });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return compacted;
|
|
258
|
+
}
|
|
259
|
+
|
|
213
260
|
export function pruneLifecycleEvidence(options = {}) {
|
|
214
261
|
const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
|
|
215
262
|
const removeEntry = options.removeEntry || ((entry) => fs.rmSync(entry, { recursive: true, force: true }));
|
|
@@ -218,6 +265,13 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
218
265
|
// the exact entry it just quarantined. Recovery must not guess away private data.
|
|
219
266
|
const initial = assessLifecycleEvidence({ ...options, policy });
|
|
220
267
|
const removed = { refresh: [], transactions: [] };
|
|
268
|
+
const compacted = [];
|
|
269
|
+
const compactState = snapshot(options);
|
|
270
|
+
if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes) {
|
|
271
|
+
for (const row of compactState.transactions.filter((entry) => TERMINAL_TRANSACTION.has(entry.latest.state))) {
|
|
272
|
+
compacted.push(...compactTerminalPhases(row));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
221
275
|
for (;;) {
|
|
222
276
|
const state = snapshot(options);
|
|
223
277
|
if (isWithin(state, policy)) break;
|
|
@@ -235,6 +289,6 @@ export function pruneLifecycleEvidence(options = {}) {
|
|
|
235
289
|
removed[candidate.kind === 'refresh' ? 'refresh' : 'transactions'].push(candidate.path);
|
|
236
290
|
}
|
|
237
291
|
const final = assessLifecycleEvidence({ ...options, policy });
|
|
238
|
-
return { ...final, before: initial.before, removed,
|
|
292
|
+
return { ...final, before: initial.before, removed, compacted,
|
|
239
293
|
after: final.after, withinBudget: final.withinBudget };
|
|
240
294
|
}
|
package/kb/verify-citation.mjs
CHANGED
|
@@ -27,40 +27,63 @@ import readline from 'node:readline';
|
|
|
27
27
|
/**
|
|
28
28
|
* Parse the reader's stdout into structured citations. Never throws; unparseable input β [].
|
|
29
29
|
*
|
|
30
|
-
* The reader dumps each hit's full document body
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* an open item, not solved here); it closes the realistic case this repo's own docs demonstrate.
|
|
30
|
+
* STRUCTURE, NOT TEXT PATTERNS (ADR-0102 G-004, #236). The reader dumps each hit's full document body
|
|
31
|
+
* inline, and a retrieved document can contain text shaped exactly like a hit header β including a forged
|
|
32
|
+
* "#N+1 repo=β¦" block that predicts the next rank, which the old next-header/next-rank guards could not
|
|
33
|
+
* stop ("not airtight"). The reader prints, before each body, `chars: <exact body length>`; the parser
|
|
34
|
+
* consumes the body BY THAT COUNT and requires the 67-'=' terminator right after it, so nothing inside a
|
|
35
|
+
* body is ever scanned for headers. A declared length that does not land on the terminator fails closed:
|
|
36
|
+
* that hit keeps no body and nothing after it is trusted; so does a body without `chars:` once an earlier
|
|
37
|
+
* hit carried one. Output with no `chars:` line at all (a reader older than this field) keeps the previous
|
|
38
|
+
* bounded-span parsing; a header with no body keeps none. `path`/`title`/scores come only from the header lines between
|
|
39
|
+
* a hit's header and its body, never from a body, and hits are numbered #1, #2, β¦ strictly in order.
|
|
41
40
|
*/
|
|
41
|
+
const SEPARATOR = '='.repeat(67);
|
|
42
42
|
export function parseCitations(stdout) {
|
|
43
43
|
const out = [];
|
|
44
44
|
const text = String(stdout ?? '');
|
|
45
|
-
const
|
|
45
|
+
const headerRe = /^#(\d+)[ \t]+repo=(\S+)([^\r\n]*)/gm;
|
|
46
46
|
const nextHeaderRe = /^#\d+\s+repo=\S+/gm;
|
|
47
|
+
const markerRe = /^----- full document -----\r?\n/gm;
|
|
47
48
|
let m;
|
|
48
49
|
let expectedRank = 1;
|
|
49
|
-
|
|
50
|
+
let structured = false;
|
|
51
|
+
while ((m = headerRe.exec(text)) !== null) {
|
|
50
52
|
const rank = Number(m[1]);
|
|
51
53
|
if (rank !== expectedRank) continue; // out-of-sequence header: a look-alike, not a real hit
|
|
52
|
-
const
|
|
53
|
-
nextHeaderRe.lastIndex =
|
|
54
|
+
const headStart = m.index + m[0].length;
|
|
55
|
+
nextHeaderRe.lastIndex = headStart;
|
|
54
56
|
const next = nextHeaderRe.exec(text);
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
57
|
+
const nextAt = next ? next.index : text.length;
|
|
58
|
+
markerRe.lastIndex = headStart;
|
|
59
|
+
const marker = markerRe.exec(text);
|
|
60
|
+
const markerAt = marker && marker.index < nextAt ? marker.index : -1;
|
|
61
|
+
const head = text.slice(headStart, markerAt >= 0 ? markerAt : nextAt);
|
|
62
|
+
const pathM = /^path\s*:\s*(.+)$/m.exec(head);
|
|
63
|
+
const titleM = /^title\s*:\s*(.+)$/m.exec(head);
|
|
64
|
+
const charsM = /^chars:\s*(\d+)\b/m.exec(head);
|
|
65
|
+
// A pathless match does not fill (or burn) its rank: real reader output never omits path, and a
|
|
66
|
+
// look-alike fragment consuming the slot would reject the real citation that fills it later.
|
|
63
67
|
if (!pathM) continue;
|
|
68
|
+
let returnedText = null;
|
|
69
|
+
let stop = false;
|
|
70
|
+
if (charsM && markerAt >= 0) {
|
|
71
|
+
const bodyStart = markerAt + marker[0].length;
|
|
72
|
+
const bodyEnd = bodyStart + Number(charsM[1]);
|
|
73
|
+
const terminator = text.startsWith(`\n${SEPARATOR}`, bodyEnd) ? 1 + SEPARATOR.length
|
|
74
|
+
: text.startsWith(`\r\n${SEPARATOR}`, bodyEnd) ? 2 + SEPARATOR.length : 0;
|
|
75
|
+
if (terminator) {
|
|
76
|
+
returnedText = text.slice(bodyStart, bodyEnd);
|
|
77
|
+
structured = true;
|
|
78
|
+
headerRe.lastIndex = bodyEnd + terminator; // resume AFTER the body: its contents are never parsed
|
|
79
|
+
} else stop = true; // the declared body does not end where it says: its boundary, and all after it, is unknown
|
|
80
|
+
} else if (markerAt >= 0 && structured) {
|
|
81
|
+
stop = true; // a body with no declared length after length-bound hits: its boundary is unknown
|
|
82
|
+
} else if (markerAt >= 0) {
|
|
83
|
+
// A reader older than `chars:`: the body is the bounded span up to the next header.
|
|
84
|
+
const body = /^----- full document -----\r?\n([\s\S]*?)\r?\n={67}(?:\r?\n|$)/m.exec(text.slice(headStart, nextAt));
|
|
85
|
+
returnedText = body ? body[1] : null;
|
|
86
|
+
}
|
|
64
87
|
expectedRank = rank + 1;
|
|
65
88
|
const repo = m[2];
|
|
66
89
|
// Metadata comes only from the header, never from a retrieved document body.
|
|
@@ -73,9 +96,6 @@ export function parseCitations(stdout) {
|
|
|
73
96
|
const fullPath = pathM[1].trim();
|
|
74
97
|
// Strip the repo prefix the reader adds, so the remainder can be matched against the store.
|
|
75
98
|
const docPath = fullPath.startsWith(`${repo}/`) ? fullPath.slice(repo.length + 1) : fullPath;
|
|
76
|
-
// Retain only the reader's bounded document body. Missing/truncated delimiters fail
|
|
77
|
-
// closed, so evaluators cannot borrow claims from headers, diagnostics, or later hits.
|
|
78
|
-
const body = /^----- full document -----\r?\n([\s\S]*?)\r?\n={67}(?:\r?\n|$)/m.exec(block);
|
|
79
99
|
out.push({
|
|
80
100
|
rank,
|
|
81
101
|
repo,
|
|
@@ -83,11 +103,12 @@ export function parseCitations(stdout) {
|
|
|
83
103
|
vec: score('vec'),
|
|
84
104
|
kind: field('kind'),
|
|
85
105
|
proofMethod: field('proof'),
|
|
86
|
-
returnedText
|
|
106
|
+
returnedText,
|
|
87
107
|
fullPath,
|
|
88
108
|
docPath,
|
|
89
109
|
title: titleM ? titleM[1].trim() : null,
|
|
90
110
|
});
|
|
111
|
+
if (stop) break;
|
|
91
112
|
}
|
|
92
113
|
return out;
|
|
93
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.5.
|
|
3
|
+
"version": "4.5.4",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain \u2014 a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
3
|
"description": "RuvNet brain transplant for Claude Code β grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
|
|
4
|
-
"version": "4.5.
|
|
4
|
+
"version": "4.5.4",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "RuvNet Brain continuity plane for Codex. Broad legacy automatic gates remain retired. Registrations here are MEASURED, not assumed: a probe hook was registered on all twelve event names the installed codex-cli 0.154.0 binary declares and a real `codex exec` run was observed (2026-09-11). SessionStart, UserPromptSubmit and SessionEnd FIRED and carry handlers. Stop keeps the pre-existing, project-scoped continuation gate; session-snapshot was added at Stop on 2026-09-29 to record each turn's outcome (turn-outcome-capture.mjs)
|
|
2
|
+
"description": "RuvNet Brain continuity plane for Codex. Broad legacy automatic gates remain retired. Registrations here are MEASURED, not assumed: a probe hook was registered on all twelve event names the installed codex-cli 0.154.0 binary declares and a real `codex exec` run was observed (2026-09-11). SessionStart, UserPromptSubmit and SessionEnd FIRED and carry handlers. Stop keeps the pre-existing, project-scoped continuation gate; session-snapshot was added at Stop on 2026-09-29 to record each turn's outcome (turn-outcome-capture.mjs) \u2014 Codex SessionEnd carries no last_assistant_message, so Stop is the only boundary that can. Its delivery rests on codex-cli 0.158.0's declared stop.command.input schema and the other Codex Stop handlers; it has NOT been live-observed and fails open. PreCompact is DECLARED ABSENT here: the binary declares the event but the probe never observed it, and a capture registered on an unobserved event would look symmetrical while capturing nothing. All handlers are bounded and fail open. Amended 2026-09-11: UserPromptSubmit also runs ground-ruvnet (grounding injection, ADR-040 \u00a7Amendment). Amended 2026-09-12: the 2026-09-11 PreToolUse/PostToolUse \"not observed\" note was measured with a prompt (`codex exec \"reply OK\"`) that never invoked a tool, so neither event had anything to fire on \u2014 that was an untested path, not a failing one. Re-probed with prompts that actually call a tool: a real apply_patch write fired PreToolUse/PostToolUse with tool_name \"apply_patch\", and a real MCP call to this repo's own search_ruvnet server fired both with tool_name \"mcp__ruvnet_brain__search_ruvnet\". Both are now registered below: decision-gate's write route (matcher includes apply_patch, Codex's raw write-tool name) and grounding-stamp (unchanged matcher already recognizes the mcp__..__search_ruvnet shape). The bash route (exec_command) remains unregistered \u2014 today's measurement covered a write and an MCP call, not exec_command, and extending on that evidence would be the same unproven leap this note replaces. Also added 2026-09-12: grounding-turn-mark (UserPromptSubmit) and grounding-turn-gate (Stop), the \"answered without searching\" pair \u2014 a prompt-level grounding directive is advisory, so this records whether it fired and forces continuation at Stop if no search_ruvnet call was recorded since (reusing grounding-stamp's own stamp evidence). Both are registered on Codex identically to Claude: the Stop-block contract (hookSpecificOutput.additionalContext) already has a proven Codex translation via codex-hook-adapter.mjs's Stop branch (see tests/unit/codex-lifecycle-hooks.test.mjs), the same path continuation-gate already uses. Capacity-aware parallel-work guidance is also registered at UserPromptSubmit: it advises the coordinator to launch only real independent workers within sampled resource headroom and the live runtime/tool cap; it never spawns workers or claims execution. Automatic normalized project observations are captured at prompt, pre-tool, post-tool and child completion; Claude also observes PostToolUseFailure. Pending and degraded memory are disclosed; this does not establish synchronous ADR-073 conformance or native Grok recall. SessionStart has an 8-second measured replay/restore envelope.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\"
|
|
11
|
-
"timeout":
|
|
10
|
+
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 7500 session-start",
|
|
11
|
+
"timeout": 8
|
|
12
12
|
}
|
|
13
13
|
]
|
|
14
14
|
}
|
|
@@ -36,6 +36,16 @@
|
|
|
36
36
|
}
|
|
37
37
|
],
|
|
38
38
|
"PreToolUse": [
|
|
39
|
+
{
|
|
40
|
+
"matcher": "*",
|
|
41
|
+
"hooks": [
|
|
42
|
+
{
|
|
43
|
+
"type": "command",
|
|
44
|
+
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot PreToolUse",
|
|
45
|
+
"timeout": 10
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
},
|
|
39
49
|
{
|
|
40
50
|
"matcher": "^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$",
|
|
41
51
|
"hooks": [
|
|
@@ -48,6 +58,16 @@
|
|
|
48
58
|
}
|
|
49
59
|
],
|
|
50
60
|
"PostToolUse": [
|
|
61
|
+
{
|
|
62
|
+
"matcher": "*",
|
|
63
|
+
"hooks": [
|
|
64
|
+
{
|
|
65
|
+
"type": "command",
|
|
66
|
+
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot PostToolUse",
|
|
67
|
+
"timeout": 10
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
},
|
|
51
71
|
{
|
|
52
72
|
"matcher": "^(?:.*__)?search_ruvnet$",
|
|
53
73
|
"hooks": [
|
|
@@ -63,6 +83,11 @@
|
|
|
63
83
|
{
|
|
64
84
|
"matcher": "*",
|
|
65
85
|
"hooks": [
|
|
86
|
+
{
|
|
87
|
+
"type": "command",
|
|
88
|
+
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot UserPromptSubmit",
|
|
89
|
+
"timeout": 10
|
|
90
|
+
},
|
|
66
91
|
{
|
|
67
92
|
"type": "command",
|
|
68
93
|
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 unprompted-speech UserPromptSubmit",
|
|
@@ -97,6 +122,18 @@
|
|
|
97
122
|
}
|
|
98
123
|
]
|
|
99
124
|
}
|
|
125
|
+
],
|
|
126
|
+
"SubagentStop": [
|
|
127
|
+
{
|
|
128
|
+
"matcher": "*",
|
|
129
|
+
"hooks": [
|
|
130
|
+
{
|
|
131
|
+
"type": "command",
|
|
132
|
+
"command": "node -e \"const f=require('node:fs'),o=require('node:os'),p=require('node:path'),c=require('node:child_process'),d=process.env.CODEX_HOME||p.join(o.homedir(),'.codex'),b=process.env.RUVNET_BRAIN_HOME||p.join(p.dirname(d),'.cache','ruvnet-brain'),w=p.join(b,'codex-hook.mjs');let s;try{s=f.statSync(w)}catch{}if(!s?.isFile())process.exit(0);const r=c.spawnSync(process.execPath,[w,...process.argv.slice(2)],{stdio:['inherit','pipe','pipe'],encoding:'utf8',env:process.env,timeout:Number(process.argv[1]),killSignal:'SIGKILL'});if(r.status===0||r.status===2){if(r.stdout)process.stdout.write(r.stdout);if(r.stderr)process.stderr.write(r.stderr)}process.exit(r.status===2?2:0)\" 9000 session-snapshot SubagentStop",
|
|
133
|
+
"timeout": 10
|
|
134
|
+
}
|
|
135
|
+
]
|
|
136
|
+
}
|
|
100
137
|
]
|
|
101
138
|
}
|
|
102
139
|
}
|