cool-workflow 0.1.81 → 0.1.83

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 (102) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +4 -4
  3. package/README.md +125 -121
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/candidate-scoring.js +20 -26
  11. package/dist/capability-core.js +80 -93
  12. package/dist/capability-registry.js +30 -3
  13. package/dist/cli.js +12 -1
  14. package/dist/commit.js +217 -204
  15. package/dist/coordinator/util.js +6 -9
  16. package/dist/dispatch.js +11 -3
  17. package/dist/doctor.js +153 -0
  18. package/dist/evidence-reasoning.js +4 -1
  19. package/dist/execution-backend/agent.js +11 -48
  20. package/dist/execution-backend.js +11 -31
  21. package/dist/gates.js +48 -0
  22. package/dist/mcp-server.js +11 -0
  23. package/dist/multi-agent/helpers.js +6 -10
  24. package/dist/multi-agent/ids.js +20 -0
  25. package/dist/multi-agent-eval.js +27 -1
  26. package/dist/multi-agent-host.js +53 -21
  27. package/dist/multi-agent-operator-ux.js +2 -1
  28. package/dist/multi-agent-trust.js +5 -5
  29. package/dist/node-projection.js +59 -0
  30. package/dist/node-snapshot.js +8 -18
  31. package/dist/orchestrator/lifecycle-operations.js +22 -1
  32. package/dist/orchestrator.js +29 -2
  33. package/dist/reclamation/hash.js +72 -0
  34. package/dist/reclamation.js +31 -112
  35. package/dist/run-registry/queue.js +6 -7
  36. package/dist/run-registry.js +35 -24
  37. package/dist/scheduler.js +112 -57
  38. package/dist/topology.js +25 -4
  39. package/dist/trust-audit.js +70 -38
  40. package/dist/validation.js +328 -0
  41. package/dist/version.js +1 -1
  42. package/dist/worker-isolation.js +163 -58
  43. package/docs/agent-delegation-drive.7.md +90 -85
  44. package/docs/agent-framework.md +33 -32
  45. package/docs/candidate-scoring.7.md +26 -24
  46. package/docs/canonical-workflow-apps.7.md +40 -40
  47. package/docs/capability-topology-registry.7.md +24 -24
  48. package/docs/cli-mcp-parity.7.md +227 -154
  49. package/docs/contract-migration-tooling.7.md +49 -41
  50. package/docs/control-plane-scheduling.7.md +45 -40
  51. package/docs/coordinator-blackboard.7.md +30 -30
  52. package/docs/dogfood-one-real-repo.7.md +44 -44
  53. package/docs/durable-state-and-locking.7.md +35 -30
  54. package/docs/end-to-end-golden-path.7.md +29 -29
  55. package/docs/error-feedback.7.md +27 -27
  56. package/docs/evidence-adoption-reasoning-chain.7.md +63 -58
  57. package/docs/execution-backends.7.md +84 -79
  58. package/docs/getting-started.md +35 -18
  59. package/docs/index.md +3 -3
  60. package/docs/mcp-app-surface.7.md +64 -64
  61. package/docs/multi-agent-cli-mcp-surface.7.md +83 -77
  62. package/docs/multi-agent-eval-replay-harness.7.md +59 -54
  63. package/docs/multi-agent-operator-ux.7.md +70 -65
  64. package/docs/multi-agent-runtime-core.7.md +39 -39
  65. package/docs/multi-agent-topologies.7.md +24 -24
  66. package/docs/multi-agent-trust-policy-audit.7.md +38 -38
  67. package/docs/node-snapshot-diff-replay.7.md +26 -21
  68. package/docs/observability-cost-accounting.7.md +50 -45
  69. package/docs/operator-ux.7.md +30 -30
  70. package/docs/pipeline-runner.7.md +31 -31
  71. package/docs/project-index.md +15 -6
  72. package/docs/real-execution-backends.7.md +47 -42
  73. package/docs/release-and-migration.7.md +43 -38
  74. package/docs/release-tooling.7.md +74 -39
  75. package/docs/routines.md +16 -16
  76. package/docs/run-registry-control-plane.7.md +120 -115
  77. package/docs/run-retention-reclamation.7.md +46 -41
  78. package/docs/sandbox-profiles.7.md +32 -32
  79. package/docs/scheduled-tasks.md +14 -14
  80. package/docs/security-trust-hardening.7.md +29 -29
  81. package/docs/source-context-profiles.7.md +28 -28
  82. package/docs/state-explosion-management.7.md +64 -59
  83. package/docs/state-node.7.md +8 -8
  84. package/docs/team-collaboration.7.md +63 -58
  85. package/docs/trust-model.md +126 -126
  86. package/docs/unix-principles.md +80 -80
  87. package/docs/vendor-manifest-loadability.7.md +20 -20
  88. package/docs/verifier-gated-commit.7.md +16 -16
  89. package/docs/web-desktop-workbench.7.md +70 -65
  90. package/docs/worker-isolation.7.md +34 -37
  91. package/docs/workflow-app-framework.7.md +38 -38
  92. package/manifest/plugin.manifest.json +4 -4
  93. package/manifest/source-context-profiles.json +1 -1
  94. package/package.json +3 -2
  95. package/scripts/canonical-apps.js +4 -4
  96. package/scripts/children/batch-delegate-child.js +58 -0
  97. package/scripts/children/http-delegate-child.js +39 -0
  98. package/scripts/dogfood-release.js +1 -1
  99. package/scripts/gen-parity-doc.js +106 -0
  100. package/scripts/golden-path.js +4 -4
  101. package/scripts/release-flow.js +181 -5
  102. package/dist/verifier-registry.js +0 -46
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
- "version": "0.1.81",
3
+ "version": "0.1.83",
4
4
  "bin": {
5
5
  "cool-workflow": "scripts/cw.js",
6
6
  "cw": "scripts/cw.js"
@@ -14,7 +14,7 @@
14
14
  "bugs": {
15
15
  "url": "https://github.com/coo1white/cool-workflow/issues"
16
16
  },
17
- "description": "Auditable workflow control-plane and orchestration runtime: TypeScript dispatch, evidence-gated verification, state commits, scheduling, routines, multi-agent coordination, and MCP. Delegates execution to external agents — never runs models.",
17
+ "description": "A workflow control plane and run-time you are able to check: it sends out jobs in TypeScript, makes certain of work against facts before it goes through, puts state into fixed records, orders jobs by time, runs jobs again and again, gets a group of agents to do their parts together, and talks MCP. It gives the doing of the work to outside agents — it never runs the models itself.",
18
18
  "type": "commonjs",
19
19
  "license": "BSD-2-Clause",
20
20
  "author": {
@@ -51,6 +51,7 @@
51
51
  "forward-ref": "node scripts/forward-ref-docs.js",
52
52
  "verify:container": "node scripts/verify-container-selfref.js",
53
53
  "gen:manifests": "node scripts/gen-manifests.js",
54
+ "gen:parity": "node scripts/gen-parity-doc.js",
54
55
  "manifest:load-check": "node test/vendor-manifest-load-smoke.js",
55
56
  "parity:check": "node scripts/parity-check.js --check",
56
57
  "version:sync": "node scripts/version-sync-check.js",
@@ -83,7 +83,7 @@ const canonicalApps = [
83
83
  "--source",
84
84
  "plugins/cool-workflow/docs/workflow-app-framework.7.md",
85
85
  "--scope",
86
- "Cool Workflow v0.1.81",
86
+ "Cool Workflow v0.1.83",
87
87
  "--freshness",
88
88
  "as of release preparation"
89
89
  ]
@@ -117,14 +117,14 @@ function main() {
117
117
  assert.ok(summary, `${app.id} must appear in app list`);
118
118
  assert.equal(summary.sourceKind, "app-directory");
119
119
  assert.equal(summary.legacy, false);
120
- assert.equal(summary.version, "0.1.81");
120
+ assert.equal(summary.version, "0.1.83");
121
121
 
122
122
  const validation = runJson(["app", "validate", manifestPath]);
123
123
  assert.equal(validation.valid, true, `${app.id} manifest must validate`);
124
124
 
125
125
  const shown = runJson(["app", "show", app.id]);
126
126
  assert.equal(shown.app.id, app.id);
127
- assert.equal(shown.app.version, "0.1.81");
127
+ assert.equal(shown.app.version, "0.1.83");
128
128
  assert.ok(shown.app.metadata.canonical, `${app.id} must be marked canonical`);
129
129
  assert.ok(shown.app.sandboxProfiles.length > 0, `${app.id} must declare sandbox profiles`);
130
130
  assertTaskIdsUnique(shown);
@@ -135,7 +135,7 @@ function main() {
135
135
  const plan = runJson(["plan", app.id, ...app.args(workspace)]);
136
136
  const state = JSON.parse(fs.readFileSync(plan.statePath, "utf8"));
137
137
  assert.equal(state.workflow.app.id, app.id);
138
- assert.equal(state.workflow.app.version, "0.1.81");
138
+ assert.equal(state.workflow.app.version, "0.1.83");
139
139
  assert.equal(state.workflow.app.metadata.canonical, true);
140
140
  assert.ok(state.tasks.some((task) => task.requiresEvidence), `${app.id} plan must include evidence gates`);
141
141
  assert.ok(state.tasks.every((task) => task.sandboxProfileId), `${app.id} plan must include sandbox hints`);
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Batch delegate child (extracted from execution-backend/agent.ts so it is a
5
+ // real, greppable, lint-able file instead of an embedded `node -e` template
6
+ // string — F11). Spawned via `node <this-path>` (shell:false) by
7
+ // runAgentBatchOutcomes.
8
+ //
9
+ // Reads jobs JSON on stdin, spawns ALL concurrently (shell:false, inherited env —
10
+ // the agent's own credentials resolve; CW never reads them), per-job SIGTERM at
11
+ // timeoutMs + SIGKILL at +5s, caps each captured stdout at 32MB, and prints the
12
+ // outcome array when every job has settled. stderr is drained (a full pipe must
13
+ // never wedge a child). A kill yields exitCode null — the no-exit-code refusal.
14
+ //
15
+ // THE RED LINE: this child only `spawn`s the operator-resolved agent binary with
16
+ // shell:false. It imports NO model SDK and reads NO credentials. Behavior MUST
17
+ // stay byte-identical to the previous embedded string.
18
+
19
+ const { spawn } = require("node:child_process");
20
+ let raw = "";
21
+ process.stdin.setEncoding("utf8");
22
+ process.stdin.on("data", (d) => (raw += d));
23
+ process.stdin.on("end", () => {
24
+ const jobs = JSON.parse(raw);
25
+ if (!jobs.length) { process.stdout.write("[]"); return; }
26
+ const out = new Array(jobs.length);
27
+ let pending = jobs.length;
28
+ const CAP = 32 * 1024 * 1024;
29
+ jobs.forEach((job, i) => {
30
+ let stdout = "";
31
+ let settled = false;
32
+ const settle = (o) => {
33
+ if (settled) return;
34
+ settled = true;
35
+ out[i] = o;
36
+ if (--pending === 0) process.stdout.write(JSON.stringify(out));
37
+ };
38
+ let child;
39
+ try {
40
+ child = spawn(job.binary, job.args, { cwd: job.cwd, env: process.env, shell: false });
41
+ } catch (error) {
42
+ settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout: "" });
43
+ return;
44
+ }
45
+ const term = setTimeout(() => { try { child.kill("SIGTERM"); } catch {} }, job.timeoutMs);
46
+ const kill = setTimeout(() => { try { child.kill("SIGKILL"); } catch {} }, job.timeoutMs + 5000);
47
+ child.stdout.on("data", (d) => { if (stdout.length < CAP) stdout += d; });
48
+ child.stderr.on("data", () => {});
49
+ child.on("error", (error) => {
50
+ clearTimeout(term); clearTimeout(kill);
51
+ settle({ spawnError: String((error && error.message) || error), exitCode: null, stdout });
52
+ });
53
+ child.on("close", (code) => {
54
+ clearTimeout(term); clearTimeout(kill);
55
+ settle({ exitCode: typeof code === "number" ? code : null, stdout });
56
+ });
57
+ });
58
+ });
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // HTTP delegate child (extracted from execution-backend.ts so it is a real,
5
+ // greppable, lint-able file instead of an embedded `node -e` template string —
6
+ // F11). Spawned via `node <this-path>` (shell:false) by runHttpDelegation.
7
+ //
8
+ // A self-contained Node child that performs the remote/CI delegation: it reads a
9
+ // JSON job on stdin, POSTs it to the endpoint, optionally polls a returned jobId,
10
+ // and prints `{ exitCode, stdout }` (or `{ error }`) on stdout. Node-only (global
11
+ // fetch, node >=18), so the driver stays portable and synchronous from CW's view.
12
+ //
13
+ // THE RED LINE: this child speaks ONLY plain HTTP to an operator-configured
14
+ // endpoint. It imports NO model SDK, holds NO API key, and constructs NO model
15
+ // API request. Behavior MUST stay byte-identical to the previous embedded string.
16
+
17
+ (async () => {
18
+ const read = () => new Promise((res) => { let b = ""; process.stdin.on("data", (c) => (b += c)); process.stdin.on("end", () => res(b)); });
19
+ try {
20
+ const job = JSON.parse((await read()) || "{}");
21
+ const endpoint = process.env.CW_DELEGATE_ENDPOINT;
22
+ if (!endpoint) throw new Error("no endpoint");
23
+ const post = await fetch(endpoint, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(job) });
24
+ if (!post.ok) throw new Error("runner responded " + post.status);
25
+ let data = await post.json();
26
+ // Poll a returned jobId until the runner reports done.
27
+ let guard = 0;
28
+ while (data && data.jobId && data.done !== true && guard++ < 600) {
29
+ await new Promise((r) => setTimeout(r, 1000));
30
+ const poll = await fetch(endpoint + (endpoint.includes("?") ? "&" : "?") + "jobId=" + encodeURIComponent(data.jobId));
31
+ if (!poll.ok) throw new Error("poll responded " + poll.status);
32
+ data = await poll.json();
33
+ }
34
+ if (typeof data.exitCode !== "number") throw new Error("runner did not report an exitCode");
35
+ process.stdout.write(JSON.stringify({ exitCode: data.exitCode, stdout: String(data.stdout || "") }));
36
+ } catch (e) {
37
+ process.stdout.write(JSON.stringify({ error: e && e.message ? e.message : String(e) }));
38
+ }
39
+ })();
@@ -5,7 +5,7 @@ const { spawnSync } = require("node:child_process");
5
5
  const fs = require("node:fs");
6
6
  const path = require("node:path");
7
7
 
8
- const TARGET_VERSION = "0.1.81";
8
+ const TARGET_VERSION = "0.1.83";
9
9
  const PREVIOUS_VERSION = "0.1.31";
10
10
  const pluginRoot = path.resolve(__dirname, "..");
11
11
  const repoRoot = path.resolve(pluginRoot, "..", "..");
@@ -0,0 +1,106 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // gen-parity-doc.js — generate the CLI<->MCP parity matrix, counts, and the
5
+ // surface-specific (cli-only / projected) enumerations in
6
+ // docs/cli-mcp-parity.7.md DIRECTLY from the capability registry (the single
7
+ // source of truth). The matrix declares itself "machine-complete by design", so
8
+ // it must BE machine-generated — a hand-maintained table drifts (it did: 132
9
+ // rows vs a 190-capability registry). Mechanism, not policy.
10
+ //
11
+ // node scripts/gen-parity-doc.js # rewrite the generated regions
12
+ // node scripts/gen-parity-doc.js --check # fail closed if the doc drifted
13
+ //
14
+ // Idempotent: re-running with no registry change is a no-op. Only the four
15
+ // marker-delimited regions are touched; all surrounding prose (incl. the Basic
16
+ // English narrative and version trailers) is preserved byte-for-byte.
17
+
18
+ const fs = require("node:fs");
19
+ const path = require("node:path");
20
+
21
+ const pluginRoot = path.resolve(__dirname, "..");
22
+ const DOC = path.join(pluginRoot, "docs", "cli-mcp-parity.7.md");
23
+ const CHECK = process.argv.includes("--check");
24
+
25
+ const { CAPABILITY_REGISTRY } = require(path.join(pluginRoot, "dist", "capability-registry.js"));
26
+
27
+ const NUM_WORDS = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve"];
28
+ const numWord = (n) => NUM_WORDS[n] || String(n);
29
+
30
+ function cliCommand(cap) {
31
+ return cap.cli ? `cw ${cap.cli.path.join(" ")}` : "—";
32
+ }
33
+ function cliDisplay(cap) {
34
+ // Human form used in the prose bullets (e.g. "schedule daemon", "demo tamper").
35
+ return cap.cli ? cap.cli.path.join(" ") : cap.capability;
36
+ }
37
+ function mcpTool(cap) {
38
+ return cap.mcp ? cap.mcp.tool : "—";
39
+ }
40
+ function payload(cap) {
41
+ if (cap.surface !== "both") return cap.surface;
42
+ return cap.payloadIdentical === false ? "projected" : "identical";
43
+ }
44
+
45
+ function buildCount() {
46
+ const caps = CAPABILITY_REGISTRY.length;
47
+ const tools = CAPABILITY_REGISTRY.filter((c) => c.mcp && c.mcp.tool).length;
48
+ return `machine-complete by design: ${caps} capabilities, ${tools} MCP tools.`;
49
+ }
50
+
51
+ function buildTable() {
52
+ const lines = [
53
+ "| Capability | CLI command | MCP tool | Core entry | Surface | Payload |",
54
+ "| --- | --- | --- | --- | --- | --- |"
55
+ ];
56
+ for (const cap of CAPABILITY_REGISTRY) {
57
+ lines.push(`| \`${cap.capability}\` | \`${cliCommand(cap)}\` | \`${mcpTool(cap)}\` | \`${cap.entry}\` | ${payload(cap) === "cli-only" || cap.surface !== "both" ? cap.surface : "both"} | ${payload(cap)} |`);
58
+ }
59
+ return lines.join("\n");
60
+ }
61
+
62
+ function buildCliOnly() {
63
+ const caps = CAPABILITY_REGISTRY.filter((c) => c.surface === "cli-only");
64
+ const head = `${numWord(caps.length)} ${caps.length === 1 ? "capability is" : "capabilities are"} CLI-only:`;
65
+ const bullets = caps.map((c) => `- \`${cliDisplay(c)}\` — ${c.reason || "(reason recorded in the registry)"}`);
66
+ return [head, "", ...bullets].join("\n");
67
+ }
68
+
69
+ function buildProjected() {
70
+ const caps = CAPABILITY_REGISTRY.filter((c) => c.surface === "both" && c.payloadIdentical === false);
71
+ const head = `${numWord(caps.length)} ${caps.length === 1 ? "capability is" : "capabilities are"} payload-divergent on purpose (\`projected\`):`;
72
+ const bullets = caps.map((c) => `- \`${c.capability}\` — ${c.reason || "(reason recorded in the registry)"}`);
73
+ return [head, "", ...bullets].join("\n");
74
+ }
75
+
76
+ const REGIONS = {
77
+ count: buildCount(),
78
+ table: buildTable(),
79
+ cliOnly: buildCliOnly(),
80
+ projected: buildProjected()
81
+ };
82
+
83
+ function replaceRegion(text, name, body) {
84
+ const begin = `<!-- gen:parity:${name} -->`;
85
+ const end = `<!-- /gen:parity:${name} -->`;
86
+ const re = new RegExp(`${begin}[\\s\\S]*?${end}`);
87
+ if (!re.test(text)) {
88
+ throw new Error(`marker ${begin} … ${end} not found in cli-mcp-parity.7.md — add the markers once, then re-run.`);
89
+ }
90
+ return text.replace(re, `${begin}\n${body}\n${end}`);
91
+ }
92
+
93
+ const original = fs.readFileSync(DOC, "utf8");
94
+ let next = original;
95
+ for (const [name, body] of Object.entries(REGIONS)) next = replaceRegion(next, name, body);
96
+
97
+ if (CHECK) {
98
+ if (next !== original) {
99
+ process.stderr.write("gen-parity-doc: docs/cli-mcp-parity.7.md is OUT OF SYNC with the capability registry. Run `node scripts/gen-parity-doc.js` and commit.\n");
100
+ process.exit(1);
101
+ }
102
+ process.stdout.write("gen-parity-doc: cli-mcp-parity.7.md in sync with the registry.\n");
103
+ } else {
104
+ if (next !== original) fs.writeFileSync(DOC, next);
105
+ process.stdout.write(`gen-parity-doc: ${next !== original ? "updated" : "unchanged"} — ${CAPABILITY_REGISTRY.length} capabilities, ${CAPABILITY_REGISTRY.filter((c) => c.mcp).length} MCP tools.\n`);
106
+ }
@@ -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.1.81");
36
+ assert.equal(appValidation.summary.version, "0.1.83");
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.1.81 end-to-end golden path."
45
+ "Prove the deterministic v0.1.83 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.1.81");
55
+ assert.equal(state.workflow.app.version, "0.1.83");
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\.1\.81/);
198
+ assert.match(report, /Workflow App: end-to-end-golden-path@0\.1\.83/);
199
199
  assert.match(report, /## Candidates/);
200
200
  assert.match(report, /## Trust Audit/);
201
201
  assert.match(report, /## Acceptance Rationale/);
@@ -15,14 +15,28 @@
15
15
  // Modes:
16
16
  // node release-flow.js [--check] gate + review, no mutation (default)
17
17
  // node release-flow.js --cut --version x.y.z [--push]
18
- // also bump:version, commit verdict, tag, (push)
18
+ // also bump:version, commit verdict, tag,
19
+ // (push), and — when --push — create the
20
+ // GitHub Release for the tag (idempotent;
21
+ // opt out with --no-release)
22
+ // node release-flow.js --release --version x.y.z create-or-skip the GitHub Release for an
23
+ // already-pushed tag (backfill); no gate/cut.
24
+ // Fails closed if gh can't create it; add
25
+ // --soft for best-effort (skip-not-fail).
19
26
  // Flags also accepted: --prev-tag <t>, --agent-command "...", --agent-model m, --dry-run
20
27
  //
21
- // Test seam: CW_RELEASE_FLOW_GATE_CMD overrides the deterministic gate command
22
- // (default: `bash <thisdir>/release-gate.sh`) so the smoke can exercise the
23
- // orchestration layer without re-running the full build/test suite.
28
+ // Test seams (smoke/operator only, never the delegated agent):
29
+ // CW_RELEASE_FLOW_GATE_CMD overrides the deterministic gate command
30
+ // (default: `bash <thisdir>/release-gate.sh`) so the smoke can exercise the
31
+ // orchestration layer without re-running the full build/test suite.
32
+ // CW_RELEASE_FLOW_GH_CMD overrides the `gh` binary (single executable token,
33
+ // spawned shell:false) so the smoke can stub GitHub Release calls offline.
24
34
  //
25
- // Zero dependency: node + git only.
35
+ // Zero dependency for the gated flow: node + git only. The GitHub Release step
36
+ // additionally uses `gh` WHEN PRESENT; it lives ONLY in the human --cut --push /
37
+ // --release paths (never a gate/CI path) and SKIPS — never fails the cut — when gh
38
+ // is absent. A Release is distribution upside layered on the already-pushed tag and
39
+ // the provenance-attested npm publish, not a correctness gate.
26
40
 
27
41
  const fs = require("node:fs");
28
42
  const path = require("node:path");
@@ -41,8 +55,11 @@ const val = (f) => {
41
55
  return i >= 0 ? argv[i + 1] : undefined;
42
56
  };
43
57
  const MODE_CUT = has("--cut");
58
+ const MODE_RELEASE = has("--release");
44
59
  const DRY_RUN = has("--dry-run");
45
60
  const PUSH = has("--push");
61
+ const NO_RELEASE = has("--no-release");
62
+ const SOFT = has("--soft");
46
63
  const cutVersion = val("--version");
47
64
  const prevTagArg = val("--prev-tag");
48
65
 
@@ -223,6 +240,148 @@ function verifyVerdict(resultPath) {
223
240
  return cap;
224
241
  }
225
242
 
243
+ // ---- GitHub Release (optional, presentation/distribution) ------------------
244
+ // NOT a correctness gate: the load-bearing artifacts (the tag + the
245
+ // provenance-attested npm publish) already exist when this runs. So `gh` is NOT
246
+ // part of the node/git portability floor — it runs ONLY in the human --cut --push
247
+ // / --release paths (never a gate/CI path), and an absent/erroring gh SKIPS with a
248
+ // stderr note rather than failing the cut. Test seam: CW_RELEASE_FLOW_GH_CMD swaps
249
+ // the `gh` binary for a stub (single executable token; always spawned shell:false).
250
+ const GH_BIN = (process.env.CW_RELEASE_FLOW_GH_CMD || "gh").trim();
251
+
252
+ function gh(args, opts = {}) {
253
+ // shell:false is AFTER the spread so no caller can override the red line.
254
+ return spawnSync(GH_BIN, args, { cwd: repoRoot, encoding: "utf8", ...opts, shell: false });
255
+ }
256
+
257
+ // gh present AND authenticated. Absent/unauth → false (caller skips, not fails).
258
+ function ghReady() {
259
+ const v = gh(["--version"]);
260
+ if (v.error || v.status !== 0) return false;
261
+ const auth = gh(["auth", "status"]);
262
+ return !auth.error && auth.status === 0;
263
+ }
264
+
265
+ function repoSlug() {
266
+ const url = git(["remote", "get-url", "origin"]).out;
267
+ const m = url.match(/github\.com[:/]+([^/]+?)\/(.+?)(?:\.git)?$/);
268
+ return m ? { owner: m[1], repo: m[2] } : null;
269
+ }
270
+
271
+ // The previous release tag relative to a SPECIFIC tag (not HEAD) — for the
272
+ // compare link in the notes.
273
+ function prevTagOf(version) {
274
+ return git(["describe", "--tags", "--abbrev=0", `v${version}^`]).out || "";
275
+ }
276
+
277
+ // Extract the `## <version>` section body from the CHANGELOG AS SHIPPED AT THE
278
+ // TAG (git show), so the notes reflect what that tag actually carried.
279
+ function changelogSection(version) {
280
+ const show = git(["show", `v${version}:CHANGELOG.md`]);
281
+ if (show.code !== 0 || !show.out) return "";
282
+ const lines = show.out.split(/\r?\n/);
283
+ const esc = version.replace(/\./g, "\\.");
284
+ const startRe = new RegExp(`^## \\[?${esc}\\b`);
285
+ let start = -1;
286
+ for (let i = 0; i < lines.length; i++) if (startRe.test(lines[i])) { start = i; break; }
287
+ if (start < 0) return "";
288
+ let end = lines.length;
289
+ for (let i = start + 1; i < lines.length; i++) if (/^## /.test(lines[i])) { end = i; break; }
290
+ return lines.slice(start + 1, end).join("\n").trim();
291
+ }
292
+
293
+ // Resolve the reviewed content commit + capability from the COMMITTED verdict at
294
+ // the tag, mirroring release-gate.yml's HEAD-or-HEAD~1 tolerance: the verdict is
295
+ // named for the reviewed content commit, which is the tag commit or its parent.
296
+ function verdictForTag(version) {
297
+ const tagCommit = git(["rev-list", "-n1", `v${version}`]).out;
298
+ const parent = git(["rev-parse", `v${version}^`]).out;
299
+ for (const sha of [tagCommit, parent].filter(Boolean)) {
300
+ const rel = `.cw-release/review-${sha}.verdict`;
301
+ const show = git(["show", `v${version}:${rel}`]);
302
+ if (show.code === 0 && /^APPROVED /.test(show.out)) {
303
+ const cap = (show.out.split(/\r?\n/)[1] || "").trim();
304
+ return { contentSha: sha, capability: cap, verdictRel: rel };
305
+ }
306
+ }
307
+ return { contentSha: "", capability: "", verdictRel: "" };
308
+ }
309
+
310
+ // Pure: assemble the release-notes markdown from already-gathered inputs. No I/O,
311
+ // so the smoke can assert the rendered notes (capability headline, CHANGELOG body,
312
+ // provenance footer links) without touching git or the network.
313
+ function buildReleaseNotes({ version, capability, changelog, slug, contentSha, verdictRel, prevTag }) {
314
+ const base = slug ? `https://github.com/${slug.owner}/${slug.repo}` : "";
315
+ const out = [];
316
+ if (capability) out.push(`> ${capability}`, "");
317
+ if (changelog) out.push(changelog, "");
318
+ out.push("---", "", "### Provenance & audit", "");
319
+ if (base && contentSha) out.push(`- **Reviewed commit:** [\`${contentSha.slice(0, 12)}\`](${base}/commit/${contentSha})`);
320
+ if (base && verdictRel) out.push(`- **Independent reviewer verdict (committed):** [\`${verdictRel}\`](${base}/blob/v${version}/${verdictRel})`);
321
+ if (base && prevTag) out.push(`- **Full diff:** [\`${prevTag}...v${version}\`](${base}/compare/${prevTag}...v${version})`);
322
+ out.push(`- **npm (provenance-attested):** [\`cool-workflow@${version}\`](https://www.npmjs.com/package/cool-workflow/v/${version})`);
323
+ // The gated-flow claim is made ONLY when a committed reviewer verdict actually
324
+ // backs it — otherwise the notes would assert a review that is not there
325
+ // (a false-green CW exists to prevent). Backfilled/ungated tags get an honest
326
+ // caveat instead.
327
+ if (verdictRel) {
328
+ out.push("", "_Released through the gated flow: deterministic gate → independent release-reviewer (verdict above) → provenance-attested npm publish._");
329
+ } else {
330
+ out.push("", "_Backfilled Release: no committed reviewer verdict was found at this tag, so these notes make no gated-review claim — integrity rests on the provenance-attested npm publish above._");
331
+ }
332
+ return `${out.join("\n").trim()}\n`;
333
+ }
334
+
335
+ // Create-or-skip the GitHub Release for an already-pushed tag. `required` (the
336
+ // standalone --release mode) → an absent/failed gh is an ERROR; otherwise (the
337
+ // cut finishing step) → skip-with-note and DO NOT fail the cut.
338
+ function releaseGitHub(version, { required = false } = {}) {
339
+ if (!/^\d+\.\d+\.\d+$/.test(version || "")) die("release requires a x.y.z version");
340
+ const tag = `v${version}`;
341
+ if (git(["rev-parse", "-q", "--verify", `refs/tags/${tag}`]).code !== 0) {
342
+ const msg = `tag ${tag} not found locally — push the tag before creating its Release.`;
343
+ if (required) die(msg);
344
+ process.stderr.write(`release-flow: note: ${msg} skipping Release.\n`);
345
+ return false;
346
+ }
347
+ if (!ghReady()) {
348
+ const msg = `gh CLI not available/authenticated — cannot create GitHub Release ${tag}. Create it later: gh release create ${tag} --notes-file <notes> --verify-tag`;
349
+ if (required) die(msg);
350
+ process.stderr.write(`release-flow: note: ${msg}\n`);
351
+ return false;
352
+ }
353
+ if (gh(["release", "view", tag]).status === 0) {
354
+ say(`GitHub Release ${tag} already exists — skipping (idempotent).`);
355
+ return true;
356
+ }
357
+ const { contentSha, capability, verdictRel } = verdictForTag(version);
358
+ if (!verdictRel) {
359
+ process.stderr.write(`release-flow: note: no committed APPROVED verdict found at ${tag} — the Release notes will make NO gated-review claim (backfilling an ungated tag).\n`);
360
+ }
361
+ const notes = buildReleaseNotes({
362
+ version,
363
+ capability,
364
+ changelog: changelogSection(version),
365
+ slug: repoSlug(),
366
+ contentSha,
367
+ verdictRel,
368
+ prevTag: prevTagOf(version)
369
+ });
370
+ const notesPath = path.join(repoRoot, ".cw-release", `release-notes-${version}.md`);
371
+ fs.mkdirSync(path.dirname(notesPath), { recursive: true });
372
+ fs.writeFileSync(notesPath, notes);
373
+ if (DRY_RUN) { say(`[dry-run] would: gh release create ${tag} --notes-file ${notesPath} --verify-tag`); return true; }
374
+ const r = gh(["release", "create", tag, "--title", tag, "--notes-file", notesPath, "--verify-tag"], { stdio: "inherit" });
375
+ if (r.status !== 0) {
376
+ const msg = `gh release create failed for ${tag} — notes saved at ${notesPath}; the tag and npm publish are unaffected.`;
377
+ if (required) die(msg);
378
+ process.stderr.write(`release-flow: note: ${msg}\n`);
379
+ return false;
380
+ }
381
+ say(`created GitHub Release ${tag}.`);
382
+ return true;
383
+ }
384
+
226
385
  // ---- 3. optional cut (bump + commit verdict + tag + push) ------------------
227
386
  function cut(resultPath, capability) {
228
387
  if (!cutVersion || !/^\d+\.\d+\.\d+$/.test(cutVersion)) die("--cut requires --version x.y.z");
@@ -241,11 +400,28 @@ function cut(resultPath, capability) {
241
400
  git(["push", "origin", `v${cutVersion}`]);
242
401
  }
243
402
  say(`tagged v${cutVersion}${PUSH ? " and pushed" : " (local only; push when ready)"}`);
403
+ // Finishing step: create the GitHub Release for the just-pushed tag. Only when
404
+ // pushed (the remote tag must exist) and not opted out; never fails the cut.
405
+ if (PUSH && !NO_RELEASE) releaseGitHub(cutVersion, { required: false });
244
406
  }
245
407
 
246
408
  // ---- main ------------------------------------------------------------------
247
409
  function main() {
248
410
  if (!HEAD) die("could not resolve HEAD");
411
+
412
+ // Standalone backfill: create-or-skip the GitHub Release for an already-pushed
413
+ // tag. No gate, no review, no mutation of tracked files — just GitHub.
414
+ if (MODE_RELEASE) {
415
+ const version = cutVersion;
416
+ if (!version || !/^\d+\.\d+\.\d+$/.test(version)) die("--release requires --version x.y.z");
417
+ // Default: fail closed if gh can't create the Release (the operator asked for
418
+ // it directly). --soft downgrades to best-effort (skip-not-fail), the same
419
+ // semantics the --cut --push finishing step uses.
420
+ const created = releaseGitHub(version, { required: !SOFT });
421
+ process.stdout.write(`${JSON.stringify({ ok: true, mode: "release", version, soft: SOFT, created }, null, 2)}\n`);
422
+ return;
423
+ }
424
+
249
425
  const markerDir = path.join(repoRoot, ".cw-release");
250
426
  fs.mkdirSync(markerDir, { recursive: true });
251
427
  const resultPath = path.join(markerDir, `review-${HEAD}.verdict`);
@@ -1,46 +0,0 @@
1
- "use strict";
2
- // Verifier Registry — open pluggability for commit-gate verifiers.
3
- //
4
- // BSD discipline (mechanism separate from policy):
5
- // - MECHANISM: a Map<string, Verifier> with register + resolve.
6
- // - POLICY: which verifiers exist is declared via registerVerifier() at load time.
7
- // - FAIL CLOSED: unknown verifier id → named refusal.
8
- // - COMPOSABLE: multiple verifiers can be chained; each runs in registration order.
9
- //
10
- // From v0.1.58: the built-in verifier (validateResultEnvelope, validateRunGates,
11
- // hasGroundedEvidence) remains the default. Registered verifiers run BEFORE the
12
- // default — they can block or add evidence but cannot override the default gate.
13
- Object.defineProperty(exports, "__esModule", { value: true });
14
- exports.registerVerifier = registerVerifier;
15
- exports.getVerifier = getVerifier;
16
- exports.listVerifiers = listVerifiers;
17
- exports.runAllVerifiers = runAllVerifiers;
18
- const _verifierRegistry = new Map();
19
- function registerVerifier(verifier) {
20
- _verifierRegistry.set(verifier.id, verifier);
21
- }
22
- function getVerifier(id) {
23
- return _verifierRegistry.get(id);
24
- }
25
- function listVerifiers() {
26
- return [..._verifierRegistry.values()];
27
- }
28
- /** Run all registered verifiers against a run. Returns the aggregated verdict:
29
- * "pass" if all pass, "block" if any block (first block reason wins). */
30
- async function runAllVerifiers(input) {
31
- const reasons = [];
32
- const evidence = [];
33
- for (const verifier of _verifierRegistry.values()) {
34
- const result = await verifier.verify(input);
35
- if (result.evidence)
36
- evidence.push(...result.evidence);
37
- if (result.verdict === "block") {
38
- reasons.push(`[${verifier.id}] ${result.reason || "blocked by verifier"}`);
39
- }
40
- else if (result.verdict === "warn") {
41
- reasons.push(`[${verifier.id}] warn: ${result.reason || "warning"}`);
42
- }
43
- }
44
- const blocked = reasons.some((r) => !r.includes("warn:"));
45
- return { verdict: blocked ? "block" : "pass", reasons, evidence };
46
- }