session-orchestrator 5.10.0 → 5.11.0

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 (55) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/AGENTS.md +2 -0
  6. package/CHANGELOG.md +32 -0
  7. package/README.md +9 -8
  8. package/SECURITY.md +1 -1
  9. package/agents/code-implementer.md +7 -1
  10. package/docs/agent-authoring.md +2 -0
  11. package/docs/events-schema.md +2 -1
  12. package/docs/fleet-checkin-audit.md +172 -0
  13. package/docs/role-profile-contract.md +139 -0
  14. package/docs/scope-collision-guard.md +3 -0
  15. package/docs/session-config-reference.md +1 -1
  16. package/evals/instruction-ablation/run.mjs +11 -6
  17. package/hooks/_lib/hook-import-set.json +27 -2
  18. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  19. package/hooks/hooks-codex.json +1 -1
  20. package/hooks/hooks.json +1 -1
  21. package/hooks/on-session-end.mjs +8 -1
  22. package/hooks/on-session-start.mjs +7 -0
  23. package/hooks/post-bash-write-verify.mjs +16 -3
  24. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  25. package/hooks/subagent-telemetry.mjs +8 -3
  26. package/hooks/wave-scope-commit-guard.mjs +4 -1
  27. package/package.json +1 -1
  28. package/rules/opt-in-stack/security-web.md +1 -1
  29. package/scripts/emit-session.mjs +13 -0
  30. package/scripts/fleet-checkin-audit.mjs +138 -0
  31. package/scripts/lib/common.mjs +2 -4
  32. package/scripts/lib/context-overhead-format.mjs +57 -0
  33. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  34. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  35. package/scripts/lib/memory-proposals/store.mjs +245 -9
  36. package/scripts/lib/project-hygiene.mjs +37 -0
  37. package/scripts/lib/role-profile/schema.mjs +219 -0
  38. package/scripts/lib/session-lock.mjs +57 -10
  39. package/scripts/lib/session-record-repair.mjs +63 -30
  40. package/scripts/lib/session-schema/constants.mjs +8 -0
  41. package/scripts/lib/session-schema/validator.mjs +33 -0
  42. package/scripts/lib/subagents-schema.mjs +9 -0
  43. package/scripts/lib/telemetry/pricing.mjs +11 -0
  44. package/scripts/measure-context-overhead.sh +4 -21
  45. package/scripts/release.mjs +359 -11
  46. package/scripts/run-quality-gate.mjs +47 -0
  47. package/scripts/validate-wave-scope.mjs +59 -6
  48. package/skills/claude-md-drift-check/checker.mjs +8 -6
  49. package/skills/npm-publish/SKILL.md +3 -3
  50. package/skills/release/SKILL.md +2 -2
  51. package/skills/repo-audit/SKILL.md +1 -1
  52. package/skills/session-end/metrics-collection.md +2 -2
  53. package/skills/session-end/session-metrics-write.md +1 -1
  54. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  55. package/skills/wave-executor/circuit-breaker.md +1 -0
@@ -35,17 +35,25 @@
35
35
  // propagation, and polls the live site. A tag/push
36
36
  // failure skips the tag-dependent GitHub-release and
37
37
  // site phases and returns reconciliation guidance.
38
+ // After the receipt it writes the publish proof
39
+ // .orchestrator/runtime/release-<target>.json (#1537).
40
+ // --reconcile Read-only resume after an accepted upload: from the
41
+ // proof, verify the exact version is served as latest
42
+ // with the checked integrity, then that a consumer
43
+ // download hashes to it. Never publishes, tags or pushes.
38
44
  //
39
45
  // USAGE:
40
46
  // node scripts/release.mjs --check [--json] [--skip-ci]
41
47
  // node scripts/release.mjs --set-version 3.19.0
42
48
  // node scripts/release.mjs --publish [--json]
49
+ // node scripts/release.mjs --reconcile [--target X.Y.Z] [--json]
43
50
  //
44
51
  // EXIT CODES:
45
52
  // 0 success
46
53
  // 1 preflight/check failure (stale surface, missing CHANGELOG entry,
47
54
  // tag/registry collision, mirror behind, dead token, CI not green,
48
- // leakage-gate hit) OR post-publish reconciliation required
55
+ // leakage-gate hit) OR post-publish reconciliation required OR
56
+ // --reconcile not yet installable (pending / failed / proof missing)
49
57
  // 2 system/usage error before the npm receipt (git/npm spawn failure,
50
58
  // missing NPM_TOKEN, unknown flag, --skip-ci combined with --publish)
51
59
  //
@@ -93,6 +101,7 @@ import { fileURLToPath } from 'node:url';
93
101
 
94
102
  import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
95
103
  import { enumerateRepoFiles } from './lib/validate/enumerate-repo-files.mjs';
104
+ import { writeJsonAtomicSync } from './lib/io.mjs';
96
105
 
