cool-workflow 0.2.3 → 0.2.5

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 (143) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +33 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/io.js +6 -20
  18. package/dist/cli/parseargv.js +7 -1
  19. package/dist/core/capability-data.js +339 -10
  20. package/dist/core/format/help.js +80 -4
  21. package/dist/core/format/recovery-hint.js +32 -0
  22. package/dist/core/multi-agent/collaboration.js +35 -6
  23. package/dist/core/multi-agent/runtime.js +7 -0
  24. package/dist/core/multi-agent/trust-policy.js +7 -1
  25. package/dist/core/pipeline/contract.js +7 -0
  26. package/dist/core/pipeline/error-feedback.js +2 -2
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +55 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +124 -13
  32. package/dist/mcp-server.js +20 -0
  33. package/dist/shell/commit.js +8 -2
  34. package/dist/shell/coordinator-io.js +73 -1
  35. package/dist/shell/drive.js +129 -64
  36. package/dist/shell/error-feedback-io.js +6 -0
  37. package/dist/shell/execution-backend/agent.js +205 -24
  38. package/dist/shell/execution-backend/container.js +44 -10
  39. package/dist/shell/execution-backend/local.js +47 -10
  40. package/dist/shell/fs-atomic.js +93 -12
  41. package/dist/shell/ledger-cli.js +9 -2
  42. package/dist/shell/multi-agent-cli.js +5 -1
  43. package/dist/shell/onramp.js +48 -5
  44. package/dist/shell/pipeline-cli.js +20 -2
  45. package/dist/shell/pipeline.js +2 -1
  46. package/dist/shell/reclamation-io.js +76 -7
  47. package/dist/shell/registry-cli.js +4 -0
  48. package/dist/shell/run-export.js +52 -4
  49. package/dist/shell/run-registry-io.js +1 -0
  50. package/dist/shell/run-store.js +156 -0
  51. package/dist/shell/scheduler-io.js +101 -10
  52. package/dist/shell/telemetry-ledger-io.js +36 -24
  53. package/dist/shell/trust-audit.js +104 -10
  54. package/dist/shell/workbench-host.js +121 -10
  55. package/dist/shell/workbench.js +79 -5
  56. package/dist/shell/worker-isolation.js +1 -1
  57. package/dist/shell/workflow-app-loader.js +67 -1
  58. package/dist/wiring/capability-table/basics.js +7 -2
  59. package/dist/wiring/capability-table/exec-backend.js +42 -24
  60. package/dist/wiring/capability-table/multi-agent.js +69 -69
  61. package/dist/wiring/capability-table/pipeline.js +60 -28
  62. package/dist/wiring/capability-table/registry-core.js +29 -6
  63. package/dist/wiring/capability-table/reporting.js +45 -39
  64. package/dist/wiring/capability-table/scheduling-registry.js +66 -60
  65. package/dist/wiring/capability-table/state.js +34 -34
  66. package/dist/wiring/capability-table/trust-ledger.js +66 -24
  67. package/dist/wiring/capability-table/workflow-apps.js +15 -15
  68. package/docs/agent-delegation-drive.7.md +17 -0
  69. package/docs/cli-mcp-parity.7.md +43 -13
  70. package/docs/contract-migration-tooling.7.md +4 -0
  71. package/docs/control-plane-scheduling.7.md +38 -0
  72. package/docs/cross-agent-ledger.7.md +20 -8
  73. package/docs/durable-state-and-locking.7.md +4 -0
  74. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  75. package/docs/execution-backends.7.md +4 -0
  76. package/docs/fix.7.md +4 -4
  77. package/docs/getting-started.md +40 -32
  78. package/docs/index.md +17 -0
  79. package/docs/launch/demo.tape +4 -3
  80. package/docs/mcp-app-surface.7.md +6 -0
  81. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  82. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  83. package/docs/multi-agent-operator-ux.7.md +4 -0
  84. package/docs/node-snapshot-diff-replay.7.md +4 -0
  85. package/docs/observability-cost-accounting.7.md +4 -0
  86. package/docs/project-index.md +43 -9
  87. package/docs/real-execution-backends.7.md +4 -0
  88. package/docs/release-and-migration.7.md +4 -0
  89. package/docs/release-tooling.7.md +22 -0
  90. package/docs/routine.7.md +22 -0
  91. package/docs/run-registry-control-plane.7.md +4 -0
  92. package/docs/run-retention-reclamation.7.md +28 -3
  93. package/docs/state-explosion-management.7.md +4 -0
  94. package/docs/team-collaboration.7.md +4 -0
  95. package/docs/trust-audit-anchor.7.md +4 -0
  96. package/docs/web-desktop-workbench.7.md +26 -2
  97. package/docs/workflow-app-framework.7.md +31 -0
  98. package/manifest/plugin.manifest.json +1 -1
  99. package/manifest/source-context-profiles.json +4 -3
  100. package/package.json +5 -2
  101. package/scripts/agents/agent-adapter-core.js +26 -0
  102. package/scripts/agents/claude-p-agent.js +4 -1
  103. package/scripts/agents/codex-agent.js +4 -0
  104. package/scripts/agents/cw-attest-wrap.js +1 -1
  105. package/scripts/agents/gemini-agent.js +4 -0
  106. package/scripts/agents/opencode-agent.js +5 -0
  107. package/scripts/block-unapproved-tag.js +160 -0
  108. package/scripts/bump-version.js +27 -10
  109. package/scripts/canonical-apps.js +4 -4
  110. package/scripts/children/batch-delegate-child.js +72 -4
  111. package/scripts/children/http-batch-delegate-child.js +132 -0
  112. package/scripts/children/http-delegate-child.js +8 -0
  113. package/scripts/dogfood-release.js +2 -2
  114. package/scripts/fake-date-for-reproduction.js +1 -1
  115. package/scripts/golden-path.js +4 -4
  116. package/scripts/lang-policy-check.js +82 -0
  117. package/scripts/purity-baseline.json +0 -30
  118. package/scripts/purity-gate.js +16 -3
  119. package/scripts/release-check.js +2 -1
  120. package/scripts/release-flow.js +264 -41
  121. package/scripts/release-gate.js +184 -0
  122. package/scripts/release-oneclick.js +438 -0
  123. package/scripts/release-tags.js +47 -0
  124. package/scripts/verdict-keygen.js +2 -2
  125. package/scripts/verify-bump-reproduction.js +193 -0
  126. package/scripts/verify-verdict-signature.js +1 -1
  127. package/scripts/version-sync-check.js +39 -22
  128. package/ui/workbench/app.css +41 -9
  129. package/ui/workbench/app.js +160 -27
  130. package/ui/workbench/index.html +2 -2
  131. package/docs/agent-framework.md +0 -177
  132. package/docs/designs/handoff-ledger.md +0 -145
  133. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  134. package/docs/launch/launch-kit.md +0 -195
  135. package/docs/launch/pre-launch-checklist.md +0 -53
  136. package/docs/readme-v0.1.87-full.md +0 -301
  137. package/docs/routines.md +0 -101
  138. package/docs/scheduled-tasks.md +0 -80
  139. package/scripts/agents/claude-p-agent.sh +0 -9
  140. package/scripts/block-unapproved-tag.sh +0 -60
  141. package/scripts/release-gate.sh +0 -94
  142. package/scripts/verify-bump-reproduction.sh +0 -148
  143. package/scripts/verify-container-selfref.js +0 -64
