session-orchestrator 5.10.0 → 5.12.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 (88) hide show
  1. package/.claude-plugin/marketplace.json +12 -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/CHANGELOG.md +51 -0
  6. package/README.md +22 -11
  7. package/SECURITY.md +1 -1
  8. package/agents/code-implementer.md +7 -1
  9. package/commands/session.md +3 -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/install.md +12 -8
  14. package/docs/role-profile-contract.md +139 -0
  15. package/docs/scope-collision-guard.md +3 -0
  16. package/docs/session-config-reference.md +7 -7
  17. package/docs/session-config-template.md +2 -2
  18. package/evals/instruction-ablation/run.mjs +11 -6
  19. package/hooks/_lib/hook-import-set.json +27 -2
  20. package/hooks/_lib/orchestrator-ignore.mjs +112 -0
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +8 -1
  24. package/hooks/on-session-start.mjs +26 -10
  25. package/hooks/post-bash-write-verify.mjs +16 -3
  26. package/hooks/pre-task-scope-disjoint.mjs +12 -0
  27. package/hooks/subagent-telemetry.mjs +8 -3
  28. package/hooks/wave-scope-commit-guard.mjs +4 -1
  29. package/package.json +1 -2
  30. package/rules/opt-in-stack/security-web.md +1 -1
  31. package/scripts/emit-session.mjs +13 -0
  32. package/scripts/fleet-checkin-audit.mjs +138 -0
  33. package/scripts/fleet-checkin.mjs +61 -3
  34. package/scripts/lib/common.mjs +2 -4
  35. package/scripts/lib/config/remote-hosts.mjs +2 -2
  36. package/scripts/lib/config.mjs +1 -1
  37. package/scripts/lib/context-overhead-format.mjs +57 -0
  38. package/scripts/lib/fleet-checkin-audit.mjs +244 -0
  39. package/scripts/lib/fleet-protocol.mjs +17 -0
  40. package/scripts/lib/memory-proposals/sink.mjs +79 -17
  41. package/scripts/lib/memory-proposals/store.mjs +245 -9
  42. package/scripts/lib/project-hygiene.mjs +37 -0
  43. package/scripts/lib/role-profile/schema.mjs +219 -0
  44. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  45. package/scripts/lib/session-invocation.mjs +14 -3
  46. package/scripts/lib/session-lock.mjs +57 -10
  47. package/scripts/lib/session-record-repair.mjs +63 -30
  48. package/scripts/lib/session-registry.mjs +14 -4
  49. package/scripts/lib/session-schema/constants.mjs +8 -0
  50. package/scripts/lib/session-schema/validator.mjs +33 -0
  51. package/scripts/lib/so-hud/collect.mjs +130 -0
  52. package/scripts/lib/so-hud/runtime.mjs +76 -0
  53. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  54. package/scripts/lib/state-md/recommendations.mjs +26 -4
  55. package/scripts/lib/subagents-schema.mjs +9 -0
  56. package/scripts/lib/telemetry/pricing.mjs +11 -0
  57. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  58. package/scripts/lib/vault-mirror/process.mjs +143 -40
  59. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  60. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  61. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  62. package/scripts/measure-context-overhead.sh +4 -21
  63. package/scripts/release.mjs +359 -11
  64. package/scripts/resolve-session-invocation.mjs +64 -8
  65. package/scripts/run-quality-gate.mjs +47 -0
  66. package/scripts/so-hud.mjs +45 -0
  67. package/scripts/state-md-idle-reset.mjs +81 -0
  68. package/scripts/validate-wave-scope.mjs +59 -6
  69. package/scripts/vault-orphans.mjs +171 -0
  70. package/skills/_shared/fleet-protocol.md +24 -2
  71. package/skills/_shared/state-ownership.md +3 -3
  72. package/skills/autopilot/SKILL.md +3 -2
  73. package/skills/claude-md-drift-check/checker.mjs +8 -6
  74. package/skills/npm-publish/SKILL.md +3 -3
  75. package/skills/release/SKILL.md +2 -2
  76. package/skills/repo-audit/SKILL.md +1 -1
  77. package/skills/session-end/SKILL.md +3 -2
  78. package/skills/session-end/metrics-collection.md +2 -2
  79. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  80. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  81. package/skills/session-end/references/session-summary-template.md +4 -0
  82. package/skills/session-end/session-metrics-write.md +1 -1
  83. package/skills/session-start/SKILL.md +1 -1
  84. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  85. package/skills/session-start/references/phase-1-2-session-lock.md +1 -1
  86. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  87. package/skills/wave-executor/circuit-breaker.md +1 -0
  88. package/AGENTS.md +0 -205
@@ -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
 
@@ -1,19 +1,67 @@
1
1
  #!/usr/bin/env node
2
2
  /** Stdin-only boundary for commands/session.md; never executes task context. */
3
- import { readFileSync } from 'node:fs';
3
+ import { accessSync, constants, readFileSync, statSync } from 'node:fs';
4
+ import { join, resolve } from 'node:path';
4
5
  import { parseArgs } from 'node:util';
5
6
  import { resolveSessionInvocation } from './lib/session-invocation.mjs';
7
+ import { readCanonicalSessions } from './lib/sessions-canonical.mjs';
8
+ import { isRealSession } from './lib/session-schema/filters.mjs';
9
+ import { resolveEventsLedgerRoot } from './lib/platform.mjs';
6
10
 
