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.
- package/README.md +26 -9
- package/bin/install.mjs +646 -366
- package/bin/nightly-refresh.mjs +115 -0
- package/kb/brain-profile.mjs +118 -32
- package/kb/lifecycle-evidence-retention.mjs +237 -0
- package/kb/refresh-run.mjs +367 -0
- package/kb/retrieval-result.mjs +39 -0
- package/kb/update-storage-transaction.mjs +439 -0
- package/package.json +11 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +2 -165
- package/plugin/hooks/hook-contracts.json +4 -65
- package/plugin/hooks/hooks.json +2 -208
- package/plugin/scripts/adr-currency-gate.mjs +29 -19
- package/plugin/scripts/capability-registry.mjs +10 -83
- package/plugin/scripts/codex-hook-adapter.mjs +62 -9
- package/plugin/scripts/codex-hook-wrapper.mjs +19 -1
- package/plugin/scripts/continuation-gate.mjs +30 -28
- package/plugin/scripts/continuation-objective.mjs +34 -0
- package/plugin/scripts/development-maintenance.mjs +49 -0
- package/plugin/scripts/hook-shim.mjs +3 -0
- package/plugin/scripts/learn-flush.mjs +6 -1
- package/plugin/scripts/lesson-gate.mjs +5 -2
- package/plugin/scripts/lesson-presentation.mjs +5 -0
- package/plugin/scripts/md-stamp.mjs +109 -6
- package/plugin/scripts/memory-doctor.mjs +3 -7
- package/plugin/scripts/nightly-controller.mjs +9 -27
- package/plugin/scripts/nightly-scheduler.mjs +472 -0
- package/plugin/scripts/project-progression-contract.mjs +4 -4
- package/plugin/scripts/project-progression-store.mjs +35 -6
- package/plugin/scripts/ruflo-bin.mjs +20 -0
- package/plugin/scripts/session-snapshot-contract.mjs +6 -1
- package/plugin/scripts/version-bump-gate.sh +14 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +15 -23
- package/scripts/build-bundle.mjs +8 -0
- package/scripts/build-primer.mjs +2 -3
- package/scripts/candidate-host-evidence.mjs +59 -41
- package/scripts/ci/build-fixture-kb.mjs +59 -2
- package/scripts/ci/mutate-hook-timeout.mjs +24 -47
- package/scripts/ci/stranger-scenario.mjs +13 -28
- package/scripts/claims-verify.mjs +219 -21
- package/scripts/console-engine.mjs +4 -18
- package/scripts/console-runtime-identity.mjs +6 -0
- package/scripts/development-maintenance.mjs +47 -0
- package/scripts/development-push-check.mjs +35 -0
- package/scripts/distill-project.mjs +24 -47
- package/scripts/doc-currency.mjs +91 -19
- package/scripts/git-hooks/pre-push +4 -162
- package/scripts/health-repair.mjs +22 -4
- package/scripts/hook-retirement-check.mjs +22 -0
- package/scripts/host-install-matrix.mjs +125 -61
- package/scripts/integration-evidence.mjs +11 -6
- package/scripts/learning-replay-fixture.mjs +23 -2
- package/scripts/nightly-two-run-proof.mjs +415 -0
- package/scripts/nightly-watchdog.mjs +17 -2
- package/scripts/npm-invocation.mjs +20 -0
- package/scripts/prepublication-evidence.mjs +42 -6
- package/scripts/primer-grounding.mjs +20 -0
- package/scripts/product-integrity-contract.mjs +22 -2
- package/scripts/public-verification-abandon.mjs +101 -0
- package/scripts/public-verification-aggregate.mjs +53 -19
- package/scripts/public-verification-finalizer.mjs +8 -2
- package/scripts/public-verification-lane.mjs +63 -8
- package/scripts/publication-receipt.mjs +109 -45
- package/scripts/published-surface-probe.mjs +10 -2
- package/scripts/qa-contract.mjs +57 -0
- package/scripts/qa-lanes.mjs +40 -0
- package/scripts/qa-runner.mjs +54 -64
- package/scripts/qe/ux-suite.mjs +10 -34
- package/scripts/qualified-candidate-check.mjs +124 -0
- package/scripts/release-abort-stale.mjs +2 -2
- package/scripts/release-projection.mjs +20 -1
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +135 -0
- package/scripts/release-transaction.mjs +81 -1
- package/scripts/remedy-registry.mjs +6 -13
- package/scripts/retrieval-canary.mjs +91 -18
- package/scripts/selfcheck.mjs +12 -4
- package/scripts/snapshot-freshness.mjs +58 -0
- package/scripts/stack-sync.mjs +41 -43
- package/scripts/staged-host-verifier.mjs +63 -14
- 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 {
|
|
23
|
-
import {
|
|
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('
|
|
199
|
-
|
|
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
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
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
|
|
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]) ===
|
|
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
|
+
}
|
package/scripts/qa-runner.mjs
CHANGED
|
@@ -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
|
|
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
|
|
12
|
-
const
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
+
}
|
package/scripts/qe/ux-suite.mjs
CHANGED
|
@@ -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.
|
|
15
|
-
//
|
|
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 {
|
|
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
|
-
//
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
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):');
|