ruvnet-brain 4.3.21 → 4.3.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +141 -9
  4. package/console/index.html +51 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/tips.html +1 -0
  9. package/kb/corpus-release-identity.mjs +239 -0
  10. package/kb/update-storage-transaction.mjs +20 -3
  11. package/package.json +9 -2
  12. package/plugin/.claude-plugin/plugin.json +2 -2
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/commands/checkpoint.md +61 -0
  15. package/plugin/hooks/codex-hooks.json +64 -1
  16. package/plugin/hooks/hook-contracts.json +299 -6
  17. package/plugin/hooks/hooks.json +81 -1
  18. package/plugin/mcp/server.mjs +23 -0
  19. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  20. package/plugin/scripts/advocacy-route.mjs +460 -0
  21. package/plugin/scripts/continuation-gate.mjs +25 -2
  22. package/plugin/scripts/continuation-objective.mjs +7 -1
  23. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  24. package/plugin/scripts/coverage-integrity.mjs +7 -0
  25. package/plugin/scripts/gates.mjs +113 -10
  26. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  27. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  28. package/plugin/scripts/hook-shim.mjs +14 -0
  29. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  30. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  31. package/plugin/scripts/project-progression-contract.mjs +16 -0
  32. package/plugin/scripts/project-progression-hook.mjs +3 -0
  33. package/plugin/scripts/project-progression-producer.mjs +252 -0
  34. package/plugin/scripts/project-progression-reader.mjs +271 -0
  35. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  36. package/plugin/scripts/project-progression-sources.mjs +220 -0
  37. package/plugin/scripts/project-progression-store.mjs +106 -13
  38. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  39. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  40. package/plugin/scripts/session-start-budget.mjs +59 -0
  41. package/plugin/scripts/session-start-core.mjs +234 -457
  42. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  43. package/plugin/scripts/session-start-health.mjs +64 -0
  44. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  45. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  46. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  47. package/plugin/scripts/session-start-signals.mjs +73 -0
  48. package/plugin/scripts/session-start-trace.mjs +86 -0
  49. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  50. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  51. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  52. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  53. package/scripts/adr-072-completion.mjs +1 -1
  54. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  55. package/scripts/approved-runtime.mjs +197 -0
  56. package/scripts/brain-novice-50.mjs +16 -1
  57. package/scripts/brain-score.mjs +23 -5
  58. package/scripts/build-bundle.mjs +971 -530
  59. package/scripts/build-concepts.mjs +36 -116
  60. package/scripts/console-engine.test.mjs +8 -7
  61. package/scripts/console-runtime-identity.mjs +4 -0
  62. package/scripts/corpus-aggregates.mjs +94 -77
  63. package/scripts/corpus-candidate.mjs +475 -222
  64. package/scripts/corpus-next-seed.mjs +225 -0
  65. package/scripts/corpus-promotion.mjs +58 -0
  66. package/scripts/corpus-reconcile.mjs +411 -105
  67. package/scripts/doc-currency.mjs +16 -1
  68. package/scripts/dual-host-deliberation.mjs +25 -2
  69. package/scripts/dual-host-suggest.mjs +17 -1
  70. package/scripts/falsify.mjs +13 -3
  71. package/scripts/gist-receipts.mjs +482 -87
  72. package/scripts/github-health-watch.mjs +12 -2
  73. package/scripts/handoff-asset.mjs +34 -0
  74. package/scripts/hook-retirement-check.mjs +8 -1
  75. package/scripts/host-registry.mjs +1 -1
  76. package/scripts/ingest-gists.mjs +74 -101
  77. package/scripts/job-heartbeat.sh +77 -14
  78. package/scripts/learning-replay-execution.mjs +10 -4
  79. package/scripts/nightly-gists.sh +27 -13
  80. package/scripts/nightly-two-run-proof.mjs +1 -1
  81. package/scripts/nightly-watchdog.mjs +61 -4
  82. package/scripts/onboarding-console.mjs +319 -27
  83. package/scripts/oracle/produce-questions.mjs +293 -0
  84. package/scripts/oracle/producer-hosts.mjs +235 -0
  85. package/scripts/oracle/repo-recall.mjs +448 -0
  86. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  87. package/scripts/oracle/source-tree.mjs +165 -0
  88. package/scripts/oracle/source-units.mjs +391 -0
  89. package/scripts/oracle/spike-run.mjs +98 -0
  90. package/scripts/oracle/unit-inventory.mjs +141 -0
  91. package/scripts/oracle/unit-sampling.mjs +128 -0
  92. package/scripts/oracle/validate-labels.mjs +250 -0
  93. package/scripts/private-overlay.mjs +248 -0
  94. package/scripts/product-integrity-contract.mjs +1 -1
  95. package/scripts/proxy/claude-proxied.sh +6 -0
  96. package/scripts/proxy/proxy-revert.sh +5 -0
  97. package/scripts/proxy/proxy-up.sh +6 -0
  98. package/scripts/proxy/proxy-verify.mjs +4 -0
  99. package/scripts/public-inputs.mjs +409 -0
  100. package/scripts/public-verification-inputs.mjs +112 -26
  101. package/scripts/public-verification-lane.mjs +1 -1
  102. package/scripts/published-surface-probe.mjs +34 -4
  103. package/scripts/qe/card-lane-gate.mjs +16 -1
  104. package/scripts/qe/session-start-gate.mjs +16 -1
  105. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  106. package/scripts/record-lesson.mjs +4 -1
  107. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  108. package/scripts/release-abort-stale.mjs +5 -1
  109. package/scripts/release-authority.mjs +104 -12
  110. package/scripts/release-channel-kind.mjs +86 -0
  111. package/scripts/release-convergence-watchdog.mjs +7 -2
  112. package/scripts/release-projection.mjs +177 -72
  113. package/scripts/release-transaction-provider.mjs +23 -6
  114. package/scripts/release.mjs +252 -17
  115. package/scripts/retrieval-canary.mjs +87 -0
  116. package/scripts/rvf-index-audit.mjs +573 -13
  117. package/scripts/rvf-wire.mjs +269 -0
  118. package/scripts/seal-gist-receipt.mjs +65 -0
  119. package/scripts/selfcheck.mjs +42 -21
  120. package/scripts/source-coverage.mjs +253 -24
  121. package/scripts/status-honesty.mjs +25 -0
  122. package/scripts/sync-census.mjs +0 -0
  123. package/scripts/sync-version.mjs +2 -0
  124. package/scripts/trismart.mjs +42 -0
  125. package/scripts/updater-manifest.mjs +162 -0
  126. package/scripts/verify-channels.mjs +17 -5
  127. package/scripts/wired-check.mjs +48 -10
  128. package/tri-smart-skill/QUICKSTART.md +37 -0
  129. package/tri-smart-skill/README.md +92 -0
  130. package/tri-smart-skill/install.cmd +14 -0
  131. package/tri-smart-skill/install.command +13 -0
  132. package/tri-smart-skill/install.mjs +51 -0
  133. package/tri-smart-skill/install.sh +9 -0
  134. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  135. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  136. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  137. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  138. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  139. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  140. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  141. 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
