ruvnet-brain 4.4.0 → 4.5.0
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 +3 -3
- package/bin/install.mjs +679 -121
- package/console/app.js +178 -81
- package/console/index.html +1 -1
- package/console/install-architecture.html +1 -0
- package/console/scope.css +4 -1
- package/console/style.css +13 -0
- package/console/tips.html +4 -4
- package/kb/brain-profile.mjs +1 -0
- package/kb/corpus-release-identity.mjs +1 -1
- package/kb/forge-update.mjs +41 -17
- package/kb/model-requirements.mjs +4 -1
- package/kb/update-storage-transaction.mjs +79 -0
- package/kb/zip-extract.mjs +22 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +5 -4
- package/plugin/commands/configure.md +5 -4
- package/plugin/commands/rnb-brief.md +41 -0
- package/plugin/commands/rnb.md +80 -0
- package/plugin/commands/rnbc.md +80 -0
- package/plugin/commands/rvbc.md +5 -4
- package/plugin/commands/rvcb.md +5 -4
- package/plugin/commands/whats-new.md +4 -4
- package/plugin/mcp/server.mjs +10 -2
- package/plugin/scripts/advocacy-route.mjs +59 -23
- package/plugin/scripts/anticipate.sh +4 -0
- package/plugin/scripts/brain-confirmation.mjs +258 -0
- package/plugin/scripts/brain-footprint.mjs +494 -0
- package/plugin/scripts/brain-location.mjs +47 -0
- package/plugin/scripts/capability-registry.mjs +11 -1
- package/plugin/scripts/continuity-brief.mjs +324 -0
- package/plugin/scripts/continuity-events.mjs +327 -0
- package/plugin/scripts/continuity-journal.mjs +500 -0
- package/plugin/scripts/decision-gate.mjs +56 -3
- package/plugin/scripts/footprint-io.mjs +186 -0
- package/plugin/scripts/ground-before-write.sh +8 -1
- package/plugin/scripts/ground-ruvnet.sh +103 -12
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-substance.mjs +1 -1
- package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
- package/plugin/scripts/hook-input.mjs +78 -4
- package/plugin/scripts/kb-copy-proof.mjs +148 -0
- package/plugin/scripts/lesson-bridge.mjs +6 -2
- package/plugin/scripts/nightly-controller.mjs +8 -1
- package/plugin/scripts/node-sqlite.mjs +41 -0
- package/plugin/scripts/package-cards.json +797 -0
- package/plugin/scripts/package-cards.rvf +0 -0
- package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
- package/plugin/scripts/package-cards.rvf.meta.json +1 -0
- package/plugin/scripts/package-recommender-client.mjs +138 -0
- package/plugin/scripts/package-recommender-flag.mjs +30 -0
- package/plugin/scripts/package-recommender.mjs +391 -0
- package/plugin/scripts/project-progression-outbox.mjs +26 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -2
- package/plugin/scripts/project-progression-store.mjs +153 -6
- package/plugin/scripts/protect-brain-state.sh +4 -1
- package/plugin/scripts/session-snapshot-hook.mjs +225 -41
- package/plugin/scripts/session-start-budget.mjs +1 -0
- package/plugin/scripts/session-start-core.mjs +34 -4
- package/plugin/scripts/session-start-health.mjs +7 -1
- package/plugin/scripts/session-start-update-plane.mjs +35 -0
- package/plugin/scripts/turn-outcome-capture.mjs +12 -1
- package/plugin/scripts/unprompted-runtime.mjs +2 -2
- package/plugin/skills/brain-console/SKILL.md +3 -3
- package/plugin/skills/rnbc/SKILL.md +24 -0
- package/plugin/skills/rvbc/SKILL.md +2 -2
- package/scripts/approved-runtime.mjs +2 -2
- package/scripts/ci/warm-brain-models.mjs +28 -0
- package/scripts/codex-hook-trust.mjs +94 -0
- package/scripts/console-instances.mjs +70 -12
- package/scripts/console-runtime-identity.mjs +5 -0
- package/scripts/corpus-canary.mjs +46 -6
- package/scripts/corpus-dispatch-decision.mjs +2 -2
- package/scripts/corpus-promotion.mjs +1 -1
- package/scripts/full-suite-gate.mjs +9 -2
- package/scripts/hook-qualify-hosts.mjs +15 -3
- package/scripts/host-install-matrix.mjs +63 -2
- package/scripts/human-approval-phrases.mjs +46 -0
- package/scripts/installed-brain-health.mjs +53 -0
- package/scripts/move-brain.mjs +310 -0
- package/scripts/onboarding-console.mjs +93 -10
- package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
- package/scripts/oracle/abstain-trace.mjs +139 -0
- package/scripts/oracle/doc2query-generate.mjs +162 -0
- package/scripts/oracle/doc2query-reach.mjs +110 -0
- package/scripts/oracle/judge-train.mjs +158 -0
- package/scripts/oracle/need-set-split.mjs +48 -0
- package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
- package/scripts/package-cards.mjs +374 -0
- package/scripts/publication-receipt.mjs +37 -9
- package/scripts/recommendation-e2e.mjs +110 -0
- package/scripts/recommendation-eval.mjs +105 -0
- package/scripts/recommendation-floor.mjs +56 -0
- package/scripts/recommendation-judge-score.mjs +74 -0
- package/scripts/recommendation-latency.mjs +95 -0
- package/scripts/recommendation-real-host-score.mjs +76 -0
- package/scripts/recommendation-real-host.mjs +137 -0
- package/scripts/release-channel-kind.mjs +1 -1
- package/scripts/release-environment-policy.mjs +33 -0
- package/scripts/single-source-check.mjs +15 -10
- package/scripts/sync-commands.mjs +5 -2
- package/scripts/wired-check.mjs +17 -2
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
// `codex` host is wired — this is the knowledge channel, not the host matrix.
|
|
36
36
|
//
|
|
37
37
|
// node scripts/corpus-canary.mjs --repo owner/name --tag corpus-sha256-<64hex> --approved-version X.Y.Z
|
|
38
|
-
// --work <dir> --verdict-out <file> [--api-base URL] [--installed-kb <dir> --home <dir>]
|
|
38
|
+
// --work <dir> --verdict-out <file> [--api-base URL] [--installed-kb <dir> --home <dir>] [--footprint-updates]
|
|
39
39
|
// exit 0 = PASS, 1 = FAIL (verdict written either way), 2 = usage.
|
|
40
40
|
|
|
41
41
|
import crypto from 'node:crypto';
|
|
@@ -45,6 +45,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|
|
45
45
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
46
46
|
import { addPrivateStore, resolveRuntime, resultFromRefreshReceipt, runInstallerDoor } from './customer-seams.mjs';
|
|
47
47
|
import { CANARY_VERDICT_KIND, CORPUS_TAG_PATTERN, REQUIRED_CANARY_CHECKS } from './corpus-promotion.mjs';
|
|
48
|
+
import { inventoryFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
48
49
|
|
|
49
50
|
export const PUBLIC_API = 'https://api.github.com';
|
|
50
51
|
export const FRESHNESS_LIMIT_MS = 48 * 3_600_000;
|
|
@@ -331,8 +332,30 @@ async function fetchReleaseList({ apiBase, repo }) {
|
|
|
331
332
|
return response.json();
|
|
332
333
|
}
|
|
333
334
|
|
|
335
|
+
/** The footprint of a customer HOME after one update (plugin/scripts/brain-footprint.mjs, never restated). */
|
|
336
|
+
export function footprintSnapshot({ home, kbDir }) {
|
|
337
|
+
const fp = inventoryFootprint({ env: { HOME: home, RUVNET_BRAIN_KB: kbDir, RUVNET_BRAIN_HOME: path.dirname(kbDir) }, home });
|
|
338
|
+
const receipts = fp.breakdown.receipts || 0;
|
|
339
|
+
return { kbCopies: fp.kbCopies, netBytes: fp.totalBytes - receipts, receipts, budgetBytes: fp.budgetBytes,
|
|
340
|
+
withinBudget: fp.withinBudget, copies: fp.cruft.filter((i) => /kb-copy|quarantine|install-/.test(i.kind)).map((i) => i.path) };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Three updates, one KB: exactly one copy after each, nothing that must not exist, no net growth. */
|
|
344
|
+
export function judgeFootprintStability(inventories, { growthAllowanceBytes = 1024 * 1024 } = {}) {
|
|
345
|
+
const problems = [];
|
|
346
|
+
inventories.forEach((inv, index) => {
|
|
347
|
+
if (inv.exitCode !== undefined && inv.exitCode !== 0) problems.push(`update ${index + 1} exited ${inv.exitCode}`);
|
|
348
|
+
if (inv.kbCopies !== 1) problems.push(`update ${index + 1}: ${inv.kbCopies} KB copies (${inv.copies.join(', ')})`);
|
|
349
|
+
if (!inv.withinBudget) problems.push(`update ${index + 1}: over the footprint budget`);
|
|
350
|
+
});
|
|
351
|
+
const growth = inventories.at(-1).netBytes - inventories[0].netBytes;
|
|
352
|
+
if (growth > growthAllowanceBytes) problems.push(`grew ${growth} bytes across ${inventories.length} updates (receipts excluded)`);
|
|
353
|
+
return problems.length ? { ok: false, detail: problems.join('; ') }
|
|
354
|
+
: { ok: true, detail: `${inventories.length} updates: 1 KB copy each time, within budget, net growth ${growth} bytes` };
|
|
355
|
+
}
|
|
356
|
+
|
|
334
357
|
async function runCase({ name, spec, repo, tag, approvedVersion, release, work, home, apiBase, install, now,
|
|
335
|
-
retryDelayMs, maxAttempts, log, writerRoot, approvedInstaller }) {
|
|
358
|
+
retryDelayMs, maxAttempts, log, writerRoot, approvedInstaller, footprintUpdates = false }) {
|
|
336
359
|
fs.mkdirSync(home, { recursive: true });
|
|
337
360
|
let runtime = approvedVersion;
|
|
338
361
|
if (spec.runtime !== 'approved') runtime = resolveRuntime(spec.runtime, { candidateTag: `v${approvedVersion}`, releases: await fetchReleaseList({ apiBase, repo }) });
|
|
@@ -362,6 +385,22 @@ async function runCase({ name, spec, repo, tag, approvedVersion, release, work,
|
|
|
362
385
|
: (fs.existsSync(resultFile) ? readJson(resultFile) : null);
|
|
363
386
|
const judged = await judgeCanary({ tag, approvedVersion, release, updater, before, beforeModules, beforeCoverage,
|
|
364
387
|
kbDir: fs.realpathSync(kbDir), home: fs.realpathSync(home), now: now() });
|
|
388
|
+
// ADR-0098 footprint check, OPT-IN (--footprint-updates): two more updates of the same customer through
|
|
389
|
+
// the same door, each followed by the footprint inventory. Off by default because its CI runtime on the
|
|
390
|
+
// real ~1.4 GB brain (two more full-tree validations) has not been measured yet; the offline fixture
|
|
391
|
+
// run is in tests/unit/corpus-canary.test.mjs.
|
|
392
|
+
if (footprintUpdates && name === 'clean' && updater.exitCode === 0) {
|
|
393
|
+
const inventories = [footprintSnapshot({ home: fs.realpathSync(home), kbDir })];
|
|
394
|
+
for (let extra = 0; extra < 2; extra += 1) {
|
|
395
|
+
fs.rmSync(resultFile, { force: true });
|
|
396
|
+
const again = spec.door === 'installer'
|
|
397
|
+
? await runInstallerDoor({ installer: await approvedInstaller(), env: childEnv, cwd: home })
|
|
398
|
+
: await runUpdater({ kbDir, env: childEnv, resultFile });
|
|
399
|
+
log(`--- [${name}] footprint update ${extra + 2} exit ${again.exitCode}\n${again.output}`);
|
|
400
|
+
inventories.push({ ...footprintSnapshot({ home: fs.realpathSync(home), kbDir }), exitCode: again.exitCode });
|
|
401
|
+
}
|
|
402
|
+
judged.checks.push({ name: 'footprint-three-updates', ...judgeFootprintStability(inventories) });
|
|
403
|
+
}
|
|
365
404
|
if (overlay) {
|
|
366
405
|
const after = Object.fromEntries(Object.keys(overlay.digests).map((f) => [f,
|
|
367
406
|
fs.existsSync(path.join(kbDir, f)) ? sha256File(path.join(kbDir, f)) : null]));
|
|
@@ -403,7 +442,7 @@ export async function runCanary({
|
|
|
403
442
|
repo, tag, approvedVersion, work, apiBase = PUBLIC_API, install = installCustomer, home = path.join(work, 'home'),
|
|
404
443
|
env = process.env, now = () => Date.now(), retryDelayMs = 30_000, maxAttempts = 3, log = () => {},
|
|
405
444
|
cases = ['clean'], writerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
|
|
406
|
-
approvedInstaller = null,
|
|
445
|
+
approvedInstaller = null, footprintUpdates = false,
|
|
407
446
|
}) {
|
|
408
447
|
if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('--repo must be owner/name');
|
|
409
448
|
if (!CORPUS_TAG_PATTERN.test(String(tag || ''))) throw new Error('--tag must be corpus-sha256-<64 hex>');
|
|
@@ -426,7 +465,7 @@ export async function runCanary({
|
|
|
426
465
|
return installerPath;
|
|
427
466
|
});
|
|
428
467
|
const common = { repo, tag, approvedVersion, release, apiBase, install, now, retryDelayMs, maxAttempts, log, writerRoot,
|
|
429
|
-
approvedInstaller: resolveInstaller };
|
|
468
|
+
approvedInstaller: resolveInstaller, footprintUpdates };
|
|
430
469
|
let cleanKb = null;
|
|
431
470
|
const clean = await runCase({ ...common, name: 'clean', spec: CANARY_CASES.clean, work, home,
|
|
432
471
|
install: async (args) => { const installed = await install(args); cleanKb = physical(installed.kbDir); return installed; } });
|
|
@@ -476,7 +515,7 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
476
515
|
const work = opt('--work') && path.resolve(opt('--work'));
|
|
477
516
|
const verdictOut = opt('--verdict-out') && path.resolve(opt('--verdict-out'));
|
|
478
517
|
if (!work || !verdictOut) {
|
|
479
|
-
process.stderr.write('usage: corpus-canary.mjs --repo o/n --tag corpus-sha256-<hex> --approved-version X.Y.Z --work <dir> --verdict-out <file> [--api-base URL] [--installed-kb <dir> --home <dir>] [--cases clean,private-overlay,older-runtime]\n');
|
|
518
|
+
process.stderr.write('usage: corpus-canary.mjs --repo o/n --tag corpus-sha256-<hex> --approved-version X.Y.Z --work <dir> --verdict-out <file> [--api-base URL] [--installed-kb <dir> --home <dir>] [--cases clean,private-overlay,older-runtime] [--footprint-updates]\n');
|
|
480
519
|
return 2;
|
|
481
520
|
}
|
|
482
521
|
fs.mkdirSync(work, { recursive: true });
|
|
@@ -488,7 +527,8 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
488
527
|
let record;
|
|
489
528
|
try {
|
|
490
529
|
record = await runCanary({ repo: opt('--repo'), tag: opt('--tag'), approvedVersion: opt('--approved-version'), work,
|
|
491
|
-
apiBase: opt('--api-base') || PUBLIC_API, install, home: path.resolve(opt('--home') || path.join(work, 'home')), log, cases
|
|
530
|
+
apiBase: opt('--api-base') || PUBLIC_API, install, home: path.resolve(opt('--home') || path.join(work, 'home')), log, cases,
|
|
531
|
+
footprintUpdates: argv.includes('--footprint-updates') });
|
|
492
532
|
} catch (error) {
|
|
493
533
|
// Could not even reach a judgement = the consumer did not accept. Still a written verdict.
|
|
494
534
|
record = { schemaVersion: 1, kind: CANARY_VERDICT_KIND, verdict: 'FAIL', repo: opt('--repo') || null, tag: opt('--tag') || null,
|
|
@@ -48,8 +48,8 @@ export function killSwitchOff(nightlyVar) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* @param nightlyVar repository variable CORPUS_NIGHTLY (armed unless it reads `off`, any case; the
|
|
52
|
-
*
|
|
51
|
+
* @param nightlyVar repository variable CORPUS_NIGHTLY (armed unless it reads `off`, any case; the newest
|
|
52
|
+
* install-verified code release is the consent — no person approves it)
|
|
53
53
|
* @param resolution { status: 'resolved', release: {tag, version, sourceSha} }
|
|
54
54
|
* | { status: 'not-yet-verified' } | { status: 'invalid', reason }
|
|
55
55
|
*/
|
|
@@ -33,7 +33,7 @@ export function evaluateCorpusPromotion({ tag, generation, currentLatest } = {})
|
|
|
33
33
|
if (!CORPUS_TAG_PATTERN.test(String(currentLatest.tagName || ''))) {
|
|
34
34
|
// A code release holds latest. Corpus promotion does not regress the runtime, because
|
|
35
35
|
// scripts/approved-runtime.mjs has already proved this archive's executables ARE the
|
|
36
|
-
//
|
|
36
|
+
// install-verified shipped runtime, byte for byte.
|
|
37
37
|
return {
|
|
38
38
|
allowed: true,
|
|
39
39
|
reason: `current latest ${currentLatest.tagName} is a code release; runtime equality is enforced by the approved runtime pin`,
|
|
@@ -50,10 +50,17 @@ export function redFiles(report, quarantine, root = ROOT) {
|
|
|
50
50
|
* assertion and stays RED. Matched on the failure text only, never the test title. */
|
|
51
51
|
const VITEST_TIMEOUT = /\b(?:Test|Hook) timed out in \d+ ?ms\b/;
|
|
52
52
|
const NUMERIC_BOUND = /\bexpected -?\d+(?:\.\d+)? to be (?:less|greater) than (?:or equal to )?-?\d+(?:\.\d+)?/;
|
|
53
|
-
|
|
53
|
+
// A DURATION, stated: a number with a time unit, or a percentile. This alone binds a bound to time.
|
|
54
|
+
const UNIT_CUE = /\d ?(?:ms|s|sec|seconds?)\b|\bp9[59]\b/i;
|
|
55
|
+
// A duration WORD is weaker: "retries before timeout: expected 5 to be less than 3" is a count. A word
|
|
56
|
+
// cue binds only a MEASURED value — a fractional left-hand number, which is what performance.now()-based
|
|
57
|
+
// timings produce ("…the ceiling: expected 337.294918 to be less than 300"); integer counts stay RED.
|
|
58
|
+
const WORD_CUE = /\b(?:took|elapsed|duration|latency|budget|ceiling|timeout)\b/i;
|
|
59
|
+
const MEASURED = /\bexpected -?\d+\.\d+ to be (?:less|greater) than/;
|
|
54
60
|
export const isTimingFailure = (message) => {
|
|
55
61
|
const text = String(message || '').slice(0, 2000);
|
|
56
|
-
return VITEST_TIMEOUT.test(text)
|
|
62
|
+
return VITEST_TIMEOUT.test(text)
|
|
63
|
+
|| (NUMERIC_BOUND.test(text) && (UNIT_CUE.test(text) || (WORD_CUE.test(text) && MEASURED.test(text))));
|
|
57
64
|
};
|
|
58
65
|
/** More flaky tests than this in one run fails the gate: load-sensitivity that wide is itself a defect. */
|
|
59
66
|
export const MAX_FLAKY = 3;
|
|
@@ -58,7 +58,15 @@ export function scanCodex({ stdout, stderr }) {
|
|
|
58
58
|
for (const l of lines(stderr)) if (/hook/i.test(l)) f.push(`stderr: ${l.slice(0, 200)}`);
|
|
59
59
|
return { findings: [...new Set(f)], unrelatedStderr: lines(stderr).filter((l) => !/hook/i.test(l)).slice(0, 3) };
|
|
60
60
|
}
|
|
61
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* Pure: scan a real `grok -p --debug-file` run.
|
|
63
|
+
*
|
|
64
|
+
* MEASURED 2026-10-01 (grok 1.0.13, docs/research/grok-hooks-2026-10-01.md): `grok -p` discovers the Brain
|
|
65
|
+
* from Claude Code's plugin cache ("plugin discovered name=ruvnet-brain … has_hooks=true") and attaches its
|
|
66
|
+
* MCP server, but the session's "loaded hooks hook_count=8" was exactly the 4 hooks in ~/.claude/settings.json
|
|
67
|
+
* plus the 4 in ~/.grok/hooks/ — no plugin's hooks at all — and no Brain hook ever completed. A discovered
|
|
68
|
+
* plugin whose hooks never dispatch is therefore a FINDING here, never a silent pass.
|
|
69
|
+
*/
|
|
62
70
|
export function scanGrok({ stdout, stderr, debug = '' }) {
|
|
63
71
|
const f = []; let loaded = null; const pluginHooks = /plugin discovered name=ruvnet-brain .*has_hooks=true/.test(debug);
|
|
64
72
|
for (const l of lines(debug)) {
|
|
@@ -68,7 +76,11 @@ export function scanGrok({ stdout, stderr, debug = '' }) {
|
|
|
68
76
|
}
|
|
69
77
|
if (stderr.trim()) f.push(`process stderr: ${JSON.stringify(stderr.trim().slice(0, 300))}`);
|
|
70
78
|
if (/hook/i.test(stdout) && /(error|failed)/i.test(stdout.match(/.{0,80}hook.{0,120}/i)?.[0] || '')) f.push('hook error text in the output stream');
|
|
71
|
-
|
|
79
|
+
const brainHooksRan = /hook completed hook_name=\S*ruvnet/.test(debug);
|
|
80
|
+
if (pluginHooks && !brainHooksRan) {
|
|
81
|
+
f.push(`Brain plugin hooks discovered but never ran (session loaded ${loaded ?? '?'} hooks): grok -p loads only global hooks — see docs/research/grok-hooks-2026-10-01.md`);
|
|
82
|
+
}
|
|
83
|
+
return { findings: [...new Set(f)], hooksLoaded: loaded, pluginHasHooks: pluginHooks, pluginHooksRan: brainHooksRan };
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
async function runClaude(root, dbgDir) {
|
|
@@ -98,7 +110,7 @@ async function runGrok(root, dbgDir) {
|
|
|
98
110
|
const debug = fs.existsSync(debugFile) ? fs.readFileSync(debugFile, 'utf8') : '';
|
|
99
111
|
const s = scanGrok({ stdout: r.stdout || '', stderr: r.stderr || '', debug });
|
|
100
112
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
101
|
-
return { status: r.status === 0 && !s.findings.length ? 'PASS' : 'FAIL', exit: r.status, ...s, note: '
|
|
113
|
+
return { status: r.status === 0 && !s.findings.length ? 'PASS' : 'FAIL', exit: r.status, ...s, note: 'real HOME: the Brain is discovered from Claude Code\'s plugin cache; whether its hooks actually RUN in grok -p is what is measured (they did not on grok 1.0.13)' };
|
|
102
114
|
}
|
|
103
115
|
|
|
104
116
|
export async function layer2(hosts, { maxLoad = 40, waitMs = 15 * 60_000, log = () => {}, root = REPO } = {}) {
|
|
@@ -37,6 +37,24 @@ export const SELF_STORE_PROOF_K = 1;
|
|
|
37
37
|
export const RELEASE_SEARCH_QUERY = 'repo:ruvnet-brain How does RuvNet Brain prove a public release artifact?';
|
|
38
38
|
export const HOST_WARMUP_TIMEOUT_MS = 300_000;
|
|
39
39
|
export const RELEASE_SEARCH_DEADLINE_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
// RETRIEVAL-CANARY FIRST-PASS BOUND, PER OS (4.5, approved by the release coordinator 2026-10-01).
|
|
42
|
+
// This bounds a CI runner's COLD canary search — the first time one warm MCP worker meets each of the
|
|
43
|
+
// 19 canary repos. It is NOT a product latency target (CONTRIBUTING.md, "Hosts and verification").
|
|
44
|
+
// RULE: the bound is the worst measured first-pass query x 1.5, rounded up to whole seconds, and never
|
|
45
|
+
// below RELEASE_SEARCH_DEADLINE_MS. The measurement is the input, so the bound cannot drift silently:
|
|
46
|
+
// changing it means changing the evidence below. Worst first-pass query, one warm worker, public 4.4.1:
|
|
47
|
+
// macOS 33,351 ms legacy:metaharness, macos-latest (ci-probe run 36886413597); 7 samples over runs
|
|
48
|
+
// 36885062160 / 36886413597 / 36887732228 on macos-latest, -15, -14: maxima
|
|
49
|
+
// 22.4, 21.7, 22.6, 33.4, 21.5, 22.9, 30.9 s; second-pass maxima 13.5-17.5 s
|
|
50
|
+
// Linux 11,741 ms ubuntu-latest (run 36885062160) -> 17.6 s, so the 30 s floor holds
|
|
51
|
+
// Windows 10,679 ms windows-latest (run 36885062160) -> 16.0 s, so the 30 s floor holds
|
|
52
|
+
export const CANARY_WORST_FIRST_PASS_MS = Object.freeze({ darwin: 33_351, linux: 11_741, win32: 10_679 });
|
|
53
|
+
export function canarySearchDeadlineMs(platform = process.platform) {
|
|
54
|
+
const worst = CANARY_WORST_FIRST_PASS_MS[platform];
|
|
55
|
+
if (!Number.isFinite(worst)) return RELEASE_SEARCH_DEADLINE_MS;
|
|
56
|
+
return Math.max(RELEASE_SEARCH_DEADLINE_MS, Math.ceil((worst * 1.5) / 1000) * 1000);
|
|
57
|
+
}
|
|
40
58
|
// The shared model-cache prewarm's own candidate pool size, named so the test asserting on it
|
|
41
59
|
// derives from this constant instead of restating the digit as a second, driftable literal.
|
|
42
60
|
export const PREWARM_POOL_SIZE = 8;
|
|
@@ -282,7 +300,10 @@ export async function runHostMatrixAsync({
|
|
|
282
300
|
try {
|
|
283
301
|
const serverPath = resolveMcpServer(context);
|
|
284
302
|
// One installed worker per host: model/store state survives smoke and every sealed case.
|
|
285
|
-
session = runMcpSearch === runInstalledMcpSearch ?
|
|
303
|
+
session = runMcpSearch === runInstalledMcpSearch ? createRestartingMcpSession({
|
|
304
|
+
open: () => createInstalledMcpSession({ serverPath, env: context.env }),
|
|
305
|
+
warm: (fresh) => fresh.search({ query: SELF_STORE_PROOF_QUERY, k: SELF_STORE_PROOF_K, timeoutMs: HOST_WARMUP_TIMEOUT_MS }),
|
|
306
|
+
}) : null;
|
|
286
307
|
const searchMcp = session ? (args) => session.search(args) : runMcpSearch;
|
|
287
308
|
// The installed shell warms its in-process models and stores asynchronously from MCP
|
|
288
309
|
// initialize. Candidate qualification must give that same worker the same cited readiness
|
|
@@ -313,7 +334,7 @@ export async function runHostMatrixAsync({
|
|
|
313
334
|
warmupGrounding, error: `MCP search grounding unproven for ${context.mode}` };
|
|
314
335
|
let receipt;
|
|
315
336
|
if (retrieval) {
|
|
316
|
-
receipt = await runRetrievalCanaries({ ...retrieval, searchTimeoutMs:
|
|
337
|
+
receipt = await runRetrievalCanaries({ ...retrieval, searchTimeoutMs: canarySearchDeadlineMs(),
|
|
317
338
|
search: async ({ query, k, timeoutMs }) => {
|
|
318
339
|
const result = await searchMcp({ mode: context.mode, serverPath, env: context.env, query, k, timeoutMs });
|
|
319
340
|
if (result.error || result.status !== 0) throw new Error(`canary MCP search failed: ${processDiagnostic(result)}`);
|
|
@@ -478,6 +499,46 @@ export function createInstalledMcpSession({ serverPath, env, timeout = 300_000,
|
|
|
478
499
|
};
|
|
479
500
|
}
|
|
480
501
|
|
|
502
|
+
// ONE SLOW QUERY MUST FAIL ONLY ITSELF. A search that times out or fails closes its session — the
|
|
503
|
+
// worker may be wedged mid-rerank — and a closed session answers every later call with that same
|
|
504
|
+
// stored error, instantly. So one slow canary case became every remaining case "timed out" (4.4.1
|
|
505
|
+
// preflight attempt 1: cases 9-18 ETIMEDOUT; recovery 36881395598: cases 14-18). This keeps one warm
|
|
506
|
+
// worker for the whole lane, as a customer has, and only after a failure opens a fresh one, warmed
|
|
507
|
+
// on the warm-up bound (never on the next case's deadline) before that case is timed.
|
|
508
|
+
export function createRestartingMcpSession({ open, warm = null }) {
|
|
509
|
+
let session = open();
|
|
510
|
+
let closed = false;
|
|
511
|
+
let restarts = 0;
|
|
512
|
+
let queue = Promise.resolve();
|
|
513
|
+
const failed = (result) => Boolean(result?.error) || result?.status !== 0;
|
|
514
|
+
const run = async (args) => {
|
|
515
|
+
if (closed) return { status: null, signal: null, error: new Error('MCP session closed'), stdout: '', stderr: '' };
|
|
516
|
+
if (!session) {
|
|
517
|
+
session = open();
|
|
518
|
+
restarts += 1;
|
|
519
|
+
if (warm) {
|
|
520
|
+
const warmed = await warm(session);
|
|
521
|
+
if (failed(warmed)) { const dead = session; session = null; await dead.close(); return warmed; }
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
const result = await session.search(args);
|
|
525
|
+
if (failed(result)) { const dead = session; session = null; await dead.close(); }
|
|
526
|
+
return result;
|
|
527
|
+
};
|
|
528
|
+
return {
|
|
529
|
+
search(args) {
|
|
530
|
+
const result = queue.then(() => run(args));
|
|
531
|
+
queue = result.then(() => undefined, () => undefined);
|
|
532
|
+
return result;
|
|
533
|
+
},
|
|
534
|
+
async close() {
|
|
535
|
+
closed = true;
|
|
536
|
+
if (session) { const live = session; session = null; await live.close(); }
|
|
537
|
+
},
|
|
538
|
+
get restarts() { return restarts; },
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
|
|
481
542
|
async function runInstalledMcpSearch(options) {
|
|
482
543
|
const session = createInstalledMcpSession(options);
|
|
483
544
|
try { return await session.search(options); } finally { await session.close(); }
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// human-approval-phrases.mjs — the matcher behind single-source B6 (tracked instructions) and B12 (the
|
|
2
|
+
// local CLAUDE.md / AGENTS.md): no instruction may put a person clicking in GitHub back into the release
|
|
3
|
+
// path (CONTRIBUTING.md, "What replaces a human approval").
|
|
4
|
+
//
|
|
5
|
+
// Two gaps the 4.3.39 review measured, both closed here:
|
|
6
|
+
// • Markdown emphasis broke the match: "Stuart's approval** of the …" did not match "Stuart's approval of".
|
|
7
|
+
// Lines are matched after stripping `*` and backticks.
|
|
8
|
+
// • B6 exempted any line containing "never" ANYWHERE, so "The owner approves the Production deployment;
|
|
9
|
+
// never skip it." passed. A negation now counts only inside the same clause as the matched phrase.
|
|
10
|
+
|
|
11
|
+
/** Phrases that re-introduce a person as a release gate (B6, tracked instruction files). */
|
|
12
|
+
export const HUMAN_APPROVAL_STEP = /(owner|stuart'?s?|maintainer) (approves?|approval|click|must approve)\b[^.;]*(deployment|release|publish|gate)|approves? the Production|hand (it|the work) over[^.;]*click|required[- ]reviewers?\b|standing authori[sz]ation permits|owner[- ]approved/i;
|
|
13
|
+
|
|
14
|
+
/** B12: the same, plus the local-file drifts that file has carried (npx ruflo, retired npm scripts). */
|
|
15
|
+
export const LOCAL_INSTRUCTION_DRIFT = /npx (-y )?(@claude-flow|claude-flow|ruflo)\b|standing authori[sz]ation permits|owner (approves?|click)|Stuart's approval (of|in GitHub)|approves? the Production|hand (it|the work) over[^.]*click|npm run (build|dev|test:integration|test:coverage|test:security)\b|not direct Agent tool|owner[- ]approved/i;
|
|
16
|
+
|
|
17
|
+
const NEGATION = /\b(no|not|never|nobody|none|without|cannot|isn't|is not|removed|retired)\b/i;
|
|
18
|
+
|
|
19
|
+
/** Markdown emphasis and code ticks are formatting, not words. */
|
|
20
|
+
export const plainText = (line) => String(line).replace(/[*`]/g, '');
|
|
21
|
+
|
|
22
|
+
/** The clause around [start, end): bounded by . ; : ! ? or the line ends. */
|
|
23
|
+
function clauseAround(text, start, end) {
|
|
24
|
+
const before = text.slice(0, start);
|
|
25
|
+
const after = text.slice(end);
|
|
26
|
+
const from = Math.max(...['.', ';', ':', '!', '?'].map((c) => before.lastIndexOf(c))) + 1;
|
|
27
|
+
const stops = ['.', ';', ':', '!', '?'].map((c) => after.indexOf(c)).filter((i) => i >= 0);
|
|
28
|
+
const to = end + (stops.length ? Math.min(...stops) : after.length);
|
|
29
|
+
return text.slice(from, to);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Hits of `pattern` in `line` that are not negated in their own clause.
|
|
34
|
+
* @returns {string[]} the matched phrases that count
|
|
35
|
+
*/
|
|
36
|
+
export function approvalHits(line, pattern = HUMAN_APPROVAL_STEP, { allowNegation = true } = {}) {
|
|
37
|
+
const text = plainText(line);
|
|
38
|
+
const global = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : `${pattern.flags}g`);
|
|
39
|
+
const hits = [];
|
|
40
|
+
for (const match of text.matchAll(global)) {
|
|
41
|
+
const clause = clauseAround(text, match.index, match.index + match[0].length);
|
|
42
|
+
if (allowNegation && NEGATION.test(clause)) continue;
|
|
43
|
+
hits.push(match[0]);
|
|
44
|
+
}
|
|
45
|
+
return hits;
|
|
46
|
+
}
|
|
@@ -5,6 +5,7 @@ import { createHash } from 'node:crypto';
|
|
|
5
5
|
import { readInstalledRuntime, isCorpusReleaseTag } from '../kb/corpus-release-identity.mjs';
|
|
6
6
|
import { cmpVersion } from './stack-sync.mjs';
|
|
7
7
|
import { isRuntimeFile } from './approved-runtime.mjs';
|
|
8
|
+
import { modelCacheReady, requiredEmbedderModels, RERANKER_MODEL } from '../kb/model-requirements.mjs';
|
|
8
9
|
|
|
9
10
|
const SEARCH_FILES = ['forge-mcp-all.mjs', 'forge-ask-all.mjs', 'forge-rerank.mjs', 'card-lane.mjs'];
|
|
10
11
|
// Keep the doctor probe answerable and bounded. A health probe must exercise a real indexed
|
|
@@ -14,6 +15,58 @@ export function doctorSmokeArgs(cacheDir) {
|
|
|
14
15
|
return ['forge-ask-all.mjs', '--dir', cacheDir, '--q', DOCTOR_SMOKE_QUERY,
|
|
15
16
|
'--repos', 'ruvnet-brain', '--k', '3', '--pool', '8', '--bounded'];
|
|
16
17
|
}
|
|
18
|
+
|
|
19
|
+
// The models the doctor's question loads whose local copy is not complete. A partial download is
|
|
20
|
+
// cold, not ready: the reader would fetch it again inside the timed question.
|
|
21
|
+
export function coldModels(kbDir, modelCache) {
|
|
22
|
+
return [...requiredEmbedderModels(kbDir), RERANKER_MODEL]
|
|
23
|
+
.filter((model, i, all) => all.indexOf(model) === i && !modelCacheReady(modelCache, model));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// One-time model download + load + first forward pass, kept OUT of the timed question so the
|
|
27
|
+
// question's limit measures answering, not fetching. Exits 3 on a bundle that predates the hooks.
|
|
28
|
+
export const MODEL_WARMUP_SCRIPT = [
|
|
29
|
+
"const ask = await import('./forge-ask.mjs');",
|
|
30
|
+
"const rr = await import('./forge-rerank.mjs');",
|
|
31
|
+
"if (typeof ask.warmQueryEmbedder !== 'function' || typeof rr.warmReranker !== 'function') process.exit(3);",
|
|
32
|
+
'await ask.warmQueryEmbedder();',
|
|
33
|
+
'await rr.warmReranker();',
|
|
34
|
+
'process.exit(0);',
|
|
35
|
+
].join('\n');
|
|
36
|
+
export const MODEL_WARMUP_TIMEOUT_MS = 300_000;
|
|
37
|
+
|
|
38
|
+
// WHY the doctor's question produced no answer, from what spawnSync returned. "slow" is the reader's
|
|
39
|
+
// own deadline: kb/query-deadline.mjs describeDeadline() prints this line, naming the phase still
|
|
40
|
+
// running, only on that path (that module is not in the npm package, so its text is the contract
|
|
41
|
+
// here). The reader works and was mid-answer — a different fault, with different advice, from a crash.
|
|
42
|
+
export function classifySmokeFailure({ error, signal, status, stderr = '', secs, limitSecs }) {
|
|
43
|
+
// spawnSync delivers its own timeout as signal SIGTERM AND error ETIMEDOUT: a timeout, not a launch failure.
|
|
44
|
+
if (error?.code === 'ETIMEDOUT') return { kind: 'timeout', cause: `timed out after ${secs}s (240s limit) with no answer` };
|
|
45
|
+
if (error) return { kind: 'launch', cause: `could not launch the reader: ${error.message}` };
|
|
46
|
+
if (signal === 'SIGTERM') return { kind: 'timeout', cause: `timed out after ${secs}s (240s limit) with no answer` };
|
|
47
|
+
if (signal) return { kind: 'killed', cause: `the reader was killed by ${signal} after ${secs}s` };
|
|
48
|
+
const phase = (String(stderr).match(/QUERY DEADLINE EXCEEDED — phase "([^"]+)"/) || [])[1];
|
|
49
|
+
if (status !== 0 && phase) {
|
|
50
|
+
return { kind: 'slow', phase,
|
|
51
|
+
cause: `still answering (phase "${phase}") when the ${limitSecs}s limit ran out, after ${secs}s — slow on this machine, not broken` };
|
|
52
|
+
}
|
|
53
|
+
if (status !== 0) return { kind: 'crash', cause: `the reader exited ${status} after ${secs}s` };
|
|
54
|
+
return { kind: 'empty', cause: `the reader exited 0 after ${secs}s but printed nothing` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Why the model warm-up did not finish (re-review B1). spawnSync's own timeout is signal SIGTERM WITH error
|
|
59
|
+
* ETIMEDOUT — a slow machine, advisory. A signal with NO error is the child dying on its own: SIGABRT,
|
|
60
|
+
* SIGSEGV, an OOM SIGKILL — a broken model runtime or cache, a failure with its own cause. The old test,
|
|
61
|
+
* `signal && !error`, had both backwards.
|
|
62
|
+
*/
|
|
63
|
+
export function classifyWarmupFailure({ error, signal, status, secs, limitSecs }) {
|
|
64
|
+
if (error?.code === 'ETIMEDOUT') return { kind: 'timeout', advisory: true, cause: `ran out of time after ${secs}s (${limitSecs}s limit) — slow on this machine, not broken` };
|
|
65
|
+
if (error) return { kind: 'launch', advisory: false, cause: `could not start: ${error.message}` };
|
|
66
|
+
if (signal) return { kind: 'crash', advisory: false, cause: `crashed (${signal}) after ${secs}s — the model runtime or its cache is broken` };
|
|
67
|
+
return { kind: 'exit', advisory: false, cause: `exited ${status} after ${secs}s` };
|
|
68
|
+
}
|
|
69
|
+
|
|
17
70
|
const version = value => typeof value === 'string' && /^v?\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(value)
|
|
18
71
|
? value.replace(/^v/, '') : null;
|
|
19
72
|
|