cool-workflow 0.2.4 → 0.2.6

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 (155) 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 +204 -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/state/run-paths.js +3 -30
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +22 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +215 -36
  32. package/dist/mcp/tool-process.js +181 -0
  33. package/dist/mcp-server.js +28 -1
  34. package/dist/shell/agent-config.js +11 -2
  35. package/dist/shell/audit-cli.js +1 -1
  36. package/dist/shell/commit.js +8 -2
  37. package/dist/shell/coordinator-io.js +73 -1
  38. package/dist/shell/drive.js +195 -128
  39. package/dist/shell/error-feedback-io.js +6 -0
  40. package/dist/shell/execution-backend/agent.js +201 -26
  41. package/dist/shell/execution-backend/container.js +44 -10
  42. package/dist/shell/execution-backend/local.js +33 -0
  43. package/dist/shell/fs-atomic.js +96 -12
  44. package/dist/shell/ledger-cli.js +9 -2
  45. package/dist/shell/metrics-cli.js +1 -1
  46. package/dist/shell/multi-agent-cli.js +9 -2
  47. package/dist/shell/multi-agent-operator-ux.js +1 -1
  48. package/dist/shell/observability.js +7 -4
  49. package/dist/shell/onramp.js +48 -5
  50. package/dist/shell/operator-ux.js +1 -1
  51. package/dist/shell/perf-trace.js +136 -0
  52. package/dist/shell/pipeline.js +3 -2
  53. package/dist/shell/reclamation-io.js +10 -9
  54. package/dist/shell/report-view-cli.js +2 -0
  55. package/dist/shell/run-export-cli.js +5 -2
  56. package/dist/shell/run-export.js +293 -31
  57. package/dist/shell/run-registry-io.js +8 -0
  58. package/dist/shell/run-store.js +183 -3
  59. package/dist/shell/scheduler-io.js +101 -10
  60. package/dist/shell/state-explosion-cli.js +4 -1
  61. package/dist/shell/telemetry-demo.js +1 -1
  62. package/dist/shell/telemetry-ledger-io.js +36 -24
  63. package/dist/shell/trust-audit.js +178 -36
  64. package/dist/shell/workbench-host.js +121 -10
  65. package/dist/shell/workbench.js +83 -6
  66. package/dist/shell/worker-isolation.js +1 -1
  67. package/dist/wiring/capability-table/basics.js +5 -0
  68. package/dist/wiring/capability-table/exec-backend.js +40 -22
  69. package/dist/wiring/capability-table/parity.js +10 -5
  70. package/dist/wiring/capability-table/pipeline.js +32 -0
  71. package/dist/wiring/capability-table/registry-core.js +26 -3
  72. package/dist/wiring/capability-table/reporting.js +7 -1
  73. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  74. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  75. package/docs/agent-delegation-drive.7.md +17 -0
  76. package/docs/cli-mcp-parity.7.md +46 -5
  77. package/docs/contract-migration-tooling.7.md +4 -0
  78. package/docs/control-plane-scheduling.7.md +38 -0
  79. package/docs/cross-agent-ledger.7.md +20 -8
  80. package/docs/durable-state-and-locking.7.md +4 -0
  81. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  82. package/docs/execution-backends.7.md +4 -0
  83. package/docs/fix.7.md +4 -4
  84. package/docs/getting-started.md +40 -32
  85. package/docs/index.md +17 -0
  86. package/docs/launch/demo.tape +4 -3
  87. package/docs/mcp-app-surface.7.md +27 -0
  88. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  89. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  90. package/docs/multi-agent-operator-ux.7.md +4 -0
  91. package/docs/node-snapshot-diff-replay.7.md +4 -0
  92. package/docs/observability-cost-accounting.7.md +4 -0
  93. package/docs/project-index.md +53 -9
  94. package/docs/real-execution-backends.7.md +4 -0
  95. package/docs/release-and-migration.7.md +4 -0
  96. package/docs/release-tooling.7.md +12 -0
  97. package/docs/routine.7.md +22 -0
  98. package/docs/run-registry-control-plane.7.md +37 -9
  99. package/docs/run-retention-reclamation.7.md +12 -4
  100. package/docs/state-explosion-management.7.md +4 -0
  101. package/docs/team-collaboration.7.md +4 -0
  102. package/docs/trust-audit-anchor.7.md +4 -0
  103. package/docs/web-desktop-workbench.7.md +72 -2
  104. package/docs/workflow-app-framework.7.md +31 -0
  105. package/manifest/plugin.manifest.json +1 -1
  106. package/manifest/source-context-profiles.json +4 -3
  107. package/package.json +6 -3
  108. package/scripts/agents/agent-adapter-core.js +26 -0
  109. package/scripts/agents/claude-p-agent.js +4 -1
  110. package/scripts/agents/codex-agent.js +4 -0
  111. package/scripts/agents/cw-attest-wrap.js +1 -1
  112. package/scripts/agents/gemini-agent.js +4 -0
  113. package/scripts/agents/opencode-agent.js +5 -0
  114. package/scripts/block-unapproved-tag.js +160 -0
  115. package/scripts/canonical-apps.js +4 -4
  116. package/scripts/children/batch-delegate-child.js +72 -4
  117. package/scripts/children/http-batch-delegate-child.js +132 -0
  118. package/scripts/children/http-delegate-child.js +8 -0
  119. package/scripts/dogfood-release.js +2 -2
  120. package/scripts/fake-date-for-reproduction.js +1 -1
  121. package/scripts/gen-manifests.js +32 -61
  122. package/scripts/golden-path.js +4 -4
  123. package/scripts/lang-policy-check.js +82 -0
  124. package/scripts/parity-check.js +42 -23
  125. package/scripts/purity-baseline.json +0 -3
  126. package/scripts/purity-gate.js +16 -3
  127. package/scripts/release-check.js +2 -1
  128. package/scripts/release-flow.js +169 -41
  129. package/scripts/release-gate.js +208 -0
  130. package/scripts/release-oneclick.js +38 -7
  131. package/scripts/release-tags.js +47 -0
  132. package/scripts/schema-version-inventory.json +31 -0
  133. package/scripts/validate-run-state-schema.js +95 -4
  134. package/scripts/verdict-keygen.js +2 -2
  135. package/scripts/verify-bump-reproduction.js +193 -0
  136. package/scripts/verify-release-verdict.js +139 -0
  137. package/scripts/verify-verdict-signature.js +1 -1
  138. package/ui/workbench/app.css +50 -10
  139. package/ui/workbench/app.js +269 -34
  140. package/ui/workbench/index.html +4 -2
  141. package/ui/workbench/inspection.js +51 -0
  142. package/ui/workbench/navigation.js +44 -0
  143. package/docs/agent-framework.md +0 -177
  144. package/docs/designs/handoff-ledger.md +0 -145
  145. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  146. package/docs/launch/launch-kit.md +0 -195
  147. package/docs/launch/pre-launch-checklist.md +0 -53
  148. package/docs/readme-v0.1.87-full.md +0 -301
  149. package/docs/routines.md +0 -101
  150. package/docs/scheduled-tasks.md +0 -80
  151. package/scripts/agents/claude-p-agent.sh +0 -9
  152. package/scripts/block-unapproved-tag.sh +0 -75
  153. package/scripts/release-gate.sh +0 -94
  154. package/scripts/verify-bump-reproduction.sh +0 -148
  155. package/scripts/verify-container-selfref.js +0 -64
