session-orchestrator 3.19.0 → 3.21.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 (158) 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/030-wave-execution.mdc +10 -8
  5. package/CHANGELOG.md +494 -0
  6. package/README.md +16 -11
  7. package/agents/analyst.md +1 -1
  8. package/agents/architect-reviewer.md +1 -1
  9. package/agents/code-implementer.md +4 -2
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/dialectic-deriver.md +1 -1
  12. package/agents/docs-writer.md +1 -1
  13. package/agents/memory-proposal-collector.md +1 -1
  14. package/agents/qa-strategist.md +1 -1
  15. package/agents/security-reviewer.md +1 -1
  16. package/agents/session-reviewer.md +42 -1
  17. package/agents/skill-applied-judge.md +1 -1
  18. package/agents/test-writer.md +1 -1
  19. package/agents/ui-developer.md +1 -1
  20. package/agents/ux-evaluator.md +1 -1
  21. package/commands/release.md +60 -0
  22. package/commands/session.md +6 -2
  23. package/docs/USER-GUIDE.md +1 -1
  24. package/docs/instruction-delivery.md +350 -0
  25. package/docs/migration-v3.md +9 -6
  26. package/docs/persona-panel.md +3 -1
  27. package/docs/scope-collision-guard.md +167 -0
  28. package/docs/session-config-reference.md +1 -41
  29. package/docs/session-config-template.md +0 -23
  30. package/hooks/_lib/guard-source-loader.mjs +304 -91
  31. package/hooks/enforce-commands.mjs +216 -17
  32. package/hooks/enforce-scope.mjs +236 -12
  33. package/hooks/hooks-codex.json +1 -1
  34. package/hooks/hooks.json +11 -1
  35. package/hooks/on-session-end.mjs +52 -5
  36. package/hooks/on-session-start.mjs +7 -4
  37. package/hooks/on-stop.mjs +127 -12
  38. package/hooks/post-bash-write-verify.mjs +8 -32
  39. package/hooks/pre-bash-destructive-guard.mjs +146 -59
  40. package/hooks/pre-bash-sessions-ledger-guard.mjs +493 -66
  41. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  42. package/package.json +2 -2
  43. package/pi/prompts/release.md +12 -0
  44. package/scripts/autopilot.mjs +3 -1
  45. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  46. package/scripts/emit-session.mjs +45 -40
  47. package/scripts/export-hw-learnings.mjs +61 -2
  48. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  49. package/scripts/lib/backlog-scan.mjs +106 -15
  50. package/scripts/lib/build-live-signals.mjs +7 -3
  51. package/scripts/lib/ci-status-banner.mjs +207 -23
  52. package/scripts/lib/command-blocker.mjs +322 -62
  53. package/scripts/lib/git-config-drift.mjs +471 -0
  54. package/scripts/lib/hardening.mjs +9 -9
  55. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  56. package/scripts/lib/io.mjs +193 -7
  57. package/scripts/lib/learnings/affinity.mjs +434 -0
  58. package/scripts/lib/learnings/candidates.mjs +736 -0
  59. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  60. package/scripts/lib/learnings/judgment.mjs +782 -0
  61. package/scripts/lib/learnings/kebab.mjs +128 -0
  62. package/scripts/lib/learnings/select.mjs +704 -0
  63. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  64. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  65. package/scripts/lib/named-vault-resolver.mjs +105 -16
  66. package/scripts/lib/peer-cards/schema.mjs +6 -2
  67. package/scripts/lib/reconcile/emitter.mjs +107 -22
  68. package/scripts/lib/reconcile/engine.mjs +9 -15
  69. package/scripts/lib/reconcile/renderer.mjs +141 -25
  70. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  71. package/scripts/lib/reconcile/writer.mjs +134 -1
  72. package/scripts/lib/redact-spans.mjs +89 -0
  73. package/scripts/lib/scope-baseline.mjs +77 -17
  74. package/scripts/lib/scope-gate.mjs +852 -72
  75. package/scripts/lib/secret-masker.mjs +262 -0
  76. package/scripts/lib/session-close-backfill.mjs +2 -2
  77. package/scripts/lib/session-lock.mjs +34 -10
  78. package/scripts/lib/session-record-repair.mjs +551 -0
  79. package/scripts/lib/session-registry.mjs +9 -1
  80. package/scripts/lib/session-schema/serializer.mjs +54 -0
  81. package/scripts/lib/session-schema.mjs +1 -0
  82. package/scripts/lib/session-token-rollup.mjs +68 -6
  83. package/scripts/lib/soul-resolve.mjs +12 -0
  84. package/scripts/lib/state-md/mission-status.mjs +21 -12
  85. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  86. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  87. package/scripts/lib/validate/check-agents.mjs +77 -5
  88. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  89. package/scripts/lib/validate/check-commands.mjs +2 -20
  90. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  91. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +18 -0
  93. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  94. package/scripts/lib/validate/check-owner-leakage.mjs +188 -20
  95. package/scripts/lib/validate/check-rules.mjs +31 -5
  96. package/scripts/lib/validate/check-skills.mjs +191 -0
  97. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  98. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  99. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  100. package/scripts/lib/validate/check-unwired-features.mjs +757 -0
  101. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  102. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  103. package/scripts/lib/validate/tier-inference.mjs +46 -8
  104. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  105. package/scripts/lib/vault-mirror/process.mjs +264 -31
  106. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  107. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  108. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  109. package/scripts/lib/vcs-repo-spec.mjs +500 -19
  110. package/scripts/print-applicable-rules.mjs +170 -7
  111. package/scripts/print-learnings-index.mjs +501 -0
  112. package/scripts/release.mjs +616 -61
  113. package/scripts/repair-invalid-sessions.mjs +209 -0
  114. package/scripts/site-numbers.mjs +1049 -0
  115. package/scripts/sweep-expired-learnings.mjs +192 -32
  116. package/scripts/validate-plugin.mjs +82 -0
  117. package/scripts/validate-wave-scope.mjs +281 -12
  118. package/scripts/vault-mirror.mjs +26 -1
  119. package/skills/_shared/monitor-patterns.md +24 -4
  120. package/skills/_shared/state-ownership.md +17 -0
  121. package/skills/brainstorm/soul.md +47 -1
  122. package/skills/claude-md-drift-check/SKILL.md +9 -1
  123. package/skills/debug/SKILL.md +4 -1
  124. package/skills/discovery/issue-templates.md +4 -4
  125. package/skills/discovery/probes-code.md +2 -2
  126. package/skills/discovery/probes-feature.md +6 -6
  127. package/skills/discovery/probes-infra.md +2 -2
  128. package/skills/discovery/probes-session.md +5 -5
  129. package/skills/dispatcher/SKILL.md +10 -1
  130. package/skills/evolve/SKILL.md +116 -18
  131. package/skills/frontmatter-guard/SKILL.md +9 -1
  132. package/skills/gitlab-ops/SKILL.md +54 -39
  133. package/skills/gitlab-portfolio/SKILL.md +10 -1
  134. package/skills/grill/soul.md +44 -1
  135. package/skills/memory-cleanup/SKILL.md +18 -5
  136. package/skills/npm-publish/SKILL.md +22 -50
  137. package/skills/persona-panel/SKILL.md +3 -1
  138. package/skills/plan/mode-new.md +23 -5
  139. package/skills/plan/soul.md +46 -3
  140. package/skills/repo-audit/SKILL.md +10 -1
  141. package/skills/session-end/SKILL.md +45 -26
  142. package/skills/session-end/metrics-collection.md +1 -1
  143. package/skills/session-end/phase-3-6-tail.md +30 -1
  144. package/skills/session-end/plan-verification.md +1 -5
  145. package/skills/session-end/session-metrics-write.md +6 -10
  146. package/skills/session-plan/SKILL.md +2 -2
  147. package/skills/session-plan/wave-template.md +1 -1
  148. package/skills/session-start/SKILL.md +15 -1
  149. package/skills/session-start/soul.md +41 -1
  150. package/skills/spinout/SKILL.md +5 -1
  151. package/skills/sunset-review/SKILL.md +11 -1
  152. package/skills/tmux-layout/SKILL.md +7 -2
  153. package/skills/vault-mirror/SKILL.md +10 -1
  154. package/skills/vault-sync/SKILL.md +10 -1
  155. package/skills/vault-sync/validator.mjs +55 -6
  156. package/skills/wave-executor/SKILL.md +1 -5
  157. package/skills/wave-executor/wave-loop.md +77 -82
  158. package/scripts/lib/mission-status-schema.mjs +0 -114
