session-orchestrator 5.9.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 (78) 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 +65 -2
  7. package/README.md +11 -9
  8. package/SECURITY.md +1 -1
  9. package/agents/code-implementer.md +7 -1
  10. package/docs/USER-GUIDE.md +2 -0
  11. package/docs/agent-authoring.md +2 -0
  12. package/docs/ci-setup.md +46 -1
  13. package/docs/events-schema.md +37 -6
  14. package/docs/fleet-checkin-audit.md +172 -0
  15. package/docs/owner-config-schema.md +18 -1
  16. package/docs/owner-privacy-patterns.md +85 -0
  17. package/docs/role-profile-contract.md +139 -0
  18. package/docs/scope-collision-guard.md +15 -2
  19. package/docs/session-config-reference.md +1 -1
  20. package/evals/instruction-ablation/run.mjs +11 -6
  21. package/hooks/_lib/hook-import-set.json +27 -2
  22. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  23. package/hooks/enforce-commands.mjs +9 -19
  24. package/hooks/enforce-scope.mjs +23 -42
  25. package/hooks/hooks-codex.json +1 -1
  26. package/hooks/hooks.json +1 -1
  27. package/hooks/on-session-end.mjs +8 -1
  28. package/hooks/on-session-start.mjs +7 -0
  29. package/hooks/post-bash-write-verify.mjs +54 -24
  30. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  31. package/hooks/subagent-telemetry.mjs +8 -3
  32. package/hooks/wave-scope-commit-guard.mjs +4 -1
  33. package/package.json +2 -2
  34. package/rules/opt-in-stack/security-web.md +1 -1
  35. package/scripts/emit-session.mjs +28 -0
  36. package/scripts/fleet-checkin-audit.mjs +138 -0
  37. package/scripts/lib/common.mjs +2 -4
  38. package/scripts/lib/context-overhead-format.mjs +57 -0
  39. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  40. package/scripts/lib/gates/gate-full.mjs +6 -0
  41. package/scripts/lib/gates/gate-helpers.mjs +2 -0
  42. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  43. package/scripts/lib/memory-proposals/store.mjs +245 -9
  44. package/scripts/lib/named-vault-resolver.mjs +1 -1
  45. package/scripts/lib/owner-yaml.mjs +33 -3
  46. package/scripts/lib/platform.mjs +70 -1
  47. package/scripts/lib/pre-push-remote-gate.mjs +107 -0
  48. package/scripts/lib/project-hygiene.mjs +37 -0
  49. package/scripts/lib/quality-gate.mjs +6 -1
  50. package/scripts/lib/redact-spans.mjs +8 -17
  51. package/scripts/lib/role-profile/schema.mjs +219 -0
  52. package/scripts/lib/scope-gate.mjs +68 -5
  53. package/scripts/lib/session-lock.mjs +57 -10
  54. package/scripts/lib/session-record-repair.mjs +63 -30
  55. package/scripts/lib/session-schema/constants.mjs +8 -0
  56. package/scripts/lib/session-schema/validator.mjs +33 -0
  57. package/scripts/lib/subagents-schema.mjs +9 -0
  58. package/scripts/lib/telemetry/pricing.mjs +11 -0
  59. package/scripts/lib/validate/check-owner-leakage.mjs +198 -1015
  60. package/scripts/lib/vault-backfill/template.mjs +1 -1
  61. package/scripts/lib/vault-mirror/render-sessions.mjs +15 -3
  62. package/scripts/lib/vault-status/board-writer.mjs +15 -7
  63. package/scripts/measure-context-overhead.sh +4 -21
  64. package/scripts/migrate-vault-paths.mjs +57 -60
  65. package/scripts/release.mjs +463 -48
  66. package/scripts/run-quality-gate.mjs +47 -0
  67. package/scripts/validate-wave-scope.mjs +59 -6
  68. package/scripts/vault-consolidate.mjs +19 -4
  69. package/skills/claude-md-drift-check/SKILL.md +1 -1
  70. package/skills/claude-md-drift-check/checker.mjs +22 -9
  71. package/skills/npm-publish/SKILL.md +3 -3
  72. package/skills/release/SKILL.md +2 -2
  73. package/skills/repo-audit/SKILL.md +1 -1
  74. package/skills/session-end/metrics-collection.md +28 -4
  75. package/skills/session-end/session-metrics-write.md +1 -1
  76. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  77. package/skills/wave-executor/circuit-breaker.md +1 -0
  78. package/skills/wave-executor/references/wave-loop-review.md +26 -2
@@ -23,7 +23,7 @@
23
23
  // + Unreleased folded, drift sweep, tag collision