@@ -0,0 +1,208 @@
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
+ const MAX_CHILD_DIAGNOSTIC_BYTES = 16 * 1024;
71
+
72
+ function boundedDiagnostic(text) {
73
+ const value = String(text || "").trim();
74
+ if (value.length <= MAX_CHILD_DIAGNOSTIC_BYTES) return value;
75
+ return `…${value.slice(-MAX_CHILD_DIAGNOSTIC_BYTES)}`;
76
+ }
77
+
78
+ function reportNpmFailure(label, result) {
79
+ // Failure facts are local diagnostics, never stdout data and never a saved
80
+ // release record. Keep the tail so a large smoke run cannot flood a terminal.
81
+ const out = boundedDiagnostic(result.stdout);
82
+ const err = boundedDiagnostic(result.stderr);
83
+ process.stderr.write(`release-gate: ${label} failed (exit ${result.status ?? "unknown"})\n`);
84
+ if (err) process.stderr.write(`[stderr]\n${err}\n`);
85
+ if (out) process.stderr.write(`[stdout]\n${out}\n`);
86
+ }
87
+
88
+ function runNpm(args, extraEnv) {
89
+ const r = spawnSync("npm", args, {
90
+ cwd: REPO_ROOT,
91
+ encoding: "utf8",
92
+ env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
93
+ });
94
+ return r;
95
+ }
96
+
97
+ // --- 1. Build & tests (run, don't trust pasted output) -----------------
98
+ say("[1/6] build");
99
+ const build = runNpm(["run", "--prefix", "plugins/cool-workflow", "build"]);
100
+ if (build.error || build.status !== 0) {
101
+ fail("build failed");
102
+ reportNpmFailure("build", build);
103
+ }
104
+
105
+ say("[2/6] tests");
106
+ const tests = runNpm(["run", "test:gate", "--prefix", "plugins/cool-workflow"], { CW_TEST_CONCURRENCY: "1" });
107
+ if (tests.error || tests.status !== 0) {
108
+ fail("tests failed");
109
+ reportNpmFailure("tests", tests);
110
+ }
111
+
112
+ if (PREV_TAG) {
113
+ const RANGE = `${PREV_TAG}..HEAD`;
114
+ const changedFiles = gitOut(["diff", "--name-only", RANGE]).split("\n").filter(Boolean);
115
+
116
+ // --- 2. Substance: changes must exist outside src/types/ and dist/ ---
117
+ // The spec (AGENTS.md / reviewer-agent.md Gate 1) is "at least one changed
118
+ // file outside src/types/ and dist/" — ANY such file (src, scripts, docs,
119
+ // workflows, tests). Count every changed path that is not under those two
120
+ // generated/declaration-only trees; declared-but-unread spec accretion is the
121
+ // reviewer agent's deeper judgment call, not this deterministic floor.
122
+ say("[3/6] substance (diff outside src/types/ and dist/)");
123
+ const SUBSTANCE = changedFiles.filter((f) => !/^plugins\/cool-workflow\/(src\/types\/|dist\/)/.test(f)).length;
124
+ if (!(SUBSTANCE > 0)) fail(`only types/dist changed since ${PREV_TAG} (spec accretion)`);
125
+
126
+ // --- 3. Test evidence: test files must have changed ------------------
127
+ say("[4/6] test evidence");
128
+ const TESTS_CHANGED = changedFiles.filter((f) => /\.(test|spec)\.|\/tests?\//.test(f)).length;
129
+ if (!(TESTS_CHANGED > 0)) fail(`zero test changes since ${PREV_TAG}`);
130
+
131
+ // --- 4. Cadence: >=4 cycles logged OR >=24h since previous tag, or a recorded HOTFIX ---
132
+ say("[5/6] cadence");
133
+ let CYCLES = 0;
134
+ const logDiff = fs.existsSync(path.join(REPO_ROOT, "ITERATION_LOG.md"))
135
+ ? gitOut(["diff", RANGE, "--", "ITERATION_LOG.md"]).split("\n")
136
+ : [];
137
+ CYCLES = logDiff.filter((line) => /^\+.*\|/.test(line)).length;
138
+ const PREV_TS = Number(gitOut(["log", "-1", "--format=%ct", PREV_TAG]));
139
+ const NOW_TS = Math.floor(Date.now() / 1000);
140
+ const HOURS = Math.floor((NOW_TS - PREV_TS) / 3600);
141
+ // Hotfix path: an urgent fix may ship inside the cadence window, but ONLY via an
142
+ // EXPLICIT, RECORDED declaration — a "HOTFIX:" line added to ITERATION_LOG.md in this
143
+ // release range, carrying a reason. It is committed (auditable in the tag's history)
144
+ // and echoed here, so the bypass is never silent and a reviewer sees the reason.
145
+ const hotfixLine = logDiff.find((line) => /^\+.*HOTFIX:/.test(line));
146
+ const HOTFIX = hotfixLine ? hotfixLine.replace(/^\+\s*/, "") : "";
147
+ if (CYCLES < 4 && HOURS < 24) {
148
+ if (HOTFIX) {
149
+ say(` cadence bypassed by recorded HOTFIX (${HOURS}h, ${CYCLES} cycle-lines): ${HOTFIX}`);
150
+ } else {
151
+ 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)`);
152
+ }
153
+ }
154
+ } else {
155
+ say("[3-5/6] no previous tag; substance/evidence/cadence checks skipped");
156
+ }
157
+
158
+ // --- 5. Branch naming: forbid version-number branches -------------------
159
+ say("[6/6] branch naming");
160
+ // On a normal checkout `git rev-parse --abbrev-ref HEAD` is the branch name. On
161
+ // a DETACHED HEAD it prints the literal string "HEAD" — and the tag-push CI
162
+ // (release-gate.yml) ALWAYS checks out the tag, so HEAD is detached there. A
163
+ // literal "HEAD" can never match the version-branch regex below, so this check
164
+ // would silently pass exactly where it is meant to be the backstop. Handle the
165
+ // detached case explicitly: gather the real candidate ref name(s) — the
166
+ // CI-provided source branch (GITHUB_HEAD_REF for a PR, else GITHUB_REF_NAME),
167
+ // plus every local/remote branch whose tip contains this commit — and judge
168
+ // each one. A truly detached checkout with no resolvable branch (a bare
169
+ // `git checkout <sha>`) has no branch to name, so there is nothing to forbid;
170
+ // that is now an explicit, understood pass, not an accidental regex miss.
171
+ const BRANCH = gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "HEAD";
172
+ let candidateBranches = [BRANCH];
173
+ if (BRANCH === "HEAD") {
174
+ candidateBranches = [];
175
+ // The CI source branch: a PR run exposes it as GITHUB_HEAD_REF; a plain push
176
+ // exposes the pushed ref as GITHUB_REF_NAME (a tag on a tag push, which simply
177
+ // will not match the feat/ regex — harmless to include).
178
+ for (const envRef of [process.env.GITHUB_HEAD_REF || "", process.env.GITHUB_REF_NAME || ""]) {
179
+ if (envRef) candidateBranches.push(envRef);
180
+ }
181
+ // Every local branch whose tip contains this commit, plus every remote-tracking
182
+ // branch (with its "<remote>/" prefix stripped so the regex still anchors on
183
+ // "feat/"). --format avoids the "* " current-branch marker git branch prints.
184
+ const localC = gitOut(["branch", "--contains", "HEAD", "--format=%(refname:short)"]);
185
+ const remoteC = gitOut(["branch", "-r", "--contains", "HEAD", "--format=%(refname:short)"])
186
+ .split("\n")
187
+ .map((b) => b.replace(/^[^/]+\//, ""))
188
+ .join("\n");
189
+ for (const chunk of [localC, remoteC]) {
190
+ if (chunk) candidateBranches.push(...chunk.split("\n"));
191
+ }
192
+ }
193
+ for (const B of candidateBranches) {
194
+ if (!B || B === "HEAD") continue;
195
+ if (/^feat\/(batch-)?v?[0-9]+/.test(B)) {
196
+ fail(`branch '${B}' is version-number-driven; name the capability instead`);
197
+ }
198
+ }
199
+
200
+ // --- Verdict ------------------------------------------------------------
201
+ if (FAIL !== 0) {
202
+ fs.rmSync(path.join(MARKER_DIR, `gate-${SHA}.ok`), { force: true });
203
+ say(`RELEASE GATE: REJECTED (${SHA})`);
204
+ process.exit(1);
205
+ }
206
+
207
+ fs.writeFileSync(path.join(MARKER_DIR, `gate-${SHA}.ok`), `${new Date().toISOString().replace(/\.\d{3}Z$/, "Z")}\n`);
208
+ say(`RELEASE GATE: PASSED (${SHA}) — next step: release-reviewer agent must record APPROVED`);
@@ -120,6 +120,28 @@ function alreadyCut() {
120
120
  return remote.code === 0 && Boolean(remote.out);
121
121
  }
122
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
+
123
145
  // ---- stage 0: preflight -----------------------------------------------------
124
146
  function runPreflight() {
125
147
  stage(0, "preflight");
@@ -318,12 +340,12 @@ function runRecordAndWaitStage() {
318
340
  }
319
341
 
320
342
  say(`waiting for release-gate on tag v${version}...`);
321
- const gateRunId = poll({
343
+ const gate = poll({
322
344
  label: "release-gate wait",
323
345
  timeoutMs: 45 * 60 * 1000,
324
346
  intervalMs: 20000,
325
347
  probe: () => {
326
- const list = gh(["run", "list", "--workflow", "release-gate", "--limit", "10", "--json", "databaseId,headBranch,status,conclusion"]);
348
+ const list = gh(["run", "list", "--workflow", "release-gate", "--limit", "10", "--json", "databaseId,headBranch,status,conclusion,updatedAt"]);
327
349
  if (list.code !== 0) return { error: true };
328
350
  let runs;
329
351
  try {
@@ -333,22 +355,29 @@ function runRecordAndWaitStage() {
333
355
  }
334
356
  const gateRun = runs.find((r) => r.headBranch === `v${version}`);
335
357
  if (gateRun && gateRun.status === "completed") {
336
- if (gateRun.conclusion === "success") return { done: true, value: gateRun.databaseId };
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) } };
337
362
  die(`release-gate FAILED — inspect: gh run view ${gateRun.databaseId} --log-failed`);
338
363
  }
339
364
  return {};
340
365
  }
341
366
  });
342
- say(`release-gate: SUCCESS (run ${gateRunId})`);
367
+ say(`release-gate: SUCCESS (run ${gate.id})`);
343
368
 
344
369
  // npm-publish is created only AFTER release-gate completes (workflow_run
345
370
  // trigger), so the run to wait for may not exist yet — and the newest
346
371
  // completed npm-publish run at this moment is usually the PREVIOUS
347
372
  // release's. Only accept a run created at-or-after the moment the gate
348
- // finished (small clock-skew allowance), and keep waiting until that run
349
- // exists and completes.
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.
350
379
  say("waiting for npm-publish...");
351
- const gateDoneMs = Date.now();
380
+ const gateDoneMs = Number.isFinite(gate.completedMs) ? gate.completedMs : Date.now();
352
381
  poll({
353
382
  label: "npm-publish wait",
354
383
  timeoutMs: 30 * 60 * 1000,
@@ -396,7 +425,9 @@ if (alreadyCut()) {
396
425
  say(`tag v${version} already exists — the cut already happened; resuming at stage 3 (CI wait + record + confirm).`);
397
426
  if (DRY_RUN) {
398
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).");
399
429
  } else {
430
+ ensureTagPushedOnResume();
400
431
  runRecordAndWaitStage();
401
432
  }
402
433
  } else {
@@ -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 };
@@ -0,0 +1,31 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "domains": [
4
+ { "domain": "agent-config", "constant": "AGENT_CONFIG_SCHEMA_VERSION", "source": "shell/agent-config.ts" },
5
+ { "domain": "blackboard", "constant": "BLACKBOARD_SCHEMA_VERSION", "source": "core/multi-agent/coordinator.ts" },
6
+ { "domain": "candidate", "constant": "CANDIDATE_SCHEMA_VERSION", "source": "core/multi-agent/candidate-scoring.ts" },
7
+ { "domain": "collaboration", "constant": "COLLABORATION_SCHEMA_VERSION", "source": "core/multi-agent/collaboration.ts" },
8
+ { "domain": "contract-migration", "constant": "CONTRACT_MIGRATION_SCHEMA_VERSION", "source": "core/state/contract-migration.ts" },
9
+ { "domain": "current-run-state", "constant": "CURRENT_RUN_STATE_SCHEMA_VERSION", "source": "core/version.ts" },
10
+ { "domain": "drive", "constant": "DRIVE_SCHEMA_VERSION", "source": "core/pipeline/drive-decide.ts" },
11
+ { "domain": "error-feedback", "constant": "ERROR_FEEDBACK_SCHEMA_VERSION", "source": "core/pipeline/error-feedback.ts" },
12
+ { "domain": "evidence-reasoning", "constant": "EVIDENCE_REASONING_SCHEMA_VERSION", "source": "shell/evidence-reasoning.ts" },
13
+ { "domain": "execution-backend", "constant": "EXECUTION_BACKEND_SCHEMA_VERSION", "source": "shell/execution-backend/registry.ts" },
14
+ { "domain": "legacy-run-state", "constant": "LEGACY_RUN_STATE_SCHEMA_VERSION", "source": "core/version.ts" },
15
+ { "domain": "metrics", "constant": "METRICS_SCHEMA_VERSION", "source": "shell/observability.ts" },
16
+ { "domain": "minimum-run-state", "constant": "MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION", "source": "core/version.ts" },
17
+ { "domain": "multi-agent", "constant": "MULTI_AGENT_SCHEMA_VERSION", "source": "core/multi-agent/runtime.ts" },
18
+ { "domain": "node-snapshot", "constant": "NODE_SNAPSHOT_SCHEMA_VERSION", "source": "core/state/node-snapshot.ts" },
19
+ { "domain": "pipeline-contract", "constant": "PIPELINE_CONTRACT_SCHEMA_VERSION", "source": "core/state/state-node.ts" },
20
+ { "domain": "run-registry", "constant": "RUN_REGISTRY_SCHEMA_VERSION", "source": "shell/run-registry-io.ts" },
21
+ { "domain": "sandbox-profile", "constant": "SANDBOX_PROFILE_SCHEMA_VERSION", "source": "shell/sandbox-profile.ts" },
22
+ { "domain": "scheduling", "constant": "SCHEDULING_SCHEMA_VERSION", "source": "shell/scheduling-io.ts" },
23
+ { "domain": "state-explosion", "constant": "STATE_EXPLOSION_SCHEMA_VERSION", "source": "core/state/state-explosion/size.ts" },
24
+ { "domain": "state-node", "constant": "STATE_NODE_SCHEMA_VERSION", "source": "core/state/state-node.ts" },
25
+ { "domain": "telemetry-ledger", "constant": "TELEMETRY_LEDGER_SCHEMA_VERSION", "source": "core/trust/telemetry-ledger.ts" },
26
+ { "domain": "topology", "constant": "TOPOLOGY_SCHEMA_VERSION", "source": "core/multi-agent/topology.ts" },
27
+ { "domain": "trust-audit", "constant": "TRUST_AUDIT_SCHEMA_VERSION", "source": "shell/trust-audit.ts" },
28
+ { "domain": "worker-isolation", "constant": "WORKER_ISOLATION_SCHEMA_VERSION", "source": "shell/worker-isolation.ts" },
29
+ { "domain": "workflow-app", "constant": "WORKFLOW_APP_SCHEMA_VERSION", "source": "core/version.ts" }
30
+ ]
31
+ }
@@ -19,6 +19,8 @@ const path = require("node:path");
19
19
  const PLUGIN_ROOT = path.resolve(__dirname, "..");
20
20
  const TYPES_FILE = path.join(PLUGIN_ROOT, "src", "core", "state", "types.ts");
21
21
  const SCHEMA_MODULE = path.join(PLUGIN_ROOT, "dist", "core", "state", "schema.js");
22
+ const SOURCE_ROOT = path.join(PLUGIN_ROOT, "src");
23
+ const VERSION_INVENTORY_FILE = path.join(__dirname, "schema-version-inventory.json");
22
24
 
23
25
  function main() {
24
26
  const errors = [];
@@ -43,6 +45,15 @@ function main() {
43
45
  const requiredKeys = new Set(schema.REQUIRED_TOP_LEVEL_KEYS || []);
44
46
  const optionalKeys = new Set(schema.OPTIONAL_TOP_LEVEL_KEYS || []);
45
47
 
48
+ let versionInventory;
49
+ try {
50
+ versionInventory = JSON.parse(fs.readFileSync(VERSION_INVENTORY_FILE, "utf8"));
51
+ } catch (error) {
52
+ console.error(`validate-run-state-schema: FATAL — cannot load ${VERSION_INVENTORY_FILE}.`);
53
+ process.exit(1);
54
+ }
55
+ errors.push(...validateSchemaVersionInventory(versionInventory, readTypeScriptSources(SOURCE_ROOT)));
56
+
46
57
  // ---- 3. Cross-check: every type key must be either required or optional ----
47
58
  for (const key of runKeys) {
48
59
  if (requiredKeys.has(key)) continue;
@@ -70,9 +81,8 @@ function main() {
70
81
  console.error("validate-run-state-schema: FAIL CLOSED — schema drift detected.");
71
82
  for (const e of errors) console.error(` ${e}`);
72
83
  console.error(
73
- `\nFix: update REQUIRED_TOP_LEVEL_KEYS and/or OPTIONAL_TOP_LEVEL_KEYS in ` +
74
- `src/run-state-schema.ts, then update normalizeRunState() and validateMigratedRunState() ` +
75
- `in src/state-migrations.ts to handle any new required fields.`
84
+ `\nFix: update the run-state key lists or schema-version-inventory.json and its canonical source. ` +
85
+ `Keep each schema domain separate.`
76
86
  );
77
87
  process.exit(1);
78
88
  }
@@ -82,6 +92,85 @@ function main() {
82
92
  `validate-run-state-schema: ok — ${runKeys.size} WorkflowRun keys accounted for ` +
83
93
  `(${requiredKeys.size} required + ${optionalKeys.size} optional = ${total})`
84
94
  );
95
+ console.error(
96
+ `validate-run-state-schema: ok — ${versionInventory.domains.length} schema version domains have one definition each`
97
+ );
98
+ }
99
+
100
+ function readTypeScriptSources(root, base = root) {
101
+ const files = [];
102
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
103
+ const absolute = path.join(root, entry.name);
104
+ if (entry.isDirectory()) {
105
+ files.push(...readTypeScriptSources(absolute, base));
106
+ continue;
107
+ }
108
+ if (!entry.isFile() || !entry.name.endsWith(".ts")) continue;
109
+ files.push({
110
+ path: path.relative(base, absolute).split(path.sep).join("/"),
111
+ source: fs.readFileSync(absolute, "utf8"),
112
+ });
113
+ }
114
+ return files;
115
+ }
116
+
117
+ function validateSchemaVersionInventory(inventory, sourceFiles) {
118
+ const errors = [];
119
+ if (!inventory || inventory.schemaVersion !== 1 || !Array.isArray(inventory.domains)) {
120
+ return ["Schema version inventory must have schemaVersion 1 and a domains array."];
121
+ }
122
+
123
+ const entriesByConstant = new Map();
124
+ const domains = new Set();
125
+ for (const entry of inventory.domains) {
126
+ if (!entry || typeof entry.domain !== "string" || typeof entry.constant !== "string" || typeof entry.source !== "string") {
127
+ errors.push("Schema version inventory entries need domain, constant, and source strings.");
128
+ continue;
129
+ }
130
+ if (domains.has(entry.domain)) errors.push(`Schema version domain "${entry.domain}" is listed more than once.`);
131
+ domains.add(entry.domain);
132
+ if (entriesByConstant.has(entry.constant)) {
133
+ errors.push(`Schema version constant "${entry.constant}" is listed more than once.`);
134
+ } else {
135
+ entriesByConstant.set(entry.constant, entry);
136
+ }
137
+ }
138
+
139
+ const definitions = new Map();
140
+ const pattern = /^\s*(?:export\s+)?const\s+([A-Z][A-Z0-9_]*_SCHEMA_VERSION)\s*(?::[^=;]+)?=/gm;
141
+ for (const file of sourceFiles) {
142
+ pattern.lastIndex = 0;
143
+ let match;
144
+ while ((match = pattern.exec(file.source)) !== null) {
145
+ const found = definitions.get(match[1]) || [];
146
+ found.push(file.path);
147
+ definitions.set(match[1], found);
148
+ }
149
+ }
150
+
151
+ for (const [constant, sources] of definitions) {
152
+ const entry = entriesByConstant.get(constant);
153
+ if (!entry) {
154
+ errors.push(`Unknown schema version domain: "${constant}" has a definition in ${sources.join(", ")}.`);
155
+ continue;
156
+ }
157
+ if (sources.length > 1) {
158
+ errors.push(`Schema version constant "${constant}" has more than one definition: ${sources.join(", ")}.`);
159
+ }
160
+ for (const source of sources) {
161
+ if (source !== entry.source) {
162
+ errors.push(`Schema version constant "${constant}" must be defined in ${entry.source}, not ${source}.`);
163
+ }
164
+ }
165
+ }
166
+
167
+ for (const [constant, entry] of entriesByConstant) {
168
+ if (!definitions.has(constant)) {
169
+ errors.push(`Schema version inventory entry "${entry.domain}" has no definition for ${constant}.`);
170
+ }
171
+ }
172
+
173
+ return errors;
85
174
  }
86
175
 
87
176
  /**
@@ -123,4 +212,6 @@ function extractInterfaceKeys(source, interfaceName) {
123
212
  return keys;
124
213
  }
125
214
 
126
- main();
215
+ if (require.main === module) main();
216
+
217
+ module.exports = { extractInterfaceKeys, validateSchemaVersionInventory };
@@ -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")