session-orchestrator 3.20.0 → 3.22.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 (202) 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/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -20,28 +20,52 @@
20
20
  // entry, README highlights prose) are NOT written —
21
21
  // they are enforced by --check instead.
22
22
  // --check Preflight: surface parity, CHANGELOG entry present
23
- // + Unreleased folded, tag collision (local, origin,
24
- // github), npm registry collision, CI green on HEAD,
25
- // leakage gate over `npm pack --dry-run`.
26
- // --publish Runs --check first, then: token publish via temp
27
- // userconfig (NPM_TOKEN from .env.local), registry
28
- // verify, annotated tag AFTER successful publish
23
+ // + Unreleased folded, drift sweep, tag collision
24
+ // (local, origin, github), github/main mirror parity,
25
+ // npm registry collision, npm token liveness, CI green
26
+ // on HEAD, leakage gate over `npm pack --dry-run`.
27
+ // --publish Runs --check first, then token publish via temp
28
+ // userconfig (NPM_TOKEN from .env.local). A
29
+ // target-confirmed npm receipt is the irreversible
30
+ // boundary: before it, failure aborts normally; after it,
31
+ // never rerun --publish. The tail tags AFTER receipt
29
32
  // (never before — eliminates "tagged but unpublished"),
30
- // push main + tag to origin AND the github mirror,
31
- // then print the post-release checklist (site deploy,
32
- // token rotation).
33
+ // pushes main + tag to origin AND github, handles the
34
+ // GitHub release (`--verify-tag`), reconciles registry
35
+ // propagation, and polls the live site. A tag/push
36
+ // failure skips the tag-dependent GitHub-release and
37
+ // site phases and returns reconciliation guidance.
33
38
  //
34
39
  // USAGE:
35
- // node scripts/release.mjs --check [--json]
40
+ // node scripts/release.mjs --check [--json] [--skip-ci]
36
41
  // node scripts/release.mjs --set-version 3.19.0
37
42
  // node scripts/release.mjs --publish [--json]
38
43
  //
39
44
  // EXIT CODES:
40
45
  // 0 success
41
- // 1 check failure (stale surface, missing CHANGELOG entry, tag/registry
42
- // collision, CI not green, leakage-gate hit)
43
- // 2 system/usage error (git/npm spawn failure, missing NPM_TOKEN,
44
- // unknown flag)
46
+ // 1 preflight/check failure (stale surface, missing CHANGELOG entry,
47
+ // tag/registry collision, mirror behind, dead token, CI not green,
48
+ // leakage-gate hit) OR post-publish reconciliation required
49
+ // 2 system/usage error before the npm receipt (git/npm spawn failure,
50
+ // missing NPM_TOKEN, unknown flag, --skip-ci combined with --publish)
51
+ //
52
+ // FAIL-CLOSED IS THE HOUSE RULE (the defect class this file kept re-growing):
53
+ // A preflight check reports on evidence it GATHERED. When the gathering
54
+ // itself fails — a non-zero exit nobody read, output in an unexpected shape,
55
+ // an empty listing — the honest verdict is "could not tell", and "could not
56
+ // tell" MUST be reported as `ok:false`. Three checks previously did the
57
+ // opposite: an errored `git grep` produced an empty hit list that read as a
58
+ // clean sweep, an unparseable `npm view` produced an empty version list that
59
+ // read as "no collision", and an `npm pack` whose listing did not parse
60
+ // produced zero scanned lines that read as "0 leaks". Each is a green check
61
+ // that verified nothing, on the one code path where being wrong is
62
+ // irreversible. Hence: every check that consumes a subprocess result routes
63
+ // through an exported `evaluate*` function below, which is pure over
64
+ // `{status, stdout, stderr}` and unit-tested against exactly the degraded
65
+ // shapes that used to pass.
66
+ //
67
+ // --skip-ci is the deliberate, operator-visible exception to that rule — and
68
+ // is therefore REFUSED under --publish (see `validateFlags`).
45
69
  //
46
70
  // SECURITY INVARIANTS (from skills/npm-publish/SKILL.md):
47
71
  // - NPM_TOKEN only from gitignored .env.local; never logged, never persisted.
@@ -63,17 +87,24 @@ import { spawnSync } from 'node:child_process';
63
87
  import { parseArgs } from 'node:util';
64
88
  import { fileURLToPath } from 'node:url';
65
89
 
90
+ import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
91
+
66
92
  const PACKAGE_NAME = 'session-orchestrator';
67
93
  const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
68
94
 
69
95
  // ---------------------------------------------------------------------------
70
96
  // Surfaces table — the SSOT both the scan and the rewrite share.
71
97
  //
72
- // Every entry: { file, patterns: [RegExp] }. Each pattern has exactly one
73
- // capture group holding the version. Matching ZERO occurrences is a hard
74
- // failure ("pattern-dead") — that is the guard against a surface silently
75
- // falling out of the check after a file refactor. All captured versions must
76
- // equal the target.
98
+ // Every entry: { file, patterns: [RegExp], checkOnly?: boolean }. Each pattern
99
+ // has exactly one capture group holding the version. Matching ZERO occurrences
100
+ // is a hard failure ("pattern-dead") — that is the guard against a surface
101
+ // silently falling out of the check after a file refactor. All captured
102
+ // versions must equal the target.
103
+ //
104
+ // `checkOnly: true` means: scanned by --check, NOT rewritten by applyVersion,
105
+ // because a different generator owns the write. See site/index.html below for
106
+ // the only current case and for why the ownership split is structural here
107
+ // rather than a comment asking the next editor to be careful.
77
108
  //
78
109
  // CHANGELOG.md is deliberately NOT here: it carries version HISTORY, so a
79
110
  // replace-all would corrupt it. It has its own editorial check below.
@@ -119,8 +150,36 @@ export const SURFACES = [
119
150
  ],
120
151
  },
121
152
  {
153
+ // ONE WRITER, ONE CHECKER — and they are not the same program.
154
+ //
155
+ // The page carries its version in three `<span data-metric="version">`
156
+ // cells, and `scripts/site-numbers.mjs --write` owns every `data-metric`
157
+ // cell on the site: it recomputes each one from its declared source (for
158
+ // `version`, that source is package.json). This table only READS them back,
159
+ // hence `checkOnly` — applyVersion deliberately does not touch this file.
160
+ //
161
+ // Why that is not a gap: --set-version runs applyVersion FIRST (package.json
162
+ // gets the target) and `site-numbers --write` SECOND, so the generator
163
+ // derives the same literal from the surface applyVersion just wrote. Adding
164
+ // a second writer here would not "make it safer" — it would make two
165
+ // programs authoritative for one cell, and the next divergence between them
166
+ // would be invisible until a release shipped. If the generator ever stops
167
+ // running, this check goes red rather than quietly self-healing, which is
168
+ // the outcome worth having.
169
+ //
170
+ // HISTORY (do not restore either old pattern): the previous entry was
171
+ // `/"softwareVersion":\s*"(...)"/` plus `/v(\d+\.\d+\.\d+)\b/g`. Commit
172
+ // 8802aa4 removed `softwareVersion` from the JSON-LD (deliberately — see the
173
+ // comment at the top of site/index.html) and replaced the bare `vX.Y.Z`
174
+ // literals with the metric cells, leaving BOTH patterns matching nothing.
175
+ // The pattern-dead guard caught that, which is the entire reason it exists.
176
+ // The `\b`-anchored one was also actively dangerous as a WRITE pattern: it
177
+ // was a replace-all over every `vX.Y.Z` on the page, so a sentence
178
+ // mentioning a historical release would have been silently rewritten to the
179
+ // new version by --set-version. The replacement is anchored to the cell.
122
180
  file: 'site/index.html',
123
- patterns: [/"softwareVersion":\s*"(\d+\.\d+\.\d+)"/, /v(\d+\.\d+\.\d+)\b/g],
181
+ patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
182
+ checkOnly: true,
124
183
  },
