ruvnet-brain 4.3.9 → 4.3.10

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 (83) hide show
  1. package/README.md +26 -9
  2. package/bin/install.mjs +646 -366
  3. package/bin/nightly-refresh.mjs +115 -0
  4. package/kb/brain-profile.mjs +118 -32
  5. package/kb/lifecycle-evidence-retention.mjs +237 -0
  6. package/kb/refresh-run.mjs +367 -0
  7. package/kb/retrieval-result.mjs +39 -0
  8. package/kb/update-storage-transaction.mjs +439 -0
  9. package/package.json +11 -2
  10. package/plugin/.claude-plugin/plugin.json +1 -1
  11. package/plugin/.codex-plugin/plugin.json +1 -1
  12. package/plugin/hooks/codex-hooks.json +2 -165
  13. package/plugin/hooks/hook-contracts.json +4 -65
  14. package/plugin/hooks/hooks.json +2 -208
  15. package/plugin/scripts/adr-currency-gate.mjs +29 -19
  16. package/plugin/scripts/capability-registry.mjs +10 -83
  17. package/plugin/scripts/codex-hook-adapter.mjs +62 -9
  18. package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
  19. package/plugin/scripts/continuation-gate.mjs +30 -28
  20. package/plugin/scripts/continuation-objective.mjs +34 -0
  21. package/plugin/scripts/development-maintenance.mjs +49 -0
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/learn-flush.mjs +6 -1
  24. package/plugin/scripts/lesson-gate.mjs +5 -2
  25. package/plugin/scripts/lesson-presentation.mjs +5 -0
  26. package/plugin/scripts/md-stamp.mjs +109 -6
  27. package/plugin/scripts/memory-doctor.mjs +3 -7
  28. package/plugin/scripts/nightly-controller.mjs +9 -27
  29. package/plugin/scripts/nightly-scheduler.mjs +472 -0
  30. package/plugin/scripts/project-progression-contract.mjs +4 -4
  31. package/plugin/scripts/project-progression-store.mjs +35 -6
  32. package/plugin/scripts/ruflo-bin.mjs +20 -0
  33. package/plugin/scripts/session-snapshot-contract.mjs +6 -1
  34. package/plugin/scripts/version-bump-gate.sh +14 -2
  35. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
  36. package/scripts/build-bundle.mjs +8 -0
  37. package/scripts/build-primer.mjs +2 -3
  38. package/scripts/candidate-host-evidence.mjs +59 -41
  39. package/scripts/ci/build-fixture-kb.mjs +59 -2
  40. package/scripts/ci/mutate-hook-timeout.mjs +24 -47
  41. package/scripts/ci/stranger-scenario.mjs +13 -28
  42. package/scripts/claims-verify.mjs +219 -21
  43. package/scripts/console-engine.mjs +4 -18
  44. package/scripts/console-runtime-identity.mjs +6 -0
  45. package/scripts/development-maintenance.mjs +47 -0
  46. package/scripts/development-push-check.mjs +35 -0
  47. package/scripts/distill-project.mjs +24 -47
  48. package/scripts/doc-currency.mjs +91 -19
  49. package/scripts/git-hooks/pre-push +4 -162
  50. package/scripts/health-repair.mjs +22 -4
  51. package/scripts/hook-retirement-check.mjs +22 -0
  52. package/scripts/host-install-matrix.mjs +125 -61
  53. package/scripts/integration-evidence.mjs +11 -6
  54. package/scripts/learning-replay-fixture.mjs +23 -2
  55. package/scripts/nightly-two-run-proof.mjs +415 -0
  56. package/scripts/nightly-watchdog.mjs +17 -2
  57. package/scripts/npm-invocation.mjs +20 -0
  58. package/scripts/prepublication-evidence.mjs +42 -6
  59. package/scripts/primer-grounding.mjs +20 -0
  60. package/scripts/product-integrity-contract.mjs +22 -2
  61. package/scripts/public-verification-abandon.mjs +101 -0
  62. package/scripts/public-verification-aggregate.mjs +53 -19
  63. package/scripts/public-verification-finalizer.mjs +8 -2
  64. package/scripts/public-verification-lane.mjs +63 -8
  65. package/scripts/publication-receipt.mjs +109 -45
  66. package/scripts/published-surface-probe.mjs +10 -2
  67. package/scripts/qa-contract.mjs +57 -0
  68. package/scripts/qa-lanes.mjs +40 -0
  69. package/scripts/qa-runner.mjs +54 -64
  70. package/scripts/qe/ux-suite.mjs +10 -34
  71. package/scripts/qualified-candidate-check.mjs +124 -0
  72. package/scripts/release-abort-stale.mjs +2 -2
  73. package/scripts/release-projection.mjs +20 -1
  74. package/scripts/release-qualification-contract.mjs +87 -0
  75. package/scripts/release-qualification.mjs +135 -0
  76. package/scripts/release-transaction.mjs +81 -1
  77. package/scripts/remedy-registry.mjs +6 -13
  78. package/scripts/retrieval-canary.mjs +91 -18
  79. package/scripts/selfcheck.mjs +12 -4
  80. package/scripts/snapshot-freshness.mjs +58 -0
  81. package/scripts/stack-sync.mjs +41 -43
  82. package/scripts/staged-host-verifier.mjs +63 -14
  83. package/scripts/wired-check.mjs +31 -2
@@ -7,7 +7,6 @@ import os from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { pipeline } from 'node:stream/promises';
9
9
  import { Readable } from 'node:stream';
10
- import readline from 'node:readline';
11
10
  import { spawn, spawnSync } from 'node:child_process';
12
11
  // ONE doctor rule and ONE mode vocabulary, shared with the staged-side check in
