session-orchestrator 3.17.0 → 3.20.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 (221) 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 +17 -1
  5. package/CHANGELOG.md +185 -412
  6. package/README.md +12 -9
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/commands/session.md +6 -2
  21. package/docs/USER-GUIDE.md +9 -4
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +6 -6
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +794 -0
  28. package/docs/rule-authoring.md +58 -9
  29. package/docs/session-config-reference.md +245 -50
  30. package/docs/session-config-template.md +39 -26
  31. package/hooks/_lib/guard-source-loader.mjs +680 -0
  32. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  33. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  34. package/hooks/config-protection.mjs +0 -0
  35. package/hooks/enforce-commands.mjs +226 -19
  36. package/hooks/enforce-scope.mjs +133 -9
  37. package/hooks/hooks-codex.json +1 -1
  38. package/hooks/hooks-cursor.json +11 -2
  39. package/hooks/hooks-pi.json +10 -0
  40. package/hooks/hooks.json +21 -1
  41. package/hooks/on-session-end.mjs +178 -18
  42. package/hooks/on-session-start.mjs +30 -4
  43. package/hooks/post-bash-write-verify.mjs +977 -0
  44. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  45. package/hooks/pre-bash-destructive-guard.mjs +616 -164
  46. package/hooks/pre-bash-issue-budget.mjs +167 -0
  47. package/hooks/pre-bash-sessions-ledger-guard.mjs +1054 -0
  48. package/hooks/pre-bash-templates-first.mjs +96 -63
  49. package/hooks/subagent-telemetry.mjs +527 -37
  50. package/package.json +6 -3
  51. package/pi/prompts/contract-version-bump.md +12 -0
  52. package/rules/README.md +32 -0
  53. package/scripts/archive-closed-prds.mjs +12 -22
  54. package/scripts/autopilot-multi.mjs +103 -20
  55. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  56. package/scripts/backfill-learnings-from-vault.mjs +967 -0
  57. package/scripts/check-doc-consistency.sh +17 -1
  58. package/scripts/emit-session.mjs +3 -40
  59. package/scripts/eval-session.mjs +50 -9
  60. package/scripts/fleet-instruction-scan.mjs +141 -0
  61. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  62. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  63. package/scripts/lib/backlog-scan.mjs +39 -6
  64. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  65. package/scripts/lib/ci-status-banner.mjs +75 -12
  66. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  67. package/scripts/lib/command-blocker.mjs +1273 -58
  68. package/scripts/lib/config/config-protection.mjs +2 -1
  69. package/scripts/lib/config/drift-check.mjs +9 -1
  70. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  71. package/scripts/lib/config/issue-budget.mjs +123 -0
  72. package/scripts/lib/config/reconcile.mjs +21 -0
  73. package/scripts/lib/config/section-extractor.mjs +121 -1
  74. package/scripts/lib/config-schema.mjs +23 -3
  75. package/scripts/lib/config.mjs +17 -0
  76. package/scripts/lib/convergence-monitor.mjs +49 -3
  77. package/scripts/lib/description-surface.mjs +535 -0
  78. package/scripts/lib/dispatcher/enumerate.mjs +26 -40
  79. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  80. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  81. package/scripts/lib/eval/engine.mjs +47 -5
  82. package/scripts/lib/events.mjs +59 -7
  83. package/scripts/lib/gates/gate-full.mjs +15 -3
  84. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  85. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  86. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  87. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  88. package/scripts/lib/handover-gate.mjs +7 -3
  89. package/scripts/lib/hardening.mjs +9 -9
  90. package/scripts/lib/harness-audit/categories/category4.mjs +9 -3
  91. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  92. package/scripts/lib/io.mjs +345 -10
  93. package/scripts/lib/issue-budget.mjs +269 -0
  94. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  95. package/scripts/lib/label-scope.mjs +47 -0
  96. package/scripts/lib/learnings/affinity.mjs +434 -0
  97. package/scripts/lib/learnings/candidates.mjs +736 -0
  98. package/scripts/lib/learnings/expiry-sweep.mjs +408 -53
  99. package/scripts/lib/learnings/judgment.mjs +782 -0
  100. package/scripts/lib/learnings/kebab.mjs +128 -0
  101. package/scripts/lib/learnings/schema.mjs +43 -3
  102. package/scripts/lib/learnings/select.mjs +550 -0
  103. package/scripts/lib/lock-reaper.mjs +1 -2
  104. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  105. package/scripts/lib/peer-discovery.mjs +645 -0
  106. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  107. package/scripts/lib/product-repo-detect.mjs +9 -8
  108. package/scripts/lib/project-hygiene.mjs +432 -0
  109. package/scripts/lib/quality-gate.mjs +167 -0
  110. package/scripts/lib/recommendations-v0.mjs +1 -1
  111. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  112. package/scripts/lib/reconcile/emitter.mjs +128 -24
  113. package/scripts/lib/reconcile/engine.mjs +156 -54
  114. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  115. package/scripts/lib/reconcile/renderer.mjs +141 -25
  116. package/scripts/lib/reconcile/sanitize.mjs +518 -0
  117. package/scripts/lib/reconcile/writer.mjs +95 -1
  118. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  119. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  120. package/scripts/lib/resource-probe.mjs +19 -0
  121. package/scripts/lib/rule-loader.mjs +6 -0
  122. package/scripts/lib/scope-baseline.mjs +564 -0
  123. package/scripts/lib/scope-gate.mjs +568 -145
  124. package/scripts/lib/session-close-backfill.mjs +63 -8
  125. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  126. package/scripts/lib/session-id.mjs +221 -41
  127. package/scripts/lib/session-lock.mjs +304 -6
  128. package/scripts/lib/session-record-repair.mjs +551 -0
  129. package/scripts/lib/session-schema/constants.mjs +22 -3
  130. package/scripts/lib/session-schema/serializer.mjs +54 -0
  131. package/scripts/lib/session-schema/validator.mjs +16 -0
  132. package/scripts/lib/session-schema.mjs +1 -0
  133. package/scripts/lib/session-token-rollup.mjs +68 -6
  134. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  135. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  136. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  137. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  138. package/scripts/lib/soul-resolve.mjs +12 -0
  139. package/scripts/lib/spiral-carryover.mjs +142 -30
  140. package/scripts/lib/state-md/mission-status.mjs +53 -3
  141. package/scripts/lib/subagents-schema.mjs +43 -9
  142. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  143. package/scripts/lib/tests-src-ratio.mjs +484 -0
  144. package/scripts/lib/tmux-layout/telemetry.mjs +43 -10
  145. package/scripts/lib/validate/check-agents.mjs +56 -0
  146. package/scripts/lib/validate/check-banner-parity.mjs +376 -0
  147. package/scripts/lib/validate/check-guard-requires-parity.mjs +1148 -0
  148. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  149. package/scripts/lib/validate/check-learning-provenance.mjs +511 -0
  150. package/scripts/lib/validate/check-owner-leakage.mjs +3 -3
  151. package/scripts/lib/validate/check-rules.mjs +244 -36
  152. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  153. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  154. package/scripts/lib/validate/check-unwired-features.mjs +549 -0
  155. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  156. package/scripts/lib/vault-archive.mjs +17 -2
  157. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  158. package/scripts/lib/vault-mirror/process.mjs +30 -0
  159. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  160. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  161. package/scripts/lib/wave-resource-gate.mjs +115 -11
  162. package/scripts/lib/worktree/listing.mjs +44 -7
  163. package/scripts/mcp-server.sh +17 -3
  164. package/scripts/measure-context-overhead.sh +151 -0
  165. package/scripts/memory-propose.mjs +72 -9
  166. package/scripts/print-applicable-rules.mjs +218 -16
  167. package/scripts/print-learnings-index.mjs +474 -0
  168. package/scripts/release.mjs +534 -0
  169. package/scripts/repair-invalid-sessions.mjs +209 -0
  170. package/scripts/run-quality-gate.mjs +123 -5
  171. package/scripts/sweep-expired-learnings.mjs +192 -32
  172. package/scripts/validate-plugin.mjs +21 -0
  173. package/scripts/validate-wave-scope.mjs +182 -17
  174. package/scripts/vault-integration-watcher.mjs +32 -10
  175. package/skills/_shared/config-reading.md +2 -2
  176. package/skills/bootstrap/fast-template.md +1 -1
  177. package/skills/brainstorm/soul.md +47 -1
  178. package/skills/claude-md-drift-check/checker.mjs +145 -28
  179. package/skills/contract-version-bump/SKILL.md +219 -0
  180. package/skills/discovery/SKILL.md +4 -4
  181. package/skills/discovery/issue-templates.md +11 -11
  182. package/skills/discovery/probes-audit.md +1 -1
  183. package/skills/discovery/probes-feature.md +1 -1
  184. package/skills/discovery/probes-session.md +26 -5
  185. package/skills/ecosystem-health/SKILL.md +1 -1
  186. package/skills/ecosystem-health/wizard.md +4 -4
  187. package/skills/evolve/SKILL.md +117 -18
  188. package/skills/gitlab-ops/SKILL.md +25 -12
  189. package/skills/gitlab-portfolio/SKILL.md +2 -2
  190. package/skills/grill/soul.md +44 -1
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +17 -1
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/plan/soul.md +46 -3
  199. package/skills/quality-gates/SKILL.md +1 -1
  200. package/skills/reconcile/SKILL.md +21 -4
  201. package/skills/session-end/SKILL.md +34 -36
  202. package/skills/session-end/discovery-scan.md +4 -2
  203. package/skills/session-end/drift-operations.md +4 -4
  204. package/skills/session-end/metrics-collection.md +13 -0
  205. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  206. package/skills/session-end/phase-3-6-tail.md +32 -2
  207. package/skills/session-end/plan-verification.md +6 -7
  208. package/skills/session-end/session-metrics-write.md +2 -0
  209. package/skills/session-end/vault-operations.md +1 -1
  210. package/skills/session-end/verification-checklist.md +1 -1
  211. package/skills/session-plan/SKILL.md +6 -2
  212. package/skills/session-plan/wave-template.md +2 -0
  213. package/skills/session-start/SKILL.md +75 -7
  214. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  215. package/skills/session-start/soul.md +41 -1
  216. package/skills/test-runner/SKILL.md +2 -2
  217. package/skills/vault-sync/validator.mjs +108 -7
  218. package/skills/wave-executor/SKILL.md +6 -7
  219. package/skills/wave-executor/circuit-breaker.md +2 -0
  220. package/skills/wave-executor/wave-loop.md +198 -80
  221. package/templates/_shared/loop.md +4 -4
