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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +179 -36
  3. package/kb/brain-profile.mjs +17 -2
  4. package/kb/forge-update.mjs +6 -2
  5. package/kb/lifecycle-evidence-retention.mjs +12 -9
  6. package/kb/refresh-run.mjs +17 -1
  7. package/kb/update-storage-transaction.mjs +33 -5
  8. package/package.json +1 -1
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/hooks/codex-hooks.json +2 -2
  12. package/plugin/hooks/hooks.json +1 -1
  13. package/plugin/scripts/capability-registry.mjs +3 -3
  14. package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
  15. package/plugin/scripts/design-wall.sh +1 -0
  16. package/plugin/scripts/ground-before-write.sh +1 -0
  17. package/plugin/scripts/ground-ruvnet.sh +3 -3
  18. package/plugin/scripts/grounding-answer.mjs +129 -0
  19. package/plugin/scripts/grounding-stamp.sh +32 -31
  20. package/plugin/scripts/grounding-turn-evidence.mjs +146 -5
  21. package/plugin/scripts/grounding-turn-gate.mjs +25 -6
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/kling-preflight.sh +1 -0
  24. package/plugin/scripts/learn-capture.sh +1 -0
  25. package/plugin/scripts/project-progression-reader.mjs +10 -0
  26. package/plugin/scripts/project-progression-sources.mjs +16 -4
  27. package/plugin/scripts/project-progression-store.mjs +201 -6
  28. package/plugin/scripts/protect-brain-state.sh +1 -0
  29. package/plugin/scripts/route-dispatch.sh +1 -0
  30. package/plugin/scripts/session-snapshot-hook.mjs +383 -37
  31. package/plugin/scripts/session-start-health.mjs +24 -3
  32. package/plugin/scripts/session-start-update-plane.mjs +1 -1
  33. package/plugin/scripts/update-apply.mjs +22 -2
  34. package/scripts/console-instances.mjs +203 -0
  35. package/scripts/console-runtime-identity.mjs +2 -0
  36. package/scripts/corpus-canary.mjs +130 -18
  37. package/scripts/customer-seams.mjs +84 -0
  38. package/scripts/customer-state-matrix.mjs +363 -0
  39. package/scripts/full-suite-gate.mjs +162 -0
  40. package/scripts/grounding-turn-replay.mjs +11 -3
  41. package/scripts/hook-qualify-core.mjs +346 -0
  42. package/scripts/hook-qualify-hosts.mjs +115 -0
  43. package/scripts/hook-qualify.mjs +101 -0
  44. package/scripts/host-cli.mjs +115 -0
  45. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  46. package/scripts/route-gold-rank.mjs +156 -0
  47. package/scripts/route-index-memory.mjs +51 -0
  48. package/scripts/route-latency-warm.mjs +123 -0
  49. package/scripts/wired-check.mjs +17 -3