97
106
  const PACKAGE_NAME = 'session-orchestrator';
98
107
  const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
@@ -856,6 +865,19 @@ function archiveDigest(tarballPath) {
856
865
  return createHash('sha256').update(readFileSync(tarballPath)).digest('hex');
857
866
  }
858
867
 
868
+ /**
869
+ * npm's Subresource-Integrity form of an archive (`sha512-<base64>`), the exact
870
+ * shape npm serves as `dist.integrity` — so the checked bytes can later be
871
+ * compared with what the registry and a consumer download actually carry.
872
+ *
873
+ * @param {string} tarballPath
874
+ * @returns {string}
875
+ */
876
+ export function archiveIntegrity(tarballPath) {
877
+ if (!lstatSync(tarballPath).isFile()) throw new Error('archive is not a regular file');
878
+ return `sha512-${createHash('sha512').update(readFileSync(tarballPath)).digest('base64')}`;
879
+ }
880
+
859
881
  /**
860
882
  * Scan extracted archive bytes, retain that archive for the callback, and always
861
883
  * clean up. Command/scan failures never call the publication callback. The
@@ -895,7 +917,7 @@ export async function withCheckedPackage(repoRoot, callback, {
895
917
  if (scan.status !== 0 || summaries.length !== 1 || Number(summaries[0][1]) < 1 || Number(summaries[0][2]) !== 0 || Number(summaries[0][3]) !== inventory.paths.length) return { ok: false, detail: 'required owner policy / packed content scan failed or incomplete' };
896
918
  if (archiveDigest(tarballPath) !== digest) throw new Error('archive-changed');
897
919
  chmodSync(tarballPath, 0o400);
898
- artifact = { tarballPath, digest };
920
+ artifact = { tarballPath, digest, integrity: archiveIntegrity(tarballPath) };
899
921
  detail = `${inventory.paths.length} packed files, filenames and contents verified`;
900
922
  } catch { return { ok: false, detail: 'packed archive preparation or content verification failed' }; }
901
923
  return { ok: true, detail, value: await callback(artifact) };
@@ -1057,6 +1079,22 @@ export function validateFlags(values) {
1057
1079
  'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
1058
1080
  };
1059
1081
  }
1082
+ // --reconcile is the read-only resume path after an accepted upload (#1537).
1083
+ // Combining it with a mode that writes would blur exactly the separation it
1084
+ // exists for, so every combination is a usage error rather than a precedence rule.
1085
+ if (values.reconcile && (values.publish || values.check || values['set-version'] || values['skip-ci'])) {
1086
+ return {
1087
+ ok: false,
1088
+ code: 2,
1089
+ message: '--reconcile cannot be combined with --publish, --check, --set-version or --skip-ci: it only verifies an already accepted upload.',
1090
+ };
1091
+ }
1092
+ if (values.target !== undefined && !values.reconcile) {
1093
+ return { ok: false, code: 2, message: '--target is only valid with --reconcile.' };
1094
+ }
1095
+ if (values.target !== undefined && !/^\d+\.\d+\.\d+$/.test(values.target)) {
1096
+ return { ok: false, code: 2, message: `invalid --target: ${values.target} (expected X.Y.Z)` };
1097
+ }
1060
1098
  return { ok: true };
1061
1099
  }
1062
1100
 
@@ -1448,15 +1486,271 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1448
1486
  };
1449
1487
  }
1450
1488
 
1489
+ // ---------------------------------------------------------------------------
1490
+ // Accepted -> available -> installable (#1537).
1491
+ //
1492
+ // npm's PUT answers HTTP 202 and the CLI prints the target receipt BEFORE the
1493
+ // version is publicly served: publish-time scanning runs asynchronously and can
1494
+ // hold a version for far longer than any in-process poll should wait (5.10.0
1495
+ // stayed 404 for more than 25 minutes after its receipt). So the receipt is
1496
+ // persisted as a proof file, and `--reconcile` resumes the verification later
1497
+ // from that proof alone. That path is structurally separate from publishing: it
1498
+ // never reaches publish(), tagAndPush(), a push, or an `npm pack` of the repo.
1499
+ // ---------------------------------------------------------------------------
1500
+
1501
+ const RELEASE_PROOF_DIR = join('.orchestrator', 'runtime');
1502
+ const INTEGRITY_RE = /^sha512-[A-Za-z0-9+/]{86}==$/;
1503
+
1504
+ /** Path of the persisted publish proof for `target` (gitignored runtime dir). */
1505
+ export function releaseProofPath(repoRoot, target) {
1506
+ return join(repoRoot, RELEASE_PROOF_DIR, `release-${target}.json`);
1507
+ }
1508
+
1509
+ /**
1510
+ * The evidence `--reconcile` needs to verify an accepted upload later without
1511
+ * the original archive: which bytes were checked (sha256 + npm-style sha512
1512
+ * integrity), when npm issued the receipt, and which commit/tag they belong to.
1513
+ *
1514
+ * @param {{target: string, artifact: {digest: string, integrity: string}, commit: string|null, receiptAt: string}} input
1515
+ */
1516
+ export function buildReleaseProof({ target, artifact, commit, receiptAt }) {
1517
+ return {
1518
+ schema: 1,
1519
+ package: PACKAGE_NAME,
1520
+ target,
1521
+ tag: `v${target}`,
1522
+ commit: commit || null,
1523
+ receiptAt,
1524
+ sha256: artifact.digest,
1525
+ integrity: artifact.integrity,
1526
+ };
1527
+ }
1528
+
1529
+ /**
1530
+ * Persist the proof atomically. Never throws: it runs after the irreversible
1531
+ * receipt, where a write failure must be reported, not turned into a pre-receipt
1532
+ * style abort.
1533
+ *
1534
+ * @returns {{ok: boolean, path: string, detail: string}}
1535
+ */
1536
+ export function writeReleaseProof(repoRoot, proof, { writeImpl = writeJsonAtomicSync } = {}) {
1537
+ const path = releaseProofPath(repoRoot, proof.target);
1538
+ try {
1539
+ const res = writeImpl(path, proof, { tmpPrefix: '.release-proof' });
1540
+ if (res?.ok) return { ok: true, path, detail: `publish proof written to ${relativePath(repoRoot, path)}` };
1541
+ return { ok: false, path, detail: `publish proof NOT written (${res?.error ?? 'unknown error'})` };
1542
+ } catch (err) {
1543
+ return { ok: false, path, detail: `publish proof NOT written (${err?.message ?? err})` };
1544
+ }
1545
+ }
1546
+
1547
+ /**
1548
+ * Read and validate the proof for `target`. A missing or malformed proof is a
1549
+ * refusal — success is never derived from `latest` alone.
1550
+ *
1551
+ * @returns {{ok: true, proof: object, path: string} | {ok: false, path: string, detail: string}}
1552
+ */
1553
+ export function readReleaseProof(repoRoot, target, { readImpl = readFileSync } = {}) {
1554
+ const path = releaseProofPath(repoRoot, target);
1555
+ let proof;
1556
+ try {
1557
+ proof = JSON.parse(readImpl(path, 'utf8'));
1558
+ } catch (err) {
1559
+ const missing = err?.code === 'ENOENT';
1560
+ return { ok: false, path, detail: missing ? `proof missing: ${relativePath(repoRoot, path)} does not exist` : `proof unreadable: ${err?.message ?? err}` };
1561
+ }
1562
+ if (!proof || typeof proof !== 'object' || proof.package !== PACKAGE_NAME || proof.target !== target || !INTEGRITY_RE.test(proof.integrity ?? '') || !/^[0-9a-f]{64}$/.test(proof.sha256 ?? '')) {
1563
+ return { ok: false, path, detail: `proof invalid: ${relativePath(repoRoot, path)} does not describe ${PACKAGE_NAME}@${target} with sha256 + sha512 integrity` };
1564
+ }
1565
+ return { ok: true, proof, path };
1566
+ }
1567
+
1568
+ /**
1569
+ * Judge one `npm view <pkg>@<target> --json` answer against the proof.
1570
+ * A 404 is `pending` — never read as "held" or "blocked": the package token
1571
+ * cannot see npm's internal scan state, so a 404 alone carries no diagnosis.
1572
+ *
1573
+ * `superseded` = exact version + integrity served, but `latest` already points
1574
+ * at a semver-greater release.
1575
+ *
1576
+ * @returns {{state: 'available'|'superseded'|'pending'|'failed', reason: string, detail: string}}
1577
+ */
1578
+ export function evaluateRegistryManifest(view, target, proof) {
1579
+ const raw = (view?.stdout || '').trim();
1580
+ let json;
1581
+ try { json = raw ? JSON.parse(raw) : null; } catch { json = undefined; }
1582
+ if (view?.status !== 0) {
1583
+ if (json?.error?.code === 'E404' || /\bE404\b/.test(`${view?.stderr || ''}`)) {
1584
+ return { state: 'pending', reason: 'not-visible', detail: `registry answers 404 for ${PACKAGE_NAME}@${target} — not yet publicly served; a 404 alone is not evidence of a hold or block` };
1585
+ }
1586
+ return { state: 'pending', reason: 'query-failed', detail: `npm view exited ${view?.status}: ${(json?.error?.summary || view?.stderr || raw).trim().slice(0, 300)}` };
1587
+ }
1588
+ if (json === null) return { state: 'pending', reason: 'not-visible', detail: `registry returned no manifest for ${PACKAGE_NAME}@${target}` };
1589
+ if (!json || typeof json !== 'object' || Array.isArray(json)) return { state: 'pending', reason: 'unparseable', detail: 'npm view output is not a single manifest object' };
1590
+ if (json.version !== target) return { state: 'pending', reason: 'not-visible', detail: `registry manifest reports version ${json.version ?? 'none'}, not ${target}` };
1591
+ const integrity = json.dist?.integrity;
1592
+ if (!integrity) return { state: 'pending', reason: 'integrity-missing', detail: 'registry manifest carries no dist.integrity yet' };
1593
+ if (integrity !== proof.integrity) {
1594
+ return { state: 'failed', reason: 'integrity-mismatch', detail: `registry dist.integrity ${integrity} differs from the checked archive ${proof.integrity}` };
1595
+ }
1596
+ const latest = json['dist-tags']?.latest;
1597
+ if (latest !== target) {
1598
+ // A later release can only have been published after this one was
1599
+ // accepted, so `latest` will never come back to the target. Keeping that
1600
+ // `pending` forever would be a standing false alarm; the exact version and
1601
+ // its bytes are still verified (here, and by the download that follows).
1602
+ if (isNewerRelease(latest, target)) {
1603
+ return { state: 'superseded', reason: 'superseded', detail: `registry serves ${target} with the checked integrity; dist-tags.latest is already the later ${latest}` };
1604
+ }
1605
+ return { state: 'pending', reason: 'latest-not-target', detail: `dist-tags.latest is ${latest ?? 'unset'}, not ${target}` };
1606
+ }
1607
+ return { state: 'available', reason: 'available', detail: `registry serves ${target} as latest with the checked integrity` };
1608
+ }
1609
+
1610
+ /** True when `candidate` is a plain X.Y.Z strictly greater than `target`; prereleases never count. */
1611
+ function isNewerRelease(candidate, target) {
1612
+ const parse = (v) => (/^\d+\.\d+\.\d+$/.test(v ?? '') ? v.split('.').map(Number) : null);
1613
+ const a = parse(candidate);
1614
+ const b = parse(target);
1615
+ if (!a || !b) return false;
1616
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] > b[i];
1617
+ return false;
1618
+ }
1619
+
1451
1620
  /**
1452
- * Publish and return the receipt boundary plus the registry reconciliation
1453
- * result. Pre-receipt failures throw; post-receipt propagation failures return.
1621
+ * Download the published tarball the way a consumer resolves it (`npm pack
1622
+ * <pkg>@<target> --ignore-scripts` into an empty temp dir), hash it, and compare
1623
+ * with the proof. Nothing from the download is extracted or executed.
1624
+ *
1625
+ * @returns {{state: 'installable'|'pending'|'failed', reason: string, detail: string}}
1626
+ */
1627
+ export function checkInstallable(target, proof, deps = {}) {
1628
+ const runImpl = deps.runImpl ?? run;
1629
+ const integrityImpl = deps.integrityImpl ?? archiveIntegrity;
1630
+ const dir = (deps.mkTempImpl ?? (() => mkdtempSync(join(tmpdir(), 'so-release-reconcile-'))))();
1631
+ try {
1632
+ let pack;
1633
+ try {
1634
+ pack = runImpl('npm', ['pack', `${PACKAGE_NAME}@${target}`, '--json', '--ignore-scripts', '--prefer-online', '--pack-destination', dir], { cwd: dir, timeout: 120000 });
1635
+ } catch (err) {
1636
+ return { state: 'pending', reason: 'download-failed', detail: `npm pack ${PACKAGE_NAME}@${target} failed: ${err?.message ?? err}` };
1637
+ }
1638
+ if (pack?.status !== 0) {
1639
+ return { state: 'pending', reason: 'download-failed', detail: `npm pack ${PACKAGE_NAME}@${target} exited ${pack?.status}: ${(pack?.stderr || '').trim().slice(0, 300)}` };
1640
+ }
1641
+ let filename;
1642
+ try {
1643
+ const records = JSON.parse(pack.stdout);
1644
+ filename = Array.isArray(records) && records.length === 1 ? records[0]?.filename : null;
1645
+ } catch { filename = null; }
1646
+ if (typeof filename !== 'string' || basename(filename) !== filename || !filename.endsWith('.tgz')) {
1647
+ return { state: 'pending', reason: 'download-unparseable', detail: 'npm pack output did not name exactly one downloaded tarball' };
1648
+ }
1649
+ let integrity;
1650
+ try { integrity = integrityImpl(join(dir, filename)); } catch (err) {
1651
+ return { state: 'pending', reason: 'download-unreadable', detail: `downloaded tarball unreadable: ${err?.message ?? err}` };
1652
+ }
1653
+ if (integrity !== proof.integrity) {
1654
+ return { state: 'failed', reason: 'integrity-mismatch', detail: `downloaded tarball ${integrity} differs from the checked archive ${proof.integrity}` };
1655
+ }
1656
+ return { state: 'installable', reason: 'installable', detail: `downloaded ${filename} matches the checked archive integrity` };
1657
+ } finally {
1658
+ try { (deps.cleanupImpl ?? rmSync)(dir, { recursive: true, force: true }); } catch { /* temp download dir only */ }
1659
+ }
1660
+ }
1661
+
1662
+ /**
1663
+ * Resume verification of an accepted upload: accepted (proof exists) ->
1664
+ * available (exact version served as latest with the checked integrity) ->
1665
+ * installable (a consumer download hashes to the checked integrity).
1666
+ *
1667
+ * Budget defaults to 20 attempts 60 s apart (19 min between first and last
1668
+ * query): npm's changelog (2026-07-28, read 2026-10-08) names ~5 min typical
1669
+ * and "up to 15 minutes or more" for publish-time scanning, without guarantee.
1670
+ * The budget is therefore a bound, not the fix — it is resumable: a `pending`
1671
+ * result is not a failure, the next `--reconcile` starts over from the same
1672
+ * proof. Only an integrity mismatch is `failed`.
1454
1673
  *
1455
1674
  * @param {string} repoRoot
1456
1675
  * @param {string} target
1457
- * @param {{runImpl?: Function, waitImpl?: Function, attempts?: number, delaySeconds?: number}} [deps]
1458
- * @returns {{receipt: {confirmed: boolean, target: string, detail: string}, propagation: ReturnType<typeof waitForRegistryPropagation>}}
1676
+ * @param {{attempts?: number, delaySeconds?: number, runImpl?: Function, waitImpl?: Function, readImpl?: Function, integrityImpl?: Function, mkTempImpl?: Function, cleanupImpl?: Function}} [deps]
1677
+ * @returns {{ok: boolean, state: 'installable'|'superseded'|'pending'|'failed'|'proof-missing', target: string, accepted: boolean, available: boolean, installable: boolean, attempts: number, reason: string, detail: string, proofPath: string}}
1459
1678
  */