7
- const HELP = `Usage: node scripts/resolve-session-invocation.mjs [--json] < arguments.txt
11
+ /**
12
+ * One ledger: `false` when absent, `undefined` when present but not a readable
13
+ * file, else whether it holds a real session.
14
+ *
15
+ * @param {string} root
16
+ * @returns {boolean|undefined}
17
+ */
18
+ function ledgerHasHistory(root) {
19
+ const file = join(root, '.orchestrator', 'metrics', 'sessions.jsonl');
20
+ try {
21
+ if (!statSync(file).isFile()) return undefined;
22
+ accessSync(file, constants.R_OK);
23
+ } catch (error) {
24
+ return error?.code === 'ENOENT' ? false : undefined;
25
+ }
26
+ return readCanonicalSessions({ filePath: file }).some(isRealSession);
27
+ }
28
+
29
+ /**
30
+ * Whether the repo holds a real session of its own: `true`/`false`, or
31
+ * `undefined` when that cannot be measured. sessions.jsonl is gitignored, so a
32
+ * linked worktree also reads its main checkout's ledger (`resolveEventsLedgerRoot`);
33
+ * one with history in either place has history. A ledger that exists but
34
+ * cannot be read is "not measured", never "no history".
35
+ *
36
+ * @param {string} repoRoot
37
+ * @returns {boolean|undefined}
38
+ */
39
+ function measureHistory(repoRoot) {
40
+ const roots = [...new Set([resolve(repoRoot), resolveEventsLedgerRoot(repoRoot)])];
41
+ let unmeasured = false;
42
+ for (const root of roots) {
43
+ const has = ledgerHasHistory(root);
44
+ if (has === true) return true;
45
+ if (has === undefined) unmeasured = true;
46
+ }
47
+ return unmeasured ? undefined : false;
48
+ }
49
+
50
+ const HELP = `Usage: node scripts/resolve-session-invocation.mjs [--json] [--repo-root <dir>] < arguments.txt
8
51
 
9
52
  Read the complete /session argument text from UTF-8 stdin.
10
53
  Resolve the leading housekeeping, feature, deep or ultradeep token; preserve
11
54
  the rest as task context. Empty input defaults to deep. An invalid mode emits
12
55
  a warning and resolves to deep, as the session command has always specified.
56
+ A bare /session in a repo whose .orchestrator/metrics/sessions.jsonl holds no
57
+ real session adds defaultReason "no-session-history": ask for task and mode
58
+ before starting. A linked git worktree reads its main checkout's ledger; an
59
+ unreadable ledger counts as not measured and adds nothing.
13
60
 
14
- --json Emit one JSON object instead of a human-readable summary
15
- --help Show this help
16
- --version Show the plugin version
61
+ --json Emit one JSON object instead of a human-readable summary
62
+ --repo-root <dir> Project root whose session history is read (default: cwd)
63
+ --help Show this help
64
+ --version Show the plugin version
17
65
 
18
66
  Example: node scripts/resolve-session-invocation.mjs --json < arguments.txt
19
67
  Write arguments.txt with a file tool; do not interpolate user text into a shell.
@@ -23,7 +71,12 @@ Exit codes: 0 resolved (including fallback), 1 bad CLI arguments, 2 stdin I/O er
23
71
  let options;
24
72
  try {
25
73
  options = parseArgs({
26
- options: { json: { type: 'boolean' }, help: { type: 'boolean' }, version: { type: 'boolean' } },
74
+ options: {
75
+ json: { type: 'boolean' },
76
+ 'repo-root': { type: 'string' },
77
+ help: { type: 'boolean' },
78
+ version: { type: 'boolean' },
79
+ },
27
80
  allowPositionals: false,
28
81
  }).values;
29
82
  } catch (error) {
@@ -45,7 +98,10 @@ if (options.help) {
45
98
  process.stderr.write('session-invocation: could not read UTF-8 stdin\n');
46
99
  process.exit(2);
47
100
  }
48
- const resolved = resolveSessionInvocation(input);
101
+ // History is read only for a bare /session: an explicit mode never needs it.
102
+ const hasHistory =
103
+ input.trim() === '' ? measureHistory(options['repo-root'] || process.cwd()) : undefined;
104
+ const resolved = resolveSessionInvocation(input, { hasHistory });
49
105
  if (resolved.invalidMode !== undefined) {
50
106
  process.stderr.write(
51
107
  `Invalid session type '${resolved.invalidMode}'. Valid types: housekeeping, feature, deep (alias: ultradeep). Falling back to deep.\n`,
@@ -54,6 +110,6 @@ if (options.help) {
54
110
  process.stdout.write(
55
111
  options.json
56
112
  ? `${JSON.stringify(resolved)}\n`
57
- : `Session type: ${resolved.sessionType}${resolved.profile ? ` (profile: ${resolved.profile})` : ''}\nTask context: ${resolved.context}\n`,
113
+ : `Session type: ${resolved.sessionType}${resolved.profile ? ` (profile: ${resolved.profile})` : ''}${resolved.defaultReason ? ` (default: ${resolved.defaultReason})` : ''}\nTask context: ${resolved.context}\n`,
58
114
  );
59
115
  }