@@ -0,0 +1,438 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // release-oneclick.js — the operator's ONE command for a full release.
5
+ //
6
+ // npm run release -- X.Y.Z
7
+ // node scripts/release-oneclick.js X.Y.Z [--dry-run]
8
+ //
9
+ // This is a pure orchestrator over EXISTING tools (release-flow.js,
10
+ // bump-version.js, sync-project-index.js, gh, git) — it adds no new release
11
+ // logic of its own, only sequencing, resumability, and fail-fast checks.
12
+ //
13
+ // The two-step release model this implements:
14
+ // 1. Ask the agent to "prepare release X.Y.Z" — it drafts the CHANGELOG
15
+ // entry and opens/merges the version-bump PR. This step never touches
16
+ // CW_RELEASE_VERDICT_PRIVKEY.
17
+ // 2. The OPERATOR runs this script, in a shell where
18
+ // CW_RELEASE_VERDICT_PRIVKEY is set. Nothing in this file reads that
19
+ // env var directly — it is forwarded to `release-flow.js`'s existing
20
+ // signVerdictIfConfigured(), unchanged.
21
+ //
22
+ // Stages (each is idempotent — probes current state before acting, so a
23
+ // re-run after a failure resumes rather than repeats):
24
+ // 0. preflight — release-flow.js --cut --preflight-only (fail in seconds,
25
+ // before the gate/vendor-preflight/reviewer spend anything)
26
+ // 1. bump PR — only if package.json is not already at the target
27
+ // version; skipped when the agent's prep PR already landed
28
+ // 2. cut — release-flow.js --cut --version X --push (gate, live
29
+ // vendor preflight, reviewer, sign, tag-only push, Release)
30
+ // 3. record+wait — PR landing the verdict+.sig onto main (informational,
31
+ // no gate implications) + poll release-gate/npm-publish +
32
+ // confirm `npm view` shows the new version
33
+ //
34
+ // Resume: when the vX.Y.Z tag already exists (local or on origin), the cut
35
+ // already happened — stages 0-2 are skipped and the run goes STRAIGHT to
36
+ // stage 3. This is what makes a re-run after a stage-3 death (a red CI run,
37
+ // a network drop, a Ctrl-C mid-wait) pick the release back up instead of
38
+ // being refused by the preflight's own already-tagged check. Every wait has
39
+ // a hard deadline and dies loudly after repeated gh/git failures — a hung
40
+ // wait is indistinguishable from a dead release otherwise.
41
+ //
42
+ // Test seam: CW_ONECLICK_GH_CMD / CW_ONECLICK_GIT_CMD override the gh/git
43
+ // binaries (single executable token, spawned shell:false) for smoke stubs.
44
+
45
+ const fs = require("node:fs");
46
+ const path = require("node:path");
47
+ const { spawnSync } = require("node:child_process");
48
+
49
+ const scriptsDir = __dirname;
50
+ const pluginRoot = path.resolve(scriptsDir, "..");
51
+ const repoRoot = path.resolve(pluginRoot, "..", "..");
52
+
53
+ const argv = process.argv.slice(2);
54
+ const DRY_RUN = argv.includes("--dry-run");
55
+ const version = argv.find((a) => /^\d+\.\d+\.\d+$/.test(a));
56
+
57
+ const GIT_BIN = (process.env.CW_ONECLICK_GIT_CMD || "git").trim();
58
+ const GH_BIN = (process.env.CW_ONECLICK_GH_CMD || "gh").trim();
59
+
60
+ function die(msg, extra) {
61
+ process.stderr.write(`release-oneclick: ${msg}\n`);
62
+ if (extra) process.stderr.write(`${extra}\n`);
63
+ process.exit(1);
64
+ }
65
+ function say(msg) {
66
+ process.stdout.write(`${msg}\n`);
67
+ }
68
+ function stage(n, label) {
69
+ say(`\n[${n}/4] ${label}`);
70
+ }
71
+ function git(args, opts = {}) {
72
+ const r = spawnSync(GIT_BIN, args, { cwd: repoRoot, encoding: "utf8", shell: false, ...opts });
73
+ return { code: r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
74
+ }
75
+ // Like git(), but stdout is returned VERBATIM — no trim. The record stage
76
+ // copies verdict/.sig bytes out of the tag commit, and those bytes must stay
77
+ // exactly what was signed (a trimmed copy next to the same .sig could never
78
+ // verify again).
79
+ function gitRaw(args, opts = {}) {
80
+ const r = spawnSync(GIT_BIN, args, { cwd: repoRoot, encoding: "buffer", shell: false, ...opts });
81
+ return { code: r.status, out: r.stdout || Buffer.alloc(0), err: (r.stderr || "").toString().trim() };
82
+ }
83
+ function gh(args, opts = {}) {
84
+ const r = spawnSync(GH_BIN, args, { cwd: repoRoot, encoding: "utf8", shell: false, ...opts });
85
+ return { code: r.status, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
86
+ }
87
+ function sleep(ms) {
88
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
89
+ }
90
+ // Shared poll helper: every wait in this script has a hard deadline (a wait
91
+ // that can spin forever hides a dead release) and dies after too many gh/git
92
+ // failures in a row (an expired auth or dead network must surface, not hang).
93
+ function poll({ label, timeoutMs, intervalMs, probe }) {
94
+ const deadline = Date.now() + timeoutMs;
95
+ let consecutiveErrors = 0;
96
+ for (;;) {
97
+ const r = probe();
98
+ if (r && r.done) return r.value;
99
+ consecutiveErrors = r && r.error ? consecutiveErrors + 1 : 0;
100
+ if (consecutiveErrors >= 10) die(`${label}: 10 gh/git probes in a row failed — check network/auth and re-run to resume.`);
101
+ if (Date.now() > deadline) die(`${label}: still not done after ${Math.round(timeoutMs / 60000)} minutes — inspect manually, then re-run to resume.`);
102
+ sleep(intervalMs);
103
+ }
104
+ }
105
+
106
+ if (!version) {
107
+ die("usage: node scripts/release-oneclick.js X.Y.Z [--dry-run]");
108
+ }
109
+
110
+ // ---- resume detection (before anything else) --------------------------------
111
+ // After a successful cut, the vX.Y.Z tag exists — release-flow's preflight
112
+ // would then (correctly, for a FRESH cut) refuse to run. But a re-run of THIS
113
+ // script after a stage-3 failure (a red CI run, a network drop, a Ctrl-C
114
+ // during the wait) must not be told "pick the next version": the release is
115
+ // mid-flight, not done. So: tag already cut -> skip straight to stage 3.
116
+ function alreadyCut() {
117
+ if (git(["tag", "-l", `v${version}`]).out) return true;
118
+ if (DRY_RUN) return false; // offline decision only in dry-run
119
+ const remote = git(["ls-remote", "origin", `refs/tags/v${version}`]);
120
+ return remote.code === 0 && Boolean(remote.out);
121
+ }
122
+
123
+ // A cut tags the verdict commit LOCALLY and only then pushes the tag. If that
124
+ // push dies (a network drop, an auth expiry) the tag is left local-only:
125
+ // alreadyCut() still sees it and resumes at stage 3, but stage 3 would then
126
+ // wait 45 minutes for a release-gate run CI never started, because the tag
127
+ // never reached origin. So on resume, before the CI wait, make sure the tag is
128
+ // actually on origin — re-push it (the same tag-only refspec cut() uses) when
129
+ // it is only local. This keeps the tag-only-push contract: no branch is pushed,
130
+ // and the tag commit's first parent is untouched (it is exactly what was cut).
131
+ function ensureTagPushedOnResume() {
132
+ const remote = git(["ls-remote", "origin", `refs/tags/v${version}`]);
133
+ if (remote.code !== 0) {
134
+ die("could not reach origin to check the tag — fix network/auth and re-run to resume.", remote.err);
135
+ }
136
+ if (remote.out) return; // already on origin — nothing to do
137
+ say(`tag v${version} is local-only (the cut's tag push must have failed) — re-pushing refs/tags/v${version}.`);
138
+ const push = git(["push", "origin", `refs/tags/v${version}`]);
139
+ if (push.code !== 0) {
140
+ die("re-push of the local-only tag failed (nothing else was done) — fix the issue and re-run to resume.", push.err);
141
+ }
142
+ say(`re-pushed refs/tags/v${version} to origin.`);
143
+ }
144
+
145
+ // ---- stage 0: preflight -----------------------------------------------------
146
+ function runPreflight() {
147
+ stage(0, "preflight");
148
+ const args = ["scripts/release-flow.js", "--cut", "--version", version, "--push", "--preflight-only"];
149
+ const r = spawnSync("node", args, { cwd: pluginRoot, encoding: "utf8", stdio: "inherit" });
150
+ if (r.status !== 0) {
151
+ die("preflight failed — fix the issue above before re-running (nothing was done yet).");
152
+ }
153
+ if (gh(["--version"]).code !== 0) die("gh CLI not found — required for the bump/verdict PRs and the GitHub Release.");
154
+ if (gh(["auth", "status"]).code !== 0) die("gh is not authenticated — run `gh auth login` first.");
155
+ say("preflight OK");
156
+ }
157
+
158
+ // ---- stage 1: bump PR (skipped if already at target version) ---------------
159
+ function currentPackageVersion() {
160
+ const pkg = JSON.parse(fs.readFileSync(path.join(pluginRoot, "package.json"), "utf8"));
161
+ return pkg.version;
162
+ }
163
+
164
+ function runBumpStage() {
165
+ stage(1, "version bump");
166
+ if (currentPackageVersion() === version) {
167
+ say(`package.json already at ${version} — assuming the prep PR already landed, skipping.`);
168
+ return;
169
+ }
170
+ if (DRY_RUN) {
171
+ say(`[dry-run] would: branch off origin/main, bump:version -- ${version} --content, sync:project-index, open + auto-merge a bump PR.`);
172
+ return;
173
+ }
174
+
175
+ const fetch = git(["fetch", "origin", "main", "--quiet"]);
176
+ if (fetch.code !== 0) die("git fetch origin main failed — the bump branch must start from the REAL main tip", fetch.err);
177
+ const branch = `release/v${version}-bump`;
178
+ const co = git(["checkout", "-B", branch, "origin/main"]);
179
+ if (co.code !== 0) die("could not branch from origin/main for the bump PR", co.err);
180
+
181
+ const bump = spawnSync("npm", ["run", "bump:version", "--", version, "--content"], { cwd: pluginRoot, stdio: "inherit" });
182
+ if (bump.status !== 0) die("bump:version --content failed");
183
+
184
+ const sync = spawnSync("npm", ["run", "sync:project-index", "--", "--repo-only"], { cwd: pluginRoot, stdio: "inherit" });
185
+ if (sync.status !== 0) die("sync:project-index failed");
186
+
187
+ // tsc's incremental cache can ship a stale dist/version.js after a version
188
+ // edit (documented gotcha) — force a clean rebuild before committing.
189
+ fs.rmSync(path.join(pluginRoot, ".cache"), { recursive: true, force: true });
190
+ const build = spawnSync("npm", ["run", "build"], { cwd: pluginRoot, stdio: "inherit" });
191
+ if (build.status !== 0) die("npm run build failed after bump");
192
+
193
+ const logPath = path.join(repoRoot, "ITERATION_LOG.md");
194
+ const log = fs.readFileSync(logPath, "utf8");
195
+ const entry = [
196
+ "# CW Iteration Log",
197
+ "",
198
+ `## Batch — v${version} version bump (Unreleased)`,
199
+ "",
200
+ `> Release prep for v${version}: bump every structured surface with`,
201
+ `> \`bump:version -- ${version} --content\` and fill the gated content`,
202
+ "> surfaces (CHANGELOG.md, RELEASE.md, docs version lists). The bump",
203
+ "> lands as its own PR before the cut so the cut's own bump:version step",
204
+ "> is a no-op. No behavior change; the version constant is the only src",
205
+ "> edit. Generated by release-oneclick.js's bump stage.",
206
+ "",
207
+ "| cycle | goal | files | tests | gate | tagged |",
208
+ "|-------|------|-------|-------|------|--------|",
209
+ `| - | Bump to v${version} across all structured + content surfaces via release-oneclick.js. | ` +
210
+ "package.json + lockfile + manifests + src/core/version.ts + matching dist/**, CHANGELOG.md, RELEASE.md, docs/*.7.md version lists. | " +
211
+ "Full local gate before PR. | BUILD OK; content-surface gate OK. | no (bump PR; the tag comes from release-flow --cut) |"
212
+ ].join("\n");
213
+ fs.writeFileSync(logPath, `${entry}\n\n${log.replace(/^# CW Iteration Log\n\n?/, "")}`);
214
+
215
+ // Tracked files only — NEVER `git add -A` here. Everything the bump stage
216
+ // touches is already tracked (bump surfaces, docs appends, ITERATION_LOG,
217
+ // dist), and an untracked stray (a scratch file, a reviewer transcript with
218
+ // the operator's home path) must not ride into a PR onto main — the exact
219
+ // incident cut()'s staging comment documents from v0.1.96.
220
+ git(["add", "-u"], { cwd: repoRoot });
221
+ const commit = git(["commit", "-m", `chore(release): bump version to ${version}`], { cwd: repoRoot });
222
+ if (commit.code !== 0) die("bump commit failed", commit.err);
223
+
224
+ // --force-with-lease: a prior run may have pushed this branch and then died
225
+ // before the PR merged; the re-run rebuilds the branch from origin/main with
226
+ // a new sha, and a plain push would be rejected non-fast-forward. The lease
227
+ // keeps this safe: it only replaces the remote branch we saw at fetch time.
228
+ const push = git(["push", "-u", "--force-with-lease", "origin", branch], { cwd: repoRoot });
229
+ if (push.code !== 0) die("bump branch push failed", push.err);
230
+
231
+ const pr = gh(["pr", "create", "--base", "main", "--head", branch, "--title", `chore(release): bump version to ${version}`, "--body", "Automated version bump ahead of the gated cut. Generated by release-oneclick.js."]);
232
+ if (pr.code !== 0) die("gh pr create failed for the bump PR", pr.err);
233
+ say(pr.out);
234
+
235
+ const merge = gh(["pr", "merge", branch, "--auto", "--squash"]);
236
+ if (merge.code !== 0) die("gh pr merge --auto failed for the bump PR", merge.err);
237
+
238
+ say("waiting for the bump PR to merge...");
239
+ poll({
240
+ label: "bump PR merge wait",
241
+ timeoutMs: 30 * 60 * 1000,
242
+ intervalMs: 15000,
243
+ probe: () => {
244
+ const view = gh(["pr", "view", branch, "--json", "state"]);
245
+ if (view.code !== 0) return { error: true };
246
+ let state;
247
+ try {
248
+ state = JSON.parse(view.out).state;
249
+ } catch {
250
+ return { error: true };
251
+ }
252
+ if (state === "MERGED") return { done: true };
253
+ if (state === "CLOSED") die("the bump PR was closed without merging");
254
+ return {};
255
+ }
256
+ });
257
+ say("bump PR merged.");
258
+ }
259
+
260
+ // ---- stage 2: cut ------------------------------------------------------------
261
+ function runCutStage() {
262
+ stage(2, "cut (gate + vendor preflight + reviewer + sign + tag + push)");
263
+ if (DRY_RUN) {
264
+ say(`[dry-run] would: node scripts/release-flow.js --cut --version ${version} --push`);
265
+ return;
266
+ }
267
+ git(["fetch", "origin", "main", "--quiet"], { cwd: repoRoot });
268
+ const co = git(["checkout", "-B", `release/v${version}-cut`, "origin/main"], { cwd: repoRoot });
269
+ if (co.code !== 0) die("could not branch from origin/main for the cut", co.err);
270
+
271
+ const r = spawnSync("node", ["scripts/release-flow.js", "--cut", "--version", version, "--push"], {
272
+ cwd: pluginRoot,
273
+ encoding: "utf8",
274
+ stdio: "inherit"
275
+ });
276
+ if (r.status !== 0) die("cut failed — see the release-flow.js output above.");
277
+ say("cut complete: tag pushed, GitHub Release created.");
278
+ }
279
+
280
+ // ---- stage 3: record on main + wait for CI + confirm npm -------------------
281
+ function verdictPaths() {
282
+ const dir = path.join(repoRoot, ".cw-release");
283
+ const tagCommit = git(["rev-parse", `v${version}^{commit}`]).out;
284
+ const reviewedParent = git(["rev-parse", `v${version}~1`]).out;
285
+ // cut() commits the verdict directly on the reviewed parent, so the
286
+ // filename is keyed on THAT sha, not the tag commit's own sha.
287
+ const verdict = path.join(dir, `review-${reviewedParent}.verdict`);
288
+ const sig = `${verdict}.sig`;
289
+ return { tagCommit, reviewedParent, verdict, sig, dir };
290
+ }
291
+
292
+ function runRecordAndWaitStage() {
293
+ stage(3, "record on main + wait for CI + confirm npm");
294
+ if (DRY_RUN) {
295
+ say("[dry-run] would: open a PR landing the verdict+.sig onto main, then poll release-gate/npm-publish/npm view.");
296
+ return;
297
+ }
298
+
299
+ const { tagCommit, reviewedParent, verdict, sig } = verdictPaths();
300
+ const relVerdict = path.relative(repoRoot, verdict);
301
+ const relSig = path.relative(repoRoot, sig);
302
+ // Byte-exact copies from the TAG commit — the .sig was made over the
303
+ // verdict file's exact bytes, so a trimmed/normalized copy on main could
304
+ // never verify against the same signature again.
305
+ const verdictBytes = gitRaw(["show", `${tagCommit}:${relVerdict}`], { cwd: repoRoot });
306
+ if (verdictBytes.code === 0) {
307
+ const fetch = git(["fetch", "origin", "main", "--quiet"], { cwd: repoRoot });
308
+ if (fetch.code !== 0) {
309
+ say(`WARN: git fetch failed (${fetch.err}) — skipping the main-record PR (informational only).`);
310
+ } else {
311
+ const branch = `release/v${version}-record`;
312
+ const co = git(["checkout", "-B", branch, "origin/main"], { cwd: repoRoot });
313
+ if (co.code === 0) {
314
+ fs.mkdirSync(path.dirname(verdict), { recursive: true });
315
+ fs.writeFileSync(verdict, verdictBytes.out);
316
+ const sigBytes = gitRaw(["show", `${tagCommit}:${relSig}`], { cwd: repoRoot });
317
+ if (sigBytes.code === 0) fs.writeFileSync(sig, sigBytes.out);
318
+ git(["add", "--", relVerdict], { cwd: repoRoot });
319
+ if (fs.existsSync(sig)) git(["add", "--", relSig], { cwd: repoRoot });
320
+ const status = git(["status", "--porcelain"], { cwd: repoRoot });
321
+ if (status.out) {
322
+ const commit = git(["commit", "-m", `chore(release): record the v${version} reviewer verdict on main`], { cwd: repoRoot });
323
+ if (commit.code === 0) {
324
+ git(["push", "-u", "--force-with-lease", "origin", branch], { cwd: repoRoot });
325
+ const pr = gh(["pr", "create", "--base", "main", "--head", branch, "--title", `chore(release): record the v${version} reviewer verdict on main`, "--body", `Informational — lands the tagged v${version} verdict + signature onto main for the repo's own audit trail. No gate implications (main's required checks don't include release-gate). Reviewed commit: ${reviewedParent}.`]);
326
+ if (pr.code === 0) {
327
+ say(pr.out);
328
+ gh(["pr", "merge", branch, "--auto", "--squash"]);
329
+ } else {
330
+ say(`WARN: could not open the record PR (${pr.err}) — continuing, this is informational only.`);
331
+ }
332
+ }
333
+ } else {
334
+ say("main already has this verdict recorded — nothing to do.");
335
+ }
336
+ }
337
+ }
338
+ } else {
339
+ say(`WARN: no verdict at ${relVerdict} in the tag commit — skipping the main-record PR (informational only, not release-blocking).`);
340
+ }
341
+
342
+ say(`waiting for release-gate on tag v${version}...`);
343
+ const gate = poll({
344
+ label: "release-gate wait",
345
+ timeoutMs: 45 * 60 * 1000,
346
+ intervalMs: 20000,
347
+ probe: () => {
348
+ const list = gh(["run", "list", "--workflow", "release-gate", "--limit", "10", "--json", "databaseId,headBranch,status,conclusion,updatedAt"]);
349
+ if (list.code !== 0) return { error: true };
350
+ let runs;
351
+ try {
352
+ runs = JSON.parse(list.out);
353
+ } catch {
354
+ return { error: true };
355
+ }
356
+ const gateRun = runs.find((r) => r.headBranch === `v${version}`);
357
+ if (gateRun && gateRun.status === "completed") {
358
+ // Carry the gate's OWN completion time out of the poll — the npm-publish
359
+ // cutoff below must key off when the gate actually finished, not off the
360
+ // wall clock now (a resume can start long after the gate completed).
361
+ if (gateRun.conclusion === "success") return { done: true, value: { id: gateRun.databaseId, completedMs: Date.parse(gateRun.updatedAt) } };
362
+ die(`release-gate FAILED — inspect: gh run view ${gateRun.databaseId} --log-failed`);
363
+ }
364
+ return {};
365
+ }
366
+ });
367
+ say(`release-gate: SUCCESS (run ${gate.id})`);
368
+
369
+ // npm-publish is created only AFTER release-gate completes (workflow_run
370
+ // trigger), so the run to wait for may not exist yet — and the newest
371
+ // completed npm-publish run at this moment is usually the PREVIOUS
372
+ // release's. Only accept a run created at-or-after the moment the gate
373
+ // FINISHED (small clock-skew allowance), and keep waiting until that run
374
+ // exists and completes. The cutoff is the gate run's own completion time,
375
+ // NOT Date.now(): on a resume minutes/days later, a now-based cutoff sits in
376
+ // the future relative to an already-completed npm-publish run and would drop
377
+ // it, hanging the wait for a publish that is already done. Fall back to now
378
+ // only if the gate run reported no parseable completion time.
379
+ say("waiting for npm-publish...");
380
+ const gateDoneMs = Number.isFinite(gate.completedMs) ? gate.completedMs : Date.now();
381
+ poll({
382
+ label: "npm-publish wait",
383
+ timeoutMs: 30 * 60 * 1000,
384
+ intervalMs: 20000,
385
+ probe: () => {
386
+ const list = gh(["run", "list", "--workflow", "npm-publish", "--limit", "5", "--json", "databaseId,status,conclusion,createdAt"]);
387
+ if (list.code !== 0) return { error: true };
388
+ let runs;
389
+ try {
390
+ runs = JSON.parse(list.out);
391
+ } catch {
392
+ return { error: true };
393
+ }
394
+ const fresh = runs
395
+ .filter((r) => Date.parse(r.createdAt) >= gateDoneMs - 120000)
396
+ .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1))[0];
397
+ if (fresh && fresh.status === "completed") {
398
+ if (fresh.conclusion === "success") return { done: true };
399
+ die(`npm-publish FAILED (conclusion: ${fresh.conclusion}) — inspect: gh run view ${fresh.databaseId} --log-failed`);
400
+ }
401
+ return {};
402
+ }
403
+ });
404
+ say("npm-publish: SUCCESS");
405
+
406
+ say("confirming npm...");
407
+ poll({
408
+ label: "npm registry confirmation",
409
+ timeoutMs: 10 * 60 * 1000,
410
+ intervalMs: 15000,
411
+ probe: () => {
412
+ const view = spawnSync("npm", ["view", "cool-workflow", "version"], { encoding: "utf8" });
413
+ if (view.status !== 0) return { error: true };
414
+ return view.stdout.trim() === version ? { done: true } : {};
415
+ }
416
+ });
417
+ say(`npm view cool-workflow version -> ${version} — confirmed live.`);
418
+ const releaseUrl = gh(["release", "view", `v${version}`, "--json", "url", "--jq", ".url"]);
419
+ say(`\nRELEASE COMPLETE: v${version}`);
420
+ if (releaseUrl.code === 0) say(`GitHub Release: ${releaseUrl.out}`);
421
+ say(`npm: https://www.npmjs.com/package/cool-workflow/v/${version}`);
422
+ }
423
+
424
+ if (alreadyCut()) {
425
+ say(`tag v${version} already exists — the cut already happened; resuming at stage 3 (CI wait + record + confirm).`);
426
+ if (DRY_RUN) {
427
+ say("[dry-run] would resume at stage 3: record PR + wait for release-gate/npm-publish + npm view confirmation.");
428
+ say("[dry-run] would first re-push refs/tags/v" + version + " if it is only local (the cut's tag push had failed).");
429
+ } else {
430
+ ensureTagPushedOnResume();
431
+ runRecordAndWaitStage();
432
+ }
433
+ } else {
434
+ runPreflight();
435
+ runBumpStage();
436
+ runCutStage();
437
+ runRecordAndWaitStage();
438
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ // release-tags.js — the ONE semver-ordered release-tag lookup, shared by
3
+ // release-flow.js and release-gate.js so the two can never drift (the gate
4
+ // used to carry a bash port of this logic; a mirror copy is exactly the kind
5
+ // of thing that rots).
6
+ //
7
+ // "What was the last release" is a SEMVER question, not an ancestry question:
8
+ // a cut's own tag commit lives on an ephemeral line that is NEVER merged as
9
+ // an ancestor of `main` (cut() tags the verdict-record commit directly), so
10
+ // `git describe --tags --abbrev=0`, which walks ONLY ancestors, always skips
11
+ // the true previous release tag and silently lands one release further back
12
+ // (verified live: `git describe --tags v0.2.4^` -> v0.2.2, skipping v0.2.3).
13
+ // List every vX.Y.Z tag and pick by version order instead.
14
+
15
+ function parseSemverTag(tag) {
16
+ const m = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
17
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
18
+ }
19
+
20
+ function compareSemver(a, b) {
21
+ for (let i = 0; i < 3; i++) if (a[i] !== b[i]) return a[i] - b[i];
22
+ return 0;
23
+ }
24
+
25
+ // gitOut: (args: string[]) => string — the caller's own git runner (each
26
+ // caller binds its own repo root), returning trimmed stdout, "" on failure.
27
+ function listReleaseTagsDesc(gitOut) {
28
+ const out = gitOut(["tag", "-l", "v*"]);
29
+ if (!out) return [];
30
+ return out
31
+ .split("\n")
32
+ .filter(Boolean)
33
+ .map((tag) => ({ tag, semver: parseSemverTag(tag) }))
34
+ .filter((t) => t.semver)
35
+ .sort((a, b) => compareSemver(b.semver, a.semver));
36
+ }
37
+
38
+ // The most recent ALREADY-RELEASED tag as of right now: the highest vX.Y.Z
39
+ // tag that does NOT already point at HEAD — so this works whether run before
40
+ // tagging or re-run on a freshly tagged commit.
41
+ function resolvePrevReleaseTag(gitOut) {
42
+ const headTags = new Set(gitOut(["tag", "--points-at", "HEAD"]).split("\n").filter(Boolean));
43
+ const found = listReleaseTagsDesc(gitOut).find((t) => !headTags.has(t.tag));
44
+ return found ? found.tag : "";
45
+ }
46
+
47
+ module.exports = { parseSemverTag, compareSemver, listReleaseTagsDesc, resolvePrevReleaseTag };
@@ -9,7 +9,7 @@
9
9
  // The PRIVATE key stays with whoever runs `release-flow.js --cut`/`--check`
10
10
  // (CW_RELEASE_VERDICT_PRIVKEY) and NEVER gets committed. The PUBLIC key is
11
11
  // committed to the repo at .cw-release/verdict-signing.pub so CI's
12
- // release-gate.yml, npm-publish.yml, and the local block-unapproved-tag.sh
12
+ // release-gate.yml, npm-publish.yml, and the local block-unapproved-tag.js
13
13
  // hook can all verify a committed verdict without holding any secret.
14
14
  //
15
15
  // Verification is opt-in and backward compatible: as long as
@@ -76,7 +76,7 @@ process.stdout.write(
76
76
  ` git -C "$(git rev-parse --show-toplevel)" commit -m "chore: add release-verdict signing public key"`,
77
77
  ``,
78
78
  `Once .cw-release/verdict-signing.pub is committed, release-gate.yml, npm-publish.yml,`,
79
- `and block-unapproved-tag.sh all start REQUIRING a valid signature on top of the`,
79
+ `and block-unapproved-tag.js all start REQUIRING a valid signature on top of the`,
80
80
  `existing APPROVED text check — an unsigned or hand-written verdict fails closed.`,
81
81
  ``
82
82
  ].join("\n")