cool-workflow 0.2.4 → 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 (132) 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 +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/trust/evidence-grounding.js +13 -1
  27. package/dist/core/util/cli-args.js +22 -0
  28. package/dist/core/version.js +1 -1
  29. package/dist/mcp/dispatch.js +22 -2
  30. package/dist/mcp/server.js +124 -13
  31. package/dist/mcp-server.js +20 -0
  32. package/dist/shell/commit.js +8 -2
  33. package/dist/shell/coordinator-io.js +73 -1
  34. package/dist/shell/drive.js +120 -63
  35. package/dist/shell/error-feedback-io.js +6 -0
  36. package/dist/shell/execution-backend/agent.js +195 -23
  37. package/dist/shell/execution-backend/container.js +44 -10
  38. package/dist/shell/execution-backend/local.js +32 -0
  39. package/dist/shell/fs-atomic.js +93 -12
  40. package/dist/shell/ledger-cli.js +9 -2
  41. package/dist/shell/multi-agent-cli.js +5 -1
  42. package/dist/shell/onramp.js +48 -5
  43. package/dist/shell/pipeline.js +2 -1
  44. package/dist/shell/reclamation-io.js +10 -9
  45. package/dist/shell/run-export.js +52 -4
  46. package/dist/shell/run-store.js +156 -0
  47. package/dist/shell/scheduler-io.js +101 -10
  48. package/dist/shell/telemetry-ledger-io.js +36 -24
  49. package/dist/shell/trust-audit.js +104 -10
  50. package/dist/shell/workbench-host.js +121 -10
  51. package/dist/shell/workbench.js +79 -5
  52. package/dist/shell/worker-isolation.js +1 -1
  53. package/dist/wiring/capability-table/basics.js +5 -0
  54. package/dist/wiring/capability-table/exec-backend.js +40 -22
  55. package/dist/wiring/capability-table/pipeline.js +32 -0
  56. package/dist/wiring/capability-table/registry-core.js +26 -3
  57. package/dist/wiring/capability-table/reporting.js +7 -1
  58. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  59. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  60. package/docs/agent-delegation-drive.7.md +15 -0
  61. package/docs/cli-mcp-parity.7.md +25 -0
  62. package/docs/contract-migration-tooling.7.md +2 -0
  63. package/docs/control-plane-scheduling.7.md +36 -0
  64. package/docs/cross-agent-ledger.7.md +20 -8
  65. package/docs/durable-state-and-locking.7.md +2 -0
  66. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  67. package/docs/execution-backends.7.md +2 -0
  68. package/docs/fix.7.md +4 -4
  69. package/docs/getting-started.md +40 -32
  70. package/docs/index.md +17 -0
  71. package/docs/launch/demo.tape +4 -3
  72. package/docs/mcp-app-surface.7.md +6 -0
  73. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  74. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  75. package/docs/multi-agent-operator-ux.7.md +2 -0
  76. package/docs/node-snapshot-diff-replay.7.md +2 -0
  77. package/docs/observability-cost-accounting.7.md +2 -0
  78. package/docs/project-index.md +39 -9
  79. package/docs/real-execution-backends.7.md +2 -0
  80. package/docs/release-and-migration.7.md +2 -0
  81. package/docs/release-tooling.7.md +2 -0
  82. package/docs/routine.7.md +22 -0
  83. package/docs/run-registry-control-plane.7.md +2 -0
  84. package/docs/run-retention-reclamation.7.md +10 -4
  85. package/docs/state-explosion-management.7.md +2 -0
  86. package/docs/team-collaboration.7.md +2 -0
  87. package/docs/trust-audit-anchor.7.md +2 -0
  88. package/docs/web-desktop-workbench.7.md +24 -2
  89. package/docs/workflow-app-framework.7.md +31 -0
  90. package/manifest/plugin.manifest.json +1 -1
  91. package/manifest/source-context-profiles.json +4 -3
  92. package/package.json +4 -2
  93. package/scripts/agents/agent-adapter-core.js +26 -0
  94. package/scripts/agents/claude-p-agent.js +4 -1
  95. package/scripts/agents/codex-agent.js +4 -0
  96. package/scripts/agents/cw-attest-wrap.js +1 -1
  97. package/scripts/agents/gemini-agent.js +4 -0
  98. package/scripts/agents/opencode-agent.js +5 -0
  99. package/scripts/block-unapproved-tag.js +160 -0
  100. package/scripts/canonical-apps.js +4 -4
  101. package/scripts/children/batch-delegate-child.js +72 -4
  102. package/scripts/children/http-batch-delegate-child.js +132 -0
  103. package/scripts/children/http-delegate-child.js +8 -0
  104. package/scripts/dogfood-release.js +2 -2
  105. package/scripts/fake-date-for-reproduction.js +1 -1
  106. package/scripts/golden-path.js +4 -4
  107. package/scripts/lang-policy-check.js +82 -0
  108. package/scripts/purity-gate.js +16 -3
  109. package/scripts/release-check.js +2 -1
  110. package/scripts/release-flow.js +128 -30
  111. package/scripts/release-gate.js +184 -0
  112. package/scripts/release-oneclick.js +38 -7
  113. package/scripts/release-tags.js +47 -0
  114. package/scripts/verdict-keygen.js +2 -2
  115. package/scripts/verify-bump-reproduction.js +193 -0
  116. package/scripts/verify-verdict-signature.js +1 -1
  117. package/ui/workbench/app.css +41 -9
  118. package/ui/workbench/app.js +160 -27
  119. package/ui/workbench/index.html +2 -2
  120. package/docs/agent-framework.md +0 -177
  121. package/docs/designs/handoff-ledger.md +0 -145
  122. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  123. package/docs/launch/launch-kit.md +0 -195
  124. package/docs/launch/pre-launch-checklist.md +0 -53
  125. package/docs/readme-v0.1.87-full.md +0 -301
  126. package/docs/routines.md +0 -101
  127. package/docs/scheduled-tasks.md +0 -80
  128. package/scripts/agents/claude-p-agent.sh +0 -9
  129. package/scripts/block-unapproved-tag.sh +0 -75
  130. package/scripts/release-gate.sh +0 -94
  131. package/scripts/verify-bump-reproduction.sh +0 -148
  132. package/scripts/verify-container-selfref.js +0 -64
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  // fake-date-for-reproduction.js — a Node `--require` preload used ONLY by
4
- // verify-bump-reproduction.sh. It pins `new Date()` (no-args construction)
4
+ // verify-bump-reproduction.js. It pins `new Date()` (no-args construction)
5
5
  // and `Date.now()` to a fixed instant, read from CW_FAKE_DATE (an ISO date or