13
12
  // scripts/staged-host-verifier.mjs. This file kept its own copies, spelled claudeOnly/
@@ -16,11 +15,12 @@ import { spawn, spawnSync } from 'node:child_process';
16
15
  // post-publication proofs below (payload assertions, MCP wiring, SOURCE.json, rpcSearch)
17
16
  // stay here — they are this side's job, not duplication.
18
17
  import { HOST_MODES, RECEIPT_MODE_NAMES, MODE_FROM_RECEIPT_NAME, classifyDoctor, VARIANTS } from './host-install-matrix.mjs';
19
- import { pathToFileURL } from 'node:url';
18
+ import { fileURLToPath, pathToFileURL } from 'node:url';
20
19
  import { evaluateCandidateReceipt, evaluatePublicationReceipt } from './release-proof.mjs';
21
20
  import { verifyPayload } from './release-payload.mjs';
22
- import { digest, sha256File } from './coverage-integrity.mjs';
23
- import { parseCitations } from '../kb/verify-citation.mjs';
21
+ import { resolveInstalledCanaryCitation } from './retrieval-canary.mjs';
22
+ import { runNightlyTwoRunProof, validateNightlyProofReceipt } from './nightly-two-run-proof.mjs';
23
+ import { parseRetrievalResult } from '../kb/retrieval-result.mjs';
24
24
 
25
25
  const REPO = 'stuinfla/ruvnet-brain';
26
26
  const PACKAGE = 'ruvnet-brain';
@@ -35,6 +35,14 @@ export function commandInvocation(name, args, { platform = process.platform, env
35
35
  };
36
36
  return { executable: name, args };
37
37
  }
38
+ export function tarExtractionInvocation(artifactPath, destination, { platform = process.platform } = {}) {
39
+ const paths = platform === 'win32' ? path.win32 : path;
40
+ const archive = paths.resolve(artifactPath);
41
+ // A relative archive argument avoids GNU tar's drive-letter remote-host syntax and also
42
+ // works with Windows BSD tar, which does not support GNU's --force-local option.
43
+ return { args: ['-xzf', `./${paths.basename(archive)}`, '-C', paths.resolve(destination)],
44
+ cwd: paths.dirname(archive) };
45
+ }
38
46
  function command(name, args, options = {}) {
39
47
  const { platform = process.platform, ...spawnOptions } = options;
40
48
  const invocation = commandInvocation(name, args, { platform, env: spawnOptions.env || process.env });
@@ -45,6 +53,31 @@ function command(name, args, options = {}) {
45
53
  }
46
54
  return String(result.stdout || '').trim();
47
55
  }
