cool-workflow 0.1.80 → 0.1.82
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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +42 -2
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/agent-config.js +21 -7
- package/dist/candidate-scoring.js +62 -48
- package/dist/capability-core.js +152 -96
- package/dist/capability-registry.js +160 -174
- package/dist/cli.js +90 -100
- package/dist/collaboration.js +5 -6
- package/dist/commit.js +232 -209
- package/dist/compare.js +18 -0
- package/dist/coordinator/classify.js +45 -0
- package/dist/coordinator/paths.js +42 -0
- package/dist/coordinator/util.js +126 -0
- package/dist/coordinator.js +127 -300
- package/dist/dispatch.js +46 -3
- package/dist/drive.js +7 -7
- package/dist/error-feedback.js +8 -4
- package/dist/evidence-reasoning.js +5 -2
- package/dist/execution-backend/agent.js +294 -0
- package/dist/execution-backend/probes.js +96 -0
- package/dist/execution-backend/util.js +47 -0
- package/dist/execution-backend.js +77 -450
- package/dist/gates.js +48 -0
- package/dist/mcp-server.js +34 -173
- package/dist/multi-agent/graph.js +84 -0
- package/dist/multi-agent/helpers.js +141 -0
- package/dist/multi-agent/ids.js +20 -0
- package/dist/multi-agent/paths.js +22 -0
- package/dist/multi-agent-eval/format.js +194 -0
- package/dist/multi-agent-eval/normalize.js +51 -0
- package/dist/multi-agent-eval.js +63 -242
- package/dist/multi-agent-host.js +53 -40
- package/dist/multi-agent-operator-ux.js +2 -1
- package/dist/multi-agent-trust.js +5 -5
- package/dist/multi-agent.js +125 -314
- package/dist/node-projection.js +59 -0
- package/dist/node-snapshot.js +11 -21
- package/dist/observability/format.js +61 -0
- package/dist/observability/intake.js +98 -0
- package/dist/observability.js +14 -160
- package/dist/operator-ux/format.js +364 -0
- package/dist/operator-ux.js +22 -363
- package/dist/orchestrator/lifecycle-operations.js +22 -1
- package/dist/orchestrator/report.js +8 -0
- package/dist/orchestrator.js +41 -10
- package/dist/reclamation.js +32 -55
- package/dist/run-export.js +138 -14
- package/dist/run-registry/derive.js +172 -0
- package/dist/run-registry/format.js +124 -0
- package/dist/run-registry/gc.js +251 -0
- package/dist/run-registry/policy.js +16 -0
- package/dist/run-registry/queue.js +116 -0
- package/dist/run-registry.js +78 -593
- package/dist/run-state-schema.js +1 -0
- package/dist/sandbox-profile.js +43 -2
- package/dist/scheduler.js +34 -4
- package/dist/state-explosion/format.js +159 -0
- package/dist/state-explosion/helpers.js +82 -0
- package/dist/state-explosion.js +65 -283
- package/dist/state-node.js +19 -4
- package/dist/telemetry-attestation.js +55 -0
- package/dist/telemetry-demo.js +15 -3
- package/dist/telemetry-ledger.js +60 -15
- package/dist/topology.js +50 -12
- package/dist/triggers.js +33 -14
- package/dist/trust-audit.js +215 -71
- package/dist/validation.js +328 -0
- package/dist/version.js +1 -1
- package/dist/worker-isolation/helpers.js +51 -0
- package/dist/worker-isolation/paths.js +46 -0
- package/dist/worker-isolation.js +182 -173
- package/docs/agent-delegation-drive.7.md +14 -0
- package/docs/cli-mcp-parity.7.md +5 -0
- package/docs/contract-migration-tooling.7.md +3 -0
- package/docs/control-plane-scheduling.7.md +3 -0
- package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +40 -0
- package/docs/durable-state-and-locking.7.md +5 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +3 -0
- package/docs/execution-backends.7.md +3 -0
- package/docs/index.md +1 -0
- package/docs/launch/launch-kit.md +46 -23
- package/docs/launch/pre-launch-checklist.md +14 -14
- package/docs/multi-agent-cli-mcp-surface.7.md +5 -0
- package/docs/multi-agent-eval-replay-harness.7.md +3 -0
- package/docs/multi-agent-operator-ux.7.md +3 -0
- package/docs/multi-agent-trust-policy-audit.7.md +27 -0
- package/docs/node-snapshot-diff-replay.7.md +3 -0
- package/docs/observability-cost-accounting.7.md +3 -0
- package/docs/project-index.md +23 -6
- package/docs/real-execution-backends.7.md +3 -0
- package/docs/release-and-migration.7.md +5 -0
- package/docs/release-tooling.7.md +35 -2
- package/docs/run-registry-control-plane.7.md +55 -8
- package/docs/run-retention-reclamation.7.md +5 -0
- package/docs/state-explosion-management.7.md +3 -0
- package/docs/team-collaboration.7.md +3 -0
- package/docs/trust-model.md +267 -0
- package/docs/vendor-manifest-loadability.7.md +43 -0
- package/docs/web-desktop-workbench.7.md +3 -0
- package/manifest/plugin.manifest.json +1 -1
- package/manifest/source-context-profiles.json +1 -1
- package/package.json +4 -2
- package/scripts/agents/builtin-templates.json +7 -0
- package/scripts/bump-version.js +5 -11
- package/scripts/canonical-apps-list.js +64 -0
- package/scripts/canonical-apps.js +19 -4
- package/scripts/children/batch-delegate-child.js +58 -0
- package/scripts/children/http-delegate-child.js +39 -0
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/parity-check.js +5 -0
- package/scripts/release-check.js +5 -1
- package/scripts/release-flow.js +181 -5
- package/scripts/version-sync-check.js +5 -8
- package/dist/capability-dispatcher.js +0 -86
|
@@ -6,6 +6,7 @@ const { execFileSync } = require("node:child_process");
|
|
|
6
6
|
const fs = require("node:fs");
|
|
7
7
|
const os = require("node:os");
|
|
8
8
|
const path = require("node:path");
|
|
9
|
+
const { CANONICAL_APP_IDS, GOLDEN_PATH_APP_ID } = require("./canonical-apps-list.js");
|
|
9
10
|
|
|
10
11
|
const pluginRoot = path.resolve(__dirname, "..");
|
|
11
12
|
const cli = path.join(pluginRoot, "scripts/cw.js");
|
|
@@ -82,7 +83,7 @@ const canonicalApps = [
|
|
|
82
83
|
"--source",
|
|
83
84
|
"plugins/cool-workflow/docs/workflow-app-framework.7.md",
|
|
84
85
|
"--scope",
|
|
85
|
-
"Cool Workflow v0.1.
|
|
86
|
+
"Cool Workflow v0.1.82",
|
|
86
87
|
"--freshness",
|
|
87
88
|
"as of release preparation"
|
|
88
89
|
]
|
|
@@ -90,6 +91,20 @@ const canonicalApps = [
|
|
|
90
91
|
];
|
|
91
92
|
|
|
92
93
|
function main() {
|
|
94
|
+
// Fail-closed drift gate (audit M5): the per-app CLI smoke below must cover
|
|
95
|
+
// exactly the DERIVED canonical set (apps/ minus metadata.example demos) less
|
|
96
|
+
// the golden-path app, which scripts/golden-path.js owns. If a new canonical
|
|
97
|
+
// app appears (or the demo marker flips) without smoke args here, this fails
|
|
98
|
+
// instead of silently skipping it — there is no second hand-copied list.
|
|
99
|
+
const expectedSmokeIds = CANONICAL_APP_IDS.filter((id) => id !== GOLDEN_PATH_APP_ID).sort();
|
|
100
|
+
const actualSmokeIds = canonicalApps.map((app) => app.id).sort();
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
actualSmokeIds,
|
|
103
|
+
expectedSmokeIds,
|
|
104
|
+
`canonical-apps smoke set drifted from derived canonical list (apps/ minus example demos, minus ${GOLDEN_PATH_APP_ID}): ` +
|
|
105
|
+
`expected ${JSON.stringify(expectedSmokeIds)}, got ${JSON.stringify(actualSmokeIds)}`
|
|
106
|
+
);
|
|
107
|
+
|
|
93
108
|
const appList = runJson(["app", "list"]);
|
|
94
109
|
const workflowList = runJson(["list"]);
|
|
95
110
|
assertUniqueIds(appList, "app list");
|
|
@@ -102,14 +117,14 @@ function main() {
|
|
|
102
117
|
assert.ok(summary, `${app.id} must appear in app list`);
|
|
103
118
|
assert.equal(summary.sourceKind, "app-directory");
|
|
104
119
|
assert.equal(summary.legacy, false);
|
|
105
|
-
assert.equal(summary.version, "0.1.
|
|
120
|
+
assert.equal(summary.version, "0.1.82");
|
|
106
121
|
|
|
107
122
|
const validation = runJson(["app", "validate", manifestPath]);
|
|
108
123
|
assert.equal(validation.valid, true, `${app.id} manifest must validate`);
|
|
109
124
|
|
|
110
125
|
const shown = runJson(["app", "show", app.id]);
|
|
111
126
|
assert.equal(shown.app.id, app.id);
|
|
112
|
-
assert.equal(shown.app.version, "0.1.
|
|
127
|
+
assert.equal(shown.app.version, "0.1.82");
|
|
113
128
|
assert.ok(shown.app.metadata.canonical, `${app.id} must be marked canonical`);
|
|
114
129
|
assert.ok(shown.app.sandboxProfiles.length > 0, `${app.id} must declare sandbox profiles`);
|
|
115
130
|
assertTaskIdsUnique(shown);
|
|
@@ -120,7 +135,7 @@ function main() {
|
|
|
120
135
|
const plan = runJson(["plan", app.id, ...app.args(workspace)]);
|
|
121
136
|
const state = JSON.parse(fs.readFileSync(plan.statePath, "utf8"));
|
|
122
137
|
assert.equal(state.workflow.app.id, app.id);
|
|
123
|
-
assert.equal(state.workflow.app.version, "0.1.
|
|
138
|
+
assert.equal(state.workflow.app.version, "0.1.82");
|
|
124
139
|
assert.equal(state.workflow.app.metadata.canonical, true);
|
|
125
140
|
assert.ok(state.tasks.some((task) => task.requiresEvidence), `${app.id} plan must include evidence gates`);
|
|
126
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.
|
|
8
|
+
const TARGET_VERSION = "0.1.82";
|
|
9
9
|
const PREVIOUS_VERSION = "0.1.31";
|
|
10
10
|
const pluginRoot = path.resolve(__dirname, "..");
|
|
11
11
|
const repoRoot = path.resolve(pluginRoot, "..", "..");
|
package/scripts/golden-path.js
CHANGED
|
@@ -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.
|
|
36
|
+
assert.equal(appValidation.summary.version, "0.1.82");
|
|
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.
|
|
45
|
+
"Prove the deterministic v0.1.82 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.
|
|
55
|
+
assert.equal(state.workflow.app.version, "0.1.82");
|
|
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\.
|
|
198
|
+
assert.match(report, /Workflow App: end-to-end-golden-path@0\.1\.82/);
|
|
199
199
|
assert.match(report, /## Candidates/);
|
|
200
200
|
assert.match(report, /## Trust Audit/);
|
|
201
201
|
assert.match(report, /## Acceptance Rationale/);
|
package/scripts/parity-check.js
CHANGED
|
@@ -150,6 +150,11 @@ async function payloadParity() {
|
|
|
150
150
|
for (const [capability, mcpTool] of GLOBAL_PROBES) {
|
|
151
151
|
const cap = capById(capability);
|
|
152
152
|
assert.equal(cap.mcp.tool, mcpTool, `probe/registry MCP tool mismatch for ${capability}`);
|
|
153
|
+
// jsonMode is the single source for the CLI's --json policy; this probe only
|
|
154
|
+
// appends --json for "flag" verbs and JSON.parse-es the result. The human
|
|
155
|
+
// rendering and "default"-verb no-flag JSON are pinned to cap.cli.jsonMode by
|
|
156
|
+
// the companion test/cli-jsonmode-parity-smoke.js, so cli.ts can't silently
|
|
157
|
+
// re-encode that policy by hand and drift from this registry data.
|
|
153
158
|
const cliArgv = [...cap.cli.path, ...(cap.cli.jsonMode === "flag" ? ["--json"] : [])];
|
|
154
159
|
const cliOut = JSON.parse(execFileSync(node, [cli, ...cliArgv], { cwd: workspace, encoding: "utf8" }));
|
|
155
160
|
const mcpOut = await mcp.tool(mcpTool, { cwd: workspace });
|
package/scripts/release-check.js
CHANGED
|
@@ -58,7 +58,11 @@ const checks = [
|
|
|
58
58
|
{ name: "dist freshness", command: ["npm", "run", "dist:check"] },
|
|
59
59
|
{ name: "type check", command: ["npm", "run", "check"] },
|
|
60
60
|
{ name: "run-state schema consistency", command: ["node", "scripts/validate-run-state-schema.js"] },
|
|
61
|
-
|
|
61
|
+
// Parallel suite (test:ci = run-all.js --concurrency auto). Each smoke runs in
|
|
62
|
+
// a private cwd + state roots (CW_HOME/HOME/TMPDIR), so concurrency is race-free.
|
|
63
|
+
// The bare `npm test` and the tag-gate (release-gate.sh) stay sequential as the
|
|
64
|
+
// deterministic backstop.
|
|
65
|
+
{ name: "tests", command: ["npm", "run", "test:ci"] },
|
|
62
66
|
{ name: "canonical apps", command: ["npm", "run", "canonical-apps"] },
|
|
63
67
|
{ name: "golden path", command: ["npm", "run", "golden-path"] },
|
|
64
68
|
{ name: "CLI MCP parity", command: ["npm", "run", "parity:check"] },
|
package/scripts/release-flow.js
CHANGED
|
@@ -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,
|
|
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
|
|
22
|
-
//
|
|
23
|
-
//
|
|
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`);
|
|
@@ -5,6 +5,7 @@ const assert = require("node:assert/strict");
|
|
|
5
5
|
const fs = require("node:fs");
|
|
6
6
|
const path = require("node:path");
|
|
7
7
|
const { spawnSync } = require("node:child_process");
|
|
8
|
+
const { CANONICAL_APP_IDS } = require("./canonical-apps-list.js");
|
|
8
9
|
|
|
9
10
|
const pluginRoot = path.resolve(__dirname, "..");
|
|
10
11
|
const repoRoot = path.resolve(pluginRoot, "..", "..");
|
|
@@ -47,14 +48,10 @@ function readReleaseSource(relativePath) {
|
|
|
47
48
|
// Read it from the released commit so the asserted-against version is itself
|
|
48
49
|
// taken from HEAD, not a half-written working copy.
|
|
49
50
|
const VERSION = JSON.parse(readReleaseSource("plugins/cool-workflow/package.json").text).version;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"pr-review-fix-ci",
|
|
55
|
-
"release-cut",
|
|
56
|
-
"research-synthesis"
|
|
57
|
-
];
|
|
51
|
+
// Canonical app ids are DERIVED from apps/ (excluding metadata.example demos) by
|
|
52
|
+
// scripts/canonical-apps-list.js — the single source bump-version.js bumps and
|
|
53
|
+
// canonical-apps.js smoke-tests. No hand-copied list to drift (audit M5).
|
|
54
|
+
const canonicalApps = CANONICAL_APP_IDS;
|
|
58
55
|
|
|
59
56
|
function main() {
|
|
60
57
|
const checks = [];
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
// Capability Dispatcher — the thin MECHANISM pipe that routes a capability id
|
|
3
|
-
// to its registered handler function. It knows nothing about which capabilities
|
|
4
|
-
// exist (that's POLICY, declared elsewhere via registerCapabilityHandler).
|
|
5
|
-
//
|
|
6
|
-
// BSD discipline:
|
|
7
|
-
// - ONE THING: map (capability id, args, ctx) -> handler output.
|
|
8
|
-
// - SEPARATE MECHANISM FROM POLICY. The Map is mechanism; which entries are
|
|
9
|
-
// registered is policy (callers registerCapabilityHandler at import time).
|
|
10
|
-
// - FAIL CLOSED. Unknown capability id -> CapabilityError with a named refusal.
|
|
11
|
-
// - COMPOSABLE. The dispatcher is a pure router; CLI and MCP surfaces compose
|
|
12
|
-
// their own formatting/protocol wrapping around it. No surface knows how to
|
|
13
|
-
// format — the handler returns raw data, the surface renders it.
|
|
14
|
-
// - NO HIDDEN STATE. The registry is a plain Map; no lazy loading, no magic.
|
|
15
|
-
//
|
|
16
|
-
// From v0.1.53: this replaces the "manual switch in cli.ts + mcp-server.ts"
|
|
17
|
-
// anti-pattern for NEW capabilities. Existing hardcoded capabilities remain in
|
|
18
|
-
// the switch until they are progressively migrated; the fallback path in both
|
|
19
|
-
// surfaces first checks the switch, then falls through to this dispatcher.
|
|
20
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.registerCapabilityHandler = registerCapabilityHandler;
|
|
22
|
-
exports.getCapabilityHandler = getCapabilityHandler;
|
|
23
|
-
exports.dispatchCapability = dispatchCapability;
|
|
24
|
-
exports.resolveCliPath = resolveCliPath;
|
|
25
|
-
exports.resolveMcpTool = resolveMcpTool;
|
|
26
|
-
exports.listCapabilityIds = listCapabilityIds;
|
|
27
|
-
class CapabilityError extends Error {
|
|
28
|
-
code;
|
|
29
|
-
capabilityId;
|
|
30
|
-
constructor(capabilityId, reason, code) {
|
|
31
|
-
super(`Capability "${capabilityId}": ${reason}`);
|
|
32
|
-
this.name = "CapabilityError";
|
|
33
|
-
this.code = code;
|
|
34
|
-
this.capabilityId = capabilityId;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
const _handlerRegistry = new Map();
|
|
38
|
-
/** Register a capability handler. Later registrations with the same capability
|
|
39
|
-
* id overwrite earlier ones (last-write-wins dedup). */
|
|
40
|
-
function registerCapabilityHandler(handler) {
|
|
41
|
-
_handlerRegistry.set(handler.descriptor.capability, handler);
|
|
42
|
-
}
|
|
43
|
-
/** Look up a handler by capability id. Returns undefined when not found. */
|
|
44
|
-
function getCapabilityHandler(capabilityId) {
|
|
45
|
-
return _handlerRegistry.get(capabilityId);
|
|
46
|
-
}
|
|
47
|
-
/** Dispatch a capability by id. Resolves the handler, invokes `run()`.
|
|
48
|
-
* Fail-closed: throws CapabilityError when no handler is registered. */
|
|
49
|
-
function dispatchCapability(capabilityId, args, ctx) {
|
|
50
|
-
const handler = _handlerRegistry.get(capabilityId);
|
|
51
|
-
if (!handler)
|
|
52
|
-
throw new CapabilityError(capabilityId, "no handler registered", "not-found");
|
|
53
|
-
return handler.run(args, ctx);
|
|
54
|
-
}
|
|
55
|
-
/** Resolve a CLI path (e.g. ["gc", "plan"]) to a capability id by matching
|
|
56
|
-
* registered handlers' `cli.path` bindings. Returns undefined when no match. */
|
|
57
|
-
function resolveCliPath(cliPath) {
|
|
58
|
-
if (!cliPath.length)
|
|
59
|
-
return undefined;
|
|
60
|
-
for (const handler of _handlerRegistry.values()) {
|
|
61
|
-
if (!handler.descriptor.cli)
|
|
62
|
-
continue;
|
|
63
|
-
const expected = handler.descriptor.cli.caseTokens || handler.descriptor.cli.path;
|
|
64
|
-
if (pathsMatch(expected, cliPath))
|
|
65
|
-
return handler.descriptor.capability;
|
|
66
|
-
}
|
|
67
|
-
return undefined;
|
|
68
|
-
}
|
|
69
|
-
/** Resolve an MCP tool name to a capability id by matching registered handlers'
|
|
70
|
-
* `mcp.tool` bindings. Returns undefined when no match. */
|
|
71
|
-
function resolveMcpTool(toolName) {
|
|
72
|
-
for (const handler of _handlerRegistry.values()) {
|
|
73
|
-
if (handler.descriptor.mcp?.tool === toolName)
|
|
74
|
-
return handler.descriptor.capability;
|
|
75
|
-
}
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
/** List all registered capability ids. */
|
|
79
|
-
function listCapabilityIds() {
|
|
80
|
-
return [..._handlerRegistry.keys()].sort();
|
|
81
|
-
}
|
|
82
|
-
function pathsMatch(expected, actual) {
|
|
83
|
-
if (expected.length !== actual.length)
|
|
84
|
-
return false;
|
|
85
|
-
return expected.every((token, i) => actual[i] === token);
|
|
86
|
-
}
|