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
|
@@ -2,10 +2,74 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import readline from 'node:readline';
|
|
5
6
|
import { spawnSync } from 'node:child_process';
|
|
6
7
|
import { fileURLToPath } from 'node:url';
|
|
7
8
|
import { canonicalJson, digest, validateCoverageLedger } from './coverage-integrity.mjs';
|
|
8
9
|
|
|
10
|
+
// Both release phases resolve against an explicit installed context, never the checkout.
|
|
11
|
+
export async function resolveInstalledCanaryCitation({ kbDir, matched, expected, passageFileDigests = new Map() }) {
|
|
12
|
+
if (!path.isAbsolute(kbDir || '')) throw new Error('installed canary KB path must be absolute');
|
|
13
|
+
if (String(matched?.repo || '').toLowerCase() !== expected.repo || matched?.path !== expected.path) return { resolved: false };
|
|
14
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(expected.repo)) throw new Error('installed citation repository violates containment');
|
|
15
|
+
const rootStat = fs.lstatSync(kbDir);
|
|
16
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink() || fs.realpathSync(kbDir) !== path.resolve(kbDir)) {
|
|
17
|
+
throw new Error('installed canary KB root or parent is a symlink; canonical containment required');
|
|
18
|
+
}
|
|
19
|
+
const files = [path.join(kbDir, `${expected.repo}.passages.jsonl`), path.join(kbDir, `${expected.repo}.big.passages.jsonl`)]
|
|
20
|
+
.filter((file) => { try { fs.lstatSync(file); return true; } catch (error) { if (error.code === 'ENOENT') return false; throw error; } });
|
|
21
|
+
for (const file of files) {
|
|
22
|
+
const stat = fs.lstatSync(file);
|
|
23
|
+
if (!stat.isFile() || stat.isSymbolicLink() || path.dirname(fs.realpathSync(file)) !== kbDir) {
|
|
24
|
+
throw new Error('installed citation file is a symlink or violates containment');
|
|
25
|
+
}
|
|
26
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0));
|
|
27
|
+
const stream = fs.createReadStream(file, { fd, autoClose: false });
|
|
28
|
+
const hash = crypto.createHash('sha256');
|
|
29
|
+
stream.on('data', (bytes) => hash.update(bytes));
|
|
30
|
+
const rows = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
31
|
+
let passageSha256 = null;
|
|
32
|
+
try {
|
|
33
|
+
for await (const line of rows) {
|
|
34
|
+
let record;
|
|
35
|
+
try { record = JSON.parse(line); } catch { continue; }
|
|
36
|
+
if (record?.path !== expected.path) continue;
|
|
37
|
+
if (digest(record) !== expected.passageSha256) continue;
|
|
38
|
+
const text = record.fullText || record.text;
|
|
39
|
+
if (typeof text !== 'string' || !text || typeof matched.text !== 'string' || !matched.text.includes(text)) continue;
|
|
40
|
+
passageSha256 = expected.passageSha256;
|
|
41
|
+
}
|
|
42
|
+
const after = fs.lstatSync(file);
|
|
43
|
+
const opened = fs.fstatSync(fd);
|
|
44
|
+
if (after.isSymbolicLink() || after.dev !== stat.dev || after.ino !== stat.ino
|
|
45
|
+
|| opened.ino !== stat.ino || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs
|
|
46
|
+
|| after.ctimeMs !== stat.ctimeMs) throw new Error('installed citation changed during verification');
|
|
47
|
+
const passageFileSha256 = hash.digest('hex');
|
|
48
|
+
passageFileDigests.set(file, passageFileSha256);
|
|
49
|
+
if (passageSha256) return { resolved: true, evidence: { passageSha256, passageFileSha256,
|
|
50
|
+
hitContentSha256: crypto.createHash('sha256').update(matched.text).digest('hex') } };
|
|
51
|
+
} finally {
|
|
52
|
+
rows.close();
|
|
53
|
+
await new Promise((resolve, reject) => stream.close((error) => error ? reject(error) : resolve()));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { resolved: false };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function expectedSources(expected) {
|
|
60
|
+
return [{ path: expected.path, passageSha256: expected.passageSha256 }, ...(expected.alternatives || [])];
|
|
61
|
+
}
|
|
62
|
+
function validExpectedSources(expected) {
|
|
63
|
+
if (!expected || !['passageSha256,path', 'alternatives,passageSha256,path'].includes(Object.keys(expected).sort().join(','))) return false;
|
|
64
|
+
if (expected.alternatives !== undefined && (!Array.isArray(expected.alternatives)
|
|
65
|
+
|| expected.alternatives.length < 1 || expected.alternatives.length > 8)) return false;
|
|
66
|
+
const sources = expectedSources(expected);
|
|
67
|
+
return new Set(sources.map((source) => source?.path)).size === sources.length && sources.every((source) =>
|
|
68
|
+
source && Object.keys(source).sort().join(',') === 'passageSha256,path'
|
|
69
|
+
&& typeof source.path === 'string' && source.path && !path.isAbsolute(source.path)
|
|
70
|
+
&& !source.path.split(/[\\/]/).includes('..') && HEX64.test(String(source.passageSha256 || '')));
|
|
71
|
+
}
|
|
72
|
+
|
|
9
73
|
const HEX40 = /^[a-f0-9]{40}$/;
|
|
10
74
|
const HEX64 = /^[a-f0-9]{64}$/;
|
|
11
75
|
|
|
@@ -96,10 +160,7 @@ export function validateRetrievalQueryEvidence(evidence) {
|
|
|
96
160
|
if (Object.keys(row || {}).sort().join(',') !== 'expected,query,recordSha256'
|
|
97
161
|
|| typeof row.query !== 'string' || row.query !== row.query.trim().replace(/\s+/g, ' ')
|
|
98
162
|
|| normalized.length < 24 || seenQueries.has(normalized)
|
|
99
|
-
|| !
|
|
100
|
-
|| typeof expected.path !== 'string' || !expected.path || path.isAbsolute(expected.path)
|
|
101
|
-
|| expected.path.split(/[\\/]/).includes('..')
|
|
102
|
-
|| !HEX64.test(String(expected.passageSha256 || ''))
|
|
163
|
+
|| !validExpectedSources(expected)
|
|
103
164
|
|| row.recordSha256 !== digest({ store, query: row.query, expected })) {
|
|
104
165
|
throw new Error(`independent retrieval query evidence for ${store} is malformed`);
|
|
105
166
|
}
|
|
@@ -187,7 +248,8 @@ export function validateRetrievalCanaryPlan(plan) {
|
|
|
187
248
|
throw new Error('oracle denominator differs from eligible coverage');
|
|
188
249
|
}
|
|
189
250
|
if (new Set(ids).size !== ids.length) throw new Error('retrieval canary plan has duplicate case ids');
|
|
190
|
-
|
|
251
|
+
const hasDelta = plan.cases.some(({ cohort }) => cohort === 'delta');
|
|
252
|
+
if ((!hasDelta && plan.noDelta !== true) || (hasDelta && plan.noDelta === true)
|
|
191
253
|
|| !plan.cases.some(({ cohort }) => cohort === 'legacy')) {
|
|
192
254
|
throw new Error('retrieval canary plan requires delta and legacy cohorts');
|
|
193
255
|
}
|
|
@@ -316,12 +378,15 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
|
|
|
316
378
|
if (!strata.has(stratum)) strata.set(stratum, []);
|
|
317
379
|
strata.get(stratum).push(entry);
|
|
318
380
|
});
|
|
381
|
+
// Source-only releases retain the same corpus sample; the plan still seals exact release bytes.
|
|
382
|
+
const samplingGeneration = coverage.kind === 'ruvnet-brain-release-coverage'
|
|
383
|
+
? coverage.corpusCoverage.coverageGeneration : coverageGeneration;
|
|
319
384
|
const sampleCount = Math.min(legacyPool.length, legacySampleSize ?? Math.max(10, Math.ceil(eligible.length * 0.1)));
|
|
320
385
|
if (sampleCount < strata.size) throw new Error(`legacy sample must cover all ${strata.size} passage-count strata`);
|
|
321
386
|
const selected = [];
|
|
322
387
|
for (const [stratum, entries] of strata) {
|
|
323
|
-
entries.sort((a, b) => digest(`${
|
|
324
|
-
.localeCompare(digest(`${
|
|
388
|
+
entries.sort((a, b) => digest(`${samplingGeneration}:${stratum}:${storeOf(a.row)}`)
|
|
389
|
+
.localeCompare(digest(`${samplingGeneration}:${stratum}:${storeOf(b.row)}`)));
|
|
325
390
|
selected.push({ ...entries.shift(), stratum });
|
|
326
391
|
}
|
|
327
392
|
while (selected.length < sampleCount) {
|
|
@@ -338,9 +403,8 @@ export function buildRetrievalCanaryPlan({ coverage, baseline, candidate, covera
|
|
|
338
403
|
const store = storeOf(row);
|
|
339
404
|
const observedPassageCount = passageCount ?? passages.get(store).length;
|
|
340
405
|
const evidence = queryEvidence.queries[store];
|
|
341
|
-
|
|
342
|
-
&& digest(row) ===
|
|
343
|
-
if (!evidence || matches.length !== 1) {
|
|
406
|
+
if (!evidence || expectedSources(evidence.expected).some((source) =>
|
|
407
|
+
passages.get(store).filter((row) => row.path === source.path && digest(row) === source.passageSha256).length !== 1)) {
|
|
344
408
|
throw new Error(`${store} has no sealed independent query evidence`);
|
|
345
409
|
}
|
|
346
410
|
return {
|
|
@@ -430,10 +494,12 @@ export function validateRetrievalCanaryReceipt(receipt, { plan, requireAcceptanc
|
|
|
430
494
|
for (const row of receipt.cases) {
|
|
431
495
|
const expected = plan.cases.find(({ id }) => id === row.id)?.expected;
|
|
432
496
|
const ranked = row.retrievalHit ? row.citations?.[row.rank - 1] : null;
|
|
497
|
+
const accepted = expectedSources(expected).find((source) => source.path === ranked?.path);
|
|
433
498
|
if (row.retrievalHit && (String(ranked?.repo || '').toLowerCase() !== expected.repo
|
|
434
|
-
||
|
|
435
|
-
|| (row.citationResolved === true && (row.citationEvidence?.passageSha256 !==
|
|
436
|
-
|| !HEX64.test(String(row.citationEvidence?.passageFileSha256 || ''))
|
|
499
|
+
|| !accepted
|
|
500
|
+
|| (row.citationResolved === true && (row.citationEvidence?.passageSha256 !== accepted.passageSha256
|
|
501
|
+
|| !HEX64.test(String(row.citationEvidence?.passageFileSha256 || ''))
|
|
502
|
+
|| (ranked.contentSha256 !== undefined && row.citationEvidence?.hitContentSha256 !== ranked.contentSha256))))) {
|
|
437
503
|
throw new Error(`retrieval canary ${row.id} hit or citation evidence differs from the sealed plan`);
|
|
438
504
|
}
|
|
439
505
|
}
|
|
@@ -448,7 +514,9 @@ export function validateRetrievalCanaryReceipt(receipt, { plan, requireAcceptanc
|
|
|
448
514
|
deltaHits: delta.filter(({ retrievalHit }) => retrievalHit === true).length,
|
|
449
515
|
deltaCitations: delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length,
|
|
450
516
|
recallAt10: hits / receipt.cases.length,
|
|
451
|
-
deltaCitationRate: delta.length
|
|
517
|
+
deltaCitationRate: delta.length
|
|
518
|
+
? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit === true && citationResolved === true).length / delta.length
|
|
519
|
+
: plan.noDelta === true ? 1 : 0,
|
|
452
520
|
unknown,
|
|
453
521
|
skipped,
|
|
454
522
|
};
|
|
@@ -478,12 +546,14 @@ export async function runRetrievalCanaries({ plan, sourceSha, artifactSha256, ca
|
|
|
478
546
|
const rows = resultRows(await search({ query: canary.query, k: 10 }));
|
|
479
547
|
const top = rows.slice(0, 10);
|
|
480
548
|
const rank = top.findIndex((row) => String(row?.repo || '').toLowerCase() === canary.expected.repo
|
|
481
|
-
&& row?.path ===
|
|
549
|
+
&& expectedSources(canary.expected).some((source) => row?.path === source.path));
|
|
482
550
|
const matched = rank >= 0 ? top[rank] : null;
|
|
483
|
-
const
|
|
551
|
+
const accepted = matched ? expectedSources(canary.expected).find((source) => source.path === matched.path) : null;
|
|
552
|
+
const resolved = matched ? await citationResolver(matched, { repo: canary.expected.repo, ...accepted }) : { resolved: false };
|
|
484
553
|
cases[index] = { id: canary.id, cohort: canary.cohort, status: 'COMPLETED', retrievalHit: rank >= 0,
|
|
485
554
|
citationResolved: resolved?.resolved === true, citationEvidence: resolved?.evidence || null,
|
|
486
|
-
rank: rank >= 0 ? rank + 1 : null, citations: top.map(({ repo, path: hitPath }) => ({ repo, path: hitPath
|
|
555
|
+
rank: rank >= 0 ? rank + 1 : null, citations: top.map(({ repo, path: hitPath, contentSha256 }) => ({ repo, path: hitPath,
|
|
556
|
+
...(contentSha256 ? { contentSha256 } : {}) })) };
|
|
487
557
|
} catch (error) {
|
|
488
558
|
cases[index] = { id: canary.id, cohort: canary.cohort, status: 'UNKNOWN', retrievalHit: false,
|
|
489
559
|
citationResolved: false, citationEvidence: null, rank: null, citations: [], error: error.message };
|
|
@@ -494,7 +564,10 @@ export async function runRetrievalCanaries({ plan, sourceSha, artifactSha256, ca
|
|
|
494
564
|
const hits = cases.filter(({ retrievalHit }) => retrievalHit).length;
|
|
495
565
|
const metrics = { total: cases.length, hits, deltaTotal: delta.length, deltaHits: delta.filter(({ retrievalHit }) => retrievalHit).length,
|
|
496
566
|
deltaCitations: delta.filter(({ retrievalHit, citationResolved }) => retrievalHit && citationResolved).length,
|
|
497
|
-
recallAt10: hits / cases.length,
|
|
567
|
+
recallAt10: hits / cases.length,
|
|
568
|
+
deltaCitationRate: delta.length
|
|
569
|
+
? delta.filter(({ retrievalHit, citationResolved }) => retrievalHit && citationResolved).length / delta.length
|
|
570
|
+
: plan.noDelta === true ? 1 : 0,
|
|
498
571
|
unknown: cases.filter(({ status }) => status === 'UNKNOWN').length,
|
|
499
572
|
skipped: cases.filter(({ status }) => status === 'SKIPPED').length };
|
|
500
573
|
const payload = { schemaVersion: 1, kind: 'ruvnet-brain-retrieval-canary-receipt', sourceSha, artifactSha256,
|
package/scripts/selfcheck.mjs
CHANGED
|
@@ -477,7 +477,7 @@ export function assertContract({ rec, measurement, mode, timeoutSec }) {
|
|
|
477
477
|
}
|
|
478
478
|
|
|
479
479
|
// ── §4 THE BATTERY: every installed registration × every stdin regime ───────────────────────────
|
|
480
|
-
export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tmpdir(), regimes = STDIN_REGIMES, surface = null, env = {} } = {}) {
|
|
480
|
+
export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tmpdir(), regimes = STDIN_REGIMES, surface = null, env = {}, inspectOnly = false } = {}) {
|
|
481
481
|
const reg = await loadRegistry();
|
|
482
482
|
const s = surface ?? resolveInstalledSurface({ home, repo });
|
|
483
483
|
if (!s.ok) return { ok: false, reason: s.reason, violations: [], results: [] };
|
|
@@ -487,6 +487,7 @@ export async function runBattery({ home = os.homedir(), repo = null, cwd = os.tm
|
|
|
487
487
|
const { contracts } = reg.loadContracts(s.root);
|
|
488
488
|
|
|
489
489
|
const registrations = readInstalledRegistrations(s.hooksFile);
|
|
490
|
+
if (inspectOnly) return { ok: true, surface: s, registrations, violations: [], results: [] };
|
|
490
491
|
const violations = [];
|
|
491
492
|
const results = [];
|
|
492
493
|
|
|
@@ -614,13 +615,20 @@ export async function selfCheck({ home = os.homedir(), repo = null, cwd = os.tmp
|
|
|
614
615
|
}
|
|
615
616
|
|
|
616
617
|
// (b) THE BATTERY
|
|
617
|
-
const battery = await runBattery({ home, repo, cwd, regimes });
|
|
618
|
+
const battery = await runBattery({ home, repo, cwd, regimes, inspectOnly: true });
|
|
618
619
|
if (!battery.ok) {
|
|
619
620
|
lines.push(`hooks: ${battery.reason}`);
|
|
620
621
|
violations.push({ kind: 'no-plugin', where: 'hooks', detail: battery.reason });
|
|
621
622
|
} else {
|
|
622
623
|
violations.push(...battery.violations);
|
|
623
|
-
|
|
624
|
+
if (battery.registrations.length !== 0) {
|
|
625
|
+
violations.push({
|
|
626
|
+
kind: 'automatic-registration',
|
|
627
|
+
where: battery.surface.source,
|
|
628
|
+
detail: `${battery.registrations.length} retired Brain lifecycle registration(s) remain installed`,
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
lines.push(`hooks: ${battery.registrations.length} automatic registrations from ${battery.surface.source}; zero is the required retired state`);
|
|
624
632
|
}
|
|
625
633
|
|
|
626
634
|
// (c) COEXISTENCE — reported, never charged to the user
|
|
@@ -656,7 +664,7 @@ export function formatVerdict(result, { color = null } = {}) {
|
|
|
656
664
|
const out = [];
|
|
657
665
|
for (const l of result.lines) out.push(` ${c.dim(l)}`);
|
|
658
666
|
if (!result.violations.length) {
|
|
659
|
-
out.push(` ${c.green('✓ Self-check passed.')}
|
|
667
|
+
out.push(` ${c.green('✓ Self-check passed.')} The installed Brain surface has zero automatic lifecycle registrations.`);
|
|
660
668
|
return out.join('\n');
|
|
661
669
|
}
|
|
662
670
|
out.push(` ${c.red(`✗ Self-check FAILED — ${result.violations.length} contract violation(s):`)}`);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* snapshot-freshness.mjs — "a claimed backup is not a found backup" as a shared, pure function.
|
|
4
|
+
*
|
|
5
|
+
* `distill-project.mjs` established this discipline (PR #192): `ruflo memory backup` exiting 0
|
|
6
|
+
* proves nothing about whether a NEW file actually landed — a stale snapshot from a prior run
|
|
7
|
+
* sitting in the same directory is otherwise indistinguishable from a genuine fresh one, and a
|
|
8
|
+
* caller that trusts the exit code alone can proceed to mutate state on a false undo guarantee.
|
|
9
|
+
* Extracted here so `health-repair.mjs`'s fleet distillation (a second, independent caller of
|
|
10
|
+
* `ruflo memory backup` per store) can share the exact same proof instead of re-deriving it —
|
|
11
|
+
* duplicating this logic once already cost a real, shipped defect (see the 2026-08-29 Dream Cycle
|
|
12
|
+
* report, "Next steps #1").
|
|
13
|
+
*/
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
// Some filesystems truncate mtime to whole seconds (FAT32, some overlay/network mounts), so a file
|
|
18
|
+
// written a moment after `sinceMs` was captured can still report an mtime slightly before it. This
|
|
19
|
+
// grace window is tolerance for that truncation, not a loophole — it stays far smaller than the gap
|
|
20
|
+
// between one run and the next.
|
|
21
|
+
export const MTIME_GRACE_MS = 1500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The newest matching snapshot file in `dir`, or null. With `sinceMs`, only a file whose mtime is
|
|
25
|
+
* no older than that moment (minus the grace window) counts. Mutating callers also provide a
|
|
26
|
+
* pre-backup inventory, so an unchanged prior snapshot cannot count within that grace. Callers that
|
|
27
|
+
* legitimately want the newest snapshot ever (e.g. a restore path) pass no `sinceMs`.
|
|
28
|
+
*/
|
|
29
|
+
export function snapshotInventory(dir) {
|
|
30
|
+
let root;
|
|
31
|
+
try { root = fs.lstatSync(dir); }
|
|
32
|
+
catch (error) { if (error.code === 'ENOENT') return new Map(); throw error; }
|
|
33
|
+
if (!root.isDirectory() || root.isSymbolicLink()) throw new Error('Backup destination is not a regular directory');
|
|
34
|
+
const inventory = new Map();
|
|
35
|
+
for (const name of fs.readdirSync(dir)) {
|
|
36
|
+
if (!/\.(db|sqlite|bak)$/i.test(name) && !/memory.*\d/.test(name)) continue;
|
|
37
|
+
const file = path.join(dir, name);
|
|
38
|
+
try {
|
|
39
|
+
const stat = fs.lstatSync(file);
|
|
40
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !stat.size) continue;
|
|
41
|
+
inventory.set(file, {
|
|
42
|
+
path: file, mtimeMs: stat.mtimeMs,
|
|
43
|
+
identity: [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(':'),
|
|
44
|
+
});
|
|
45
|
+
} catch { /* a disappearing candidate is not a located backup */ }
|
|
46
|
+
}
|
|
47
|
+
return inventory;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function newestSnapshot(dir, sinceMs = 0, graceMs = MTIME_GRACE_MS, before = null) {
|
|
51
|
+
try {
|
|
52
|
+
const files = [...snapshotInventory(dir).values()]
|
|
53
|
+
.filter(file => file.mtimeMs >= sinceMs - graceMs
|
|
54
|
+
&& (!before || before.get(file.path)?.identity !== file.identity))
|
|
55
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
56
|
+
return files[0]?.path ?? null;
|
|
57
|
+
} catch { return null; }
|
|
58
|
+
}
|
package/scripts/stack-sync.mjs
CHANGED
|
@@ -186,13 +186,15 @@ export function listInstalledPlugins(pluginsDir = PLUGINS_DIR) {
|
|
|
186
186
|
// not equality" discipline as the rest of this file. Fall back to the manifest's own version field
|
|
187
187
|
// if the on-disk plugin.json is unreadable, so a present plugin is NEVER reported "not installed".
|
|
188
188
|
let installed = null;
|
|
189
|
+
const instances = [];
|
|
189
190
|
for (const rec of Array.isArray(records) ? records : []) {
|
|
190
191
|
const v = (rec && rec.installPath ? pluginVersion(rec.installPath) : null)
|
|
191
192
|
|| (rec && rec.version && rec.version !== 'unknown' ? rec.version : null);
|
|
193
|
+
instances.push({ scope: rec?.scope || 'unknown', installPath: rec?.installPath || null, version: v, readable: Boolean(rec?.installPath && pluginVersion(rec.installPath)) });
|
|
192
194
|
if (!v) continue;
|
|
193
195
|
if (installed === null || cmpVersion(v, installed) > 0) installed = v;
|
|
194
196
|
}
|
|
195
|
-
out.push({ name, installed, source: 'plugin', marketplace });
|
|
197
|
+
out.push({ name, installed, source: 'plugin', marketplace, instances });
|
|
196
198
|
}
|
|
197
199
|
return out;
|
|
198
200
|
}
|
|
@@ -209,15 +211,8 @@ function listInstalled({ lib = GLOBAL_LIB, pluginsDir = PLUGINS_DIR } = {}) {
|
|
|
209
211
|
}
|
|
210
212
|
};
|
|
211
213
|
scan(lib);
|
|
212
|
-
//
|
|
213
|
-
|
|
214
|
-
// npm dist-tags. A plugin-only tool is added, so it can never be reported "not installed".
|
|
215
|
-
const seen = new Set(out.map((r) => r.name));
|
|
216
|
-
for (const p of listInstalledPlugins(pluginsDir)) {
|
|
217
|
-
if (seen.has(p.name)) continue;
|
|
218
|
-
seen.add(p.name);
|
|
219
|
-
out.push(p);
|
|
220
|
-
}
|
|
214
|
+
// Preserve both distribution sources; one must not hide the other.
|
|
215
|
+
out.push(...listInstalledPlugins(pluginsDir));
|
|
221
216
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
222
217
|
}
|
|
223
218
|
|
|
@@ -258,14 +253,9 @@ export function findShadows(npxCache = NPX_CACHE, lib = GLOBAL_LIB) {
|
|
|
258
253
|
// exits) share ONE classification, never two that can drift.
|
|
259
254
|
export function classify(pkgs) {
|
|
260
255
|
return pkgs.map((p) => {
|
|
261
|
-
//
|
|
262
|
-
// is no npm dist-tag oracle for it (querying `npm view <plugin>` would compare against an unrelated
|
|
263
|
-
// package or 404), so we do not manufacture a drift signal. It is present and installed ⇒ CURRENT;
|
|
264
|
-
// a plugin is only BROKEN if we could not read any version at all. Either way it is COUNTED and is
|
|
265
|
-
// never reported "not installed". This also keeps the npm-registry "blind tool" guard scoped to
|
|
266
|
-
// real npm rows (see audit()/auditModel()).
|
|
256
|
+
// Installed records prove presence, not marketplace currency.
|
|
267
257
|
if (p.source === 'plugin') {
|
|
268
|
-
return { ...p, tag: 'plugin', target:
|
|
258
|
+
return { ...p, tag: 'plugin', target: null, state: p.installed ? 'INSTALLED_UNVERIFIED' : 'BROKEN', evidence: 'local install record only; marketplace revision not checked' };
|
|
269
259
|
}
|
|
270
260
|
const want = TAG_POLICY[p.name] || DEFAULT_TAG;
|
|
271
261
|
const tags = registryTags(p.name);
|
|
@@ -293,9 +283,7 @@ function audit() {
|
|
|
293
283
|
// inside the fix for it. Silence is not health.
|
|
294
284
|
const rows = classify(pkgs);
|
|
295
285
|
const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
|
|
296
|
-
//
|
|
297
|
-
// here would let a fully-unreachable registry hide behind a couple of installed plugins — the exact
|
|
298
|
-
// "blind tool reports health" bug this guard exists to kill, reintroduced. Denominator = npm rows.
|
|
286
|
+
// Registry reachability is measured only against npm rows; plugins remain unverified.
|
|
299
287
|
const npmRows = rows.filter((r) => r.source !== 'plugin');
|
|
300
288
|
if (npmRows.length && unresolved.length === npmRows.length) {
|
|
301
289
|
die(`could not reach the npm registry for ANY of ${npmRows.length} npm packages.\n` +
|
|
@@ -303,7 +291,7 @@ function audit() {
|
|
|
303
291
|
}
|
|
304
292
|
|
|
305
293
|
const shadows = findShadows();
|
|
306
|
-
return { rows, unresolved, shadows, stale: shadows.filter((s) => s.global && s.version
|
|
294
|
+
return { rows, unresolved, shadows, stale: shadows.filter((s) => s.global && isBehind(s.version, s.global)) };
|
|
307
295
|
}
|
|
308
296
|
|
|
309
297
|
// Non-exiting audit for embedders (the Onboarding Console). Same measurement as audit(), but returns
|
|
@@ -316,32 +304,44 @@ export function auditModel() {
|
|
|
316
304
|
const rows = classify(pkgs);
|
|
317
305
|
const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
|
|
318
306
|
const shadows = findShadows();
|
|
319
|
-
const stale = shadows.filter((s) => s.global && s.version
|
|
307
|
+
const stale = shadows.filter((s) => s.global && isBehind(s.version, s.global));
|
|
320
308
|
// Same npm-scoped guard as audit() (ISSUE #22): plugin rows never count toward "registry unreachable".
|
|
321
309
|
const npmRows = rows.filter((r) => r.source !== 'plugin');
|
|
322
310
|
const error = npmRows.length && unresolved.length === npmRows.length
|
|
323
311
|
? `could not reach the npm registry for any of ${npmRows.length} npm packages` : null;
|
|
324
|
-
return { rows, unresolved, shadows, stale, error };
|
|
312
|
+
return { rows, unresolved, shadows, stale, error, summary: summarizeAudit({ rows, shadows }) };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function summarizeAudit({ rows, shadows }) {
|
|
316
|
+
const count = (state) => rows.filter((row) => row.state === state).length;
|
|
317
|
+
const summary = { behind: count('BEHIND'), broken: count('BROKEN'),
|
|
318
|
+
unverified: count('INSTALLED_UNVERIFIED') + count('UNRESOLVED'), shadows: shadows.length,
|
|
319
|
+
stale: shadows.filter((s) => s.global && isBehind(s.version, s.global)).length };
|
|
320
|
+
summary.verdict = summary.behind || summary.broken || summary.shadows ? 'DRIFT'
|
|
321
|
+
: summary.unverified || !rows.length ? 'UNKNOWN' : 'PASS';
|
|
322
|
+
summary.exitCode = summary.verdict === 'PASS' ? 0 : summary.verdict === 'UNKNOWN' ? 4 : 1;
|
|
323
|
+
return summary;
|
|
325
324
|
}
|
|
326
325
|
|
|
327
326
|
function report({ rows, shadows, stale }) {
|
|
328
|
-
const w = Math.max(...rows.map((r) => r.name.length));
|
|
327
|
+
const w = Math.max(1, ...rows.map((r) => r.name.length));
|
|
329
328
|
const nPlugin = rows.filter((r) => r.source === 'plugin').length;
|
|
330
329
|
const nNpm = rows.length - nPlugin;
|
|
331
|
-
log(`\n RuvNet stack — ${rows.length}
|
|
330
|
+
log(`\n RuvNet stack — ${rows.length} distribution records (${nNpm} npm-global, ${nPlugin} Claude Code plugin)\n`);
|
|
332
331
|
for (const r of rows) {
|
|
333
|
-
const mark = { CURRENT: ' ok ', BEHIND: 'BEHIND', AHEAD: ' ahead', BROKEN: 'BROKEN', UNRESOLVED: ' ?? ' }[r.state];
|
|
332
|
+
const mark = { CURRENT: ' ok ', BEHIND: 'BEHIND', AHEAD: ' ahead', BROKEN: 'BROKEN', UNRESOLVED: ' ?? ', INSTALLED_UNVERIFIED: 'UNVERIFIED' }[r.state];
|
|
334
333
|
const detail = r.state === 'BEHIND' ? `${r.installed} -> ${r.target} (@${r.tag})`
|
|
335
334
|
: r.state === 'AHEAD' ? `${r.installed} (ahead of @${r.tag} ${r.target} — alpha track; left alone)`
|
|
336
335
|
: r.state === 'BROKEN' ? `no readable version on disk; registry has ${r.target ?? '?'}`
|
|
337
|
-
: r.source === 'plugin' ? `${r.installed} (plugin · ${r.marketplace} marketplace)`
|
|
336
|
+
: r.source === 'plugin' ? `${r.installed} (plugin · ${r.marketplace}; marketplace currency NOT CHECKED; ${r.instances?.length || 1} scope record(s))`
|
|
338
337
|
: r.installed;
|
|
339
338
|
log(` [${mark}] ${r.name.padEnd(w)} ${detail}`);
|
|
339
|
+
if (r.instances?.length > 1) for (const instance of r.instances) log(` ${instance.scope}: ${instance.version || 'UNREADABLE'} (${instance.readable ? 'disk' : 'record only'})`);
|
|
340
340
|
}
|
|
341
341
|
if (shadows.length) {
|
|
342
342
|
log(`\n npx shadow copies:`);
|
|
343
343
|
for (const s of shadows) {
|
|
344
|
-
log(` ${s.name}@${s.version}${s.global && s.version
|
|
344
|
+
log(` ${s.name}@${s.version}${s.global && isBehind(s.version, s.global) ? ` STALE — global is ${s.global}` : ''}`);
|
|
345
345
|
}
|
|
346
346
|
}
|
|
347
347
|
log('');
|
|
@@ -391,8 +391,9 @@ function sync({ dryRun = false } = {}) {
|
|
|
391
391
|
const toInstall = [...behind, ...broken.filter((r) => r.target)].map((r) => `${r.name}@${r.target}`);
|
|
392
392
|
|
|
393
393
|
if (!toInstall.length && !stale.length) {
|
|
394
|
-
log(
|
|
395
|
-
writeReceipt(a, [], []);
|
|
394
|
+
log(` No npm repair selected. Audit: ${JSON.stringify(summarizeAudit(a))}`);
|
|
395
|
+
if (!dryRun) writeReceipt(a, [], []);
|
|
396
|
+
process.exitCode = summarizeAudit(a).exitCode;
|
|
396
397
|
return;
|
|
397
398
|
}
|
|
398
399
|
if (dryRun) {
|
|
@@ -431,7 +432,10 @@ function sync({ dryRun = false } = {}) {
|
|
|
431
432
|
}
|
|
432
433
|
if (wrong.length) die(`npm reported success but THE DISK DISAGREES:\n - ${wrong.join('\n - ')}`);
|
|
433
434
|
|
|
434
|
-
|
|
435
|
+
const after = audit();
|
|
436
|
+
writeReceipt(after, toInstall, purged);
|
|
437
|
+
process.exitCode = summarizeAudit(after).exitCode;
|
|
438
|
+
log(` Remaining audit: ${JSON.stringify(summarizeAudit(after))}`);
|
|
435
439
|
log(`\n synced ${toInstall.length} package(s); purged ${purged.length} shadow(s); verified against disk.`);
|
|
436
440
|
}
|
|
437
441
|
|
|
@@ -440,18 +444,12 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('stack-sync.mjs'))
|
|
|
440
444
|
const args = process.argv.slice(2);
|
|
441
445
|
if (args.includes('--audit')) {
|
|
442
446
|
const a = audit();
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
log(` DRIFT: ${behind.length} behind, ${broken.length} broken, ${stale.length} stale shadow(s)` +
|
|
450
|
-
(unres ? `, ${unres} UNMEASURED (registry unreachable)` : '') + '.');
|
|
451
|
-
log(` Fix: node scripts/stack-sync.mjs --sync\n`);
|
|
452
|
-
process.exit(1); // non-zero: a watchdog must never call a drifted stack "green"
|
|
453
|
-
}
|
|
454
|
-
log(' one copy of everything, all current, no shadows.\n');
|
|
447
|
+
report(a);
|
|
448
|
+
const summary = summarizeAudit(a);
|
|
449
|
+
log(` Audit: ${JSON.stringify(summary)}`);
|
|
450
|
+
if (summary.unverified) log(' Required: verify each plugin against its marketplace revision and each unresolved npm target against registry tags.');
|
|
451
|
+
if (summary.shadows) log(' Cached copies are present; inspect invocation paths before removing any cache. No copies removed by audit.');
|
|
452
|
+
process.exitCode = summary.exitCode;
|
|
455
453
|
} else if (args.includes('--sync')) {
|
|
456
454
|
sync({ dryRun: args.includes('--dry-run') });
|
|
457
455
|
} else {
|
|
@@ -4,8 +4,48 @@ import fs from 'node:fs';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { runHostMatrixAsync } from './host-install-matrix.mjs';
|
|
7
|
+
import { validatePlanAgainstCoverage } from './retrieval-canary.mjs';
|
|
8
|
+
import { extractZip } from '../kb/zip-extract.mjs';
|
|
9
|
+
import { tarExtractionInvocation } from './publication-receipt.mjs';
|
|
7
10
|
|
|
8
11
|
const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
12
|
+
|
|
13
|
+
export function readCandidateRetrieval({ manifest, planFile, coverageFile }) {
|
|
14
|
+
if (!planFile || !coverageFile) throw new Error('candidate retrieval requires sealed plan and coverage files');
|
|
15
|
+
const plan = JSON.parse(fs.readFileSync(planFile));
|
|
16
|
+
const coverageBytes = fs.readFileSync(coverageFile);
|
|
17
|
+
const coverage = JSON.parse(coverageBytes);
|
|
18
|
+
// An observed baseline defines the delta only; it never supplies candidate PASS evidence.
|
|
19
|
+
validatePlanAgainstCoverage(plan, coverage, { allowObservedBaseline: true });
|
|
20
|
+
const npm = manifest.members?.filter(({ role }) => role === 'npm') || [];
|
|
21
|
+
const bundle = manifest.members?.filter(({ role }) => role === 'bundle') || [];
|
|
22
|
+
const coverageSha256 = crypto.createHash('sha256').update(coverageBytes).digest('hex');
|
|
23
|
+
if (npm.length !== 1 || bundle.length !== 1
|
|
24
|
+
|| plan.candidate.sourceSha !== manifest.candidateSha
|
|
25
|
+
|| plan.candidate.packageSha256 !== npm[0].sha256 || plan.candidate.archiveSha256 !== bundle[0].sha256
|
|
26
|
+
|| plan.coverage.sha256 !== coverageSha256 || plan.candidate.coverageSha256 !== coverageSha256
|
|
27
|
+
|| plan.coverage.bytes !== coverageBytes.length
|
|
28
|
+
|| coverage.releaseIdentity?.sourceSnapshot !== manifest.candidateSha
|
|
29
|
+
|| coverage.releaseIdentity?.version !== manifest.version || coverage.releaseIdentity?.tag !== manifest.tag
|
|
30
|
+
|| plan.candidate.publicLedgerSha256 !== coverage.generationLedger?.sha256
|
|
31
|
+
|| plan.candidate.publicLedgerBytes !== coverage.generationLedger?.bytes
|
|
32
|
+
|| plan.candidate.publicStoreCount !== coverage.generationLedger?.storeCount
|
|
33
|
+
|| plan.candidate.publicInventoryPartitionSha256 !== coverage.publicInventoryPartitionSha256) {
|
|
34
|
+
throw new Error('candidate retrieval plan, coverage or artifact identity mismatch');
|
|
35
|
+
}
|
|
36
|
+
return { plan, sourceSha: manifest.candidateSha, artifactSha256: npm[0].sha256,
|
|
37
|
+
candidateArchiveSha256: bundle[0].sha256, packageBytes: npm[0].size, bundleBytes: bundle[0].size };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function verifyCandidateRetrievalAssets({ retrieval, assets }) {
|
|
41
|
+
for (const [file, digest, bytes] of [[assets.packagePath, retrieval.artifactSha256, retrieval.packageBytes],
|
|
42
|
+
[assets.bundlePath, retrieval.candidateArchiveSha256, retrieval.bundleBytes]]) {
|
|
43
|
+
if (!Number.isSafeInteger(bytes) || bytes < 1 || fs.statSync(file).size !== bytes || sha256(file) !== digest) {
|
|
44
|
+
throw new Error(`candidate artifact does not match sealed retrieval input: ${file}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
9
49
|
const locate = (name) => {
|
|
10
50
|
try { return execFileSync('which', [name], { encoding: 'utf8' }).trim(); } catch { return null; }
|
|
11
51
|
};
|
|
@@ -23,25 +63,32 @@ const run = (name, args, options) => {
|
|
|
23
63
|
// tests/unit/staged-host-verifier.test.mjs pins it, but it is now an alias, not a second copy.
|
|
24
64
|
export { classifyDoctor as classifyDoctorResult } from './host-install-matrix.mjs';
|
|
25
65
|
|
|
26
|
-
const preparePackage = ({ packagePath, bundlePath }) => {
|
|
66
|
+
const preparePackage = async ({ packagePath, bundlePath }) => {
|
|
27
67
|
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-staged-host-'));
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
fs.
|
|
68
|
+
try {
|
|
69
|
+
const extraction = tarExtractionInvocation(packagePath, temp);
|
|
70
|
+
run('tar', extraction.args, { cwd: extraction.cwd });
|
|
71
|
+
const packageRoot = path.join(temp, 'package');
|
|
72
|
+
const bundleRoot = path.join(packageRoot, 'dist', 'ruvnet-brain');
|
|
73
|
+
fs.mkdirSync(bundleRoot, { recursive: true });
|
|
74
|
+
await extractZip(bundlePath, bundleRoot);
|
|
75
|
+
const nested = path.join(bundleRoot, 'ruvnet-brain');
|
|
76
|
+
if (fs.existsSync(nested)) {
|
|
77
|
+
for (const name of fs.readdirSync(nested)) fs.renameSync(path.join(nested, name), path.join(bundleRoot, name));
|
|
78
|
+
fs.rmdirSync(nested);
|
|
79
|
+
}
|
|
80
|
+
return { temp, packageRoot };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
83
|
+
throw error;
|
|
37
84
|
}
|
|
38
|
-
return { temp, packageRoot };
|
|
39
85
|
};
|
|
40
86
|
|
|
41
|
-
export function stagedHostVerifier({ assets, identity }) {
|
|
87
|
+
export function stagedHostVerifier({ assets, identity, retrieval }, { runMatrix = runHostMatrixAsync } = {}) {
|
|
42
88
|
return {
|
|
43
89
|
async verify({ source, assets: observedAssets = assets }) {
|
|
44
|
-
|
|
90
|
+
if (retrieval) verifyCandidateRetrievalAssets({ retrieval, assets: observedAssets });
|
|
91
|
+
const prepared = await preparePackage(observedAssets);
|
|
45
92
|
try {
|
|
46
93
|
// The loop, the mode names, the env and the doctor verdict all live in
|
|
47
94
|
// scripts/host-install-matrix.mjs, shared with the published-side check in
|
|
@@ -50,13 +97,15 @@ export function stagedHostVerifier({ assets, identity }) {
|
|
|
50
97
|
// install shape than the one that runs after publication — so the two halves of a release
|
|
51
98
|
// were judging different things and could not be compared. Only the STAGED-vs-PUBLISHED
|
|
52
99
|
// difference is real, and it is now a named variant rather than a second implementation.
|
|
53
|
-
const matrix = await
|
|
100
|
+
const matrix = await runMatrix({
|
|
54
101
|
packageRoot: prepared.packageRoot,
|
|
55
102
|
version: identity.version,
|
|
56
103
|
variant: 'staged',
|
|
57
104
|
locate,
|
|
58
105
|
temp: prepared.temp,
|
|
106
|
+
retrieval,
|
|
59
107
|
});
|
|
108
|
+
if (retrieval) verifyCandidateRetrievalAssets({ retrieval, assets: observedAssets });
|
|
60
109
|
if (matrix.verdict !== 'PASS') {
|
|
61
110
|
return { verdict: 'FAIL', source, error: matrix.error, fixtures: matrix.fixtures };
|
|
62
111
|
}
|