@@ -20,28 +20,47 @@
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`.
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`.
26
27
  // --publish Runs --check first, then: token publish via temp
27
28
  // userconfig (NPM_TOKEN from .env.local), registry
28
29
  // verify, annotated tag AFTER successful publish
29
30
  // (never before — eliminates "tagged but unpublished"),
30
31
  // push main + tag to origin AND the github mirror,
31
- // then print the post-release checklist (site deploy,
32
- // token rotation).
32
+ // the GitHub release (idempotent, `--verify-tag`), the
33
+ // live-site poll, then the post-release checklist.
33
34
  //
34
35
  // USAGE:
35
- // node scripts/release.mjs --check [--json]
36
+ // node scripts/release.mjs --check [--json] [--skip-ci]
36
37
  // node scripts/release.mjs --set-version 3.19.0
37
38
  // node scripts/release.mjs --publish [--json]
38
39
  //
39
40
  // EXIT CODES:
40
41
  // 0 success
41
42
  // 1 check failure (stale surface, missing CHANGELOG entry, tag/registry
42
- // collision, CI not green, leakage-gate hit)
43
+ // collision, mirror behind, dead token, CI not green, leakage-gate hit)
43
44
  // 2 system/usage error (git/npm spawn failure, missing NPM_TOKEN,
44
- // unknown flag)
45
+ // unknown flag, --skip-ci combined with --publish)
46
+ //
47
+ // FAIL-CLOSED IS THE HOUSE RULE (the defect class this file kept re-growing):
48
+ // A preflight check reports on evidence it GATHERED. When the gathering
49
+ // itself fails — a non-zero exit nobody read, output in an unexpected shape,
50
+ // an empty listing — the honest verdict is "could not tell", and "could not
51
+ // tell" MUST be reported as `ok:false`. Three checks previously did the
52
+ // opposite: an errored `git grep` produced an empty hit list that read as a
53
+ // clean sweep, an unparseable `npm view` produced an empty version list that
54
+ // read as "no collision", and an `npm pack` whose listing did not parse
55
+ // produced zero scanned lines that read as "0 leaks". Each is a green check
56
+ // that verified nothing, on the one code path where being wrong is
57
+ // irreversible. Hence: every check that consumes a subprocess result routes
58
+ // through an exported `evaluate*` function below, which is pure over
59
+ // `{status, stdout, stderr}` and unit-tested against exactly the degraded
60
+ // shapes that used to pass.
61
+ //
62
+ // --skip-ci is the deliberate, operator-visible exception to that rule — and
63
+ // is therefore REFUSED under --publish (see `validateFlags`).
45
64
  //
46
65
  // SECURITY INVARIANTS (from skills/npm-publish/SKILL.md):
47
66
  // - NPM_TOKEN only from gitignored .env.local; never logged, never persisted.
@@ -63,17 +82,24 @@ import { spawnSync } from 'node:child_process';
63
82
  import { parseArgs } from 'node:util';
64
83
  import { fileURLToPath } from 'node:url';
65
84
 
85
+ import { resolveRepoSpec } from './lib/vcs-repo-spec.mjs';
86
+
66
87
  const PACKAGE_NAME = 'session-orchestrator';
67
88
  const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
68
89
 
69
90
  // ---------------------------------------------------------------------------
70
91
  // Surfaces table — the SSOT both the scan and the rewrite share.
71
92
  //
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.
93
+ // Every entry: { file, patterns: [RegExp], checkOnly?: boolean }. Each pattern
94
+ // has exactly one capture group holding the version. Matching ZERO occurrences
95
+ // is a hard failure ("pattern-dead") — that is the guard against a surface
96
+ // silently falling out of the check after a file refactor. All captured
97
+ // versions must equal the target.
98
+ //
99
+ // `checkOnly: true` means: scanned by --check, NOT rewritten by applyVersion,
100
+ // because a different generator owns the write. See site/index.html below for
101
+ // the only current case and for why the ownership split is structural here
102
+ // rather than a comment asking the next editor to be careful.
77
103
  //
78
104
  // CHANGELOG.md is deliberately NOT here: it carries version HISTORY, so a
79
105
  // replace-all would corrupt it. It has its own editorial check below.
@@ -119,8 +145,36 @@ export const SURFACES = [
119
145
  ],
120
146
  },
