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.
Files changed (105) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +679 -121
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +258 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +68 -6
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +14 -2
  58. package/plugin/scripts/project-progression-store.mjs +153 -6
  59. package/plugin/scripts/protect-brain-state.sh +4 -1
  60. package/plugin/scripts/session-snapshot-hook.mjs +225 -41
  61. package/plugin/scripts/session-start-budget.mjs +1 -0
  62. package/plugin/scripts/session-start-core.mjs +34 -4
  63. package/plugin/scripts/session-start-health.mjs +7 -1
  64. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  65. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  66. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  67. package/plugin/skills/brain-console/SKILL.md +3 -3
  68. package/plugin/skills/rnbc/SKILL.md +24 -0
  69. package/plugin/skills/rvbc/SKILL.md +2 -2
  70. package/scripts/approved-runtime.mjs +2 -2
  71. package/scripts/ci/warm-brain-models.mjs +28 -0
  72. package/scripts/codex-hook-trust.mjs +94 -0
  73. package/scripts/console-instances.mjs +70 -12
  74. package/scripts/console-runtime-identity.mjs +5 -0
  75. package/scripts/corpus-canary.mjs +46 -6
  76. package/scripts/corpus-dispatch-decision.mjs +2 -2
  77. package/scripts/corpus-promotion.mjs +1 -1
  78. package/scripts/full-suite-gate.mjs +9 -2
  79. package/scripts/hook-qualify-hosts.mjs +15 -3
  80. package/scripts/host-install-matrix.mjs +63 -2
  81. package/scripts/human-approval-phrases.mjs +46 -0
  82. package/scripts/installed-brain-health.mjs +53 -0
  83. package/scripts/move-brain.mjs +310 -0
  84. package/scripts/onboarding-console.mjs +93 -10
  85. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  86. package/scripts/oracle/abstain-trace.mjs +139 -0
  87. package/scripts/oracle/doc2query-generate.mjs +162 -0
  88. package/scripts/oracle/doc2query-reach.mjs +110 -0
  89. package/scripts/oracle/judge-train.mjs +158 -0
  90. package/scripts/oracle/need-set-split.mjs +48 -0
  91. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  92. package/scripts/package-cards.mjs +374 -0
  93. package/scripts/publication-receipt.mjs +37 -9
  94. package/scripts/recommendation-e2e.mjs +110 -0
  95. package/scripts/recommendation-eval.mjs +105 -0
  96. package/scripts/recommendation-floor.mjs +56 -0
  97. package/scripts/recommendation-judge-score.mjs +74 -0
  98. package/scripts/recommendation-latency.mjs +95 -0
  99. package/scripts/recommendation-real-host-score.mjs +76 -0
  100. package/scripts/recommendation-real-host.mjs +137 -0
  101. package/scripts/release-channel-kind.mjs +1 -1
  102. package/scripts/release-environment-policy.mjs +33 -0
  103. package/scripts/single-source-check.mjs +15 -10
  104. package/scripts/sync-commands.mjs +5 -2
  105. 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 owner's
52
- * approval of the code release is the consent)
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
- // owner-approved shipped runtime, byte for byte.
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
- const DURATION_CUE = /\d ?ms\b|\bp9[59]\b|\btook\b|\belapsed\b|\bduration\b|\blatency\b|\bbudget\b|\bceiling\b|\btimeout\b/i;
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) || (NUMERIC_BOUND.test(text) && DURATION_CUE.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
- /** Pure: scan a real `grok -p --debug-file` run. */
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
- return { findings: [...new Set(f)], hooksLoaded: loaded, pluginHasHooks: pluginHooks, pluginHooksRan: /hook_name=plugin|hook_name=ruvnet/.test(debug) };
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: 'throwaway cwd is untrusted by Grok, so project/plugin hook loading there is part of what is measured' };
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 ? createInstalledMcpSession({ serverPath, env: context.env }) : null;
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: RELEASE_SEARCH_DEADLINE_MS,
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