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.
Files changed (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +189 -9
  4. package/console/index.html +70 -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/style.css +26 -0
  9. package/console/tips.html +1 -0
  10. package/kb/corpus-release-identity.mjs +239 -0
  11. package/kb/update-storage-transaction.mjs +20 -3
  12. package/package.json +9 -2
  13. package/plugin/.claude-plugin/plugin.json +2 -2
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/commands/checkpoint.md +61 -0
  16. package/plugin/hooks/codex-hooks.json +64 -1
  17. package/plugin/hooks/hook-contracts.json +299 -6
  18. package/plugin/hooks/hooks.json +81 -1
  19. package/plugin/mcp/server.mjs +23 -0
  20. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  21. package/plugin/scripts/advocacy-route.mjs +460 -0
  22. package/plugin/scripts/continuation-gate.mjs +25 -2
  23. package/plugin/scripts/continuation-objective.mjs +7 -1
  24. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  25. package/plugin/scripts/coverage-integrity.mjs +7 -0
  26. package/plugin/scripts/gates.mjs +113 -10
  27. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  28. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  29. package/plugin/scripts/hook-shim.mjs +14 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  53. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  54. package/scripts/adr-072-completion.mjs +1 -1
  55. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  56. package/scripts/approved-runtime.mjs +197 -0
  57. package/scripts/brain-novice-50.mjs +16 -1
  58. package/scripts/brain-score.mjs +23 -5
  59. package/scripts/build-bundle.mjs +971 -530
  60. package/scripts/build-concepts.mjs +36 -116
  61. package/scripts/console-engine.test.mjs +8 -7
  62. package/scripts/console-runtime-identity.mjs +4 -0
  63. package/scripts/corpus-aggregates.mjs +94 -77
  64. package/scripts/corpus-candidate.mjs +475 -222
  65. package/scripts/corpus-next-seed.mjs +225 -0
  66. package/scripts/corpus-promotion.mjs +58 -0
  67. package/scripts/corpus-reconcile.mjs +411 -105
  68. package/scripts/doc-currency.mjs +16 -1
  69. package/scripts/dual-host-deliberation.mjs +25 -2
  70. package/scripts/dual-host-suggest.mjs +17 -1
  71. package/scripts/falsify.mjs +13 -3
  72. package/scripts/gist-receipts.mjs +482 -87
  73. package/scripts/github-health-watch.mjs +12 -2
  74. package/scripts/handoff-asset.mjs +34 -0
  75. package/scripts/hook-retirement-check.mjs +8 -1
  76. package/scripts/host-registry.mjs +1 -1
  77. package/scripts/ingest-gists.mjs +74 -101
  78. package/scripts/job-heartbeat.sh +77 -14
  79. package/scripts/learning-replay-execution.mjs +10 -4
  80. package/scripts/nightly-gists.sh +27 -13
  81. package/scripts/nightly-two-run-proof.mjs +1 -1
  82. package/scripts/nightly-watchdog.mjs +61 -4
  83. package/scripts/onboarding-console.mjs +364 -28
  84. package/scripts/oracle/produce-questions.mjs +293 -0
  85. package/scripts/oracle/producer-hosts.mjs +235 -0
  86. package/scripts/oracle/repo-recall.mjs +448 -0
  87. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  88. package/scripts/oracle/source-tree.mjs +165 -0
  89. package/scripts/oracle/source-units.mjs +391 -0
  90. package/scripts/oracle/spike-run.mjs +98 -0
  91. package/scripts/oracle/unit-inventory.mjs +141 -0
  92. package/scripts/oracle/unit-sampling.mjs +128 -0
  93. package/scripts/oracle/validate-labels.mjs +250 -0
  94. package/scripts/private-overlay.mjs +248 -0
  95. package/scripts/product-integrity-contract.mjs +1 -1
  96. package/scripts/proxy/claude-proxied.sh +6 -0
  97. package/scripts/proxy/proxy-revert.sh +5 -0
  98. package/scripts/proxy/proxy-up.sh +6 -0
  99. package/scripts/proxy/proxy-verify.mjs +4 -0
  100. package/scripts/public-inputs.mjs +409 -0
  101. package/scripts/public-verification-inputs.mjs +112 -26
  102. package/scripts/public-verification-lane.mjs +1 -1
  103. package/scripts/published-surface-probe.mjs +34 -4
  104. package/scripts/qe/card-lane-gate.mjs +16 -1
  105. package/scripts/qe/session-start-gate.mjs +16 -1
  106. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  107. package/scripts/record-lesson.mjs +4 -1
  108. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  109. package/scripts/release-abort-stale.mjs +5 -1
  110. package/scripts/release-authority.mjs +104 -12
  111. package/scripts/release-channel-kind.mjs +86 -0
  112. package/scripts/release-convergence-watchdog.mjs +7 -2
  113. package/scripts/release-projection.mjs +177 -72
  114. package/scripts/release-transaction-provider.mjs +47 -10
  115. package/scripts/release-transaction.mjs +40 -11
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -4,6 +4,7 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { canonicalJson } from './coverage-integrity.mjs';
7
+ import { isCorpusReleaseTag, latestCodeReleaseTag } from './release-channel-kind.mjs';
7
8
  import {
8
9
  pollObservation, RECEIPT_PREFIX, receiptDisposition, transactionIdFor,
9
10
  } from './release-transaction.mjs';
@@ -16,6 +17,15 @@ const command = (name, args, options = {}) => execFileSync(name, args, {
16
17
  encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 30_000, ...options,
17
18
  }).trim();
18
19
  const json = (name, args, options) => JSON.parse(command(name, args, options));
20
+ // ADR-086 S1: `releases/latest` is the customer download pointer and is a corpus generation on any
21
+ // night a corpus round shipped. Every question this provider asks is about the CODE generation, so
22
+ // it must resolve the latest CODE release rather than read a pointer that now answers differently.
23
+ const latestCodeTag = () => latestCodeReleaseTag(json('gh', ['api', `repos/${REPO}/releases?per_page=30`]));
24
+ // Two DIFFERENT questions, deliberately: latestCodeTag() is "which CODE generation is newest" (a
25
+ // corpus generation may hold the pointer — designed steady state); githubPointerTag() is "what does
26
+ // GitHub's releases/latest actually point at right now". The publish-github-nonlatest gate needs the
27
+ // second and was reading the first, which made it unsatisfiable for every freshly published release.
28
+ const githubPointerTag = () => maybe(() => json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name, null);
19
29
  const maybe = (callback, fallback = null) => {
20
30
  try { return callback(); } catch { return fallback; }
21
31
  };
@@ -74,14 +84,26 @@ const sha256File = (file) => {
74
84
  return hash.digest('hex');
75
85
  };
76
86
  const assetDigest = (asset) => withTempAsset(asset, sha256File);
87
+ // 4.3.25 run 35056636514: `npm publish` succeeded at ~04:47:31Z and the registry first showed the
88
+ // version at 04:53:40Z (time.modified) — roughly six minutes. The poll gave up at 156s, the job
89
+ // reported FAILURE, and an already-published release was left half-promoted with npm and GitHub
90
+ // naming different generations (the exact #77 split). The action was never the problem; the
91
+ // deadline was shorter than npm's propagation for this package. Ten minutes covers the observed
92
+ // ~6 with margin; the env overrides remain for a runner that knows better.
77
93
  const OBSERVATION_POLICY = {
78
- maxElapsedMs: Number(process.env.RUVNET_NPM_VISIBILITY_TIMEOUT_MS || 180_000),
79
- maxAttempts: Number(process.env.RUVNET_NPM_VISIBILITY_ATTEMPTS || 14),
94
+ maxElapsedMs: Number(process.env.RUVNET_NPM_VISIBILITY_TIMEOUT_MS || 600_000),
95
+ maxAttempts: Number(process.env.RUVNET_NPM_VISIBILITY_ATTEMPTS || 40),
80
96
  initialDelayMs: 1_000,
81
97
  maxDelayMs: 15_000,
82
98
  multiplier: 1.8,
83
99
  jitter: (delay) => Math.floor(Math.random() * Math.min(1_000, delay * 0.2)),
84
100
  };
101
+ // The one command in this file that mutates a public channel gets its stderr in the job log and a
102
+ // timeout sized for a real registry upload. command()'s defaults — stderr piped and dropped, 30s —
103
+ // meant 4.3.25's first run left NO diagnostic between "stage-npm" and the deadline error, and a slow
104
+ // registry would have killed a legitimate publish mid-upload. Named, so the publish call stays one
105
+ // pinnable line for tests/unit/release-lineage + publication-receipt-wiring (one publisher, one tag).
106
+ const PUBLISH_COMMAND_OPTIONS = Object.freeze({ stdio: ['ignore', 'pipe', 'inherit'], timeout: 600_000 });
85
107
 
86
108
  export function selectCurrentReleaseBytes({ remoteBytes, localBytes, generatedBytes, identity }) {
87
109
  const bytes = remoteBytes || localBytes || generatedBytes;
@@ -216,7 +238,7 @@ export function liveReleaseProvider({ root = process.cwd() } = {}) {
216
238
  }
217
239
  }
218
240
  const npmLatest = command('npm', ['view', `${PACKAGE}@latest`, 'version']);
219
- const githubLatest = json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name;
241
+ const githubLatest = latestCodeTag();
220
242
  const legacySettled = [];
221
243
  const pending = [];
222
244
  for (const { receipt, release } of latestByTransaction.values()) {
@@ -257,7 +279,8 @@ export function liveReleaseProvider({ root = process.cwd() } = {}) {
257
279
  async observeSnapshot(identity, draft = activeDraft, { forceAssets = false } = {}) {
258
280
  try {
259
281
  const release = draft?.id ? hydratedRelease(releaseById(draft.id)) : null;
260
- const latestTag = json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name;
282
+ const latestTag = latestCodeTag();
283
+ const pointerTag = githubPointerTag();
261
284
  const candidate = maybe(() => json('npm', ['view', `${PACKAGE}@candidate-v${identity.version}`, '--json']), null);
262
285
  const exactVersion = maybe(() => json('npm', ['view', `${PACKAGE}@${identity.version}`, '--json']), null);
263
286
  const latestVersion = command('npm', ['view', `${PACKAGE}@latest`, 'version']);
@@ -290,8 +313,10 @@ export function liveReleaseProvider({ root = process.cwd() } = {}) {
290
313
  published: release.draft === false,
291
314
  latest: latestTag === identity.tag,
292
315
  latestTag,
316
+ // What releases/latest actually points at — distinct from `latest` (code recency) above.
317
+ pointerTag,
293
318
  assetsExact: assetsExactFor(release, identity, forceAssets),
294
- } : { tag: null, sha: null, draft: false, published: false, latest: false, assetsExact: false },
319
+ } : { tag: null, sha: null, draft: false, published: false, latest: false, pointerTag, assetsExact: false },
295
320
  publicReceiptExact,
296
321
  publicHostsExact,
297
322
  };
@@ -395,7 +420,7 @@ export function liveReleaseProvider({ root = process.cwd() } = {}) {
395
420
  command('npm', ['dist-tag', 'add', `${PACKAGE}@${identity.version}`, `candidate-v${identity.version}`]);
396
421
  return;
397
422
  }
398
- command('npm', ['publish', packagePath, '--tag', `candidate-v${identity.version}`]);
423
+ command('npm', ['publish', packagePath, '--tag', `candidate-v${identity.version}`], PUBLISH_COMMAND_OPTIONS);
399
424
  },
400
425
 
401
426
  async observeNpmCandidate(identity) {
@@ -409,7 +434,10 @@ export function liveReleaseProvider({ root = process.cwd() } = {}) {
409
434
 
410
435
  async observeGithub(identity) {
411
436
  const release = json('gh', ['api', `repos/${REPO}/releases/tags/${identity.tag}`]);
412
- const latest = maybe(() => json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name, null);
437
+ // "Is this release the current CODE generation", not "does it hold the latest pointer". Once a
438
+ // corpus generation takes the pointer — which is the designed steady state — a genuinely
439
+ // published code release would otherwise start observing itself as no longer latest.
440
+ const latest = maybe(() => latestCodeTag(), null);
413
441
  return { tag: release.tag_name, sha: tagSha(identity.tag, root), latest: latest === identity.tag };
414
442
  },
415
443
 
@@ -424,14 +452,23 @@ export function liveReleaseProvider({ root = process.cwd() } = {}) {
424
452
  return { version: command('npm', ['view', `${PACKAGE}@latest`, 'version']) };
425
453
  },
426
454
  async makeGithubLatest(draft, identity, expectedPrior) {
427
- const current = json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name;
455
+ // The prior-generation check is about the CODE lineage: it exists to catch "someone promoted a
456
+ // different product generation while this transaction was in flight". A corpus generation
457
+ // legitimately holding `releases/latest` is NOT that — it is the designed steady state between
458
+ // code releases — so the comparison is made against the latest CODE release, and the pointer's
459
+ // own kind is recorded rather than treated as a conflict.
460
+ const pointer = githubPointerTag();
461
+ const current = latestCodeTag();
428
462
  if (current !== expectedPrior && current !== identity.tag) {
429
- throw new Error(`refusing GitHub promotion: latest is ${current}, expected ${expectedPrior}`);
463
+ throw new Error(`refusing GitHub promotion: latest code release is ${current}, expected ${expectedPrior}`);
464
+ }
465
+ if (isCorpusReleaseTag(pointer)) {
466
+ process.stdout.write(`[release] promoting over corpus generation ${pointer} — the code bundle carries its own corpus\n`);
430
467
  }
431
468
  command('gh', ['api', '-X', 'PATCH', `repos/${REPO}/releases/${draft.id}`, '-f', 'make_latest=true']);
432
469
  },
433
470
  async observeGithubLatest() {
434
- return { tag: json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name };
471
+ return { tag: latestCodeTag() };
435
472
  },
436
473
  async restoreNpmLatest(prior, expected) {
437
474
  const current = command('npm', ['view', `${PACKAGE}@latest`, 'version']);
@@ -194,6 +194,25 @@ const npmCandidateExact = (snapshot, identity) => snapshot?.npm?.candidateVersio
194
194
  const npmLatestIsB = (snapshot, identity) => snapshot?.npm?.latestVersion === identity.version;
195
195
  const githubIsB = (snapshot, identity) => snapshot?.github?.sha === identity.candidateSha
196
196
  && snapshot?.github?.tag === identity.tag;
197
+ // TWO DIFFERENT QUESTIONS ABOUT "LATEST", ANSWERED BY TWO DIFFERENT FIELDS (4.3.25, 2026-09-16).
198
+ //
199
+ // `snapshot.github.latest` is "is this the newest CODE release" — observeGithub derives it from
200
+ // latestCodeReleaseTag(), deliberately, because per ADR-086 S1 a corpus generation may legitimately
201
+ // hold GitHub's releases/latest pointer and a code release must not observe itself as demoted.
202
+ // `snapshot.github.pointerTag` is what releases/latest actually points at.
203
+ //
204
+ // Every promotion, recovery, verification and abort check in this file wants the SECOND question:
205
+ // "has the pointer been moved to B yet". Ten sites read the first. A freshly published code release
206
+ // is ALWAYS the newest code release, so `!latest` was unsatisfiable (publish gate, resume clause —
207
+ // the transaction wedged at github-promote-intent), `latest` was trivially true (promotion looked
208
+ // already done, the pointer never moved, converge and finalize passed with it unmoved), and abort
209
+ // refused because "latest" had moved. Ask the pointer. Snapshots without one (recorded receipts,
210
+ // older providers) fall back to code recency so history stays interpretable.
211
+ const githubPointerIsB = (snapshot, identity) => (snapshot?.github?.pointerTag !== undefined
212
+ ? snapshot.github.pointerTag === identity.tag
213
+ : snapshot?.github?.latest === true);
214
+ const githubPointerTag = (snapshot) => (snapshot?.github?.pointerTag !== undefined
215
+ ? snapshot.github.pointerTag : snapshot?.github?.latestTag);
197
216
 
198
217
  /**
199
218
  * Pure, total next-action selector. Receipt history is an audit chain, never permission to skip a
@@ -206,7 +225,7 @@ export function reduceReleaseState({ lastReceipt, snapshot, identity, prior }) {
206
225
  const npmB = npmLatestIsB(snapshot, identity);
207
226
  const githubB = githubIsB(snapshot, identity);
208
227
  const githubPublished = githubB && snapshot.github?.published === true;
209
- const githubLatest = githubPublished && snapshot.github?.latest === true;
228
+ const githubLatest = githubPublished && githubPointerIsB(snapshot, identity);
210
229
 
211
230
  if (snapshot.npm?.candidateVersion === identity.version && snapshot.npm?.candidateIntegrity
212
231
  && snapshot.npm.candidateIntegrity !== identity.packageIntegrity) {
@@ -338,8 +357,11 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
338
357
  await transition('prepared', { recovered: true });
339
358
  continue;
340
359
  }
360
+ // 4.3.25 run 35109036116: run 2 died after writing github-promote-intent, run 3 found the draft
361
+ // published, the reducer asked for promote-npm — not a legal edge from here. This clause was the
362
+ // only legal exit and its `!latest` was unsatisfiable. Wedged.
341
363
  if (current.state === 'github-promote-intent' && githubIsB(snapshot, identity)
342
- && snapshot.github?.published && !snapshot.github?.latest) {
364
+ && snapshot.github?.published && !githubPointerIsB(snapshot, identity)) {
343
365
  await transition('github-promoted-nonlatest', { github: snapshot.github, recovered: true });
344
366
  continue;
345
367
  }
@@ -348,7 +370,7 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
348
370
  continue;
349
371
  }
350
372
  if (current.state === 'github-latest-intent' && npmLatestIsB(snapshot, identity)
351
- && githubIsB(snapshot, identity) && snapshot.github?.latest) {
373
+ && githubIsB(snapshot, identity) && githubPointerIsB(snapshot, identity)) {
352
374
  await transition('defaults-promoted', { npm: snapshot.npm, github: snapshot.github, recovered: true });
353
375
  continue;
354
376
  }
@@ -357,7 +379,7 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
357
379
  throw new Error('npm compensation recovered; resume the same transaction');
358
380
  }
359
381
  if (current.state === 'finalize-intent' && npmCandidateExact(snapshot, identity) && npmLatestIsB(snapshot, identity)
360
- && githubIsB(snapshot, identity) && snapshot.github?.published && snapshot.github?.latest
382
+ && githubIsB(snapshot, identity) && snapshot.github?.published && githubPointerIsB(snapshot, identity)
361
383
  && snapshot.github?.assetsExact) {
362
384
  return append('channels-converged', { verdict: 'PUBLISHED_NOT_VERIFIED', recovered: true,
363
385
  npm: snapshot.npm, github: snapshot.github });
@@ -425,7 +447,10 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
425
447
  await transition('github-promote-intent');
426
448
  await adapter.publishDraftNonLatest(draft, identity);
427
449
  const observed = await adapter.observeSnapshot(identity, draft);
428
- if (!(githubIsB(observed, identity) && observed.github.published && !observed.github.latest)) {
450
+ // Published as B, and the POINTER has not moved to B yet — it is promoted later, in
451
+ // make-github-latest. 4.3.25 run 35057738103 published this correctly and then threw here on
452
+ // `!latest`, which a just-published code release can never satisfy.
453
+ if (!(githubIsB(observed, identity) && observed.github.published && !githubPointerIsB(observed, identity))) {
429
454
  throw new Error('GitHub non-latest publication not observed');
430
455
  }
431
456
  await transition('github-promoted-nonlatest', { github: observed.github });
@@ -446,7 +471,7 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
446
471
  await transition('github-latest-intent');
447
472
  await adapter.makeGithubLatest(draft, identity, prior.githubLatest);
448
473
  const observed = await adapter.observeSnapshot(identity, draft);
449
- if (!(npmLatestIsB(observed, identity) && observed.github?.latest && githubIsB(observed, identity))) {
474
+ if (!(npmLatestIsB(observed, identity) && githubPointerIsB(observed, identity) && githubIsB(observed, identity))) {
450
475
  throw new Error('provider defaults not jointly observed at candidate B');
451
476
  }
452
477
  await transition('defaults-promoted', { npm: observed.npm, github: observed.github });
@@ -469,7 +494,7 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
469
494
  }
470
495
  await transition('finalize-intent');
471
496
  const reobserved = await adapter.observeSnapshot(identity, draft, { forceAssets: true });
472
- if (!(npmLatestIsB(reobserved, identity) && reobserved.github?.latest && githubIsB(reobserved, identity))) {
497
+ if (!(npmLatestIsB(reobserved, identity) && githubPointerIsB(reobserved, identity) && githubIsB(reobserved, identity))) {
473
498
  throw new Error('provider defaults drifted before channel convergence');
474
499
  }
475
500
  if (!reobserved.github?.assetsExact || !npmCandidateExact(reobserved, identity)) {
@@ -491,8 +516,12 @@ export async function abortReleaseTransaction({ identity, receipts, reason, auth
491
516
  }
492
517
  const snapshot = await adapter.observeSnapshot(identity);
493
518
  const prior = chain[0]?.observation?.prior;
494
- if (!prior || snapshot.npm?.latestVersion === identity.version || snapshot.github?.latest === true
495
- || snapshot.npm?.latestVersion !== prior.npmLatest || snapshot.github?.latestTag !== prior.githubLatest) {
519
+ // "Both defaults still at the captured prior generation" is a question about the npm `latest`
520
+ // dist-tag and GitHub's releases/latest POINTER. A published-but-not-promoted candidate is the
521
+ // newest CODE release, so the old `latest === true` / `latestTag !== prior` reads refused to abort
522
+ // exactly the transaction that most needs aborting: one wedged after publish, before promotion.
523
+ if (!prior || snapshot.npm?.latestVersion === identity.version || githubPointerIsB(snapshot, identity)
524
+ || snapshot.npm?.latestVersion !== prior.npmLatest || githubPointerTag(snapshot) !== prior.githubLatest) {
496
525
  throw new Error('release abort cannot prove both defaults at captured prior generation');
497
526
  }
498
527
  const receipt = stateReceipt({
@@ -550,7 +579,7 @@ export async function finalizeReleaseTransaction({
550
579
  }
551
580
  const snapshot = await adapter.observeSnapshot(identity, discovered.matchingDrafts?.[0], { forceAssets: true });
552
581
  if (snapshot.readError || !npmLatestIsB(snapshot, identity) || !npmCandidateExact(snapshot, identity)
553
- || !githubIsB(snapshot, identity) || snapshot.github?.published !== true || snapshot.github?.latest !== true
582
+ || !githubIsB(snapshot, identity) || snapshot.github?.published !== true || !githubPointerIsB(snapshot, identity)
554
583
  || snapshot.github?.assetsExact !== true) throw new Error('public channels drifted before install verification');
555
584
  const materialized = await adapter.materializePublicVerificationAggregate({ identity, aggregate });
556
585
  const aggregateAssetSha256 = crypto.createHash('sha256').update(canonicalJson(aggregate)).digest('hex');
@@ -642,7 +671,7 @@ export async function abandonPublicVerificationTransaction({ identity, expected,
642
671
  }
643
672
  const snapshot = await adapter.observeSnapshot(identity, discovered.matchingDrafts?.[0], { forceAssets: true });
644
673
  if (snapshot.readError || !npmLatestIsB(snapshot, identity) || !npmCandidateExact(snapshot, identity)
645
- || !githubIsB(snapshot, identity) || snapshot.github?.published !== true || snapshot.github?.latest !== true
674
+ || !githubIsB(snapshot, identity) || snapshot.github?.published !== true || !githubPointerIsB(snapshot, identity)
646
675
  || snapshot.github?.assetsExact !== true
647
676
  || canonicalJson(snapshot.npm) !== canonicalJson(current.observation.npm)
648
677
  || canonicalJson(snapshot.github) !== canonicalJson(current.observation.github)) {
@@ -35,6 +35,29 @@ import { materializePublicationHandoff, resolvePublicationHandoffPaths } from '.
35
35
  import { liveReleaseProvider } from './release-transaction-provider.mjs';
36
36
  import { stagedHostVerifier } from './staged-host-verifier.mjs';
37
37
  import { verifyPayload } from './release-payload.mjs';
38
+ import { verifyCorpusReceipt } from './corpus-candidate.mjs';
39
+ import { readDiagnosticAccuracyReport } from './oracle/retrieval-accuracy.mjs';
40
+ import { loadFixture, readRecallReport } from './oracle/repo-recall.mjs';
41
+
42
+ /**
43
+ * The measured retrieval numbers, stated in the release notes themselves rather than left behind a
44
+ * digest. Both halves go in together on purpose: the number that qualified the release, and the one
45
+ * it did NOT meet. A reader who sees only the first would reasonably assume the second was fine.
46
+ */
47
+ const recallNotes = (receipt) => {
48
+ const r = receipt.recallSummary;
49
+ if (!r) return [];
50
+ return [
51
+ `Retrieval (blocking): ${r.repositoriesAnswering}/${r.questions} repositories answer a real question`
52
+ + ` about themselves from their own content; ${r.exactFileTop5}/${r.questions} return the exact`
53
+ + ` labeled file in the top 5 (floor ${r.floor}).`,
54
+ 'NOT measured: generated-answer correctness, citation support, or unscoped whole-corpus discovery.',
55
+ `ADR-086 C3 was NOT met and is NOT claimed — its measurement ships as ${receipt.accuracyReport.file}`
56
+ + ' for inspection.',
57
+ ];
58
+ };
59
+ import { verifyBundle } from './verify-bundle.mjs';
60
+ import { CORPUS_GENERATION_FIELD, evaluateCorpusPromotion } from './corpus-promotion.mjs';
38
61
 
39
62
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
40
63
  const PUBLISH = process.argv.includes('--publish');
@@ -83,7 +106,7 @@ function corpusFailure(message) {
83
106
  throw new Error(`[corpus-seed] ${message}`);
84
107
  }
85
108
 
86
- export function runProtectedCorpusSeed({
109
+ export async function runProtectedCorpusSeed({
87
110
  argv = process.argv.slice(2),
88
111
  env = process.env,
89
112
  root = ROOT,
@@ -94,6 +117,13 @@ export function runProtectedCorpusSeed({
94
117
  corpusFailure(environmentFailures.join('; '));
95
118
  }
96
119
 
120
+ // The corpus route may never enter product publication. This is belt to the workflow's braces: the
121
+ // corpus job binds an environment that holds no NPM_TOKEN at all, so npm is unreachable from it by
122
+ // construction; this refuses the combined invocation outright so the two modes can never share one
123
+ // process even if a future workflow edit put them in the same job.
124
+ if (argv.includes('--publish')) corpusFailure('--corpus-seed cannot be combined with --publish; corpus routing must never enter product publication');
125
+ const promoteLatest = argv.includes('--promote-latest');
126
+
97
127
  const tag = cliArg(argv, '--corpus-tag');
98
128
  const bundleFile = cliArg(argv, '--corpus-bundle');
99
129
  const receiptFile = cliArg(argv, '--corpus-receipt');
@@ -130,11 +160,20 @@ export function runProtectedCorpusSeed({
130
160
  corpusFailure('target must exactly equal HEAD, GITHUB_SHA, and the corpus receipt builderSourceSha');
131
161
  }
132
162
 
133
- const boundaryIdentities = [receipt.privateFence, receipt.eligibilityPolicy, receipt.generationLedger];
163
+ // Schema 3 (ADR-086 Step 15 / A6): the receipt binds the full provenance closure shipped INSIDE
164
+ // the sealed archive (ARCHIVE-MANIFEST.json, PRIVATE-STORES.json, RVF-GENERATIONS.json,
165
+ // SOURCE.json) AND the detached, digest-bound retrieval-accuracy report that measured this exact
166
+ // archive. Schema 2 is refused outright: a schema-2 seed carries no accuracy binding, so it is
167
+ // UNPUBLISHABLE from here forward and data/corpus-seed.json must be re-pointed at a schema-3 seed
168
+ // in the same change that publishes one.
169
+ const boundaryIdentities = [receipt.privateFence, receipt.generationLedger, receipt.sourceManifest, receipt.archiveManifest];
134
170
  const storeBindingsValid = Number.isSafeInteger(receipt.storeCount) && receipt.storeCount > 0
135
171
  && Array.isArray(receipt.stores) && receipt.stores.length === receipt.storeCount
136
172
  && receipt.stores.every((store) => typeof store?.name === 'string' && store.name.length > 0
137
- && /^[a-f0-9]{7,64}$/.test(String(store.sourceCommit || ''))
173
+ && ['repository', 'gist-aggregate', 'derived'].includes(store.kind)
174
+ && (store.kind === 'repository'
175
+ ? /^[a-f0-9]{7,64}$/.test(String(store.sourceCommit || ''))
176
+ : store.sourceCommit === null || /^[a-f0-9]{7,64}$/i.test(String(store.sourceCommit || '')))
138
177
  && typeof store.builtUtc === 'string' && Number.isFinite(Date.parse(store.builtUtc))
139
178
  && typeof store.model === 'string' && store.model.length > 0
140
179
  && Number.isSafeInteger(store.dimensions) && store.dimensions > 0
@@ -146,9 +185,10 @@ export function runProtectedCorpusSeed({
146
185
  const generatorFile = path.join(root, 'scripts/corpus-candidate.mjs');
147
186
  const generatorValid = fs.existsSync(generatorFile)
148
187
  && receipt.generator?.corpusCandidateSha256 === sha256File(generatorFile);
149
- if (receipt.schemaVersion !== 1 || receipt.kind !== 'ruvnet-brain-corpus-candidate'
150
- || !receipt.createdAt || !receipt.coverageGeneration || !storeBindingsValid || !emptyFailureArrays
188
+ if (receipt.schemaVersion !== 3 || receipt.kind !== 'ruvnet-brain-corpus-candidate'
189
+ || !receipt.createdAt || !storeBindingsValid || !emptyFailureArrays
151
190
  || !privateExclusionsValid || !boundaryIdentities.every(exactFileIdentity) || !exactFileIdentity(receipt.archive)
191
+ || !exactFileIdentity(receipt.accuracyReport)
152
192
  || !generatorValid
153
193
  || receipt.archive.file !== path.basename(bundleFile)) {
154
194
  corpusFailure('corpus receipt bindings are incomplete or invalid');
@@ -160,6 +200,98 @@ export function runProtectedCorpusSeed({
160
200
  }
161
201
  if (digestMatch[1] !== archiveSha256) corpusFailure('corpus tag digest does not match the receipt and archive');
162
202
 
203
+ // ADR-086 Step 15's second binding. The detached accuracy report travels beside the archive; this
204
+ // proves (a) the file the receipt names is the file present here, byte for byte, (b) the report
205
+ // was measured against THESE archive bytes, (c) every partition in both query modes passed
206
+ // 20x>=19x with no timeouts and no bounded sampling, and (d) it was produced by the committed
207
+ // benchmark against the committed oracle — so a swapped oracle or a patched benchmark is caught
208
+ // here even though the receipt itself carries only {file, sha256, bytes}.
209
+ const accuracyReportFile = `${bundleFile}.accuracy.json`;
210
+ if (receipt.accuracyReport.file !== path.basename(accuracyReportFile)) {
211
+ corpusFailure('corpus receipt names an accuracy report that is not the one beside this archive');
212
+ }
213
+ if (!fs.existsSync(accuracyReportFile) || !fs.statSync(accuracyReportFile).isFile()) {
214
+ corpusFailure(`detached retrieval-accuracy report missing beside the archive (${path.basename(accuracyReportFile)})`);
215
+ }
216
+ if (sha256File(accuracyReportFile) !== receipt.accuracyReport.sha256
217
+ || fs.statSync(accuracyReportFile).size !== receipt.accuracyReport.bytes) {
218
+ corpusFailure('detached retrieval-accuracy report bytes do not match the corpus receipt');
219
+ }
220
+ const committedOracleFile = path.join(root, 'data/retrieval-accuracy-oracle.json');
221
+ const accuracyGeneratorFile = path.join(root, 'scripts/oracle/retrieval-accuracy.mjs');
222
+ if (!fs.existsSync(committedOracleFile)) corpusFailure('committed retrieval-accuracy oracle is missing from the release checkout');
223
+ if (!fs.existsSync(accuracyGeneratorFile)) corpusFailure('committed retrieval-accuracy benchmark is missing from the release checkout');
224
+ // The BLOCKING retrieval predicate at publication is the frozen-fixture repo-recall gate, read
225
+ // through the same module candidate acceptance used so the two can never drift apart. ADR-086's
226
+ // C3 report still has to exist and still has to be bound to these exact archive bytes — an
227
+ // unbound diagnostic looks like evidence and is worse than none — but its score no longer refuses
228
+ // publication. That reduction is declared in docs/adr/0086 and in the published report itself.
229
+ const archiveIdentity = { file: receipt.archive.file, sha256: archiveSha256, bytes: fs.statSync(bundleFile).size };
230
+ try {
231
+ readDiagnosticAccuracyReport({
232
+ reportFile: accuracyReportFile,
233
+ archive: archiveIdentity,
234
+ expectedOracleSha256: sha256File(committedOracleFile),
235
+ expectedGeneratorSha256: sha256File(accuracyGeneratorFile),
236
+ });
237
+ } catch (error) {
238
+ corpusFailure(`the published C3 diagnostic is not bound to this archive (${error.message})`);
239
+ }
240
+ const recallReportFile = `${bundleFile}.recall.json`;
241
+ if (!fs.existsSync(recallReportFile)) {
242
+ corpusFailure(`detached repo-recall report missing beside the archive (${path.basename(recallReportFile)})`);
243
+ }
244
+ if (!receipt.recallReport
245
+ || sha256File(recallReportFile) !== receipt.recallReport.sha256
246
+ || fs.statSync(recallReportFile).size !== receipt.recallReport.bytes) {
247
+ corpusFailure('detached repo-recall report bytes do not match the corpus receipt');
248
+ }
249
+ try {
250
+ readRecallReport({
251
+ reportFile: recallReportFile,
252
+ archive: archiveIdentity,
253
+ expectedFixtureSha256: loadFixture().fixtureSha256,
254
+ });
255
+ } catch (error) {
256
+ corpusFailure(`retrieval does not qualify this corpus for publication (${error.message})`);
257
+ }
258
+
259
+ // Deep re-verification — moved here 2026-09-13 from the deleted scripts/corpus-seed-publish.mjs
260
+ // (ADR-085). Everything above proves the receipt is well-FORMED and that the archive's outer
261
+ // digest matches it; none of it proves the receipt is TRUE. verifyCorpusReceipt re-extracts the
262
+ // sealed archive and re-derives the entire candidate from its own bytes — per-store file digests,
263
+ // private-store fence, generation ledger, RVF index audit — and requires canonical equality with
264
+ // the receipt. A receipt with a single forged store digest passes every check above and fails
265
+ // here. It runs before any `gh` call so an untrue candidate never reaches the network.
266
+ try {
267
+ await verifyCorpusReceipt({
268
+ receiptFile, bundleFile, accuracyReportFile, recallReportFile, expectedBuilderSha: target, expectedArchiveSha256: archiveSha256,
269
+ });
270
+ } catch (error) {
271
+ corpusFailure(`corpus receipt does not verify against the sealed archive (${error.message})`);
272
+ }
273
+
274
+ // EVERY local proof happens before the first network call. `gh` must never be reached by a
275
+ // candidate that is already known to be unpublishable — that is the same discipline the deep
276
+ // verifyCorpusReceipt above follows, and a customer release with an unusable signature is exactly
277
+ // as unpublishable as an untrue receipt.
278
+ const signatureFile = `${bundleFile}.sig`;
279
+ const digestFile = `${bundleFile}.sha256`;
280
+ const generation = String(receipt.createdAt || '');
281
+ if (promoteLatest) {
282
+ for (const [label, file] of [['detached signature', signatureFile], ['sha256 sidecar', digestFile]]) {
283
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) {
284
+ corpusFailure(`customer corpus promotion requires a ${label} beside the archive (${path.basename(file)} missing) — the updater fails closed without it`);
285
+ }
286
+ }
287
+ // The real verifier, against the trust root that ships inside the npm package. Signing happens in
288
+ // the workflow with the environment-scoped key; this proves the bytes about to be published
289
+ // verify with the key kb/forge-update.mjs actually carries.
290
+ const signature = verifyBundle(bundleFile, signatureFile);
291
+ if (!signature.ok) corpusFailure(`detached signature does not verify against the shipped trust root (${signature.reason})`);
292
+ if (!Number.isFinite(Date.parse(generation))) corpusFailure('corpus receipt createdAt is not a readable generation timestamp');
293
+ }
294
+
163
295
  const viewArgs = ['release', 'view', tag, '--json', 'tagName', '--repo', repo];
164
296
  const ghCommand = env.RUVNET_GH_COMMAND || 'gh';
165
297
  const ghPrefix = env.RUVNET_GH_SCRIPT ? [env.RUVNET_GH_SCRIPT] : [];
@@ -168,33 +300,136 @@ export function runProtectedCorpusSeed({
168
300
  const viewError = String(view.error?.message || view.stderr || view.stdout || '');
169
301
  if (!/(release not found|no release found)/i.test(viewError)) corpusFailure(`cannot prove ${tag} is absent (${viewError.trim() || `gh exited ${view.status}`})`);
170
302
 
303
+ const receiptSha256 = sha256File(receiptFile);
304
+ const gh = (args) => run(ghCommand, [...ghPrefix, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
305
+
306
+ if (!promoteLatest) {
307
+ // BOOTSTRAP/RECOVERY seeds stay exactly as ADR-086's original contract left them: an immutable
308
+ // prerelease that never touches releases/latest. Dual's C4 resolution (S1) narrows the change to
309
+ // CUSTOMER releases — "Bootstrap-only releases may remain prereleases."
310
+ const notes = [
311
+ 'Content-addressed RuvNet Brain corpus seed.',
312
+ `Archive SHA-256: ${archiveSha256}`,
313
+ `Receipt SHA-256: ${receiptSha256}`,
314
+ `Accuracy report SHA-256: ${receipt.accuracyReport.sha256}`,
315
+ `Recall report SHA-256: ${receipt.recallReport.sha256}`,
316
+ ...recallNotes(receipt),
317
+ `Stores: ${receipt.storeCount}`,
318
+ `Builder source SHA: ${receipt.builderSourceSha}`,
319
+ 'This published prerelease is immutable and must never be replaced.',
320
+ ].join('\n');
321
+ // The detached accuracy report ships AS AN ASSET. Without it a downloader holds an archive it
322
+ // cannot re-verify — "reverify the downloaded final artifact against the measured identity"
323
+ // requires the measurement to travel with the artifact it measured.
324
+ const createArgs = [
325
+ 'release', 'create', tag,
326
+ '--prerelease', '--latest=false',
327
+ '--target', target,
328
+ '--repo', repo,
329
+ '--title', `Immutable corpus seed ${archiveSha256.slice(0, 16)}`,
330
+ '--notes', notes,
331
+ bundleFile, receiptFile, accuracyReportFile, recallReportFile,
332
+ ];
333
+ const create = gh(createArgs);
334
+ if (create.error || create.status !== 0) {
335
+ corpusFailure(`protected corpus publication failed (${String(create.error?.message || create.stderr || create.stdout || '').trim()})`);
336
+ }
337
+ return { tag, target, repository: repo, archiveSha256, receiptSha256, promoted: false };
338
+ }
339
+
340
+ // ── CUSTOMER CORPUS RELEASE (ADR-086 step 17 / C4 resolution S1) ───────────────────────────────
341
+ // The old path was invisible AND unusable to a customer, for two independent reasons, and fixing
342
+ // only one leaves the channel dead. `--prerelease --latest=false` means kb/forge-update.mjs's
343
+ // releases/latest poll never sees it; and with no detached .sig the updater fails closed anyway
344
+ // (kb/forge-update.mjs:1275 fetches `${url}.sig`, :1284-1285 exits 4 when verification fails).
345
+ // scripts/verify-channels.mjs checks exactly these two things (checks 3 and 4) against the live
346
+ // endpoints, and is the owner's post-publish acceptance gate.
347
+ const latestView = gh(['release', 'view', '--json', 'tagName,body', '--repo', repo]);
348
+ let currentLatest = null;
349
+ if (!latestView.error && latestView.status === 0) {
350
+ try { currentLatest = JSON.parse(String(latestView.stdout || 'null')); }
351
+ catch (error) { corpusFailure(`cannot read the current latest release (${error.message})`); }
352
+ if (!currentLatest || typeof currentLatest.tagName !== 'string') corpusFailure('current latest release carries no tag name');
353
+ } else {
354
+ const latestError = String(latestView.error?.message || latestView.stderr || latestView.stdout || '');
355
+ if (!/(release not found|no release found)/i.test(latestError)) {
356
+ corpusFailure(`cannot determine the current latest release (${latestError.trim() || `gh exited ${latestView.status}`})`);
357
+ }
358
+ }
359
+ const promotion = evaluateCorpusPromotion({ tag, generation, currentLatest });
360
+ if (!promotion.allowed) corpusFailure(promotion.reason);
361
+
171
362
  const notes = [
172
- 'Content-addressed RuvNet Brain corpus seed.',
363
+ 'RuvNet Brain corpus generation — signed, content-addressed, and promoted to latest.',
364
+ `${CORPUS_GENERATION_FIELD} ${generation}`,
173
365
  `Archive SHA-256: ${archiveSha256}`,
174
- `Receipt SHA-256: ${sha256File(receiptFile)}`,
366
+ `Receipt SHA-256: ${receiptSha256}`,
175
367
  `Stores: ${receipt.storeCount}`,
176
- `Coverage generation: ${receipt.coverageGeneration}`,
177
- 'This published prerelease is immutable and must never be replaced.',
368
+ `Builder source SHA: ${receipt.builderSourceSha}`,
369
+ `Shipped runtime: ${receipt.archiveManifestReleaseTag}`,
370
+ ...recallNotes(receipt),
371
+ 'Immutable: this tag is the archive digest and must never be replaced.',
178
372
  ].join('\n');
179
- const createArgs = [
373
+
374
+ // ASSETS COMPLETE BEFORE PROMOTION. `gh release create` uploads assets AFTER the release exists, so
375
+ // creating a non-draft release directly opens a window in which releases/latest resolves to a
376
+ // release with no archive — every polling client in that window fails or, worse, half-downloads.
377
+ // Create as a draft (invisible to releases/latest), prove all four assets landed, and only then
378
+ // flip draft off and claim latest in one edit.
379
+ // Both reports ride with every corpus release for the same reason they ride with a seed: a customer
380
+ // (or the next night's dispatcher) that downloads the archive must be able to reverify it against
381
+ // the identity it was actually measured under — the blocking recall gate AND the C3 diagnostic it
382
+ // scored 59.0% on, so nobody has to take either number on trust.
383
+ const assetFiles = [bundleFile, signatureFile, digestFile, receiptFile, accuracyReportFile, recallReportFile];
384
+ const create = gh([
180
385
  'release', 'create', tag,
181
- '--prerelease', '--latest=false',
386
+ '--draft',
182
387
  '--target', target,
183
388
  '--repo', repo,
184
- '--title', `Immutable corpus seed ${archiveSha256.slice(0, 16)}`,
389
+ '--title', `RuvNet Brain corpus ${archiveSha256.slice(0, 16)}`,
185
390
  '--notes', notes,
186
- bundleFile, receiptFile,
187
- ];
188
- const create = run(ghCommand, [...ghPrefix, ...createArgs], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
391
+ ...assetFiles,
392
+ ]);
189
393
  if (create.error || create.status !== 0) {
190
394
  corpusFailure(`protected corpus publication failed (${String(create.error?.message || create.stderr || create.stdout || '').trim()})`);
191
395
  }
192
- return { tag, target, repository: repo, archiveSha256, receiptSha256: sha256File(receiptFile) };
396
+
397
+ const expectedAssets = assetFiles.map((file) => path.basename(file)).sort();
398
+ const draftView = gh(['release', 'view', tag, '--json', 'isDraft,assets', '--repo', repo]);
399
+ if (draftView.error || draftView.status !== 0) corpusFailure('cannot confirm the draft corpus release before promotion');
400
+ let draft;
401
+ try { draft = JSON.parse(String(draftView.stdout || 'null')); }
402
+ catch (error) { corpusFailure(`cannot read the draft corpus release (${error.message})`); }
403
+ const uploaded = (draft?.assets || []).filter((asset) => asset?.state === 'uploaded' && Number.isSafeInteger(asset.size) && asset.size > 0);
404
+ if (draft?.isDraft !== true || JSON.stringify(uploaded.map((asset) => asset.name).sort()) !== JSON.stringify(expectedAssets)) {
405
+ corpusFailure(`refusing to promote an incomplete corpus release; expected ${expectedAssets.join(', ')} fully uploaded on a draft`);
406
+ }
407
+
408
+ const promote = gh(['release', 'edit', tag, '--repo', repo, '--draft=false', '--latest', '--prerelease=false']);
409
+ if (promote.error || promote.status !== 0) {
410
+ corpusFailure(`corpus promotion to latest failed (${String(promote.error?.message || promote.stderr || promote.stdout || '').trim()})`);
411
+ }
412
+
413
+ const finalView = gh(['release', 'view', tag, '--json', 'tagName,isDraft,isLatest,isPrerelease,assets', '--repo', repo]);
414
+ if (finalView.error || finalView.status !== 0) corpusFailure('cannot confirm the promoted corpus release');
415
+ let promoted;
416
+ try { promoted = JSON.parse(String(finalView.stdout || 'null')); }
417
+ catch (error) { corpusFailure(`cannot read the promoted corpus release (${error.message})`); }
418
+ const promotedAssets = (promoted?.assets || []).map((asset) => asset?.name).sort();
419
+ if (promoted?.tagName !== tag || promoted.isDraft !== false || promoted.isLatest !== true
420
+ || promoted.isPrerelease !== false || JSON.stringify(promotedAssets) !== JSON.stringify(expectedAssets)) {
421
+ corpusFailure('corpus release did not reach a complete, non-draft, non-prerelease latest state');
422
+ }
423
+
424
+ return {
425
+ tag, target, repository: repo, archiveSha256, receiptSha256, promoted: true,
426
+ generation, supersededLatest: currentLatest?.tagName || null,
427
+ };
193
428
  }
194
429
 
195
430
  if (CORPUS_SEED) {
196
431
  try {
197
- const result = runProtectedCorpusSeed();
432
+ const result = await runProtectedCorpusSeed();
198
433
  console.log(JSON.stringify({ ok: true, mode: 'corpus-seed', ...result }, null, 2));
199
434
  } catch (error) {
200
435
  console.error(error.message);