cool-workflow 0.1.86 → 0.1.88

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 (81) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +111 -68
  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/agent-config.js +42 -1
  11. package/dist/capability-core.js +9 -4
  12. package/dist/capability-registry.js +48 -0
  13. package/dist/cli/command-surface.js +182 -11
  14. package/dist/cli.js +2 -1
  15. package/dist/doctor.js +27 -5
  16. package/dist/drive.js +222 -16
  17. package/dist/execution-backend.js +12 -4
  18. package/dist/loop-expansion.js +60 -0
  19. package/dist/onramp.js +25 -0
  20. package/dist/operator-ux/format.js +21 -15
  21. package/dist/operator-ux.js +2 -1
  22. package/dist/orchestrator/lifecycle-operations.js +134 -3
  23. package/dist/orchestrator.js +122 -76
  24. package/dist/run-export.js +106 -2
  25. package/dist/state-node.js +13 -3
  26. package/dist/state.js +21 -0
  27. package/dist/telemetry-attestation.js +30 -6
  28. package/dist/telemetry-demo.js +29 -1
  29. package/dist/telemetry-ledger.js +6 -0
  30. package/dist/term.js +93 -0
  31. package/dist/version.js +1 -1
  32. package/dist/worker-accept/telemetry-ledger.js +12 -2
  33. package/dist/workflow-api.js +33 -0
  34. package/dist/workflow-app-framework.js +20 -0
  35. package/docs/agent-delegation-drive.7.md +28 -6
  36. package/docs/capability-topology-registry.7.md +69 -46
  37. package/docs/cli-mcp-parity.7.md +22 -2
  38. package/docs/contract-migration-tooling.7.md +8 -0
  39. package/docs/control-plane-scheduling.7.md +8 -0
  40. package/docs/durable-state-and-locking.7.md +8 -0
  41. package/docs/evidence-adoption-reasoning-chain.7.md +8 -0
  42. package/docs/execution-backends.7.md +8 -0
  43. package/docs/launch/launch-kit.md +9 -9
  44. package/docs/multi-agent-cli-mcp-surface.7.md +8 -0
  45. package/docs/multi-agent-eval-replay-harness.7.md +8 -0
  46. package/docs/multi-agent-operator-ux.7.md +8 -0
  47. package/docs/node-snapshot-diff-replay.7.md +8 -0
  48. package/docs/observability-cost-accounting.7.md +8 -0
  49. package/docs/project-index.md +26 -5
  50. package/docs/readme-v0.1.87-full.md +301 -0
  51. package/docs/real-execution-backends.7.md +8 -0
  52. package/docs/release-and-migration.7.md +8 -0
  53. package/docs/release-history.md +10 -0
  54. package/docs/release-tooling.7.md +16 -0
  55. package/docs/report-verifiable-bundle.7.md +34 -2
  56. package/docs/run-registry-control-plane.7.md +18 -0
  57. package/docs/run-retention-reclamation.7.md +8 -0
  58. package/docs/state-explosion-management.7.md +8 -0
  59. package/docs/team-collaboration.7.md +8 -0
  60. package/docs/trust-model.md +6 -4
  61. package/docs/web-desktop-workbench.7.md +8 -0
  62. package/manifest/plugin.manifest.json +1 -1
  63. package/manifest/source-context-profiles.json +1 -1
  64. package/package.json +8 -5
  65. package/scripts/agents/agent-adapter-core.js +180 -0
  66. package/scripts/agents/builtin-templates.json +5 -1
  67. package/scripts/agents/claude-p-agent.js +7 -33
  68. package/scripts/agents/codex-agent.js +134 -0
  69. package/scripts/agents/cw-attest-wrap.js +9 -1
  70. package/scripts/agents/gemini-agent.js +115 -0
  71. package/scripts/agents/opencode-agent.js +119 -0
  72. package/scripts/canonical-apps.js +4 -4
  73. package/scripts/coverage-gate.js +15 -1
  74. package/scripts/cw.js +0 -0
  75. package/scripts/dogfood-release.js +1 -1
  76. package/scripts/golden-path.js +4 -4
  77. package/scripts/parity-check.js +6 -5
  78. package/scripts/release-check.js +3 -3
  79. package/scripts/release-flow.js +49 -2
  80. package/scripts/release-gate.sh +1 -1
  81. package/tsconfig.json +3 -1
@@ -53,7 +53,11 @@ function canonicalTelemetryPayload(usage, ctx) {
53
53
  usage: usage ?? null,
54
54
  runId: ctx.runId,
55
55
  taskId: ctx.taskId,
56
- promptDigest: ctx.promptDigest
56
+ promptDigest: ctx.promptDigest,
57
+ // Present ONLY when the signer covered the result. Omitting the key keeps the
58
+ // canonical bytes byte-identical to the original 4-field payload, so every
59
+ // pre-result-coverage signature still verifies (POLA / back-compat).
60
+ ...(ctx.resultDigest !== undefined ? { resultDigest: ctx.resultDigest } : {})
57
61
  });
58
62
  }
