ruvnet-brain 4.2.2-dev → 4.3.1-dev
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 +15 -14
- package/bin/install.mjs +11 -2
- package/console/architecture.html +12 -14
- package/console/install-mockup.html +3 -3
- package/kb/zip-extract.mjs +8 -2
- package/package.json +7 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +1 -1
- package/plugin/hooks/hooks.json +10 -0
- package/plugin/host-adapters/claude.json +11 -0
- package/plugin/host-adapters/codex.json +11 -0
- package/plugin/mcp/managed-cli-interface.mjs +77 -10
- package/plugin/mcp/server.mjs +2 -1
- package/plugin/scripts/anticipate.sh +30 -1
- package/plugin/scripts/capability-claim-evidence.mjs +431 -0
- package/plugin/scripts/capability-inventory-receipt.mjs +225 -0
- package/plugin/scripts/capability-registry.mjs +6 -0
- package/plugin/scripts/capability-routing.mjs +70 -0
- package/plugin/scripts/continuation-gate.mjs +68 -2
- package/plugin/scripts/coverage-integrity.mjs +575 -0
- package/plugin/scripts/ground-ruvnet.sh +2 -2
- package/plugin/scripts/nightly-controller.mjs +19 -2
- package/plugin/scripts/project-progression-contract.mjs +409 -0
- package/plugin/scripts/project-progression-hook.mjs +196 -0
- package/plugin/scripts/project-progression-outbox.mjs +95 -0
- package/plugin/scripts/project-progression-session-start.mjs +183 -0
- package/plugin/scripts/project-progression-store.mjs +248 -0
- package/plugin/scripts/project-store-resolver.mjs +106 -0
- package/plugin/scripts/session-snapshot-hook.mjs +29 -1
- package/plugin/scripts/session-start-core.mjs +22 -18
- package/plugin/scripts/spend-guard.mjs +6 -1
- package/scripts/adr-072-completion.mjs +98 -0
- package/scripts/behavioral-l1-l4.mjs +11 -8
- package/scripts/brain-score.mjs +7 -2
- package/scripts/build-bundle.mjs +82 -6
- package/scripts/card-from-source.mjs +32 -6
- package/scripts/corpus-aggregates.mjs +194 -0
- package/scripts/corpus-candidate.mjs +56 -36
- package/scripts/corpus-reconcile.mjs +295 -28
- package/scripts/corpus-seed-publish.mjs +3 -3
- package/scripts/coverage-integrity.mjs +3 -0
- package/scripts/gist-receipts.mjs +183 -0
- package/scripts/git-hooks/pre-push +21 -0
- package/scripts/host-registry.mjs +118 -0
- package/scripts/independent-review-receipt.mjs +499 -0
- package/scripts/ingest-gists.mjs +34 -5
- package/scripts/ingest-new-repos.mjs +5 -0
- package/scripts/metaharness-gate.mjs +58 -0
- package/scripts/nightly-gists.sh +3 -3
- package/scripts/nightly-wrapper.sh +75 -69
- package/scripts/product-integrity-contract.mjs +156 -0
- package/scripts/public-inventory.mjs +4 -0
- package/scripts/public-verification-aggregate.mjs +342 -0
- package/scripts/public-verification-finalizer.mjs +59 -0
- package/scripts/public-verification-inputs.mjs +473 -0
- package/scripts/public-verification-lane.mjs +244 -0
- package/scripts/publication-receipt.mjs +50 -6
- package/scripts/rebuild-gists-from-receipts.mjs +50 -5
- package/scripts/release-projection.mjs +86 -0
- package/scripts/release-transaction-provider.mjs +43 -102
- package/scripts/release-transaction.mjs +139 -51
- package/scripts/release.mjs +15 -7
- package/scripts/restore-local-ingests.mjs +2 -2
- package/scripts/retrieval-canary.mjs +533 -0
- package/scripts/routing-flywheel.mjs +12 -1
- package/scripts/rvf-generation.mjs +9 -3
- package/scripts/self-update.mjs +19 -44
- package/scripts/source-coverage.mjs +415 -0
- package/scripts/source-scope-receipt.mjs +84 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/wired-check.mjs +114 -24
- package/scripts/worktree-integrity.mjs +123 -0
|
@@ -3,8 +3,9 @@ import crypto from 'node:crypto';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
+
import { canonicalJson } from './coverage-integrity.mjs';
|
|
6
7
|
import {
|
|
7
|
-
pollObservation, RECEIPT_PREFIX,
|
|
8
|
+
pollObservation, RECEIPT_PREFIX, receiptDisposition, transactionIdFor,
|
|
8
9
|
} from './release-transaction.mjs';
|
|
9
10
|
|
|
10
11
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
@@ -94,9 +95,7 @@ export function selectCurrentReleaseBytes({ remoteBytes, localBytes, generatedBy
|
|
|
94
95
|
return Buffer.from(bytes);
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
export function liveReleaseProvider({
|
|
98
|
-
root = process.cwd(), candidateReceipt = null, publicationReceipt = null,
|
|
99
|
-
} = {}) {
|
|
98
|
+
export function liveReleaseProvider({ root = process.cwd() } = {}) {
|
|
100
99
|
let releases = [];
|
|
101
100
|
let activeDraft = null;
|
|
102
101
|
const assetDigestCache = new Map();
|
|
@@ -215,7 +214,8 @@ export function liveReleaseProvider({
|
|
|
215
214
|
const legacySettled = [];
|
|
216
215
|
const pending = [];
|
|
217
216
|
for (const { receipt, release } of latestByTransaction.values()) {
|
|
218
|
-
|
|
217
|
+
const disposition = receiptDisposition(receipt);
|
|
218
|
+
if (['verified', 'closed-unsuccessful', 'legacy-closed'].includes(disposition)) continue;
|
|
219
219
|
// CONVERGENCE IS A FACT ABOUT THE CHANNELS, NOT ABOUT THE RECEIPT FORMAT (fixed 2026-08-07).
|
|
220
220
|
//
|
|
221
221
|
// This required `receipt.schemaVersion === 1`. Receipts are written as schemaVersion 2
|
|
@@ -230,7 +230,7 @@ export function liveReleaseProvider({
|
|
|
230
230
|
// all three hold, the transaction reached its goal whatever schema its receipts use. The
|
|
231
231
|
// check is not weakened — the schema clause was never load-bearing for that question, it
|
|
232
232
|
// just silently expired when the schema moved on.
|
|
233
|
-
const settled = release.draft === false
|
|
233
|
+
const settled = receipt.schemaVersion <= 2 && release.draft === false
|
|
234
234
|
&& receipt.identity?.version === npmLatest && receipt.identity?.tag === githubLatest;
|
|
235
235
|
if (settled) legacySettled.push(receipt.transactionId);
|
|
236
236
|
else pending.push(receipt);
|
|
@@ -318,6 +318,43 @@ export function liveReleaseProvider({
|
|
|
318
318
|
}
|
|
319
319
|
},
|
|
320
320
|
|
|
321
|
+
async materializePublicVerificationAggregate({ aggregate }) {
|
|
322
|
+
const anchor = activeDraft;
|
|
323
|
+
if (!anchor) throw new Error('no release anchor for public verification aggregate');
|
|
324
|
+
const release = hydratedRelease(releaseById(anchor.id));
|
|
325
|
+
const aggregateAsset = Buffer.from(canonicalJson(aggregate));
|
|
326
|
+
const signatureAsset = Buffer.from(aggregate.signature, 'base64');
|
|
327
|
+
const expected = {
|
|
328
|
+
'public-verification-aggregate.json': aggregateAsset,
|
|
329
|
+
'public-verification-aggregate.sig': signatureAsset,
|
|
330
|
+
};
|
|
331
|
+
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-public-aggregate-'));
|
|
332
|
+
try {
|
|
333
|
+
for (const [name, bytes] of Object.entries(expected)) {
|
|
334
|
+
const existing = release.assets?.find((asset) => asset.name === name);
|
|
335
|
+
if (existing) {
|
|
336
|
+
if (!assetBytes(existing).equals(bytes)) throw new Error(`public verification asset conflict: ${name}`);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
const file = path.join(temp, name);
|
|
340
|
+
fs.writeFileSync(file, bytes, { flag: 'wx', mode: 0o600 });
|
|
341
|
+
command('gh', ['release', 'upload', anchor.tag, file, '--repo', REPO]);
|
|
342
|
+
}
|
|
343
|
+
const observed = hydratedRelease(releaseById(anchor.id));
|
|
344
|
+
for (const [name, bytes] of Object.entries(expected)) {
|
|
345
|
+
const asset = observed.assets?.find((item) => item.name === name);
|
|
346
|
+
if (!asset || !assetBytes(asset).equals(bytes)) throw new Error(`public verification asset readback differs: ${name}`);
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
aggregateSha256: aggregate.aggregateSha256,
|
|
350
|
+
aggregateAssetSha256: sha256(aggregateAsset),
|
|
351
|
+
signatureAssetSha256: sha256(signatureAsset),
|
|
352
|
+
};
|
|
353
|
+
} finally {
|
|
354
|
+
fs.rmSync(temp, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
|
|
321
358
|
async readReceipt(draft, sequence) {
|
|
322
359
|
const name = `${RECEIPT_PREFIX}${String(sequence).padStart(4, '0')}.json`;
|
|
323
360
|
const release = hydratedRelease(releaseById((draft || activeDraft).id));
|
|
@@ -395,101 +432,5 @@ export function liveReleaseProvider({
|
|
|
395
432
|
if (current !== expected) throw new Error(`refusing compensation: npm latest is ${current}, expected ${expected}`);
|
|
396
433
|
command('npm', ['dist-tag', 'add', `${PACKAGE}@${prior}`, 'latest']);
|
|
397
434
|
},
|
|
398
|
-
async finalize(identity, receipt, hostVerifier) {
|
|
399
|
-
if (!candidateReceipt || !publicationReceipt) {
|
|
400
|
-
throw new Error('final convergence requires candidate and publication receipt paths');
|
|
401
|
-
}
|
|
402
|
-
// THE HOST VERDICT IS MEASURED, NOT DECLARED — and it is measured FIRST.
|
|
403
|
-
//
|
|
404
|
-
// finalize() used to accept the verifier as `_hostVerifier` and ignore it, then build
|
|
405
|
-
// `hosts = { verdict: 'PASS', … }` as a literal. Every release therefore asserted host
|
|
406
|
-
// convergence with no host verified, while tests/helpers/release-transaction-fixture.mjs:153
|
|
407
|
-
// called the seam faithfully — a fault-injection suite certifying wiring the real publisher
|
|
408
|
-
// never ran. Fable 5 and GPT-5.6-Sol independently made this their #1 finding.
|
|
409
|
-
//
|
|
410
|
-
// It runs BEFORE publication and sealing because the order encodes the meaning: you seal a
|
|
411
|
-
// release you have verified, not the reverse. It is also the cheapest way to fail — an
|
|
412
|
-
// unusable artifact stops here instead of after a 20-minute publish.
|
|
413
|
-
if (!hostVerifier || typeof hostVerifier.verify !== 'function') {
|
|
414
|
-
return { verdict: 'FAIL', hostVerifierError: 'no host verifier was supplied to finalize' };
|
|
415
|
-
}
|
|
416
|
-
// No `assets` override: stagedHostVerifier is constructed in release.mjs:238 with the exact
|
|
417
|
-
// staged bytes for this candidate and defaults to them (staged-host-verifier.mjs:57), so
|
|
418
|
-
// passing nothing is what keeps production verifying the artifact actually being shipped.
|
|
419
|
-
const verified = await hostVerifier.verify({ source: 'final', identity });
|
|
420
|
-
if (verified.verdict !== 'PASS') {
|
|
421
|
-
return {
|
|
422
|
-
verdict: 'FAIL',
|
|
423
|
-
hosts: { verdict: verified.verdict, verifier: { error: verified.error, fixtures: verified.fixtures } },
|
|
424
|
-
previousReceiptDigest: receipt.receiptDigest,
|
|
425
|
-
};
|
|
426
|
-
}
|
|
427
|
-
if (!fs.existsSync(path.resolve(root, publicationReceipt))) {
|
|
428
|
-
const publication = spawnSync(process.execPath, [
|
|
429
|
-
'scripts/publication-receipt.mjs', '--candidate', candidateReceipt, '--out', publicationReceipt,
|
|
430
|
-
], { cwd: root, encoding: 'utf8', timeout: 1_200_000 });
|
|
431
|
-
if (publication.error || publication.status !== 0) {
|
|
432
|
-
return { verdict: 'FAIL', publicationError: String(publication.stderr || publication.error?.message) };
|
|
433
|
-
}
|
|
434
|
-
}
|
|
435
|
-
const seal = spawnSync(process.execPath, [
|
|
436
|
-
'scripts/release-proof.mjs', '--candidate', candidateReceipt, '--publication', publicationReceipt,
|
|
437
|
-
], { cwd: root, encoding: 'utf8', timeout: 300_000 });
|
|
438
|
-
if (seal.error || seal.status !== 0) {
|
|
439
|
-
return { verdict: 'FAIL', sealError: String(seal.stderr || seal.error?.message) };
|
|
440
|
-
}
|
|
441
|
-
const publication = JSON.parse(fs.readFileSync(path.resolve(root, publicationReceipt), 'utf8'));
|
|
442
|
-
|
|
443
|
-
// `verified` was measured at the top of finalize, before publication and sealing.
|
|
444
|
-
const hosts = {
|
|
445
|
-
verdict: verified.verdict,
|
|
446
|
-
claudeOnly: publication.installed?.claudeOnly,
|
|
447
|
-
codexOnly: publication.installed?.codexOnly,
|
|
448
|
-
dual: publication.installed?.dual,
|
|
449
|
-
verifier: { artifactSha256: verified.artifactSha256, fixtures: verified.fixtures, error: verified.error },
|
|
450
|
-
};
|
|
451
|
-
const observed = publication.postPublicationChecks?.find(({ name }) => name === 'published-surface-probe');
|
|
452
|
-
const result = {
|
|
453
|
-
verdict: observed?.status === 'completed' && observed?.conclusion === 'success' ? 'PASS' : 'FAIL',
|
|
454
|
-
hosts, surface: observed, publicationReceipt, previousReceiptDigest: receipt.receiptDigest,
|
|
455
|
-
};
|
|
456
|
-
if (result.verdict === 'PASS') {
|
|
457
|
-
const currentRelease = path.join(path.dirname(path.resolve(root, publicationReceipt)), 'current-release.json');
|
|
458
|
-
const generatedCurrentRelease = Buffer.from(`${JSON.stringify({
|
|
459
|
-
schemaVersion: 1,
|
|
460
|
-
transactionId: receipt.transactionId,
|
|
461
|
-
version: identity.version,
|
|
462
|
-
tag: identity.tag,
|
|
463
|
-
candidateSha: identity.candidateSha,
|
|
464
|
-
verdict: 'PASS',
|
|
465
|
-
publicationReceiptSha256: sha256(fs.readFileSync(path.resolve(root, publicationReceipt))),
|
|
466
|
-
hosts,
|
|
467
|
-
surface: observed,
|
|
468
|
-
}, null, 2)}\n`);
|
|
469
|
-
let remote = hydratedRelease(releaseById(activeDraft.id)).assets
|
|
470
|
-
?.find((asset) => asset.name === 'current-release.json');
|
|
471
|
-
let currentReleaseBytes;
|
|
472
|
-
try {
|
|
473
|
-
currentReleaseBytes = selectCurrentReleaseBytes({
|
|
474
|
-
remoteBytes: remote ? assetBytes(remote) : null,
|
|
475
|
-
localBytes: !remote && fs.existsSync(currentRelease) ? fs.readFileSync(currentRelease) : null,
|
|
476
|
-
generatedBytes: generatedCurrentRelease,
|
|
477
|
-
identity: { transactionId: receipt.transactionId, version: identity.version, candidateSha: identity.candidateSha },
|
|
478
|
-
});
|
|
479
|
-
} catch (error) {
|
|
480
|
-
return { ...result, verdict: 'FAIL', currentReleaseError: error.message };
|
|
481
|
-
}
|
|
482
|
-
if (!fs.existsSync(currentRelease)) fs.writeFileSync(currentRelease, currentReleaseBytes, { flag: 'wx', mode: 0o600 });
|
|
483
|
-
if (!remote) {
|
|
484
|
-
command('gh', ['release', 'upload', activeDraft.tag, currentRelease, '--repo', REPO]);
|
|
485
|
-
remote = hydratedRelease(releaseById(activeDraft.id)).assets
|
|
486
|
-
?.find((asset) => asset.name === 'current-release.json');
|
|
487
|
-
}
|
|
488
|
-
if (!remote || sha256(assetBytes(remote)) !== sha256(currentReleaseBytes)) {
|
|
489
|
-
return { ...result, verdict: 'FAIL', currentReleaseError: 'public receipt verification failed' };
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
return result;
|
|
493
|
-
},
|
|
494
435
|
};
|
|
495
436
|
}
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import crypto from 'node:crypto';
|
|
3
|
+
import { canonicalJson } from './coverage-integrity.mjs';
|
|
4
|
+
import { verifyPublicVerificationAggregate } from './public-verification-aggregate.mjs';
|
|
5
|
+
|
|
6
|
+
export { canonicalJson };
|
|
3
7
|
|
|
4
8
|
export const RECEIPT_PREFIX = 'release-transaction-';
|
|
5
|
-
export const
|
|
9
|
+
export const CURRENT_RECEIPT_SCHEMA_VERSION = 3;
|
|
10
|
+
export const SUCCESS_CLOSED_STATES = new Set(['install-verified']);
|
|
11
|
+
export const UNSUCCESSFUL_CLOSED_STATES = new Set(['aborted', 'abandoned', 'superseded']);
|
|
12
|
+
|
|
13
|
+
export function receiptDisposition(receipt) {
|
|
14
|
+
if (!receipt || !Number.isInteger(receipt.schemaVersion)) return 'invalid';
|
|
15
|
+
if (receipt.schemaVersion <= 2 && receipt.state === 'channels-converged') return 'legacy-closed';
|
|
16
|
+
if (receipt.schemaVersion >= 3 && SUCCESS_CLOSED_STATES.has(receipt.state)) return 'verified';
|
|
17
|
+
if (UNSUCCESSFUL_CLOSED_STATES.has(receipt.state)) return 'closed-unsuccessful';
|
|
18
|
+
if (receipt.schemaVersion >= 3 && receipt.state === 'channels-converged') return 'pending-public-verification';
|
|
19
|
+
return 'pending';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const isClosedReceipt = (receipt) => ['verified', 'closed-unsuccessful', 'legacy-closed']
|
|
23
|
+
.includes(receiptDisposition(receipt));
|
|
6
24
|
|
|
7
25
|
// `aborted` WAS UNREACHABLE, AND THAT BRICKED THE RELEASE RAIL (found 2026-08-07, issue #77).
|
|
8
26
|
//
|
|
9
|
-
// `aborted`
|
|
27
|
+
// `aborted` was historically declared terminal, but no state below listed it as a target — so it
|
|
10
28
|
// was a terminal state nothing could ever enter. Combined with `manual-intervention-required`
|
|
11
29
|
// (which has NO outgoing transitions and is NOT terminal), an interrupted release had exactly two
|
|
12
30
|
// destinations and both were permanent non-terminal dead ends.
|
|
@@ -33,7 +51,7 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
|
|
|
33
51
|
'npm-candidate-staged': ['remote-materialization-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
34
52
|
'remote-materialization-intent': ['prepared', 'manual-intervention-required', ...ABORTABLE],
|
|
35
53
|
prepared: ['github-promote-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
36
|
-
'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required', ...ABORTABLE],
|
|
54
|
+
'github-promote-intent': ['github-promoted-nonlatest', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
37
55
|
'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
38
56
|
'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
39
57
|
// `defaults-promoted` added 2026-08-07: when GitHub is ALREADY latest at the moment npm is
|
|
@@ -51,7 +69,10 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
|
|
|
51
69
|
'defaults-promoted': ['finalize-intent', 'manual-intervention-required', ...ABORTABLE],
|
|
52
70
|
'finalize-intent': ['channels-converged', 'manual-intervention-required', ...ABORTABLE],
|
|
53
71
|
'manual-intervention-required': [],
|
|
54
|
-
'channels-converged': [],
|
|
72
|
+
'channels-converged': ['install-verified', 'abandoned', 'superseded'],
|
|
73
|
+
'install-verified': [],
|
|
74
|
+
abandoned: [],
|
|
75
|
+
superseded: [],
|
|
55
76
|
aborted: [],
|
|
56
77
|
});
|
|
57
78
|
|
|
@@ -79,16 +100,6 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
|
|
|
79
100
|
* here. This does not change the digest of any receipt that never held an undefined value, which is
|
|
80
101
|
* every receipt that currently verifies.
|
|
81
102
|
*/
|
|
82
|
-
export const canonicalJson = (value) => {
|
|
83
|
-
if (Array.isArray(value)) return `[${value.map((item) => (item === undefined ? 'null' : canonicalJson(item))).join(',')}]`;
|
|
84
|
-
if (value && typeof value === 'object') {
|
|
85
|
-
return `{${Object.keys(value).sort()
|
|
86
|
-
.filter((key) => value[key] !== undefined)
|
|
87
|
-
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
|
|
88
|
-
}
|
|
89
|
-
return JSON.stringify(value);
|
|
90
|
-
};
|
|
91
|
-
|
|
92
103
|
export const receiptPayload = (receipt) => {
|
|
93
104
|
const { signature: _signature, receiptDigest: _digest, ...payload } = receipt;
|
|
94
105
|
return canonicalJson(payload);
|
|
@@ -125,7 +136,8 @@ export function signReceipt(receipt, privateKey) {
|
|
|
125
136
|
}
|
|
126
137
|
|
|
127
138
|
export function verifyReceipt(receipt, publicKey) {
|
|
128
|
-
if (!receipt || ![1, 2].includes(receipt.schemaVersion)
|
|
139
|
+
if (!receipt || ![1, 2, CURRENT_RECEIPT_SCHEMA_VERSION].includes(receipt.schemaVersion)
|
|
140
|
+
|| !/^[a-f0-9]{64}$/.test(receipt.transactionId || '')) {
|
|
129
141
|
throw new Error('invalid release transaction receipt');
|
|
130
142
|
}
|
|
131
143
|
const { signature, receiptDigest, ...unsigned } = receipt;
|
|
@@ -149,13 +161,20 @@ export function validateReceiptChain(receipts, identity, publicKey) {
|
|
|
149
161
|
if ((receipt.previousReceiptDigest || null) !== (previous?.receiptDigest || null)) {
|
|
150
162
|
throw new Error('release receipt chain conflict');
|
|
151
163
|
}
|
|
164
|
+
if (previous && receipt.schemaVersion >= 3 && receiptDisposition(previous) === 'legacy-closed') {
|
|
165
|
+
throw new Error('legacy-closed release receipt cannot be promoted into schema 3');
|
|
166
|
+
}
|
|
167
|
+
if (previous && receipt.schemaVersion >= 3
|
|
168
|
+
&& !ALLOWED_TRANSITIONS[previous.state]?.includes(receipt.state)) {
|
|
169
|
+
throw new Error(`illegal release transition ${previous.state} -> ${receipt.state}`);
|
|
170
|
+
}
|
|
152
171
|
previous = receipt;
|
|
153
172
|
}
|
|
154
173
|
return sorted;
|
|
155
174
|
}
|
|
156
175
|
|
|
157
176
|
const stateReceipt = ({ identity, prior, state, observation = {}, privateKey }) => signReceipt({
|
|
158
|
-
schemaVersion:
|
|
177
|
+
schemaVersion: CURRENT_RECEIPT_SCHEMA_VERSION,
|
|
159
178
|
transactionId: transactionIdFor(identity),
|
|
160
179
|
sequence: prior ? prior.sequence + 1 : 0,
|
|
161
180
|
previousReceiptDigest: prior?.receiptDigest || null,
|
|
@@ -203,11 +222,15 @@ export function reduceReleaseState({ lastReceipt, snapshot, identity, prior }) {
|
|
|
203
222
|
}
|
|
204
223
|
if (npmB && snapshot.github?.draft === true) return { action: 'compensate-npm' };
|
|
205
224
|
if (npmB && githubLatest) {
|
|
206
|
-
if (lastReceipt.state === '
|
|
225
|
+
if (lastReceipt.state === 'install-verified') {
|
|
207
226
|
return githubPublished && assetsExact && candidateExact
|
|
208
|
-
&& snapshot.publicReceiptExact && snapshot.publicHostsExact
|
|
209
227
|
? { action: 'complete' }
|
|
210
|
-
: { action: 'manual', reason: '
|
|
228
|
+
: { action: 'manual', reason: 'verified public channels drifted' };
|
|
229
|
+
}
|
|
230
|
+
if (lastReceipt.state === 'channels-converged') {
|
|
231
|
+
return githubPublished && assetsExact && candidateExact
|
|
232
|
+
? { action: 'await-install-verification' }
|
|
233
|
+
: { action: 'manual', reason: 'channel-converged state drifted' };
|
|
211
234
|
}
|
|
212
235
|
return { action: 'finalize' };
|
|
213
236
|
}
|
|
@@ -257,7 +280,7 @@ export async function pollObservation(read, predicate, {
|
|
|
257
280
|
throw new Error(`visibility deadline exceeded after ${now() - started}ms: ${detail}`);
|
|
258
281
|
}
|
|
259
282
|
|
|
260
|
-
export async function runReleaseTransaction({ identity, assets, adapter, privateKey, publicKey
|
|
283
|
+
export async function runReleaseTransaction({ identity, assets, adapter, privateKey, publicKey }) {
|
|
261
284
|
const expectedId = transactionIdFor(identity);
|
|
262
285
|
const discovered = await adapter.discover(identity);
|
|
263
286
|
const competing = discovered.pending?.filter((item) => item.transactionId !== expectedId) || [];
|
|
@@ -269,7 +292,8 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
|
|
|
269
292
|
const draft = discovered.matchingDrafts?.[0] || await adapter.createDraft(identity);
|
|
270
293
|
const append = async (state, observation = {}) => {
|
|
271
294
|
const recoveryCompensation = state === 'compensation-intent' && current
|
|
272
|
-
&& !
|
|
295
|
+
&& !isClosedReceipt(current) && current.state !== 'manual-intervention-required'
|
|
296
|
+
&& current.state !== 'channels-converged';
|
|
273
297
|
if (current && !ALLOWED_TRANSITIONS[current.state]?.includes(state) && current.state !== state
|
|
274
298
|
&& !recoveryCompensation) {
|
|
275
299
|
throw new Error(`illegal release transition ${current.state} -> ${state}`);
|
|
@@ -298,10 +322,10 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
|
|
|
298
322
|
const snapshot = await adapter.observeSnapshot(identity, draft, {
|
|
299
323
|
forceAssets: current.state === 'finalize-intent' || current.state === 'channels-converged',
|
|
300
324
|
});
|
|
301
|
-
if (current.state === 'channels-converged') {
|
|
302
|
-
const
|
|
303
|
-
if (
|
|
304
|
-
throw new Error(`
|
|
325
|
+
if (current.state === 'channels-converged' || current.state === 'install-verified') {
|
|
326
|
+
const disposition = reduceReleaseState({ lastReceipt: current, snapshot, identity, prior });
|
|
327
|
+
if (['complete', 'await-install-verification'].includes(disposition.action)) return current;
|
|
328
|
+
throw new Error(`release state drift: ${disposition.reason || disposition.action}`);
|
|
305
329
|
}
|
|
306
330
|
// A process may die after the provider side effect and before its observation receipt. Rebuild
|
|
307
331
|
// that missing receipt from fresh state before asking the reducer for the next command.
|
|
@@ -332,11 +356,11 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
|
|
|
332
356
|
await transition('compensated', { npm: snapshot.npm, github: snapshot.github, recovered: true });
|
|
333
357
|
throw new Error('npm compensation recovered; resume the same transaction');
|
|
334
358
|
}
|
|
335
|
-
if (current.state === 'finalize-intent' && snapshot
|
|
336
|
-
&& npmCandidateExact(snapshot, identity) && npmLatestIsB(snapshot, identity)
|
|
359
|
+
if (current.state === 'finalize-intent' && npmCandidateExact(snapshot, identity) && npmLatestIsB(snapshot, identity)
|
|
337
360
|
&& githubIsB(snapshot, identity) && snapshot.github?.published && snapshot.github?.latest
|
|
338
361
|
&& snapshot.github?.assetsExact) {
|
|
339
|
-
return append('channels-converged', { verdict: '
|
|
362
|
+
return append('channels-converged', { verdict: 'PUBLISHED_NOT_VERIFIED', recovered: true,
|
|
363
|
+
npm: snapshot.npm, github: snapshot.github });
|
|
340
364
|
}
|
|
341
365
|
const decision = reduceReleaseState({ lastReceipt: current, snapshot, identity, prior });
|
|
342
366
|
const stageRecord = {
|
|
@@ -351,6 +375,7 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
|
|
|
351
375
|
? `::notice title=Release stage ${decision.action}::${JSON.stringify(stageRecord)}`
|
|
352
376
|
: JSON.stringify(stageRecord));
|
|
353
377
|
if (decision.action === 'complete') return current;
|
|
378
|
+
if (decision.action === 'await-install-verification') return current;
|
|
354
379
|
if (decision.action === 'manual') {
|
|
355
380
|
await transition('manual-intervention-required', { reason: decision.reason, snapshot });
|
|
356
381
|
throw new Error(`release requires manual intervention: ${decision.reason}`);
|
|
@@ -443,32 +468,15 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
|
|
|
443
468
|
await transition('defaults-promoted', { npm: snapshot.npm, github: snapshot.github });
|
|
444
469
|
}
|
|
445
470
|
await transition('finalize-intent');
|
|
446
|
-
const final = await adapter.finalize(identity, current, hostVerifier);
|
|
447
|
-
// SAY WHICH CONVERGENCE FAILED, AND WHY (2026-08-07). This threw the bare sentence
|
|
448
|
-
// `final release convergence failed` while `finalize` had already returned exactly the reason
|
|
449
|
-
// — hostVerifierError, hosts.verifier.error, publicationError or sealError — and the throw
|
|
450
|
-
// discarded every one of them. That is the same defect that let `spawnSync ENOBUFS` masquerade
|
|
451
|
-
// as `staged GitHub payload mismatch` for three days: an error that reports its category and
|
|
452
|
-
// withholds its evidence. Publication had ALREADY succeeded on both channels here, so the
|
|
453
|
-
// operator is reading this line while npm and GitHub are correct, with nothing to act on.
|
|
454
|
-
if (final.verdict !== 'PASS') {
|
|
455
|
-
const why = final.hostVerifierError
|
|
456
|
-
|| final.hosts?.verifier?.error
|
|
457
|
-
|| final.publicationError
|
|
458
|
-
|| final.sealError
|
|
459
|
-
|| `verdict=${final.verdict ?? '(none)'}`;
|
|
460
|
-
const fixtures = final.hosts?.verifier?.fixtures;
|
|
461
|
-
const detail = fixtures ? ` | fixtures: ${JSON.stringify(fixtures).slice(0, 400)}` : '';
|
|
462
|
-
throw new Error(`final release convergence failed: ${why}${detail}`);
|
|
463
|
-
}
|
|
464
471
|
const reobserved = await adapter.observeSnapshot(identity, draft, { forceAssets: true });
|
|
465
472
|
if (!(npmLatestIsB(reobserved, identity) && reobserved.github?.latest && githubIsB(reobserved, identity))) {
|
|
466
|
-
throw new Error('provider defaults drifted
|
|
473
|
+
throw new Error('provider defaults drifted before channel convergence');
|
|
467
474
|
}
|
|
468
|
-
if (!reobserved.github?.assetsExact || !reobserved
|
|
469
|
-
throw new Error('
|
|
475
|
+
if (!reobserved.github?.assetsExact || !npmCandidateExact(reobserved, identity)) {
|
|
476
|
+
throw new Error('provider artifacts drifted before channel convergence');
|
|
470
477
|
}
|
|
471
|
-
return append('channels-converged',
|
|
478
|
+
return append('channels-converged', { verdict: 'PUBLISHED_NOT_VERIFIED',
|
|
479
|
+
npm: reobserved.npm, github: reobserved.github });
|
|
472
480
|
}
|
|
473
481
|
}
|
|
474
482
|
throw new Error('release reducer exceeded its bounded transition count');
|
|
@@ -478,7 +486,9 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
|
|
|
478
486
|
if (!authorized) throw new Error('release abort requires explicit human authorization');
|
|
479
487
|
const chain = validateReceiptChain(receipts, identity, publicKey);
|
|
480
488
|
const current = chain.at(-1);
|
|
481
|
-
if (!current ||
|
|
489
|
+
if (!current || isClosedReceipt(current) || !ALLOWED_TRANSITIONS[current.state]?.includes('aborted')) {
|
|
490
|
+
throw new Error('release transaction is not abortable');
|
|
491
|
+
}
|
|
482
492
|
const snapshot = await adapter.observeSnapshot(identity);
|
|
483
493
|
const prior = chain[0]?.observation?.prior;
|
|
484
494
|
if (!prior || snapshot.npm?.latestVersion === identity.version || snapshot.github?.latest === true
|
|
@@ -492,3 +502,81 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
|
|
|
492
502
|
await adapter.appendReceipt(null, receipt, `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`);
|
|
493
503
|
return receipt;
|
|
494
504
|
}
|
|
505
|
+
|
|
506
|
+
export async function finalizeReleaseTransaction({
|
|
507
|
+
identity,
|
|
508
|
+
aggregate,
|
|
509
|
+
adapter,
|
|
510
|
+
privateKey,
|
|
511
|
+
publicKey,
|
|
512
|
+
aggregatePublicKey,
|
|
513
|
+
} = {}) {
|
|
514
|
+
if (!adapter || typeof adapter.discover !== 'function' || typeof adapter.observeSnapshot !== 'function'
|
|
515
|
+
|| typeof adapter.materializePublicVerificationAggregate !== 'function'
|
|
516
|
+
|| typeof adapter.appendReceipt !== 'function' || typeof adapter.readReceipt !== 'function') {
|
|
517
|
+
throw new Error('public verification finalizer adapter is incomplete');
|
|
518
|
+
}
|
|
519
|
+
const expectedAggregateIdentity = {
|
|
520
|
+
...aggregate?.identity,
|
|
521
|
+
sourceSha: identity?.candidateSha,
|
|
522
|
+
version: identity?.version,
|
|
523
|
+
tag: identity?.tag,
|
|
524
|
+
artifactSha256: identity?.packageSha256,
|
|
525
|
+
bundleSha256: identity?.bundleSha256,
|
|
526
|
+
payloadId: identity?.payloadId,
|
|
527
|
+
releaseTransactionId: transactionIdFor(identity),
|
|
528
|
+
};
|
|
529
|
+
verifyPublicVerificationAggregate(aggregate, aggregatePublicKey, expectedAggregateIdentity);
|
|
530
|
+
const discovered = await adapter.discover(identity);
|
|
531
|
+
const chain = validateReceiptChain(discovered.receipts || [], identity, publicKey);
|
|
532
|
+
const current = chain.at(-1);
|
|
533
|
+
if (!current) throw new Error('release transaction has no receipt chain');
|
|
534
|
+
if (current.state === 'install-verified') {
|
|
535
|
+
if (current.observation?.publicVerification?.aggregateSha256 !== aggregate.aggregateSha256) {
|
|
536
|
+
throw new Error('release is already install-verified by a different aggregate');
|
|
537
|
+
}
|
|
538
|
+
return current;
|
|
539
|
+
}
|
|
540
|
+
if (current.schemaVersion < 3 || current.state !== 'channels-converged') {
|
|
541
|
+
throw new Error(`public verification finalizer requires schema-3 channels-converged, found ${current.state}`);
|
|
542
|
+
}
|
|
543
|
+
const snapshot = await adapter.observeSnapshot(identity, discovered.matchingDrafts?.[0], { forceAssets: true });
|
|
544
|
+
if (snapshot.readError || !npmLatestIsB(snapshot, identity) || !npmCandidateExact(snapshot, identity)
|
|
545
|
+
|| !githubIsB(snapshot, identity) || snapshot.github?.published !== true || snapshot.github?.latest !== true
|
|
546
|
+
|| snapshot.github?.assetsExact !== true) throw new Error('public channels drifted before install verification');
|
|
547
|
+
const materialized = await adapter.materializePublicVerificationAggregate({ identity, aggregate });
|
|
548
|
+
const aggregateAssetSha256 = crypto.createHash('sha256').update(canonicalJson(aggregate)).digest('hex');
|
|
549
|
+
const signatureAssetSha256 = crypto.createHash('sha256').update(Buffer.from(aggregate.signature, 'base64')).digest('hex');
|
|
550
|
+
if (materialized?.aggregateSha256 !== aggregate.aggregateSha256
|
|
551
|
+
|| materialized?.aggregateAssetSha256 !== aggregateAssetSha256
|
|
552
|
+
|| materialized?.signatureAssetSha256 !== signatureAssetSha256) {
|
|
553
|
+
throw new Error('materialized public verification aggregate differs from signed evidence');
|
|
554
|
+
}
|
|
555
|
+
const receipt = stateReceipt({
|
|
556
|
+
identity,
|
|
557
|
+
prior: current,
|
|
558
|
+
state: 'install-verified',
|
|
559
|
+
observation: {
|
|
560
|
+
verdict: 'INSTALL_VERIFIED',
|
|
561
|
+
publicVerification: {
|
|
562
|
+
aggregateSha256: aggregate.aggregateSha256,
|
|
563
|
+
aggregateAssetSha256,
|
|
564
|
+
signatureAssetSha256,
|
|
565
|
+
lanes: aggregate.metrics.leaves,
|
|
566
|
+
recallAt10: aggregate.metrics.recallAt10,
|
|
567
|
+
deltaCitationRate: aggregate.metrics.deltaCitationRate,
|
|
568
|
+
reviewReceipts: aggregate.reviews,
|
|
569
|
+
},
|
|
570
|
+
npm: snapshot.npm,
|
|
571
|
+
github: snapshot.github,
|
|
572
|
+
},
|
|
573
|
+
privateKey,
|
|
574
|
+
});
|
|
575
|
+
const name = `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`;
|
|
576
|
+
await adapter.appendReceipt(discovered.matchingDrafts?.[0] || null, receipt, name);
|
|
577
|
+
const persisted = await adapter.readReceipt(discovered.matchingDrafts?.[0] || null, receipt.sequence);
|
|
578
|
+
verifyReceipt(persisted, publicKey);
|
|
579
|
+
if (canonicalJson(persisted) !== canonicalJson(receipt)) throw new Error('install-verified receipt readback differs');
|
|
580
|
+
validateReceiptChain([...chain, persisted], identity, publicKey);
|
|
581
|
+
return persisted;
|
|
582
|
+
}
|
package/scripts/release.mjs
CHANGED
|
@@ -32,10 +32,10 @@ import crypto from 'node:crypto';
|
|
|
32
32
|
import { validateProtectedPublishInvocation } from './protected-release-invocation.mjs';
|
|
33
33
|
import { runReleaseTransaction } from './release-transaction.mjs';
|
|
34
34
|
import { liveReleaseProvider } from './release-transaction-provider.mjs';
|
|
35
|
-
import { stagedHostVerifier } from './staged-host-verifier.mjs';
|
|
36
35
|
import { verifyPayload } from './release-payload.mjs';
|
|
37
36
|
|
|
38
37
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
38
|
+
const IS_MAIN = path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url);
|
|
39
39
|
const PUBLISH = process.argv.includes('--publish');
|
|
40
40
|
const CORPUS_SEED = process.argv.includes('--corpus-seed');
|
|
41
41
|
let protectedCandidate = null;
|
|
@@ -159,7 +159,9 @@ export function runProtectedCorpusSeed({
|
|
|
159
159
|
if (digestMatch[1] !== archiveSha256) corpusFailure('corpus tag digest does not match the receipt and archive');
|
|
160
160
|
|
|
161
161
|
const viewArgs = ['release', 'view', tag, '--json', 'tagName', '--repo', repo];
|
|
162
|
-
const
|
|
162
|
+
const ghCommand = env.RUVNET_GH_COMMAND || 'gh';
|
|
163
|
+
const ghPrefix = env.RUVNET_GH_SCRIPT ? [env.RUVNET_GH_SCRIPT] : [];
|
|
164
|
+
const view = run(ghCommand, [...ghPrefix, ...viewArgs], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
163
165
|
if (!view.error && view.status === 0) corpusFailure(`release ${tag} already exists; refusing to overwrite immutable corpus seed`);
|
|
164
166
|
const viewError = String(view.error?.message || view.stderr || view.stdout || '');
|
|
165
167
|
if (!/(release not found|no release found)/i.test(viewError)) corpusFailure(`cannot prove ${tag} is absent (${viewError.trim() || `gh exited ${view.status}`})`);
|
|
@@ -181,14 +183,14 @@ export function runProtectedCorpusSeed({
|
|
|
181
183
|
'--notes', notes,
|
|
182
184
|
bundleFile, receiptFile,
|
|
183
185
|
];
|
|
184
|
-
const create = run(
|
|
186
|
+
const create = run(ghCommand, [...ghPrefix, ...createArgs], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
185
187
|
if (create.error || create.status !== 0) {
|
|
186
188
|
corpusFailure(`protected corpus publication failed (${String(create.error?.message || create.stderr || create.stdout || '').trim()})`);
|
|
187
189
|
}
|
|
188
190
|
return { tag, target, repository: repo, archiveSha256, receiptSha256: sha256File(receiptFile) };
|
|
189
191
|
}
|
|
190
192
|
|
|
191
|
-
if (CORPUS_SEED) {
|
|
193
|
+
if (IS_MAIN && CORPUS_SEED) {
|
|
192
194
|
try {
|
|
193
195
|
const result = runProtectedCorpusSeed();
|
|
194
196
|
console.log(JSON.stringify({ ok: true, mode: 'corpus-seed', ...result }, null, 2));
|
|
@@ -196,7 +198,7 @@ if (CORPUS_SEED) {
|
|
|
196
198
|
console.error(error.message);
|
|
197
199
|
process.exitCode = 1;
|
|
198
200
|
}
|
|
199
|
-
} else {
|
|
201
|
+
} else if (IS_MAIN) {
|
|
200
202
|
|
|
201
203
|
console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`);
|
|
202
204
|
|
|
@@ -371,6 +373,9 @@ if (PUBLISH) {
|
|
|
371
373
|
corpusSeedSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.corpusSeedPath)).digest('hex'),
|
|
372
374
|
generationLedgerSha256: crypto.createHash('sha256').update(fs.readFileSync(assets.generationLedgerPath)).digest('hex'),
|
|
373
375
|
};
|
|
376
|
+
const identityPath = path.resolve(ROOT, process.env.RUVNET_RELEASE_IDENTITY || 'release-evidence/release-identity.json');
|
|
377
|
+
if (fs.existsSync(identityPath)) throw new Error(`refusing to overwrite release identity: ${identityPath}`);
|
|
378
|
+
fs.writeFileSync(identityPath, `${JSON.stringify(identity, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
374
379
|
const privatePem = process.env.RUVNET_SIGNING_KEY;
|
|
375
380
|
if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts');
|
|
376
381
|
const finalReceipt = await runReleaseTransaction({
|
|
@@ -381,9 +386,12 @@ if (PUBLISH) {
|
|
|
381
386
|
}),
|
|
382
387
|
privateKey: crypto.createPrivateKey(privatePem),
|
|
383
388
|
publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')),
|
|
384
|
-
hostVerifier: stagedHostVerifier({ assets, identity }),
|
|
385
389
|
});
|
|
386
390
|
if (finalReceipt.state !== 'channels-converged') throw new Error(`release transaction stopped at ${finalReceipt.state}`);
|
|
391
|
+
const channelReceiptPath = path.resolve(ROOT,
|
|
392
|
+
process.env.RUVNET_CHANNEL_RECEIPT || 'release-evidence/channels-converged-receipt.json');
|
|
393
|
+
if (fs.existsSync(channelReceiptPath)) throw new Error(`refusing to overwrite channel receipt: ${channelReceiptPath}`);
|
|
394
|
+
fs.writeFileSync(channelReceiptPath, `${JSON.stringify(finalReceipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
387
395
|
} else {
|
|
388
396
|
step('D', 'remote staged release transaction — SKIPPED (check-only; pass --publish to publish)');
|
|
389
397
|
}
|
|
@@ -396,7 +404,7 @@ if (!PUBLISH) {
|
|
|
396
404
|
}
|
|
397
405
|
|
|
398
406
|
if (PUBLISH) {
|
|
399
|
-
console.log(`\n${c.
|
|
407
|
+
console.log(`\n${c.y(c.b('PUBLISHED, NOT VERIFIED'))} — npm and GitHub expose the sealed candidate; public install verification is still required.\n`);
|
|
400
408
|
} else {
|
|
401
409
|
console.log(`\n${c.g(c.b('✓✓✓ PREFLIGHT PASS — NOT PUBLISHED'))} — the committed candidate passed every check-only gate.\n`);
|
|
402
410
|
}
|
|
@@ -42,7 +42,7 @@ import fs from 'node:fs';
|
|
|
42
42
|
import path from 'node:path';
|
|
43
43
|
import { execFileSync } from 'node:child_process';
|
|
44
44
|
import { fileURLToPath } from 'node:url';
|
|
45
|
-
import { storeRoot, storesAt } from '../kb/store-root.mjs';
|
|
45
|
+
import { storeRoot, storesAt, rootNeverMaterialized } from '../kb/store-root.mjs';
|
|
46
46
|
|
|
47
47
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
48
48
|
const LEDGER = path.join(ROOT, 'kb', 'local-ingests.json');
|
|
@@ -71,7 +71,7 @@ export function missingIngests({ ledgerFile = LEDGER, root = storeRoot() } = {})
|
|
|
71
71
|
export function classify({ ledgerFile = LEDGER, root = storeRoot() } = {}) {
|
|
72
72
|
const missing = missingIngests({ ledgerFile, root });
|
|
73
73
|
if (!missing.length) return { state: OK, missing };
|
|
74
|
-
if (
|
|
74
|
+
if (rootNeverMaterialized(root)) return { state: NEVER_MATERIALIZED, missing };
|
|
75
75
|
return { state: WIPED, missing };
|
|
76
76
|
}
|
|
77
77
|
|