24
24
  // (local, origin, github), github/main mirror parity,
25
25
  // npm registry collision, npm token liveness, CI green
26
- // on HEAD, leakage gate over `npm pack --dry-run`.
26
+ // on HEAD, leakage gate over retained archive filenames and contents.
27
27
  // --publish Runs --check first, then token publish via temp
28
28
  // userconfig (NPM_TOKEN from .env.local). A
29
29
  // target-confirmed npm receipt is the irreversible
@@ -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
  //
@@ -80,15 +88,20 @@ import {
80
88
  chmodSync,
81
89
  rmSync,
82
90
  realpathSync,
91
+ mkdirSync,
92
+ lstatSync,
83
93
  } from 'node:fs';
84
- import { join, relative as relativePath, sep } from 'node:path';
94
+ import { join, relative as relativePath, sep, basename, isAbsolute } from 'node:path';
85
95
  import { tmpdir } from 'node:os';
86
96
  import { spawnSync } from 'node:child_process';
97
+ import { createHash } from 'node:crypto';
98
+ import { packedFilename } from './lib/plugin-package-stage.mjs';
87
99
  import { parseArgs } from 'node:util';
88
100
  import { fileURLToPath } from 'node:url';
89
101
 
90
102
  import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
91
103
  import { enumerateRepoFiles } from './lib/validate/enumerate-repo-files.mjs';
104
+ import { writeJsonAtomicSync } from './lib/io.mjs';
92
105
 
93
106
  const PACKAGE_NAME = 'session-orchestrator';
94
107
  const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
@@ -828,6 +841,93 @@ export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } =
828
841
  : { ok: true, detail: `${entries} packed entries, 0 leaks` };
829
842
  }
830
843
 