@@ -0,0 +1,534 @@
1
+ #!/usr/bin/env node
2
+ // scripts/release.mjs
3
+ //
4
+ // Release als ein Dispatch — the local half of issue #978.
5
+ //
6
+ // WHY THIS EXISTS (the incident class):
7
+ // Version, tag, npm publish, README badge, CHANGELOG entry, site copy and
8
+ // the two plugin manifests were SEVEN independent manual acts. Every past
9
+ // release forgot at least one: v3.18.0 shipped without its tag for 65
10
+ // commits (retro-tagged 2026-07-31), the marketplace pin froze ~588 commits
11
+ // behind HEAD (#851). The defense is a single surfaces table that both the
12
+ // rewrite and the check share — a version literal that exists outside the
13
+ // table is found by the drift sweep, and a table pattern that stops
14
+ // matching its file is a hard error, never a silent pass.
15
+ //
16
+ // PHASES:
17
+ // --set-version X.Y.Z Mechanically rewrite every version surface, then
18
+ // sync package-lock.json via `npm install
19
+ // --package-lock-only`. Editorial surfaces (CHANGELOG
20
+ // entry, README highlights prose) are NOT written —
21
+ // they are enforced by --check instead.
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
29
+ // (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
+ //
34
+ // USAGE:
35
+ // node scripts/release.mjs --check [--json]
36
+ // node scripts/release.mjs --set-version 3.19.0
37
+ // node scripts/release.mjs --publish [--json]
38
+ //
39
+ // EXIT CODES:
40
+ // 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)
45
+ //
46
+ // SECURITY INVARIANTS (from skills/npm-publish/SKILL.md):
47
+ // - NPM_TOKEN only from gitignored .env.local; never logged, never persisted.
48
+ // - Temp userconfig chmod 600, removed in a finally block.
49
+ // - Leakage gate runs before EVERY publish, not only the first.
50
+
51
+ import {
52
+ readFileSync,
53
+ writeFileSync,
54
+ existsSync,
55
+ mkdtempSync,
56
+ chmodSync,
57
+ rmSync,
58
+ realpathSync,
59
+ } from 'node:fs';
60
+ import { join } from 'node:path';
61
+ import { tmpdir } from 'node:os';
62
+ import { spawnSync } from 'node:child_process';
63
+ import { parseArgs } from 'node:util';
64
+ import { fileURLToPath } from 'node:url';
65
+
66
+ const PACKAGE_NAME = 'session-orchestrator';
67
+ const SPAWN_OPTS = { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 };
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Surfaces table — the SSOT both the scan and the rewrite share.
71
+ //
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.
77
+ //
78
+ // CHANGELOG.md is deliberately NOT here: it carries version HISTORY, so a
79
+ // replace-all would corrupt it. It has its own editorial check below.
80
+ // package-lock.json is also special-cased (thousands of dep "version" keys).
81
+ // ---------------------------------------------------------------------------
82
+ export const SURFACES = [
83
+ {
84
+ file: 'package.json',
85
+ patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/],
86
+ },
87
+ {
88
+ file: '.claude-plugin/plugin.json',
89
+ patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/],
90
+ },
91
+ {
92
+ file: '.claude-plugin/marketplace.json',
93
+ patterns: [/"version":\s*"(\d+\.\d+\.\d+)"/g],
94
+ },
95
+ {
96
+ // Codex manifest: version is '<base>+codex.<YYYYMMDDHHmmss>' (see
97
+ // scripts/lib/codex/plugin-contract.mjs). The base must equal the target;
98
+ // applyVersion additionally rotates the cachebuster timestamp. This
99
+ // surface was the first drift-sweep catch: a plain `rg` census missed it
100
+ // because ripgrep skips hidden directories by default — only `git grep`
101
+ // (and the validate-plugin base-version check) saw it.
102
+ file: '.codex-plugin/plugin.json',
103
+ patterns: [/"version":\s*"(\d+\.\d+\.\d+)\+codex\./],
104
+ },
105
+ {
106
+ file: 'hooks/hooks.json',
107
+ patterns: [/Session Orchestrator v(\d+\.\d+\.\d+)/],
108
+ },
109
+ {
110
+ file: 'hooks/hooks-codex.json',
111
+ patterns: [/Session Orchestrator v(\d+\.\d+\.\d+)/],
112
+ },
113
+ {
114
+ file: 'README.md',
115
+ patterns: [
116
+ /version-(\d+\.\d+\.\d+)-blue\.svg/,
117
+ /^## Recent highlights \(v(\d+\.\d+\.\d+)\)/m,
118
+ /Highlights of the v(\d+\.\d+\.\d+) line:/,
119
+ ],
120
+ },
121
+ {
122
+ file: 'site/index.html',
123
+ patterns: [/"softwareVersion":\s*"(\d+\.\d+\.\d+)"/, /v(\d+\.\d+\.\d+)\b/g],
124
+ },
125
+ {
126
+ file: 'site/llms.txt',
127
+ patterns: [/Version:\s*(\d+\.\d+\.\d+)/],
128
+ },
129
+ {
130
+ file: 'site/llms-full.txt',
131
+ patterns: [/Version\s+(\d+\.\d+\.\d+)/g],
132
+ },
133
+ ];
134
+
135
+ /**
136
+ * Scan every surface against the target version.
137
+ * Pure over the filesystem — no git/network. Returns one row per surface:
138
+ * { file, ok, problems: string[] }.
139
+ */
140
+ export function scanSurfaces(repoRoot, target) {
141
+ const rows = [];
142
+ for (const surface of SURFACES) {
143
+ const abs = join(repoRoot, surface.file);
144
+ const problems = [];
145
+ if (!existsSync(abs)) {
146
+ rows.push({ file: surface.file, ok: false, problems: ['file missing'] });
147
+ continue;
148
+ }
149
+ const text = readFileSync(abs, 'utf8');
150
+ for (const pattern of surface.patterns) {
151
+ const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
152
+ const found = [...text.matchAll(re)].map((m) => m[1]);
153
+ if (found.length === 0) {
154
+ problems.push(`pattern-dead: /${pattern.source}/ matches nothing`);
155
+ continue;
156
+ }
157
+ const stale = found.filter((v) => v !== target);
158
+ if (stale.length > 0) {
159
+ problems.push(`/${pattern.source}/ found ${stale.join(', ')} (want ${target})`);
160
+ }
161
+ }
162
+ rows.push({ file: surface.file, ok: problems.length === 0, problems });
163
+ }
164
+
165
+ // package-lock.json — parse, don't pattern-match (dep versions everywhere).
166
+ const lockPath = join(repoRoot, 'package-lock.json');
167
+ if (!existsSync(lockPath)) {
168
+ rows.push({ file: 'package-lock.json', ok: false, problems: ['file missing'] });
169
+ } else {
170
+ const problems = [];
171
+ try {
172
+ const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
173
+ if (lock.version !== target) problems.push(`root version is ${lock.version} (want ${target})`);
174
+ const rootPkg = lock.packages?.[''];
175
+ if (rootPkg && rootPkg.version !== target) {
176
+ problems.push(`packages[""].version is ${rootPkg.version} (want ${target})`);
177
+ }
178
+ } catch (err) {
179
+ problems.push(`unparseable: ${err.message}`);
180
+ }
181
+ rows.push({ file: 'package-lock.json', ok: problems.length === 0, problems });
182
+ }
183
+
184
+ return rows;
185
+ }
186
+
187
+ /**
188
+ * Mechanically rewrite every surface to the target version by replacing the
189
+ * captured version in each pattern match. Idempotent. Does NOT touch
190
+ * CHANGELOG.md or package-lock.json (the caller syncs the lock via npm).
191
+ * Returns the list of files actually changed.
192
+ */
193
+ export function applyVersion(repoRoot, target) {
194
+ const changed = [];
195
+ for (const surface of SURFACES) {
196
+ const abs = join(repoRoot, surface.file);
197
+ if (!existsSync(abs)) continue;
198
+ const before = readFileSync(abs, 'utf8');
199
+ let after = before;
200
+ for (const pattern of surface.patterns) {
201
+ const re = new RegExp(pattern.source, pattern.flags.includes('g') ? pattern.flags : pattern.flags + 'g');
202
+ after = after.replace(re, (full, captured) => full.replace(captured, target));
203
+ }
204
+ if (surface.file === '.codex-plugin/plugin.json') {
205
+ const stamp = new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14);
206
+ after = after.replace(/(\+codex\.)\d{14}/, `$1${stamp}`);
207
+ }
208
+ if (after !== before) {
209
+ writeFileSync(abs, after);
210
+ changed.push(surface.file);
211
+ }
212
+ }
213
+ return changed;
214
+ }
215
+
216
+ /**
217
+ * Editorial gate: the CHANGELOG must carry a dated entry for the target as
218
+ * its topmost release, and [Unreleased] must be folded (empty) — an
219
+ * Unreleased section with content means the release notes are incomplete.
220
+ */
221
+ export function checkChangelogEntry(text, target) {
222
+ const problems = [];
223
+ const entryRe = new RegExp(`^## \\[${target.replace(/\./g, '\\.')}\\] - \\d{4}-\\d{2}-\\d{2}`, 'm');
224
+ if (!entryRe.test(text)) {
225
+ problems.push(`no "## [${target}] - YYYY-MM-DD" entry`);
226
+ }
227
+ const headings = [...text.matchAll(/^## \[([^\]]+)\]/gm)].map((m) => m[1]);
228
+ const firstRelease = headings.find((h) => h.toLowerCase() !== 'unreleased');
229
+ if (firstRelease && firstRelease !== target) {
230
+ problems.push(`topmost release entry is [${firstRelease}], not [${target}]`);
231
+ }
232
+ const unreleasedMatch = text.match(/^## \[Unreleased\]([\s\S]*?)(?=^## \[|$(?![\s\S]))/m);
233
+ if (unreleasedMatch && unreleasedMatch[1].trim() !== '') {
234
+ problems.push('[Unreleased] still has content — fold it into the release entry');
235
+ }
236
+ return { ok: problems.length === 0, problems };
237
+ }
238
+
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.
241
+ 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 },
249
+ ];
250
+
251
+ /** Pure check over pack-output lines. Returns violations: {name, line}[]. */
252
+ export function checkLeakage(lines) {
253
+ const violations = [];
254
+ for (const line of lines) {
255
+ for (const { name, re } of LEAKAGE_PATTERNS) {
256
+ if (re.test(line)) violations.push({ name, line: line.trim() });
257
+ }
258
+ }
259
+ return violations;
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Impure orchestration below — git/npm/network. Not unit-tested; exercised
264
+ // by the release runs themselves.
265
+ // ---------------------------------------------------------------------------
266
+
267
+ function run(cmd, args, opts = {}) {
268
+ const res = spawnSync(cmd, args, { ...SPAWN_OPTS, ...opts });
269
+ if (res.error) throw new Error(`${cmd} ${args.join(' ')}: ${res.error.message}`);
270
+ return res;
271
+ }
272
+
273
+ function mustRun(cmd, args, opts = {}) {
274
+ const res = run(cmd, args, opts);
275
+ if (res.status !== 0) {
276
+ throw new Error(`${cmd} ${args.join(' ')} exited ${res.status}: ${(res.stderr || res.stdout || '').slice(0, 500)}`);
277
+ }
278
+ return res;
279
+ }
280
+
281
+ function readPackageVersion(repoRoot) {
282
+ return JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')).version;
283
+ }
284
+
285
+ async function preflight(repoRoot, target, { skipCi = false } = {}) {
286
+ const checks = [];
287
+ const add = (name, ok, detail = '') => checks.push({ name, ok, detail });
288
+
289
+ // 1. Git state: on main, clean tree, HEAD pushed.
290
+ const branch = run('git', ['branch', '--show-current'], { cwd: repoRoot }).stdout.trim();
291
+ 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 });
295
+ 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)}`);
298
+
299
+ // 2. Surface parity.
300
+ const surfaceRows = scanSurfaces(repoRoot, target);
301
+ for (const row of surfaceRows) {
302
+ add(`surface:${row.file}`, row.ok, row.problems.join('; '));
303
+ }
304
+
305
+ // 3. CHANGELOG editorial gate.
306
+ const changelog = checkChangelogEntry(readFileSync(join(repoRoot, 'CHANGELOG.md'), 'utf8'), target);
307
+ add('changelog-entry', changelog.ok, changelog.problems.join('; '));
308
+
309
+ // 3b. Drift sweep: no TRACKED file outside the surfaces table + allowlist
310
+ // may still carry the previous release's version literal. `git grep` (not
311
+ // rg) on purpose — it searches every tracked file including hidden
312
+ // directories, which is exactly how the forgotten .codex-plugin manifest
313
+ // was invisible to a plain rg census. Allowlisted: files that legitimately
314
+ // 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/, ''))
317
+ .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)/;
320
+ 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}`);
323
+ } else {
324
+ add('drift-sweep', true, 'no previous tag to sweep against');
325
+ }
326
+
327
+ // 4. Tag collision — local, origin, github mirror.
328
+ 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`);
331
+ for (const remote of ['origin', 'github']) {
332
+ const ls = run('git', ['ls-remote', '--tags', remote, `refs/tags/${tag}`], { cwd: repoRoot });
333
+ const collision = ls.status === 0 && ls.stdout.trim() !== '';
334
+ add(`tag-free-${remote}`, ls.status === 0 && !collision, collision ? `${tag} already on ${remote}` : ls.status !== 0 ? `ls-remote ${remote} failed` : '');
335
+ }
336
+
337
+ // 5. npm registry collision (E404 = name free = fine for a first publish).
338
+ 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)}`);
350
+ }
351
+
352
+ // 6. CI green on HEAD (the repo's iron session-start rule applies to
353
+ // releases doubly: local green is not evidence — see .claude/rules).
354
+ if (skipCi) {
355
+ add('ci-green-on-head', true, 'SKIPPED via --skip-ci');
356
+ } else {
357
+ const { checkCiStatus } = await import('./lib/ci-status-banner.mjs');
358
+ const ci = await checkCiStatus({ repoRoot, timeoutMs: 15000 });
359
+ const green = ci !== null && ci.status === 'green';
360
+ add('ci-green-on-head', green, ci === null ? 'CI status unavailable' : `status: ${ci.status}${ci.failingJobName ? ` (${ci.failingJobName})` : ''}`);
361
+ }
362
+
363
+ // 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`);
368
+
369
+ return checks;
370
+ }
371
+
372
+ function changelogExcerpt(repoRoot, target) {
373
+ const text = readFileSync(join(repoRoot, 'CHANGELOG.md'), 'utf8');
374
+ const re = new RegExp(`^## \\[${target.replace(/\./g, '\\.')}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, 'm');
375
+ const m = text.match(re);
376
+ return m ? m[1].trim().split('\n').slice(0, 40).join('\n') : '';
377
+ }
378
+
379
+ function publish(repoRoot, target) {
380
+ // Token: only from gitignored .env.local (verify the ignore before reading).
381
+ const ignored = run('git', ['check-ignore', '.env.local'], { cwd: repoRoot });
382
+ 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);
385
+ if (!tokenMatch) throw new Error('NPM_TOKEN not found in .env.local');
386
+ const token = tokenMatch[1].trim();
387
+
388
+ const tmpDir = mkdtempSync(join(tmpdir(), 'release-npmrc-'));
389
+ const tmpRc = join(tmpDir, 'npmrc');
390
+ try {
391
+ writeFileSync(tmpRc, `//registry.npmjs.org/:_authToken=${token}\n`);
392
+ 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
+ }
400
+ } finally {
401
+ rmSync(tmpDir, { recursive: true, force: true });
402
+ }
403
+
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']);
409
+ }
410
+ throw new Error(`registry verify failed: npm view does not report ${target} after 5 attempts`);
411
+ }
412
+
413
+ function tagAndPush(repoRoot, target) {
414
+ const tag = `v${target}`;
415
+ const excerpt = changelogExcerpt(repoRoot, target);
416
+ const msgDir = mkdtempSync(join(tmpdir(), 'release-tagmsg-'));
417
+ const msgFile = join(msgDir, 'msg');
418
+ 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 });
423
+ }
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);
429
+ }
430
+ return { tag, pushed };
431
+ }
432
+
433
+ function printChecks(checks, asJson, version) {
434
+ const ok = checks.every((c) => c.ok);
435
+ if (asJson) {
436
+ console.log(JSON.stringify({ ok, version, checks }, null, 2));
437
+ } else {
438
+ for (const c of checks) {
439
+ console.log(`${c.ok ? ' ok ' : 'FAIL '} ${c.name}${c.detail ? ` — ${c.detail}` : ''}`);
440
+ }
441
+ console.log(ok ? `\nAll ${checks.length} checks green for v${version}.` : `\n${checks.filter((c) => !c.ok).length} of ${checks.length} checks FAILED for v${version}.`);
442
+ }
443
+ return ok;
444
+ }
445
+
446
+ async function main() {
447
+ const { values } = parseArgs({
448
+ options: {
449
+ 'set-version': { type: 'string' },
450
+ check: { type: 'boolean', default: false },
451
+ publish: { type: 'boolean', default: false },
452
+ 'skip-ci': { type: 'boolean', default: false },
453
+ json: { type: 'boolean', default: false },
454
+ help: { type: 'boolean', default: false },
455
+ version: { type: 'boolean', default: false },
456
+ },
457
+ });
458
+
459
+ if (values.help) {
460
+ console.log('Usage: node scripts/release.mjs [--set-version X.Y.Z | --check | --publish] [--skip-ci] [--json]');
461
+ 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.');
463
+ return 0;
464
+ }
465
+ if (values.version) {
466
+ console.log(readPackageVersion(repoRootOf()));
467
+ return 0;
468
+ }
469
+
470
+ const repoRoot = repoRootOf();
471
+
472
+ if (values['set-version']) {
473
+ const target = values['set-version'];
474
+ if (!/^\d+\.\d+\.\d+$/.test(target)) {
475
+ console.error(`invalid version: ${target}`);
476
+ return 2;
477
+ }
478
+ const changed = applyVersion(repoRoot, target);
479
+ mustRun('npm', ['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], { cwd: repoRoot });
480
+ console.log(`Rewrote ${changed.length} surface file(s) to ${target}:`);
481
+ for (const f of changed) console.log(` ${f}`);
482
+ console.log(' package-lock.json (via npm install --package-lock-only)');
483
+ console.log('\nEditorial TODOs (enforced by --check):');
484
+ console.log(` 1. CHANGELOG.md — write the "## [${target}] - YYYY-MM-DD" entry, fold [Unreleased].`);
485
+ console.log(' 2. README.md — rewrite the "Recent highlights" section content.');
486
+ return 0;
487
+ }
488
+
489
+ if (values.check || values.publish) {
490
+ const target = readPackageVersion(repoRoot);
491
+ const checks = await preflight(repoRoot, target, { skipCi: values['skip-ci'] });
492
+ const ok = printChecks(checks, values.json && !values.publish, target);
493
+ if (!ok) return 1;
494
+ if (!values.publish) return 0;
495
+
496
+ 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;
506
+ }
507
+
508
+ console.error('Nothing to do — pass --check, --publish, or --set-version X.Y.Z (see --help).');
509
+ return 2;
510
+ }
511
+
512
+ function repoRootOf() {
513
+ const res = spawnSync('git', ['rev-parse', '--show-toplevel'], SPAWN_OPTS);
514
+ if (res.status !== 0) throw new Error('not inside a git repository');
515
+ return res.stdout.trim();
516
+ }
517
+
518
+ const isMain = (() => {
519
+ try {
520
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
521
+ } catch {
522
+ return false;
523
+ }
524
+ })();
525
+
526
+ if (isMain) {
527
+ main().then(
528
+ (code) => process.exit(code),
529
+ (err) => {
530
+ console.error(`release.mjs: ${err.message}`);
531
+ process.exit(2);
532
+ },
533
+ );
534
+ }