121
147
  {
148
+ // ONE WRITER, ONE CHECKER — and they are not the same program.
149
+ //
150
+ // The page carries its version in three `<span data-metric="version">`
151
+ // cells, and `scripts/site-numbers.mjs --write` owns every `data-metric`
152
+ // cell on the site: it recomputes each one from its declared source (for
153
+ // `version`, that source is package.json). This table only READS them back,
154
+ // hence `checkOnly` — applyVersion deliberately does not touch this file.
155
+ //
156
+ // Why that is not a gap: --set-version runs applyVersion FIRST (package.json
157
+ // gets the target) and `site-numbers --write` SECOND, so the generator
158
+ // derives the same literal from the surface applyVersion just wrote. Adding
159
+ // a second writer here would not "make it safer" — it would make two
160
+ // programs authoritative for one cell, and the next divergence between them
161
+ // would be invisible until a release shipped. If the generator ever stops
162
+ // running, this check goes red rather than quietly self-healing, which is
163
+ // the outcome worth having.
164
+ //
165
+ // HISTORY (do not restore either old pattern): the previous entry was
166
+ // `/"softwareVersion":\s*"(...)"/` plus `/v(\d+\.\d+\.\d+)\b/g`. Commit
167
+ // 8802aa4 removed `softwareVersion` from the JSON-LD (deliberately — see the
168
+ // comment at the top of site/index.html) and replaced the bare `vX.Y.Z`
169
+ // literals with the metric cells, leaving BOTH patterns matching nothing.
170
+ // The pattern-dead guard caught that, which is the entire reason it exists.
171
+ // The `\b`-anchored one was also actively dangerous as a WRITE pattern: it
172
+ // was a replace-all over every `vX.Y.Z` on the page, so a sentence
173
+ // mentioning a historical release would have been silently rewritten to the
174
+ // new version by --set-version. The replacement is anchored to the cell.
122
175
  file: 'site/index.html',
123
- patterns: [/"softwareVersion":\s*"(\d+\.\d+\.\d+)"/, /v(\d+\.\d+\.\d+)\b/g],
176
+ patterns: [/data-metric="version"[^>]*>(\d+\.\d+\.\d+)</g],
177
+ checkOnly: true,
124
178
  },