844
+ /** Strict npm 11/12 inventory, preserving the existing filename policy/floor. */
845
+ export function evaluatePackedInventory(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
846
+ if (pack.status !== 0) return { ok: false, detail: 'npm pack failed' };
847
+ try {
848
+ const json = JSON.parse(pack.stdout);
849
+ const records = Array.isArray(json) ? json : json && typeof json === 'object' ? Object.values(json) : [];
850
+ if (records.length !== 1) throw new Error('inventory-shape');
851
+ const record = records[0];
852
+ const filename = packedFilename([record]);
853
+ if (!filename || basename(filename) !== filename || !/^[A-Za-z0-9][A-Za-z0-9._-]*\.tgz$/.test(filename)) throw new Error('archive-name');
854
+ if (!Array.isArray(record.files) || record.files.length > 20000 || record.files.length < minEntries) throw new Error('inventory-floor');
855
+ const paths = record.files.map((entry) => entry?.path);
856
+ if (paths.some((name) => typeof name !== 'string' || !name || name.length > 4096 || isAbsolute(name) || name.split('/').some((part) => !part || part === '.' || part === '..') || (name.includes('\\') || [...name].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127))) || new Set(paths).size !== paths.length) throw new Error('inventory-path');
857
+ const violations = checkLeakage(paths.map((name) => `npm notice 1B ${name}`));
858
+ if (violations.length) return { ok: false, detail: 'forbidden packed filename class: ' + [...new Set(violations.map((v) => v.name))].join(', ') };
859
+ return { ok: true, detail: `${paths.length} packed entries, 0 filename leaks`, record, filename, paths };
860
+ } catch { return { ok: false, detail: 'invalid packed inventory or insufficient entry count' }; }
861
+ }
862
+
863
+ function archiveDigest(tarballPath) {
864
+ if (!lstatSync(tarballPath).isFile()) throw new Error('checked archive is not a regular file');
865
+ return createHash('sha256').update(readFileSync(tarballPath)).digest('hex');
866
+ }
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
+
881
+ /**
882
+ * Scan extracted archive bytes, retain that archive for the callback, and always
883
+ * clean up. Command/scan failures never call the publication callback. The
884
+ * 20,000-entry inventory ceiling matches the scanner; revisit for larger packs.
885
+ */
886
+ export async function withCheckedPackage(repoRoot, callback, {
887
+ runImpl = run, env = process.env, minEntries = MIN_PACKED_ENTRIES, cleanupImpl = rmSync,
888
+ } = {}) {
889
+ const work = mkdtempSync(join(tmpdir(), 'so-release-pack-'));
890
+ try {
891
+ let artifact;
892
+ let detail;
893
+ try {
894
+ const opts = { cwd: repoRoot, env: { ...env, npm_config_loglevel: 'notice' }, timeout: 60000 };
895
+ const pack = runImpl('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', work], opts);
896
+ const inventory = evaluatePackedInventory(pack, { minEntries });
897
+ if (!inventory.ok) return inventory;
898
+ const tarballPath = join(work, inventory.filename);
899
+ const digest = archiveDigest(tarballPath);
900
+ // Refuse links/special members BEFORE extraction and check the file census.
901
+ const members = runImpl('tar', ['-tzf', tarballPath], opts);
902
+ const types = runImpl('tar', ['-tvzf', tarballPath], opts);
903
+ if (members.status !== 0 || types.status !== 0 || types.stdout.trim().split('\n').some((line) => !/^[-d]/.test(line))) throw new Error('archive-list');
904
+ const names = members.stdout.trim().split('\n');
905
+ const files = names.filter((name) => !name.endsWith('/'));
906
+ const expected = new Set(inventory.paths.map((name) => `package/${name}`));
907
+ if (names.some((name) => !name.startsWith('package/') || name.split('/').some((part) => part === '..' || part === '.')) || files.length !== expected.size || new Set(files).size !== files.length || files.some((name) => !expected.has(name))) throw new Error('archive-census');
908
+ const unpacked = join(work, 'unpacked');
909
+ mkdirSync(unpacked);
910
+ const extracted = runImpl('tar', ['-xzf', tarballPath, '-C', unpacked], opts);
911
+ if (extracted.status !== 0) throw new Error('archive-extraction');
912
+ const inventoryPath = join(work, 'inventory.json');
913
+ writeFileSync(inventoryPath, JSON.stringify([inventory.record]), { mode: 0o600 });
914
+ const scanner = fileURLToPath(new URL('./lib/validate/check-owner-leakage.mjs', import.meta.url));
915
+ const scan = runImpl(process.execPath, [scanner, join(unpacked, 'package'), '--require-owner-patterns', '--packed-files', inventoryPath], opts);
916
+ const summaries = [...(scan.stdout || '').matchAll(/^Results: (\d+) passed, (\d+) failed \((\d+) scanned files\)$/gm)];
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' };
918
+ if (archiveDigest(tarballPath) !== digest) throw new Error('archive-changed');
919
+ chmodSync(tarballPath, 0o400);
920
+ artifact = { tarballPath, digest, integrity: archiveIntegrity(tarballPath) };
921
+ detail = `${inventory.paths.length} packed files, filenames and contents verified`;
922
+ } catch { return { ok: false, detail: 'packed archive preparation or content verification failed' }; }
923
+ return { ok: true, detail, value: await callback(artifact) };
924
+ } finally {
925
+ // Cleanup must never replace an irreversible receipt or the original error.
926
+ try { cleanupImpl(work, { recursive: true, force: true }); }
927
+ catch { console.error('release: temporary package cleanup failed; release outcome unchanged'); }
928
+ }
929
+ }
930
+
831
931
  /**
832
932
  * Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
833
933
  *
@@ -979,6 +1079,22 @@ export function validateFlags(values) {
979
1079
  'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
980
1080
  };
981
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
+ }
982
1098
  return { ok: true };
983
1099
  }
984
1100
 
@@ -1209,22 +1325,6 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
1209
1325
  add('ci-green-on-head', ciRows.gitlab.ok, ciRows.gitlab.detail);
1210
1326
  add('ci-green-on-head-github', ciRows.github.ok, ciRows.github.detail);
1211
1327
 
1212
- // 7. Leakage gate over the actual pack file list.
1213
- // `npm_config_loglevel` is INHERITED, and `npm pack --dry-run` writes its whole
1214
- // file listing as `npm notice` lines. Any ancestor that ran under `npm run
1215
- // --silent` (the pre-push gate does exactly that) therefore hands this child a
1216
- // silent loglevel and the listing is EMPTY with exit 0 — measured: 818 notice
1217
- // lines normally, 0 under `npm_config_loglevel=silent`. The blind-scan floor
1218
- // turns that into a correct fail-closed verdict, but a red leakage row that
1219
- // means "we could not look" is not the row anyone reads it as. Pin the level
1220
- // rather than inherit it, so the gate's input never depends on its caller.
1221
- const pack = run('npm', ['pack', '--dry-run'], {
1222
- cwd: repoRoot,
1223
- env: { ...process.env, npm_config_loglevel: 'notice' },
1224
- });
1225
- const leakage = evaluateLeakageGate(pack);
1226
- add('leakage-gate', leakage.ok, leakage.detail);
1227
-
1228
1328
  return checks;
1229
1329
  }
1230
1330
 
@@ -1386,15 +1486,271 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1386
1486
  };
1387
1487
  }
1388
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
+
1389
1547
  /**
1390
- * Publish and return the receipt boundary plus the registry reconciliation
1391
- * result. Pre-receipt failures throw; post-receipt propagation failures return.
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
+
1620
+ /**
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`.
1392
1673
  *
1393
1674
  * @param {string} repoRoot
1394
1675
  * @param {string} target
1395
- * @param {{runImpl?: Function, waitImpl?: Function, attempts?: number, delaySeconds?: number}} [deps]
1396
- * @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}}
1397
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
+
1398
1754
  /**
1399
1755
  * The exact `npm publish` invocation: argv plus spawn options.
1400
1756
  *
@@ -1407,7 +1763,7 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1407
1763
  * reads as unconfirmed, `publish()` throws, and `main()` exits 2 = "pre-receipt,
1408
1764
  * safe to rerun" while the registry already holds the version. That is the worst
1409
1765
  * failure this file can produce, and it is the same inherited-silent trap the
1410
- * leakage gate's `npm pack --dry-run` was already pinned against (see preflight
1766
+ * leakage gate's `npm pack --json` was already pinned against (see preflight
1411
1767
  * step 7). The pin makes the receipt independent of the caller's environment.
1412
1768
  *
1413
1769
  * Exported because `publish()` itself deliberately is not (it is the
@@ -1418,10 +1774,11 @@ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
1418
1774
  * @param {string} userconfigPath — the 0600 temp npmrc carrying the token
1419
1775
  * @returns {{cmd: string, args: string[], opts: {cwd: string, env: object}}}
1420
1776
  */
