ruvnet-brain 4.3.21 → 4.3.26
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 +5 -5
- package/bin/install.mjs +275 -60
- package/console/app.js +189 -9
- package/console/index.html +70 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/style.css +26 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +364 -28
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +47 -10
- package/scripts/release-transaction.mjs +40 -11
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -36,6 +36,7 @@ import path from 'node:path';
|
|
|
36
36
|
import {
|
|
37
37
|
RECEIPT_PREFIX, isClosedReceipt, ALLOWED_TRANSITIONS, canonicalJson, digestReceipt, signReceipt,
|
|
38
38
|
} from './release-transaction.mjs';
|
|
39
|
+
import { latestCodeReleaseTag } from './release-channel-kind.mjs';
|
|
39
40
|
|
|
40
41
|
const REPO = 'stuinfla/ruvnet-brain';
|
|
41
42
|
const arg = (n) => { const i = process.argv.indexOf(n); return i >= 0 ? process.argv[i + 1] : null; };
|
|
@@ -75,7 +76,10 @@ if (!(ALLOWED_TRANSITIONS[last.state] || []).includes('aborted')) {
|
|
|
75
76
|
|
|
76
77
|
// Never abort something that actually shipped and simply failed to record it.
|
|
77
78
|
const npmLatest = execFileSync('npm', ['view', 'ruvnet-brain@latest', 'version'], { encoding: 'utf8' }).trim();
|
|
78
|
-
|
|
79
|
+
// ADR-086 S1: latest may be a corpus generation, which never equals a code tag. Comparing against
|
|
80
|
+
// it would say "this is not the live generation" about a release that genuinely shipped, and then
|
|
81
|
+
// abort it — the exact mistake this check exists to prevent.
|
|
82
|
+
const ghLatest = latestCodeReleaseTag(JSON.parse(gh(['api', `repos/${REPO}/releases?per_page=30`])));
|
|
79
83
|
if (last.identity?.version === npmLatest && last.identity?.tag === ghLatest) {
|
|
80
84
|
die(`${TAG} IS the currently published generation on both channels — that is a converged release, not an abandoned one`);
|
|
81
85
|
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// move an npm dist-tag. CI and the canonical release path both execute this check.
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
|
-
import { fileURLToPath
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
8
|
|
|
9
9
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
10
10
|
const CANONICAL_PUBLISHERS = new Set([
|
|
@@ -12,13 +12,59 @@ const CANONICAL_PUBLISHERS = new Set([
|
|
|
12
12
|
'scripts/release-transaction-provider.mjs',
|
|
13
13
|
]);
|
|
14
14
|
const SOURCE_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.sh', '.yml', '.yaml']);
|
|
15
|
+
const SCANNED_DIRECTORIES = Object.freeze([
|
|
16
|
+
'scripts', 'deploy', 'bin', '.github/workflows', 'plugin', 'kb', 'console',
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
// Cut a trailing line comment WITHOUT touching a `//` that lives inside a string literal. The old
|
|
20
|
+
// form was `line.replace(/\/\/.*$/, '')`, which cuts at the FIRST `//` on the line with no idea
|
|
21
|
+
// what it is inside: a single line carrying a URL ahead of the call — say
|
|
22
|
+
// const registry = 'https://registry.npmjs.org'; execFileSync('npm', ['publish']);
|
|
23
|
+
// had the publish call deleted before any detector saw it, so the gate reported PASS on a file
|
|
24
|
+
// that plainly publishes. Quote-aware scan, escape-aware, no parser needed.
|
|
25
|
+
function stripLineComment(line) {
|
|
26
|
+
let quote = null;
|
|
27
|
+
for (let index = 0; index < line.length; index += 1) {
|
|
28
|
+
const character = line[index];
|
|
29
|
+
if (quote) {
|
|
30
|
+
if (character === '\\') { index += 1; continue; }
|
|
31
|
+
if (character === quote) quote = null;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (character === "'" || character === '"' || character === '`') { quote = character; continue; }
|
|
35
|
+
if (character === '/' && line[index + 1] === '/') return line.slice(0, index);
|
|
36
|
+
}
|
|
37
|
+
return line;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// A heredoc body is DATA, not executable source — the same reason comments are stripped. Widening
|
|
41
|
+
// this gate to plugin/ immediately proved why it matters: plugin/scripts/ground-ruvnet.sh writes an
|
|
42
|
+
// instruction block containing the words "npm publish" inside `cat <<EOF`, and the gate reported it
|
|
43
|
+
// as an unauthorized publisher. A gate that rejects a compliant file is as broken as one that
|
|
44
|
+
// accepts a violating file; both get fixed, neither gets shipped.
|
|
45
|
+
function stripHeredocBodies(source) {
|
|
46
|
+
const lines = source.split('\n');
|
|
47
|
+
const kept = [];
|
|
48
|
+
let terminator = null;
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
if (terminator !== null) {
|
|
51
|
+
if (line.trim() === terminator) terminator = null;
|
|
52
|
+
kept.push('');
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
const opener = /<<-?\s*(?:'([A-Za-z_][A-Za-z0-9_]*)'|"([A-Za-z_][A-Za-z0-9_]*)"|([A-Za-z_][A-Za-z0-9_]*))/.exec(line);
|
|
56
|
+
kept.push(line);
|
|
57
|
+
if (opener) terminator = opener[1] || opener[2] || opener[3];
|
|
58
|
+
}
|
|
59
|
+
return kept.join('\n');
|
|
60
|
+
}
|
|
15
61
|
|
|
16
62
|
function executableSource(source) {
|
|
17
63
|
const withoutBlocks = source.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
18
|
-
return withoutBlocks
|
|
64
|
+
return stripHeredocBodies(withoutBlocks)
|
|
19
65
|
.split('\n')
|
|
20
66
|
.filter((line) => !line.trimStart().startsWith('#'))
|
|
21
|
-
.map(
|
|
67
|
+
.map(stripLineComment)
|
|
22
68
|
.join('\n');
|
|
23
69
|
}
|
|
24
70
|
|
|
@@ -79,13 +125,47 @@ function sourceFiles(root) {
|
|
|
79
125
|
else if (entry.isFile() && SOURCE_EXTENSIONS.has(path.extname(entry.name))) files.push(child);
|
|
80
126
|
}
|
|
81
127
|
};
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
128
|
+
// Every directory that ships executable code, not just the four this gate started with. `plugin/`
|
|
129
|
+
// and `kb/` were invisible here while both contain real, shipped, executable files — kb/ holds one
|
|
130
|
+
// of the three trust-root copies checked below, and plugin/skills/*/scripts/ holds genuine release
|
|
131
|
+
// machinery (plugin/skills/release-proof/scripts/release-proof.mjs). A one-publisher gate that
|
|
132
|
+
// cannot see half the publishers is a gate in name only.
|
|
133
|
+
for (const directory of SCANNED_DIRECTORIES) visit(path.join(root, ...directory.split('/')));
|
|
86
134
|
return files;
|
|
87
135
|
}
|
|
88
136
|
|
|
137
|
+
// The Ed25519 trust root is EMBEDDED in the shipped executables deliberately (SEC-0010 #6): it has
|
|
138
|
+
// to travel with the code, or an attacker who swaps the downloaded bundle could swap the key it is
|
|
139
|
+
// checked against too. rUv's ADR-174 helper-signing bakes RUFLO_HELPERS_PUBKEY in for exactly this
|
|
140
|
+
// reason. Because bin/install.mjs and kb/forge-update.mjs ship standalone across a packaging
|
|
141
|
+
// boundary they cannot import one shared constant, so the copies are reconciled HERE — which is the
|
|
142
|
+
// gate kb/forge-update.mjs's own comment has always promised ("the release gate checks that
|
|
143
|
+
// identity") and which nothing has ever actually performed. Without it, a PR that edits an embedded
|
|
144
|
+
// key to an attacker's, leaving keys/*.pem untouched, ships to every installer and self-updating
|
|
145
|
+
// client with no test and no gate objecting.
|
|
146
|
+
const TRUST_ROOT_FILE = 'keys/ruvnet-brain-signing.pub.pem';
|
|
147
|
+
const TRUST_ROOT_HOLDERS = Object.freeze(['bin/install.mjs', 'kb/forge-update.mjs']);
|
|
148
|
+
const EMBEDDED_TRUST_ROOT = /const SIGNING_PUBKEY_PEM = `(-----BEGIN PUBLIC KEY-----[\s\S]*?-----END PUBLIC KEY-----)`/;
|
|
149
|
+
|
|
150
|
+
const normalizeKey = (pem) => String(pem).replace(/\r\n/g, '\n').trim();
|
|
151
|
+
|
|
152
|
+
export function findTrustRootDrift(root = ROOT) {
|
|
153
|
+
const drift = [];
|
|
154
|
+
const canonicalFile = path.join(root, TRUST_ROOT_FILE);
|
|
155
|
+
if (!fs.existsSync(canonicalFile)) return [{ file: TRUST_ROOT_FILE, reason: 'canonical trust root is missing' }];
|
|
156
|
+
const canonical = normalizeKey(fs.readFileSync(canonicalFile, 'utf8'));
|
|
157
|
+
for (const holder of TRUST_ROOT_HOLDERS) {
|
|
158
|
+
const absolute = path.join(root, ...holder.split('/'));
|
|
159
|
+
if (!fs.existsSync(absolute)) { drift.push({ file: holder, reason: 'trust-root holder is missing' }); continue; }
|
|
160
|
+
const match = EMBEDDED_TRUST_ROOT.exec(fs.readFileSync(absolute, 'utf8'));
|
|
161
|
+
if (!match) { drift.push({ file: holder, reason: 'embedded SIGNING_PUBKEY_PEM not found' }); continue; }
|
|
162
|
+
if (normalizeKey(match[1]) !== canonical) {
|
|
163
|
+
drift.push({ file: holder, reason: `embedded trust root differs from ${TRUST_ROOT_FILE}` });
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return drift;
|
|
167
|
+
}
|
|
168
|
+
|
|
89
169
|
export function findUnauthorizedPublishers(root = ROOT) {
|
|
90
170
|
const findings = sourceFiles(root).flatMap((absolute) => {
|
|
91
171
|
const relative = path.relative(root, absolute).split(path.sep).join('/');
|
|
@@ -103,15 +183,27 @@ export function findUnauthorizedPublishers(root = ROOT) {
|
|
|
103
183
|
|
|
104
184
|
export function main(root = ROOT) {
|
|
105
185
|
const findings = findUnauthorizedPublishers(root);
|
|
106
|
-
|
|
107
|
-
|
|
186
|
+
const drift = findTrustRootDrift(root);
|
|
187
|
+
if (findings.length === 0 && drift.length === 0) {
|
|
188
|
+
console.log('[release-authority] PASS: scripts/release.mjs is the only publisher; trust root is identical in all copies');
|
|
108
189
|
return 0;
|
|
109
190
|
}
|
|
110
|
-
|
|
111
|
-
|
|
191
|
+
if (findings.length) {
|
|
192
|
+
console.error('[release-authority] FAIL: publication action found outside scripts/release.mjs');
|
|
193
|
+
for (const finding of findings) console.error(` ${finding.file}: ${finding.action}`);
|
|
194
|
+
}
|
|
195
|
+
if (drift.length) {
|
|
196
|
+
console.error('[release-authority] FAIL: the embedded Ed25519 trust root drifted from its canonical copy');
|
|
197
|
+
for (const entry of drift) console.error(` ${entry.file}: ${entry.reason}`);
|
|
198
|
+
}
|
|
112
199
|
return 1;
|
|
113
200
|
}
|
|
114
201
|
|
|
115
|
-
|
|
202
|
+
// realpath BOTH sides. `path.resolve` alone leaves a symlinked invocation comparing two different
|
|
203
|
+
// spellings of one file, so the guard silently reads false and the CLI exits 0 having done nothing —
|
|
204
|
+
// the same defect 6eead7bc fixed in release-projection.mjs, which is a gate this repo relies on.
|
|
205
|
+
if (((() => {
|
|
206
|
+
try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
|
207
|
+
})())) {
|
|
116
208
|
process.exitCode = main();
|
|
117
209
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/release-channel-kind.mjs — "what KIND of release is this?", stated once.
|
|
3
|
+
//
|
|
4
|
+
// THE CONFLICT THIS RESOLVES. ADR-086's C4 resolution (S1) requires customer corpus releases to be
|
|
5
|
+
// promoted to `releases/latest`, because kb/forge-update.mjs discovers updates by polling exactly
|
|
6
|
+
// that pointer. But six author-side tools were written when `releases/latest` could only ever be a
|
|
7
|
+
// code release, and each of them independently assumes its tag is a semver `vX.Y.Z`:
|
|
8
|
+
//
|
|
9
|
+
// scripts/verify-channels.mjs compares latest's tag to the shipping version — and
|
|
10
|
+
// release.mjs:547-548 runs it as `runOrDie` in step E, so a
|
|
11
|
+
// corpus release on latest BRICKS the owner's own preflight
|
|
12
|
+
// scripts/published-surface-probe.mjs asserts npm version == GitHub tag
|
|
13
|
+
// scripts/github-health-watch.mjs same equality, nightly — a corpus tag fires a false
|
|
14
|
+
// "issue #77 recurring" alert
|
|
15
|
+
// scripts/release-transaction-provider.mjs reads latest mid-transaction, and refuses promotion
|
|
16
|
+
// when latest is not the tag it expected
|
|
17
|
+
// scripts/release-convergence-watchdog.mjs would read "not converged" forever and keep dispatching
|
|
18
|
+
// scripts/release-abort-stale.mjs would call a genuinely-shipped release abandoned
|
|
19
|
+
//
|
|
20
|
+
// Patching six call sites six ways would leave six subtly different answers to one question. The
|
|
21
|
+
// question is shared, so the answer is shared: `releases/latest` means "what a customer downloads"
|
|
22
|
+
// and may legitimately be EITHER kind; every check about the CODE generation must ask for the latest
|
|
23
|
+
// CODE release instead of assuming latest is one.
|
|
24
|
+
//
|
|
25
|
+
// This module is deliberately PURE — no network, no `gh`, no fetch. Each consumer already has its own
|
|
26
|
+
// transport; handing them a predicate keeps this testable and keeps the transports theirs.
|
|
27
|
+
|
|
28
|
+
/** Content-addressed corpus generation: the tag IS the archive's sha256. */
|
|
29
|
+
export const CORPUS_TAG_PATTERN = /^corpus-sha256-[0-9a-f]{64}$/;
|
|
30
|
+
/** Owner-approved product release: a plain semver tag. */
|
|
31
|
+
export const CODE_TAG_PATTERN = /^v\d+\.\d+\.\d+$/;
|
|
32
|
+
|
|
33
|
+
export function releaseKind(tag) {
|
|
34
|
+
const value = String(tag ?? '');
|
|
35
|
+
if (CORPUS_TAG_PATTERN.test(value)) return 'corpus';
|
|
36
|
+
if (CODE_TAG_PATTERN.test(value)) return 'code';
|
|
37
|
+
return 'other';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const isCorpusReleaseTag = (tag) => releaseKind(tag) === 'corpus';
|
|
41
|
+
export const isCodeReleaseTag = (tag) => releaseKind(tag) === 'code';
|
|
42
|
+
|
|
43
|
+
const timeOf = (release) => {
|
|
44
|
+
const parsed = Date.parse(release?.published_at || release?.created_at || release?.publishedAt || release?.createdAt || '');
|
|
45
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
46
|
+
};
|
|
47
|
+
const tagOf = (release) => String(release?.tag_name ?? release?.tagName ?? '');
|
|
48
|
+
const isDraft = (release) => release?.draft === true || release?.isDraft === true;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The newest published CODE release from a `GET /repos/{owner}/{repo}/releases` page (or a
|
|
52
|
+
* `gh release list --json` page — both spellings are accepted).
|
|
53
|
+
*
|
|
54
|
+
* Drafts are excluded: a draft has no tag a customer or a channel check can resolve. Prereleases are
|
|
55
|
+
* NOT excluded — the repo genuinely ships stabilization generations, and treating one as invisible
|
|
56
|
+
* would reintroduce the "latest is stale" blindness from the other direction.
|
|
57
|
+
*
|
|
58
|
+
* Ordering is by publication time when the API supplies it, falling back to list order, which the
|
|
59
|
+
* GitHub releases API already returns newest-first.
|
|
60
|
+
*/
|
|
61
|
+
export function pickLatestCodeRelease(releases) {
|
|
62
|
+
const rows = (Array.isArray(releases) ? releases : [])
|
|
63
|
+
.filter((release) => release && !isDraft(release) && isCodeReleaseTag(tagOf(release)));
|
|
64
|
+
if (rows.length === 0) return null;
|
|
65
|
+
const dated = rows.filter((release) => timeOf(release) !== null);
|
|
66
|
+
if (dated.length === rows.length) {
|
|
67
|
+
return [...rows].sort((left, right) => timeOf(right) - timeOf(left))[0];
|
|
68
|
+
}
|
|
69
|
+
return rows[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function latestCodeReleaseTag(releases) {
|
|
73
|
+
const release = pickLatestCodeRelease(releases);
|
|
74
|
+
return release ? tagOf(release) : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One sentence a consumer can print when `releases/latest` is NOT a code release, so the situation
|
|
79
|
+
* reads as designed rather than as a defect someone must chase.
|
|
80
|
+
*/
|
|
81
|
+
export function describeLatestPointer(latestTag) {
|
|
82
|
+
const kind = releaseKind(latestTag);
|
|
83
|
+
if (kind === 'corpus') return `releases/latest is corpus generation ${latestTag} (by design — that is the customer download)`;
|
|
84
|
+
if (kind === 'code') return `releases/latest is code release ${latestTag}`;
|
|
85
|
+
return `releases/latest is ${latestTag || '(none)'} — neither a code release nor a corpus generation`;
|
|
86
|
+
}
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { execFileSync } from 'node:child_process';
|
|
26
26
|
import fs from 'node:fs';
|
|
27
27
|
import path from 'node:path';
|
|
28
|
+
import { latestCodeReleaseTag } from './release-channel-kind.mjs';
|
|
28
29
|
|
|
29
30
|
const ROOT = path.dirname(path.dirname(new URL(import.meta.url).pathname));
|
|
30
31
|
const DISPATCH = process.argv.includes('--dispatch');
|
|
@@ -44,11 +45,15 @@ function stand_down(reason) {
|
|
|
44
45
|
|
|
45
46
|
// ── 1. Is there anything to converge? ────────────────────────────────────────────────────────────
|
|
46
47
|
const npmLatest = tryShy(() => sh('npm', ['view', 'ruvnet-brain', 'dist-tags.latest']));
|
|
47
|
-
|
|
48
|
+
// ADR-086 S1: `gh release view` with no tag resolves `releases/latest`, which is a corpus
|
|
49
|
+
// generation on any night a corpus round shipped. Comparing npm against a corpus digest would read
|
|
50
|
+
// "never converged" forever and keep dispatching the publisher — a guard that silently disarms into
|
|
51
|
+
// a loop is worse than one that breaks loudly.
|
|
52
|
+
const ghLatest = tryShy(() => latestCodeReleaseTag(JSON.parse(sh('gh', ['api', `repos/${REPO}/releases?per_page=30`]))));
|
|
48
53
|
if (!npmLatest || !ghLatest) stand_down(`could not read published surfaces (npm=${npmLatest} github=${ghLatest})`);
|
|
49
54
|
|
|
50
55
|
const ghVersion = ghLatest.replace(/^v/, '');
|
|
51
|
-
log(`npm dist-tags.latest = ${npmLatest} · GitHub
|
|
56
|
+
log(`npm dist-tags.latest = ${npmLatest} · GitHub latest code release = ${ghLatest}`);
|
|
52
57
|
if (npmLatest === ghVersion) {
|
|
53
58
|
log(`CONVERGED — both surfaces name ${npmLatest}. Issue #77's invariant holds; nothing to do.`);
|
|
54
59
|
process.exit(0);
|
|
@@ -1,74 +1,182 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Build the release-bound coverage projection from an
|
|
3
|
-
//
|
|
2
|
+
// Build the release-bound coverage projection from an ALREADY-SEALED corpus coverage ledger and the
|
|
3
|
+
// exact selected generation ledger/inventory an assembly pass just computed.
|
|
4
|
+
//
|
|
5
|
+
// Step 5 of the corpus-seed/release pipeline consolidation (2026-09-13). Before this, the projection
|
|
6
|
+
// re-read `RVF-GENERATIONS.json` from an assets directory on its own, re-derived the public store set
|
|
7
|
+
// from disk, and — because the corpus coverage handed to it could have been observed at a DIFFERENT
|
|
8
|
+
// moment than the one the seed was actually sealed against — filtered rows to "seeded" ones and
|
|
9
|
+
// rewrote every surviving eligible row to `status: 'CURRENT'`. That entire class of drift is now
|
|
10
|
+
// impossible: `corpusCoverage` reaching this function is Step 4's own sealed measurement of the
|
|
11
|
+
// EXACT corpus directory being assembled (reconciliation only terminates once every eligible row is
|
|
12
|
+
// already CURRENT and artifact-bound against that same directory), so there is nothing left to
|
|
13
|
+
// reconcile here. `createReleaseProjection` is now a pure function: it never touches disk, never
|
|
14
|
+
// re-derives the ledger or the public store set, and never changes a row, a status, a reason, the
|
|
15
|
+
// enumeration, or a per-gist version — it only wraps the sealed measurement in a release identity.
|
|
16
|
+
// The caller (scripts/build-bundle.mjs's assembleBundle) is the ONE place that reads inputs from
|
|
17
|
+
// disk and the ONE place that writes the resulting files, exactly once, alongside everything else it
|
|
18
|
+
// assembles in the same pass.
|
|
4
19
|
import crypto from 'node:crypto';
|
|
5
20
|
import fs from 'node:fs';
|
|
6
21
|
import path from 'node:path';
|
|
7
22
|
import { fileURLToPath } from 'node:url';
|
|
8
|
-
import {
|
|
23
|
+
import { PUBLIC_GENERATIONS_FILE, generationLedgerBytes, projectPublicGenerationLedger,
|
|
9
24
|
releaseCoverageGenerationFor, validateCoverageDirectory } from '../plugin/scripts/coverage-integrity.mjs';
|
|
10
25
|
import { validatePublicInventory } from './public-inventory.mjs';
|
|
11
26
|
import { readRvfGenerations } from './rvf-generation.mjs';
|
|
12
27
|
|
|
13
28
|
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
14
|
-
const arg = (name, fallback = null) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : fallback; };
|
|
15
|
-
const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
16
29
|
const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
30
|
+
const HEX40 = /^[a-f0-9]{40}$/;
|
|
31
|
+
const HEX64 = /^[a-f0-9]{64}$/;
|
|
17
32
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
33
|
+
// Validation-only (Step 5 compatibility note): the assembled directory's RVF-GENERATIONS.json is now
|
|
34
|
+
// written exactly once, by assembleBundle -> projectStoreViews, already in its final, correctly-bound
|
|
35
|
+
// shape. There is nothing left for this function to derive or write — it only proves the finished
|
|
36
|
+
// assembly is internally consistent and bound to the exact release identity being published. Kept
|
|
37
|
+
// (rather than deleted and its callers repointed) because it is the one place every consumer already
|
|
38
|
+
// calls for this proof: scripts/build-bundle.mjs's assembleBundle, and the release-qualification test
|
|
39
|
+
// suite (tests/unit/assembled-release-projection.test.mjs).
|
|
20
40
|
export function bindAssembledReleaseProjection({ assetsDir, version, sourceSnapshot }) {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|| ledger.brainVersion !== version || ledger.releaseTag !== `v${version}`
|
|
25
|
-
|| ledger.sourceSnapshot !== sourceSnapshot) {
|
|
26
|
-
throw new Error('assembled public ledger does not bind this release source');
|
|
27
|
-
}
|
|
28
|
-
fs.writeFileSync(path.join(assetsDir, 'RVF-GENERATIONS.json'), `${JSON.stringify({
|
|
29
|
-
...ledger, kind: 'ruvnet-brain-runtime-generation-ledger',
|
|
30
|
-
}, null, 2)}\n`);
|
|
31
|
-
const result = validateCoverageDirectory(assetsDir, { expectedVersion: version,
|
|
32
|
-
expectedSourceSnapshot: sourceSnapshot, requireCompleteProfile: true });
|
|
41
|
+
const result = validateCoverageDirectory(assetsDir, {
|
|
42
|
+
expectedVersion: version, expectedSourceSnapshot: sourceSnapshot, requireCompleteProfile: true,
|
|
43
|
+
});
|
|
33
44
|
if (!result.valid) throw new Error(`assembled release projection rejected: ${result.failures.join('; ')}`);
|
|
34
45
|
return result;
|
|
35
46
|
}
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
/**
|
|
49
|
+
* createReleaseProjection({ corpusCoverage, selectedLedger, identity, seedIdentity, inventory })
|
|
50
|
+
* -> ReleaseProjection
|
|
51
|
+
*
|
|
52
|
+
* `corpusCoverage` — the exact ruvnet-brain-corpus-coverage object Step 4 sealed for the directory
|
|
53
|
+
* being assembled. Every row, status, reason, and the enumeration receipt are
|
|
54
|
+
* carried through UNCHANGED (algorithm step 9) — this function reads them, it
|
|
55
|
+
* never rewrites them.
|
|
56
|
+
* `selectedLedger` — the PUBLIC generation ledger view assembleBundle's projectStoreViews already
|
|
57
|
+
* derived once from the selected records (schemaVersion 2, kind
|
|
58
|
+
* 'ruvnet-brain-public-generation-ledger', carrying `identity`'s version/
|
|
59
|
+
* sourceSnapshot). Never independently re-read from disk here.
|
|
60
|
+
* `identity` — { version, sourceSnapshot }: the exact release this projection is bound to.
|
|
61
|
+
* `seedIdentity` — { tag, archiveSha256, archiveBytes, baselineReceiptSha256 }: the immutable
|
|
62
|
+
* corpus seed this assembly started from, and the sha256 of the baseline
|
|
63
|
+
* observation receipt proving that seed was not silently substituted.
|
|
64
|
+
* `inventory` — the ValidatePublicInventory() result assembleBundle already computed once
|
|
65
|
+
* while validating the finalized corpus (its `partitionSha256` is the evidence
|
|
66
|
+
* digest bound into the release ledger).
|
|
67
|
+
*
|
|
68
|
+
* Returns `{ releaseCoverage, corpusCoverageBytes, publicGenerationLedgerBytes }` — plain data; this
|
|
69
|
+
* function performs no I/O of its own.
|
|
70
|
+
*/
|
|
71
|
+
export function createReleaseProjection({ corpusCoverage, selectedLedger, identity, seedIdentity, inventory }) {
|
|
72
|
+
if (!corpusCoverage || corpusCoverage.kind !== 'ruvnet-brain-corpus-coverage' || !Array.isArray(corpusCoverage.rows)) {
|
|
73
|
+
throw new Error('corpus coverage must be an observed ruvnet-brain-corpus-coverage ledger');
|
|
74
|
+
}
|
|
75
|
+
const version = identity?.version;
|
|
76
|
+
const sourceSnapshot = identity?.sourceSnapshot;
|
|
77
|
+
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version || '')) throw new Error('release version is invalid');
|
|
78
|
+
if (!HEX40.test(sourceSnapshot || '')) throw new Error('release source snapshot is invalid');
|
|
79
|
+
if (!seedIdentity?.tag || !HEX64.test(seedIdentity.archiveSha256 || '')
|
|
80
|
+
|| !Number.isSafeInteger(seedIdentity.archiveBytes) || !HEX64.test(seedIdentity.baselineReceiptSha256 || '')) {
|
|
81
|
+
throw new Error('corpus seed identity is incomplete');
|
|
82
|
+
}
|
|
83
|
+
if (!selectedLedger || selectedLedger.kind !== 'ruvnet-brain-public-generation-ledger'
|
|
84
|
+
|| !selectedLedger.stores || typeof selectedLedger.stores !== 'object' || Array.isArray(selectedLedger.stores)
|
|
85
|
+
|| Object.keys(selectedLedger.stores).length === 0) {
|
|
86
|
+
throw new Error('selected public generation ledger is malformed or empty');
|
|
87
|
+
}
|
|
88
|
+
if (selectedLedger.brainVersion !== version || selectedLedger.sourceSnapshot !== sourceSnapshot) {
|
|
89
|
+
throw new Error('selected public generation ledger does not bind this release identity');
|
|
44
90
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
91
|
+
if (!inventory || typeof inventory.partitionSha256 !== 'string' || !HEX64.test(inventory.partitionSha256)) {
|
|
92
|
+
throw new Error('public inventory evidence is required');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const ledgerBytes = generationLedgerBytes(selectedLedger);
|
|
96
|
+
const corpusBytes = Buffer.from(`${JSON.stringify(corpusCoverage, null, 2)}\n`);
|
|
97
|
+
const releaseCoverage = {
|
|
98
|
+
schemaVersion: 1,
|
|
99
|
+
kind: 'ruvnet-brain-release-coverage',
|
|
100
|
+
owner: corpusCoverage.owner,
|
|
101
|
+
observedAt: corpusCoverage.observedAt,
|
|
102
|
+
generatorSourceSha: corpusCoverage.generatorSourceSha,
|
|
103
|
+
sourceObservationSha256: corpusCoverage.sourceObservationSha256,
|
|
104
|
+
snapshotRoot: corpusCoverage.snapshotRoot,
|
|
105
|
+
releaseIdentity: { version, tag: `v${version}`, sourceSnapshot },
|
|
106
|
+
corpusSeed: { tag: seedIdentity.tag, archiveSha256: seedIdentity.archiveSha256,
|
|
107
|
+
archiveBytes: seedIdentity.archiveBytes, receiptSha256: seedIdentity.baselineReceiptSha256 },
|
|
108
|
+
corpusCoverage: { sha256: sha256(corpusBytes), coverageGeneration: corpusCoverage.coverageGeneration },
|
|
109
|
+
generationLedger: { file: PUBLIC_GENERATIONS_FILE, sha256: sha256(ledgerBytes),
|
|
110
|
+
bytes: ledgerBytes.length, storeCount: Object.keys(selectedLedger.stores).length },
|
|
111
|
+
installedProjectionSchema: 2,
|
|
112
|
+
policy: corpusCoverage.policy,
|
|
113
|
+
// UNCHANGED from the sealed measurement (algorithm step 9): no row, status, reason, or
|
|
114
|
+
// enumeration count is ever rewritten here.
|
|
115
|
+
enumerationReceipt: corpusCoverage.enumerationReceipt,
|
|
116
|
+
rows: corpusCoverage.rows,
|
|
117
|
+
totals: corpusCoverage.totals,
|
|
118
|
+
publicInventoryPartitionSha256: inventory.partitionSha256,
|
|
119
|
+
};
|
|
120
|
+
releaseCoverage.releaseCoverageGeneration = releaseCoverageGenerationFor(releaseCoverage);
|
|
121
|
+
return { releaseCoverage, corpusCoverageBytes: corpusBytes, publicGenerationLedgerBytes: ledgerBytes };
|
|
49
122
|
}
|
|
50
123
|
|
|
51
|
-
|
|
124
|
+
/**
|
|
125
|
+
* projectReleaseFromAssets(...) — TEMPORARY COMPATIBILITY PATH. RETIRES AT PLAN STEP 11.
|
|
126
|
+
*
|
|
127
|
+
* P1-c (Dual, 2026-09-14): the step-5 consolidation replaced this file's CLI with an unconditional
|
|
128
|
+
* `process.exitCode = 1`, while .github/workflows/ci.yml's release-qe job still invoked it and then
|
|
129
|
+
* passed `--coverage`/`--projection` to a build-bundle CLI that rejected both. A release gate that
|
|
130
|
+
* can only exit 1 is not a gate, and documenting that it is expected to fail does not repair it.
|
|
131
|
+
* The consumer sequence and the scripts now AGREE again: ci.yml declares the legacy rail explicitly
|
|
132
|
+
* with `--legacy-seed-projection`, this CLI produces the projection it did before the consolidation,
|
|
133
|
+
* and build-bundle.mjs's legacySeedProjection branch consumes it.
|
|
134
|
+
*
|
|
135
|
+
* This is NOT a return to the pre-consolidation semantics for new work: `createReleaseProjection`
|
|
136
|
+
* above remains the single-pass, pure, in-process producer every step-11 consumer will call. This
|
|
137
|
+
* function exists ONLY because `data/corpus-seed.json` still pins v4.2.1-dev, a PRE-Step-3/4 seed
|
|
138
|
+
* whose contents (measured 2026-09-14: 186 stores, no sealed public-input selection, no
|
|
139
|
+
* public-store-classes.json, no ruv-gists.sources.json) cannot satisfy the sealed-corpus contract.
|
|
140
|
+
* Steps 8-11 publish and re-pin a Step-4-produced seed; this function is deleted with that switch.
|
|
141
|
+
*
|
|
142
|
+
* Unchanged from the pre-consolidation producer in the one respect that matters: rows are SCOPED to
|
|
143
|
+
* what the immutable seed actually carries (a repository whose store is absent, or a gist the sealed
|
|
144
|
+
* receipt does not carry, is dropped — never rewritten CURRENT), and the complete observation is
|
|
145
|
+
* preserved untouched in CORPUS-COVERAGE.json.
|
|
146
|
+
*/
|
|
147
|
+
export function projectReleaseFromAssets({ corpusCoverage, assetsDir, version, sourceSnapshot,
|
|
52
148
|
corpusSeed, baselineReceiptSha256, outDir }) {
|
|
53
149
|
if (!corpusCoverage || corpusCoverage.kind !== 'ruvnet-brain-corpus-coverage') {
|
|
54
150
|
throw new Error('corpus coverage must be an observed ruvnet-brain-corpus-coverage ledger');
|
|
55
151
|
}
|
|
56
152
|
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version || '')) throw new Error('release version is invalid');
|
|
57
|
-
if (
|
|
58
|
-
if (!corpusSeed?.tag ||
|
|
59
|
-
|| !Number.isSafeInteger(corpusSeed.archiveBytes) ||
|
|
153
|
+
if (!HEX40.test(sourceSnapshot || '')) throw new Error('release source snapshot is invalid');
|
|
154
|
+
if (!corpusSeed?.tag || !HEX64.test(corpusSeed.archiveSha256 || '')
|
|
155
|
+
|| !Number.isSafeInteger(corpusSeed.archiveBytes) || !HEX64.test(baselineReceiptSha256 || '')) {
|
|
60
156
|
throw new Error('corpus seed identity is incomplete');
|
|
61
157
|
}
|
|
62
|
-
const assets = path.resolve(assetsDir);
|
|
158
|
+
const assets = path.resolve(assetsDir || '');
|
|
63
159
|
const sourceLedger = readRvfGenerations(assets);
|
|
64
|
-
// Source observation and release assets are different evidence planes. The observation is
|
|
65
|
-
// generated against the maintainer checkout, where RVF binaries are intentionally absent;
|
|
66
|
-
// the seed is the immutable release input. Build the release-scoped rows from the actual seed
|
|
67
|
-
// files and its byte-bound ledger, while preserving the complete observation in CORPUS-COVERAGE.
|
|
68
160
|
const availableStores = new Set(fs.readdirSync(assets)
|
|
69
161
|
.filter((name) => /^.+\.big\.rvf$/.test(name)).map((name) => name.slice(0, -'.big.rvf'.length).toLowerCase()));
|
|
70
|
-
|
|
71
|
-
|
|
162
|
+
// The seed is the truth for gists too, at GIST granularity: `ruv-gists.big.rvf` being present says
|
|
163
|
+
// the aggregate shipped, not that every gist rUv has published since the seal is inside it. A gist
|
|
164
|
+
// the sealed receipt does not carry is unseeded exactly like a repository whose store is absent.
|
|
165
|
+
// NO FALLBACK to the maintainer's checkout copy of the receipt (the 2026-09-13 divergence bug): a
|
|
166
|
+
// receipt that is not in the assembled candidate simply does not exist for this projection.
|
|
167
|
+
const receiptFile = path.join(assets, 'ruv-gists.sources.json');
|
|
168
|
+
let gistReceipt = null;
|
|
169
|
+
if (fs.existsSync(receiptFile)) {
|
|
170
|
+
gistReceipt = JSON.parse(fs.readFileSync(receiptFile, 'utf8'));
|
|
171
|
+
if (gistReceipt.schemaVersion !== 3 || gistReceipt.kind !== 'ruvnet-brain-gist-source-receipts') {
|
|
172
|
+
throw new Error('the assembled candidate carries a downgraded gist receipt (ruv-gists.sources.json) '
|
|
173
|
+
+ '-- refusing to project a release from an unreconciled or schema-downgraded receipt');
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const seededGistIds = new Set(gistReceipt ? Object.keys(gistReceipt.gists) : []);
|
|
177
|
+
const seeded = (row) => availableStores.has(String(row.artifact?.store || '').toLowerCase())
|
|
178
|
+
&& (row.kind !== 'gist' || seededGistIds.has(String(row.key || '').replace(/^gist:/, '')));
|
|
179
|
+
const seededRows = corpusCoverage.rows.filter((row) => row.disposition === 'eligible' && seeded(row));
|
|
72
180
|
if (!seededRows.length) throw new Error('immutable seed contains no eligible corpus stores');
|
|
73
181
|
const seededExcludedRows = corpusCoverage.rows.filter((row) => row.disposition !== 'eligible'
|
|
74
182
|
&& availableStores.has(String(row.artifact?.store || '').toLowerCase()));
|
|
@@ -80,26 +188,21 @@ export function createReleaseProjection({ corpusCoverage, assetsDir, version, so
|
|
|
80
188
|
return { ...row, status: 'CURRENT', artifact: { ...row.artifact,
|
|
81
189
|
sourceCommit: generation.sourceCommit, rvfSha256: generation.sha256 } };
|
|
82
190
|
});
|
|
83
|
-
// The release ledger is the shipped public set, not a catalog of policy-excluded rows.
|
|
84
|
-
// Excluded rows remain in COVERAGE.json as auditable policy evidence, but must not be
|
|
85
|
-
// inserted into PUBLIC-RVF-GENERATIONS.json or counted as installed public stores.
|
|
86
191
|
const publicStores = [...new Set(rows.filter((row) => row.disposition === 'eligible')
|
|
87
192
|
.map((row) => String(row.artifact.store).toLowerCase()))];
|
|
88
193
|
const classesFile = path.join(assets, 'public-store-classes.json');
|
|
89
194
|
const derivedStores = fs.existsSync(classesFile)
|
|
90
|
-
?
|
|
195
|
+
? (JSON.parse(fs.readFileSync(classesFile, 'utf8')).derived || []).map((entry) => String(entry.store).toLowerCase())
|
|
196
|
+
: [];
|
|
91
197
|
const ledgerStores = [...new Set([...publicStores, ...derivedStores])];
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
expected: projectedCoverage.totals.repositories },
|
|
101
|
-
gists: { ...corpusCoverage.enumerationReceipt.gists,
|
|
102
|
-
expected: projectedCoverage.totals.gists } };
|
|
198
|
+
const totals = { ...corpusCoverage.totals, rows: rows.length,
|
|
199
|
+
repositories: rows.filter((row) => row.kind === 'repository').length,
|
|
200
|
+
gists: rows.filter((row) => row.kind === 'gist').length,
|
|
201
|
+
byStatus: Object.fromEntries([...new Set(rows.map((row) => row.status))].sort()
|
|
202
|
+
.map((status) => [status, rows.filter((row) => row.status === status).length])) };
|
|
203
|
+
const enumerationReceipt = { ...corpusCoverage.enumerationReceipt,
|
|
204
|
+
repositories: { ...corpusCoverage.enumerationReceipt.repositories, expected: totals.repositories },
|
|
205
|
+
gists: { ...corpusCoverage.enumerationReceipt.gists, expected: totals.gists } };
|
|
103
206
|
const ledger = projectPublicGenerationLedger({ ledger: sourceLedger, publicStores: ledgerStores, version, sourceSnapshot });
|
|
104
207
|
const ledgerBytes = generationLedgerBytes(ledger);
|
|
105
208
|
const corpusBytes = Buffer.from(`${JSON.stringify(corpusCoverage, null, 2)}\n`);
|
|
@@ -111,39 +214,41 @@ export function createReleaseProjection({ corpusCoverage, assetsDir, version, so
|
|
|
111
214
|
corpusSeed: { tag: corpusSeed.tag, archiveSha256: corpusSeed.archiveSha256,
|
|
112
215
|
archiveBytes: corpusSeed.archiveBytes, receiptSha256: baselineReceiptSha256 },
|
|
113
216
|
corpusCoverage: { sha256: sha256(corpusBytes), coverageGeneration: corpusCoverage.coverageGeneration },
|
|
114
|
-
generationLedger: { file:
|
|
217
|
+
generationLedger: { file: PUBLIC_GENERATIONS_FILE, sha256: sha256(ledgerBytes),
|
|
115
218
|
bytes: ledgerBytes.length, storeCount: Object.keys(ledger.stores).length },
|
|
116
|
-
installedProjectionSchema: 2, policy: corpusCoverage.policy, enumerationReceipt
|
|
117
|
-
rows: projectedCoverage.rows, totals: projectedCoverage.totals,
|
|
219
|
+
installedProjectionSchema: 2, policy: corpusCoverage.policy, enumerationReceipt, rows, totals,
|
|
118
220
|
};
|
|
119
|
-
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
-
fs.writeFileSync(path.join(assets, 'ruv-gists.sources.json'), `${JSON.stringify(gistReceipt, null, 2)}\n`);
|
|
221
|
+
// Re-read from disk (never the object handed in): validatePublicInventory's partition digest omits
|
|
222
|
+
// a directly-supplied receipt's own evidence identity, which would then permanently disagree with
|
|
223
|
+
// the independent digest bindAssembledReleaseProjection computes from the same file moments later.
|
|
123
224
|
const inventory = validatePublicInventory({ assetsDir: assets, coverage: releaseBase, ledger });
|
|
124
225
|
releaseBase.publicInventoryPartitionSha256 = inventory.partitionSha256;
|
|
125
226
|
releaseBase.releaseCoverageGeneration = releaseCoverageGenerationFor(releaseBase);
|
|
126
|
-
const out = path.resolve(outDir);
|
|
227
|
+
const out = path.resolve(outDir || '');
|
|
127
228
|
fs.mkdirSync(out, { recursive: true });
|
|
128
229
|
fs.writeFileSync(path.join(out, 'CORPUS-COVERAGE.json'), corpusBytes);
|
|
129
230
|
fs.writeFileSync(path.join(out, 'COVERAGE.json'), `${JSON.stringify(releaseBase, null, 2)}\n`);
|
|
130
|
-
fs.writeFileSync(path.join(out,
|
|
131
|
-
|
|
132
|
-
for
|
|
231
|
+
fs.writeFileSync(path.join(out, PUBLIC_GENERATIONS_FILE), ledgerBytes);
|
|
232
|
+
// Any derived/aggregate evidence the candidate carried travels WITH the projection; nothing is
|
|
233
|
+
// synthesized for a file the candidate did not have.
|
|
234
|
+
for (const file of ['ruv-gists.sources.json', 'public-store-classes.json', 'concepts.sources.json']) {
|
|
133
235
|
const source = path.join(assets, file);
|
|
134
236
|
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(out, file));
|
|
135
237
|
}
|
|
136
238
|
return releaseBase;
|
|
137
239
|
}
|
|
138
240
|
|
|
139
|
-
if (
|
|
241
|
+
if (((() => { try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })())) {
|
|
242
|
+
const arg = (name, fallback = null) => { const i = process.argv.indexOf(name); return i >= 0 ? process.argv[i + 1] : fallback; };
|
|
140
243
|
try {
|
|
141
244
|
const corpusFile = path.resolve(arg('--corpus', path.join(ROOT, 'data', 'source-coverage.json')));
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
245
|
+
const projection = projectReleaseFromAssets({
|
|
246
|
+
corpusCoverage: JSON.parse(fs.readFileSync(corpusFile, 'utf8')),
|
|
247
|
+
assetsDir: arg('--assets'), outDir: arg('--out', 'release-evidence'),
|
|
248
|
+
version: arg('--version'), sourceSnapshot: arg('--source-snapshot'),
|
|
145
249
|
baselineReceiptSha256: arg('--baseline-receipt-sha256'),
|
|
146
|
-
corpusSeed: { tag: arg('--seed-tag'), archiveSha256: arg('--seed-sha256'), archiveBytes: Number(arg('--seed-bytes')) }
|
|
250
|
+
corpusSeed: { tag: arg('--seed-tag'), archiveSha256: arg('--seed-sha256'), archiveBytes: Number(arg('--seed-bytes')) },
|
|
251
|
+
});
|
|
147
252
|
console.log(JSON.stringify({ ok: true, releaseCoverageGeneration: projection.releaseCoverageGeneration }));
|
|
148
253
|
} catch (error) { console.error(`[release-projection] ${error.message}`); process.exitCode = 1; }
|
|
149
254
|
}
|