@@ -0,0 +1,203 @@
1
+ // console-instances.mjs — the installer's view of running Consoles: which receipts are real, and how
2
+ // to replace an owned Console that is still serving a pre-update runtime without asking anyone.
3
+ //
4
+ // Two owner-Mac failures (2026-09-30) this exists for:
5
+ // 1. Receipts left by Consoles that had long since died (2026-09-16/17) were counted as "stale
6
+ // running" forever, so --doctor said pending-console-restart / FAILING with nothing to restart.
7
+ // A receipt whose pid is gone AND whose port does not answer /api/runtime with the receipt's
8
+ // own identity is debris: it is pruned.
9
+ // 2. After a successful --update the installer told the owner to "restart Console". The Console
10
+ // already knows how to replace an owned stale instance of itself (scripts/onboarding-console.mjs
11
+ // launchConsole, state 'stale-running': token-authenticated shutdown, then serve on the same
12
+ // port). The installer now runs exactly that, from the freshly activated runtime, without --open.
13
+ //
14
+ // Everything here is synchronous because syncHostsAfterUpdate is; the one network probe runs in a
15
+ // bounded child node process.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
+
20
+ const PRODUCT = 'ruvnet-brain-console';
21
+ // The same identity fields onboarding-console.mjs sameRuntimeIdentity compares.
22
+ const IDENTITY_KEYS = ['product', 'schema', 'apiContract', 'pid', 'port', 'startedAt', 'scope',
23
+ 'scriptRealpath', 'runtimeVersion', 'sourceSha256'];
24
+
25
+ export function pidAlive(pid) {
26
+ try { process.kill(pid, 0); return true; }
27
+ catch (error) { return error?.code !== 'ESRCH'; } // EPERM: alive, owned by someone else
28
+ }
29
+
30
+ /** GET 127.0.0.1:<port>/api/runtime → parsed JSON, or null. Bounded; never throws. */
31
+ export function probeRuntimeSync(port, timeoutMs = 1500) {
32
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
33
+ const code = `const h=require('node:http');const r=h.get({host:'127.0.0.1',port:${port},path:'/api/runtime',timeout:${timeoutMs}},(s)=>{let b='';s.on('data',(c)=>{b+=c;});s.on('end',()=>{if(s.statusCode===200)process.stdout.write(b);process.exit(0);});});r.on('error',()=>process.exit(0));r.on('timeout',()=>{r.destroy();process.exit(0);});`;
34
+ const run = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', timeout: timeoutMs + 1000, windowsHide: true });
35
+ try { return run.stdout ? JSON.parse(run.stdout) : null; } catch { return null; }
36
+ }
37
+
38
+ export const sameIdentity = (left, right) => IDENTITY_KEYS.every((key) => left?.[key] === right?.[key]);
39
+
40
+ const MONTHS = 'JanFebMarAprMayJunJulAugSepOctNovDec';
41
+ /** When process `pid` started (epoch ms, UTC, 1 s resolution), or null when it cannot be read. */
42
+ let clockTicks = null;
43
+ /**
44
+ * Linux: boot-relative start (/proc/<pid>/stat field 22, clock ticks since boot) anchored at the kernel's
45
+ * boot time (/proc/stat btime) — not `ps lstart`, which a forward wall-clock jump would push later.
46
+ */
47
+ export function linuxProcessStartMs(pid, { readFile = fs.readFileSync, spawn = spawnSync } = {}) {
48
+ try {
49
+ const stat = String(readFile(`/proc/${pid}/stat`, 'utf8'));
50
+ const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' '); // field 3 onward (comm may hold spaces)
51
+ const startTicks = Number(fields[22 - 3]);
52
+ const btime = Number(String(readFile('/proc/stat', 'utf8')).match(/^btime\s+(\d+)/m)?.[1]);
53
+ if (clockTicks === null) clockTicks = Number(String(spawn('getconf', ['CLK_TCK'], { encoding: 'utf8' })?.stdout || '').trim()) || 100;
54
+ if (!Number.isFinite(startTicks) || !Number.isFinite(btime)) return null;
55
+ return btime * 1000 + Math.round((startTicks * 1000) / clockTicks);
56
+ } catch { return null; }
57
+ }
58
+
59
+ export function processStartMs(pid, { spawn = spawnSync, platform = process.platform, readFile } = {}) {
60
+ if (!Number.isInteger(pid) || pid <= 0) return null;
61
+ if (platform === 'linux') {
62
+ const linux = linuxProcessStartMs(pid, { spawn, ...(readFile ? { readFile } : {}) });
63
+ if (linux !== null) return linux;
64
+ }
65
+ const run = process.platform === 'win32'
66
+ ? spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
67
+ `$p=Get-Process -Id ${pid} -ErrorAction Stop; $p.StartTime.ToUniversalTime().ToString('ddd MMM d HH:mm:ss yyyy',[Globalization.CultureInfo]::InvariantCulture)`],
68
+ { encoding: 'utf8', windowsHide: true })
69
+ : spawn('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
70
+ if (run?.status !== 0) return null;
71
+ const m = String(run.stdout || '').trim().match(/^\w{3}\s+(\w{3})\s+(\d{1,2})\s+(\d{2}):(\d{2}):(\d{2})\s+(\d{4})$/);
72
+ if (!m || MONTHS.indexOf(m[1]) % 3 !== 0) return null;
73
+ return Date.UTC(+m[6], MONTHS.indexOf(m[1]) / 3, +m[2], +m[3], +m[4], +m[5]);
74
+ }
75
+
76
+ /**
77
+ * A live pid that is NOT the receipt's Console: the process now holding that pid started after the
78
+ * Console wrote its receipt (a Console's process always starts before it records startedAt). Two seconds
79
+ * of slack cover the 1 s resolution of the start time. Unreadable start time = not provably reused.
80
+ */
81
+ export function pidReused(receipt, { startMs = processStartMs } = {}) {
82
+ const recorded = Date.parse(receipt?.startedAt || '');
83
+ const started = startMs(receipt?.pid);
84
+ return Number.isFinite(recorded) && Number.isFinite(started) && started > recorded + 2_000;
85
+ }
86
+
87
+ /**
88
+ * Console receipts in `receiptDir`, with dead ones pruned. A receipt is dead when its port does not
89
+ * answer /api/runtime with the receipt's identity AND either its pid is not alive or that pid now
90
+ * belongs to a process that started after the receipt was written (pid reuse). A busy live Console
91
+ * (pid alive, same process) is never pruned. A receipt with no integer pid cannot be proven dead.
92
+ */
93
+ export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = probeRuntimeSync, startMs = processStartMs } = {}) {
94
+ const live = [];
95
+ const pruned = [];
96
+ let names = [];
97
+ try { names = fs.readdirSync(receiptDir).filter((name) => name.endsWith('.json')); }
98
+ catch { return { live, pruned }; } // no receipt directory is the ordinary "no Console" state
99
+ for (const name of names) {
100
+ const file = path.join(receiptDir, name);
101
+ let receipt;
102
+ try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
103
+ if (receipt?.product !== PRODUCT || receipt.schema !== 1) continue;
104
+ if (Number.isInteger(receipt.pid) && receipt.pid > 0) {
105
+ const dead = !alive(receipt.pid);
106
+ const reused = !dead && pidReused(receipt, { startMs });
107
+ if (dead || reused) {
108
+ const answer = probe(receipt.port);
109
+ if (!answer || !sameIdentity(answer, receipt)) {
110
+ try { fs.unlinkSync(file); } catch { /* raced or read-only: still not a running Console */ }
111
+ pruned.push({ file, pid: receipt.pid, port: receipt.port ?? null, startedAt: receipt.startedAt ?? null,
112
+ reason: dead ? 'pid not alive' : 'pid reused by a process started after the receipt' });
113
+ continue;
114
+ }
115
+ }
116
+ }
117
+ live.push({ file, receipt });
118
+ }
119
+ return { live, pruned };
120
+ }
121
+
122
+ // The replacement Console is a user-facing process, not part of the installer run. It inherits the
123
+ // user's environment — provider API keys (ANTHROPIC/OPENAI/OPENROUTER/GEMINI/GOOGLE/XAI) the Console reads,
124
+ // HTTP(S)_PROXY / NO_PROXY / NODE_EXTRA_CA_CERTS its release fetch needs — minus the flags that describe
125
+ // THIS run: the scheduler's nightly identity, test-harness switches, the refresh-run lock token, Node/npm
126
+ // process plumbing. A denylist, so nothing the Console legitimately reads is silently lost.
127
+ const CONSOLE_ENV_DENY = new Set(['RUVNET_NIGHTLY', 'RUVNET_BRAIN_TEST', 'RUVNET_BRAIN_TEST_LATEST_TAG', 'RUVNET_BRAIN_SCHEDULER_TEST',
128
+ 'RUVNET_BRAIN_IMPORT_ONLY', 'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_STRICT_INSTALL', 'RUVNET_REFRESH_RUN_TOKEN',
129
+ 'RUVNET_REFRESH_RECEIPT', 'RUVNET_UPGRADE_NOTICE_FILE', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'INIT_CWD', 'CONSOLE_PORT',
130
+ // The launching host session's own identity (the spine version it booted, the hook host, its Node).
131
+ 'RUVNET_BRAIN_ACTIVE_VERSION', 'RUVNET_HOOK_HOST', 'RUVNET_NODE_BIN']);
132
+ // CLAUDE* describes the Claude Code SESSION that ran the installer (CLAUDECODE, CLAUDE_PROJECT_DIR,
133
+ // CLAUDE_PLUGIN_ROOT, CLAUDE_SESSION_ID, CLAUDE_CODE_*): stale for a long-lived Console. CLAUDE_CONFIG_DIR
134
+ // is the user's configuration location, not session state, and is kept (host-update.mjs forwards it too).
135
+ const CONSOLE_ENV_DENY_PREFIX = /^(?:RUVNET_NIGHTLY_|npm_|VITEST|CLAUDE(?!_CONFIG_DIR$))/;
136
+ export function consoleEnv(env = process.env, port) {
137
+ const clean = Object.fromEntries(Object.entries(env).filter(([key, value]) => value != null
138
+ && !CONSOLE_ENV_DENY.has(key) && !CONSOLE_ENV_DENY_PREFIX.test(key)));
139
+ return { ...clean, CONSOLE_PORT: String(port) };
140
+ }
141
+
142
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
143
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
144
+
145
+ /**
146
+ * Replace every owned Console still serving an older runtime by running the activated runtime's own
147
+ * launcher (`<entry> --serve`, never --open) in that Console's scope on that Console's port. The
148
+ * launcher authenticates the shutdown with the private control token from the receipt; we only start
149
+ * it and wait for the receipt to name the new runtime. Returns one result per stale receipt, each
150
+ * with `replaced` and, when false, the exact reason.
151
+ */
152
+ export function replaceStaleConsoles({ entry, identity, receiptDir, env = process.env, timeoutMs = 20_000,
153
+ spawnFn = spawn, alive = pidAlive, probe = probeRuntimeSync } = {}) {
154
+ const results = [];
155
+ const { live } = readConsoleReceipts(receiptDir, { alive, probe });
156
+ for (const { file, receipt } of live) {
157
+ if (receipt.sourceSha256 === identity.sourceSha256) continue;
158
+ const where = { scope: receipt.scope ?? null, port: receipt.port ?? null, pid: receipt.pid ?? null };
159
+ const refuse = (reason) => results.push({ ...where, replaced: false, reason });
160
+ if (!Number.isInteger(receipt.pid) || !Number.isInteger(receipt.port) || typeof receipt.scope !== 'string') {
161
+ refuse('its receipt has no pid/port/scope, so it cannot be addressed safely'); continue;
162
+ }
163
+ if (!/^[a-f0-9]{48}$/.test(receipt.controlToken || '')) {
164
+ refuse('its receipt carries no control token, so the installer cannot prove it owns that Console'); continue;
165
+ }
166
+ if (!fs.existsSync(entry)) { refuse(`the activated Console runtime is missing (${entry})`); continue; }
167
+ if (!fs.existsSync(receipt.scope)) { refuse(`its project directory no longer exists (${receipt.scope})`); continue; }
168
+ // The pid is alive (dead ones were pruned by readConsoleReceipts). If its port does not answer with
169
+ // this receipt's identity, it is either a busy Console or a reused pid — we cannot tell which, so we
170
+ // never delete the receipt (that could orphan a live stale Console). Report it at once instead of
171
+ // launching and waiting 20s for a process that may never release anything.
172
+ const answer = probe(receipt.port) || probe(receipt.port);
173
+ if (!answer || !sameIdentity(answer, receipt)) {
174
+ refuse(`pid ${receipt.pid} is alive but port ${receipt.port} does not answer with that Console's identity (busy, or the pid was reused); its receipt was kept — restart Console`);
175
+ continue;
176
+ }
177
+ try {
178
+ const child = spawnFn(process.execPath, [entry, '--serve'], { cwd: receipt.scope, detached: true, stdio: 'ignore',
179
+ windowsHide: true, env: consoleEnv(env, receipt.port) });
180
+ child.on?.('error', () => {});
181
+ child.unref?.();
182
+ } catch (error) { refuse(`could not start the current Console: ${error.message}`); continue; }
183
+ // Done = the scope's receipt names the new runtime from a new live process AND the old process
184
+ // is gone (the launcher falls back to a free port if the old one never lets go — that is not a
185
+ // replacement, it is two Consoles).
186
+ const until = Date.now() + timeoutMs;
187
+ let current = null;
188
+ let oldAlive = true;
189
+ while (Date.now() < until) {
190
+ const seen = readJson(file);
191
+ current = seen?.sourceSha256 === identity.sourceSha256 && seen.pid !== receipt.pid && alive(seen.pid) ? seen : null;
192
+ oldAlive = alive(receipt.pid);
193
+ if (current && !oldAlive) break;
194
+ sleepSync(200);
195
+ }
196
+ const secs = Math.round(timeoutMs / 1000);
197
+ if (current && !oldAlive) { results.push({ ...where, replaced: true, newPid: current.pid, newPort: current.port }); continue; }
198
+ if (current) refuse(`the current Console started (pid ${current.pid}, port ${current.port}) but the old one (pid ${receipt.pid}) did not exit within ${secs}s`);
199
+ else if (oldAlive) refuse(`the old Console (pid ${receipt.pid}) did not release port ${receipt.port} within ${secs}s`);
200
+ else refuse(`the old Console stopped but the current one did not report the new runtime within ${secs}s`);
201
+ }
202
+ return results;
203
+ }
@@ -42,6 +42,8 @@ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
42
42
  // Keep their bytes in the same copy/digest authority as the installer itself.