1421
- export function publishInvocation(repoRoot, userconfigPath) {
1777
+ export function publishInvocation(repoRoot, userconfigPath, artifact) {
1778
+ if (!artifact?.tarballPath || !artifact.digest || archiveDigest(artifact.tarballPath) !== artifact.digest) throw new Error('refusing publish without unchanged checked archive');
1422
1779
  return {
1423
1780
  cmd: 'npm',
1424
- args: ['publish', '--access', 'public', '--userconfig', userconfigPath],
1781
+ args: ['publish', artifact.tarballPath, '--ignore-scripts', '--access', 'public', '--userconfig', userconfigPath],
1425
1782
  opts: { cwd: repoRoot, env: { ...process.env, npm_config_loglevel: 'notice' } },
1426
1783
  };
1427
1784
  }
@@ -1434,19 +1791,48 @@ function publish(repoRoot, target, deps = {}) {
1434
1791
  const token = loadNpmToken(repoRoot);
1435
1792
  const runImpl = deps.runImpl ?? run;
1436
1793
  const res = withTempUserconfig(token, (tmpRc) => {
1437
- const call = publishInvocation(repoRoot, tmpRc);
1794
+ const call = publishInvocation(repoRoot, tmpRc, deps.artifact);
1438
1795
  return runImpl(call.cmd, call.args, call.opts);
1439
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;
1440
1815
  const receipt = evaluatePublishReceipt(res, target);
1441
1816
  if (!receipt.confirmed) throw new Error(receipt.detail);
1442
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
+
1443
1829
  const propagation = waitForRegistryPropagation(repoRoot, target, {
1444
1830
  attempts: deps.attempts,
1445
1831
  delaySeconds: deps.delaySeconds,
1446
1832
  runImpl,
1447
1833
  waitImpl: deps.waitImpl,
1448
1834
  });
1449
- return { receipt, propagation };
1835
+ return { receipt, propagation, proof };
1450
1836
  }
1451
1837
 
1452
1838
  function tagAndPush(repoRoot, target) {
@@ -1540,6 +1926,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
1540
1926
  release: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
1541
1927
  live: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
1542
1928
  propagation,
1929
+ proof: publication.proof,
1543
1930
  reconciliation: [
1544
1931
  { phase: 'tag-and-push', kind: 'failed', detail: err instanceof Error ? err.message : String(err) },
1545
1932
  { phase: 'github-release', kind: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
@@ -1570,6 +1957,7 @@ export async function runPublishRelease(repoRoot, target, deps = {}) {
1570
1957
  release,
1571
1958
  live,
1572
1959
  propagation,
1960
+ proof: publication.proof,
1573
1961
  reconciliation,
1574
1962
  };
1575
1963
  }
@@ -1628,10 +2016,17 @@ export function printPublishOutcome(outcome, target, io = {}) {
1628
2016
  const tagAndPushFailed = outcome.reconciliation.some((item) => item.phase === 'tag-and-push');
1629
2017
 
1630
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
+ }
1631
2023
  if (outcome.propagation.ok) {
1632
2024
  log(` registry verified (${outcome.propagation.detail}).`);
1633
2025
  } else {
1634
- 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}`);
1635
2030
  }
1636
2031
  if (!tagAndPushFailed) {
1637
2032
  log(` tagged ${outcome.tag} (AFTER publish) and pushed main+tag to: ${outcome.pushed.join(', ')}.`);
@@ -1681,6 +2076,7 @@ export function printPublishOutcome(outcome, target, io = {}) {
1681
2076
  error(` - ${item.phase}${item.kind ? ` (${item.kind})` : ''}: ${item.detail}`);
1682
2077
  }
1683
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).`);
1684
2080
  return 1;
1685
2081
  }
1686
2082
 
@@ -1860,6 +2256,8 @@ async function main() {
1860
2256
  check: { type: 'boolean', default: false },
1861
2257
  publish: { type: 'boolean', default: false },
1862
2258
  'skip-ci': { type: 'boolean', default: false },
2259
+ reconcile: { type: 'boolean', default: false },
2260
+ target: { type: 'string' },
1863
2261
  json: { type: 'boolean', default: false },
1864
2262
  help: { type: 'boolean', default: false },
1865
2263
  version: { type: 'boolean', default: false },
@@ -1867,12 +2265,14 @@ async function main() {
1867
2265
  });
1868
2266
 
1869
2267
  if (values.help) {
1870
- 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]');
1871
2269
  console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
1872
2270
  console.log(' Receipt boundary: before the confirmed npm receipt, failure aborts; after it, never rerun --publish.');
1873
2271
  console.log(' Tag/push failure after receipt skips GitHub-release and site phases and returns reconciliation guidance.');
1874
2272
  console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
1875
- 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.');
1876
2276
  return 0;
1877
2277
  }
1878
2278
 
@@ -1888,6 +2288,17 @@ async function main() {
1888
2288
 
1889
2289
  const repoRoot = repoRootOf();
1890
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
+
1891
2302
  if (values['set-version']) {
1892
2303
  const target = values['set-version'];
1893
2304
  if (!/^\d+\.\d+\.\d+$/.test(target)) {
@@ -1919,23 +2330,27 @@ async function main() {
1919
2330
  if (values.check || values.publish) {
1920
2331
  const target = readPackageVersion(repoRoot);
1921
2332
  const checks = await preflight(repoRoot, target, { skipCi: values['skip-ci'] });
1922
- const ok = printChecks(checks, values.json && !values.publish, target);
1923
- if (!ok) return 1;
1924
- if (!values.publish) return 0;
1925
-
1926
- console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
1927
- // No spawn timeout is set anywhere in this file, on purpose: a kill in the
1928
- // middle of `npm publish` or a tag push is the very failure the receipt
1929
- // boundary exists to avoid. The wall-clock cost is real, though — each of
1930
- // the two `git push` remotes re-runs the husky pre-push full gate (~2.5 min
1931
- // each, measured) and the site poll waits up to 120 s.
1932
- console.log(' This tail can run ~7 minutes (pre-push gate x2 remotes + up to 120s site poll).');
1933
- console.log(' Run it with a >=600s command timeout or in the background — do NOT kill it mid-run.');
1934
- const outcome = await runPublishRelease(repoRoot, target, { publishImpl: publish });
1935
- return printPublishOutcome(outcome, target);
1936
- }
1937
-
1938
- console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
2333
+ const checked = await withCheckedPackage(repoRoot, async (artifact) => {
2334
+ checks.push({ name: 'leakage-gate', ok: true, detail: 'packed filenames and required owner-policy content scan verified' });
2335
+ const ok = printChecks(checks, values.json && !values.publish, target);
2336
+ if (!ok) return 1;
2337
+ if (!values.publish) return 0;
2338
+ console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
2339
+ console.log(' Publish and post-receipt reconciliation may take several minutes; do not kill it mid-run.');
2340
+ const outcome = await runPublishRelease(repoRoot, target, {
2341
+ publishImpl: (root, version) => publish(root, version, { artifact }),
2342
+ });
2343
+ return printPublishOutcome(outcome, target);
2344
+ });
2345
+ if (!checked.ok) {
2346
+ checks.push({ name: 'leakage-gate', ok: false, detail: checked.detail });
2347
+ printChecks(checks, values.json && !values.publish, target);
2348
+ return 1;
2349
+ }
2350
+ return checked.value;
2351
+ }
2352
+
2353
+ console.error('Nothing to do — pass --check, --publish, --reconcile, or --set-version X.Y.Z (see --help).');
1939
2354
  return 2;
1940
2355
  }
1941
2356