56
+ export function validateCandidateSource(root, { sha, version }) {
57
+ const candidateRoot = fs.realpathSync(root);
58
+ const options = { cwd: candidateRoot };
59
+ const top = command('git', ['rev-parse', '--show-toplevel'], options);
60
+ const head = command('git', ['rev-parse', 'HEAD'], options);
61
+ // Git and Node can spell the same Windows directory with different casing/separators.
62
+ // Compare actual directory identity, never case-fold distinct source paths.
63
+ const candidateDirectory = fs.statSync(candidateRoot, { bigint: true });
64
+ const topDirectory = fs.statSync(top, { bigint: true });
65
+ if (!candidateDirectory.isDirectory() || !topDirectory.isDirectory()
66
+ || candidateDirectory.ino <= 0n || topDirectory.ino <= 0n
67
+ || candidateDirectory.dev !== topDirectory.dev || candidateDirectory.ino !== topDirectory.ino
68
+ || !/^[a-f0-9]{40}$/.test(String(sha)) || head !== sha) {
69
+ throw new Error(`candidate checkout ${head} does not match candidate ${sha}`);
70
+ }
71
+ if (command('git', ['status', '--porcelain', '--untracked-files=no'], options)) {
72
+ throw new Error('candidate checkout has tracked changes');
73
+ }
74
+ const manifest = path.join(candidateRoot, 'package.json');
75
+ const stat = fs.lstatSync(manifest);
76
+ if (!stat.isFile() || stat.isSymbolicLink() || !version || readJson(manifest).version !== version) {
77
+ throw new Error('candidate checkout package version differs from the released candidate');
78
+ }
79
+ return candidateRoot;
80
+ }
48
81
  function locate(name, { platform = process.platform } = {}) {
49
82
  const query = platform === 'win32' ? (name === 'node' ? 'node.exe' : `${name}.cmd`) : name;
50
83
  try {
@@ -166,18 +199,32 @@ export function assertInstalledPayload(sourceRoot, installedRoot) {
166
199
  return checked;
167
200
  }
168
201
 
169
- export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS) {
202
+ export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS, {
203
+ requiredRepo = 'ruvnet-brain',
204
+ } = {}) {
170
205
  if (!Number.isSafeInteger(k) || k < 1 || k > 50) throw new Error(`invalid search result count: ${k}`);
171
206
  return new Promise((resolve, reject) => {
172
207
  const child = spawn(process.execPath, [server], { env, stdio: ['pipe', 'pipe', 'pipe'] });
173
208
  let stdout = '';
174
209
  let stderr = '';
175
210
  let timer;
211
+ let killTimer;
212
+ let closeTimer;
213
+ let outcome;
176
214
  const pending = new Map();
177
215
  const finish = (error, value) => {
216
+ if (outcome) return;
217
+ outcome = { error, value };
178
218
  clearTimeout(timer);
219
+ pending.clear();
220
+ // Do not let the next doctor/search probe contend with this process while it exits.
221
+ // A child that ignores graceful shutdown must not leave the verifier hanging either.
222
+ killTimer = setTimeout(() => {
223
+ outcome.error = new Error('installed Brain did not shut down within 5000ms');
224
+ child.kill('SIGKILL');
225
+ closeTimer = setTimeout(() => reject(new Error('installed Brain process did not close after SIGKILL')), 1000);
226
+ }, 5000);
179
227
  child.kill('SIGTERM');
180
- error ? reject(error) : resolve(value);
181
228
  };
182
229
  timer = setTimeout(() => finish(new Error(`installed Brain exceeded ${timeoutMs}ms deadline`)), timeoutMs);
183
230
  child.stderr.on('data', (chunk) => { stderr += chunk; });
@@ -195,8 +242,12 @@ export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS) {
195
242
  }
196
243
  });
197
244
  child.on('error', (error) => finish(error));
198
- child.on('exit', (code) => {
199
- if (pending.size) finish(new Error(`installed Brain exited ${code}: ${stderr.slice(0, 400)}`));
245
+ child.on('close', (code) => {
246
+ clearTimeout(timer);
247
+ clearTimeout(killTimer);
248
+ clearTimeout(closeTimer);
249
+ const result = outcome || { error: new Error(`installed Brain exited ${code}: ${stderr.slice(0, 400)}`) };
250
+ result.error ? reject(result.error) : resolve(result.value);
200
251
  });
201
252
  const call = (id, method, params = {}) => new Promise((done) => {
202
253
  pending.set(id, done);
@@ -212,13 +263,17 @@ export function rpcSearch(server, env, query, k = 5, timeoutMs = DEADLINE_MS) {
212
263
  const broadMs = Math.round(performance.now() - started);
213
264
  const text = (searched.result?.content || []).map((item) => item.text || '').join('\n');
214
265
  if (searched.error || searched.result?.isError || /search_ruvnet error:/i.test(text)) throw new Error(`installed Brain search failed: ${text.slice(0, 400)}`);
215
- if (!/repo=ruvnet-brain/i.test(text) || !/path\s*:/i.test(text)) throw new Error('installed Brain search returned no ruvnet-brain source citation');
216
- finish(null, { broadMs, text });
266
+ if (!/repo=/i.test(text) || !/path\s*:/i.test(text)) throw new Error('installed Brain search returned no source citation');
267
+ const repos = [...text.matchAll(/repo=([\w.-]+)/gi)].map((match) => match[1].toLowerCase());
268
+ if (requiredRepo && !repos.includes(String(requiredRepo).toLowerCase())) {
269
+ throw new Error(`installed Brain search returned no ${requiredRepo} source citation`);
270
+ }
271
+ finish(null, { broadMs, text, mcpResult: searched.result });
217
272
  })().catch((error) => finish(error));
218
273
  });
219
274
  }
220
275
 
221
- export function livePublicationAdapter({ root = process.cwd() } = {}) {
276
+ export function livePublicationAdapter({ root = process.cwd(), candidateRoot = root } = {}) {
222
277
  const installContexts = new Map();
223
278
  const passageFileDigests = new Map();
224
279
  let installTemp = null;
@@ -246,11 +301,40 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
246
301
  return { path: destination, tag: release.tag_name, sha: tagCommit(root, tag) };
247
302
  },
248
303
 
304
+ async runNativeNightly({ identity, workflowRunId }) {
305
+ const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-nightly-'));
306
+ let completed = false;
307
+ try {
308
+ const npm = await this.downloadNpm({ version: identity.version,
309
+ destination: path.join(temp, 'package.tgz') });
310
+ const bundle = await this.downloadGithub({ tag: identity.tag, assetName: 'ruvnet-brain.zip',
311
+ destination: path.join(temp, 'ruvnet-brain.zip') });
312
+ const signature = await this.downloadGithub({ tag: identity.tag, assetName: 'ruvnet-brain.zip.sig',
313
+ destination: path.join(temp, 'ruvnet-brain.zip.sig') });
314
+ if (sha256(npm.path) !== identity.packageSha256 || sha256(bundle.path) !== identity.bundleSha256
315
+ || npm.version !== identity.version || bundle.sha !== identity.candidateSha
316
+ || signature.sha !== identity.candidateSha) throw new Error('nightly public artifact identity mismatch');
317
+ const proof = await runNightlyTwoRunProof({ packagePath: npm.path, bundlePath: bundle.path,
318
+ signaturePath: signature.path, sourceSha: identity.candidateSha, workflowRunId,
319
+ out: path.join(temp, 'nightly-proof.json') });
320
+ const validation = validateNightlyProofReceipt(proof, { platform: process.platform, version: identity.version,
321
+ packageSha256: identity.packageSha256, bundleSha256: identity.bundleSha256,
322
+ sourceSha: identity.candidateSha, workflowRunId });
323
+ if (!validation.ok) throw new Error(`public nightly proof failed: ${validation.failures.join('; ')}`);
324
+ completed = true;
325
+ return proof;
326
+ } finally {
327
+ if (completed) fs.rmSync(temp, { recursive: true, force: true });
328
+ else console.error(`Native public artifact diagnostics retained at ${temp}`);
329
+ }
330
+ },
331
+
249
332
  async installHosts({ artifactPath, artifactSha256, bundlePath, bundleSha256, version }) {
250
- const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-'));
333
+ const temp = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-install-')));
251
334
  installTemp = temp;
252
335
  const packageRoot = path.join(temp, 'package');
253
- command('tar', ['-xzf', artifactPath, '-C', temp]);
336
+ const extraction = tarExtractionInvocation(artifactPath, temp);
337
+ command('tar', extraction.args, { cwd: extraction.cwd });
254
338
  stageVerifiedBundle({ bundlePath, bundleSha256, packageRoot });
255
339
  const sealedPlugin = path.join(packageRoot, 'plugin');
256
340
  const results = {};
@@ -342,46 +426,26 @@ export function livePublicationAdapter({ root = process.cwd() } = {}) {
342
426
  async searchInstalled({ mode, query, k }) {
343
427
  const context = installContexts.get(mode);
344
428
  if (!context) throw new Error(`${mode} public host is not installed`);
345
- const result = await rpcSearch(findMcpServer(context.home), context.env, query, k, DEADLINE_MS);
346
- return parseCitations(result.text).map((citation) => ({
347
- repo: citation.repo.toLowerCase(),
348
- path: citation.docPath,
349
- }));
429
+ // Each canary's exact expected repository/path/passage is checked by the canary validator.
430
+ const result = await rpcSearch(findMcpServer(context.home), context.env, query, k, DEADLINE_MS,
431
+ { requiredRepo: null });
432
+ return parseRetrievalResult(result.mcpResult, { query, k });
350
433
  },
351
434
 
352
435
  async resolveInstalledCitation({ mode, matched, expected }) {
353
436
  const context = installContexts.get(mode);
354
437
  if (!context) throw new Error(`${mode} public host is not installed`);
355
- if (String(matched?.repo || '').toLowerCase() !== expected.repo || matched?.path !== expected.path) {
356
- return { resolved: false };
357
- }
358
- const files = [
359
- path.join(context.kb, `${expected.repo}.passages.jsonl`),
360
- path.join(context.kb, `${expected.repo}.big.passages.jsonl`),
361
- ].filter((file) => fs.existsSync(file));
362
- for (const file of files) {
363
- const rows = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
364
- try {
365
- for await (const line of rows) {
366
- let record;
367
- try { record = JSON.parse(line); } catch { continue; }
368
- if (record?.path !== expected.path) continue;
369
- const passageSha256 = digest(record);
370
- if (passageSha256 !== expected.passageSha256) return { resolved: false };
371
- if (!passageFileDigests.has(file)) passageFileDigests.set(file, sha256File(file));
372
- return { resolved: true, evidence: { passageSha256, passageFileSha256: passageFileDigests.get(file) } };
373
- }
374
- } finally { rows.close(); }
375
- }
376
- return { resolved: false };
438
+ return resolveInstalledCanaryCitation({ kbDir: context.kb, matched, expected, passageFileDigests });
377
439
  },
378
440
 
379
- async probePublishedSurface({ sha }) {
380
- const head = command('git', ['rev-parse', 'HEAD'], { cwd: root });
381
- if (head !== sha) throw new Error(`published-surface probe checkout ${head} does not match candidate ${sha}`);
382
- const result = spawnSync(process.execPath, ['scripts/published-surface-probe.mjs', '--json'], {
383
- cwd: root, env: process.env, encoding: 'utf8', timeout: 600_000,
441
+ async probePublishedSurface({ sha, version }) {
442
+ const source = validateCandidateSource(candidateRoot, { sha, version });
443
+ // The verifier supplies executable checks; the candidate supplies only the released source.
444
+ const script = fileURLToPath(new URL('./published-surface-probe.mjs', import.meta.url));
445
+ const result = spawnSync(process.execPath, [script, '--json'], {
446
+ cwd: source, env: process.env, encoding: 'utf8', timeout: 600_000,
384
447
  });
448
+ validateCandidateSource(candidateRoot, { sha, version });
385
449
  let parsed;
386
450
  try { parsed = JSON.parse(String(result.stdout || '')); } catch { throw new Error(`published-surface-probe emitted invalid JSON: ${String(result.stderr || '').slice(0, 300)}`); }
387
451
  if (result.status !== 0 || parsed.verdict !== 'PASS') throw new Error(`published-surface-probe is ${parsed.verdict || `exit ${result.status}`}`);
@@ -52,6 +52,7 @@ import fs from 'node:fs';
52
52
  import os from 'node:os';
53
53
  import path from 'node:path';
54
54
  import { spawnSync } from 'node:child_process';
55
+ import { fileURLToPath } from 'node:url';
55
56
 
56
57
  export const EXIT = Object.freeze({ PASS: 0, FAIL: 1, UNKNOWN: 4 });
57
58
 
@@ -127,6 +128,12 @@ async function probeRegistry() {
127
128
  }
128
129
 
129
130
  // ── B. EXECUTE the published package, from the registry ────────────────────────────────────────
131
+ export function npxInvocation(args, { platform = process.platform, env = process.env } = {}) {
132
+ if (platform === 'win32') return {
133
+ executable: env.ComSpec || 'cmd.exe', args: ['/d', '/c', 'npx.cmd', ...args],
134
+ };
135
+ return { executable: 'npx', args };
136
+ }
130
137
  function probeExec() {
131
138
  const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-brain-probe-'));
132
139
  try {
@@ -134,7 +141,8 @@ function probeExec() {
134
141
  // (npm resolve → download → unpack → bin mapping → node parses every module it imports) while
135
142
  // touching NOTHING on the machine. `--doctor`/`--plan` reach the network and the brain dir; this
136
143
  // probe must never be the reason a surface changes.
137
- const r = spawnSync('npx', ['-y', `${PKG}@latest`, '--help'], {
144
+ const invocation = npxInvocation(['-y', `${PKG}@latest`, '--help']);
145
+ const r = spawnSync(invocation.executable, invocation.args, {
138
146
  encoding: 'utf8',
139
147
  timeout: 300_000,
140
148
  cwd: home,
@@ -237,5 +245,5 @@ export async function main() {
237
245
  return EXIT[verdict];
238
246
  }
239
247
 
240
- const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
248
+ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
241
249
  if (invokedDirectly) process.exit(await main());
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { execFileSync } from 'node:child_process';
5
+
6
+ export function verdictOf(results) {
7
+ if (results.some(({ status }) => ['FAIL', 'TIMEOUT', 'BLOCKED'].includes(status))) return 'FAIL';
8
+ return results.length && results.every(({ status }) => status === 'PASS') ? 'PASS' : 'UNKNOWN';
9
+ }
10
+
11
+ // A source digest includes dirty tracked files and untracked source, never ignored build outputs.
12
+ export function sourceIdentity(root) {
13
+ const git = (args) => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
14
+ let sha = null;
15
+ try { sha = git(['rev-parse', 'HEAD']).trim(); } catch { /* unborn repository */ }
16
+ const files = [...new Set(git(['ls-files', '-z', '--cached', '--others', '--exclude-standard']).split('\0').filter(Boolean))].sort();
17
+ const hash = createHash('sha256');
18
+ for (const relative of files) {
19
+ const file = path.join(root, relative);
20
+ let bytes = Buffer.from('deleted');
21
+ try {
22
+ const stat = fs.lstatSync(file);
23
+ bytes = stat.isSymbolicLink() ? Buffer.from(`symlink:${fs.readlinkSync(file)}`)
24
+ : stat.isFile() ? Buffer.concat([Buffer.from(`${stat.mode & 0o777}:`), fs.readFileSync(file)]) : Buffer.from('directory');
25
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
26
+ hash.update(JSON.stringify([relative, createHash('sha256').update(bytes).digest('hex')]));
27
+ }
28
+ return { sha, dirty: Boolean(git(['status', '--porcelain']).trim()), digest: hash.digest('hex'), files: files.length, recipe: 'git-source-bytes-v1' };
29
+ }
30
+
31
+ export async function runLanes(lanes, execute, concurrency = 2) {
32
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error('invalid QA concurrency');
33
+ const names = new Set(lanes.map(({ name }) => name));
34
+ if (names.size !== lanes.length || lanes.some((lane) => (lane.dependsOn || []).some((name) => !names.has(name)))) throw new Error('invalid QA dependency');
35
+ const pending = [...lanes], completed = new Map();
36
+ while (pending.length) {
37
+ const resources = new Set(), batch = [];
38
+ for (const lane of pending) {
39
+ if (!(lane.dependsOn || []).every((name) => completed.has(name))) continue;
40
+ if ((lane.dependsOn || []).some((name) => completed.get(name).status !== 'PASS')) {
41
+ completed.set(lane.name, { name: lane.name, status: 'BLOCKED', reason: 'required prerequisite did not pass' });
42
+ continue;
43
+ }
44
+ if (batch.length >= concurrency || (lane.resource && resources.has(lane.resource))) continue;
45
+ batch.push(lane); resources.add(lane.resource);
46
+ }
47
+ if (!batch.length && !pending.some(({ name }) => completed.has(name))) throw new Error('QA dependency cycle');
48
+ await Promise.all(batch.map(async (lane) => {
49
+ let result;
50
+ try { result = await execute(lane); }
51
+ catch (error) { result = { name: lane.name, status: 'FAIL', reason: error.message }; }
52
+ completed.set(lane.name, result);
53
+ }));
54
+ for (let index = pending.length - 1; index >= 0; index--) if (completed.has(pending[index].name)) pending.splice(index, 1);
55
+ }
56
+ return lanes.map(({ name }) => completed.get(name));
57
+ }
@@ -0,0 +1,40 @@
1
+ // Shared producers for local diagnostics, CI, and candidate qualification.
2
+ export function selectLanes(lanes, requested) {
3
+ if (!requested.length) return lanes;
4
+ const selected = new Set();
5
+ const visit = (name) => {
6
+ const lane = lanes.find((entry) => entry.name === name);
7
+ if (!lane) throw new Error(`unknown QA lane: ${name}`);
8
+ if (selected.has(name)) return;
9
+ selected.add(name);
10
+ for (const dependency of lane.dependsOn || []) visit(dependency);
11
+ };
12
+ requested.forEach(visit);
13
+ return lanes.filter(({ name }) => selected.has(name));
14
+ }
15
+
16
+ export function qaLanes({ release = false, base = null, runtimeCensusArgs = [] } = {}) {
17
+ const node = (name, script, args = [], extra = {}) => ({ name, command: process.execPath, args: [script, ...args], resource: 'static', ...extra });
18
+ const test = (name, args) => node(name, 'node_modules/vitest/vitest.mjs', ['run', ...args], { resource: 'tests' });
19
+ return [
20
+ node('version', 'scripts/sync-version.mjs', ['--check']),
21
+ node('convergence', 'scripts/convergence-manifest.mjs'),
22
+ node('execution-policy', 'scripts/execution-policy.mjs', ['{"action":"delegate","description":"architecture audit","nativeHosts":["codex"]}']),
23
+ node('architecture', 'scripts/product-integrity-contract.mjs', ['--check-source']),
24
+ node('docs', 'scripts/doc-currency.mjs', ['--check', ...(base ? ['--changed', base] : ['--warn-drift'])]),
25
+ node('wiring', 'scripts/wired-check.mjs', ['--check']),
26
+ node('substitution', 'scripts/no-silent-substitution.mjs'),
27
+ node('catalog', 'scripts/verify-model-catalog.mjs'),
28
+ test('coverage', ['tests/unit', '--coverage']),
29
+ node('claims-source', 'scripts/claims-verify.mjs', ['--strict', '--scope', 'source'], { dependsOn: ['coverage'], report: 'claims' }),
30
+ test('mesh', ['tests/mesh']),
31
+ node('plugin', 'plugin/test/run-tests.mjs', [], { resource: 'tests' }),
32
+ ...(release ? [
33
+ test('mutation', ['tests/mutation']),
34
+ test('regression', ['tests/regression']),
35
+ test('integration', ['tests/integration']),
36
+ test('continuity', ['tests/acceptance/cross-host-project-resume.test.mjs']),
37
+ node('claims-runtime', 'scripts/claims-verify.mjs', ['--strict', '--scope', 'runtime', ...runtimeCensusArgs], { report: 'claims' }),
38
+ ] : []),
39
+ ];
40
+ }
@@ -1,72 +1,62 @@
1
1
  #!/usr/bin/env node
2
- // Canonical bounded QA runner. Local and CI use the same lanes, command order, timeout policy,
3
- // and machine-readable receipt; a human log is never the release verdict.
4
2
  import fs from 'node:fs';
5
3
  import path from 'node:path';
6
- import { execFileSync, spawn } from 'node:child_process';
4
+ import os from 'node:os';
5
+ import { spawn } from 'node:child_process';
7
6
  import { fileURLToPath } from 'node:url';
7
+ import { qaLanes, selectLanes } from './qa-lanes.mjs';
8
+ import { runLanes, verdictOf, sourceIdentity } from './qa-contract.mjs';
8
9
 
9
10
  const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
11
  const release = process.argv.includes('--release');
11
- const timeoutMs = Number(process.env.QA_TIMEOUT_MS || (release ? 15 * 60_000 : 8 * 60_000));
12
- const lanes = [
13
- ['version', 'npm', ['run', 'version:check']],
14
- ['convergence', 'npm', ['run', 'convergence:check']],
15
- ['execution-policy', 'npm', ['run', 'execution-policy:check']],
16
- ['docs', 'npm', ['run', 'doc:currency']],
17
- ['wiring', 'npm', ['run', 'wired:check']],
18
- ['substitution', 'npm', ['run', 'substitution:check']],
19
- ['catalog', 'npm', ['run', 'catalog:verify']],
20
- ['contract', 'npx', ['vitest', 'run',
21
- 'tests/unit/brain-off.test.mjs',
22
- 'tests/unit/hook-hardening.test.mjs',
23
- 'tests/unit/hook-battery.test.mjs',
24
- 'tests/unit/session-start-core-parity.test.mjs',
25
- 'tests/unit/lesson-gate.test.mjs',
26
- 'tests/unit/wired-check.test.mjs',
27
- 'tests/unit/no-restated-truth.test.mjs',
28
- 'tests/unit/verify-citation.test.mjs',
29
- 'tests/unit/eval-brain-gate.test.mjs',
30
- 'tests/unit/distill-project.test.mjs',
31
- 'tests/unit/record-lesson.test.mjs',
32
- 'tests/unit/learn-capture-project-root.test.mjs',
33
- 'tests/unit/codex-claude-hook-parity.test.mjs',
34
- 'tests/unit/brain-stamp-resolve-built-from-sha.test.mjs',
35
- 'tests/unit/qa-runner-concurrency.test.mjs',
36
- 'tests/unit/execution-preflight.test.mjs',
37
- '--reporter=dot']],
38
- ['mesh', 'npm', ['run', 'test:mesh']],
39
- ['plugin', 'npm', ['test']],
40
- ...(release ? [['mutation', 'npm', ['run', 'test:mutation']], ['claims', 'npm', ['run', 'claims:verify']], ['integration', 'npm', ['run', 'test:integration']]] : []),
41
- ];
42
- const receiptDir = path.join(root, '.qa');
43
- fs.mkdirSync(receiptDir, { recursive: true });
44
- const started = new Date().toISOString();
45
- const sha = (() => {
46
- try { return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim(); }
47
- catch { return null; }
48
- })();
49
- const run = ([name, command, args]) => new Promise((resolve) => {
50
- const begin = Date.now();
51
- const child = spawn(command, args, { cwd: root, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false });
52
- let stdout = '', stderr = '', timedOut = false;
53
- child.stdout.on('data', (chunk) => { stdout += chunk; process.stdout.write(chunk); });
54
- child.stderr.on('data', (chunk) => { stderr += chunk; process.stderr.write(chunk); });
55
- const timer = setTimeout(() => { timedOut = true; child.kill('SIGTERM'); setTimeout(() => child.kill('SIGKILL'), 2000).unref(); }, timeoutMs);
56
- child.on('close', (code, signal) => {
57
- clearTimeout(timer);
58
- resolve({ name, command: [command, ...args].join(' '), status: timedOut ? 'TIMEOUT' : code === 0 ? 'PASS' : 'FAIL', exitCode: code, signal, elapsedMs: Date.now() - begin, stdoutTail: stdout.slice(-4000), stderrTail: stderr.slice(-4000) });
59
- });
12
+ const argument = (name) => { const i = process.argv.indexOf(name); return i < 0 ? null : process.argv[i + 1]; };
13
+ const requested = process.argv.flatMap((value, index) => value === '--lane' ? [process.argv[index + 1]] : []);
14
+ const runtimeCensusArgs = ['--candidate-kb', '--candidate-sha', '--candidate-version', '--candidate-root',
15
+ '--payload-manifest', '--payload-signature', '--payload-id', '--qualification-mode'].flatMap((name) => {
16
+ if (!process.argv.includes(name)) return [];
17
+ const value = argument(name);
18
+ if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
19
+ return [name, value];
60
20
  });
61
- // Every lane is independent. Run the complete set concurrently so one failure does not hide
62
- // later failures and force a serial fix-rerun cycle. The aggregate receipt remains the single
63
- // verdict, while each lane keeps its own evidence for targeted repair.
64
- const results = await Promise.all(lanes.map(async (lane) => {
65
- const result = await run(lane);
66
- fs.writeFileSync(path.join(receiptDir, `${result.name}.json`), `${JSON.stringify({ schema: 'ruvnet-brain.qa.lane', sha, ...result }, null, 2)}\n`);
67
- return result;
68
- }));
69
- const receipt = { schema: 'ruvnet-brain.qa.aggregate', contract: release ? 'release' : 'pr', sha, started, ended: new Date().toISOString(), status: results.every((r) => r.status === 'PASS') && results.length === lanes.length ? 'PASS' : 'FAIL', requiredLanes: lanes.map(([name]) => name), results };
70
- fs.writeFileSync(path.join(receiptDir, 'aggregate.json'), `${JSON.stringify(receipt, null, 2)}\n`);
71
- console.log(JSON.stringify({ status: receipt.status, sha, lanes: results.map(({ name, status, elapsedMs }) => ({ name, status, elapsedMs })) }));
72
- process.exit(receipt.status === 'PASS' ? 0 : 1);
21
+ const lanes = selectLanes(qaLanes({ release, runtimeCensusArgs, base: argument('--base') || process.env.QA_BASE_SHA }), requested);
22
+ if (process.argv.includes('--list')) {
23
+ console.log(JSON.stringify(lanes, null, 2));
24
+ } else {
25
+ const timeoutMs = Number(process.env.QA_TIMEOUT_MS || (release ? 15 * 60_000 : 8 * 60_000));
26
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error('invalid QA timeout');
27
+ const receiptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-brain-qa-'));
28
+ const started = new Date().toISOString();
29
+ const source = sourceIdentity(root);
30
+ const run = (lane) => new Promise((resolve) => {
31
+ const begin = Date.now();
32
+ const evidenceFile = lane.report ? path.join(receiptDir, lane.name + '-evidence.json') : null;
33
+ const args = evidenceFile ? [...lane.args, '--report', evidenceFile] : lane.args;
34
+ const child = spawn(lane.command, args, { cwd: root, env: process.env, stdio: ['ignore', 'pipe', 'pipe'], shell: false, detached: process.platform !== 'win32' });
35
+ let stdout = '', stderr = '', timedOut = false, spawnError = null, escalation = null;
36
+ child.stdout.on('data', (chunk) => { stdout = (stdout + chunk).slice(-4000); process.stdout.write(chunk); });
37
+ child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-4000); process.stderr.write(chunk); });
38
+ child.on('error', (error) => { spawnError = error.message; });
39
+ const kill = (signal) => {
40
+ if (!child.pid) return;
41
+ try {
42
+ if (process.platform === 'win32') spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore' }).on('error', () => {});
43
+ else process.kill(-child.pid, signal);
44
+ } catch (error) { if (error.code !== 'ESRCH') spawnError = error.message; }
45
+ };
46
+ const timer = setTimeout(() => { timedOut = true; kill('SIGTERM'); escalation = setTimeout(() => kill('SIGKILL'), 2000); escalation.unref(); }, timeoutMs);
47
+ child.on('close', (code, signal) => {
48
+ clearTimeout(timer);
49
+ clearTimeout(escalation);
50
+ resolve({ name: lane.name, command: [lane.command, ...args], evidenceFile, status: timedOut ? 'TIMEOUT' : spawnError ? 'FAIL' : code === 0 ? 'PASS' : code === 4 ? 'UNKNOWN' : 'FAIL', exitCode: code, signal, spawnError, elapsedMs: Date.now() - begin, stdoutTail: stdout, stderrTail: stderr });
51
+ });
52
+ });
53
+ const results = await runLanes(lanes, run, 2);
54
+ const after = sourceIdentity(root);
55
+ const stable = after.digest === source.digest;
56
+ const status = stable ? verdictOf(results) : 'UNKNOWN';
57
+ const receipt = { schema: 'ruvnet-brain.qa.aggregate', contract: release ? 'release' : 'pr', selection: requested.length ? 'partial' : 'complete', source, sourceAfter: after, sourceStable: stable, started, ended: new Date().toISOString(), status, requiredLanes: lanes.map(({ name }) => name), results };
58
+ for (const result of results) fs.writeFileSync(path.join(receiptDir, result.name + '.json'), JSON.stringify({ schema: 'ruvnet-brain.qa.lane', source, ...result }, null, 2), { flag: 'wx' });
59
+ fs.writeFileSync(path.join(receiptDir, 'aggregate.json'), JSON.stringify(receipt, null, 2), { flag: 'wx' });
60
+ console.log(JSON.stringify({ status, receiptDir, source, sourceStable: stable, lanes: results.map(({ name, status }) => ({ name, status })) }));
61
+ process.exitCode = status === 'PASS' ? 0 : status === 'UNKNOWN' ? 4 : 1;
62
+ }
@@ -11,16 +11,8 @@
11
11
  // scheduler jitter — it can only be a correctness regression. THIS is a genuine hard gate: a
12
12
  // breach here fails the suite, not warns it. See scripts/qe/card-lane-gate.mjs for the full
13
13
  // reasoning and the in-process (no subprocess per firing) measurement method.
14
- // 3. SESSION-START WALL TIME (added 2026-07-28) is the SAME tier as 2, and is here because tier 2
15
- // alone was not enough. An independent grader's words: the card-lane gate "measures a
16
- // 0.03–0.22ms in-process function against a 250ms budget (~1000x headroom — it can only catch
17
- // catastrophic regression classes)", while "everything the user actually FEELS — heavy-lane
18
- // query seconds, session-start WALL TIME, install minutes, dead air, refusal clarity — is
19
- // advisory or unmeasured". Session-start wall time is the first of those promoted out of tier 1:
20
- // it is the hook a stranger's Claude Code fires before their first prompt is answered, it is
21
- // already measured by scripts/selfcheck.mjs's external process-group watchdog (no second timer
22
- // was written), and its budget is set from a measured distribution — p95 1000ms is ~3.1x the
23
- // worst measured p95 (323ms over n=110), NOT 1000x. See scripts/qe/session-start-gate.mjs.
14
+ // 3. Automatic hook retirement is checked across declared source registries and pointers.
15
+ // The old SessionStart latency probe remains separately callable for diagnostics.
24
16
  //
25
17
  // HONESTY (same rules as the product):
26
18
  // • Every number is measured on THIS run. Nothing is asserted from memory.
@@ -39,7 +31,7 @@ import os from 'node:os';
39
31
  import { fileURLToPath } from 'node:url';
40
32
  import { runCommandProbe } from '../../tests/ux/command-probe.mjs';
41
33
  import { runCardLaneGate } from './card-lane-gate.mjs';
42
- import { runSessionStartGate } from './session-start-gate.mjs';
34
+ import { automaticHookRetirementStatus } from '../../bin/install.mjs';
43
35
 
44
36
  const HERE = path.dirname(fileURLToPath(import.meta.url));
45
37
  const RENDER_PROBE = path.resolve(HERE, '../../tests/ux/render-probe.mjs');
@@ -322,28 +314,11 @@ export async function runUxSuite() {
322
314
  hardFailures.push(`card-lane latency gate: could not run — ${e.message}`);
323
315
  }
324
316
 
325
- // ── Probe 5: session-start wall time — HARD GATE, the first USER-FELT number (ADR-058 D6) ───
326
- // Wired exactly like probe 4 above and for the same reason: same tier, same "could not measure is
327
- // never success" handling, same refusal to reuse line()'s "(proposed)" phrasing, which is correct
328
- // for an advisory row and would misreport a HARD breach.
329
- console.log('\n ── session-start wall time (plugin/hooks/hooks.json SessionStart) — HARD GATE, user-felt ──');
330
- try {
331
- const ss = await runSessionStartGate();
332
- const b = ss.budget;
333
- const tag = (ok) => (ok ? '✓' : '✗ HARD FAIL');
334
- console.log(` ${'session-start cold first fire'.padEnd(30)} ${ss.warmupMs.toFixed(0).padStart(10)}ms ${ss.warmupTimedOut ? '✗ HARD FAIL (declared timeout exceeded)' : '✓ inside declared timeout'}`);
335
- console.log(` ${'session-start p50'.padEnd(30)} ${ss.p50.toFixed(0).padStart(10)}ms (reported, not gated)`);
336
- console.log(` ${'session-start p95'.padEnd(30)} ${ss.p95.toFixed(0).padStart(10)}ms budget ${b.p95BudgetMs}ms ${tag(ss.p95 <= b.p95BudgetMs)}`);
337
- console.log(` ${'session-start max'.padEnd(30)} ${ss.max.toFixed(0).padStart(10)}ms absolute-fail ${b.absoluteFailMs}ms ${tag(ss.max <= b.absoluteFailMs)}`);
338
- console.log(` firings: ${ss.n} sequential fires of the REAL registered command via selfcheck.mjs's watchdog, from ${ss.surface.source}`);
339
- if (ss.warmupStderr) {
340
- console.log(` cold trace: ${ss.warmupStderr.trim().split('\n').join(' | ')}`);
341
- }
342
- if (!ss.pass) for (const r of ss.reasons) hardFailures.push(`session-start wall time: ${r}`);
343
- } catch (e) {
344
- console.log(` ! could not run the session-start wall-time gate: ${e.message}`);
345
- hardFailures.push(`session-start wall-time gate: could not run — ${e.message}`);
346
- }
317
+ // Automatic lifecycle hooks are retired; measure registry state, not a fictitious latency.
318
+ const hookRetirement = automaticHookRetirementStatus(path.resolve(HERE, '../..'));
319
+ console.log(`\n Automatic hooks: ${hookRetirement.ok ? 'RETIRED' : 'FAIL'} — ${hookRetirement.files.length} surfaces inspected`);
320
+ if (!hookRetirement.ok) hardFailures.push(...hookRetirement.errors,
321
+ ...hookRetirement.registrations.map(row => `automatic registration remains: ${row.file} ${row.event}`));
347
322
 
348
323
  // ── Not run on this host (stated, never faked) ──────────────────────────────────────────────
349
324
  console.log('\n ── execution scope ──');
@@ -363,6 +338,7 @@ export async function runUxSuite() {
363
338
  budgetsMs: budgets,
364
339
  render,
365
340
  command: cmd,
341
+ hookRetirement,
366
342
  hardFailures,
367
343
  pass: hardFailures.length === 0,
368
344
  scope: {
@@ -375,7 +351,7 @@ export async function runUxSuite() {
375
351
 
376
352
  console.log('\n ── verdict ──');
377
353
  if (hardFailures.length === 0) {
378
- console.log(' PASS — every probe ran and every render, explanation, dead-air, decision-lane, and session-start HARD budget passed.\n');
354
+ console.log(' PASS — every probe ran and every render, explanation, dead-air, decision-lane HARD budget passed; automatic-hook retirement verified.\n');
379
355
  return receipt;
380
356
  }
381
357
  console.log(' FAIL (hard):');