125
184
  {
126
185
  file: 'site/llms.txt',
@@ -187,12 +246,15 @@ export function scanSurfaces(repoRoot, target) {
187
246
  /**
188
247
  * Mechanically rewrite every surface to the target version by replacing the
189
248
  * captured version in each pattern match. Idempotent. Does NOT touch
190
- * CHANGELOG.md or package-lock.json (the caller syncs the lock via npm).
249
+ * CHANGELOG.md or package-lock.json (the caller syncs the lock via npm), nor
250
+ * any `checkOnly` surface (another generator owns that file's write — see the
251
+ * site/index.html entry in SURFACES).
191
252
  * Returns the list of files actually changed.
192
253
  */
193
254
  export function applyVersion(repoRoot, target) {
194
255
  const changed = [];
195
256
  for (const surface of SURFACES) {
257
+ if (surface.checkOnly) continue;
196
258
  const abs = join(repoRoot, surface.file);
197
259
  if (!existsSync(abs)) continue;
198
260
  const before = readFileSync(abs, 'utf8');
@@ -236,32 +298,328 @@ export function checkChangelogEntry(text, target) {
236
298
  return { ok: problems.length === 0, problems };
237
299
  }
238
300
 
239
- // The seven leakage patterns from skills/npm-publish/SKILL.md, applied to
240
- // `npm pack --dry-run` output lines. Any hit blocks the publish.
301
+ // The leak checks operate only on paths extracted from real packed-entry lines,
302
+ // never on arbitrary `npm notice` prose. `tests` and `.claude` are exact path
303
+ // segments: nested copies leak too, while `contest` and `.claude-plugin` do not.
304
+ function hasPathSegment(path, segment) {
305
+ return path.split('/').includes(segment);
306
+ }
307
+
241
308
  export const LEAKAGE_PATTERNS = [
242
- { name: 'tests/', re: /npm notice.* tests\// },
243
- { name: '.orchestrator/', re: /npm notice.*\.orchestrator\// },
244
- { name: '.claude/', re: /npm notice.*\s\.claude\// },
245
- { name: '.github/', re: /npm notice.*\.github\// },
246
- { name: 'node_modules', re: /node_modules/ },
247
- { name: '.env', re: /npm notice.*\.env/i },
248
- { name: 'owner.yaml', re: /owner\.yaml/i },
309
+ { name: 'tests/', matches: (path) => hasPathSegment(path, 'tests') },
310
+ { name: '.orchestrator/', matches: (path) => /\.orchestrator\//.test(path) },
311
+ { name: '.claude/', matches: (path) => hasPathSegment(path, '.claude') },
312
+ { name: '.github/', matches: (path) => /\.github\//.test(path) },
313
+ { name: 'node_modules', matches: (path) => /node_modules/.test(path) },
314
+ { name: '.env', matches: (path) => /\.env/i.test(path) },
315
+ { name: 'owner.yaml', matches: (path) => /owner\.yaml/i.test(path) },
316
+ // Claimed as checked by docs/distribution/npm-publish-checklist.md long before
317
+ // any code checked it (measured 2026-08-19: 3 leakage lists, 3 different sets).
318
+ // `files` in package.json overrides .gitignore, so a stray .DS_Store inside a
319
+ // shipped directory reaches the tarball.
320
+ { name: '.DS_Store', matches: (path) => /\.DS_Store/.test(path) },
249
321
  ];
250
322
 
251
- /** Pure check over pack-output lines. Returns violations: {name, line}[]. */
323
+ // Bootstrap's public Standard path copies each selected template in full, so
324
+ // these two sanity tests are intentional scaffold assets, not package-internal
325
+ // test material. This is exact by path and applies only to the `tests/` class:
326
+ // do not turn it into a templates/** or segment-level bypass.
327
+ const INTENTIONAL_TEST_ASSET_PATHS = new Set([
328
+ 'templates/node-minimal/tests/sanity.test.ts',
329
+ 'templates/python-uv/tests/test_sanity.py',
330
+ ]);
331
+
332
+ // `commands/release.md` quotes the `npm view` OUTPUT that proves the 3.18.0 gap,
333
+ // dated at the line. Bumping it would destroy the evidence it exists to carry —
334
+ // the registry state on that date is the whole point of the paragraph.
335
+ //
336
+ // `site/guide/index.html` carries ONE dated historical sentence — "re-checked
337
+ // against v<prev> on <date>" — deliberately left as a literal: a release that
338
+ // bumped the version while the date stood still would fabricate a verification
339
+ // nobody ran. The page is not unguarded by this exemption. It loses only the
340
+ // coarse prev-tag sweep and keeps the STRICTER guard in
341
+ // tests/scripts/site-numbers.test.mjs, which forbids ANY vX.Y.Z and the current
342
+ // package version outside a `data-metric` cell on EVERY shipped page, and
343
+ // exempts exactly the lines marked `site-numbers:historical`.
344
+ export const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|docs\/|tests\/|skills\/npm-publish\/|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json|site\/guide\/index\.html|commands\/release\.md)/;
345
+
346
+ /** Pure check over packed-entry lines. Returns violations: {name, line}[]. */
252
347
  export function checkLeakage(lines) {
253
348
  const violations = [];
254
349
  for (const line of lines) {
255
- for (const { name, re } of LEAKAGE_PATTERNS) {
256
- if (re.test(line)) violations.push({ name, line: line.trim() });
350
+ const entry = parsePackedEntry(line);
351
+ if (!entry) continue;
352
+ for (const { name, matches } of LEAKAGE_PATTERNS) {
353
+ if (name === 'tests/' && INTENTIONAL_TEST_ASSET_PATHS.has(entry.path)) continue;
354
+ if (matches(entry.path)) violations.push({ name, line: line.trim() });
257
355
  }
258
356
  }
259
357
  return violations;
260
358
  }
261
359
 
360
+ /**
361
+ * One packed tarball entry in `npm pack --dry-run` output:
362
+ * `npm notice 1.3kB .claude-plugin/marketplace.json`.
363
+ *
364
+ * This grammar intentionally excludes npm's package metadata and summary
365
+ * notices. Leakage decisions must be made over a file path, not a sentence
366
+ * that happens to mention one.
367
+ */
368
+ export const PACKED_ENTRY_RE = /^npm notice\s+(\d+(?:\.\d+)?\s*(?:B|kB|MB|GB))\s+(\S.*)$/;
369
+
370
+ /**
371
+ * Parse an npm packed-entry notice into its path. Returns null for every other
372
+ * npm notice line, including package metadata and summaries.
373
+ *
374
+ * @param {string} line
375
+ * @returns {{path: string}|null}
376
+ */
377
+ export function parsePackedEntry(line) {
378
+ const match = line.match(PACKED_ENTRY_RE);
379
+ return match ? { path: match[2].trim() } : null;
380
+ }
381
+
382
+ /**
383
+ * Floor on parsed packed entries, below which the leak scan is presumed BLIND
384
+ * rather than clean.
385
+ *
386
+ * Measured 2026-08-21 with `npm pack --dry-run`: npm's own summary reports
387
+ * `total files: 805` and {@link PACKED_ENTRY_RE} independently counts 805 —
388
+ * two differently-shaped measurements agreeing. Package size 2.9 MB, unpacked
389
+ * 9.0 MB. The count dropped from 830 when `package.json` `files` gained
390
+ * `!scripts/tests/**` and `!skills/vault-sync/tests/**`; those 28 entries were
391
+ * shipped in 3.21.0 (scanned: no secrets, no owner data — ballast, not an
392
+ * incident). Independently confirmable after the fact:
393
+ * `npm view session-orchestrator@3.21.0 dist.fileCount` returns 832 — npm's own
394
+ * count of what the registry accepted for THAT version, from outside this repo,
395
+ * and therefore still the pre-exclusion number.
396
+ *
397
+ * (An earlier revision of this comment claimed the checklist "still records the
398
+ * older ~750 files" baseline. It did not: commit a2e495c rewrote that line to
399
+ * the measured 830/2.9/8.9 at the same SHA this comment was written. The claim
400
+ * was a second copy of a fact, contradicting the first, inside the file that
401
+ * argues against second copies. Caught by the post-publish review panel.)
402
+ *
403
+ * 400 is a FLOOR, not a pin — deliberately ~50% of today's count. It cannot
404
+ * break on growth (the pack only grows), and it is far enough below 805 that a
405
+ * deliberate docs/skills prune would not trip it. What it does catch is the
406
+ * whole failure class in one number: an npm output-format change, an
407
+ * `npm notice` prefix rename, a `files`/`.npmignore` edit that drops entire
408
+ * trees — every state in which the scan sees a handful of lines, finds no
409
+ * leak pattern in them, and reports "0 leaks" with total confidence.
410
+ */
411
+ export const MIN_PACKED_ENTRIES = 400;
412
+
413
+ // ---------------------------------------------------------------------------
414
+ // Preflight evaluators — pure over a spawn result `{status, stdout, stderr}`.
415
+ //
416
+ // These exist so the DECISION of every preflight check is unit-testable while
417
+ // the subprocess call itself stays in the impure section below. Each returns
418
+ // `{ok, detail}`. The shared contract, and the reason this family exists at
419
+ // all, is the FAIL-CLOSED house rule in the file header: an evaluator may
420
+ // return `ok:true` only when it has positively SEEN the evidence, never merely
421
+ // because it failed to see a counterexample.
422
+ // ---------------------------------------------------------------------------
423
+
424
+ /**
425
+ * Drift sweep verdict over a `git grep -l` result.
426
+ *
427
+ * `git grep` exit codes: 0 = matches found, 1 = no match (the success case
428
+ * here), anything else = it did not run. Measured on git 2.x: a bad regex and
429
+ * a bad pathspec both exit 128; git also documents 2 for usage errors. The old
430
+ * inline code read `.stdout` without ever looking at `.status`, so BOTH the
431
+ * no-match case and the it-crashed case produced an empty hit list and the
432
+ * same reassuring detail line, "no tracked file still carries X". A sweep that
433
+ * never ran is not a clean sweep.
434
+ *
435
+ * @param {{status: number, stdout?: string, stderr?: string}} grep
436
+ * @param {string} prevTag — the previous release literal being swept for
437
+ * @param {RegExp} allowlist — files that legitimately carry version HISTORY
438
+ * @returns {{ok: boolean, detail: string}}
439
+ */
440
+ export function evaluateDriftSweep(grep, prevTag, allowlist) {
441
+ if (grep.status !== 0 && grep.status !== 1) {
442
+ return {
443
+ ok: false,
444
+ detail: `git grep did not run (exit ${grep.status}): ${(grep.stderr || '').trim().slice(0, 200)} — sweep for ${prevTag} is inconclusive`,
445
+ };
446
+ }
447
+ const hits = (grep.stdout || '')
448
+ .split('\n')
449
+ .filter(Boolean)
450
+ .filter((f) => !allowlist.test(f));
451
+ return {
452
+ ok: hits.length === 0,
453
+ detail: hits.length
454
+ ? `still carry ${prevTag}: ${hits.slice(0, 5).join(', ')}`
455
+ : `no tracked file outside the allowlist still carries ${prevTag}`,
456
+ };
457
+ }
458
+
459
+ /**
460
+ * Registry-collision verdict over `npm view <pkg> versions --json`.
461
+ *
462
+ * The most dangerous of the three fail-opens this file carried: on `status 0`
463
+ * with unparseable stdout, the old code swallowed the parse error, left the
464
+ * version list EMPTY, and concluded from that emptiness that the target was
465
+ * free — reporting `latest: ?` while claiming the collision check had passed.
466
+ * Reproduced verbatim: a `<html>` body (proxy/captive-portal response) with
467
+ * exit 0 yields `ok = true`. Any npm output-format change lands in the same
468
+ * hole. An empty ARRAY is treated identically: a published package always has
469
+ * at least one version, so an empty list is a shape we do not understand, not
470
+ * an all-clear.
471
+ *
472
+ * @param {{status: number, stdout?: string, stderr?: string}} view
473
+ * @param {string} target
474
+ * @returns {{ok: boolean, detail: string}}
475
+ */
476
+ export function evaluateRegistryCollision(view, target) {
477
+ if (view.status !== 0) {
478
+ const e404 = /E404/.test(view.stderr || '');
479
+ return e404
480
+ ? { ok: true, detail: 'package not yet on registry (first publish)' }
481
+ : { ok: false, detail: `npm view failed (exit ${view.status}): ${(view.stderr || '').slice(0, 200)}` };
482
+ }
483
+ const raw = view.stdout || '';
484
+ let parsed;
485
+ try {
486
+ parsed = JSON.parse(raw);
487
+ } catch {
488
+ return {
489
+ ok: false,
490
+ detail: `npm view returned unparseable JSON (${raw.length} bytes, starts "${raw.trim().slice(0, 40)}") — cannot rule out a collision on ${target}`,
491
+ };
492
+ }
493
+ const published = Array.isArray(parsed) ? parsed : [parsed];
494
+ if (published.length === 0) {
495
+ return { ok: false, detail: `npm view returned an empty version list — cannot rule out a collision on ${target}` };
496
+ }
497
+ return published.includes(target)
498
+ ? { ok: false, detail: `${target} already published` }
499
+ : { ok: true, detail: `latest: ${published[published.length - 1]}` };
500
+ }
501
+
502
+ /**
503
+ * Leakage-gate verdict over an `npm pack --dry-run` result.
504
+ *
505
+ * The SURFACES table has `pattern-dead` for exactly this class — a matcher that
506
+ * stops matching its input must be a hard error, never a silent pass — and the
507
+ * leak scan had no equivalent: an `npm pack` that exits 0 with output the scan
508
+ * cannot parse yields zero scanned lines, zero violations, and the verdict
509
+ * "0 packed entries, 0 leaks". Reproduced verbatim with empty stdout+stderr.
510
+ * {@link MIN_PACKED_ENTRIES} is that missing `pattern-dead`.
511
+ *
512
+ * The floor is asserted on the SAME lines `checkLeakage` scans, not on npm's
513
+ * `total files:` summary line. That is the point: the summary could survive a
514
+ * format change that broke the per-entry lines, and it is the per-entry lines
515
+ * whose absence blinds the scan.
516
+ *
517
+ * @param {{status: number, stdout?: string, stderr?: string}} pack
518
+ * @param {{minEntries?: number}} [opts]
519
+ * @returns {{ok: boolean, detail: string}}
520
+ */
521
+ export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
522
+ if (pack.status !== 0) {
523
+ return { ok: false, detail: `npm pack failed (exit ${pack.status}): ${(pack.stderr || '').trim().slice(-200)}` };
524
+ }
525
+ const lines = `${pack.stdout || ''}\n${pack.stderr || ''}`.split('\n');
526
+ const entries = lines.filter(parsePackedEntry).length;
527
+ if (entries < minEntries) {
528
+ return {
529
+ ok: false,
530
+ detail: `only ${entries} packed entries parsed (floor ${minEntries}) — the pack listing did not parse, so the leak scan read ${entries} line(s) and its "no leaks" verdict means nothing`,
531
+ };
532
+ }
533
+ const violations = checkLeakage(lines);
534
+ return violations.length
535
+ ? { ok: false, detail: violations.map((v) => `${v.name}: ${v.line}`).slice(0, 5).join(' | ') }
536
+ : { ok: true, detail: `${entries} packed entries, 0 leaks` };
537
+ }
538
+
539
+ /**
540
+ * Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
541
+ *
542
+ * Preflight compared HEAD against `origin/main` only. The Vercel deploy hangs
543
+ * off the GITHUB mirror, so a mirror that lags is invisible until
544
+ * `verifyLiveSite` fails — which happens AFTER npm publish and AFTER both tag
545
+ * pushes, i.e. after the two irreversible steps. Same fail-closed shape as
546
+ * `tag-free-github`: a failed `ls-remote` is a failed check, and so is output
547
+ * that carries no sha (an empty answer for `refs/heads/main` means the branch
548
+ * is not there at all, which is not parity either).
549
+ *
550
+ * @param {string} remote
551
+ * @param {{status: number, stdout?: string, stderr?: string}} ls
552
+ * @param {string} head — the local HEAD sha
553
+ * @param {string} [branch]
554
+ * @returns {{ok: boolean, detail: string}}
555
+ */
556
+ export function evaluateRemoteHeadParity(remote, ls, head, branch = 'main') {
557
+ if (ls.status !== 0) {
558
+ return { ok: false, detail: `ls-remote ${remote} failed (exit ${ls.status}): ${(ls.stderr || '').trim().slice(0, 200)}` };
559
+ }
560
+ const sha = (ls.stdout || '').trim().split(/\s+/)[0] || '';
561
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
562
+ return { ok: false, detail: `ls-remote ${remote} returned no sha for refs/heads/${branch} — cannot compare` };
563
+ }
564
+ return sha === head
565
+ ? { ok: true, detail: sha.slice(0, 8) }
566
+ : { ok: false, detail: `${remote}/${branch} at ${sha.slice(0, 8)}, HEAD at ${head.slice(0, 8)} — the mirror is behind` };
567
+ }
568
+
569
+ /**
570
+ * npm-auth verdict over `npm whoami --userconfig <tmp>`.
571
+ *
572
+ * A dead or revoked token used to surface only inside `publish()`, i.e. after
573
+ * every other preflight check had passed and the operator had committed to the
574
+ * release. The probe is read-only and costs one request. Fail-closed on the
575
+ * empty-identity case too: `whoami` exiting 0 while printing nothing is not
576
+ * proof of an identity.
577
+ *
578
+ * @param {{status: number, stdout?: string, stderr?: string}|null} whoami
579
+ * @returns {{ok: boolean, detail: string}}
580
+ */
581
+ export function evaluateNpmAuth(whoami) {
582
+ if (!whoami) return { ok: false, detail: 'npm whoami was not run' };
583
+ if (whoami.status !== 0) {
584
+ return { ok: false, detail: `npm whoami exited ${whoami.status}: ${(whoami.stderr || '').trim().slice(0, 200)}` };
585
+ }
586
+ const who = (whoami.stdout || '').trim();
587
+ return who
588
+ ? { ok: true, detail: `authenticated as ${who}` }
589
+ : { ok: false, detail: 'npm whoami exited 0 with an empty identity — the token could not be confirmed' };
590
+ }
591
+
592
+ /**
593
+ * Flag-combination gate, applied before any work.
594
+ *
595
+ * `--skip-ci` turns the CI check into `ok:true` with the detail
596
+ * "SKIPPED via --skip-ci". That is a legitimate affordance for `--check` (an
597
+ * operator inspecting surface parity while a pipeline is still running) and an
598
+ * illegitimate one for `--publish`: it would let a green summary that verified
599
+ * nothing about CI authorise npm publish + two tag pushes, none of which can be
600
+ * taken back. The refusal is a usage error (exit 2), not a check failure —
601
+ * nothing was checked.
602
+ *
603
+ * @param {{publish?: boolean, 'skip-ci'?: boolean}} values
604
+ * @returns {{ok: boolean, code?: number, message?: string}}
605
+ */
606
+ export function validateFlags(values) {
607
+ if (values.publish && values['skip-ci']) {
608
+ return {
609
+ ok: false,
610
+ code: 2,
611
+ message:
612
+ '--skip-ci is refused under --publish: it makes ci-green-on-head pass without checking anything, and publish is irreversible.\n' +
613
+ 'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
614
+ };
615
+ }
616
+ return { ok: true };
617
+ }
618
+
262
619
  // ---------------------------------------------------------------------------
263
- // Impure orchestration below — git/npm/network. Not unit-tested; exercised
264
- // by the release runs themselves.
620
+ // Impure orchestration below — git/npm/network. The DECISIONS live in the
621
+ // evaluators above and are unit-tested; what remains here is the plumbing that
622
+ // feeds them.
265
623
  // ---------------------------------------------------------------------------
266
624
 
267
625
  function run(cmd, args, opts = {}) {
@@ -286,15 +644,48 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
286
644
  const checks = [];
287
645
  const add = (name, ok, detail = '') => checks.push({ name, ok, detail });
288
646
 
289
- // 1. Git state: on main, clean tree, HEAD pushed.
647
+ // 1. Git state: on main, clean tree, HEAD present on BOTH publish remotes.
648
+ //
649
+ // Both remotes, symmetrically, and both read LIVE via ls-remote rather than
650
+ // from a local tracking ref. origin (GitLab) is where the code lives; github
651
+ // is where the Vercel git integration watches, so a lagging mirror means the
652
+ // site cannot deploy — and that was previously discovered only by
653
+ // verifyLiveSite, i.e. after npm publish and both tag pushes had already
654
+ // happened. The old origin check read `origin/main` after a `git fetch` whose
655
+ // exit status nobody inspected: a failed fetch left a stale tracking ref that
656
+ // could still equal HEAD, so the comparison was against remembered state
657
+ // rather than remote state. ls-remote has no such intermediate.
290
658
  const branch = run('git', ['branch', '--show-current'], { cwd: repoRoot }).stdout.trim();
291
659
  add('branch-is-main', branch === 'main', branch);
292
- const dirty = run('git', ['status', '--porcelain'], { cwd: repoRoot }).stdout.trim();
293
- add('working-tree-clean', dirty === '', dirty ? `${dirty.split('\n').length} dirty path(s)` : '');
294
- run('git', ['fetch', 'origin', 'main', '--quiet'], { cwd: repoRoot });
660
+ // `git status` exit status is read, not assumed: an empty stdout from a
661
+ // FAILED status call is indistinguishable from a genuinely clean tree, and
662
+ // the empty-reads-as-all-clear shape is exactly the fail-open this file was
663
+ // hardened against elsewhere. Same reasoning for the two `git tag -l` reads
664
+ // below. A third subprocess shares the shape — the `git ls-remote --tags`
665
+ // collision probe further down reads an empty stdout as "no collision" — but
666
+ // that one guards it with an explicit `ls.status === 0`, so it is not
667
+ // fail-open. Emptiness-means-all-clear is the shape to look for; reading the
668
+ // status is what makes it safe. (Census: 14 `run(` call sites in this file.
669
+ // It does NOT cover the raw `spawnSync` calls — a payload-keyed census misses
670
+ // the consumer that uses a different channel, which is how the unchecked
671
+ // propagation wait stayed invisible to it.)
672
+ const status = run('git', ['status', '--porcelain'], { cwd: repoRoot });
673
+ const dirty = (status.stdout || '').trim();
674
+ add(
675
+ 'working-tree-clean',
676
+ status.status === 0 && dirty === '',
677
+ status.status !== 0
678
+ ? `git status failed (exit ${status.status}) — cleanliness unknown`
679
+ : dirty
680
+ ? `${dirty.split('\n').length} dirty path(s)`
681
+ : '',
682
+ );
295
683
  const head = run('git', ['rev-parse', 'HEAD'], { cwd: repoRoot }).stdout.trim();
296
- const originMain = run('git', ['rev-parse', 'origin/main'], { cwd: repoRoot }).stdout.trim();
297
- add('head-pushed', head === originMain, head === originMain ? head.slice(0, 8) : `HEAD ${head.slice(0, 8)} != origin/main ${originMain.slice(0, 8)}`);
684
+ for (const remote of ['origin', 'github']) {
685
+ const ls = run('git', ['ls-remote', remote, 'refs/heads/main'], { cwd: repoRoot });
686
+ const parity = evaluateRemoteHeadParity(remote, ls, head);
687
+ add(`head-pushed-${remote}`, parity.ok, parity.detail);
688
+ }
298
689
 
299
690
  // 2. Surface parity.
300
691
  const surfaceRows = scanSurfaces(repoRoot, target);
@@ -312,22 +703,33 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
312
703
  // directories, which is exactly how the forgotten .codex-plugin manifest
313
704
  // was invisible to a plain rg census. Allowlisted: files that legitimately
314
705
  // carry version HISTORY.
315
- const prevTag = run('git', ['tag', '-l', 'v*', '--sort=-v:refname'], { cwd: repoRoot })
316
- .stdout.split('\n').map((t) => t.trim().replace(/^v/, ''))
706
+ const tagList = run('git', ['tag', '-l', 'v*', '--sort=-v:refname'], { cwd: repoRoot });
707
+ const prevTag = (tagList.stdout || '')
708
+ .split('\n').map((t) => t.trim().replace(/^v/, ''))
317
709
  .filter((t) => /^\d+\.\d+\.\d+$/.test(t) && t !== target)[0];
318
- if (prevTag) {
319
- const HISTORY_ALLOWLIST = /^(CHANGELOG\.md|README\.md|docs\/|tests\/|skills\/npm-publish\/|scripts\/release\.mjs|\.orchestrator\/|site\/leaderboard\.json)/;
710
+ if (tagList.status !== 0) {
711
+ // "No previous tag" and "could not list tags" are different facts, and only
712
+ // one of them means the sweep is unnecessary.
713
+ add('drift-sweep', false, `git tag -l failed (exit ${tagList.status}) — cannot determine the previous release to sweep for`);
714
+ } else if (prevTag) {
320
715
  const grep = run('git', ['grep', '-l', '--fixed-strings', prevTag, '--', '.'], { cwd: repoRoot });
321
- const hits = grep.stdout.split('\n').filter(Boolean).filter((f) => !HISTORY_ALLOWLIST.test(f));
322
- add('drift-sweep', hits.length === 0, hits.length ? `still carry ${prevTag}: ${hits.slice(0, 5).join(', ')}` : `no tracked file outside the allowlist still carries ${prevTag}`);
716
+ const sweep = evaluateDriftSweep(grep, prevTag, HISTORY_ALLOWLIST);
717
+ add('drift-sweep', sweep.ok, sweep.detail);
323
718
  } else {
324
719
  add('drift-sweep', true, 'no previous tag to sweep against');
325
720
  }
326
721
 
327
722
  // 4. Tag collision — local, origin, github mirror.
328
723
  const tag = `v${target}`;
329
- const localTag = run('git', ['tag', '-l', tag], { cwd: repoRoot }).stdout.trim();
330
- add('tag-free-local', localTag === '', localTag && `${tag} already exists locally`);
724
+ const localTagRes = run('git', ['tag', '-l', tag], { cwd: repoRoot });
725
+ const localTag = (localTagRes.stdout || '').trim();
726
+ add(
727
+ 'tag-free-local',
728
+ localTagRes.status === 0 && localTag === '',
729
+ localTagRes.status !== 0
730
+ ? `git tag -l failed (exit ${localTagRes.status}) — local tag collision unknown`
731
+ : localTag && `${tag} already exists locally`,
732
+ );
331
733
  for (const remote of ['origin', 'github']) {
332
734
  const ls = run('git', ['ls-remote', '--tags', remote, `refs/tags/${tag}`], { cwd: repoRoot });
333
735
  const collision = ls.status === 0 && ls.stdout.trim() !== '';
@@ -336,21 +738,32 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
336
738
 
337
739
  // 5. npm registry collision (E404 = name free = fine for a first publish).
338
740
  const view = run('npm', ['view', PACKAGE_NAME, 'versions', '--json'], { cwd: repoRoot });
339
- if (view.status === 0) {
340
- let published = [];
341
- try {
342
- const parsed = JSON.parse(view.stdout);
343
- published = Array.isArray(parsed) ? parsed : [parsed];
344
- } catch {
345
- /* unparseable view output treat as unknown, fail below */
346
- }
347
- add('registry-version-free', !published.includes(target), published.includes(target) ? `${target} already published` : `latest: ${published[published.length - 1] ?? '?'}`);
348
- } else {
349
- add('registry-version-free', /E404/.test(view.stderr || ''), /E404/.test(view.stderr || '') ? 'package not yet on registry (first publish)' : `npm view failed: ${(view.stderr || '').slice(0, 200)}`);
741
+ const registry = evaluateRegistryCollision(view, target);
742
+ add('registry-version-free', registry.ok, registry.detail);
743
+
744
+ // 5b. npm token liveness. Read-only, one request, and it answers the one
745
+ // question the rest of the preflight cannot: is the credential we are about
746
+ // to publish with actually alive? Without it, a revoked or expired token
747
+ // surfaces inside publish() after every other check has gone green and the
748
+ // operator has committed to the release. Same token discipline as publish():
749
+ // .env.local only, temp userconfig at 0600, removed in a finally.
750
+ let auth;
751
+ try {
752
+ auth = withTempUserconfig(loadNpmToken(repoRoot), (rc) =>
753
+ run('npm', ['whoami', '--userconfig', rc], { cwd: repoRoot }),
754
+ );
755
+ const verdict = evaluateNpmAuth(auth);
756
+ add('npm-token-live', verdict.ok, verdict.detail);
757
+ } catch (err) {
758
+ // A missing/ungitignored .env.local is a legitimate red preflight, not a
759
+ // crash: "cannot publish from here" is exactly what the operator needs.
760
+ add('npm-token-live', false, err.message);
350
761
  }
351
762
 
352
763
  // 6. CI green on HEAD (the repo's iron session-start rule applies to
353
764
  // releases doubly: local green is not evidence — see .claude/rules).
765
+ // --skip-ci is refused under --publish upstream in validateFlags(); it can
766
+ // only reach this branch from --check.
354
767
  if (skipCi) {
355
768
  add('ci-green-on-head', true, 'SKIPPED via --skip-ci');
356
769
  } else {
@@ -361,10 +774,20 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
361
774
  }
362
775
 
363
776
  // 7. Leakage gate over the actual pack file list.
364
- const pack = run('npm', ['pack', '--dry-run'], { cwd: repoRoot });
365
- const lines = `${pack.stdout}\n${pack.stderr}`.split('\n');
366
- const violations = checkLeakage(lines);
367
- add('leakage-gate', pack.status === 0 && violations.length === 0, violations.length ? violations.map((v) => `${v.name}: ${v.line}`).slice(0, 5).join(' | ') : pack.status !== 0 ? 'npm pack failed' : `${lines.filter((l) => /npm notice.*[0-9]+B /.test(l)).length} packed entries, 0 leaks`);
777
+ // `npm_config_loglevel` is INHERITED, and `npm pack --dry-run` writes its whole
778
+ // file listing as `npm notice` lines. Any ancestor that ran under `npm run
779
+ // --silent` (the pre-push gate does exactly that) therefore hands this child a
780
+ // silent loglevel and the listing is EMPTY with exit 0 measured: 818 notice
781
+ // lines normally, 0 under `npm_config_loglevel=silent`. The blind-scan floor
782
+ // turns that into a correct fail-closed verdict, but a red leakage row that
783
+ // means "we could not look" is not the row anyone reads it as. Pin the level
784
+ // rather than inherit it, so the gate's input never depends on its caller.
785
+ const pack = run('npm', ['pack', '--dry-run'], {
786
+ cwd: repoRoot,
787
+ env: { ...process.env, npm_config_loglevel: 'notice' },
788
+ });
789
+ const leakage = evaluateLeakageGate(pack);
790
+ add('leakage-gate', leakage.ok, leakage.detail);
368
791
 
369
792
  return checks;
370
793
  }
@@ -376,58 +799,498 @@ function changelogExcerpt(repoRoot, target) {
376
799
  return m ? m[1].trim().split('\n').slice(0, 40).join('\n') : '';
377
800
  }
378
801
 
379
- function publish(repoRoot, target) {
380
- // Token: only from gitignored .env.local (verify the ignore before reading).
802
+ /**
803
+ * Read NPM_TOKEN from the gitignored .env.local, refusing if the ignore is not
804
+ * actually in force. Throws with an operator-actionable message; the token
805
+ * itself is never part of any message.
806
+ */
807
+ function loadNpmToken(repoRoot) {
381
808
  const ignored = run('git', ['check-ignore', '.env.local'], { cwd: repoRoot });
382
809
  if (ignored.status !== 0) throw new Error('.env.local is NOT gitignored — refusing to read a token from it');
383
- const envLocal = readFileSync(join(repoRoot, '.env.local'), 'utf8');
384
- const tokenMatch = envLocal.match(/^NPM_TOKEN=(.+)$/m);
810
+ if (!existsSync(join(repoRoot, '.env.local'))) throw new Error('.env.local not found — no NPM_TOKEN to publish with');
811
+ const tokenMatch = readFileSync(join(repoRoot, '.env.local'), 'utf8').match(/^NPM_TOKEN=(.+)$/m);
385
812
  if (!tokenMatch) throw new Error('NPM_TOKEN not found in .env.local');
386
- const token = tokenMatch[1].trim();
813
+ return tokenMatch[1].trim();
814
+ }
387
815
 
816
+ /**
817
+ * Run `fn(userconfigPath)` against a throwaway npm userconfig carrying the
818
+ * token. Extracted so the preflight liveness probe and the publish itself share
819
+ * ONE implementation of the security invariants from
820
+ * skills/npm-publish/SKILL.md — 0600, and removed in a finally even when the
821
+ * callback throws. Two hand-copied versions of this dance would be two places
822
+ * for a token file to be left behind.
823
+ */
824
+ function withTempUserconfig(token, fn) {
388
825
  const tmpDir = mkdtempSync(join(tmpdir(), 'release-npmrc-'));
389
826
  const tmpRc = join(tmpDir, 'npmrc');
390
827
  try {
391
- writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`);
828
+ writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 });
392
829
  chmodSync(tmpRc, 0o600);
393
- const res = run('npm', ['publish', '--access', 'public', '--userconfig', tmpRc], { cwd: repoRoot });
394
- const out = `${res.stdout}\n${res.stderr}`;
395
- if (res.status !== 0 || !out.includes(`+ ${PACKAGE_NAME}@${target}`)) {
396
- // Never echo the raw output wholesale into logs beyond the error slice —
397
- // it cannot contain the token (npm masks userconfig), but stay frugal.
398
- throw new Error(`npm publish failed (exit ${res.status}): ${out.slice(0, 800)}`);
399
- }
830
+ return fn(tmpRc);
400
831
  } finally {
401
- rmSync(tmpDir, { recursive: true, force: true });
832
+ // NEVER let cleanup decide the release outcome. This finally runs AFTER
833
+ // `npm publish` has already published and BEFORE the caller evaluates the
834
+ // receipt, so a throwing rmSync (EPERM/EBUSY -- `force` only swallows
835
+ // ENOENT) surfaced as a pre-receipt system failure: published, untagged,
836
+ // unpushed, and reported as "safe to re-run". That is the #1088 F1 shape at
837
+ // its last remaining site. A surviving 0600 token file is a hygiene problem,
838
+ // so it is announced rather than swallowed.
839
+ try {
840
+ rmSync(tmpDir, { recursive: true, force: true });
841
+ } catch (err) {
842
+ process.stderr.write(
843
+ `WARN: could not remove temporary npm userconfig ${tmpDir} (${err?.message ?? err}). ` +
844
+ `It contains a write token — delete it and rotate the token.\n`,
845
+ );
846
+ }
402
847
  }
848
+ }
849
+
850
+ /**
851
+ * Evaluate npm publish output for the receipt that makes the release immutable.
852
+ * A successful process alone is insufficient: the receipt must name this package
853
+ * and this exact target version.
854
+ *
855
+ * @param {{status: number|null, stdout?: string, stderr?: string}} result
856
+ * @param {string} target
857
+ * @returns {{confirmed: boolean, target: string, detail: string}}
858
+ */
859
+ export function evaluatePublishReceipt(result, target) {
860
+ const output = `${result.stdout || ''}\n${result.stderr || ''}`;
861
+ const receipt = new RegExp(`(?:^|\\n)\\+ ${PACKAGE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}@${target.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\r?$|\\s)`);
862
+ const confirmed = result.status === 0 && receipt.test(output);
863
+ return {
864
+ confirmed,
865
+ target,
866
+ detail: confirmed
867
+ ? `${PACKAGE_NAME}@${target} receipt confirmed`
868
+ : `npm publish did not emit a target-confirmed receipt for ${PACKAGE_NAME}@${target} (exit ${result.status})`,
869
+ };
870
+ }
871
+
872
+ /**
873
+ * Poll the registry after npm has issued a target-confirmed receipt. Every
874
+ * failure remains visible, but none invalidates the already irreversible npm
875
+ * publish; callers must reconcile rather than retry `--publish`.
876
+ *
877
+ * @param {string} repoRoot
878
+ * @param {string} target
879
+ * @param {{attempts?: number, delaySeconds?: number, runImpl?: Function, waitImpl?: Function}} [deps]
880
+ * @returns {{ok: boolean, kind: 'verified'|'timeout'|'query-failed'|'wait-failed', attempts: number, detail: string}}
881
+ */
882
+ export function waitForRegistryPropagation(repoRoot, target, deps = {}) {
883
+ const attempts = deps.attempts ?? 5;
884
+ const delaySeconds = deps.delaySeconds ?? 3;
885
+ const runImpl = deps.runImpl ?? run;
886
+ const waitImpl = deps.waitImpl ?? (() => runImpl('sleep', [String(delaySeconds)], { cwd: repoRoot }));
403
887
 
404
- // Registry verify with propagation retries.
405
- for (let attempt = 1; attempt <= 5; attempt++) {
406
- const view = run('npm', ['view', PACKAGE_NAME, 'version'], { cwd: repoRoot });
407
- if (view.status === 0 && view.stdout.trim() === target) return;
408
- if (attempt < 5) spawnSync('sleep', ['3']);
888
+ for (let attempt = 1; attempt <= attempts; attempt++) {
889
+ let view;
890
+ try {
891
+ view = runImpl('npm', ['view', PACKAGE_NAME, 'version'], { cwd: repoRoot });
892
+ } catch (err) {
893
+ return {
894
+ ok: false,
895
+ kind: 'query-failed',
896
+ attempts: attempt,
897
+ detail: `registry query failed on attempt ${attempt}/${attempts}: ${err.message}`,
898
+ };
899
+ }
900
+ if (view.status === 0 && (view.stdout || '').trim() === target) {
901
+ return {
902
+ ok: true,
903
+ kind: 'verified',
904
+ attempts: attempt,
905
+ detail: `registry reports ${target} on attempt ${attempt}/${attempts}`,
906
+ };
907
+ }
908
+ if (view.status !== 0) {
909
+ return {
910
+ ok: false,
911
+ kind: 'query-failed',
912
+ attempts: attempt,
913
+ detail: `registry query failed on attempt ${attempt}/${attempts} (exit ${view.status}): ${(view.stderr || view.stdout || '').trim().slice(0, 300)}`,
914
+ };
915
+ }
916
+ if (attempt < attempts) {
917
+ let wait;
918
+ try {
919
+ wait = waitImpl({ attempt, delaySeconds });
920
+ } catch (err) {
921
+ return {
922
+ ok: false,
923
+ kind: 'wait-failed',
924
+ attempts: attempt,
925
+ detail: `registry propagation wait failed after attempt ${attempt}/${attempts}: ${err.message}`,
926
+ };
927
+ }
928
+ if (!wait || wait.status !== 0 || wait.error) {
929
+ return {
930
+ ok: false,
931
+ kind: 'wait-failed',
932
+ attempts: attempt,
933
+ detail: `registry propagation wait failed after attempt ${attempt}/${attempts} (exit ${wait?.status ?? 'unknown'}): ${(wait?.error?.message || wait?.stderr || wait?.stdout || '').trim().slice(0, 300)}`,
934
+ };
935
+ }
936
+ }
409
937
  }
410
- throw new Error(`registry verify failed: npm view does not report ${target} after 5 attempts`);
938
+ return {
939
+ ok: false,
940
+ kind: 'timeout',
941
+ attempts,
942
+ detail: `registry did not report ${target} after ${attempts} attempts`,
943
+ };
944
+ }
945
+
946
+ /**
947
+ * Publish and return the receipt boundary plus the registry reconciliation
948
+ * result. Pre-receipt failures throw; post-receipt propagation failures return.
949
+ *
950
+ * @param {string} repoRoot
951
+ * @param {string} target
952
+ * @param {{runImpl?: Function, waitImpl?: Function, attempts?: number, delaySeconds?: number}} [deps]
953
+ * @returns {{receipt: {confirmed: boolean, target: string, detail: string}, propagation: ReturnType<typeof waitForRegistryPropagation>}}
954
+ */
955
+ // Deliberately NOT exported: this is the irreversible act, and every production
956
+ // path to it runs through main() -> preflight() (leakage gate, CI gate, dirty-tree
957
+ // gate). Exporting it made the whole gate chain bypassable by any importer, and no
958
+ // consumer needs it -- the tests drive runPublishRelease with an injected publisher.
959
+ function publish(repoRoot, target, deps = {}) {
960
+ const token = loadNpmToken(repoRoot);
961
+ const runImpl = deps.runImpl ?? run;
962
+ const res = withTempUserconfig(token, (tmpRc) =>
963
+ runImpl('npm', ['publish', '--access', 'public', '--userconfig', tmpRc], { cwd: repoRoot }),
964
+ );
965
+ const receipt = evaluatePublishReceipt(res, target);
966
+ if (!receipt.confirmed) throw new Error(receipt.detail);
967
+
968
+ const propagation = waitForRegistryPropagation(repoRoot, target, {
969
+ attempts: deps.attempts,
970
+ delaySeconds: deps.delaySeconds,
971
+ runImpl,
972
+ waitImpl: deps.waitImpl,
973
+ });
974
+ return { receipt, propagation };
411
975
  }
412
976
 
413
977
  function tagAndPush(repoRoot, target) {
414
978
  const tag = `v${target}`;
415
- const excerpt = changelogExcerpt(repoRoot, target);
416
- const msgDir = mkdtempSync(join(tmpdir(), 'release-tagmsg-'));
417
- const msgFile = join(msgDir, 'msg');
979
+ const progress = { tag, localTagCreated: false, pushed: [], remotes: [] };
418
980
  try {
419
- writeFileSync(msgFile, `${tag}\n\n${excerpt}\n`);
420
- mustRun('git', ['tag', '-a', tag, '-F', msgFile], { cwd: repoRoot });
421
- } finally {
422
- rmSync(msgDir, { recursive: true, force: true });
981
+ const excerpt = changelogExcerpt(repoRoot, target);
982
+ const msgDir = mkdtempSync(join(tmpdir(), 'release-tagmsg-'));
983
+ const msgFile = join(msgDir, 'msg');
984
+ try {
985
+ writeFileSync(msgFile, `${tag}\n\n${excerpt}\n`);
986
+ mustRun('git', ['tag', '-a', tag, '-F', msgFile], { cwd: repoRoot });
987
+ progress.localTagCreated = true;
988
+ } finally {
989
+ rmSync(msgDir, { recursive: true, force: true });
990
+ }
991
+ for (const remote of ['origin', 'github']) {
992
+ const remoteProgress = { remote, mainPushed: false, tagPushed: false };
993
+ progress.remotes.push(remoteProgress);
994
+ mustRun('git', ['push', remote, 'main'], { cwd: repoRoot });
995
+ remoteProgress.mainPushed = true;
996
+ mustRun('git', ['push', remote, tag], { cwd: repoRoot });
997
+ remoteProgress.tagPushed = true;
998
+ progress.pushed.push(remote);
999
+ }
1000
+ return { tag, pushed: progress.pushed };
1001
+ } catch (err) {
1002
+ const failure = err instanceof Error ? err : new Error(String(err));
1003
+ failure.releaseProgress = progress;
1004
+ throw failure;
423
1005
  }
424
- const pushed = [];
425
- for (const remote of ['origin', 'github']) {
426
- mustRun('git', ['push', remote, 'main'], { cwd: repoRoot });
427
- mustRun('git', ['push', remote, tag], { cwd: repoRoot });
428
- pushed.push(remote);
1006
+ }
1007
+
1008
+ /**
1009
+ * Run the irreversible-release tail after npm's target-confirmed receipt.
1010
+ * A tag/push failure stops tag-dependent phases; every other post-receipt
1011
+ * finding remains a returned reconciliation result rather than a retry signal.
1012
+ *
1013
+ * @param {string} repoRoot
1014
+ * @param {string} target
1015
+ * @param {{publishImpl?: Function, tagAndPushImpl?: Function, ensureGithubReleaseImpl?: Function, verifyLiveSiteImpl?: Function}} [deps]
1016
+ * @returns {Promise<{status: 'complete'|'post-publish-reconciliation', receipt: object, tag: string|null, pushed: string[], tagProgress?: object, release: object, live: object, propagation: object, reconciliation: Array<{phase: string, kind?: string, detail: string}>}>}
1017
+ */
1018
+ export async function runPublishRelease(repoRoot, target, deps = {}) {
1019
+ // Fail-closed: the real publisher must be handed in explicitly. Defaulting to
1020
+ // the live `npm publish --access public` meant an importer that merely forgot
1021
+ // `publishImpl` performed an irreversible public release with the token from
1022
+ // .env.local and no preflight. main() wires it at the one call site that sits
1023
+ // behind the gate chain.
1024
+ const publishImpl = deps.publishImpl;
1025
+ if (typeof publishImpl !== 'function') {
1026
+ throw new Error('runPublishRelease requires an explicit publishImpl — refusing to publish by default');
1027
+ }
1028
+ const tagAndPushImpl = deps.tagAndPushImpl ?? tagAndPush;
1029
+ const ensureGithubReleaseImpl = deps.ensureGithubReleaseImpl ?? ensureGithubRelease;
1030
+ const verifyLiveSiteImpl = deps.verifyLiveSiteImpl ?? verifyLiveSite;
1031
+ const publication = publishImpl(repoRoot, target);
1032
+
1033
+ if (!publication?.receipt?.confirmed || publication.receipt.target !== target) {
1034
+ throw new Error(`refusing release tail without a target-confirmed npm publish receipt for ${PACKAGE_NAME}@${target}`);
1035
+ }
1036
+
1037
+ const propagation = publication.propagation;
1038
+ let tagAndPushResult;
1039
+ try {
1040
+ tagAndPushResult = tagAndPushImpl(repoRoot, target);
1041
+ } catch (err) {
1042
+ const rawProgress = err?.releaseProgress;
1043
+ const remotes = Array.isArray(rawProgress?.remotes)
1044
+ ? rawProgress.remotes
1045
+ .filter((remote) => typeof remote?.remote === 'string')
1046
+ .map((remote) => ({
1047
+ remote: remote.remote,
1048
+ mainPushed: remote.mainPushed === true,
1049
+ tagPushed: remote.tagPushed === true,
1050
+ }))
1051
+ : [];
1052
+ const tagProgress = {
1053
+ tag: typeof rawProgress?.tag === 'string' ? rawProgress.tag : null,
1054
+ localTagCreated: rawProgress?.localTagCreated === true,
1055
+ remotes,
1056
+ };
1057
+ const pushed = remotes.filter((remote) => remote.mainPushed && remote.tagPushed).map((remote) => remote.remote);
1058
+ const prerequisite = 'skipped because tag-and-push did not complete';
1059
+ return {
1060
+ status: 'post-publish-reconciliation',
1061
+ receipt: publication.receipt,
1062
+ tag: tagProgress.tag,
1063
+ pushed,
1064
+ tagProgress,
1065
+ release: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
1066
+ live: { ok: false, skipped: true, state: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
1067
+ propagation,
1068
+ reconciliation: [
1069
+ { phase: 'tag-and-push', kind: 'failed', detail: err instanceof Error ? err.message : String(err) },
1070
+ { phase: 'github-release', kind: 'skipped-prerequisite', detail: `GitHub release ${prerequisite}` },
1071
+ { phase: 'live-site', kind: 'skipped-prerequisite', detail: `live-site verification ${prerequisite}` },
1072
+ ],
1073
+ };
1074
+ }
1075
+
1076
+ const { tag, pushed } = tagAndPushResult;
1077
+ const release = ensureGithubReleaseImpl(repoRoot, target);
1078
+ const live = await verifyLiveSiteImpl(target);
1079
+ const reconciliation = [];
1080
+ if (!propagation?.ok) {
1081
+ reconciliation.push({
1082
+ phase: 'registry-propagation',
1083
+ kind: propagation?.kind ?? 'unknown',
1084
+ detail: propagation?.detail ?? 'registry propagation was not verified',
1085
+ });
1086
+ }
1087
+ if (!release.ok) reconciliation.push({ phase: 'github-release', detail: release.detail });
1088
+ if (!live.ok) reconciliation.push({ phase: 'live-site', detail: live.detail });
1089
+
1090
+ return {
1091
+ status: reconciliation.length === 0 ? 'complete' : 'post-publish-reconciliation',
1092
+ receipt: publication.receipt,
1093
+ tag,
1094
+ pushed,
1095
+ release,
1096
+ live,
1097
+ propagation,
1098
+ reconciliation,
1099
+ };
1100
+ }
1101
+
1102
+ /**
1103
+ * Print a completed publish-tail outcome and return the CLI exit code.
1104
+ *
1105
+ * @param {{status: string, propagation: object, tag: string|null, pushed: string[], release: object, live: object, reconciliation: Array<{phase: string, kind?: string, detail: string}>}} outcome
1106
+ * @param {string} target
1107
+ * @param {{log?: Function, error?: Function}} [io]
1108
+ * @returns {number}
1109
+ */
1110
+ export function printPublishOutcome(outcome, target, io = {}) {
1111
+ const log = io.log ?? console.log;
1112
+ const error = io.error ?? console.error;
1113
+ const tagAndPushFailed = outcome.reconciliation.some((item) => item.phase === 'tag-and-push');
1114
+
1115
+ log(` + ${PACKAGE_NAME}@${target} — target-confirmed npm receipt.`);
1116
+ if (outcome.propagation.ok) {
1117
+ log(` registry verified (${outcome.propagation.detail}).`);
1118
+ } else {
1119
+ error(`\nRECONCILIATION: registry propagation is not yet verified — ${outcome.propagation.detail}`);
1120
+ }
1121
+ if (!tagAndPushFailed) {
1122
+ log(` tagged ${outcome.tag} (AFTER publish) and pushed main+tag to: ${outcome.pushed.join(', ')}.`);
1123
+ }
1124
+
1125
+ if (outcome.release.skipped) {
1126
+ error(`\nSKIPPED: ${outcome.release.detail}.`);
1127
+ } else if (outcome.release.ok) {
1128
+ log(` ${outcome.release.detail}.`);
1129
+ } else {
1130
+ error(`\nRECONCILIATION: ${outcome.release.detail}`);
1131
+ if (outcome.release.state === 'create-failed') {
1132
+ error(` Recover with: gh release create ${outcome.tag} --verify-tag --title ${outcome.tag} --notes-file <changelog excerpt>`);
1133
+ } else {
1134
+ error(' Inspect `gh release view` and its authentication/network state before attempting any create.');
1135
+ }
1136
+ }
1137
+
1138
+ if (outcome.live.skipped) {
1139
+ error(`\nSKIPPED: ${outcome.live.detail}.`);
1140
+ } else if (!outcome.live.ok) {
1141
+ error(`\nRECONCILIATION: live site did not reach ${target}.`);
1142
+ error(` ${outcome.live.detail}`);
1143
+ error(' Check https://vercel.com/kanevrys-projects/session-orchestrator for the deploy.');
1144
+ } else {
1145
+ log(` site live at ${target} (${outcome.live.detail}).`);
1146
+ }
1147
+
1148
+ if (outcome.status === 'post-publish-reconciliation') {
1149
+ error('\nPost-publish reconciliation required: npm has accepted the target release.');
1150
+ for (const item of outcome.reconciliation) {
1151
+ error(` - ${item.phase}${item.kind ? ` (${item.kind})` : ''}: ${item.detail}`);
1152
+ }
1153
+ error(' Do NOT rerun `--publish`; reconcile the listed post-publish state directly.');
1154
+ return 1;
1155
+ }
1156
+
1157
+ log(`\nRelease complete: ${PACKAGE_NAME}@${target} is published, tagged, released and live.`);
1158
+ log('\nPost-release checklist (manual):');
1159
+ log(' 1. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
1160
+ log(' 2. pi.dev gallery indexes asynchronously — do not block on it.');
1161
+ return 0;
1162
+ }
1163
+
1164
+ /**
1165
+ * Create the GitHub release for `v<target>`, or confirm the existing one.
1166
+ *
1167
+ * WHY THIS IS CODE AND NOT A CHECKLIST LINE: it was a checklist line, and the
1168
+ * evidence that a checklist line is not a mechanism is in the release history.
1169
+ * The GitHub releases for v3.15, v3.18, v3.19 and v3.20 were all created within
1170
+ * a THREE-SECOND window on 2026-08-19 — hand-backfilled in one sitting, 5 to 31
1171
+ * days after their tags, where the releases that were not forgotten were made 19
1172
+ * seconds to 2.5 minutes after theirs. The same class of gap left 3.18.0 with a
1173
+ * tag, a GitHub release and a CHANGELOG entry that the npm registry has still
1174
+ * never seen.
1175
+ *
1176
+ * Three properties make this safe to run unconditionally after a push:
1177
+ * - `--verify-tag` makes gh refuse when the tag is not on the remote, so
1178
+ * "release without a tag" is structurally impossible rather than merely
1179
+ * discouraged.
1180
+ * - The `gh release view` probe avoids a duplicate-release error when a
1181
+ * release is already present. It does not authorize rerunning `--publish`:
1182
+ * post-receipt failures are reconciled directly.
1183
+ * - The `-R` spec comes from `resolveRepoSpec({vcs:'github'})` (#1039), not a
1184
+ * hardcoded owner/repo, so a fork or a renamed remote targets its own repo.
1185
+ *
1186
+ * Never throws: the caller has already published to npm and pushed both tags by
1187
+ * the time this runs, so an exception here would report a successful release as
1188
+ * a crash. Failure comes back as `{ok:false}` with the recovery command.
1189
+ *
1190
+ * @param {string} repoRoot
1191
+ * @param {string} target
1192
+ * @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
1193
+ * @returns {{ok: boolean, created: boolean, tag: string, state: 'exists'|'created'|'unknown'|'create-failed', detail: string, argv?: string[]}}
1194
+ */
1195
+ export function ensureGithubRelease(repoRoot, target, deps = {}) {
1196
+ const runImpl = deps.runImpl ?? run;
1197
+ const tag = `v${target}`;
1198
+ const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
1199
+ // resolveRepoSpec returns undefined when it cannot auto-detect; its contract
1200
+ // is that callers OMIT the flag rather than pass `-R undefined`.
1201
+ const repoFlag = spec ? ['--repo', spec] : [];
1202
+
1203
+ try {
1204
+ const existing = runImpl('gh', ['release', 'view', tag, ...repoFlag], { cwd: repoRoot });
1205
+ const viewOutput = `${existing.stdout || ''}\n${existing.stderr || ''}`.trim();
1206
+ if (existing.status === 0 && viewOutput) {
1207
+ return { ok: true, created: false, tag, state: 'exists', detail: `GitHub release ${tag} already exists — no-op` };
1208
+ }
1209
+ // `gh release view` is tri-state. Only its documented absence response is
1210
+ // permission to create; auth, network, empty and malformed responses leave
1211
+ // release state unknown and must not trigger a write to GitHub.
1212
+ if (!(existing.status === 1 && /^release not found$/i.test(viewOutput))) {
1213
+ return {
1214
+ ok: false,
1215
+ created: false,
1216
+ tag,
1217
+ state: 'unknown',
1218
+ detail: `could not determine whether GitHub release ${tag} exists (gh release view exited ${existing.status}: ${viewOutput.slice(0, 300) || 'empty output'})`,
1219
+ };
1220
+ }
1221
+
1222
+ const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
1223
+ const notesFile = join(notesDir, 'notes.md');
1224
+ let argv;
1225
+ try {
1226
+ writeFileSync(notesFile, `${changelogExcerpt(repoRoot, target)}\n`);
1227
+ argv = ['release', 'create', tag, ...repoFlag, '--verify-tag', '--title', tag, '--notes-file', notesFile];
1228
+ const created = runImpl('gh', argv, { cwd: repoRoot });
1229
+ if (created.status !== 0) {
1230
+ return {
1231
+ ok: false,
1232
+ created: false,
1233
+ tag,
1234
+ state: 'create-failed',
1235
+ argv,
1236
+ detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
1237
+ };
1238
+ }
1239
+ return { ok: true, created: true, tag, state: 'created', argv, detail: `GitHub release ${tag} created (--verify-tag)` };
1240
+ } finally {
1241
+ rmSync(notesDir, { recursive: true, force: true });
1242
+ }
1243
+ } catch (err) {
1244
+ return { ok: false, created: false, tag, state: 'unknown', detail: `gh could not be run: ${err.message}` };
429
1245
  }
430
- return { tag, pushed };
1246
+ }
1247
+
1248
+ /**
1249
+ * Poll the live site until it serves `expected`, or give up.
1250
+ *
1251
+ * WHY POLLING: the Vercel git integration builds asynchronously after the push
1252
+ * to `github`, so a single immediate check would report a false negative on
1253
+ * every release. WHY AT ALL: the live site silently fell a release behind twice
1254
+ * in four weeks (#1043) — a deploy that reports success at the push and is
1255
+ * never re-read afterwards cannot tell "deployed" from "did not deploy".
1256
+ *
1257
+ * Fail-closed by design: a network error, a non-200, an unparseable body and a
1258
+ * genuine version mismatch are four DISTINCT reported outcomes, never collapsed
1259
+ * onto one "not ok" — collapsing them is the defect class this replaces.
1260
+ *
1261
+ * @param {string} expected — the version literal the site must serve
1262
+ * @param {{url?: string, attempts?: number, delayMs?: number, fetchImpl?: Function}} [opts]
1263
+ * @returns {Promise<{ok: boolean, detail: string}>}
1264
+ */
1265
+ export async function verifyLiveSite(expected, opts = {}) {
1266
+ const url = opts.url ?? 'https://session-orchestrator.com/llms.txt';
1267
+ const attempts = opts.attempts ?? 12;
1268
+ const delayMs = opts.delayMs ?? 10_000;
1269
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
1270
+ let last = 'no attempt made';
1271
+
1272
+ for (let i = 1; i <= attempts; i++) {
1273
+ try {
1274
+ const res = await doFetch(url, { headers: { 'Cache-Control': 'no-cache' } });
1275
+ if (!res.ok) {
1276
+ last = `HTTP ${res.status} from ${url}`;
1277
+ } else {
1278
+ const body = await res.text();
1279
+ const m = body.match(/^Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m);
1280
+ if (!m) {
1281
+ last = `no "Version: X.Y.Z" line in ${url} (${body.length} bytes) — the surface moved, fix the check`;
1282
+ } else if (m[1] === expected) {
1283
+ return { ok: true, detail: `attempt ${i}/${attempts}, ${url}` };
1284
+ } else {
1285
+ last = `live serves ${m[1]}, expected ${expected}`;
1286
+ }
1287
+ }
1288
+ } catch (err) {
1289
+ last = `fetch failed: ${err.message}`;
1290
+ }
1291
+ if (i < attempts) await new Promise((r) => setTimeout(r, delayMs));
1292
+ }
1293
+ return { ok: false, detail: `${last} (gave up after ${attempts} attempts)` };
431
1294
  }
432
1295
 
433
1296
  function printChecks(checks, asJson, version) {
@@ -459,9 +1322,18 @@ async function main() {
459
1322
  if (values.help) {
460
1323
  console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
461
1324
  console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
462
- console.log('Exit codes: 0 success, 1 check failure, 2 system/usage error.');
1325
+ console.log(' Receipt boundary: before the confirmed npm receipt, failure aborts; after it, never rerun --publish.');
1326
+ console.log(' Tag/push failure after receipt skips GitHub-release and site phases and returns reconciliation guidance.');
1327
+ console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
1328
+ console.log('Exit codes: 0 success, 1 preflight/check failure or post-publish reconciliation, 2 pre-receipt system/usage error.');
463
1329
  return 0;
464
1330
  }
1331
+
1332
+ const flags = validateFlags(values);
1333
+ if (!flags.ok) {
1334
+ console.error(flags.message);
1335
+ return flags.code;
1336
+ }
465
1337
  if (values.version) {
466
1338
  console.log(readPackageVersion(repoRootOf()));
467
1339
  return 0;
@@ -477,9 +1349,20 @@ async function main() {
477
1349
  }
478
1350
  const changed = applyVersion(repoRoot, target);
479
1351
  mustRun('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: repoRoot });
1352
+
1353
+ // Re-stamp the site's measured census (#1043, second drift level). The
1354
+ // version literals above are only half the problem: the "Measured in this
1355
+ // repository" block was typed once on 2026-08-03 and 5 of its 8 figures
1356
+ // were wrong twelve days later. Release time is the RIGHT moment and CI is
1357
+ // the wrong one — `sessions` and `learnings` grow on every session, so a
1358
+ // pipeline gate on them would be permanently red. The page discloses that
1359
+ // by stamping the date and SHA it was counted at, which this refreshes too.
1360
+ mustRun('node', ['scripts/site-numbers.mjs', '--write'], { cwd: repoRoot });
1361
+
480
1362
  console.log(`Rewrote ${changed.length} surface file(s) to ${target}:`);
481
1363
  for (const f of changed) console.log(` ${f}`);
482
1364
  console.log(' package-lock.json (via npm install --package-lock-only)');
1365
+ console.log(' site/index.html cells + site/_census.json re-stamped (scripts/site-numbers.mjs --write) — commit BOTH');
483
1366
  console.log('\nEditorial TODOs (enforced by --check):');
484
1367
  console.log(` 1. CHANGELOG.md — write the "## [${target}] - YYYY-MM-DD" entry, fold [Unreleased].`);
485
1368
  console.log(' 2. README.md — rewrite the "Recent highlights" section content.');
@@ -494,15 +1377,8 @@ async function main() {
494
1377
  if (!values.publish) return 0;
495
1378
 
496
1379
  console.log(`\nPublishing ${PACKAGE_NAME}@${target} ...`);
497
- publish(repoRoot, target);
498
- console.log(` + ${PACKAGE_NAME}@${target} — registry verified.`);
499
- const { tag, pushed } = tagAndPush(repoRoot, target);
500
- console.log(` tagged ${tag} (AFTER publish) and pushed main+tag to: ${pushed.join(', ')}.`);
501
- console.log('\nPost-release checklist (manual):');
502
- console.log(' 1. Site deploy: cd site && vercel --prod');
503
- console.log(' 2. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
504
- console.log(' 3. pi.dev gallery indexes asynchronously — do not block on it.');
505
- return 0;
1380
+ const outcome = await runPublishRelease(repoRoot, target, { publishImpl: publish });
1381
+ return printPublishOutcome(outcome, target);
506
1382
  }
507
1383
 
508
1384
  console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');