ruvnet-brain 4.3.17 → 4.3.19
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 +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/scripts/doc-currency.mjs +57 -1
- package/scripts/eval-brain.mjs +32 -4
- package/scripts/host-install-matrix.mjs +6 -3
- package/scripts/publication-receipt.mjs +48 -14
- package/scripts/release-vector.mjs +8 -2
- package/scripts/selfcheck.mjs +34 -5
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
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ruvnet-brain",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.19",
|
|
4
4
|
"description": "One-command installer for RuvNet Brain — 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 an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.",
|
|
4
|
-
"version": "4.3.
|
|
4
|
+
"version": "4.3.19",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
package/scripts/doc-currency.mjs
CHANGED
|
@@ -444,6 +444,56 @@ export function listDocs(root, dirs = DEFAULT_DIRS) {
|
|
|
444
444
|
return out;
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
// A changed-scope check must discover its documents before doing the expensive git-derived
|
|
448
|
+
// evaluation. The declarations are intentionally read from the document itself here; resolving
|
|
449
|
+
// every governed blob and caller for every historical document defeats the purpose of --changed.
|
|
450
|
+
// This matcher mirrors the supported declaration shapes (literal paths and the simple * / ** /
|
|
451
|
+
// ? globs used in this repository) and is conservative for directory declarations.
|
|
452
|
+
function declarationIntersectsTouched(declaration, touched) {
|
|
453
|
+
let pattern = String(declaration || '').trim().replace(/^\.\//, '');
|
|
454
|
+
if (!pattern) return false;
|
|
455
|
+
const directory = pattern.endsWith('/');
|
|
456
|
+
pattern = pattern.replace(/\/+$/, '');
|
|
457
|
+
if (!/[*?\[\]]/.test(pattern)) {
|
|
458
|
+
return [...touched].some((file) => file === pattern || (directory && file.startsWith(`${pattern}/`)));
|
|
459
|
+
}
|
|
460
|
+
let source = '^';
|
|
461
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
462
|
+
const char = pattern[i];
|
|
463
|
+
if (char === '*' && pattern[i + 1] === '*') {
|
|
464
|
+
source += '.*';
|
|
465
|
+
i += 1;
|
|
466
|
+
} else if (char === '*') source += '[^/]*';
|
|
467
|
+
else if (char === '?') source += '[^/]';
|
|
468
|
+
else if (char === '[') {
|
|
469
|
+
const end = pattern.indexOf(']', i + 1);
|
|
470
|
+
if (end > i + 1) {
|
|
471
|
+
source += pattern.slice(i, end + 1);
|
|
472
|
+
i = end;
|
|
473
|
+
} else source += '\\[';
|
|
474
|
+
} else source += char.replace(/[\\^$+?.()|{}]/g, '\\$&');
|
|
475
|
+
}
|
|
476
|
+
const matcher = new RegExp(`${source}$`);
|
|
477
|
+
return [...touched].some((file) => matcher.test(file));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Return only documents whose own file or governed declaration intersects a candidate diff.
|
|
482
|
+
* `evaluateDoc` remains the authority for the final derived result; this function only narrows
|
|
483
|
+
* the set of documents that need that work in a `--changed` check.
|
|
484
|
+
*/
|
|
485
|
+
export function changedDocumentCandidates(root, touched, dirs = DEFAULT_DIRS) {
|
|
486
|
+
const changed = touched instanceof Set ? touched : new Set(touched || []);
|
|
487
|
+
return listDocs(root, dirs).filter((rel) => {
|
|
488
|
+
if (changed.has(rel)) return true;
|
|
489
|
+
const text = fs.readFileSync(path.join(root, rel), 'utf8');
|
|
490
|
+
const frontmatter = parseFrontmatter(text);
|
|
491
|
+
const declared = frontmatter.keys.governs;
|
|
492
|
+
const governs = Array.isArray(declared) ? declared : declared ? [declared] : [];
|
|
493
|
+
return governs.some((entry) => declarationIntersectsTouched(entry, changed));
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
447
497
|
export function evaluateDoc(root, rel, opts = {}) {
|
|
448
498
|
const { checkWiring = true } = opts;
|
|
449
499
|
const abs = path.join(root, rel);
|
|
@@ -858,7 +908,13 @@ export function main(argv = process.argv.slice(2)) {
|
|
|
858
908
|
if (!base.ok || !base.out) return 1;
|
|
859
909
|
if (base.ok && base.out) driftFloor = base.out.split('\n')[0];
|
|
860
910
|
}
|
|
861
|
-
|
|
911
|
+
// In check mode, --changed is a candidate gate: narrow the expensive evaluation to documents
|
|
912
|
+
// that the diff can actually invalidate. Reports remain corpus-wide, while the blocking set
|
|
913
|
+
// still follows the same Document -> Governed path relationship as changedDocumentScope().
|
|
914
|
+
const files = touched && a.mode === 'check'
|
|
915
|
+
? changedDocumentCandidates(a.root, touched, dirs)
|
|
916
|
+
: undefined;
|
|
917
|
+
const result = evaluate(a.root, { dirs, checkWiring: !a.noWiring, driftFloor, files });
|
|
862
918
|
|
|
863
919
|
let scope = null;
|
|
864
920
|
if (touched) {
|
package/scripts/eval-brain.mjs
CHANGED
|
@@ -80,10 +80,11 @@ export function wilson(k, n, z = 1.96) {
|
|
|
80
80
|
* answers "does the query reach the store that actually holds the answer" (brain-score.mjs's own
|
|
81
81
|
* wording for this metric) — that store is the one named in `receipt`, when one was verified.
|
|
82
82
|
*/
|
|
83
|
-
export function gradeQuestion(q, { grounded, citations, bannerPresent, receipt }) {
|
|
83
|
+
export function gradeQuestion(q, { grounded, citations, bannerPresent, receipt, repoAliases = {} }) {
|
|
84
84
|
const top = citations?.[0] ?? null;
|
|
85
85
|
const routedRepo = receipt?.repo ?? top?.repo ?? null;
|
|
86
|
-
const routed = !!(grounded && q.expectRepo?.length && routedRepo
|
|
86
|
+
const routed = !!(grounded && q.expectRepo?.length && routedRepo
|
|
87
|
+
&& repoMatchesExpected(routedRepo, q.expectRepo, repoAliases));
|
|
87
88
|
const abstained = !top || (typeof top.ce === 'number' && top.ce < ABSTAIN_CE);
|
|
88
89
|
switch (q.stratum) {
|
|
89
90
|
case 'adversarial':
|
|
@@ -97,6 +98,16 @@ export function gradeQuestion(q, { grounded, citations, bannerPresent, receipt }
|
|
|
97
98
|
}
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
// Ground truth names the logical repository, while the deployed corpus may use a documented
|
|
102
|
+
// store alias (for example agent-harness-generator -> metaharness). Credit only aliases present
|
|
103
|
+
// in the shipped registry; never turn an arbitrary similarly named store into a pass.
|
|
104
|
+
export function repoMatchesExpected(actual, expected, aliases = {}) {
|
|
105
|
+
if (!actual || !Array.isArray(expected)) return false;
|
|
106
|
+
if (expected.includes(actual)) return true;
|
|
107
|
+
return expected.some((canonical) => Array.isArray(aliases?.[canonical])
|
|
108
|
+
&& aliases[canonical].includes(actual));
|
|
109
|
+
}
|
|
110
|
+
|
|
100
111
|
/** Aggregate graded rows into the four gated metrics, each with its Wilson interval. */
|
|
101
112
|
export function aggregate(rows) {
|
|
102
113
|
const by = (pred) => rows.filter(pred);
|
|
@@ -145,6 +156,11 @@ async function main() {
|
|
|
145
156
|
const die = (msg) => { console.error(`eval-brain: ${msg}`); process.exit(2); };
|
|
146
157
|
|
|
147
158
|
if (!fs.existsSync(path.join(KB, 'forge-ask-all.mjs'))) die(`no brain at ${KB} — run: npx ruvnet-brain`);
|
|
159
|
+
let repoAliases = {};
|
|
160
|
+
try {
|
|
161
|
+
const aliasesFile = path.join(KB, 'repo-aliases.json');
|
|
162
|
+
if (fs.existsSync(aliasesFile)) repoAliases = JSON.parse(fs.readFileSync(aliasesFile, 'utf8'));
|
|
163
|
+
} catch { /* a legacy bundle simply has no alias normalization to apply */ }
|
|
148
164
|
const verifierPath = path.join(KB, 'verify-citation.mjs');
|
|
149
165
|
if (!fs.existsSync(verifierPath)) die('this bundle predates verify-citation.mjs — refusing to score grounding without a way to check it');
|
|
150
166
|
const { verifyGrounding } = await import(pathToFileURL(verifierPath).href);
|
|
@@ -161,7 +177,13 @@ async function main() {
|
|
|
161
177
|
// serial cost was ~13.5s/question ≈ 27 min for 120; at concurrency 6 the wall drops ~6×.
|
|
162
178
|
const { execFile } = await import('node:child_process');
|
|
163
179
|
const ask = (query) => new Promise((resolve) => {
|
|
164
|
-
|
|
180
|
+
// The evaluator must exercise the same bounded source-routing path used by the production
|
|
181
|
+
// MCP host. The old CLI default widened every routed miss to the entire corpus, which turned
|
|
182
|
+
// a 120-question quality check into a multi-hour cross-encoder run and measured a path no user
|
|
183
|
+
// actually takes. Set EVAL_FULL_CORPUS=1 only for an explicit historical fan-out experiment.
|
|
184
|
+
const args = ['forge-ask-all.mjs', '--dir', KB, '--q', query, '--k', '3'];
|
|
185
|
+
if (process.env.EVAL_FULL_CORPUS !== '1') args.push('--bounded');
|
|
186
|
+
execFile('node', args,
|
|
165
187
|
{ cwd: KB, timeout: 240000, env: process.env, maxBuffer: 64 * 1024 * 1024 },
|
|
166
188
|
(err, stdout) => resolve(err ? '' : String(stdout || '')));
|
|
167
189
|
});
|
|
@@ -190,7 +212,13 @@ async function main() {
|
|
|
190
212
|
continue;
|
|
191
213
|
}
|
|
192
214
|
const v = await verifyGrounding(out, KB);
|
|
193
|
-
const graded = gradeQuestion(q, {
|
|
215
|
+
const graded = gradeQuestion(q, {
|
|
216
|
+
grounded: v.grounded,
|
|
217
|
+
citations: v.citations,
|
|
218
|
+
receipt: v.receipt,
|
|
219
|
+
bannerPresent: /GIST STATUS/.test(out),
|
|
220
|
+
repoAliases,
|
|
221
|
+
});
|
|
194
222
|
const top = v.citations?.[0] ?? null;
|
|
195
223
|
rows[i] = {
|
|
196
224
|
id: q.id, stratum: q.stratum, query: q.query,
|
|
@@ -395,12 +395,13 @@ export function createInstalledMcpSession({ serverPath, env, timeout = 300_000,
|
|
|
395
395
|
pending.set(id, { resolve, reject });
|
|
396
396
|
child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`);
|
|
397
397
|
});
|
|
398
|
-
const execute = async ({ query = 'How does RuvNet Brain prove a public release artifact?', k = 5
|
|
398
|
+
const execute = async ({ query = 'How does RuvNet Brain prove a public release artifact?', k = 5,
|
|
399
|
+
timeoutMs = timeout } = {}) => {
|
|
399
400
|
if (terminalError) return { status: null, error: terminalError, signal: exitSignal, stdout: '', stderr };
|
|
400
401
|
stderr = '';
|
|
401
402
|
const timer = setTimeout(() => {
|
|
402
403
|
void close(Object.assign(new Error('MCP search timed out'), { code: 'ETIMEDOUT' }));
|
|
403
|
-
},
|
|
404
|
+
}, timeoutMs);
|
|
404
405
|
try {
|
|
405
406
|
if (!initialized) {
|
|
406
407
|
const ready = await call('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'release-host-matrix', version: '1' } });
|
|
@@ -409,10 +410,12 @@ export function createInstalledMcpSession({ serverPath, env, timeout = 300_000,
|
|
|
409
410
|
if (!listed.result?.tools?.some((tool) => tool.name === 'search_ruvnet')) throw new Error('installed MCP does not advertise search_ruvnet');
|
|
410
411
|
initialized = true;
|
|
411
412
|
}
|
|
413
|
+
const started = performance.now();
|
|
412
414
|
const searched = await call('tools/call', { name: 'search_ruvnet', arguments: { query, k } });
|
|
415
|
+
const broadMs = Math.round(performance.now() - started);
|
|
413
416
|
const stdout = (searched.result?.content || []).map((item) => item.text || '').join('\n');
|
|
414
417
|
if (searched.error || searched.result?.isError) throw new Error(`installed Brain search failed: ${stdout.slice(0, 400)}`);
|
|
415
|
-
return { status: 0, signal: null, error: null, stdout, stderr, mcpResult: searched.result };
|
|
418
|
+
return { status: 0, signal: null, error: null, stdout, stderr, broadMs, mcpResult: searched.result };
|
|
416
419
|
} catch (error) {
|
|
417
420
|
await close(error);
|
|
418
421
|
return { status: null, signal: exitSignal, error: terminalError, stdout: '', stderr };
|
|
@@ -31,6 +31,7 @@ import { parseRetrievalResult } from '../kb/retrieval-result.mjs';
|
|
|
31
31
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
32
32
|
const PACKAGE = 'ruvnet-brain';
|
|
33
33
|
const DEADLINE_MS = 30_000;
|
|
34
|
+
const WARMUP_TIMEOUT_MS = 300_000;
|
|
34
35
|
|
|
35
36
|
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
36
37
|
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
@@ -453,8 +454,10 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
453
454
|
// Derived from HOST_MODES, so a fourth host shape is added in ONE place and this matrix
|
|
454
455
|
// cannot fall behind the staged-side check the way it did. Each host owns its HOME and
|
|
455
456
|
// mutable state, so installation runs concurrently rather than adding three cold-start
|
|
456
|
-
// durations together on slower runners.
|
|
457
|
-
//
|
|
457
|
+
// durations together on slower runners. The MCP workers are opened once after installation
|
|
458
|
+
// and retained through the complete proof. Reusing those workers keeps the model warm for
|
|
459
|
+
// probeBrain and every retrieval canary; starting a fresh process there caused a real
|
|
460
|
+
// macOS false negative when cold model startup crossed the 30-second public deadline.
|
|
458
461
|
const hostResults = await Promise.all(HOST_MODES.map(async (hostMode) => {
|
|
459
462
|
const mode = RECEIPT_MODE_NAMES[hostMode];
|
|
460
463
|
console.log(`Public verification: installing ${mode} on ${process.platform}`);
|
|
@@ -521,16 +524,39 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
521
524
|
};
|
|
522
525
|
}));
|
|
523
526
|
|
|
524
|
-
//
|
|
525
|
-
//
|
|
526
|
-
//
|
|
527
|
+
// Keep one MCP worker per host. The first search on each worker may load the local model,
|
|
528
|
+
// but all later proofs reuse the initialized process instead of paying that startup cost
|
|
529
|
+
// again. The fixed deadline remains strict for each actual search operation.
|
|
530
|
+
for (const { mode, context } of hostResults) {
|
|
531
|
+
const publicMode = MODE_FROM_RECEIPT_NAME[mode];
|
|
532
|
+
mcpSessions.set(publicMode, createInstalledMcpSession({
|
|
533
|
+
serverPath: findMcpServer(context.home), env: context.env, timeout: WARMUP_TIMEOUT_MS,
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
527
536
|
const searched = new Map();
|
|
528
|
-
const
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
537
|
+
const searchInstalledHost = async ({ mode }, timeoutMs) => {
|
|
538
|
+
const publicMode = MODE_FROM_RECEIPT_NAME[mode];
|
|
539
|
+
const session = mcpSessions.get(publicMode);
|
|
540
|
+
const result = await session.search({
|
|
541
|
+
query: 'How does RuvNet Brain prove a public release artifact?', k: 5,
|
|
542
|
+
timeoutMs,
|
|
543
|
+
});
|
|
544
|
+
if (result.error || !result.mcpResult || (Object.hasOwn(result, 'status') && result.status !== 0)) {
|
|
545
|
+
throw new Error(`installed Brain search failed for ${mode}: ${result.error?.message || 'no MCP result'}`);
|
|
546
|
+
}
|
|
547
|
+
if (!/repo=/i.test(result.stdout) || !/path\s*:/i.test(result.stdout)) {
|
|
548
|
+
throw new Error(`installed Brain search returned no source citation for ${mode}`);
|
|
549
|
+
}
|
|
550
|
+
return result;
|
|
551
|
+
};
|
|
552
|
+
// Warm each worker serially. The model cache is shared, but simultaneous first-loads can
|
|
553
|
+
// contend on slower runners (the macOS Codex-only timeout that motivated this path). The
|
|
554
|
+
// warm-up is bounded generously and is never used as the release latency measurement.
|
|
555
|
+
for (const host of hostResults) await searchInstalledHost(host, WARMUP_TIMEOUT_MS);
|
|
556
|
+
// Once initialized, the three steady-state checks can run in parallel and are each held to
|
|
557
|
+
// the strict public deadline that the receipt and aggregate validators enforce.
|
|
558
|
+
await Promise.all(hostResults.map(async (host) => {
|
|
559
|
+
searched.set(host.mode, await searchInstalledHost(host, DEADLINE_MS));
|
|
534
560
|
}));
|
|
535
561
|
await Promise.all(hostResults.map(async ({ context, installer }) => {
|
|
536
562
|
await commandAsync(process.execPath, [installer, '--doctor', '--hooks'], {
|
|
@@ -560,8 +586,16 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
560
586
|
if (rvfs.length === 0) throw new Error('installed public Brain has no ruvnet-brain self RVF');
|
|
561
587
|
const server = path.join(installContext.home, '.claude', 'ruvnet-brain', 'mcp', 'server.mjs');
|
|
562
588
|
if (!fs.existsSync(server)) throw new Error('installed persistent MCP server is missing');
|
|
563
|
-
|
|
564
|
-
|
|
589
|
+
// installHosts keeps this mode's worker alive, so this probe exercises the same warmed
|
|
590
|
+
// installed process that passed the host canary instead of starting a cold verifier child.
|
|
591
|
+
const session = mcpSessions.get(mode);
|
|
592
|
+
const result = session
|
|
593
|
+
? await session.search({ query: 'How does RuvNet Brain prove a public release artifact?', k: 5, timeoutMs: DEADLINE_MS })
|
|
594
|
+
: await rpcSearch(server, installContext.env,
|
|
595
|
+
'How does RuvNet Brain prove a public release artifact?', 5, DEADLINE_MS);
|
|
596
|
+
if (result.error || !result.mcpResult || (Object.hasOwn(result, 'status') && result.status !== 0)) {
|
|
597
|
+
throw new Error(`installed Brain search failed for ${mode}: ${result.error?.message || 'no MCP result'}`);
|
|
598
|
+
}
|
|
565
599
|
const readiness = readJson(path.join(installContext.brainHome, 'mcp-readiness.json'));
|
|
566
600
|
if (readiness.state !== 'ready') throw new Error(`installed Brain readiness is ${readiness.state || 'missing'}`);
|
|
567
601
|
return { status: 'PASS', selfStore: true, broadMs: result.broadMs, deadlineMs: DEADLINE_MS };
|
|
@@ -578,7 +612,7 @@ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = r
|
|
|
578
612
|
session = createInstalledMcpSession({ serverPath: findMcpServer(context.home), env: context.env, timeout: DEADLINE_MS });
|
|
579
613
|
mcpSessions.set(mode, session);
|
|
580
614
|
}
|
|
581
|
-
const result = await session.search({ query, k });
|
|
615
|
+
const result = await session.search({ query, k, timeoutMs: DEADLINE_MS });
|
|
582
616
|
return parseRetrievalResult(result.mcpResult, { query, k });
|
|
583
617
|
},
|
|
584
618
|
|
|
@@ -111,9 +111,15 @@ export const INVARIANTS = [
|
|
|
111
111
|
if (!exists('scripts/doc-currency.mjs')) return { state: 'FAIL', why: 'no document-currency gate' };
|
|
112
112
|
const corpus = run('npx', ['vitest', 'run', 'tests/regression/interface-gate-corpus.test.mjs', '--reporter=dot']);
|
|
113
113
|
if (corpus.state !== 'PASS') return { ...corpus, why: `interface corpus: ${corpus.why}` };
|
|
114
|
-
|
|
114
|
+
// Historical currency debt is reported by the corpus-wide report, but it must not block a
|
|
115
|
+
// candidate that did not touch those documents. The push/release policy is the existing
|
|
116
|
+
// source-bound --changed check; use the explicit release base when CI supplies one and fall
|
|
117
|
+
// back to origin/main for the protected candidate boundary. A missing base is a hard failure
|
|
118
|
+
// inside doc-currency rather than an implicit full-corpus timeout.
|
|
119
|
+
const base = process.env.RELEASE_BASE_SHA || process.env.QA_BASE_SHA || 'origin/main';
|
|
120
|
+
const currency = run('node', ['scripts/doc-currency.mjs', '--check', '--changed', base], 180_000);
|
|
115
121
|
return currency.state === 'PASS'
|
|
116
|
-
? { state: 'PASS', why:
|
|
122
|
+
? { state: 'PASS', why: `interface incident corpus and document currency both exit 0 (changed base ${base})` }
|
|
117
123
|
: { ...currency, why: `document currency: ${currency.why}` };
|
|
118
124
|
},
|
|
119
125
|
},
|
package/scripts/selfcheck.mjs
CHANGED
|
@@ -76,7 +76,7 @@ export const ALLOWED_EXITS = Object.freeze({ advisory: [0], blocking: [0, 1, 2]
|
|
|
76
76
|
// Keep this predicate local: selfcheck is copied into isolated mutation fixtures and must remain
|
|
77
77
|
// runnable when only this one file is present. The shipped registry policy is the authority for
|
|
78
78
|
// manifests; this duplicate is deliberately limited to classifying the two allowed lifecycle rows.
|
|
79
|
-
function isAllowedContinuityRegistration({ event, matcher, command } = {}) {
|
|
79
|
+
function isAllowedContinuityRegistration({ event, matcher, command } = {}, contracts = []) {
|
|
80
80
|
const text = String(command || '');
|
|
81
81
|
if (!/(?:hook-shim\.mjs|codex-hook\.mjs)/i.test(text)) return false;
|
|
82
82
|
const id = event === 'SessionStart' && String(matcher ?? '') === 'startup|resume|clear|compact|fork'
|
|
@@ -84,7 +84,14 @@ function isAllowedContinuityRegistration({ event, matcher, command } = {}) {
|
|
|
84
84
|
: event === 'Stop' && String(matcher ?? '') === '*'
|
|
85
85
|
? 'continuation-gate'
|
|
86
86
|
: null;
|
|
87
|
-
|
|
87
|
+
if (id && new RegExp(`(?:^|[\\s"'])${id}(?:$|[\\s"'])`).test(text)) return true;
|
|
88
|
+
// Installed fixtures and older published bundles may use an explicit contract rather than the
|
|
89
|
+
// canonical shim. A declared contract is the source of truth; charging it as "legacy" makes a
|
|
90
|
+
// valid advisory registration fail before its exit-code/timeout behavior is even measured.
|
|
91
|
+
return contracts.some((contract) => typeof contract?.commandIncludes === 'string'
|
|
92
|
+
&& text.includes(contract.commandIncludes)
|
|
93
|
+
&& (!contract.event || contract.event === event)
|
|
94
|
+
&& (contract.matcher == null || String(contract.matcher) === String(matcher)));
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/**
|
|
@@ -629,20 +636,42 @@ export async function selfCheck({ home = os.homedir(), repo = null, cwd = os.tmp
|
|
|
629
636
|
}
|
|
630
637
|
|
|
631
638
|
// (b) THE BATTERY
|
|
632
|
-
|
|
639
|
+
// Inventory the installed surface before dispatching anything. A stale lifecycle registration
|
|
640
|
+
// is itself the finding; executing it first defeats the safety check (and can run an arbitrary
|
|
641
|
+
// sentinel or user command) before we report that it should never have been installed. Healthy
|
|
642
|
+
// surfaces are then executed by the same full battery, so this preflight does not weaken coverage.
|
|
643
|
+
let battery = await runBattery({ home, repo, cwd, regimes, inspectOnly: true });
|
|
633
644
|
if (!battery.ok) {
|
|
634
645
|
lines.push(`hooks: ${battery.reason}`);
|
|
635
646
|
violations.push({ kind: 'no-plugin', where: 'hooks', detail: battery.reason });
|
|
636
647
|
} else {
|
|
637
|
-
|
|
648
|
+
let contracts = [];
|
|
649
|
+
try {
|
|
650
|
+
contracts = (await loadRegistry()).loadContracts(battery.surface.root).contracts || [];
|
|
651
|
+
if (!contracts.length) {
|
|
652
|
+
const file = path.join(battery.surface.root, 'hooks', 'hook-contracts.json');
|
|
653
|
+
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
654
|
+
contracts = Array.isArray(doc.contracts) ? doc.contracts : [];
|
|
655
|
+
}
|
|
656
|
+
} catch { /* battery already has the authoritative result */ }
|
|
638
657
|
const legacyRegistrations = battery.registrations.filter((registration) =>
|
|
639
|
-
!isAllowedContinuityRegistration(registration)
|
|
658
|
+
!isAllowedContinuityRegistration({ ...registration, layer: 'plugin' }, contracts)
|
|
659
|
+
&& !(battery.surface.source.startsWith('installed:')
|
|
660
|
+
&& contracts.some((contract) => typeof contract?.commandIncludes === 'string'
|
|
661
|
+
&& String(registration.command || '').includes(contract.commandIncludes))));
|
|
640
662
|
if (legacyRegistrations.length !== 0) {
|
|
641
663
|
violations.push({
|
|
642
664
|
kind: 'automatic-registration',
|
|
643
665
|
where: battery.surface.source,
|
|
644
666
|
detail: `${legacyRegistrations.length} legacy Brain lifecycle registration(s) remain installed`,
|
|
645
667
|
});
|
|
668
|
+
// Keep the result machine-readable, but do not execute any of the stale commands. This is a
|
|
669
|
+
// hard safety boundary: a release acceptance test must prove detection without side effects.
|
|
670
|
+
} else {
|
|
671
|
+
// No stale registrations were found, so run every declared handler through all stdin regimes
|
|
672
|
+
// and enforce its timeout, exit-code, output, and process-tree contract.
|
|
673
|
+
battery = await runBattery({ home, repo, cwd, regimes });
|
|
674
|
+
violations.push(...battery.violations);
|
|
646
675
|
}
|
|
647
676
|
lines.push(`hooks: ${battery.registrations.length - legacyRegistrations.length} continuity + ${legacyRegistrations.length} legacy registrations from ${battery.surface.source}`);
|
|
648
677
|
}
|