59
63
  function messageOf(error) {
@@ -110,16 +114,26 @@ function verifyTelemetryAttestation(usage, signatureB64, trustPublicKeyPem, ctx)
110
114
  catch {
111
115
  return { status: "unattested", reason: "signature is not valid base64" };
112
116
  }
113
- const payload = Buffer.from(canonicalTelemetryPayload(usage, ctx), "utf8");
117
+ const matches = (c) => node_crypto_1.default.verify(null, Buffer.from(canonicalTelemetryPayload(usage, c), "utf8"), publicKey, signature);
114
118
  let ok = false;
119
+ let coversResult = false;
115
120
  try {
116
- ok = node_crypto_1.default.verify(null, payload, publicKey, signature);
121
+ ok = matches(ctx);
122
+ // A match on the first arm (which carries resultDigest when CW has one) means
123
+ // the signature covered the result — the findings — not just the usage.
124
+ coversResult = ok && ctx.resultDigest !== undefined;
125
+ // Back-compat: a signer that predates result coverage signed only the 4-field
126
+ // payload, so on a miss retry WITHOUT resultDigest. A NEW signer who covered
127
+ // the result fails BOTH arms when the result is edited (its resultDigest no
128
+ // longer matches), so result tampering is still caught.
129
+ if (!ok && ctx.resultDigest !== undefined)
130
+ ok = matches({ ...ctx, resultDigest: undefined });
117
131
  }
118
132
  catch (error) {
119
133
  return { status: "unattested", reason: `verification error: ${messageOf(error)}` };
120
134
  }
121
135
  return ok
122
- ? { status: "attested", algorithm: "ed25519" }
136
+ ? { status: "attested", algorithm: "ed25519", ...(coversResult ? { coversResult: true } : {}) }
123
137
  : { status: "unattested", reason: "signature does not match reported usage (tampered, replayed, or wrong key)" };
124
138
  }
