ruvnet-brain 4.3.40 → 4.4.1
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 +179 -36
- package/kb/brain-profile.mjs +17 -2
- package/kb/forge-update.mjs +6 -2
- package/kb/lifecycle-evidence-retention.mjs +12 -9
- package/kb/refresh-run.mjs +17 -1
- package/kb/update-storage-transaction.mjs +33 -5
- 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 +2 -2
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/capability-registry.mjs +3 -3
- package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
- package/plugin/scripts/design-wall.sh +1 -0
- package/plugin/scripts/ground-before-write.sh +1 -0
- package/plugin/scripts/ground-ruvnet.sh +3 -3
- package/plugin/scripts/grounding-answer.mjs +129 -0
- package/plugin/scripts/grounding-stamp.sh +32 -31
- package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
- package/plugin/scripts/grounding-turn-gate.mjs +25 -6
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/kling-preflight.sh +1 -0
- package/plugin/scripts/learn-capture.sh +1 -0
- package/plugin/scripts/project-progression-reader.mjs +10 -0
- package/plugin/scripts/project-progression-sources.mjs +16 -4
- package/plugin/scripts/project-progression-store.mjs +201 -6
- package/plugin/scripts/protect-brain-state.sh +1 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-snapshot-hook.mjs +383 -37
- package/plugin/scripts/session-start-health.mjs +24 -3
- package/plugin/scripts/session-start-update-plane.mjs +1 -1
- package/plugin/scripts/update-apply.mjs +22 -2
- package/scripts/console-instances.mjs +203 -0
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/corpus-canary.mjs +130 -18
- package/scripts/customer-seams.mjs +84 -0
- package/scripts/customer-state-matrix.mjs +363 -0
- package/scripts/full-suite-gate.mjs +162 -0
- package/scripts/grounding-turn-replay.mjs +11 -3
- package/scripts/hook-qualify-core.mjs +346 -0
- package/scripts/hook-qualify-hosts.mjs +115 -0
- package/scripts/hook-qualify.mjs +101 -0
- package/scripts/host-cli.mjs +115 -0
- package/scripts/qe/agentic-qe-4.3.mjs +0 -1
- package/scripts/route-gold-rank.mjs +156 -0
- package/scripts/route-index-memory.mjs +51 -0
- package/scripts/route-latency-warm.mjs +123 -0
- package/scripts/wired-check.mjs +17 -3
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// host-cli.mjs — run the `claude` / `codex` CLI from the installer, tolerating a binary that is
|
|
2
|
+
// briefly absent because the host is updating itself.
|
|
3
|
+
//
|
|
4
|
+
// 2026-09-30, owner's Mac: Claude Code self-updated during `npx ruvnet-brain --update`;
|
|
5
|
+
// ~/.npm-global/bin/claude was missing for a few seconds and the installer printed raw
|
|
6
|
+
// "No such file or directory" shell errors. A missing binary is now retried with a short bounded
|
|
7
|
+
// backoff (3 tries over ~20s by default) and, if it never comes back, reported as ONE clear line.
|
|
8
|
+
// Raw stderr from a missing-binary attempt is swallowed; stderr from a real run is passed through.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
const IS_WIN = process.platform === 'win32';
|
|
13
|
+
export const HOST_CLI_RETRY_DELAYS_MS = Object.freeze([5_000, 15_000]);
|
|
14
|
+
const sleepSync = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
15
|
+
// CLIs this process has seen working. Only a CLI that was here (or whose PATH link still exists)
|
|
16
|
+
// can be "transiently" missing; one that was never installed fails at once, with no 20s wait.
|
|
17
|
+
const SEEN = new Set();
|
|
18
|
+
// CLIs that stayed missing through one full wait: gone for this process. A permanently dangling link
|
|
19
|
+
// must not cost ~20s on EVERY later call — one bounded wait per process, then fail at once.
|
|
20
|
+
const GONE = new Set();
|
|
21
|
+
|
|
22
|
+
/** The binary's real path, or null when it is not on PATH or its link points at nothing. */
|
|
23
|
+
export function resolveHostCli(cmd, { spawn = spawnSync } = {}) {
|
|
24
|
+
const probe = IS_WIN
|
|
25
|
+
? spawn('where', [cmd], { encoding: 'utf8' })
|
|
26
|
+
: spawn('sh', ['-c', 'command -v -- "$1"', 'sh', cmd], { encoding: 'utf8' });
|
|
27
|
+
if (probe.error || probe.status !== 0) return null;
|
|
28
|
+
const found = String(probe.stdout || '').split(/\r?\n/).find(Boolean);
|
|
29
|
+
if (!found) return null;
|
|
30
|
+
if (!IS_WIN && !found.includes('/')) { SEEN.add(cmd); return found; } // builtin/function: nothing to stat
|
|
31
|
+
try { const real = fs.realpathSync(found); SEEN.add(cmd); return real; }
|
|
32
|
+
catch { return null; } // dangling link mid-update
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** A PATH entry named `cmd` exists (even as a link that currently points nowhere). */
|
|
36
|
+
export function hostCliOnPath(cmd, { env = process.env } = {}) {
|
|
37
|
+
const names = IS_WIN ? [cmd, `${cmd}.cmd`, `${cmd}.exe`] : [cmd];
|
|
38
|
+
for (const dir of String(env.PATH || '').split(IS_WIN ? ';' : ':').filter(Boolean)) {
|
|
39
|
+
for (const name of names) { try { fs.lstatSync(`${dir}/${name}`); return true; } catch { /* next */ } }
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Is the CLI usable? A link that exists but points nowhere is a host mid-self-update: wait for it
|
|
46
|
+
* (same bounded backoff). A CLI that is simply not installed returns at once, with no wait.
|
|
47
|
+
*/
|
|
48
|
+
export function waitForHostCli(cmd, { delays = HOST_CLI_RETRY_DELAYS_MS, sleep = sleepSync, resolve = resolveHostCli,
|
|
49
|
+
onPath = hostCliOnPath } = {}) {
|
|
50
|
+
if (resolve(cmd)) { GONE.delete(cmd); return { present: true, waited: false }; }
|
|
51
|
+
if (!onPath(cmd)) return { present: false, waited: false };
|
|
52
|
+
const waited = Math.round(delays.reduce((sum, ms) => sum + ms, 0) / 1000);
|
|
53
|
+
const message = `the \`${cmd}\` command is on PATH but points at nothing (checked for ${waited}s — it may be updating itself)`;
|
|
54
|
+
if (GONE.has(cmd)) return { present: false, waited: false, message };
|
|
55
|
+
for (const ms of delays) {
|
|
56
|
+
sleep(ms);
|
|
57
|
+
if (resolve(cmd)) return { present: true, waited: true };
|
|
58
|
+
}
|
|
59
|
+
GONE.add(cmd);
|
|
60
|
+
return { present: false, waited: true, message };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const MISSING_TEXT = /(?:No such file or directory|command not found|ENOENT|is not recognized as an internal or external command)/i;
|
|
64
|
+
|
|
65
|
+
/** True when this attempt failed because the CLI itself could not be executed. */
|
|
66
|
+
export function missingBinaryAttempt(result, cmd, { resolve = resolveHostCli } = {}) {
|
|
67
|
+
if (result?.error?.code === 'ENOENT') return true;
|
|
68
|
+
if (result?.error || result?.status === 0) return false;
|
|
69
|
+
// 127 is the shell's "could not execute" — but only when the binary really is gone; a CLI that
|
|
70
|
+
// resolves and itself exits 127 ran, and its failure is its own.
|
|
71
|
+
if (result?.status === 127) return !resolve(cmd);
|
|
72
|
+
// A run that started and failed for its own reasons is not "missing"; only a failure whose text
|
|
73
|
+
// says a file was missing AND whose binary no longer resolves is.
|
|
74
|
+
return MISSING_TEXT.test(String(result?.stderr || '')) && !resolve(cmd);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* spawnSync-compatible. Extra fields on the returned object: `attempts`, and on exhaustion
|
|
79
|
+
* `missingBinary: true` plus `message` (the one line to print). With stdio 'inherit' (the default),
|
|
80
|
+
* stdin/stdout stay inherited; stderr is captured so a missing-binary attempt prints nothing.
|
|
81
|
+
*/
|
|
82
|
+
export function runHostCli(cmd, args, {
|
|
83
|
+
delays = HOST_CLI_RETRY_DELAYS_MS, sleep = sleepSync, spawn = spawnSync, resolve = resolveHostCli,
|
|
84
|
+
onPath = hostCliOnPath, stdio = 'inherit', echoStderr = (text) => process.stderr.write(text), ...opts
|
|
85
|
+
} = {}) {
|
|
86
|
+
const inherit = stdio === 'inherit';
|
|
87
|
+
const spawnOpts = { shell: IS_WIN, ...opts, stdio: inherit ? ['inherit', 'inherit', 'pipe'] : stdio };
|
|
88
|
+
if (inherit && !spawnOpts.encoding) spawnOpts.encoding = 'utf8';
|
|
89
|
+
let result;
|
|
90
|
+
if (GONE.has(cmd)) delays = []; // already waited once this process: one attempt, no backoff
|
|
91
|
+
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
|
|
92
|
+
if (attempt > 0) sleep(delays[attempt - 1]);
|
|
93
|
+
result = spawn(cmd, args, spawnOpts);
|
|
94
|
+
const missing = missingBinaryAttempt(result, cmd, { resolve });
|
|
95
|
+
if (!missing) { SEEN.add(cmd); GONE.delete(cmd); }
|
|
96
|
+
// Never installed (not seen this run, no PATH entry at all): the caller's own "not found"
|
|
97
|
+
// handling applies at once, exactly as before.
|
|
98
|
+
if (!missing || (attempt === 0 && !SEEN.has(cmd) && !onPath(cmd))) {
|
|
99
|
+
if (inherit && result?.stderr) echoStderr(String(result.stderr));
|
|
100
|
+
return Object.assign(result ?? {}, { attempts: attempt + 1 });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const waited = Math.round(delays.reduce((sum, ms) => sum + ms, 0) / 1000);
|
|
104
|
+
GONE.add(cmd);
|
|
105
|
+
return Object.assign(result ?? {}, {
|
|
106
|
+
attempts: delays.length + 1,
|
|
107
|
+
missingBinary: true,
|
|
108
|
+
message: delays.length
|
|
109
|
+
? `the \`${cmd}\` command was not available (tried ${delays.length + 1} times over ${waited}s — it may be updating itself); skipped \`${cmd} ${args.join(' ')}\``
|
|
110
|
+
: `the \`${cmd}\` command is still not available (it already stayed missing through a wait earlier in this run); skipped \`${cmd} ${args.join(' ')}\``,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Test seam: forget which CLIs this process has seen. */
|
|
115
|
+
export function resetSeenHostClis() { SEEN.clear(); GONE.clear(); }
|
|
@@ -58,7 +58,6 @@ const LANES = Object.freeze({
|
|
|
58
58
|
release: [
|
|
59
59
|
vitest([
|
|
60
60
|
'tests/qe/release/packed-clean-install.test.mjs',
|
|
61
|
-
'tests/qe/release/release-publish-contract.test.mjs',
|
|
62
61
|
'tests/qe/release/stable-spine-recovery.test.mjs',
|
|
63
62
|
'tests/qe/security/release-abuse-cases.test.mjs',
|
|
64
63
|
], 240_000),
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/route-gold-rank.mjs — where does the source router put the store that holds the answer?
|
|
4
|
+
*
|
|
5
|
+
* search_ruvnet first plans a bounded set of stores (kb/forge-ask-all.mjs planSourceRoute) and only
|
|
6
|
+
* then retrieves and reranks inside them. A store the plan never opens cannot be cited, however good
|
|
7
|
+
* the reranker is. This harness measures ONLY that first step: for each question it runs the exact
|
|
8
|
+
* planner the search path runs (no model, no retrieval) and records the rank of the first planned
|
|
9
|
+
* store that matches the question's gold repository. It reports, with 95% Wilson intervals:
|
|
10
|
+
*
|
|
11
|
+
* goldAt1 / goldAt3 / goldAt5 gold store among the first 1 / 3 / 5 planned stores
|
|
12
|
+
* declined the planner opened nothing (bounded search refuses)
|
|
13
|
+
* reposOpened mean planned stores per question (cost: each is a retrieval + rerank)
|
|
14
|
+
*
|
|
15
|
+
* Question sets (any number, each optional):
|
|
16
|
+
* --needs <file> novice need set (data/need-set/need-set-v1.json shape: questions[].need, .repo)
|
|
17
|
+
* --heldout <file> evals/held-out.json shape (questions[].query, .stratum, .expectRepo); the
|
|
18
|
+
* named/described/scenario strata are scored, adversarial is reported as declined
|
|
19
|
+
* --recall-fixture <file> data/retrieval-query-evidence.json with each store's identity stripped
|
|
20
|
+
* (unnameRecallQuery): one described need per store, 180+ stores
|
|
21
|
+
*
|
|
22
|
+
* node scripts/route-gold-rank.mjs --kb <kbDir> [--impl <forge-ask-all.mjs>] [--needs f] [--heldout f]
|
|
23
|
+
* [--recall-fixture f] [--out f]
|
|
24
|
+
*
|
|
25
|
+
* --impl lets the SAME KB be routed by two code versions (a baseline checkout and this one).
|
|
26
|
+
* Measurements only: it writes nothing but --out.
|
|
27
|
+
*/
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
import { wilson, repoMatchesExpected } from './eval-brain.mjs';
|
|
32
|
+
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
|
|
35
|
+
/** Rank (1-based) of the first planned store matching any expected repo, or null. Case-insensitive. */
|
|
36
|
+
export function goldRank(plannedRepos, expected, aliases = {}) {
|
|
37
|
+
const lowerAliases = Object.fromEntries(Object.entries(aliases || {})
|
|
38
|
+
.map(([canonical, list]) => [canonical.toLowerCase(), (list || []).map((s) => String(s).toLowerCase())]));
|
|
39
|
+
const want = (expected || []).map((e) => String(e).toLowerCase());
|
|
40
|
+
const idx = (plannedRepos || []).findIndex((repo) => repoMatchesExpected(String(repo).toLowerCase(), want, lowerAliases));
|
|
41
|
+
return idx < 0 ? null : idx + 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rate = (rows, pred) => {
|
|
45
|
+
const k = rows.filter(pred).length;
|
|
46
|
+
const w = wilson(k, rows.length);
|
|
47
|
+
return { k, n: rows.length, p: +w.p.toFixed(4), lo: +w.lo.toFixed(4), hi: +w.hi.toFixed(4) };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Summarize scored rows ({ rank, repos, group }) into the reported metrics, overall and per group. */
|
|
51
|
+
export function summarizeRoutes(rows) {
|
|
52
|
+
const one = (rs) => ({
|
|
53
|
+
goldAt1: rate(rs, (r) => r.rank != null && r.rank <= 1),
|
|
54
|
+
goldAt3: rate(rs, (r) => r.rank != null && r.rank <= 3),
|
|
55
|
+
goldAt5: rate(rs, (r) => r.rank != null && r.rank <= 5),
|
|
56
|
+
declined: rate(rs, (r) => !r.repos.length),
|
|
57
|
+
reposOpened: rs.length ? +(rs.reduce((s, r) => s + r.repos.length, 0) / rs.length).toFixed(3) : 0,
|
|
58
|
+
});
|
|
59
|
+
const groups = [...new Set(rows.map((r) => r.group))].sort();
|
|
60
|
+
return { all: one(rows), byGroup: Object.fromEntries(groups.map((g) => [g, one(rows.filter((r) => r.group === g))])) };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Route every question through `planSourceRoute` and score it. Pure apart from the planner itself. */
|
|
64
|
+
export function measureRouteRanks({ kbDir, questions, planSourceRoute, discoverRepos, aliases = {} }) {
|
|
65
|
+
const discovered = discoverRepos(kbDir);
|
|
66
|
+
const rows = [];
|
|
67
|
+
const t0 = performance.now();
|
|
68
|
+
for (const q of questions) {
|
|
69
|
+
const planned = planSourceRoute({ dir: kbDir, query: q.query, discovered }) || { repos: [] };
|
|
70
|
+
const repos = Array.isArray(planned.repos) ? planned.repos : [];
|
|
71
|
+
rows.push({ id: q.id, group: q.group, expected: q.expected, repos, reason: planned.reason || null,
|
|
72
|
+
rank: q.expected?.length ? goldRank(repos, q.expected, aliases) : null });
|
|
73
|
+
}
|
|
74
|
+
return { rows, msPerQuestion: questions.length ? +((performance.now() - t0) / questions.length).toFixed(1) : 0 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The recall fixture asks one question per store and NAMES the store ("In the agentdb repository,
|
|
79
|
+
* what is AgentDB ..."). With every spelling of the store key removed it becomes a described need
|
|
80
|
+
* for 180+ stores, so routing can be checked on far more than three repositories. Honest limit: a
|
|
81
|
+
* display name that is not the store key (e.g. "2 BoT Talk" for 2bottalk) can survive the strip.
|
|
82
|
+
*/
|
|
83
|
+
export function unnameRecallQuery(query, store) {
|
|
84
|
+
const words = String(store).toLowerCase().match(/[a-z0-9]+/g) || [];
|
|
85
|
+
const name = new RegExp(`(?<![a-z0-9])${words.map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('[\\s._-]*')}(?![a-z0-9])`, 'gi');
|
|
86
|
+
return String(query).replace(/^in the \S+ repository,\s*/i, '').replace(name, 'this project').replace(/\s+/g, ' ').trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function loadQuestions({ needs, heldout, recallFixture }) {
|
|
90
|
+
const out = { needs: [], heldout: [], offTopic: [], recallUnnamed: [] };
|
|
91
|
+
if (recallFixture) {
|
|
92
|
+
for (const [store, q] of Object.entries(JSON.parse(fs.readFileSync(recallFixture, 'utf8')).queries)) {
|
|
93
|
+
out.recallUnnamed.push({ id: store, group: 'recall', query: unnameRecallQuery(q.query, store), expected: [store] });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (needs) {
|
|
97
|
+
for (const q of JSON.parse(fs.readFileSync(needs, 'utf8')).questions) {
|
|
98
|
+
out.needs.push({ id: q.id, group: q.repo, query: q.need, expected: [q.repo] });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (heldout) {
|
|
102
|
+
for (const q of JSON.parse(fs.readFileSync(heldout, 'utf8')).questions) {
|
|
103
|
+
if (q.stratum === 'adversarial') out.offTopic.push({ id: q.id, group: 'adversarial', query: q.query, expected: [] });
|
|
104
|
+
else if (['named', 'described', 'scenario'].includes(q.stratum)) {
|
|
105
|
+
out.heldout.push({ id: q.id, group: q.stratum, query: q.query, expected: q.expectRepo || [] });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What a committed report says about its inputs. Never a local absolute path: the KB is named by its
|
|
114
|
+
* build stamp, and the implementation by its repo-relative path (or `<impl>` when it lives outside
|
|
115
|
+
* this checkout). The code's identity is the commit the report is filed with.
|
|
116
|
+
*/
|
|
117
|
+
export function reportIdentity({ impl, kbDir }) {
|
|
118
|
+
let kbBuild = null;
|
|
119
|
+
try {
|
|
120
|
+
const m = JSON.parse(fs.readFileSync(path.join(kbDir, 'manifest.json'), 'utf8'));
|
|
121
|
+
kbBuild = { brainVersion: m.brainVersion ?? null, generated: m.generated ?? null };
|
|
122
|
+
} catch { /* no manifest */ }
|
|
123
|
+
const rel = path.relative(ROOT, path.resolve(impl));
|
|
124
|
+
return { impl: rel && !rel.startsWith('..') && !path.isAbsolute(rel) ? rel : '<impl>', kbDir: '<kb>', kbBuild };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function arg(name) {
|
|
128
|
+
const i = process.argv.indexOf(name);
|
|
129
|
+
return i >= 0 ? process.argv[i + 1] : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
const kbDir = arg('--kb');
|
|
134
|
+
if (!kbDir || !fs.existsSync(kbDir)) throw new Error('--kb <dir> is required and must exist');
|
|
135
|
+
const impl = path.resolve(arg('--impl') || path.join(ROOT, 'kb/forge-ask-all.mjs'));
|
|
136
|
+
const mod = await import(pathToFileURL(impl).href);
|
|
137
|
+
if (typeof mod.planSourceRoute !== 'function') throw new Error(`${impl} does not export planSourceRoute`);
|
|
138
|
+
let aliases = {};
|
|
139
|
+
try { aliases = JSON.parse(fs.readFileSync(path.join(kbDir, 'repo-aliases.json'), 'utf8')); } catch { /* none */ }
|
|
140
|
+
const sets = loadQuestions({ needs: arg('--needs'), heldout: arg('--heldout'), recallFixture: arg('--recall-fixture') });
|
|
141
|
+
const report = { kind: 'ruvnet-brain-route-gold-rank', ...reportIdentity({ impl, kbDir }), measuredAt: new Date().toISOString(), sets: {} };
|
|
142
|
+
for (const [name, questions] of Object.entries(sets)) {
|
|
143
|
+
if (!questions.length) continue;
|
|
144
|
+
const { rows, msPerQuestion } = measureRouteRanks({ kbDir, questions, planSourceRoute: mod.planSourceRoute,
|
|
145
|
+
discoverRepos: mod.discoverRepos, aliases });
|
|
146
|
+
report.sets[name] = { ...summarizeRoutes(rows), msPerQuestion, rows };
|
|
147
|
+
}
|
|
148
|
+
const outFile = arg('--out');
|
|
149
|
+
if (outFile) fs.writeFileSync(outFile, `${JSON.stringify(report, null, 2)}\n`);
|
|
150
|
+
const brief = Object.fromEntries(Object.entries(report.sets).map(([k, v]) => [k, { ...v.all, msPerQuestion: v.msPerQuestion }]));
|
|
151
|
+
console.log(JSON.stringify(brief, null, 1));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
155
|
+
await main();
|
|
156
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/route-index-memory.mjs — how much memory the router metadata index keeps alive in a
|
|
4
|
+
* long-lived process (the warm MCP worker), and how long the cold build takes.
|
|
5
|
+
*
|
|
6
|
+
* Builds the index for every store by routing one question, forces GC, and reports the retained
|
|
7
|
+
* delta of heapUsed and arrayBuffers. Needs --expose-gc.
|
|
8
|
+
*
|
|
9
|
+
* node --expose-gc scripts/route-index-memory.mjs --kb <kbDir> [--impl <forge-ask-all.mjs>]
|
|
10
|
+
*
|
|
11
|
+
* Prints JSON with no local paths (the KB is named by its build stamp).
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
18
|
+
const arg = (name) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : undefined; };
|
|
19
|
+
|
|
20
|
+
export function retained(before, after) {
|
|
21
|
+
const mb = (x) => +(x / 1048576).toFixed(1);
|
|
22
|
+
return { heapUsedMB: mb(after.heapUsed - before.heapUsed), arrayBuffersMB: mb(after.arrayBuffers - before.arrayBuffers),
|
|
23
|
+
totalMB: mb(after.heapUsed - before.heapUsed + after.arrayBuffers - before.arrayBuffers) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
if (typeof globalThis.gc !== 'function') throw new Error('run with node --expose-gc');
|
|
28
|
+
const kbDir = arg('--kb');
|
|
29
|
+
if (!kbDir || !fs.existsSync(kbDir)) throw new Error('--kb <dir> is required and must exist');
|
|
30
|
+
const impl = path.resolve(arg('--impl') || path.join(ROOT, 'kb/forge-ask-all.mjs'));
|
|
31
|
+
const { metadataSourceRoute, discoverRepos } = await import(pathToFileURL(impl).href);
|
|
32
|
+
const repos = discoverRepos(kbDir);
|
|
33
|
+
const query = 'keep embeddings searchable offline on a laptop without a database server';
|
|
34
|
+
metadataSourceRoute('warm up the tokenizer and module state only', path.join(kbDir, '__none__'), []);
|
|
35
|
+
globalThis.gc(); globalThis.gc();
|
|
36
|
+
const before = process.memoryUsage();
|
|
37
|
+
const t0 = performance.now();
|
|
38
|
+
metadataSourceRoute(query, kbDir, repos);
|
|
39
|
+
const coldMs = Math.round(performance.now() - t0);
|
|
40
|
+
globalThis.gc(); globalThis.gc();
|
|
41
|
+
const after = process.memoryUsage();
|
|
42
|
+
const t1 = performance.now();
|
|
43
|
+
metadataSourceRoute(query, kbDir, repos);
|
|
44
|
+
const warmMs = Math.round(performance.now() - t1);
|
|
45
|
+
let kbBuild = null;
|
|
46
|
+
try { const m = JSON.parse(fs.readFileSync(path.join(kbDir, 'manifest.json'), 'utf8')); kbBuild = { brainVersion: m.brainVersion, generated: m.generated }; } catch { /* none */ }
|
|
47
|
+
const rel = path.relative(ROOT, impl);
|
|
48
|
+
console.log(JSON.stringify({ impl: rel.startsWith('..') ? '<impl>' : rel, kbBuild, stores: repos.length, coldMs, warmMs, retained: retained(before, after) }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/route-latency-warm.mjs — paired WARM latency of two or more search runtimes on the same
|
|
4
|
+
* questions, in ONE process (the warm MCP worker's regime).
|
|
5
|
+
*
|
|
6
|
+
* Each arm is a KB directory whose forge-ask-all.mjs is the code under test: a shadow copy of a
|
|
7
|
+
* corpus with that version's kb/*.mjs. Every question is asked of every arm back to back. The arm
|
|
8
|
+
* order rotates per question, and each question first waits for the 1-minute load average to drop
|
|
9
|
+
* below --max-load, so the arms of one question share conditions. searchAll is called exactly as
|
|
10
|
+
* kb/forge-mcp-all.mjs calls it (k 6, allowFullCorpus false). Rows are appended to <out>.rows.jsonl
|
|
11
|
+
* as they finish, so an interrupted run keeps what it measured.
|
|
12
|
+
*
|
|
13
|
+
* node scripts/route-latency-warm.mjs --set <need-set.json> --out <file> [--stride 3] [--max-load 60]
|
|
14
|
+
* --arm A_base=<kbDir> --arm B_final=<kbDir> [--arm ...]
|
|
15
|
+
* node scripts/route-latency-warm.mjs --summarize <rows.jsonl> --arms A_base,B_final[,...]
|
|
16
|
+
*
|
|
17
|
+
* The summary reports, per arm, p50 / p90 and mean stores searched; for each pair, the p50 and p90
|
|
18
|
+
* deltas and the median per-question difference, each with a 95% paired bootstrap interval
|
|
19
|
+
* (2000 resamples, fixed seed). Nothing it writes contains a local path.
|
|
20
|
+
*/
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import os from 'node:os';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
25
|
+
|
|
26
|
+
export const percentile = (xs, p) => {
|
|
27
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
28
|
+
return s[Math.min(s.length - 1, Math.floor(p * s.length))];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Seeded LCG, so a summary of the same rows always prints the same intervals. */
|
|
32
|
+
function rng(seed = 42) {
|
|
33
|
+
let s = seed;
|
|
34
|
+
return () => ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function summarizeRows(rows, labels, { resamples = 2000, seed = 42 } = {}) {
|
|
38
|
+
const ok = rows.filter((r) => labels.every((a) => r[a] && !r[a].err));
|
|
39
|
+
const out = { n: rows.length, nOk: ok.length, arms: {}, deltas: {} };
|
|
40
|
+
if (rows.length) out.loadAt = { min: Math.min(...rows.map((r) => r.loadAt)), max: Math.max(...rows.map((r) => r.loadAt)) };
|
|
41
|
+
for (const a of labels) {
|
|
42
|
+
const ms = ok.map((r) => r[a].ms);
|
|
43
|
+
out.arms[a] = { p50: Math.round(percentile(ms, 0.5)), p90: Math.round(percentile(ms, 0.9)),
|
|
44
|
+
meanRepos: ok.length ? +(ok.reduce((s, r) => s + (r[a].repos || 0), 0) / ok.length).toFixed(3) : 0,
|
|
45
|
+
errors: rows.filter((r) => r[a]?.err).length };
|
|
46
|
+
}
|
|
47
|
+
const rnd = rng(seed);
|
|
48
|
+
for (let x = 0; x < labels.length; x++) for (let y = x + 1; y < labels.length; y++) {
|
|
49
|
+
const A = labels[x];
|
|
50
|
+
const B = labels[y];
|
|
51
|
+
const stat = (idx) => ({
|
|
52
|
+
p50: percentile(idx.map((i) => ok[i][B].ms), 0.5) - percentile(idx.map((i) => ok[i][A].ms), 0.5),
|
|
53
|
+
p90: percentile(idx.map((i) => ok[i][B].ms), 0.9) - percentile(idx.map((i) => ok[i][A].ms), 0.9),
|
|
54
|
+
medianPaired: percentile(idx.map((i) => ok[i][B].ms - ok[i][A].ms), 0.5),
|
|
55
|
+
});
|
|
56
|
+
const all = stat(ok.map((_, i) => i));
|
|
57
|
+
const boots = Array.from({ length: resamples }, () => stat(ok.map(() => Math.floor(rnd() * ok.length))));
|
|
58
|
+
const ci = (k) => [Math.round(percentile(boots.map((s) => s[k]), 0.025)), Math.round(percentile(boots.map((s) => s[k]), 0.975))];
|
|
59
|
+
out.deltas[`${B} - ${A}`] = { p50: Math.round(all.p50), p50CI: ci('p50'), p90: Math.round(all.p90), p90CI: ci('p90'),
|
|
60
|
+
medianPairedDiff: Math.round(all.medianPaired), medianPairedCI: ci('medianPaired'),
|
|
61
|
+
bFaster: `${ok.filter((r) => r[B].ms < r[A].ms).length}/${ok.length}` };
|
|
62
|
+
}
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const args = process.argv.slice(2);
|
|
67
|
+
const arg = (name) => { const i = args.indexOf(name); return i >= 0 ? args[i + 1] : undefined; };
|
|
68
|
+
const all = (name) => args.flatMap((v, i) => (v === name ? [args[i + 1]] : []));
|
|
69
|
+
|
|
70
|
+
async function run() {
|
|
71
|
+
const set = arg('--set');
|
|
72
|
+
const out = arg('--out');
|
|
73
|
+
if (!set || !out) throw new Error('--set and --out are required');
|
|
74
|
+
const stride = Math.max(1, Number(arg('--stride')) || 1);
|
|
75
|
+
const maxLoad = Number(arg('--max-load')) || 60;
|
|
76
|
+
const arms = [];
|
|
77
|
+
for (const spec of all('--arm')) {
|
|
78
|
+
const [label, dir] = spec.split('=');
|
|
79
|
+
const { searchAll } = await import(pathToFileURL(path.join(path.resolve(dir), 'forge-ask-all.mjs')).href);
|
|
80
|
+
arms.push({ label, dir: path.resolve(dir), searchAll });
|
|
81
|
+
}
|
|
82
|
+
if (arms.length < 2) throw new Error('at least two --arm label=kbDir are required');
|
|
83
|
+
const questions = JSON.parse(fs.readFileSync(set, 'utf8')).questions.filter((_, i) => i % stride === 0);
|
|
84
|
+
const rowsFile = `${out}.rows.jsonl`;
|
|
85
|
+
fs.writeFileSync(rowsFile, '');
|
|
86
|
+
const ask = async (arm, query) => {
|
|
87
|
+
const t0 = performance.now();
|
|
88
|
+
let res = null;
|
|
89
|
+
let err = null;
|
|
90
|
+
try { res = await arm.searchAll({ dir: arm.dir, query, k: 6, allowFullCorpus: false }); } catch (e) { err = String(e?.message || e).slice(0, 200); }
|
|
91
|
+
return { ms: performance.now() - t0, repos: res?.repos?.length ?? null,
|
|
92
|
+
top: res?.results?.[0] ? `${res.results[0].repo}/${res.results[0].path}` : null, err };
|
|
93
|
+
};
|
|
94
|
+
for (const q of questions.slice(0, 3)) for (const arm of arms) await ask(arm, q.need); // warm, untimed
|
|
95
|
+
const rows = [];
|
|
96
|
+
for (let i = 0; i < questions.length; i++) {
|
|
97
|
+
while (os.loadavg()[0] >= maxLoad) await new Promise((r) => setTimeout(r, 15000));
|
|
98
|
+
const row = { id: questions[i].id, loadAt: +os.loadavg()[0].toFixed(1) };
|
|
99
|
+
for (let j = 0; j < arms.length; j++) {
|
|
100
|
+
const arm = arms[(i + j) % arms.length];
|
|
101
|
+
row[arm.label] = await ask(arm, questions[i].need);
|
|
102
|
+
}
|
|
103
|
+
rows.push(row);
|
|
104
|
+
fs.appendFileSync(rowsFile, `${JSON.stringify(row)}\n`);
|
|
105
|
+
process.stderr.write(`\r[route-latency-warm] ${i + 1}/${questions.length}`);
|
|
106
|
+
}
|
|
107
|
+
const summary = summarizeRows(rows, arms.map((a) => a.label));
|
|
108
|
+
fs.writeFileSync(out, `${JSON.stringify(summary, null, 1)}\n`);
|
|
109
|
+
console.log(JSON.stringify(summary, null, 1));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function main() {
|
|
113
|
+
const rowsFile = arg('--summarize');
|
|
114
|
+
if (rowsFile) {
|
|
115
|
+
const rows = fs.readFileSync(rowsFile, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
116
|
+
console.log(JSON.stringify(summarizeRows(rows, String(arg('--arms') || '').split(',').filter(Boolean)), null, 1));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
await run();
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|
package/scripts/wired-check.mjs
CHANGED
|
@@ -75,8 +75,16 @@ const STANDALONE = [
|
|
|
75
75
|
+ 'producer exists, and Step 15 owns any prepareCorpusCandidate wiring. The three modules it drives (source-units, '
|
|
76
76
|
+ 'produce-questions, validate-labels) are each wired to a real caller; this driver is the human entry point.'],
|
|
77
77
|
['gate', 'retired automatic-hook helper and manual benchmark retained for explicit human use; no workflow or scheduler invokes this expensive command'],
|
|
78
|
-
['
|
|
78
|
+
['route-gold-rank', 'human-run measurement harness: routes question sets through kb/forge-ask-all.mjs planSourceRoute (no model) and reports where the gold store lands, with Wilson intervals; nothing to schedule'],
|
|
79
|
+
['route-latency-warm', 'human-run measurement harness: paired warm latency of two or more search runtimes in one process, load-gated, with paired bootstrap intervals; minutes to hours of model time, never scheduled'],
|
|
80
|
+
['route-index-memory', 'human-run measurement harness: retained memory and cold/warm time of the router metadata index (needs node --expose-gc); nothing to schedule'],
|
|
81
|
+
['dream-issue-gate','pure Dream Cycle disposition policy; invoked by the external issue adapter, never a GitHub writer'],
|
|
79
82
|
['sync-census', 'explicit maintainer census writer; a destructive source-to-surface refresh is never scheduled'],
|
|
83
|
+
['customer-state-matrix', 'human-run release-qualification harness (2026-09-30): applies ONE published release through the real '
|
|
84
|
+
+ 'customer door to a COPY of a real install in one customer state at a time; it downloads releases and writes multi-GB '
|
|
85
|
+
+ 'scratch trees, so it is never scheduled. Its install seam is corpus-canary.mjs\'s own (imported), and the states it '
|
|
86
|
+
+ 'found are pinned as unit tests (tests/unit/customer-state-matrix.test.mjs and the per-defect tests).'],
|
|
87
|
+
['hook-qualify', 'human-run hook qualification matrix (claude/codex/grok host contracts, repeat + concurrency, optional real-host Layer 2 that spends a real turn per host) run on an idle machine before a hook release; its core and host scanners are imported by it and by tests/unit/hook-qualify.test.mjs, and it is deliberately not scheduled or in CI because Layer 2 needs signed-in hosts'],
|
|
80
88
|
['grounding-turn-replay', 'human-run measurement harness for grounding-turn-gate.mjs (ADR-0030 #1 and shadow #2/#3): replays real transcripts read-only through the same pure functions the hooks call, sharing completion-claim-replay.mjs\'s turn walkers; nothing to schedule'],
|
|
81
89
|
['duplicate-gate-replay','human-run tuning harness for plugin/scripts/duplicate-gate.mjs: replays the gate over git history, read-only, when its thresholds are re-tuned; it imports the gate\'s own scoring functions, so there is no second copy to drift and nothing to schedule'],
|
|
82
90
|
['sync-commands', 'explicit maintainer alias synchronizer; run deliberately before release, never from a lifecycle hook'],
|
|
@@ -92,8 +100,9 @@ const STANDALONE = [
|
|
|
92
100
|
['lesson-ratify', 'the human control surface — a CLI is its entire purpose'],
|
|
93
101
|
['lesson-migrate-agentdb', 'one-time reconciliation between the two AgentDB lesson stores and the '
|
|
94
102
|
+ 'one plugin lesson store, run deliberately by a human/model, never scheduled. Completion is '
|
|
95
|
-
+ '
|
|
96
|
-
+ 'canary
|
|
103
|
+
+ 'checked by tests/diagnostics/lesson-migrate-agentdb.test.mjs (0 pending rows), a machine drift '
|
|
104
|
+
+ 'canary over the developer\'s own AgentDB stores (outside the hermetic suite; run it by hand) rather '
|
|
105
|
+
+ 'than automated invocation — the same shape as lesson-seed/lesson-ratify above.'],
|
|
97
106
|
['stamp-sweep', 'ADR-056 §2 — the ONE-TIME backfill half of the stamp rule. A human runs it once '
|
|
98
107
|
+ '(--apply) to reach the files nobody is editing; the ongoing half is the md-stamp PostToolUse '
|
|
99
108
|
+ 'hook, which IS wired. Deliberately not in a gate: it WRITES to documents, and a writer that '
|
|
@@ -352,6 +361,11 @@ const CALLER_ROOTS = [
|
|
|
352
361
|
];
|
|
353
362
|
const CALLER_EXTS = new Set(['.mjs', '.js', '.sh', '.json', '.html', '.yml', '.yaml']);
|
|
354
363
|
export const REQUIRED_OPERATIONAL_EXPORTS = [
|
|
364
|
+
// 4.4 update robustness (2026-09-30): bin/install.mjs syncHostsAfterUpdate replaces an owned stale
|
|
365
|
+
// Console and prunes dead Console receipts through these; host claude/codex calls go through runHostCli.
|
|
366
|
+
{ rel: 'scripts/console-instances.mjs', symbol: 'replaceStaleConsoles' },
|
|
367
|
+
{ rel: 'scripts/console-instances.mjs', symbol: 'readConsoleReceipts' },
|
|
368
|
+
{ rel: 'scripts/host-cli.mjs', symbol: 'runHostCli' },
|
|
355
369
|
{ rel: 'scripts/corpus-reconcile.mjs', symbol: 'syncCorpusInputs' },
|
|
356
370
|
{ rel: 'scripts/corpus-aggregates.mjs', symbol: 'rebuildCorpusAggregates' },
|
|
357
371
|
{ rel: 'scripts/corpus-reconcile.mjs', symbol: 'acquireCorpusGeneration' },
|