43
43
  'kb/refresh-run.mjs',
44
44
  'kb/lifecycle-evidence-retention.mjs',
45
+ // install.mjs --update recovers an interrupted storage transaction before it looks for the updater.
46
+ 'kb/update-storage-transaction.mjs',
45
47
  'kb/model-requirements.mjs',
46
48
  'kb/zip-extract.mjs',
47
49
  // install.mjs imports this STATICALLY (corpus transport identity + approved-runtime stamping,
@@ -42,7 +42,8 @@ import crypto from 'node:crypto';
42
42
  import fs from 'node:fs';
43
43
  import path from 'node:path';
44
44
  import { spawn, spawnSync } from 'node:child_process';
45
- import { pathToFileURL } from 'node:url';
45
+ import { fileURLToPath, pathToFileURL } from 'node:url';
46
+ import { addPrivateStore, resolveRuntime, resultFromRefreshReceipt, runInstallerDoor } from './customer-seams.mjs';
46
47
  import { CANARY_VERDICT_KIND, CORPUS_TAG_PATTERN, REQUIRED_CANARY_CHECKS } from './corpus-promotion.mjs';
47
48
 
48
49
  export const PUBLIC_API = 'https://api.github.com';
@@ -284,7 +285,7 @@ export function installCustomer({ approvedVersion, work, home, log = () => {} })
284
285
  runLogged(process.execPath, [installer, '--yes', '--force', '--version', `v${approvedVersion}`, '--no-nightly-prompt',
285
286
  '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline', '--no-selfcheck'],
286
287
  { env, cwd: home, timeoutMs: 1_800_000, log });