6
6
  // YYYY-MM-DD string). `new Date(explicit args)` is left untouched.
7
7
  //
@@ -33,7 +33,7 @@ function main() {
33
33
  const appValidation = runJson(["app", "validate", "end-to-end-golden-path"], pluginRoot);
34
34
  assert.equal(appValidation.valid, true);
35
35
  assert.equal(appValidation.summary.id, "end-to-end-golden-path");
36
- assert.equal(appValidation.summary.version, "0.2.4");
36
+ assert.equal(appValidation.summary.version, "0.2.5");
37
37
 
38
38
  const plan = runJson(
39
39
  [
@@ -42,7 +42,7 @@ function main() {
42
42
  "--repo",
43
43
  tmp,
44
44
  "--question",
45
- "Prove the deterministic v0.2.4 end-to-end golden path."
45
+ "Prove the deterministic v0.2.5 end-to-end golden path."
46
46
  ],
47
47
  pluginRoot
48
48
  );
@@ -52,7 +52,7 @@ function main() {
52
52
 
53
53
  let state = readJson(plan.statePath);
54
54
  assert.equal(state.workflow.app.id, "end-to-end-golden-path");
55
- assert.equal(state.workflow.app.version, "0.2.4");
55
+ assert.equal(state.workflow.app.version, "0.2.5");
56
56
  assert.equal(state.loopStage, "interpret");
57
57
 
58
58
  const dispatch = runJson(["dispatch", plan.runId, "--limit", "1", "--sandbox", "readonly"], tmp);
@@ -195,7 +195,7 @@ function main() {
195
195
  assert.equal(reportPath, plan.reportPath);
196
196
  assert.ok(fs.existsSync(reportPath));
197
197
  const report = fs.readFileSync(reportPath, "utf8");
198
- assert.match(report, /Workflow App: end-to-end-golden-path@0\.2\.4/);
198
+ assert.match(report, /Workflow App: end-to-end-golden-path@0\.2\.5/);
199
199
  assert.match(report, /## Candidates/);
200
200
  assert.match(report, /## Trust Audit/);
201
201
  assert.match(report, /## Acceptance Rationale/);
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // lang-policy-check.js — this project is written in JavaScript and
4
+ // TypeScript only (AGENTS.md hard rule, 2026-07-13). Every git-tracked file
5
+ // must be JS/TS, a recognized non-code file (docs, data, config, binary
6
+ // asset), or one of the explicit, path-scoped exceptions below — a real
7
+ // non-JS/TS file the project genuinely needs. Fail closed: an unrecognized
8
+ // extension/basename is a hard stop, forcing a deliberate choice (write it
9
+ // in JS/TS, or add a new exception here with a reason) instead of a silent
10
+ // pass. Mechanism only; the allowlists ARE the policy, kept as plain data
11
+ // in this one file — no separate config to drift from it.
12
+
13
+ const { execFileSync } = require("node:child_process");
14
+ const path = require("node:path");
15
+
16
+ const CODE_EXTENSIONS = new Set(["js", "mjs", "cjs", "ts", "tsx", "jsx"]);
17
+
18
+ // Real, needed files this project cannot write in JS/TS -- the exact same
19
+ // set already carved out of GitHub's Languages stats in .gitattributes, for
20
+ // the same reasons (see that file's comments). Scoped to an EXACT path,
21
+ // never a whole directory, so a new file dropped in the same folder does
22
+ // NOT silently inherit the exception.
23
+ const EXCEPT_PATHS = new Set([
24
+ "Formula/cool-workflow.rb", // Homebrew formula must be Ruby
25
+ "plugins/cool-workflow/ui/workbench/index.html", // served from disk by design
26
+ "plugins/cool-workflow/ui/workbench/app.css" // served from disk by design
27
+ ]);
28
+
29
+ // Not "code" at all -- docs, data, config, and binary assets. Fine
30
+ // anywhere in the tree; this policy is only about what the project is
31
+ // WRITTEN in, not about every file it ships or tracks.
32
+ const NON_CODE_EXTENSIONS = new Set([
33
+ "md", "mdc", "txt", "json", "jsonl", "yml", "yaml", "toml",
34
+ "verdict", "sig", "pub", "tape",
35
+ "svg", "png", "jpg", "jpeg", "gif", "webp", "ico", "pdf",
36
+ "woff", "woff2", "gz", "tgz", "zip", "lock"
37
+ ]);
38
+ // Extensionless / dotfile basenames that are also not code.
39
+ const NON_CODE_BASENAMES = new Set([
40
+ "LICENSE", ".gitignore", ".gitattributes", ".editorconfig", ".windsurfrules", ".npmrc", ".nvmrc"
41
+ ]);
42
+
43
+ // `EXCEPT_PATHS` are written repo-root-relative, so tracked files must be
44
+ // resolved the same way regardless of the invocation cwd (this runs both as
45
+ // `npm run lang:check` from plugins/cool-workflow/ AND from release-check.js
46
+ // at various cwds) -- resolve the real top-level first, never trust the
47
+ // invocation cwd to already BE it.
48
+ function trackedFiles() {
49
+ const repoRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
50
+ return execFileSync("git", ["-C", repoRoot, "ls-files"], { encoding: "utf8" })
51
+ .split("\n")
52
+ .filter(Boolean);
53
+ }
54
+
55
+ function classify(file) {
56
+ if (EXCEPT_PATHS.has(file)) return { ok: true };
57
+ const base = path.basename(file);
58
+ if (NON_CODE_BASENAMES.has(base)) return { ok: true };
59
+ const dot = base.lastIndexOf(".");
60
+ if (dot <= 0) return { ok: false, ext: null };
61
+ const ext = base.slice(dot + 1).toLowerCase();
62
+ if (CODE_EXTENSIONS.has(ext) || NON_CODE_EXTENSIONS.has(ext)) return { ok: true };
63
+ return { ok: false, ext };
64
+ }
65
+
66
+ const files = trackedFiles();
67
+ const violations = [];
68
+ for (const file of files) {
69
+ const result = classify(file);
70
+ if (!result.ok) violations.push({ file, ext: result.ext });
71
+ }
72
+
73
+ if (violations.length > 0) {
74
+ process.stderr.write("lang-policy-check: this project is JavaScript/TypeScript only. Unrecognized file(s):\n");
75
+ for (const v of violations) {
76
+ process.stderr.write(` - ${v.file}${v.ext ? ` (.${v.ext})` : " (no extension)"}\n`);
77
+ }
78
+ process.stderr.write("Write it in JavaScript/TypeScript, or add a scoped, reasoned exception to scripts/lang-policy-check.js.\n");
79
+ process.exit(1);
80
+ }
81
+
82
+ process.stdout.write(`lang-policy-check: ${files.length} tracked files, all JS/TS or an allowed non-code/exception file.\n`);
@@ -112,8 +112,12 @@ function stripComments(text) {
112
112
 
113
113
  // Every `from "spec"` (import or export-from, type or value — a type-only
114
114
  // import still names a module, so it counts the same way a real dependency
115
- // scan would) plus every `require("spec")`. Non-greedy across `from` finds
116
- // each import's own clause without needing a real parser.
115
+ // scan would) plus every `require("spec")` and every dynamic `import("spec")`.
116
+ // Non-greedy across `from` finds each import's own clause without needing a
117
+ // real parser. The dynamic-import form matters because a core/ file could
118
+ // otherwise smuggle in an impure or cross-layer module through
119
+ // `await import("node:fs")` — the one import shape the static and require
120
+ // scans did not see, so the ratchet was blind to it.
117
121
  function extractSpecifiers(codeText) {
118
122
  const specs = [];
119
123
  const fromRe = /(?:^|\n)\s*(?:import|export)\s[\s\S]*?from\s+["']([^"']+)["']/g;
@@ -121,6 +125,8 @@ function extractSpecifiers(codeText) {
121
125
  while ((m = fromRe.exec(codeText))) specs.push(m[1]);
122
126
  const requireRe = /require\(\s*["']([^"']+)["']\s*\)/g;
123
127
  while ((m = requireRe.exec(codeText))) specs.push(m[1]);
128
+ const dynamicImportRe = /import\(\s*["']([^"']+)["']\s*\)/g;
129
+ while ((m = dynamicImportRe.exec(codeText))) specs.push(m[1]);
124
130
  return specs;
125
131
  }
126
132
 
@@ -236,4 +242,11 @@ function main() {
236
242
  process.stdout.write("purity gate: src/ matches the committed baseline (no new core/shell layer breaks, no clock/env drift).\n");
237
243
  }
238
244
 
239
- main();
245
+ // Run the gate only when invoked directly (node scripts/purity-gate.js). When
246
+ // required by a test, expose the internal helpers so the specifier scan can be
247
+ // unit-checked without shelling out or writing into the real src/ tree.
248
+ if (require.main === module) {
249
+ main();
250
+ }
251
+
252
+ module.exports = { stripComments, extractSpecifiers, layerOf, scan };
@@ -60,12 +60,13 @@ const checks = [
60
60
  // from that fresh build — strictly stronger than a bare `npm run build`.
61
61
  { name: "dist freshness", command: ["npm", "run", "dist:check"] },
62
62
  { name: "core/shell purity", command: ["npm", "run", "purity:check"] },
63
+ { name: "language policy (JS/TS only)", command: ["npm", "run", "lang:check"] },
63
64
  { name: "type check", command: ["npm", "run", "check"] },
64
65
  { name: "onramp contract", command: ["npm", "run", "onramp:check"] },
65
66
  { name: "run-state schema consistency", command: ["node", "scripts/validate-run-state-schema.js"] },
66
67
  // Every test path uses --concurrency auto by default (parallel, race-free:
67
68
  // each smoke runs in a private cwd + state roots). The release tag-gate
68
- // (release-gate.sh) forces CW_TEST_CONCURRENCY=1 to stay sequential as the
69
+ // (release-gate.js) forces CW_TEST_CONCURRENCY=1 to stay sequential as the
69
70
  // deterministic backstop.
70
71
  { name: "tests", command: ["npm", "run", "test:ci"] },
71
72
  // Pure core/ unit tests (test/*.test.js) — a separate suite from the smoke
@@ -43,7 +43,7 @@
43
43
  // the verdict file's exact bytes, writing a sidecar
44
44
  // .cw-release/review-<FULLSHA>.verdict.sig (base64 ed25519). Once
45
45
  // .cw-release/verdict-signing.pub is committed to the repo, release-gate.yml,
46
- // npm-publish.yml, and block-unapproved-tag.sh all start REQUIRING a valid
46
+ // npm-publish.yml, and block-unapproved-tag.js all start REQUIRING a valid
47
47
  // signature in addition to the APPROVED text check — closing the gap where
48
48
  // anyone with shell access could hand-write a passing verdict file. Until
49
49
  // that public key is committed, every check stays exactly as before
@@ -51,7 +51,7 @@
51
51
  //
52
52
  // Test seams (smoke/operator only, never the delegated agent):
53
53
  // CW_RELEASE_FLOW_GATE_CMD overrides the deterministic gate command
54
- // (default: `bash <thisdir>/release-gate.sh`) so the smoke can exercise the
54
+ // (default: `node <thisdir>/release-gate.js`) so the smoke can exercise the
55
55
  // orchestration layer without re-running the full build/test suite.
56
56
  // CW_RELEASE_FLOW_GH_CMD overrides the `gh` binary (single executable token,
57
57
  // spawned shell:false) so the smoke can stub GitHub Release calls offline.
@@ -107,15 +107,25 @@ if (top.status !== 0) die("not inside a git work tree");
107
107
  const repoRoot = top.stdout.trim();
108
108
 
109
109
  const HEAD = git(["rev-parse", "HEAD"]).out;
110
- // Previous tag, excluding any tag that already points at HEAD (so this works
111
- // whether run before tagging or re-run on a freshly tagged commit — same fix
112
- // as release-gate.sh).
110
+
111
+ // ---- release-tag lookup (semver-ordered, NOT git-ancestry-walked) ---------
112
+ // The lookup itself lives in release-tags.js, SHARED with release-gate.js so
113
+ // the two can never drift (the gate used to carry a bash port of it). See
114
+ // that file's header for why this is a semver question, not `git describe`.
115
+ const releaseTags = require("./release-tags.js");
116
+ const parseSemverTag = releaseTags.parseSemverTag;
117
+ const compareSemver = releaseTags.compareSemver;
118
+ const gitTagOut = (args) => git(args).out;
119
+ function listReleaseTagsDesc() {
120
+ return releaseTags.listReleaseTagsDesc(gitTagOut);
121
+ }
122
+
123
+ // The most recent ALREADY-RELEASED tag as of right now, excluding any tag
124
+ // that already points at HEAD (so this works whether run before tagging or
125
+ // re-run on a freshly tagged commit).
113
126
  function resolvePrevTag() {
114
127
  if (prevTagArg) return prevTagArg;
115
- const headTags = git(["tag", "--points-at", "HEAD"]).out.split("\n").filter(Boolean);
116
- let prev = git(["describe", "--tags", "--abbrev=0"]).out;
117
- if (prev && headTags.includes(prev)) prev = git(["describe", "--tags", "--abbrev=0", "HEAD^"]).out;
118
- return prev || "";
128
+ return releaseTags.resolvePrevReleaseTag(gitTagOut);
119
129
  }
120
130
  const PREV_TAG = resolvePrevTag();
121
131
 
@@ -129,7 +139,7 @@ function runGate() {
129
139
  // the smoke and operator overrides only, never the delegated agent.
130
140
  r = spawnSync(override, { cwd: repoRoot, encoding: "utf8", shell: true, stdio: "inherit" });
131
141
  } else {
132
- r = spawnSync("bash", [path.join(scriptsDir, "release-gate.sh")], {
142
+ r = spawnSync(process.execPath, [path.join(scriptsDir, "release-gate.js")], {
133
143
  cwd: repoRoot,
134
144
  encoding: "utf8",
135
145
  stdio: "inherit"
@@ -173,17 +183,28 @@ function runVendorPreflight() {
173
183
  }
174
184
 
175
185
  // ---- 2. independent reviewer, delegated to the configured agent -------------
176
- // Default reviewer deadline. The zero-trust reviewer re-runs release-gate.sh,
186
+ // Default reviewer deadline. The zero-trust reviewer re-runs release-gate.js,
177
187
  // whose sequential test suite alone is ~12 min, then reads + reasons over the
178
188
  // diff — so a 10-min default guaranteed a timeout on a real release. 30 min gives
179
189
  // headroom; override with CW_AGENT_TIMEOUT_MS (or --agent-timeout-ms).
180
190
  const REVIEWER_TIMEOUT_MS = 1800000;
181
191
  function reviewerPromptBody() {
182
192
  // Reuse the committed reviewer spec as the prompt; strip YAML frontmatter.
193
+ // Fail closed if the spec is gone or empty: a missing/empty spec used to
194
+ // yield "", which shipped the reviewer an EMPTY review prompt (only the
195
+ // candidate context, no instructions) — a review with no rubric is worse
196
+ // than no review, since it still produces a signable verdict. This project's
197
+ // rule is to refuse the unverifiable, not fabricate a pass.
183
198
  const specPath = path.join(pluginRoot, "agents", "release-reviewer.md");
184
- let body = fs.existsSync(specPath) ? fs.readFileSync(specPath, "utf8") : "";
185
- body = body.replace(/^---[\s\S]*?---\n/, "");
186
- return body.trim();
199
+ const rel = path.relative(repoRoot, specPath);
200
+ if (!fs.existsSync(specPath)) {
201
+ die(`reviewer prompt file is missing at ${rel} — will not run a review with an empty prompt. Restore agents/release-reviewer.md.`);
202
+ }
203
+ const body = fs.readFileSync(specPath, "utf8").replace(/^---[\s\S]*?---\n/, "").trim();
204
+ if (!body) {
205
+ die(`reviewer prompt file at ${rel} is empty after its header is removed — will not run a review with an empty prompt.`);
206
+ }
207
+ return body;
187
208
  }
188
209
 
189
210
  function buildReviewerInput(resultPath) {
@@ -222,11 +243,36 @@ function substitute(arg, map) {
222
243
  return arg.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in map ? String(map[k]) : m));
223
244
  }
224
245
 
246
+ /** True when `line` is a REJECTED verdict line, allowing the markdown
247
+ * decoration a reviewer commonly wraps it in (`**REJECTED**`, `REJECTED:`,
248
+ * `### REJECTED`, `REJECTED.`) — stripped of leading heading/quote/emphasis
249
+ * markers, the line must start with REJECTED not immediately followed by
250
+ * another letter (so "REJECTEDLY speaking..." is still correctly NOT a
251
+ * rejection marker). Must stay at least as permissive as the APPROVED
252
+ * matcher below, or a decorated REJECTED can fall through to it. */
253
+ function isRejectedLine(line) {
254
+ const stripped = line.trim().replace(/^[#>\s]+/, "").replace(/^[*_]+/, "");
255
+ return /^REJECTED(?![A-Za-z])/i.test(stripped);
256
+ }
257
+
225
258
  /** Extract verdict lines from agent stdout. Returns the verdict text (first line
226
259
  * must be APPROVED <sha> or REJECTED) or null if no valid verdict found.
227
260
  * Also logs failures to stderr so the operator can inspect. */
228
261
  function extractVerdictFromStdout(stdout, resultPath) {
229
262
  const lines = stdout.split(/\r?\n/);
263
+ // Check for REJECTED first, over the WHOLE text, and return right away when
264
+ // found — do not also look for APPROVED in that case. This is the safe,
265
+ // fail-closed order (this project's own rule: fail closed, conservatively).
266
+ // A verbose agent wrapper can echo the required-format example back in its
267
+ // own output (e.g. a report that quotes the prompt's instructions), so the
268
+ // exact text "APPROVED <sha>" can show up even when the real verdict is
269
+ // REJECTED. If BOTH markers are present anywhere in the text, that must
270
+ // read as REJECTED, never as an approval that then gets a real signature.
271
+ const rejectedLine = lines.find((line) => isRejectedLine(line));
272
+ if (rejectedLine) {
273
+ process.stderr.write(`reviewer REJECTED via stdout — full output:\n${stdout.trim()}\n`);
274
+ return rejectedLine.trim();
275
+ }
230
276
  const approvedLine = lines.find((line) => /^APPROVED\s+\S+/.test(line.trim()));
231
277
  if (approvedLine) {
232
278
  const idx = lines.indexOf(approvedLine);
@@ -238,16 +284,25 @@ function extractVerdictFromStdout(stdout, resultPath) {
238
284
  say(`reviewer verdict captured from stdout → ${resultPath}`);
239
285
  return verdict;
240
286
  }
241
- const rejectedLine = lines.find((line) => /^REJECTED/i.test(line.trim()));
242
- if (rejectedLine) {
243
- process.stderr.write(`reviewer REJECTED via stdout — full output:\n${stdout.trim()}\n`);
244
- return rejectedLine.trim();
245
- }
246
287
  // Partial: print the last 20 lines to help the operator diagnose.
247
288
  process.stderr.write(`reviewer stdout had no APPROVED/REJECTED line. Last 20 lines:\n${lines.slice(-20).join("\n").trim()}\n`);
248
289
  return null;
249
290
  }
250
291
 
292
+ // The reviewer must never be able to read the verdict signing key or other
293
+ // release secrets. The signing key is the ONLY thing that tells a real
294
+ // reviewer's verdict apart from one an agent fabricated by hand — if the
295
+ // reviewer process itself can read that key, it could sign its own made-up
296
+ // approval, which defeats the whole point of signing. So strip these from
297
+ // the env we hand to the reviewer child process, even though the operator's
298
+ // own shell (this script's own process.env) may have them set.
299
+ const REVIEWER_ENV_DENY = ["CW_RELEASE_VERDICT_PRIVKEY", "CW_AGENT_ATTEST_PRIVKEY", "CW_WORKBENCH_TOKEN"];
300
+ function buildReviewerEnv() {
301
+ const env = { ...process.env, CW_RELEASE_REVIEW: "1" };
302
+ for (const key of REVIEWER_ENV_DENY) delete env[key];
303
+ return env;
304
+ }
305
+
251
306
  function delegateReview(resultPath, inputPath) {
252
307
  // Reuse the canonical agent-config resolver (flags > env > file).
253
308
  let resolveAgentConfig;
@@ -300,6 +355,13 @@ function delegateReview(resultPath, inputPath) {
300
355
  // Capture stdout so agents that print verdicts (rather than writing the
301
356
  // result file) are supported. Agents that DO write the file still work:
302
357
  // the file takes precedence. stderr goes to the terminal for live output.
358
+ // A verdict file left over from an earlier run — or planted by anyone with
359
+ // write access to this checkout — must never survive into a fresh review
360
+ // round. Delete it (and any .sig sidecar) BEFORE the reviewer runs, so the
361
+ // "verdict file already exists" check right after spawnSync only ever sees
362
+ // a file the reviewer wrote THIS round, never a stale or planted one.
363
+ try { fs.unlinkSync(resultPath); } catch {}
364
+ try { fs.unlinkSync(`${resultPath}.sig`); } catch {}
303
365
  const r = spawnSync(bin, args, {
304
366
  cwd: repoRoot,
305
367
  // CW_RELEASE_REVIEW=1 is a vendor-agnostic signal that THIS spawn is a
@@ -308,7 +370,9 @@ function delegateReview(resultPath, inputPath) {
308
370
  // exec-capable sandbox — a read-only/low-effort reviewer can't execute the
309
371
  // gate it judges and degrades to fabricated verdicts. Preflight liveness
310
372
  // probes never set it, so they stay fast and read-only.
311
- env: { ...process.env, CW_RELEASE_REVIEW: "1" },
373
+ // buildReviewerEnv() also strips the verdict signing key and other release
374
+ // secrets from what the reviewer child process can see — see its comment.
375
+ env: buildReviewerEnv(),
312
376
  encoding: "utf8",
313
377
  timeout: cfg.timeoutMs || REVIEWER_TIMEOUT_MS,
314
378
  shell: false,
@@ -369,7 +433,7 @@ function verifyVerdict(resultPath) {
369
433
  const lines = text.split(/\r?\n/);
370
434
  const firstLine = lines[0] || "";
371
435
  if (firstLine !== `APPROVED ${HEAD}`) {
372
- if (firstLine.toUpperCase() !== "REJECTED") {
436
+ if (!isRejectedLine(firstLine)) {
373
437
  const normalized = extractVerdictFromStdout(text, resultPath);
374
438
  if (normalized && normalized.split(/\r?\n/)[0] === `APPROVED ${HEAD}`) {
375
439
  fs.writeFileSync(resultPath, `${normalized}\n`);
@@ -388,7 +452,11 @@ function resolveVerdictPrivateKey(value) {
388
452
  const trimmed = String(value).trim();
389
453
  if (trimmed.includes("BEGIN") && trimmed.includes("KEY")) return trimmed;
390
454
  if (fs.existsSync(trimmed)) return fs.readFileSync(trimmed, "utf8");
391
- die(`CW_RELEASE_VERDICT_PRIVKEY does not point to a readable key file or inline PEM: ${trimmed}`);
455
+ // NEVER echo the value: if the operator set the var to inline key material
456
+ // that failed the BEGIN/KEY check (e.g. a raw base64 body), interpolating
457
+ // `trimmed` here would print the secret to stderr — which the oneclick flow
458
+ // tees into the operator's release log. Describe it instead.
459
+ die(`CW_RELEASE_VERDICT_PRIVKEY (length ${trimmed.length}) is neither a readable key file path nor an inline PEM (must contain BEGIN and KEY).`);
392
460
  }
393
461
 
394
462
  /** Called only after verifyVerdict() has confirmed resultPath's bytes are a
@@ -451,10 +519,14 @@ function repoSlug() {
451
519
  return m ? { owner: m[1], repo: m[2] } : null;
452
520
  }
453
521
 
454
- // The previous release tag relative to a SPECIFIC tag (not HEAD) — for the
455
- // compare link in the notes.
522
+ // The previous release tag relative to a SPECIFIC target version (not HEAD's
523
+ // ancestry) — for the compare link in the notes. See listReleaseTagsDesc's
524
+ // header comment above for why this is a semver lookup, not `git describe`.
456
525
  function prevTagOf(version) {
457
- return git(["describe", "--tags", "--abbrev=0", `v${version}^`]).out || "";
526
+ const target = parseSemverTag(`v${version}`);
527
+ if (!target) return "";
528
+ const found = listReleaseTagsDesc().find((t) => compareSemver(t.semver, target) < 0);
529
+ return found ? found.tag : "";
458
530
  }
459
531
 
460
532
  // Full regex escape (same shape as parity-check.js's helper — scripts stay
@@ -647,7 +719,14 @@ function preflightCut() {
647
719
  if (PUSH && git(["remote"]).out.split("\n").includes("origin")) {
648
720
  // remote tag must not exist (someone/some run already published this version)
649
721
  const remoteTag = git(["ls-remote", "origin", `refs/tags/v${cutVersion}`]);
650
- if (remoteTag.code === 0 && remoteTag.out) {
722
+ // Fail CLOSED on a failed ls-remote: a non-zero exit (network blip, auth
723
+ // error) is NOT evidence the tag is absent. Treating it as "no remote tag"
724
+ // (the old `code === 0 && out` shape) let a transient failure skip the
725
+ // already-published guard and cut a version that may already exist.
726
+ if (remoteTag.code !== 0) {
727
+ die(`could not check origin for tag v${cutVersion} (git ls-remote failed) — refusing to cut blind`, remoteTag.err);
728
+ }
729
+ if (remoteTag.out) {
651
730
  die(`tag v${cutVersion} already exists on origin — this version is already published; pick the next version`);
652
731
  }
653
732
  // HEAD must be the current origin/main tip — cutting a stale HEAD publishes
@@ -669,6 +748,17 @@ function preflightCut() {
669
748
  // ---- 3. optional cut (bump + commit verdict + tag + push) ------------------
670
749
  function cut(resultPath, capability) {
671
750
  if (!cutVersion || !/^\d+\.\d+\.\d+$/.test(cutVersion)) die("--cut requires --version x.y.z");
751
+ // HEAD is captured once at process start (const HEAD) and everything the cut
752
+ // signs/tags is bound to it. The gate + vendor preflight + the ~30-minute
753
+ // reviewer all run in the shared checkout in between, and this repo runs
754
+ // concurrent agent sessions — so re-verify HEAD has not moved before we
755
+ // commit the verdict onto (and tag) it. Tagging an interloper commit would
756
+ // publish an immutable vX.Y.Z that CI then rejects (verdict filename != HEAD),
757
+ // burning the version number. Fail closed and let the operator re-run.
758
+ const headNow = git(["rev-parse", "HEAD"]).out;
759
+ if (headNow !== HEAD) {
760
+ die(`HEAD moved during the release flow (expected ${HEAD.slice(0, 12)}, now ${headNow.slice(0, 12)}) — re-run to restart the flow from the current HEAD`);
761
+ }
672
762
  if (DRY_RUN) { say(`[dry-run] would: bump:version ${cutVersion}, commit verdict, tag v${cutVersion}${PUSH ? `, push refs/tags/v${cutVersion} (tag only, no branch)` : ""}`); return; }
673
763
  const bump = spawnSync("npm", ["run", "bump:version", "--", cutVersion], { cwd: pluginRoot, encoding: "utf8", stdio: "inherit" });
674
764
  if (bump.status !== 0) die("bump:version failed");
@@ -683,11 +773,19 @@ function cut(resultPath, capability) {
683
773
  // (that tripped pii-redaction-smoke and red-failed release-gate for v0.1.96).
684
774
  // `git add -u` touches tracked files only, so no untracked file can be swept in;
685
775
  // the verdict (and its .sig sidecar, when verdict signing is configured) are
686
- // the only new paths the cut is allowed to add.
687
- git(["add", "-u"]);
688
- git(["add", "--", path.relative(repoRoot, resultPath)]);
776
+ // the only new paths the cut is allowed to add. Check every add's exit code:
777
+ // a swallowed git-add failure (a read-only object store, a locked index) used
778
+ // to surface only later as a confusing "verdict commit failed" a cut that
779
+ // fails to stage the SIGNED verdict must die right here, before the commit.
780
+ const addTracked = git(["add", "-u"]);
781
+ if (addTracked.code !== 0) die("git add -u failed while staging the cut", addTracked.err);
782
+ const addVerdict = git(["add", "--", path.relative(repoRoot, resultPath)]);
783
+ if (addVerdict.code !== 0) die("git add of the verdict file failed", addVerdict.err);
689
784
  const sigPath = `${resultPath}.sig`;
690
- if (fs.existsSync(sigPath)) git(["add", "--", path.relative(repoRoot, sigPath)]);
785
+ if (fs.existsSync(sigPath)) {
786
+ const addSig = git(["add", "--", path.relative(repoRoot, sigPath)]);
787
+ if (addSig.code !== 0) die("git add of the verdict signature failed", addSig.err);
788
+ }
691
789
  const commit = git(["commit", "-m", `chore(release): record APPROVED reviewer verdict for v${cutVersion}`]);
692
790
  if (commit.code !== 0) die("verdict commit failed", commit.err);
693
791
  const tag = git(["tag", "-a", `v${cutVersion}`, "-m", `v${cutVersion}: ${capability || "release"}`]);
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ // release-gate.js — deterministic release checks for cool-workflow.
4
+ // Pass = writes .cw-release/gate-<HEAD-sha>.ok
5
+ // This script encodes everything that does NOT need LLM judgment.
6
+ // (Node port of the former release-gate.sh; behavior kept line for line —
7
+ // same six steps, same messages, same exit map: 0 = PASSED, 1 = REJECTED.)
8
+
9
+ const fs = require("node:fs");
10
+ const path = require("node:path");
11
+ const { spawnSync } = require("node:child_process");
12
+ const { resolvePrevReleaseTag } = require("./release-tags.js");
13
+
14
+ const top = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" });
15
+ if (top.status !== 0) {
16
+ process.stderr.write("release-gate: not inside a git work tree\n");
17
+ process.exit(1);
18
+ }
19
+ const REPO_ROOT = top.stdout.trim();
20
+
21
+ function gitOut(args) {
22
+ const r = spawnSync("git", args, { cwd: REPO_ROOT, encoding: "utf8" });
23
+ return r.status === 0 ? (r.stdout || "").trim() : "";
24
+ }
25
+
26
+ const SHA = gitOut(["rev-parse", "HEAD"]);
27
+ let FAIL = 0;
28
+ function say(msg) {
29
+ process.stdout.write(`${msg}\n`);
30
+ }
31
+ function fail(msg) {
32
+ say(`GATE FAIL: ${msg}`);
33
+ FAIL = 1;
34
+ }
35
+
36
+ // Resolve the PREVIOUS release tag by SEMVER ORDER, not ancestry — shared
37
+ // with release-flow.js via release-tags.js (see that file's header for the
38
+ // full story: `git describe --tags` walks ancestry and silently SKIPS the
39
+ // true previous release tag under the tag-only-push design, making the
40
+ // substance/test-evidence/cadence checks below compare against the wrong
41
+ // baseline and pass permanently; ITERATION_LOG cycles 35/36).
42
+ const PREV_TAG = resolvePrevReleaseTag(gitOut);
43
+
44
+ // An empty PREV_TAG is ambiguous: (a) the genuine first release, so skipping
45
+ // substance/evidence/cadence is right, or (b) tags DO exist but this clone can
46
+ // not see them — a shallow clone hides them in history, and a `git clone
47
+ // --no-tags` / `fetch-tags: false` clone of a long-tagged repo shows 0 local
48
+ // tags too. (a) and (b) look IDENTICAL from local state (both give an empty
49
+ // describe and 0 tags), so we can NOT auto-tell them apart. Fail CLOSED: skip
50
+ // the checks ONLY when the operator positively declares a first release with
51
+ // CW_FIRST_RELEASE=1 (explicit + logged, the same shape the cadence HOTFIX
52
+ // override uses). Otherwise REJECT — a mis-fetched clone must never look like a
53
+ // first release and silently pass. Found by a 2026-07-12 security check; the
54
+ // shallow signal alone closed only half the hole (the `--no-tags` full clone
55
+ // still slipped through).
56
+ if (!PREV_TAG) {
57
+ const shallow = spawnSync("git", ["rev-parse", "--is-shallow-repository"], { cwd: REPO_ROOT, encoding: "utf8" });
58
+ const IS_SHALLOW = shallow.status === 0 ? shallow.stdout.trim() : "false";
59
+ if (IS_SHALLOW === "true") {
60
+ fail("cannot resolve the previous release tag: this is a shallow git clone (git rev-parse --is-shallow-repository = true), so an older tag may be hidden and substance/test-evidence/cadence cannot be trusted. Fetch full history (actions/checkout with fetch-depth: 0) before running this gate.");
61
+ } else if (process.env.CW_FIRST_RELEASE === "1") {
62
+ say("no previous tag; genuine first release declared (CW_FIRST_RELEASE=1) — substance/evidence/cadence will be skipped");
63
+ } else {
64
+ fail("cannot resolve the previous release tag on a full (non-shallow) clone. Either tags were not fetched (git clone --no-tags, or actions/checkout fetch-tags: false — fetch tags and re-run), or this is the genuine first release (set CW_FIRST_RELEASE=1 to declare that explicitly). Refusing to silently skip substance/test-evidence/cadence.");
65
+ }
66
+ }
67
+ const MARKER_DIR = path.join(REPO_ROOT, ".cw-release");
68
+ fs.mkdirSync(MARKER_DIR, { recursive: true });
69
+
70
+ function runNpm(args, extraEnv) {
71
+ // stdio ignored on purpose (the old script sent both streams to /dev/null):
72
+ // the gate's verdict is the exit code; a red step is re-run by hand for detail.
73
+ const r = spawnSync("npm", args, {
74
+ cwd: REPO_ROOT,
75
+ stdio: "ignore",
76
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
77
+ });
78
+ return !r.error && r.status === 0;
79
+ }
80
+
81
+ // --- 1. Build & tests (run, don't trust pasted output) -----------------
82
+ say("[1/6] build");
83
+ if (!runNpm(["run", "--prefix", "plugins/cool-workflow", "build"])) fail("build failed");
84
+
85
+ say("[2/6] tests");
86
+ if (!runNpm(["run", "test:gate", "--prefix", "plugins/cool-workflow"], { CW_TEST_CONCURRENCY: "1" })) fail("tests failed");
87
+
88
+ if (PREV_TAG) {
89
+ const RANGE = `${PREV_TAG}..HEAD`;
90
+ const changedFiles = gitOut(["diff", "--name-only", RANGE]).split("\n").filter(Boolean);
91
+
92
+ // --- 2. Substance: changes must exist outside src/types/ and dist/ ---
93
+ // The spec (AGENTS.md / reviewer-agent.md Gate 1) is "at least one changed
94
+ // file outside src/types/ and dist/" — ANY such file (src, scripts, docs,
95
+ // workflows, tests). Count every changed path that is not under those two
96
+ // generated/declaration-only trees; declared-but-unread spec accretion is the
97
+ // reviewer agent's deeper judgment call, not this deterministic floor.
98
+ say("[3/6] substance (diff outside src/types/ and dist/)");
99
+ const SUBSTANCE = changedFiles.filter((f) => !/^plugins\/cool-workflow\/(src\/types\/|dist\/)/.test(f)).length;
100
+ if (!(SUBSTANCE > 0)) fail(`only types/dist changed since ${PREV_TAG} (spec accretion)`);
101
+
102
+ // --- 3. Test evidence: test files must have changed ------------------
103
+ say("[4/6] test evidence");
104
+ const TESTS_CHANGED = changedFiles.filter((f) => /\.(test|spec)\.|\/tests?\//.test(f)).length;
105
+ if (!(TESTS_CHANGED > 0)) fail(`zero test changes since ${PREV_TAG}`);
106
+
107
+ // --- 4. Cadence: >=4 cycles logged OR >=24h since previous tag, or a recorded HOTFIX ---
108
+ say("[5/6] cadence");
109
+ let CYCLES = 0;
110
+ const logDiff = fs.existsSync(path.join(REPO_ROOT, "ITERATION_LOG.md"))
111
+ ? gitOut(["diff", RANGE, "--", "ITERATION_LOG.md"]).split("\n")
112
+ : [];
113
+ CYCLES = logDiff.filter((line) => /^\+.*\|/.test(line)).length;
114
+ const PREV_TS = Number(gitOut(["log", "-1", "--format=%ct", PREV_TAG]));
115
+ const NOW_TS = Math.floor(Date.now() / 1000);
116
+ const HOURS = Math.floor((NOW_TS - PREV_TS) / 3600);
117
+ // Hotfix path: an urgent fix may ship inside the cadence window, but ONLY via an
118
+ // EXPLICIT, RECORDED declaration — a "HOTFIX:" line added to ITERATION_LOG.md in this
119
+ // release range, carrying a reason. It is committed (auditable in the tag's history)
120
+ // and echoed here, so the bypass is never silent and a reviewer sees the reason.
121
+ const hotfixLine = logDiff.find((line) => /^\+.*HOTFIX:/.test(line));
122
+ const HOTFIX = hotfixLine ? hotfixLine.replace(/^\+\s*/, "") : "";
123
+ if (CYCLES < 4 && HOURS < 24) {
124
+ if (HOTFIX) {
125
+ say(` cadence bypassed by recorded HOTFIX (${HOURS}h, ${CYCLES} cycle-lines): ${HOTFIX}`);
126
+ } else {
127
+ fail(`cadence: only ${CYCLES} cycles logged and ${HOURS}h since ${PREV_TAG} (need >=4 cycles, >=24h, or a recorded 'HOTFIX:' line in ITERATION_LOG.md)`);
128
+ }
129
+ }
130
+ } else {
131
+ say("[3-5/6] no previous tag; substance/evidence/cadence checks skipped");
132
+ }
133
+
134
+ // --- 5. Branch naming: forbid version-number branches -------------------
135
+ say("[6/6] branch naming");
136
+ // On a normal checkout `git rev-parse --abbrev-ref HEAD` is the branch name. On
137
+ // a DETACHED HEAD it prints the literal string "HEAD" — and the tag-push CI
138
+ // (release-gate.yml) ALWAYS checks out the tag, so HEAD is detached there. A
139
+ // literal "HEAD" can never match the version-branch regex below, so this check
140
+ // would silently pass exactly where it is meant to be the backstop. Handle the
141
+ // detached case explicitly: gather the real candidate ref name(s) — the
142
+ // CI-provided source branch (GITHUB_HEAD_REF for a PR, else GITHUB_REF_NAME),
143
+ // plus every local/remote branch whose tip contains this commit — and judge
144
+ // each one. A truly detached checkout with no resolvable branch (a bare
145
+ // `git checkout <sha>`) has no branch to name, so there is nothing to forbid;
146
+ // that is now an explicit, understood pass, not an accidental regex miss.
147
+ const BRANCH = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "HEAD";
148
+ let candidateBranches = [BRANCH];
149
+ if (BRANCH === "HEAD") {
150
+ candidateBranches = [];
151
+ // The CI source branch: a PR run exposes it as GITHUB_HEAD_REF; a plain push
152
+ // exposes the pushed ref as GITHUB_REF_NAME (a tag on a tag push, which simply
153
+ // will not match the feat/ regex — harmless to include).
154
+ for (const envRef of [process.env.GITHUB_HEAD_REF || "", process.env.GITHUB_REF_NAME || ""]) {
155
+ if (envRef) candidateBranches.push(envRef);
156
+ }
157
+ // Every local branch whose tip contains this commit, plus every remote-tracking
158
+ // branch (with its "<remote>/" prefix stripped so the regex still anchors on
159
+ // "feat/"). --format avoids the "* " current-branch marker git branch prints.
160
+ const localC = gitOut(["branch", "--contains", "HEAD", "--format=%(refname:short)"]);
161
+ const remoteC = gitOut(["branch", "-r", "--contains", "HEAD", "--format=%(refname:short)"])
162
+ .split("\n")
163
+ .map((b) => b.replace(/^[^/]+\//, ""))
164
+ .join("\n");
165
+ for (const chunk of [localC, remoteC]) {
166
+ if (chunk) candidateBranches.push(...chunk.split("\n"));
167
+ }
168
+ }
169
+ for (const B of candidateBranches) {
170
+ if (!B || B === "HEAD") continue;
171
+ if (/^feat\/(batch-)?v?[0-9]+/.test(B)) {
172
+ fail(`branch '${B}' is version-number-driven; name the capability instead`);
173
+ }
174
+ }
175
+
176
+ // --- Verdict ------------------------------------------------------------
177
+ if (FAIL !== 0) {
178
+ fs.rmSync(path.join(MARKER_DIR, `gate-${SHA}.ok`), { force: true });
179
+ say(`RELEASE GATE: REJECTED (${SHA})`);
180
+ process.exit(1);
181
+ }
182
+
183
+ fs.writeFileSync(path.join(MARKER_DIR, `gate-${SHA}.ok`), `${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}\n`);
184
+ say(`RELEASE GATE: PASSED (${SHA}) — next step: release-reviewer agent must record APPROVED`);