125
179
  {
126
180
  file: 'site/llms.txt',
@@ -187,12 +241,15 @@ export function scanSurfaces(repoRoot, target) {
187
241
  /**
188
242
  * Mechanically rewrite every surface to the target version by replacing the
189
243
  * captured version in each pattern match. Idempotent. Does NOT touch
190
- * CHANGELOG.md or package-lock.json (the caller syncs the lock via npm).
244
+ * CHANGELOG.md or package-lock.json (the caller syncs the lock via npm), nor
245
+ * any `checkOnly` surface (another generator owns that file's write — see the
246
+ * site/index.html entry in SURFACES).
191
247
  * Returns the list of files actually changed.
192
248
  */
193
249
  export function applyVersion(repoRoot, target) {
194
250
  const changed = [];
195
251
  for (const surface of SURFACES) {
252
+ if (surface.checkOnly) continue;
196
253
  const abs = join(repoRoot, surface.file);
197
254
  if (!existsSync(abs)) continue;
198
255
  const before = readFileSync(abs, 'utf8');
@@ -246,8 +303,27 @@ export const LEAKAGE_PATTERNS = [
246
303
  { name: 'node_modules', re: /node_modules/ },
247
304
  { name: '.env', re: /npm notice.*\.env/i },
248
305
  { name: 'owner.yaml', re: /owner\.yaml/i },
306
+ // Claimed as checked by docs/distribution/npm-publish-checklist.md long before
307
+ // any code checked it (measured 2026-08-19: 3 leakage lists, 3 different sets).
308
+ // `files` in package.json overrides .gitignore, so a stray .DS_Store inside a
309
+ // shipped directory reaches the tarball.
310
+ { name: '.DS_Store', re: /\.DS_Store/ },
249
311
  ];
250
312
 
313
+ // `commands/release.md` quotes the `npm view` OUTPUT that proves the 3.18.0 gap,
314
+ // dated at the line. Bumping it would destroy the evidence it exists to carry —
315
+ // the registry state on that date is the whole point of the paragraph.
316
+ //
317
+ // `site/guide/index.html` carries ONE dated historical sentence — "re-checked
318
+ // against v<prev> on <date>" — deliberately left as a literal: a release that
319
+ // bumped the version while the date stood still would fabricate a verification
320
+ // nobody ran. The page is not unguarded by this exemption. It loses only the
321
+ // coarse prev-tag sweep and keeps the STRICTER guard in
322
+ // tests/scripts/site-numbers.test.mjs, which forbids ANY vX.Y.Z and the current
323
+ // package version outside a `data-metric` cell on EVERY shipped page, and
324
+ // exempts exactly the lines marked `site-numbers:historical`.
325
+ 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)/;
326
+
251
327
  /** Pure check over pack-output lines. Returns violations: {name, line}[]. */
252
328
  export function checkLeakage(lines) {
253
329
  const violations = [];
@@ -259,9 +335,249 @@ export function checkLeakage(lines) {
259
335
  return violations;
260
336
  }
261
337
 
338
+ /**
339
+ * One packed tarball entry in `npm pack --dry-run` output:
340
+ * `npm notice 1.3kB .claude-plugin/marketplace.json`.
341
+ *
342
+ * The previous inline counter was `/npm notice.*[0-9]+B /`, which requires a
343
+ * DIGIT immediately before the `B` and therefore matched only entries sized in
344
+ * plain bytes — 108 of the 830 real entries at 8984224. It was never load-
345
+ * bearing (it only decorated a detail string), but it becomes load-bearing the
346
+ * moment a floor is asserted on it, so it is fixed here rather than floored at
347
+ * a number that means nothing.
348
+ */
349
+ export const PACKED_ENTRY_RE = /^npm notice\s+[\d.]+\s*(?:B|kB|MB|GB)\s+\S/;
350
+
351
+ /**
352
+ * Floor on parsed packed entries, below which the leak scan is presumed BLIND
353
+ * rather than clean.
354
+ *
355
+ * Measured at 8984224 with `npm pack --dry-run`: npm's own summary reports
356
+ * `total files: 830` and {@link PACKED_ENTRY_RE} independently counts 830 —
357
+ * two differently-shaped measurements agreeing. Package size 2.9 MB, unpacked
358
+ * 8.9 MB. (docs/distribution/npm-publish-checklist.md still records the older
359
+ * "~750 files, ~6.5 MB unpacked" baseline; that file has a different owner.)
360
+ *
361
+ * 400 is a FLOOR, not a pin — deliberately ~48% of today's count. It cannot
362
+ * break on growth (the pack only grows), and it is far enough below 830 that a
363
+ * deliberate docs/skills prune would not trip it. What it does catch is the
364
+ * whole failure class in one number: an npm output-format change, an
365
+ * `npm notice` prefix rename, a `files`/`.npmignore` edit that drops entire
366
+ * trees — every state in which the scan sees a handful of lines, finds no
367
+ * leak pattern in them, and reports "0 leaks" with total confidence.
368
+ */
369
+ export const MIN_PACKED_ENTRIES = 400;
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Preflight evaluators — pure over a spawn result `{status, stdout, stderr}`.
373
+ //
374
+ // These exist so the DECISION of every preflight check is unit-testable while
375
+ // the subprocess call itself stays in the impure section below. Each returns
376
+ // `{ok, detail}`. The shared contract, and the reason this family exists at
377
+ // all, is the FAIL-CLOSED house rule in the file header: an evaluator may
378
+ // return `ok:true` only when it has positively SEEN the evidence, never merely
379
+ // because it failed to see a counterexample.
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /**
383
+ * Drift sweep verdict over a `git grep -l` result.
384
+ *
385
+ * `git grep` exit codes: 0 = matches found, 1 = no match (the success case
386
+ * here), anything else = it did not run. Measured on git 2.x: a bad regex and
387
+ * a bad pathspec both exit 128; git also documents 2 for usage errors. The old
388
+ * inline code read `.stdout` without ever looking at `.status`, so BOTH the
389
+ * no-match case and the it-crashed case produced an empty hit list and the
390
+ * same reassuring detail line, "no tracked file still carries X". A sweep that
391
+ * never ran is not a clean sweep.
392
+ *
393
+ * @param {{status: number, stdout?: string, stderr?: string}} grep
394
+ * @param {string} prevTag — the previous release literal being swept for
395
+ * @param {RegExp} allowlist — files that legitimately carry version HISTORY
396
+ * @returns {{ok: boolean, detail: string}}
397
+ */
398
+ export function evaluateDriftSweep(grep, prevTag, allowlist) {
399
+ if (grep.status !== 0 && grep.status !== 1) {
400
+ return {
401
+ ok: false,
402
+ detail: `git grep did not run (exit ${grep.status}): ${(grep.stderr || '').trim().slice(0, 200)} — sweep for ${prevTag} is inconclusive`,
403
+ };
404
+ }
405
+ const hits = (grep.stdout || '')
406
+ .split('\n')
407
+ .filter(Boolean)
408
+ .filter((f) => !allowlist.test(f));
409
+ return {
410
+ ok: hits.length === 0,
411
+ detail: hits.length
412
+ ? `still carry ${prevTag}: ${hits.slice(0, 5).join(', ')}`
413
+ : `no tracked file outside the allowlist still carries ${prevTag}`,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Registry-collision verdict over `npm view <pkg> versions --json`.
419
+ *
420
+ * The most dangerous of the three fail-opens this file carried: on `status 0`
421
+ * with unparseable stdout, the old code swallowed the parse error, left the
422
+ * version list EMPTY, and concluded from that emptiness that the target was
423
+ * free — reporting `latest: ?` while claiming the collision check had passed.
424
+ * Reproduced verbatim: a `<html>` body (proxy/captive-portal response) with
425
+ * exit 0 yields `ok = true`. Any npm output-format change lands in the same
426
+ * hole. An empty ARRAY is treated identically: a published package always has
427
+ * at least one version, so an empty list is a shape we do not understand, not
428
+ * an all-clear.
429
+ *
430
+ * @param {{status: number, stdout?: string, stderr?: string}} view
431
+ * @param {string} target
432
+ * @returns {{ok: boolean, detail: string}}
433
+ */
434
+ export function evaluateRegistryCollision(view, target) {
435
+ if (view.status !== 0) {
436
+ const e404 = /E404/.test(view.stderr || '');
437
+ return e404
438
+ ? { ok: true, detail: 'package not yet on registry (first publish)' }
439
+ : { ok: false, detail: `npm view failed (exit ${view.status}): ${(view.stderr || '').slice(0, 200)}` };
440
+ }
441
+ const raw = view.stdout || '';
442
+ let parsed;
443
+ try {
444
+ parsed = JSON.parse(raw);
445
+ } catch {
446
+ return {
447
+ ok: false,
448
+ detail: `npm view returned unparseable JSON (${raw.length} bytes, starts "${raw.trim().slice(0, 40)}") — cannot rule out a collision on ${target}`,
449
+ };
450
+ }
451
+ const published = Array.isArray(parsed) ? parsed : [parsed];
452
+ if (published.length === 0) {
453
+ return { ok: false, detail: `npm view returned an empty version list — cannot rule out a collision on ${target}` };
454
+ }
455
+ return published.includes(target)
456
+ ? { ok: false, detail: `${target} already published` }
457
+ : { ok: true, detail: `latest: ${published[published.length - 1]}` };
458
+ }
459
+
460
+ /**
461
+ * Leakage-gate verdict over an `npm pack --dry-run` result.
462
+ *
463
+ * The SURFACES table has `pattern-dead` for exactly this class — a matcher that
464
+ * stops matching its input must be a hard error, never a silent pass — and the
465
+ * leak scan had no equivalent: an `npm pack` that exits 0 with output the scan
466
+ * cannot parse yields zero scanned lines, zero violations, and the verdict
467
+ * "0 packed entries, 0 leaks". Reproduced verbatim with empty stdout+stderr.
468
+ * {@link MIN_PACKED_ENTRIES} is that missing `pattern-dead`.
469
+ *
470
+ * The floor is asserted on the SAME lines `checkLeakage` scans, not on npm's
471
+ * `total files:` summary line. That is the point: the summary could survive a
472
+ * format change that broke the per-entry lines, and it is the per-entry lines
473
+ * whose absence blinds the scan.
474
+ *
475
+ * @param {{status: number, stdout?: string, stderr?: string}} pack
476
+ * @param {{minEntries?: number}} [opts]
477
+ * @returns {{ok: boolean, detail: string}}
478
+ */
479
+ export function evaluateLeakageGate(pack, { minEntries = MIN_PACKED_ENTRIES } = {}) {
480
+ if (pack.status !== 0) {
481
+ return { ok: false, detail: `npm pack failed (exit ${pack.status}): ${(pack.stderr || '').trim().slice(-200)}` };
482
+ }
483
+ const lines = `${pack.stdout || ''}\n${pack.stderr || ''}`.split('\n');
484
+ const entries = lines.filter((l) => PACKED_ENTRY_RE.test(l)).length;
485
+ if (entries < minEntries) {
486
+ return {
487
+ ok: false,
488
+ 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`,
489
+ };
490
+ }
491
+ const violations = checkLeakage(lines);
492
+ return violations.length
493
+ ? { ok: false, detail: violations.map((v) => `${v.name}: ${v.line}`).slice(0, 5).join(' | ') }
494
+ : { ok: true, detail: `${entries} packed entries, 0 leaks` };
495
+ }
496
+
497
+ /**
498
+ * Remote-branch parity verdict over `git ls-remote <remote> refs/heads/<branch>`.
499
+ *
500
+ * Preflight compared HEAD against `origin/main` only. The Vercel deploy hangs
501
+ * off the GITHUB mirror, so a mirror that lags is invisible until
502
+ * `verifyLiveSite` fails — which happens AFTER npm publish and AFTER both tag
503
+ * pushes, i.e. after the two irreversible steps. Same fail-closed shape as
504
+ * `tag-free-github`: a failed `ls-remote` is a failed check, and so is output
505
+ * that carries no sha (an empty answer for `refs/heads/main` means the branch
506
+ * is not there at all, which is not parity either).
507
+ *
508
+ * @param {string} remote
509
+ * @param {{status: number, stdout?: string, stderr?: string}} ls
510
+ * @param {string} head — the local HEAD sha
511
+ * @param {string} [branch]
512
+ * @returns {{ok: boolean, detail: string}}
513
+ */
514
+ export function evaluateRemoteHeadParity(remote, ls, head, branch = 'main') {
515
+ if (ls.status !== 0) {
516
+ return { ok: false, detail: `ls-remote ${remote} failed (exit ${ls.status}): ${(ls.stderr || '').trim().slice(0, 200)}` };
517
+ }
518
+ const sha = (ls.stdout || '').trim().split(/\s+/)[0] || '';
519
+ if (!/^[0-9a-f]{40}$/i.test(sha)) {
520
+ return { ok: false, detail: `ls-remote ${remote} returned no sha for refs/heads/${branch} — cannot compare` };
521
+ }
522
+ return sha === head
523
+ ? { ok: true, detail: sha.slice(0, 8) }
524
+ : { ok: false, detail: `${remote}/${branch} at ${sha.slice(0, 8)}, HEAD at ${head.slice(0, 8)} — the mirror is behind` };
525
+ }
526
+
527
+ /**
528
+ * npm-auth verdict over `npm whoami --userconfig <tmp>`.
529
+ *
530
+ * A dead or revoked token used to surface only inside `publish()`, i.e. after
531
+ * every other preflight check had passed and the operator had committed to the
532
+ * release. The probe is read-only and costs one request. Fail-closed on the
533
+ * empty-identity case too: `whoami` exiting 0 while printing nothing is not
534
+ * proof of an identity.
535
+ *
536
+ * @param {{status: number, stdout?: string, stderr?: string}|null} whoami
537
+ * @returns {{ok: boolean, detail: string}}
538
+ */
539
+ export function evaluateNpmAuth(whoami) {
540
+ if (!whoami) return { ok: false, detail: 'npm whoami was not run' };
541
+ if (whoami.status !== 0) {
542
+ return { ok: false, detail: `npm whoami exited ${whoami.status}: ${(whoami.stderr || '').trim().slice(0, 200)}` };
543
+ }
544
+ const who = (whoami.stdout || '').trim();
545
+ return who
546
+ ? { ok: true, detail: `authenticated as ${who}` }
547
+ : { ok: false, detail: 'npm whoami exited 0 with an empty identity — the token could not be confirmed' };
548
+ }
549
+
550
+ /**
551
+ * Flag-combination gate, applied before any work.
552
+ *
553
+ * `--skip-ci` turns the CI check into `ok:true` with the detail
554
+ * "SKIPPED via --skip-ci". That is a legitimate affordance for `--check` (an
555
+ * operator inspecting surface parity while a pipeline is still running) and an
556
+ * illegitimate one for `--publish`: it would let a green summary that verified
557
+ * nothing about CI authorise npm publish + two tag pushes, none of which can be
558
+ * taken back. The refusal is a usage error (exit 2), not a check failure —
559
+ * nothing was checked.
560
+ *
561
+ * @param {{publish?: boolean, 'skip-ci'?: boolean}} values
562
+ * @returns {{ok: boolean, code?: number, message?: string}}
563
+ */
564
+ export function validateFlags(values) {
565
+ if (values.publish && values['skip-ci']) {
566
+ return {
567
+ ok: false,
568
+ code: 2,
569
+ message:
570
+ '--skip-ci is refused under --publish: it makes ci-green-on-head pass without checking anything, and publish is irreversible.\n' +
571
+ 'Run `--check --skip-ci` to inspect the other surfaces, then `--publish` once CI is actually green on HEAD.',
572
+ };
573
+ }
574
+ return { ok: true };
575
+ }
576
+
262
577
  // ---------------------------------------------------------------------------
263
- // Impure orchestration below — git/npm/network. Not unit-tested; exercised
264
- // by the release runs themselves.
578
+ // Impure orchestration below — git/npm/network. The DECISIONS live in the
579
+ // evaluators above and are unit-tested; what remains here is the plumbing that
580
+ // feeds them.
265
581
  // ---------------------------------------------------------------------------
266
582
 
267
583
  function run(cmd, args, opts = {}) {
@@ -286,15 +602,42 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
286
602
  const checks = [];
287
603
  const add = (name, ok, detail = '') => checks.push({ name, ok, detail });
288
604
 
289
- // 1. Git state: on main, clean tree, HEAD pushed.
605
+ // 1. Git state: on main, clean tree, HEAD present on BOTH publish remotes.
606
+ //
607
+ // Both remotes, symmetrically, and both read LIVE via ls-remote rather than
608
+ // from a local tracking ref. origin (GitLab) is where the code lives; github
609
+ // is where the Vercel git integration watches, so a lagging mirror means the
610
+ // site cannot deploy — and that was previously discovered only by
611
+ // verifyLiveSite, i.e. after npm publish and both tag pushes had already
612
+ // happened. The old origin check read `origin/main` after a `git fetch` whose
613
+ // exit status nobody inspected: a failed fetch left a stale tracking ref that
614
+ // could still equal HEAD, so the comparison was against remembered state
615
+ // rather than remote state. ls-remote has no such intermediate.
290
616
  const branch = run('git', ['branch', '--show-current'], { cwd: repoRoot }).stdout.trim();
291
617
  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 });
618
+ // `git status` exit status is read, not assumed: an empty stdout from a
619
+ // FAILED status call is indistinguishable from a genuinely clean tree, and
620
+ // the empty-reads-as-all-clear shape is exactly the fail-open this file was
621
+ // hardened against elsewhere. Same reasoning for the two `git tag -l` reads
622
+ // below — those are the only other preflight subprocesses whose emptiness
623
+ // means "all clear" (census: all 14 `run(` call sites in this file).
624
+ const status = run('git', ['status', '--porcelain'], { cwd: repoRoot });
625
+ const dirty = (status.stdout || '').trim();
626
+ add(
627
+ 'working-tree-clean',
628
+ status.status === 0 && dirty === '',
629
+ status.status !== 0
630
+ ? `git status failed (exit ${status.status}) — cleanliness unknown`
631
+ : dirty
632
+ ? `${dirty.split('\n').length} dirty path(s)`
633
+ : '',
634
+ );
295
635
  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)}`);
636
+ for (const remote of ['origin', 'github']) {
637
+ const ls = run('git', ['ls-remote', remote, 'refs/heads/main'], { cwd: repoRoot });
638
+ const parity = evaluateRemoteHeadParity(remote, ls, head);
639
+ add(`head-pushed-${remote}`, parity.ok, parity.detail);
640
+ }
298
641
 
299
642
  // 2. Surface parity.
300
643
  const surfaceRows = scanSurfaces(repoRoot, target);
@@ -312,22 +655,33 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
312
655
  // directories, which is exactly how the forgotten .codex-plugin manifest
313
656
  // was invisible to a plain rg census. Allowlisted: files that legitimately
314
657
  // 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/, ''))
658
+ const tagList = run('git', ['tag', '-l', 'v*', '--sort=-v:refname'], { cwd: repoRoot });
659
+ const prevTag = (tagList.stdout || '')
660
+ .split('\n').map((t) => t.trim().replace(/^v/, ''))
317
661
  .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)/;
662
+ if (tagList.status !== 0) {
663
+ // "No previous tag" and "could not list tags" are different facts, and only
664
+ // one of them means the sweep is unnecessary.
665
+ add('drift-sweep', false, `git tag -l failed (exit ${tagList.status}) — cannot determine the previous release to sweep for`);
666
+ } else if (prevTag) {
320
667
  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}`);
668
+ const sweep = evaluateDriftSweep(grep, prevTag, HISTORY_ALLOWLIST);
669
+ add('drift-sweep', sweep.ok, sweep.detail);
323
670
  } else {
324
671
  add('drift-sweep', true, 'no previous tag to sweep against');
325
672
  }
326
673
 
327
674
  // 4. Tag collision — local, origin, github mirror.
328
675
  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`);
676
+ const localTagRes = run('git', ['tag', '-l', tag], { cwd: repoRoot });
677
+ const localTag = (localTagRes.stdout || '').trim();
678
+ add(
679
+ 'tag-free-local',
680
+ localTagRes.status === 0 && localTag === '',
681
+ localTagRes.status !== 0
682
+ ? `git tag -l failed (exit ${localTagRes.status}) — local tag collision unknown`
683
+ : localTag && `${tag} already exists locally`,
684
+ );
331
685
  for (const remote of ['origin', 'github']) {
332
686
  const ls = run('git', ['ls-remote', '--tags', remote, `refs/tags/${tag}`], { cwd: repoRoot });
333
687
  const collision = ls.status === 0 && ls.stdout.trim() !== '';
@@ -336,21 +690,32 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
336
690
 
337
691
  // 5. npm registry collision (E404 = name free = fine for a first publish).
338
692
  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)}`);
693
+ const registry = evaluateRegistryCollision(view, target);
694
+ add('registry-version-free', registry.ok, registry.detail);
695
+
696
+ // 5b. npm token liveness. Read-only, one request, and it answers the one
697
+ // question the rest of the preflight cannot: is the credential we are about
698
+ // to publish with actually alive? Without it, a revoked or expired token
699
+ // surfaces inside publish() after every other check has gone green and the
700
+ // operator has committed to the release. Same token discipline as publish():
701
+ // .env.local only, temp userconfig at 0600, removed in a finally.
702
+ let auth;
703
+ try {
704
+ auth = withTempUserconfig(loadNpmToken(repoRoot), (rc) =>
705
+ run('npm', ['whoami', '--userconfig', rc], { cwd: repoRoot }),
706
+ );
707
+ const verdict = evaluateNpmAuth(auth);
708
+ add('npm-token-live', verdict.ok, verdict.detail);
709
+ } catch (err) {
710
+ // A missing/ungitignored .env.local is a legitimate red preflight, not a
711
+ // crash: "cannot publish from here" is exactly what the operator needs.
712
+ add('npm-token-live', false, err.message);
350
713
  }
351
714
 
352
715
  // 6. CI green on HEAD (the repo's iron session-start rule applies to
353
716
  // releases doubly: local green is not evidence — see .claude/rules).
717
+ // --skip-ci is refused under --publish upstream in validateFlags(); it can
718
+ // only reach this branch from --check.
354
719
  if (skipCi) {
355
720
  add('ci-green-on-head', true, 'SKIPPED via --skip-ci');
356
721
  } else {
@@ -362,9 +727,8 @@ async function preflight(repoRoot, target, { skipCi = false } = {}) {
362
727
 
363
728
  // 7. Leakage gate over the actual pack file list.
364
729
  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`);
730
+ const leakage = evaluateLeakageGate(pack);
731
+ add('leakage-gate', leakage.ok, leakage.detail);
368
732
 
369
733
  return checks;
370
734
  }
@@ -376,30 +740,51 @@ function changelogExcerpt(repoRoot, target) {
376
740
  return m ? m[1].trim().split('\n').slice(0, 40).join('\n') : '';
377
741
  }
378
742
 
379
- function publish(repoRoot, target) {
380
- // Token: only from gitignored .env.local (verify the ignore before reading).
743
+ /**
744
+ * Read NPM_TOKEN from the gitignored .env.local, refusing if the ignore is not
745
+ * actually in force. Throws with an operator-actionable message; the token
746
+ * itself is never part of any message.
747
+ */
748
+ function loadNpmToken(repoRoot) {
381
749
  const ignored = run('git', ['check-ignore', '.env.local'], { cwd: repoRoot });
382
750
  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);
751
+ if (!existsSync(join(repoRoot, '.env.local'))) throw new Error('.env.local not found — no NPM_TOKEN to publish with');
752
+ const tokenMatch = readFileSync(join(repoRoot, '.env.local'), 'utf8').match(/^NPM_TOKEN=(.+)$/m);
385
753
  if (!tokenMatch) throw new Error('NPM_TOKEN not found in .env.local');
386
- const token = tokenMatch[1].trim();
754
+ return tokenMatch[1].trim();
755
+ }
387
756
 
757
+ /**
758
+ * Run `fn(userconfigPath)` against a throwaway npm userconfig carrying the
759
+ * token. Extracted so the preflight liveness probe and the publish itself share
760
+ * ONE implementation of the security invariants from
761
+ * skills/npm-publish/SKILL.md — 0600, and removed in a finally even when the
762
+ * callback throws. Two hand-copied versions of this dance would be two places
763
+ * for a token file to be left behind.
764
+ */
765
+ function withTempUserconfig(token, fn) {
388
766
  const tmpDir = mkdtempSync(join(tmpdir(), 'release-npmrc-'));
389
767
  const tmpRc = join(tmpDir, 'npmrc');
390
768
  try {
391
- writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`);
769
+ writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`, { mode: 0o600 });
392
770
  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
- }
771
+ return fn(tmpRc);
400
772
  } finally {
401
773
  rmSync(tmpDir, { recursive: true, force: true });
402
774
  }
775
+ }
776
+
777
+ function publish(repoRoot, target) {
778
+ const token = loadNpmToken(repoRoot);
779
+ const res = withTempUserconfig(token, (tmpRc) =>
780
+ run('npm', ['publish', '--access', 'public', '--userconfig', tmpRc], { cwd: repoRoot }),
781
+ );
782
+ const out = `${res.stdout}\n${res.stderr}`;
783
+ if (res.status !== 0 || !out.includes(`+ ${PACKAGE_NAME}@${target}`)) {
784
+ // Never echo the raw output wholesale into logs beyond the error slice —
785
+ // it cannot contain the token (npm masks userconfig), but stay frugal.
786
+ throw new Error(`npm publish failed (exit ${res.status}): ${out.slice(0, 800)}`);
787
+ }
403
788
 
404
789
  // Registry verify with propagation retries.
405
790
  for (let attempt = 1; attempt <= 5; attempt++) {
@@ -430,6 +815,124 @@ function tagAndPush(repoRoot, target) {
430
815
  return { tag, pushed };
431
816
  }
432
817
 
818
+ /**
819
+ * Create the GitHub release for `v<target>`, or confirm the existing one.
820
+ *
821
+ * WHY THIS IS CODE AND NOT A CHECKLIST LINE: it was a checklist line, and the
822
+ * evidence that a checklist line is not a mechanism is in the release history.
823
+ * The GitHub releases for v3.15, v3.18, v3.19 and v3.20 were all created within
824
+ * a THREE-SECOND window on 2026-08-19 — hand-backfilled in one sitting, 5 to 31
825
+ * days after their tags, where the releases that were not forgotten were made 19
826
+ * seconds to 2.5 minutes after theirs. The same class of gap left 3.18.0 with a
827
+ * tag, a GitHub release and a CHANGELOG entry that the npm registry has still
828
+ * never seen.
829
+ *
830
+ * Three properties make this safe to run unconditionally after a push:
831
+ * - `--verify-tag` makes gh refuse when the tag is not on the remote, so
832
+ * "release without a tag" is structurally impossible rather than merely
833
+ * discouraged.
834
+ * - The `gh release view` probe first makes a re-run a no-op instead of an
835
+ * error, so a second `--publish` pass after a partial failure is not blocked
836
+ * by the step that already succeeded.
837
+ * - The `-R` spec comes from `resolveRepoSpec({vcs:'github'})` (#1039), not a
838
+ * hardcoded owner/repo, so a fork or a renamed remote targets its own repo.
839
+ *
840
+ * Never throws: the caller has already published to npm and pushed both tags by
841
+ * the time this runs, so an exception here would report a successful release as
842
+ * a crash. Failure comes back as `{ok:false}` with the recovery command.
843
+ *
844
+ * @param {string} repoRoot
845
+ * @param {string} target
846
+ * @param {{runImpl?: Function, repoSpec?: string}} [deps] — injection seam for tests
847
+ * @returns {{ok: boolean, created: boolean, tag: string, detail: string, argv?: string[]}}
848
+ */
849
+ export function ensureGithubRelease(repoRoot, target, deps = {}) {
850
+ const runImpl = deps.runImpl ?? run;
851
+ const tag = `v${target}`;
852
+ const spec = deps.repoSpec ?? resolveRepoSpec({ repoRoot, vcs: 'github' });
853
+ // resolveRepoSpec returns undefined when it cannot auto-detect; its contract
854
+ // is that callers OMIT the flag rather than pass `-R undefined`.
855
+ const repoFlag = spec ? ['--repo', spec] : [];
856
+
857
+ try {
858
+ const existing = runImpl('gh', ['release', 'view', tag, ...repoFlag], { cwd: repoRoot });
859
+ if (existing.status === 0) {
860
+ return { ok: true, created: false, tag, detail: `GitHub release ${tag} already exists — no-op` };
861
+ }
862
+
863
+ const notesDir = mkdtempSync(join(tmpdir(), 'release-ghnotes-'));
864
+ const notesFile = join(notesDir, 'notes.md');
865
+ let argv;
866
+ try {
867
+ writeFileSync(notesFile, `${changelogExcerpt(repoRoot, target)}\n`);
868
+ argv = ['release', 'create', tag, ...repoFlag, '--verify-tag', '--title', tag, '--notes-file', notesFile];
869
+ const created = runImpl('gh', argv, { cwd: repoRoot });
870
+ if (created.status !== 0) {
871
+ return {
872
+ ok: false,
873
+ created: false,
874
+ tag,
875
+ argv,
876
+ detail: `gh release create exited ${created.status}: ${(created.stderr || created.stdout || '').trim().slice(0, 300)}`,
877
+ };
878
+ }
879
+ return { ok: true, created: true, tag, argv, detail: `GitHub release ${tag} created (--verify-tag)` };
880
+ } finally {
881
+ rmSync(notesDir, { recursive: true, force: true });
882
+ }
883
+ } catch (err) {
884
+ return { ok: false, created: false, tag, detail: `gh could not be run: ${err.message}` };
885
+ }
886
+ }
887
+
888
+ /**
889
+ * Poll the live site until it serves `expected`, or give up.
890
+ *
891
+ * WHY POLLING: the Vercel git integration builds asynchronously after the push
892
+ * to `github`, so a single immediate check would report a false negative on
893
+ * every release. WHY AT ALL: the live site silently fell a release behind twice
894
+ * in four weeks (#1043) — a deploy that reports success at the push and is
895
+ * never re-read afterwards cannot tell "deployed" from "did not deploy".
896
+ *
897
+ * Fail-closed by design: a network error, a non-200, an unparseable body and a
898
+ * genuine version mismatch are four DISTINCT reported outcomes, never collapsed
899
+ * onto one "not ok" — collapsing them is the defect class this replaces.
900
+ *
901
+ * @param {string} expected — the version literal the site must serve
902
+ * @param {{url?: string, attempts?: number, delayMs?: number, fetchImpl?: Function}} [opts]
903
+ * @returns {Promise<{ok: boolean, detail: string}>}
904
+ */
905
+ export async function verifyLiveSite(expected, opts = {}) {
906
+ const url = opts.url ?? 'https://session-orchestrator.com/llms.txt';
907
+ const attempts = opts.attempts ?? 12;
908
+ const delayMs = opts.delayMs ?? 10_000;
909
+ const doFetch = opts.fetchImpl ?? globalThis.fetch;
910
+ let last = 'no attempt made';
911
+
912
+ for (let i = 1; i <= attempts; i++) {
913
+ try {
914
+ const res = await doFetch(url, { headers: { 'Cache-Control': 'no-cache' } });
915
+ if (!res.ok) {
916
+ last = `HTTP ${res.status} from ${url}`;
917
+ } else {
918
+ const body = await res.text();
919
+ const m = body.match(/^Version:\s*([0-9]+\.[0-9]+\.[0-9]+)/m);
920
+ if (!m) {
921
+ last = `no "Version: X.Y.Z" line in ${url} (${body.length} bytes) — the surface moved, fix the check`;
922
+ } else if (m[1] === expected) {
923
+ return { ok: true, detail: `attempt ${i}/${attempts}, ${url}` };
924
+ } else {
925
+ last = `live serves ${m[1]}, expected ${expected}`;
926
+ }
927
+ }
928
+ } catch (err) {
929
+ last = `fetch failed: ${err.message}`;
930
+ }
931
+ if (i < attempts) await new Promise((r) => setTimeout(r, delayMs));
932
+ }
933
+ return { ok: false, detail: `${last} (gave up after ${attempts} attempts)` };
934
+ }
935
+
433
936
  function printChecks(checks, asJson, version) {
434
937
  const ok = checks.every((c) => c.ok);
435
938
  if (asJson) {
@@ -459,9 +962,16 @@ async function main() {
459
962
  if (values.help) {
460
963
  console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
461
964
  console.log('Release als ein Dispatch: surface sync, preflight checks, token publish, tag AFTER publish.');
965
+ console.log(' --skip-ci allowed with --check only; REFUSED with --publish (it verifies nothing).');
462
966
  console.log('Exit codes: 0 success, 1 check failure, 2 system/usage error.');
463
967
  return 0;
464
968
  }
969
+
970
+ const flags = validateFlags(values);
971
+ if (!flags.ok) {
972
+ console.error(flags.message);
973
+ return flags.code;
974
+ }
465
975
  if (values.version) {
466
976
  console.log(readPackageVersion(repoRootOf()));
467
977
  return 0;
@@ -477,9 +987,20 @@ async function main() {
477
987
  }
478
988
  const changed = applyVersion(repoRoot, target);
479
989
  mustRun('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: repoRoot });
990
+
991
+ // Re-stamp the site's measured census (#1043, second drift level). The
992
+ // version literals above are only half the problem: the "Measured in this
993
+ // repository" block was typed once on 2026-08-03 and 5 of its 8 figures
994
+ // were wrong twelve days later. Release time is the RIGHT moment and CI is
995
+ // the wrong one — `sessions` and `learnings` grow on every session, so a
996
+ // pipeline gate on them would be permanently red. The page discloses that
997
+ // by stamping the date and SHA it was counted at, which this refreshes too.
998
+ mustRun('node', ['scripts/site-numbers.mjs', '--write'], { cwd: repoRoot });
999
+
480
1000
  console.log(`Rewrote ${changed.length} surface file(s) to ${target}:`);
481
1001
  for (const f of changed) console.log(` ${f}`);
482
1002
  console.log(' package-lock.json (via npm install --package-lock-only)');
1003
+ console.log(' site/index.html cells + site/_census.json re-stamped (scripts/site-numbers.mjs --write) — commit BOTH');
483
1004
  console.log('\nEditorial TODOs (enforced by --check):');
484
1005
  console.log(` 1. CHANGELOG.md — write the "## [${target}] - YYYY-MM-DD" entry, fold [Unreleased].`);
485
1006
  console.log(' 2. README.md — rewrite the "Recent highlights" section content.');
@@ -498,10 +1019,44 @@ async function main() {
498
1019
  console.log(` + ${PACKAGE_NAME}@${target} — registry verified.`);
499
1020
  const { tag, pushed } = tagAndPush(repoRoot, target);
500
1021
  console.log(` tagged ${tag} (AFTER publish) and pushed main+tag to: ${pushed.join(', ')}.`);
1022
+
1023
+ // GitHub release — after the tag is on the remote (so --verify-tag can do
1024
+ // its job), before the site poll. Not fatal on its own: npm and both tags
1025
+ // are already published at this point, and reporting a shipped release as a
1026
+ // crash would be a worse lie than reporting the one missing artefact.
1027
+ const release = ensureGithubRelease(repoRoot, target);
1028
+ if (release.ok) {
1029
+ console.log(` ${release.detail}.`);
1030
+ } else {
1031
+ console.error(`\nFAIL: ${release.detail}`);
1032
+ console.error(' npm publish and both tag pushes SUCCEEDED — only the GitHub release is missing.');
1033
+ console.error(` Recover with: gh release create ${tag} --verify-tag --title ${tag} --notes-file <changelog excerpt>`);
1034
+ }
1035
+
1036
+ // The push to `github` above triggers the Vercel git integration, which
1037
+ // deploys site/ (see vercel.json `outputDirectory`). The deploy is async,
1038
+ // so poll rather than assume. This replaces the old manual checklist line
1039
+ // `cd site && vercel --prod` — a checklist line is not a mechanism, and it
1040
+ // was skipped twice in four weeks (#1043), leaving the live site a full
1041
+ // release behind while every other surface said otherwise.
1042
+ const live = await verifyLiveSite(target);
1043
+ if (!live.ok) {
1044
+ console.error(`\nFAIL: live site did not reach ${target}.`);
1045
+ console.error(` ${live.detail}`);
1046
+ console.error(' npm and the tags ARE published — only the site lag remains.');
1047
+ console.error(' Check https://vercel.com/kanevrys-projects/session-orchestrator for the deploy.');
1048
+ } else {
1049
+ console.log(` site live at ${target} (${live.detail}).`);
1050
+ }
1051
+
1052
+ // Both post-publish steps report before either decides the exit code —
1053
+ // an operator who lost the GitHub release should still learn whether the
1054
+ // site deployed, and vice versa.
1055
+ if (!release.ok || !live.ok) return 1;
1056
+
501
1057
  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.');
1058
+ console.log(' 1. Rotate/delete the npm token: https://www.npmjs.com/settings/<user>/tokens');
1059
+ console.log(' 2. pi.dev gallery indexes asynchronously — do not block on it.');
505
1060
  return 0;
506
1061
  }
507
1062