1679
+ export function reconcileRelease(repoRoot, target, deps = {}) {
1680
+ const attempts = deps.attempts ?? 20;
1681
+ const delaySeconds = deps.delaySeconds ?? 60;
1682
+ const runImpl = deps.runImpl ?? run;
1683
+ const waitImpl = deps.waitImpl ?? (() => runImpl('sleep', [String(delaySeconds)], { cwd: repoRoot }));
1684
+ const base = { target, accepted: false, available: false, installable: false, attempts: 0 };
1685
+
1686
+ const read = readReleaseProof(repoRoot, target, { readImpl: deps.readImpl });
1687
+ if (!read.ok) {
1688
+ return { ...base, ok: false, state: 'proof-missing', reason: 'proof-missing', detail: read.detail, proofPath: read.path };
1689
+ }
1690
+ const { proof } = read;
1691
+ const done = (state, step, attempt, extra) => ({ ...base, accepted: true, ...extra, ok: state === 'installable' || state === 'superseded', state, attempts: attempt, reason: step.reason, detail: step.detail, proofPath: read.path });
1692
+
1693
+ let last = { reason: 'not-run', detail: 'no attempt ran' };
1694
+ let available = false;
1695
+ for (let attempt = 1; attempt <= attempts; attempt++) {
1696
+ let view;
1697
+ try {
1698
+ view = runImpl('npm', ['view', `${PACKAGE_NAME}@${target}`, '--json'], { cwd: repoRoot, timeout: 60000 });
1699
+ } catch (err) {
1700
+ view = { status: null, stdout: '', stderr: err?.message ?? String(err) };
1701
+ }
1702
+ const manifest = evaluateRegistryManifest(view, target, proof);
1703
+ if (manifest.state === 'failed') return done('failed', manifest, attempt, { available: false });
1704
+ if (manifest.state === 'available' || manifest.state === 'superseded') {
1705
+ available = true;
1706
+ const install = checkInstallable(target, proof, deps);
1707
+ if (install.state === 'installable') {
1708
+ // Superseded keeps its own state (and exit 0): the release's bytes are
1709
+ // verified installable, only the `latest` tag moved on — not a failure,
1710
+ // but worth saying so the operator does not read it as "is latest".
1711
+ if (manifest.state === 'superseded') return done('superseded', { reason: 'superseded', detail: `${manifest.detail}; ${install.detail}` }, attempt, { available: true, installable: true });
1712
+ return done('installable', install, attempt, { available: true, installable: true });
1713
+ }
1714
+ if (install.state === 'failed') return done('failed', install, attempt, { available: true });
1715
+ last = install;
1716
+ } else {
1717
+ available = false;
1718
+ last = manifest;
1719
+ }
1720
+ if (attempt < attempts) {
1721
+ let wait;
1722
+ try { wait = waitImpl({ attempt, delaySeconds }); } catch (err) { wait = { status: null, error: err }; }
1723
+ if (!wait || wait.status !== 0 || wait.error) {
1724
+ return done('pending', { reason: 'wait-failed', detail: `wait failed after attempt ${attempt}/${attempts}; last state: ${last.detail}` }, attempt, { available });
1725
+ }
1726
+ }
1727
+ }
1728
+ return done('pending', { reason: last.reason, detail: `${last.detail} (after ${attempts} attempts; run \`--reconcile --target ${target}\` again later)` }, attempts, { available });
1729
+ }
1730
+
1731
+ /** Print a reconcile result and return the exit code (0 only when installable or superseded). */
1732
+ export function printReconcileResult(result, { json = false, log = console.log, error = console.error } = {}) {
1733
+ if (json) {
1734
+ log(JSON.stringify(result, null, 2));
1735
+ return result.ok ? 0 : 1;
1736
+ }
1737
+ const mark = (flag) => (flag ? 'yes' : 'no');
1738
+ log(`${PACKAGE_NAME}@${result.target}: ${result.state} — accepted ${mark(result.accepted)}, available ${mark(result.available)}, installable ${mark(result.installable)}`);
1739
+ if (result.ok) {
1740
+ log(` ${result.detail}`);
1741
+ if (result.state === 'superseded') log(` Note: ${result.target} is installable but no longer \`latest\` — a later release took the tag.`);
1742
+ return 0;
1743
+ }
1744
+ error(` ${result.reason}: ${result.detail}`);
1745
+ if (result.state === 'pending') {
1746
+ error(` Still pending, not failed. Run \`node scripts/release.mjs --reconcile --target ${result.target}\` again later;`);
1747
+ error(' the owner-visible package state is on npmjs.com (package page / account notifications). Never rerun --publish.');
1748
+ } else if (result.state === 'proof-missing') {
1749
+ error(' Without the publish proof there is nothing to verify the registry bytes against; success is never inferred from `latest`.');
1750
+ }
1751
+ return 1;
1752
+ }
1753
+
1460
1754
  /**
1461
1755
  * The exact `npm publish` invocation: argv plus spawn options.
1462
1756
  *
@@ -1500,16 +1794,45 @@ function publish(repoRoot, target, deps = {}) {
1500
1794
  const call = publishInvocation(repoRoot, tmpRc, deps.artifact);
1501
1795
  return runImpl(call.cmd, call.args, call.opts);
1502
1796
  });
1797
+ return settlePublishReceipt(repoRoot, target, res, { ...deps, runImpl });
1798
+ }
1799
+
1800
+ /**
1801
+ * Everything `publish()` does AFTER `npm publish` returned: judge the receipt,
1802
+ * persist the publish proof, then wait for the registry. Split out of the
1803
+ * unexported publisher so the order "proof before registry wait" is testable
1804
+ * (#1537) — this function receives an npm result and cannot publish anything.
1805
+ * Pre-receipt failures throw; post-receipt propagation failures return.
1806
+ *
1807
+ * @param {string} repoRoot
1808
+ * @param {string} target
1809
+ * @param {{status: number|null, stdout?: string, stderr?: string}} res — the `npm publish` result
1810
+ * @param {{artifact: {digest: string, integrity: string}, runImpl?: Function, waitImpl?: Function, attempts?: number, delaySeconds?: number}} deps
1811
+ * @returns {{receipt: {confirmed: boolean, target: string, detail: string}, propagation: ReturnType<typeof waitForRegistryPropagation>, proof: ReturnType<typeof writeReleaseProof>}}
1812
+ */
1813
+ export function settlePublishReceipt(repoRoot, target, res, deps = {}) {
1814
+ const runImpl = deps.runImpl ?? run;
1503
1815
  const receipt = evaluatePublishReceipt(res, target);
1504
1816
  if (!receipt.confirmed) throw new Error(receipt.detail);
1505
1817
 
1818
+ // Persist the proof BEFORE the registry wait: if this process dies while
1819
+ // waiting, `--reconcile` can still resume from it (#1537). Never throws.
1820
+ let commit = null;
1821
+ try {
1822
+ const head = runImpl('git', ['rev-parse', 'HEAD'], { cwd: repoRoot });
1823
+ if (head.status === 0) commit = head.stdout.trim();
1824
+ } catch { /* commit stays null; the proof is still useful */ }
1825
+ const proof = writeReleaseProof(repoRoot, buildReleaseProof({
1826
+ target, artifact: deps.artifact, commit, receiptAt: new Date().toISOString(),
1827
+ }));
1828
+
1506
1829
  const propagation = waitForRegistryPropagation(repoRoot, target, {
1507
1830
  attempts: deps.attempts,
1508
1831
  delaySeconds: deps.delaySeconds,
1509
1832
  runImpl,
1510
1833
  waitImpl: deps.waitImpl,
1511
1834
  });
1512
- return { receipt, propagation };
1835
+ return { receipt, propagation, proof };
1513
1836
  }