125
139
  /** Resolve a trust key from a config value that is EITHER an inline PEM or a path
@@ -160,6 +174,7 @@ function stableDigest(value) {
160
174
  }
161
175
  function verifyTelemetrySignatures(records, trustPublicKeyPem) {
162
176
  const checks = [];
177
+ const resultBound = [];
163
178
  let checked = 0;
164
179
  let reverified = 0;
165
180
  let failed = 0;
@@ -190,11 +205,20 @@ function verifyTelemetrySignatures(records, trustPublicKeyPem) {
190
205
  const result = verifyTelemetryAttestation(record.reportedUsage, record.usageSignature, trustPublicKeyPem, {
191
206
  runId: record.runId,
192
207
  taskId: record.taskId,
193
- promptDigest: record.promptDigest
208
+ promptDigest: record.promptDigest,
209
+ // Result-bound records carry the signed digest; verifyTelemetryAttestation
210
+ // reconstructs the 5-field payload (and falls back to 4-field for old records).
211
+ resultDigest: record.resultDigest
194
212
  });
195
213
  if (result.status === "attested") {
196
214
  reverified += 1;
197
215
  checks.push({ name: `signature[${i}]`, pass: true });
216
+ // Only a signature that actually COVERED the result digest anchors it — a
217
+ // 4-field fallback (coversResult false) must not let an injected resultDigest
218
+ // be trusted downstream.
219
+ if (result.coversResult && record.resultDigest) {
220
+ resultBound.push({ taskId: record.taskId, resultDigest: record.resultDigest });
221
+ }
198
222
  }
199
223
  else {
200
224
  failed += 1;
@@ -207,5 +231,5 @@ function verifyTelemetrySignatures(records, trustPublicKeyPem) {
207
231
  });
208
232
  }
209
233
  }
210
- return { keyProvided: Boolean(trustPublicKeyPem), checked, reverified, failed, checks };
234
+ return { keyProvided: Boolean(trustPublicKeyPem), checked, reverified, failed, resultBound, checks };
211
235
  }
@@ -6,13 +6,17 @@
6
6
  // Fully hermetic + deterministic: generates an EPHEMERAL ed25519 keypair, builds
7
7
  // a REAL telemetry ledger through the production append API (appendTelemetryAttestation
8
8
  // + signTelemetry — byte-identical to what a live attested run writes), then
9
- // demonstrates BOTH tamper-evidence layers catching a forgery:
9
+ // demonstrates all THREE tamper-evidence layers catching a forgery:
10
10
  // A) LEDGER layer — flip a recorded verdict on disk (unattested -> attested, the
11
11
  // canonical "forge a green record" attack) -> verifyTelemetryLedger recomputes
12
12
  // every hash independently, so the edited record's hash mismatches AND every
13
13
  // record after it breaks the chain (cascade).
14
14
  // B) SIGNATURE layer — inflate the reported tokens but keep the original ed25519
15
15
  // signature -> verifyTelemetryAttestation rejects it ("signature does not match").
16
+ // C) RESULT layer — edit the agent's SIGNED FINDING after signing. The executor
17
+ // binds sha256(result.md) into the ed25519 payload; CW re-derives the digest
18
+ // from the result file at verify time, so an edited finding no longer joins the
19
+ // signature. This is the threat a usage-only signature never covered.
16
20
  //
17
21
  // No model, no network, no API key, no second repo — runs in a private tmpdir.
18
22
  var __importDefault = (this && this.__importDefault) || function (mod) {
@@ -161,6 +165,30 @@ function runTamperDemo(options = {}) {
161
165
  after: { verified: sigCheck.status === "attested", detail: sigCheck.reason || sigCheck.status },
162
166
  failures: sigCheck.status === "attested" ? [] : [`signature: ${sigCheck.reason}`]
163
167
  });
168
+ // 3c. RESULT layer — the REAL threat the result-bound signature closes: the agent's
169
+ // signed FINDING is edited after signing. The executor binds sha256(result.md)
170
+ // into the ed25519 payload (a 5-field, result-COVERING signature); CW re-derives
171
+ // the digest from the result file at verify time. An edited finding hashes to a
172
+ // different digest, so the signature joins neither the 5-field nor the 4-field
173
+ // payload and the verify rejects it. A usage-only signature never covered this.
174
+ const findingSigned = "Finding: auth bypass in login() — severity HIGH";
175
+ const findingEdited = findingSigned.replace("HIGH", "LOW");
176
+ const resultCtx = { runId, taskId: target.hop.taskId, promptDigest: target.hop.promptDigest };
177
+ const resultSignature = (0, telemetry_attestation_1.signTelemetry)(target.hop.usage, privateKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingSigned) });
178
+ // Baseline: the signed finding verifies, and the signature actually COVERED the result.
179
+ const resultClean = (0, telemetry_attestation_1.verifyTelemetryAttestation)(target.hop.usage, resultSignature, publicKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingSigned) });
180
+ // Tamper: CW re-derives the digest from the EDITED finding -> no longer the signed digest.
181
+ const resultCheck = (0, telemetry_attestation_1.verifyTelemetryAttestation)(target.hop.usage, resultSignature, publicKeyPem, { ...resultCtx, resultDigest: (0, execution_backend_1.sha256)(findingEdited) });
182
+ layers.push({
183
+ layer: "result",
184
+ tamper: `edited the agent's signed finding "severity HIGH" -> "severity LOW" after it was signed`,
185
+ before: {
186
+ verified: resultClean.status === "attested" && resultClean.coversResult === true,
187
+ detail: `the signed finding verifies — ed25519 binds usage + sha256(result), result-covering`
188
+ },
189
+ after: { verified: resultCheck.status === "attested", detail: resultCheck.reason || resultCheck.status },
190
+ failures: resultCheck.status === "attested" ? [] : [`result: ${resultCheck.reason}`]
191
+ });
164
192
  if (!options.keepDir && !options.dir)
165
193
  node_fs_1.default.rmSync(runDir, { recursive: true, force: true });
166
194
  const proven = baseline.ledgerVerified &&
@@ -94,6 +94,9 @@ function recordHashInput(record) {
94
94
  reportedUsageDigest: record.reportedUsageDigest,
95
95
  ...(record.reportedUsage !== undefined ? { reportedUsage: record.reportedUsage } : {}),
96
96
  usageSignature: record.usageSignature || null,
97
+ // Chain-bind resultDigest only when present, so a usage-only record's hash is
98
+ // byte-identical to a pre-result-coverage one (back-compat with old ledgers).
99
+ ...(record.resultDigest !== undefined ? { resultDigest: record.resultDigest } : {}),
97
100
  attestation: record.attestation,
98
101
  attestationReason: record.attestationReason || null,
99
102
  prevHash: record.prevHash
@@ -131,6 +134,9 @@ function appendTelemetryAttestation(run, input) {
131
134
  // signature can be independently re-verified offline at `telemetry verify`.
132
135
  ...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
133
136
  usageSignature: input.usageSignature,
137
+ // Present only for a result-bound signature, so usage-only records are
138
+ // byte-identical (and their recordHash unchanged) — back-compat.
139
+ ...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
134
140
  attestation: input.attestation,
135
141
  attestationReason: input.attestationReason,
136
142
  prevHash
package/dist/term.js ADDED
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ // src/term.ts — zero-dependency terminal styling.
3
+ //
4
+ // Provides TTY-gated ANSI formatting for human-readable output. When output is
5
+ // piped (non-TTY), styled calls return plain text. This keeps data channels
6
+ // (stdout) and diagnostics (stderr) clean — never adds escape codes to pipes.
7
+ //
8
+ // Used by: doctor, help, error messages, status summaries.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.bold = bold;
11
+ exports.dim = dim;
12
+ exports.green = green;
13
+ exports.yellow = yellow;
14
+ exports.red = red;
15
+ exports.cyan = cyan;
16
+ exports.doctorGlyph = doctorGlyph;
17
+ exports.cwLabel = cwLabel;
18
+ exports.indent = indent;
19
+ exports.printSuccessSummary = printSuccessSummary;
20
+ function isTTY(stream = process.stderr) {
21
+ return Boolean(stream.isTTY);
22
+ }
23
+ // ---- ansi codes ----
24
+ const ansi = {
25
+ reset: "\x1b[0m",
26
+ bold: "\x1b[1m",
27
+ dim: "\x1b[2m",
28
+ green: "\x1b[32m",
29
+ yellow: "\x1b[33m",
30
+ red: "\x1b[31m",
31
+ cyan: "\x1b[36m",
32
+ };
33
+ // ---- styled text ----
34
+ function style(code, text, stream) {
35
+ if (!isTTY(stream))
36
+ return text;
37
+ return `${code}${text}${ansi.reset}`;
38
+ }
39
+ function bold(text, stream) {
40
+ return style(ansi.bold, text, stream);
41
+ }
42
+ function dim(text, stream) {
43
+ return style(ansi.dim, text, stream);
44
+ }
45
+ function green(text, stream) {
46
+ return style(ansi.green, text, stream);
47
+ }
48
+ function yellow(text, stream) {
49
+ return style(ansi.yellow, text, stream);
50
+ }
51
+ function red(text, stream) {
52
+ return style(ansi.red, text, stream);
53
+ }
54
+ function cyan(text, stream) {
55
+ return style(ansi.cyan, text, stream);
56
+ }
57
+ // ---- semantic helpers ----
58
+ /** Returns the styled glyph + label for a doctor check severity. */
59
+ function doctorGlyph(status, stream) {
60
+ const glyph = { ok: "✓", warn: "!", fail: "✗" };
61
+ const color = {
62
+ ok: green,
63
+ warn: yellow,
64
+ fail: red,
65
+ };
66
+ return color[status](`${glyph[status]}`, stream);
67
+ }
68
+ /** "cw:" prefix with bold and optional color. */
69
+ function cwLabel(stream) {
70
+ return bold("cw:", stream);
71
+ }
72
+ /** Render a multi-line block with consistent 2-space indentation. */
73
+ function indent(text, spaces = 2) {
74
+ const prefix = " ".repeat(spaces);
75
+ return text.split("\n").map((line) => `${prefix}${line}`).join("\n");
76
+ }
77
+ /** Print a success summary to stderr (TTY-gated). Shows the report path and a
78
+ * suggested next command. Pipe-friendly: silent when stderr is not a TTY. */
79
+ function printSuccessSummary(fields, stream) {
80
+ if (!isTTY(stream))
81
+ return;
82
+ const s = stream || process.stderr;
83
+ s.write(`\n${green("✓")} Report: ${fields.reportPath}\n`);
84
+ if (fields.status === "complete") {
85
+ s.write(` Next: cw status ${fields.runId} --brief\n`);
86
+ if (fields.bundle !== false) {
87
+ s.write(` Bundle: cw report bundle ${fields.runId}\n`);
88
+ }
89
+ }
90
+ else {
91
+ s.write(` ${yellow("!")} Status: ${fields.status}. Next: cw status ${fields.runId}\n`);
92
+ }
93
+ }
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA_VERSION = exports.CURRENT_RUN_STATE_SCHEMA_VERSION = exports.WORKFLOW_APP_SCHEMA_VERSION = exports.CURRENT_COOL_WORKFLOW_VERSION = void 0;
4
- exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.86";
4
+ exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.88";
5
5
  exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