287
- return { kbDir: env.RUVNET_BRAIN_KB };
288
+ return { kbDir: env.RUVNET_BRAIN_KB, home };
288
289
  }
289
290
 
290
291
  function runUpdater({ kbDir, env, resultFile }) {
@@ -310,17 +311,35 @@ async function fetchRelease({ apiBase, repo, tag }) {
310
311
  * installer); the unit suite hands in a customer tree built offline so the updater, the judge and the
311
312
  * verdict run for real without a 555 MB download.
312
313
  */
313
- export async function runCanary({
314
- repo, tag, approvedVersion, work, apiBase = PUBLIC_API, install = installCustomer, home = path.join(work, 'home'),
315
- env = process.env, now = () => Date.now(), retryDelayMs = 30_000, maxAttempts = 3, log = () => {},
316
- }) {
317
- if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('--repo must be owner/name');
318
- if (!CORPUS_TAG_PATTERN.test(String(tag || ''))) throw new Error('--tag must be corpus-sha256-<64 hex>');
319
- if (!SEMVER.test(String(approvedVersion || ''))) throw new Error('--approved-version must be X.Y.Z');
314
+ /**
315
+ * THE CUSTOMER STATES THE CANARY ASKS ABOUT. `clean` is the original (and default) case. The others are
316
+ * the two states 2026-09-30 showed are not covered by it (scripts/customer-state-matrix.mjs measured
317
+ * both on real releases): a machine carrying a private overlay, and a machine installed at an OLDER
318
+ * runtime that reaches the candidate through `npx ruvnet-brain@latest --update`. Each extra case is a
319
+ * fresh install in its own directory, run AFTER the clean case and deleted before the next, so peak disk
320
+ * stays one install + one apply. Its checks are the same judge's, prefixed `<case>:`; any failure is a FAIL.
321
+ */
322
+ export const CANARY_CASES = Object.freeze({
323
+ clean: Object.freeze({ runtime: 'approved', door: 'updater', overlay: false }),
324
+ 'private-overlay': Object.freeze({ runtime: 'approved', door: 'updater', overlay: true }),
325
+ 'older-runtime': Object.freeze({ runtime: 'N-1', door: 'installer', overlay: false }),
326
+ });
327
+
328
+ async function fetchReleaseList({ apiBase, repo }) {
329
+ const response = await fetch(`${apiBase}/repos/${repo}/releases?per_page=100`, { headers: { accept: 'application/vnd.github+json' } });
330
+ if (!response.ok) throw new Error(`release list returned HTTP ${response.status}`);
331
+ return response.json();
332
+ }
333
+
334
+ async function runCase({ name, spec, repo, tag, approvedVersion, release, work, home, apiBase, install, now,
335
+ retryDelayMs, maxAttempts, log, writerRoot, approvedInstaller }) {
320
336
  fs.mkdirSync(home, { recursive: true });
321
- const started = now();
322
- const { kbDir } = await install({ approvedVersion, work, home, log });
323
- const release = await fetchRelease({ apiBase, repo, tag });
337
+ let runtime = approvedVersion;
338
+ if (spec.runtime !== 'approved') runtime = resolveRuntime(spec.runtime, { candidateTag: `v${approvedVersion}`, releases: await fetchReleaseList({ apiBase, repo }) });
339
+ const installed = await install({ approvedVersion: runtime, work, home, log });
340
+ const { kbDir } = installed;
341
+ home = installed.home || home;
342
+ const overlay = spec.overlay ? await addPrivateStore({ kbDir, scratch: work, writerRoot }) : null;
324
343
  const beforeModules = installedModules(kbDir);
325
344
  const beforeCoverage = coverageRows(path.join(kbDir, 'CORPUS-COVERAGE.json'));
326
345
  const { before } = pointUpdaterAtCandidate({ kbDir, apiBase, repo, tag });
@@ -331,15 +350,107 @@ export async function runCanary({
331
350
  let updater = { exitCode: null, output: '', attempts: 0 };
332
351
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
333
352
  fs.rmSync(resultFile, { force: true });
334
- const run = await runUpdater({ kbDir, env: childEnv, resultFile });
353
+ const run = spec.door === 'installer'
354
+ ? await runInstallerDoor({ installer: await approvedInstaller(), env: childEnv, cwd: home })
355
+ : await runUpdater({ kbDir, env: childEnv, resultFile });
335
356
  updater = { ...run, attempts: attempt };
336
- log(`--- forge-update.mjs --apply (attempt ${attempt}) exit ${run.exitCode}\n${run.output}`);
357
+ log(`--- [${name}] ${spec.door === 'installer' ? 'install.mjs --update' : 'forge-update.mjs --apply'} (attempt ${attempt}) exit ${run.exitCode}\n${run.output}`);
337
358
  if (run.exitCode !== NETWORK_EXIT || attempt === maxAttempts) break;
338
359
  await new Promise((resolve) => setTimeout(resolve, retryDelayMs * attempt));
339
360
  }
340
- updater.result = fs.existsSync(resultFile) ? readJson(resultFile) : null;
361
+ updater.result = spec.door === 'installer' ? resultFromRefreshReceipt(childEnv.RUVNET_BRAIN_HOME)
362
+ : (fs.existsSync(resultFile) ? readJson(resultFile) : null);
341
363
  const judged = await judgeCanary({ tag, approvedVersion, release, updater, before, beforeModules, beforeCoverage,
342
364
  kbDir: fs.realpathSync(kbDir), home: fs.realpathSync(home), now: now() });
365
+ if (overlay) {
366
+ const after = Object.fromEntries(Object.keys(overlay.digests).map((f) => [f,
367
+ fs.existsSync(path.join(kbDir, f)) ? sha256File(path.join(kbDir, f)) : null]));
368
+ const changed = Object.keys(after).filter((f) => after[f] !== overlay.digests[f]);
369
+ judged.checks.push({ name: 'private-store-preserved', ok: changed.length === 0,
370
+ detail: changed.length ? `private files changed or lost: ${changed.join(', ')}` : `${Object.keys(after).length} private file(s) byte-identical` });
371
+ }
372
+ return { updater, checks: judged.checks, runtime };
373
+ }
374
+
375
+ const physical = (dir) => { try { return fs.realpathSync(dir); } catch { return path.resolve(dir); } };
376
+
377
+ /**
378
+ * `--installed-kb`: the clean case runs on the supplied KB (as before); every extra case gets its OWN copy
379
+ * of that KB as it was BEFORE the clean case touched it, under the case's work dir, so the supplied brain
380
+ * never gains the overlay case's private store or a rewritten PRIVATE-STORES.json.
381
+ *
382
+ * DISK: copies are reflink clones (COPYFILE_FICLONE) where the filesystem supports it (APFS, btrfs, XFS
383
+ * with reflink) and cost almost nothing; elsewhere (ext4, most CI runners) they are full copies. Then the
384
+ * peak is the supplied KB + one snapshot + one case copy (each case's copy is deleted before the next),
385
+ * i.e. about 3x the KB (~4 GB for a 1.3 GB brain). Hardlinks are not used: an update that rewrote a
386
+ * file in place would write through into the caller's brain.
387
+ */
388
+ export function suppliedKbInstaller({ installedKb, work, cases }) {
389
+ const supplied = path.resolve(installedKb);
390
+ const snapshot = path.join(work, 'supplied-kb-snapshot');
391
+ const copy = (from, to) => fs.cpSync(from, to, { recursive: true, verbatimSymlinks: true, mode: fs.constants.COPYFILE_FICLONE });
392
+ if (cases.length > 1) { fs.rmSync(snapshot, { recursive: true, force: true }); copy(supplied, snapshot); }
393
+ return ({ work: caseWork, home }) => {
394
+ if (path.resolve(caseWork) === path.resolve(work)) return { kbDir: supplied };
395
+ const kbDir = path.join(home, '.cache', 'ruvnet-brain', 'kb');
396
+ fs.mkdirSync(path.dirname(kbDir), { recursive: true });
397
+ copy(snapshot, kbDir);
398
+ return { kbDir, home };
399
+ };
400
+ }
401
+
402
+ export async function runCanary({
403
+ repo, tag, approvedVersion, work, apiBase = PUBLIC_API, install = installCustomer, home = path.join(work, 'home'),
404
+ env = process.env, now = () => Date.now(), retryDelayMs = 30_000, maxAttempts = 3, log = () => {},
405
+ cases = ['clean'], writerRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
406
+ approvedInstaller = null,
407
+ }) {
408
+ if (!/^[^/\s]+\/[^/\s]+$/.test(String(repo || ''))) throw new Error('--repo must be owner/name');
409
+ if (!CORPUS_TAG_PATTERN.test(String(tag || ''))) throw new Error('--tag must be corpus-sha256-<64 hex>');
410
+ if (!SEMVER.test(String(approvedVersion || ''))) throw new Error('--approved-version must be X.Y.Z');
411
+ if (cases[0] !== 'clean' || cases.some((name) => !CANARY_CASES[name])) {
412
+ throw new Error(`--cases must start with clean and name only: ${Object.keys(CANARY_CASES).join(', ')}`);
413
+ }
414
+ const started = now();
415
+ const release = await fetchRelease({ apiBase, repo, tag });
416
+ // The approved package's installer — what `npx ruvnet-brain@latest` runs — fetched once, on demand.
417
+ let installerPath = null;
418
+ const resolveInstaller = approvedInstaller || (async () => {
419
+ if (installerPath) return installerPath;
420
+ const prefix = path.join(work, 'approved-npx');
421
+ fs.mkdirSync(prefix, { recursive: true });
422
+ const npm = path.join(path.dirname(process.execPath), process.platform === 'win32' ? 'npm.cmd' : 'npm');
423
+ runLogged(npm, ['install', '--prefix', prefix, '--no-audit', '--no-fund', '--no-save', `ruvnet-brain@${approvedVersion}`],
424
+ { env: customerEnv({ home, work }), cwd: prefix, timeoutMs: 600_000, log });
425
+ installerPath = path.join(prefix, 'node_modules', 'ruvnet-brain', 'bin', 'install.mjs');
426
+ return installerPath;
427
+ });
428
+ const common = { repo, tag, approvedVersion, release, apiBase, install, now, retryDelayMs, maxAttempts, log, writerRoot,
429
+ approvedInstaller: resolveInstaller };
430
+ let cleanKb = null;
431
+ const clean = await runCase({ ...common, name: 'clean', spec: CANARY_CASES.clean, work, home,
432
+ install: async (args) => { const installed = await install(args); cleanKb = physical(installed.kbDir); return installed; } });
433
+ const { updater } = clean;
434
+ const judged = { checks: [...clean.checks] };
435
+ for (const name of cases.slice(1)) {
436
+ const caseWork = path.join(work, `case-${name}`);
437
+ let outcome;
438
+ try {
439
+ // An extra case adds a private store and rewrites PRIVATE-STORES.json: it must never do that to the
440
+ // clean case's KB (with --installed-kb, the caller's own brain). Each case gets its own tree.
441
+ const caseInstall = async (args) => {
442
+ const installed = await install(args);
443
+ if (physical(installed.kbDir) === cleanKb) throw new Error(`case ${name} was handed the clean case's KB (${installed.kbDir}); refusing to mutate it`);
444
+ return installed;
445
+ };
446
+ outcome = await runCase({ ...common, install: caseInstall, name, spec: CANARY_CASES[name], work: caseWork, home: path.join(caseWork, 'home') });
447
+ } catch (error) {
448
+ outcome = { checks: [{ name: 'case-ran', ok: false, detail: error.message }] };
449
+ }
450
+ for (const entry of outcome.checks) judged.checks.push({ ...entry, name: `${name}:${entry.name}` });
451
+ fs.rmSync(caseWork, { recursive: true, force: true });
452
+ }
453
+ judged.verdict = judged.checks.every((entry) => entry.ok) ? 'PASS' : 'FAIL';
343
454
  return {
344
455
  schemaVersion: 1,
345
456
  kind: CANARY_VERDICT_KIND,
@@ -365,18 +476,19 @@ async function main(argv = process.argv.slice(2)) {
365
476
  const work = opt('--work') && path.resolve(opt('--work'));
366
477
  const verdictOut = opt('--verdict-out') && path.resolve(opt('--verdict-out'));
367
478
  if (!work || !verdictOut) {
368
- 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>]\n');
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');
369
480
  return 2;
370
481
  }
371
482
  fs.mkdirSync(work, { recursive: true });
372
483
  const logFile = path.join(work, 'canary.log');
373
484
  const log = (text) => { fs.appendFileSync(logFile, `${text}\n`); };
374
485
  const installedKb = opt('--installed-kb');
375
- const install = installedKb ? () => ({ kbDir: path.resolve(installedKb) }) : installCustomer;
486
+ const cases = String(opt('--cases') || 'clean').split(',').map((name) => name.trim()).filter(Boolean);
487
+ const install = installedKb ? suppliedKbInstaller({ installedKb, work, cases }) : installCustomer;
376
488
  let record;
377
489
  try {
378
490
  record = await runCanary({ repo: opt('--repo'), tag: opt('--tag'), approvedVersion: opt('--approved-version'), work,
379
- apiBase: opt('--api-base') || PUBLIC_API, install, home: path.resolve(opt('--home') || path.join(work, 'home')), log });
491
+ apiBase: opt('--api-base') || PUBLIC_API, install, home: path.resolve(opt('--home') || path.join(work, 'home')), log, cases });
380
492
  } catch (error) {
381
493
  // Could not even reach a judgement = the consumer did not accept. Still a written verdict.
382
494
  record = { schemaVersion: 1, kind: CANARY_VERDICT_KIND, verdict: 'FAIL', repo: opt('--repo') || null, tag: opt('--tag') || null,
@@ -0,0 +1,84 @@
1
+ // scripts/customer-seams.mjs — the customer-side seams shared by scripts/corpus-canary.mjs (the nightly
2
+ // consumer gate) and scripts/customer-state-matrix.mjs (the same seams across many machine states). One copy.
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { spawn } from 'node:child_process';
7
+ import { pathToFileURL } from 'node:url';
8
+
9
+ const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
10
+ const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
11
+
12
+ const SEMVER_TAG = /^v(\d+)\.(\d+)\.(\d+)$/;
13
+ /** `N-k`: the k-th published (non-draft) semver code release behind `candidateTag`, from a live release list. */
14
+ export function resolveRuntime(offset, { candidateTag, releases }) {
15
+ const match = /^N-(\d+)$/.exec(offset);
16
+ if (!match) throw new Error(`runtime offset must be N-<k>: ${offset}`);
17
+ const key = (tag) => SEMVER_TAG.exec(tag).slice(1).map(Number);
18
+ const cmp = (a, b) => { const [x, y] = [key(a), key(b)]; return y[0] - x[0] || y[1] - x[1] || y[2] - x[2]; };
19
+ const tags = releases.filter((r) => !r.draft && SEMVER_TAG.test(r.tag_name || '')).map((r) => r.tag_name).sort(cmp);
20
+ const at = tags.indexOf(candidateTag);
21
+ if (at < 0) throw new Error(`candidate ${candidateTag} is not a published code release`);
22
+ const tag = tags[at + Number(match[1])];
23
+ if (!tag) throw new Error(`no published release ${offset} behind ${candidateTag}`);
24
+ return tag.slice(1);
25
+ }
26
+
27
+ /**
28
+ * A customer's PRIVATE store: the smallest real public store family, renamed, stamped by the product's
29
+ * own writer (scripts/private-overlay.mjs) with an alias and a capability card — the same shape the
30
+ * owner's 108 private stores have. Exercises capture -> candidate restore -> coverage convergence.
31
+ */
32
+ export async function addPrivateStore({ kbDir, scratch, writerRoot, name = 'acme-private-notes' }) {
33
+ const ledger = readJson(path.join(kbDir, 'RVF-GENERATIONS.json')).stores;
34
+ const donor = Object.entries(ledger).filter(([, g]) => /\.big\.rvf$/.test(g.file || ''))
35
+ .sort(([, a], [, b]) => a.bytes - b.bytes)[0][0];
36
+ const from = path.join(scratch, 'private-sidecars');
37
+ fs.mkdirSync(from, { recursive: true });
38
+ for (const file of fs.readdirSync(kbDir).filter((f) => f.startsWith(`${donor}.`) || f === `${donor}-primer.md`)) {
39
+ fs.copyFileSync(path.join(kbDir, file), path.join(from, file.replace(donor, name)));
40
+ }
41
+ const { applyPrivateOverlay } = await import(pathToFileURL(path.join(writerRoot, 'scripts', 'private-overlay.mjs')).href);
42
+ const card = path.join(scratch, `${name}.card.md`);
43
+ fs.writeFileSync(card, `Private notes (a renamed copy of ${donor}). Reach for it only for this customer's own material.\n`);
44
+ // The fence comes first, exactly as the writer demands of a customer (PRIVATE-STORES.json).
45
+ const fenceFile = path.join(kbDir, 'PRIVATE-STORES.json');
46
+ const fence = fs.existsSync(fenceFile) ? readJson(fenceFile) : { privateStores: [] };
47
+ fs.writeFileSync(fenceFile, `${JSON.stringify({ ...fence, privateStores: [...new Set([...(fence.privateStores || []), name])] }, null, 2)}\n`);
48
+ applyPrivateOverlay({ root: kbDir, from, stores: [name], aliases: { [name]: [`${name}-alias`] }, cards: { [name]: card } });
49
+ const digests = Object.fromEntries(fs.readdirSync(kbDir).filter((f) => f.startsWith(`${name}.`) || f.startsWith(`${name}-`))
50
+ .sort().map((f) => [f, sha256File(path.join(kbDir, f))]));
51
+ return { name, donor, digests };
52
+ }
53
+
54
+ /**
55
+ * The `npx ruvnet-brain@latest --update` door: the APPROVED package's own installer, which self-upgrades
56
+ * the installed updater, re-stamps the runtime, takes the refresh lock and runs the updater. Fallback is
57
+ * disabled for the same reason the clean case never uses this door: a refused candidate must not turn
58
+ * into a green fresh install. The updater's receipt is reconstructed from the refresh-run receipt the
59
+ * installer settles (the installer deletes its private result file).
60
+ */
61
+ export function runInstallerDoor({ installer, env, cwd }) {
62
+ return new Promise((resolve) => {
63
+ const child = spawn(process.execPath, [installer, '--update', '--no-nightly-prompt'],
64
+ { cwd, env: { ...env, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
65
+ let output = '';
66
+ child.stdout.on('data', (chunk) => { output += chunk; });
67
+ child.stderr.on('data', (chunk) => { output += chunk; });
68
+ child.on('error', (error) => resolve({ exitCode: null, output: `${output}\n${error.message}` }));
69
+ child.on('close', (exitCode) => resolve({ exitCode, output }));
70
+ });
71
+ }
72
+
73
+ export function resultFromRefreshReceipt(brainHome) {
74
+ const dir = path.join(brainHome, 'refresh-runs');
75
+ if (!fs.existsSync(dir)) return null;
76
+ const newest = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => path.join(dir, f))
77
+ .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
78
+ if (!newest) return null;
79
+ const receipt = readJson(newest);
80
+ const phase = (name) => receipt.phases?.find((p) => p.phase === name)?.evidence || {};
81
+ return { terminalVerdict: receipt.status === 'SUCCEEDED' ? phase('update').terminalVerdict || null : receipt.terminalVerdict,
82
+ bundleSha256: phase('bundle-assembly').bundleSha256 || null, coverageSha256: phase('coverage-generation').coverageSha256 || null,
83
+ refreshStatus: receipt.status };
84
+ }