- const ghLatest = JSON.parse(gh(['api', `repos/${REPO}/releases/latest`])).tag_name;
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, pathToFileURL } from 'node:url';
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((line) => line.replace(/\/\/.*$/, ''))
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
- visit(path.join(root, 'scripts'));
83
- visit(path.join(root, 'deploy'));
84
- visit(path.join(root, 'bin'));
85
- visit(path.join(root, '.github', 'workflows'));
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
- if (findings.length === 0) {
107
- console.log('[release-authority] PASS: scripts/release.mjs is the only publisher');
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
- console.error('[release-authority] FAIL: publication action found outside scripts/release.mjs');
111
- for (const finding of findings) console.error(` ${finding.file}: ${finding.action}`);
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
- if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
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
- const ghLatest = tryShy(() => sh('gh', ['release', 'view', '--repo', REPO, '--json', 'tagName', '-q', '.tagName']));
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 releases/latest = ${ghLatest}`);
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 observed corpus ledger and exact bundle bytes.
3
- // This is a producer; public-verification-inputs.mjs independently proves the emitted files.
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 { digest, generationLedgerBytes, projectPublicGenerationLedger,
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
- // The first assembly supplies seed bytes to the projection producer. Only the final, projected
19
- // assembly has a release-bound public ledger; its runtime ledger must describe that same set.
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 ledger = readJson(path.join(assetsDir, 'PUBLIC-RVF-GENERATIONS.json'));
22
- if (!/^[a-f0-9]{40}$/.test(sourceSnapshot || '') || ledger.schemaVersion !== 2
23
- || ledger.kind !== 'ruvnet-brain-public-generation-ledger'
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
- function seedCompatibleGistReceipt() {
38
- const source = readJson(path.join(ROOT, 'kb', 'ruv-gists.sources.json'));
39
- const gists = {};
40
- for (const [id, row] of Object.entries(source.gists || {})) {
41
- const files = row.files;
42
- gists[id] = { versionSha: row.versionSha, files,
43
- contentDigest: crypto.createHash('sha256').update(JSON.stringify(files)).digest('hex') };
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
- // The v4.2.1 seed predates the schema-3 source-byte binding. Keep the release proof honest:
46
- // schema 2 proves the exact gist identity/file inventory, while CORPUS-COVERAGE retains the
47
- // newer source observation separately. Do not relabel the old seed as current-source evidence.
48
- return { schemaVersion: 2, owner: source.owner, generated: source.generated, gists };
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
- export function createReleaseProjection({ corpusCoverage, assetsDir, version, sourceSnapshot,
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 (!/^[a-f0-9]{40}$/.test(sourceSnapshot || '')) throw new Error('release source snapshot is invalid');
58
- if (!corpusSeed?.tag || !/^[a-f0-9]{64}$/.test(corpusSeed.archiveSha256 || '')
59
- || !Number.isSafeInteger(corpusSeed.archiveBytes) || !/^[a-f0-9]{64}$/.test(baselineReceiptSha256 || '')) {
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
- const seededRows = corpusCoverage.rows.filter((row) => row.disposition === 'eligible'
71
- && availableStores.has(String(row.artifact?.store || '').toLowerCase()));
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
- ? readJson(classesFile).derived.map((entry) => String(entry.store).toLowerCase()) : [];
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 projectedCoverage = { ...corpusCoverage, rows,
93
- totals: { ...corpusCoverage.totals, rows: rows.length,
94
- repositories: rows.filter((row) => row.kind === 'repository').length,
95
- gists: rows.filter((row) => row.kind === 'gist').length,
96
- byStatus: Object.fromEntries([...new Set(rows.map((row) => row.status))].sort()
97
- .map((status) => [status, rows.filter((row) => row.status === status).length])) } };
98
- const projectedEnumerationReceipt = { ...corpusCoverage.enumerationReceipt,
99
- repositories: { ...corpusCoverage.enumerationReceipt.repositories,
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: 'PUBLIC-RVF-GENERATIONS.json', sha256: sha256(ledgerBytes),
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: projectedEnumerationReceipt,
117
- rows: projectedCoverage.rows, totals: projectedCoverage.totals,
219
+ installedProjectionSchema: 2, policy: corpusCoverage.policy, enumerationReceipt, rows, totals,
118
220
  };
119
- const gistReceipt = seedCompatibleGistReceipt();
120
- // Keep the projected receipt in the same asset root that the final archive
121
- // validator reads, so both partition hashes include identical evidence.
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, 'PUBLIC-RVF-GENERATIONS.json'), ledgerBytes);
131
- fs.writeFileSync(path.join(out, 'ruv-gists.sources.json'), `${JSON.stringify(gistReceipt, null, 2)}\n`);
132
- for (const file of ['public-store-classes.json', 'concepts.sources.json']) {
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 (path.resolve(process.argv[1] || '') === fileURLToPath(import.meta.url)) {
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 corpus = readJson(corpusFile);
143
- const projection = createReleaseProjection({ corpusCoverage: corpus, assetsDir: arg('--assets'),
144
- outDir: arg('--out', 'release-evidence'), version: arg('--version'), sourceSnapshot: arg('--source-snapshot'),
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
  }