1514
1837
 
1515
1838
  function tagAndPush(repoRoot, target) {
@@ -1603,6 +1926,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
1603
1926
  release: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
1604
1927
  live: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
1605
1928
  propagation,
1929
+ proof: publication.proof,
1606
1930
  reconciliation: [
1607
1931
  { phase: 'tag-and-push', kind: 'failed', detail: err instanceof Error ? err.message : String(err) },
1608
1932
  { phase: 'github-release', kind: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
@@ -1633,6 +1957,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
1633
1957
  release,
1634
1958
  live,
1635
1959
  propagation,
1960
+ proof: publication.proof,
1636
1961
  reconciliation,
1637
1962
  };
1638
1963
  }
@@ -1691,10 +2016,17 @@ export function printPublishOutcome(outcome, target, io = {}) {
1691
2016
  const tagAndPushFailed = outcome.reconciliation.some((item) => item.phase === 'tag-and-push');
1692
2017
 
1693
2018
  log(` + ${PACKAGE_NAME}@${target} — target-confirmed npm receipt.`);
2019
+ if (outcome.proof) {
2020
+ if (outcome.proof.ok) log(` ${outcome.proof.detail}.`);
2021
+ else error(`\nWARN: ${outcome.proof.detail} — \`--reconcile\` cannot verify this release without it.`);
2022
+ }
1694
2023
  if (outcome.propagation.ok) {
1695
2024
  log(` registry verified (${outcome.propagation.detail}).`);
1696
2025
  } else {
1697
- error(`\nRECONCILIATION: registry propagation is not yet verified — ${outcome.propagation.detail}`);
2026
+ // An accepted upload can stay unserved for many minutes while npm's
2027
+ // publish-time checks run (#1537) — this is not a short propagation delay.
2028
+ error(`\nRECONCILIATION: registry availability is not yet verified — ${outcome.propagation.detail}`);
2029
+ error(` npm accepted the upload; resume verification with: node scripts/release.mjs --reconcile --target ${target}`);
1698
2030
  }
1699
2031
  if (!tagAndPushFailed) {
1700
2032
  log(` tagged ${outcome.tag} (AFTER publish) and pushed main+tag to: ${outcome.pushed.join(', ')}.`);
@@ -1744,6 +2076,7 @@ export function printPublishOutcome(outcome, target, io = {}) {
1744
2076
  error(` - ${item.phase}${item.kind ? ` (${item.kind})` : ''}: ${item.detail}`);
1745
2077
  }
1746
2078
  error(' Do NOT rerun `--publish`; reconcile the listed post-publish state directly.');
2079
+ if (!outcome.propagation.ok) error(` Registry state: \`node scripts/release.mjs --reconcile --target ${target}\` (read-only, resumable).`);
1747
2080
  return 1;
1748
2081
  }
1749
2082
 
@@ -1923,6 +2256,8 @@ async function main() {
1923
2256
  check: { type: 'boolean', default: false },
1924
2257
  publish: { type: 'boolean', default: false },
1925
2258
  'skip-ci': { type: 'boolean', default: false },
2259
+ reconcile: { type: 'boolean', default: false },
2260
+ target: { type: 'string' },
1926
2261
  json: { type: 'boolean', default: false },
1927
2262
  help: { type: 'boolean', default: false },
1928
2263
  version: { type: 'boolean', default: false },
@@ -1930,12 +2265,14 @@ async function main() {
1930
2265
  });
1931
2266
 
1932
2267
  if (values.help) {
1933
- console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
2268
+ console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish | --reconcile [--target X.Y.Z]] [--skip-ci] [--json]');
1934
2269
  console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
1935
2270
  console.log(' Receipt boundary: before the confirmed npm receipt, failure aborts; after it, never rerun --publish.');
1936
2271
  console.log(' Tag/push failure after receipt skips GitHub-release and site phases and returns reconciliation guidance.');
1937
2272
  console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
1938
- console.log('Exit codes: 0 success, 1 preflight/check failure or post-publish reconciliation, 2 pre-receipt system/usage error.');
2273
+ console.log(' --reconcile read-only resume after an accepted upload: proof -> available -> installable. Never publishes, tags or pushes.');
2274
+ console.log(' --target defaults to package.json version; needs .orchestrator/runtime/release-<target>.json from --publish.');
2275
+ console.log('Exit codes: 0 success (--reconcile: installable or superseded), 1 preflight/check failure, post-publish reconciliation or --reconcile pending/failed/proof missing, 2 pre-receipt system/usage error.');
1939
2276
  return 0;
1940
2277
  }
1941
2278
 
@@ -1951,6 +2288,17 @@ async function main() {
1951
2288
 
1952
2289
  const repoRoot = repoRootOf();
1953
2290
 
2291
+ if (values.reconcile) {
2292
+ // Deliberately before and apart from the --check/--publish branch: no
2293
+ // preflight, no withCheckedPackage (no pack of the repo), no publisher.
2294
+ const target = values.target ?? readPackageVersion(repoRoot);
2295
+ if (!/^\d+\.\d+\.\d+$/.test(target)) {
2296
+ console.error(`invalid package.json version for --reconcile: ${target} (expected X.Y.Z; pass --target)`);
2297
+ return 2;
2298
+ }
2299
+ return printReconcileResult(reconcileRelease(repoRoot, target), { json: values.json });
2300
+ }
2301
+
1954
2302
  if (values['set-version']) {
1955
2303
  const target = values['set-version'];
1956
2304
  if (!/^\d+\.\d+\.\d+$/.test(target)) {
@@ -2002,7 +2350,7 @@ async function main() {
2002
2350
  return checked.value;
2003
2351
  }
2004
2352
 
2005
- console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
2353
+ console.error('Nothing to do — pass --check, --publish, --reconcile, or --set-version X.Y.Z (see --help).');
2006
2354
  return 2;
2007
2355
  }
2008
2356
 
@@ -226,6 +226,49 @@ function failedFilesFromGateStdout(stdout) {
226
226
  return named.length > 0 ? named : null;
227
227
  }
228
228
 
229
+ /**
230
+ * The reporting variants — they exit 0 even after a failing check, so their
231
+ * event NAME never carries the check verdict (#1459). `full-gate` exits non-zero
232
+ * on a failing check, so the name already says it there.
233
+ */
234
+ const REPORTING_VARIANTS = new Set(['baseline', 'incremental', 'per-file']);
235
+
236
+ /**
237
+ * Did any check of a reporting-variant envelope fail? (#1487 point 3)
238
+ *
239
+ * Three states, never two — the same absent-is-not-zero contract as `counts`:
240
+ * - `true` — `typecheck` or `test` carries `status: 'fail'` (a killed command
241
+ * is published as `fail` too, see `runCheck`);
242
+ * - `false` — both command objects are present, neither is stubbed, at least
243
+ * one actually `pass`ed and none failed — the envelope MEASURED
244
+ * that nothing failed;
245
+ * - `null` — the envelope is unparseable or a command object is missing or
246
+ * carries an unknown status, a command is a stub (echo/noop
247
+ * reports `pass` without running), or both were skipped. The
248
+ * caller then OMITS the key: a
249
+ * default `false` would read as "measured green" for a run that
250
+ * measured nothing.
251
+ *
252
+ * Never throws.
253
+ *
254
+ * @param {string} stdout — the gate sub-script's captured stdout.
255
+ * @returns {boolean|null}
256
+ */
257
+ function checkFailedFromGateStdout(stdout) {
258
+ if (typeof stdout !== 'string' || !stdout.trim()) return null;
259
+ let parsed;
260
+ try {
261
+ parsed = JSON.parse(stdout);
262
+ } catch {
263
+ return null;
264
+ }
265
+ const statuses = ['typecheck', 'test'].map((name) => parsed?.[name]?.status);
266
+ if (statuses.includes('fail')) return true;
267
+ if (parsed?.stubbed?.typecheck || parsed?.stubbed?.test) return null;
268
+ if (!statuses.every((st) => st === 'pass' || st === 'skip')) return null;
269
+ return statuses.includes('pass') ? false : null;
270
+ }
271
+
229
272
  /**
230
273
  * Validate and resolve the `--ledger-root` flag (see the telemetry block below).
231
274
  *
@@ -570,12 +613,16 @@ async function main() {
570
613
  // record that run as `passed`. Their exit code stays 0 on purpose: it has
571
614
  // never carried the check verdict there (a failing check exits 0 too).
572
615
  const failed = exitCode !== 0 || killFields.timed_out === true;
616
+ // #1487 point 3 (owner decision, option a): the reporting variants keep
617
+ // `.passed` and their exit code, but carry the check verdict as a field.
618
+ const checkFailed = REPORTING_VARIANTS.has(variant) ? checkFailedFromGateStdout(gateStdout) : null;
573
619
  await emitEvent(
574
620
  `orchestrator.quality_gate.${failed ? 'failed' : 'passed'}`,
575
621
  {
576
622
  variant,
577
623
  exit_code: exitCode,
578
624
  ...killFields,
625
+ ...(checkFailed !== null ? { check_failed: checkFailed } : {}),
579
626
  ...(counts ? { counts } : {}),
580
627
  ...(failedFiles ? { failed_files: failedFiles } : {}),
581
628
  ...(waveNumber !== null ? { wave_number: waveNumber } : {}),