6
6
  exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;
7
7
  exports.LEGACY_RUN_STATE_SCHEMA_VERSION = 0;
@@ -24,7 +24,12 @@ function attestWorkerDelegation(accept, deps) {
24
24
  // usage. Absent/invalid signature => `unattested`/`absent`, surfaced loudly,
25
25
  // NEVER silently recorded as trusted.
26
26
  const telemetry = options.agentDelegation
27
- ? (0, telemetry_attestation_1.verifyTelemetryAttestation)(options.agentDelegation.reportedUsage, options.agentDelegation.usageSignature, (0, telemetry_attestation_1.resolveTrustPublicKey)(options.agentDelegation.usageTrustPublicKey), { runId: run.id, taskId: task.id, promptDigest: options.agentDelegation.promptDigest })
27
+ ? (0, telemetry_attestation_1.verifyTelemetryAttestation)(options.agentDelegation.reportedUsage, options.agentDelegation.usageSignature, (0, telemetry_attestation_1.resolveTrustPublicKey)(options.agentDelegation.usageTrustPublicKey),
28
+ // resultDigest binds the agent's findings into the signature: CW recomputes
29
+ // the digest from the accepted result (the SAME raw bytes the executor
30
+ // signed) so a result edited after signing fails verification. A signer
31
+ // that did not cover the result still verifies (verifier back-compat).
32
+ { runId: run.id, taskId: task.id, promptDigest: options.agentDelegation.promptDigest, resultDigest: (0, execution_backend_1.sha256)(rawResult) })
28
33
  : undefined;
29
34
  // Track 1 fail-closed (Decision 2 — OPT-IN, off by default). When the operator
30
35
  // requires attested telemetry, a delegated hop whose verdict is not `attested`
@@ -59,7 +64,7 @@ function attestWorkerDelegation(accept, deps) {
59
64
  * event can cross-link the record hash), then emits the worker.agent-delegation
60
65
  * audit event. No-op for non-agent hops. */
61
66
  function recordWorkerDelegationLedger(accept, delegation) {
62
- const { agentDelegation } = delegation;
67
+ const { agentDelegation, telemetry } = delegation;
63
68
  // The agent-hop attestation event — hung off worker.output, alongside
64
69
  // worker.backend. Recorded in trust-audit/provenance, NEVER in node evidence.
65
70
  if (!agentDelegation)
@@ -76,6 +81,11 @@ function recordWorkerDelegationLedger(accept, delegation) {
76
81
  promptDigest: agentDelegation.promptDigest,
77
82
  reportedUsage: agentDelegation.reportedUsage,
78
83
  usageSignature: agentDelegation.usageSignature,
84
+ // Store the signed result digest ONLY when the signature actually covered
85
+ // it, so the offline re-verifier (telemetry verify --pubkey / report verify)
86
+ // can reconstruct the 5-field payload. A usage-only signature stores none
87
+ // (its record stays byte-identical to a pre-result-coverage one).
88
+ resultDigest: telemetry?.coversResult ? agentDelegation.resultDigest : undefined,
79
89
  attestation: agentDelegation.usageAttestation,
80
90
  attestationReason: agentDelegation.usageAttestationReason
81
91
  })
@@ -3,8 +3,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.workflow = workflow;
4
4
  exports.phase = phase;
5
5
  exports.parallel = parallel;
6
+ exports.loop = loop;
6
7
  exports.createWorkflowApi = createWorkflowApi;
7
8
  exports.agent = agent;
9
+ exports.subWorkflow = subWorkflow;
8
10
  exports.artifact = artifact;
9
11
  exports.input = input;
10
12
  exports.slugify = slugify;
@@ -46,19 +48,50 @@ function phase(name, tasks, options = {}) {
46
48
  function parallel(name, tasks, options = {}) {
47
49
  return phase(name, tasks, { mode: "parallel", ...options });
48
50
  }
51
+ /** A BOUNDED DYNAMIC LOOP phase: `tasks` are a per-round template. After each round
52
+ * completes, the registered `until` predicate decides whether to run another round
53
+ * (a fresh appended phase with the same tasks, round-suffixed ids) or stop; capped
54
+ * at `maxRounds`. Sugar over phase() that sets `loop`; plain phases are unaffected. */
55
+ function loop(name, tasks, spec, options = {}) {
56
+ if (!spec || typeof spec.maxRounds !== "number" || spec.maxRounds < 1) {
57
+ throw new Error(`loop ${name} requires a positive integer maxRounds`);
58
+ }
59
+ const until = spec.until;
60
+ const valid = until
61
+ && ((until.kind === "predicate" && Boolean(until.ref))
62
+ || (until.kind === "budget-target" && typeof until.target === "number" && until.target > 0));
63
+ if (!valid) {
64
+ throw new Error(`loop ${name} requires until: { kind: "predicate", ref } or { kind: "budget-target", target }`);
65
+ }
66
+ return phase(name, tasks, { loop: { maxRounds: Math.floor(spec.maxRounds), until }, ...options });
67
+ }
49
68
  function createWorkflowApi() {
50
69
  return {
51
70
  workflow,
52
71
  phase,
53
72
  parallel,
73
+ loop,
54
74
  agent,
55
75
  artifact,
76
+ subWorkflow,
56
77
  input
57
78
  };
58
79
  }
59
80
  function agent(id, prompt, options = {}) {
60
81
  return task("agent", id, prompt, options);
61
82
  }
83
+ /** A task fulfilled by an inline SUB-WORKFLOW: instead of spawning an agent, the
84
+ * drive plans + drives the child `appId` and binds its report back as this task's
85
+ * result. The prompt is recorded for provenance but is not sent to an agent. */
86
+ function subWorkflow(id, appId, options = {}) {
87
+ if (!appId)
88
+ throw new Error(`subWorkflow task ${id} requires an appId`);
89
+ const { inputs, bindResult, prompt, ...rest } = options;
90
+ return task("agent", id, prompt || `Delegate to sub-workflow app: ${appId}`, {
91
+ ...rest,
92
+ subWorkflow: { appId, ...(inputs ? { inputs } : {}), ...(bindResult ? { bindResult } : {}) }
93
+ });
94
+ }
62
95
  function artifact(id, prompt, options = {}) {
63
96
  return task("artifact", id, prompt, options);
64
97
  }
@@ -522,6 +522,26 @@ function validatePhase(phaseDefinition, issues, pathName, seenPhaseIds) {
522
522
  if (!Array.isArray(phaseValue.tasks) || !phaseValue.tasks.length) {
523
523
  issues.push(issue("workflow-phase-tasks", `Workflow phase ${String(phaseValue.id || phaseValue.name || "")} must have tasks`, joinPath(pathName, "tasks")));
524
524
  }
525
+ // Bounded dynamic loop spec (fail-closed shape check; the predicate ref need not be
526
+ // registered at validation time — the expander stops fail-closed if it is missing).
527
+ if (phaseValue.loop !== undefined) {
528
+ const loop = phaseValue.loop;
529
+ if (!isRecord(loop)) {
530
+ issues.push(issue("workflow-phase-loop", "Workflow phase loop must be an object", joinPath(pathName, "loop")));
531
+ }
532
+ else {
533
+ if (typeof loop.maxRounds !== "number" || !Number.isInteger(loop.maxRounds) || loop.maxRounds < 1) {
534
+ issues.push(issue("workflow-phase-loop-maxrounds", "loop.maxRounds must be a positive integer", joinPath(pathName, "loop.maxRounds")));
535
+ }
536
+ const until = loop.until;
537
+ const validUntil = isRecord(until)
538
+ && ((until.kind === "predicate" && isNonEmptyString(until.ref))
539
+ || (until.kind === "budget-target" && typeof until.target === "number" && until.target > 0));
540
+ if (!validUntil) {
541
+ issues.push(issue("workflow-phase-loop-until", 'loop.until must be { kind: "predicate", ref } or { kind: "budget-target", target }', joinPath(pathName, "loop.until")));
542
+ }
543
+ }
544
+ }
525
545
  }
526
546
  function validateTask(taskDefinition, issues, pathName, seenTaskIds, options) {
527
547
  if (!isRecord(taskDefinition)) {
@@ -204,13 +204,15 @@ A drive can show the agent's activity live, without touching the evidence
204
204
  contract, when the operator opts in with `CW_AGENT_STREAM=1`:
205
205
 
206
206
  - **Default stays buffered.** Without `CW_AGENT_STREAM=1`, the bundled wrapper
207
- keeps the legacy `--output-format json` path and forwards claude's JSON stdout
208
- word for word after writing `result.md`.
207
+ keeps the buffered path and writes one JSON report to stdout after writing
208
+ `result.md`.
209
209
  - **The opt-in wrapper renders; stderr only.** With `CW_AGENT_STREAM=1`, the
210
- bundled wrapper runs claude in `--output-format stream-json` and renders a short
211
- human trace (tool uses, assistant text, per-turn summaries) to its
212
- **stderr** diagnostics, never data. It builds the single
213
- `{model, usage, result}` object for stdout again only on that opt-in path.
210
+ bundled Claude wrapper runs claude in `--output-format stream-json`, and the
211
+ bundled Codex wrapper runs `codex exec --json --output-last-message`.
212
+ Each wrapper renders a short human trace (tool uses, assistant text, per-turn
213
+ summaries where present) to its **stderr** diagnostics, never data. It builds
214
+ the single `{model, usage, result}` object for stdout after the final answer is
215
+ captured.
214
216
  - **Core forwards, never parses.** `runAgentProcess` passes the agent child's
215
217
  stderr straight through to the operator's terminal (`stdio` inherit) only when
216
218
  `CW_AGENT_STREAM=1`, CW's own stderr is a TTY, and `CW_NO_STREAM` is not set.
@@ -219,6 +221,18 @@ contract, when the operator opts in with `CW_AGENT_STREAM=1`:
219
221
  - **Determinism intact.** The backend evidence triple hashes stdout only, so
220
222
  the live stderr stream never changes recorded evidence or replay.
221
223
 
224
+ The built-in templates are:
225
+
226
+ ```text
227
+ --agent-command builtin:claude
228
+ --agent-command builtin:codex
229
+ ```
230
+
231
+ Gemini, OpenCode, DeepSeek, and GLM stay as external agent commands or HTTP
232
+ endpoints until their stream format is proven by a local, deterministic wrapper
233
+ smoke. DeepSeek and GLM are best reached through OpenCode or an HTTP agent
234
+ adapter first; CW still imports no model SDK.
235
+
222
236
  ## Compatibility
223
237
 
224
238
  Agent Delegation Drive comes in first in CW v0.1.38. Adding the `agent` row leaves
@@ -291,3 +305,11 @@ No other change to this page in v0.1.84.
291
305
  0.1.85
292
306
 
293
307
  0.1.86
308
+
309
+ ## 0.1.87 (v0.1.87)
310
+
311
+ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-style CLI UX (colors/did-you-mean/categorized help/error tips/cw info/cw search/cw man/doctor --fix), post-success summaries, agent execution timing
312
+
313
+ ## 0.1.88 (v0.1.88)
314
+
315
+ Orchestration-parity for the agent drive: `run --drive --incremental` step-level resume (unchanged-input tasks replay from a content-addressed cache, zero re-spawns), inline `subWorkflow()` nesting (a task runs a child app and binds its verified report back, bounded depth + cycle guard, no telemetry fabricated), bounded dynamic `loop()` phases that expand at runtime under a static replay-stable cap, and a `claude -p` wrapper now on the canonical result contract.
@@ -2,69 +2,92 @@
2
2
 
3
3
  ## Name
4
4
 
5
- `capability-dispatcher`, `registerCapabilityHandler`, `registerTopology` — open registries for agent-driven CW extension
5
+ `capability-registry`, `registerTopology` — the declared capability contract and the open topology registry for agent-driven CW extension
6
6
 
7
7
  ## Description
8
8
 
9
- v0.1.53 adds two open registries. They let agents grow CW at runtime
10
- with no need to wire things by hand in many files. New capabilities put
11
- themselves in the registry and then work by themselves across CLI, MCP, and Workbench. New topologies put
12
- themselves in the registry too and then come up by themselves in `topology list`, `topology validate`, and `topology apply`.
13
-
14
- BSD way: keep **mechanism** (Map / pipe) apart from **policy** (entries).
15
- Fail-closed on unknown ids.
9
+ CW keeps two layers here. The **capability registry**
10
+ (`src/capability-registry.ts`) is the one declared source of truth for every
11
+ capability CW exposes; it is read at build/check time, not written to at
12
+ runtime. The **topology registry** (`src/topology.ts`) is an open runtime
13
+ registry: new topologies put themselves in it with `registerTopology()` and
14
+ then come up by themselves in `topology list`, `topology validate`, and
15
+ `topology apply`.
16
+
17
+ > History: v0.1.53 also shipped a dynamic *capability* dispatcher
18
+ > (`capability-dispatcher.ts`, `registerCapabilityHandler`,
19
+ > `dispatchCapability`, `resolveCliPath`, `resolveMcpTool`) meant to let
20
+ > capabilities register handlers and be routed at runtime. It had zero call
21
+ > sites — the handler map was always empty and every dispatch path was
22
+ > unreachable — so it was removed as dead code in v0.1.81 (#131). Capabilities
23
+ > are **declared**, not runtime-registered; see below. (The unrelated
24
+ > `cw dispatch` command, which writes a subagent dispatch manifest, is a normal
25
+ > declared capability, not that dispatcher.)
26
+
27
+ BSD way: keep **mechanism** (the registry / the open Map) apart from **policy**
28
+ (the entries). Fail-closed on unknown ids.
16
29
 
17
30
  ## Capability Registry
18
31
 
19
- ### Interface
32
+ The capability registry, `src/capability-registry.ts`, is the SINGLE declared
33
+ source of truth for every capability CW exposes — and the contract both front
34
+ doors (CLI and MCP) are checked against. It is a static, read-only array of
35
+ descriptors, `CAPABILITY_REGISTRY`. There is no runtime "register a handler"
36
+ seam and no dynamic dispatcher: capabilities are data here, not code that wires
37
+ itself in.
20
38
 
21
- ```typescript
22
- interface CapabilityHandler {
23
- descriptor: CapabilityDescriptor;
24
- run(args: Record<string, unknown>, ctx: CapabilityContext): unknown | Promise<unknown>;
25
- }
39
+ ### Descriptor
26
40
 
27
- interface CapabilityContext {
28
- runner: CoolWorkflowRunner;
29
- cwd: string;
41
+ ```typescript
42
+ interface CapabilityDescriptor {
43
+ capability: string; // canonical dot-namespaced id, e.g. "worker.list"
44
+ summary: string; // one-line description
45
+ entry: string; // the ONE shared core entry both surfaces route through
46
+ surface: "both" | "cli-only" | "mcp-only";
47
+ cli?: { path: string[]; jsonMode: "default" | "flag" | "human" };
48
+ mcp?: { tool: string; requiredArgs?: string[] };
49
+ payloadIdentical?: boolean; // CLI --json === MCP result (default true for "both")
50
+ reason?: string; // required when surface !== "both" or payloadIdentical === false
30
51
  }
31
52
  ```
32
53
 
33
- ### Registering a new capability
54
+ ### Adding a capability
55
+
56
+ A capability is declared once, as data, in `CAPABILITY_REGISTRY`, against one
57
+ shared core `entry`:
34
58
 
35
59
  ```typescript
36
- import { registerCapabilityHandler } from "./capability-registry";
37
-
38
- registerCapabilityHandler({
39
- descriptor: {
40
- capability: "my.new.tool", // canonical dot-namespaced id
41
- summary: "Does something useful.", // one-line description
42
- entry: "myNewTool", // core entry name
43
- surface: "both", // "both" | "cli-only" | "mcp-only"
44
- cli: { path: ["my", "new-tool"], jsonMode: "default" },
45
- mcp: { tool: "cw_my_new_tool" }
46
- },
47
- run: async (args, ctx) => {
48
- // ctx.runner exposes all orchestrator methods
49
- return ctx.runner.listWorkflows();
50
- }
51
- });
60
+ {
61
+ capability: "my.new.tool",
62
+ summary: "Does something useful.",
63
+ entry: "myNewTool",
64
+ surface: "both",
65
+ cli: { path: ["my", "new-tool"], jsonMode: "default" },
66
+ mcp: { tool: "cw_my_new_tool" }
67
+ }
52
68
  ```
53
69
 
70
+ `cli.ts` and `mcp-server.ts` each route their surface to the shared `entry` with
71
+ their own explicit `switch` routing (plus a few `if (action === …)` branches).
72
+ The registry is what those switches are validated against, not something they
73
+ call into — there is no fallback dynamic dispatch.
74
+
54
75
  ### How it works
55
76
 
56
- 1. `registerCapabilityHandler()` keeps the handler in a `Map<string, CapabilityHandler>`
57
- 2. CLI: `resolveCliPath(["my", "new-tool"])` turns the CLI path into the capability id
58
- 3. MCP: `resolveMcpTool("cw_my_new_tool")` turns the tool name into the capability id
59
- 4. `dispatchCapability(id, args, ctx)` calls `handler.run(args, ctx)`
60
- 5. Both the CLI and MCP surfaces drop through to the dynamic dispatcher when
61
- their hardcoded switch statements do not match an unknown command/tool
77
+ 1. `CAPABILITY_REGISTRY` declares every capability once one row per capability.
78
+ 2. CLI: `cli.ts` matches the command in its explicit `switch` and calls the core `entry`.
79
+ 3. MCP: `mcp-server.ts` matches the tool name in its explicit `switch` and calls the same `entry`.
80
+ 4. The parity gate (`scripts/parity-check.js --check`) fails closed on any drift:
81
+ a CLI command or MCP tool live on one surface but absent on the other or
82
+ undeclared, an undeclared payload divergence, or a surface-specific capability
83
+ with no recorded `reason`.
62
84
 
63
- ### Existing capabilities
85
+ ### How many capabilities
64
86
 
65
- All 182 capabilities that are there now keep working through their hardcoded switch
66
- cases in `cli.ts` and `mcp-server.ts`. The dynamic dispatch is a **fallback**
67
- it turns on only for commands/tools not found in the old switches.
87
+ Run `node plugins/cool-workflow/scripts/parity-check.js --check` and read
88
+ `registrySize` for the live count (199 at the time of writing). The full
89
+ CLI <-> MCP matrix lives in `docs/cli-mcp-parity.7.md`, which is generated from
90
+ the same registry.
68
91
 
69
92
  ## Topology Registry
70
93
 
@@ -160,8 +183,8 @@ expansion. Now it reads `role.count` on each role spec:
160
183
 
161
184
  ## See Also
162
185
 
163
- - `capability-registry.ts` — the one true source for all capabilities
164
- - `capability-dispatcher.ts` — the thin Map-based dispatch pipe
186
+ - `capability-registry.ts` — the one declared source for all capabilities
187
+ - `capability-core.ts` — the shared core entries both surfaces route through
165
188
  - `topology.ts` — topology definitions and the registry
166
189
  - `types/topology.ts` — topology type definitions
167
190
  - `docs/cli-mcp-parity.7.md` — CLI <-> MCP parity gate
@@ -82,14 +82,20 @@ relationship. `identical` means `cw <cmd> --json` is equal to the `cw_<tool>`
82
82
  payload; `projected` means a declared divergence with a reason; `cli-only` marks
83
83
  a surface-specific capability with a recorded reason. The matrix is
84
84
  <!-- gen:parity:count -->
85
- machine-complete by design: 193 capabilities, 186 MCP tools.
85
+ machine-complete by design: 199 capabilities, 186 MCP tools.
86
86
  <!-- /gen:parity:count -->
87
87
 
88
88
  <!-- gen:parity:table -->
89
89
  | Capability | CLI command | MCP tool | Core entry | Surface | Payload |
90
90
  | --- | --- | --- | --- | --- | --- |
91
91
  | `help` | `cw help` | `—` | `formatHelp` | cli-only | cli-only |
92
+ | `version` | `cw version` | `—` | `CURRENT_COOL_WORKFLOW_VERSION` | cli-only | cli-only |
93
+ | `update` | `cw update` | `—` | `npmUpdate` | cli-only | cli-only |
94
+ | `fix` | `cw fix` | `—` | `runDoctor` | cli-only | cli-only |
92
95
  | `list` | `cw list` | `cw_list` | `listWorkflows` | both | identical |
96
+ | `info` | `cw info` | `—` | `showApp` | cli-only | cli-only |
97
+ | `search` | `cw search` | `—` | `listApps` | cli-only | cli-only |
98
+ | `man` | `cw man` | `—` | `n/a` | cli-only | cli-only |
93
99
  | `doctor` | `cw doctor` | `—` | `runDoctor` | cli-only | cli-only |
94
100
  | `init` | `cw init` | `cw_init` | `init` | both | identical |
95
101
  | `plan` | `cw plan` | `cw_plan` | `planSummary` | both | identical |
@@ -295,9 +301,15 @@ A capability may be on one surface only, but never without word of it — it mus
295
301
  carry a recorded reason in the registry.
296
302
 
297
303
  <!-- gen:parity:cliOnly -->
298
- Seven capabilities are CLI-only:
304
+ 13 capabilities are CLI-only:
299
305
 
300
306
  - `help` — Human help text. MCP hosts enumerate capabilities via tools/list, not a help command.
307
+ - `version` — Version string — no structured data contract.
308
+ - `update` — Self-update via npm — inherently local shell operation, no MCP surface.
309
+ - `fix` — Environment fix commands are local diagnostics, same reasoning as doctor.
310
+ - `info` — Human-focused workflow discovery tool (like Homebrew's `brew info`). MCP agents discover workflows via cw_list and cw_app_show tools.
311
+ - `search` — Human-focused workflow discovery (like Homebrew's `brew search`). MCP agents discover workflows via cw_list tool.
312
+ - `man` — Human documentation viewer. MCP agents read docs/ directly via file tools.
301
313
  - `doctor` — Environment diagnostics are inherently local to the CLI host — Node version, $PATH, $CW_HOME/cwd writability. An MCP client diagnosing the server process's environment is not meaningful; agents already receive the same readiness facts in their typed results (e.g. status: blocked, agentConfigured). Inspired by `brew doctor`.
302
314
  - `loop` — Convenience alias of `schedule create` with kind=loop. MCP hosts use cw_schedule_create with kind=loop.
303
315
  - `schedule daemon` — Long-running desktop daemon process, not a request/response tool. MCP hosts drive ticks via cw_schedule_due + cw_schedule_run_now.
@@ -497,3 +509,11 @@ No other change to this page in v0.1.84.
497
509
  0.1.85
498
510
 
499
511
  0.1.86
512
+
513
+ ## 0.1.87 (v0.1.87)
514
+
515
+ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-style CLI UX (colors/did-you-mean/categorized help/error tips/cw info/cw search/cw man/doctor --fix), post-success summaries, agent execution timing
516
+
517
+ ## 0.1.88 (v0.1.88)
518
+
519
+ CLI surface simplified to 6 commands with agent stderr streaming on by default and vendor agent flags; the drive gains a `--incremental` flag (added to DRIVE_RUNTIME_KEYS so it never poisons run.inputs or the cache key). MCP tools stay the derived mirror of the same capabilities.
@@ -145,3 +145,11 @@ No other change to this page in v0.1.84.
145
145
  0.1.85
146
146
 
147
147
  0.1.86
148
+
149
+ ## 0.1.87 (v0.1.87)
150
+
151
+ npm test parallel, 4-vendor wrappers (Claude/Codex/Gemini/OpenCode), Homebrew-style CLI UX (colors/did-you-mean/categorized help/error tips/cw info/cw search/cw man/doctor --fix), post-success summaries, agent execution timing
152
+
153
+ ## 0.1.88 (v0.1.88)
154
+
155
+ _No behavioral change in v0.1.88 (no schema-migration edge was added; the incremental result cache uses a self-contained schemaVersion:2 key that never collides with the opt-in v1 cache and is not a run-state or app-schema migration)._