cool-workflow 0.1.87 → 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 (73) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +107 -71
  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 +24 -0
  13. package/dist/cli/command-surface.js +102 -1
  14. package/dist/doctor.js +14 -1
  15. package/dist/drive.js +222 -16
  16. package/dist/execution-backend.js +4 -4
  17. package/dist/loop-expansion.js +60 -0
  18. package/dist/onramp.js +25 -0
  19. package/dist/orchestrator/lifecycle-operations.js +134 -3
  20. package/dist/orchestrator.js +24 -76
  21. package/dist/run-export.js +106 -2
  22. package/dist/state-node.js +13 -3
  23. package/dist/state.js +21 -0
  24. package/dist/telemetry-attestation.js +30 -6
  25. package/dist/telemetry-demo.js +29 -1
  26. package/dist/telemetry-ledger.js +6 -0
  27. package/dist/version.js +1 -1
  28. package/dist/worker-accept/telemetry-ledger.js +12 -2
  29. package/dist/workflow-api.js +33 -0
  30. package/dist/workflow-app-framework.js +20 -0
  31. package/docs/agent-delegation-drive.7.md +4 -0
  32. package/docs/capability-topology-registry.7.md +69 -46
  33. package/docs/cli-mcp-parity.7.md +12 -2
  34. package/docs/contract-migration-tooling.7.md +4 -0
  35. package/docs/control-plane-scheduling.7.md +4 -0
  36. package/docs/durable-state-and-locking.7.md +4 -0
  37. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  38. package/docs/execution-backends.7.md +4 -0
  39. package/docs/launch/launch-kit.md +9 -9
  40. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  41. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  42. package/docs/multi-agent-operator-ux.7.md +4 -0
  43. package/docs/node-snapshot-diff-replay.7.md +4 -0
  44. package/docs/observability-cost-accounting.7.md +4 -0
  45. package/docs/project-index.md +20 -5
  46. package/docs/readme-v0.1.87-full.md +301 -0
  47. package/docs/real-execution-backends.7.md +4 -0
  48. package/docs/release-and-migration.7.md +4 -0
  49. package/docs/release-tooling.7.md +4 -0
  50. package/docs/report-verifiable-bundle.7.md +34 -2
  51. package/docs/run-registry-control-plane.7.md +14 -0
  52. package/docs/run-retention-reclamation.7.md +4 -0
  53. package/docs/state-explosion-management.7.md +4 -0
  54. package/docs/team-collaboration.7.md +4 -0
  55. package/docs/trust-model.md +6 -4
  56. package/docs/web-desktop-workbench.7.md +4 -0
  57. package/manifest/plugin.manifest.json +1 -1
  58. package/manifest/source-context-profiles.json +1 -1
  59. package/package.json +8 -5
  60. package/scripts/agents/agent-adapter-core.js +1 -1
  61. package/scripts/agents/builtin-templates.json +2 -1
  62. package/scripts/agents/claude-p-agent.js +7 -33
  63. package/scripts/agents/codex-agent.js +1 -1
  64. package/scripts/agents/cw-attest-wrap.js +9 -1
  65. package/scripts/agents/gemini-agent.js +1 -1
  66. package/scripts/agents/opencode-agent.js +1 -1
  67. package/scripts/canonical-apps.js +4 -4
  68. package/scripts/coverage-gate.js +15 -1
  69. package/scripts/cw.js +0 -0
  70. package/scripts/dogfood-release.js +1 -1
  71. package/scripts/golden-path.js +4 -4
  72. package/scripts/release-flow.js +49 -2
  73. package/tsconfig.json +3 -1
package/dist/doctor.js CHANGED
@@ -91,7 +91,15 @@ function runDoctor(args = {}, env = process.env, cwd = process.cwd()) {
91
91
  : { name: "node", status: "fail", detail: `Node ${process.version} is below the required v18.`, fix: "Install Node.js 18+ (e.g. `brew install node`, or https://nodejs.org)." });
92
92
  // 2. Agent backend — CW delegates execution; without one, real runs park.
93
93
  const cfg = (0, agent_config_1.resolveAgentConfig)(args, env);
94
- if (cfg.source === "none") {
94
+ if (cfg.source === "auto") {
95
+ const vendor = (cfg.model && cfg.model.startsWith("builtin:")) ? cfg.model.slice("builtin:".length) : "auto";
96
+ checks.push({
97
+ name: "agent",
98
+ status: "ok",
99
+ detail: `Agent auto-detected: ${vendor}. Set CW_AGENT_COMMAND or --agent-command to override.`
100
+ });
101
+ }
102
+ else if (cfg.source === "none") {
95
103
  checks.push({
96
104
  name: "agent",
97
105
  status: "warn",
@@ -161,6 +169,11 @@ function formatDoctorReport(report) {
161
169
  const summaryGlyph = report.ok ? (0, term_1.green)("✓") : (0, term_1.red)("✗");
162
170
  lines.push(`${summaryGlyph} ${report.summary}`);
163
171
  if (report.onramp) {
172
+ lines.push("");
173
+ lines.push("Quick start (3 steps):");
174
+ lines.push(" 1. cw demo tamper — prove trust checks work (30s)");
175
+ lines.push(" 2. cw demo bundle — prove portable bundles (30s)");
176
+ lines.push(' 3. cw -q "what risks?" — your first real report (needs an agent)');
164
177
  lines.push("");
165
178
  lines.push("Onramp");
166
179
  lines.push(` ${report.onramp.summary}`);
package/dist/drive.js CHANGED
@@ -25,7 +25,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
25
25
  return (mod && mod.__esModule) ? mod : { "default": mod };
26
26
  };
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
- exports.DRIVE_SCHEMA_VERSION = void 0;
28
+ exports.MAX_SUB_WORKFLOW_DEPTH = exports.DRIVE_SCHEMA_VERSION = void 0;
29
29
  exports.driveStep = driveStep;
30
30
  exports.driveConcurrentRound = driveConcurrentRound;
31
31
  exports.drive = drive;
@@ -38,9 +38,14 @@ const worker_isolation_1 = require("./worker-isolation");
38
38
  const agent_config_1 = require("./agent-config");
39
39
  const scheduling_1 = require("./scheduling");
40
40
  const observability_1 = require("./observability");
41
+ const loop_expansion_1 = require("./loop-expansion");
42
+ const telemetry_attestation_1 = require("./telemetry-attestation");
41
43
  const state_1 = require("./state");
44
+ const trust_audit_1 = require("./trust-audit");
42
45
  const compare_1 = require("./compare");
43
46
  exports.DRIVE_SCHEMA_VERSION = 1;
47
+ /** Hard cap on inline sub-workflow nesting depth (fail-closed bound on recursion). */
48
+ exports.MAX_SUB_WORKFLOW_DEPTH = 4;
44
49
  /** The task the next drive step would advance: a RUNNING (already-dispatched,
45
50
  * awaiting fulfillment / retry) task first, else the next PENDING task in the
46
51
  * first runnable phase. Deterministic; honors the existing phase gate. */
@@ -71,10 +76,12 @@ function exitCodeFromEvidence(evidence) {
71
76
  function agentConfigured(config) {
72
77
  return Boolean(config.command || config.endpoint);
73
78
  }
74
- /** Opt-in progress to STDERR (stdout stays clean JSON), gated on CW_DRIVE_PROGRESS,
75
- * so a live multi-minute drive is observable. */
79
+ /** Progress to STDERR (stdout stays clean JSON). On by default when stderr is a
80
+ * TTY; silent in CI/pipes. CW_DRIVE_PROGRESS=0 forces off, =1 forces on. */
76
81
  function emitProgress(message) {
77
- if (process.env.CW_DRIVE_PROGRESS)
82
+ const forcedOff = process.env.CW_DRIVE_PROGRESS === "0";
83
+ const forcedOn = process.env.CW_DRIVE_PROGRESS === "1";
84
+ if ((Boolean(process.stderr.isTTY) && !forcedOff) || forcedOn)
78
85
  process.stderr.write(`[drive] ${message}\n`);
79
86
  }
80
87
  /** Advance exactly ONE deterministic step. Pure-ish: all mutation is through the
@@ -202,7 +209,7 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
202
209
  // immediate activity instead of a long silence on the first worker. task.label
203
210
  // is the human-facing display name; the id stays the stable reference.
204
211
  const promptDigest = node_fs_1.default.existsSync(manifest.inputPath) ? (0, execution_backend_1.sha256)(node_fs_1.default.readFileSync(manifest.inputPath, "utf8")) : (0, execution_backend_1.sha256)(manifest.prompt || "");
205
- const cachePath = resultCachePath(run, selected, (0, execution_backend_1.sha256)(selected.prompt));
212
+ const cachePath = resultCachePath(run, selected, (0, execution_backend_1.sha256)(selected.prompt), ctx.incremental, ctx.incremental ? incrementalDelegationDigest(selected, manifest, ctx.config) : "");
206
213
  if (cachePath && node_fs_1.default.existsSync(cachePath)) {
207
214
  emitProgress(`↺ ${selected.label || selected.id} (${selected.phase}) — accepting cached result`);
208
215
  try {
@@ -220,6 +227,12 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
220
227
  reason: "result cache hit"
221
228
  });
222
229
  }
230
+ // Sub-workflow fulfillment (alternative to the agent backend): plan + drive a
231
+ // CHILD run and bind its report back as this task's result. Leaf work is still
232
+ // external-agent delegation at every level; CW imports no model SDK here.
233
+ if (selected.subWorkflow) {
234
+ return runSubWorkflow(ctx, run, selected, workerId, manifest);
235
+ }
223
236
  emitProgress(`→ ${selected.label || selected.id} (${selected.phase}) — ${dispatched ? "dispatched, " : ""}spawning agent, may take minutes…`);
224
237
  const envelope = (0, execution_backend_1.runBackend)(buildAgentRequest(ctx, run, selected, manifest, preparedOutcome));
225
238
  const handle = envelope.provenance.handle;
@@ -269,7 +282,63 @@ function processSelectedTask(ctx, selected, preparedOutcome) {
269
282
  reportedModel
270
283
  });
271
284
  }
272
- function resultCachePath(run, task, promptDigest) {
285
+ function cacheFilePath(run, task, digest) {
286
+ return node_path_1.default.join(run.cwd, ".cw", "cache", "worker-results", (0, state_1.safeFileName)(run.workflow.id), `${(0, state_1.safeFileName)(task.id)}-${digest.replace(/^sha256:/, "").slice(0, 32)}.md`);
287
+ }
288
+ /** Digest of the per-task DELEGATION config that determines a result but is NOT
289
+ * carried by the prompt or run.inputs: the resolved model (task override OR the
290
+ * global agent-config model), the agent IDENTITY (which binary/endpoint actually
291
+ * produces the bytes — `command`/`args`/`endpoint`), the backend driver, and the
292
+ * resolved sandbox PROFILE ID. All of these are operator flags/env (`--agent-model`,
293
+ * `--agent-command`, `--agent-endpoint`, ...) stripped from run.inputs by
294
+ * DRIVE_RUNTIME_KEYS, so they must be folded here or swapping the model/agent/
295
+ * endpoint would serve a stale result (and attest the wrong producer). The sandbox
296
+ * PROFILE ID (not the full resolved policy) is used because the policy's read/write
297
+ * paths embed the per-run worker dir, so the full policy is NOT stable across runs
298
+ * (it would defeat all reuse); the id is stable and changes on a profile swap. Args
299
+ * are secret-stripped (no credential lands in the digest input). `config.args` is
300
+ * the un-substituted template (e.g. `{{result}}`), so it is stable across runs.
301
+ * Deterministic: stableStringify, no clock/random. */
302
+ function incrementalDelegationDigest(task, manifest, config) {
303
+ return (0, execution_backend_1.sha256)((0, telemetry_attestation_1.stableStringify)({
304
+ model: task.model || config.model || "",
305
+ agentType: task.agentType || "agent",
306
+ sandboxProfileId: manifest.sandboxPolicy?.id || task.sandboxProfileId || "",
307
+ command: config.command || "",
308
+ args: config.args ? (0, execution_backend_1.stripSecretArgs)(config.args) : [],
309
+ endpoint: config.endpoint || ""
310
+ }));
311
+ }
312
+ function resultCachePath(run, task, promptDigest, incremental, delegationDigest) {
313
+ // Incremental resume (opt-in, run-level): EVERY task is keyed by content so a
314
+ // re-run reuses the longest unchanged prefix. The key folds the rendered prompt,
315
+ // the full run.inputs, the per-task DELEGATION config (model/backend/sandbox —
316
+ // result-determining but NOT carried by prompt/inputs, so editing the model must
317
+ // invalidate), and the UPSTREAM RESULT digests (not just prompts: a CW prompt is
318
+ // rendered from run.inputs and does NOT carry an upstream task's result bytes, so
319
+ // a changed/nondeterministic upstream result must invalidate downstream — which
320
+ // keying on upstream result bytes does). A changed prompt/input/model perturbs
321
+ // that task's key, and its changed result perturbs every downstream key, so the
322
+ // "first changed task and everything after" re-run falls out for free. The phase
323
+ // barrier guarantees every upstream task is `completed` before this task runs, so
324
+ // its result bytes are available to digest. schemaVersion:2 never collides with
325
+ // the opt-in schemaVersion:1 cache below.
326
+ if (incremental) {
327
+ const upstreamResultsDigest = previousPhaseResultsDigest(run, task);
328
+ if (upstreamResultsDigest === undefined)
329
+ return undefined;
330
+ const digest = (0, execution_backend_1.sha256)((0, telemetry_attestation_1.stableStringify)({
331
+ schemaVersion: 2,
332
+ workflowId: run.workflow.id,
333
+ taskId: task.id,
334
+ promptDigest,
335
+ runInputsDigest: (0, execution_backend_1.sha256)((0, telemetry_attestation_1.stableStringify)(run.inputs || {})),
336
+ delegationDigest,
337
+ upstreamResultsDigest
338
+ }));
339
+ return cacheFilePath(run, task, digest);
340
+ }
341
+ // Default: the per-task OPT-IN cache (unchanged — POLA).
273
342
  const policy = task.resultCache;
274
343
  if (!policy || policy.mode !== "read-write")
275
344
  return undefined;
@@ -288,12 +357,13 @@ function resultCachePath(run, task, promptDigest) {
288
357
  keyValue,
289
358
  promptDigest,
290
359
  completedResultsDigest
291
- })).replace(/^sha256:/, "");
292
- return node_path_1.default.join(run.cwd, ".cw", "cache", "worker-results", (0, state_1.safeFileName)(run.workflow.id), `${(0, state_1.safeFileName)(task.id)}-${digest.slice(0, 32)}.md`);
360
+ }));
361
+ return cacheFilePath(run, task, digest);
293
362
  }
294
- function completedResultsCacheDigest(run, task) {
295
- if (task.resultCache?.includeCompletedResults !== "previous-phases")
296
- return "";
363
+ /** Digest of the result bytes of every task in strictly-earlier phases (deterministic
364
+ * id order). `undefined` when any such task is not yet completed/readable — so a
365
+ * caller never keys on partial upstream state. */
366
+ function previousPhaseResultsDigest(run, task) {
297
367
  const phaseIndex = run.phases.findIndex((phase) => phase.name === task.phase || phase.id === task.phase);
298
368
  if (phaseIndex < 0)
299
369
  return undefined;
@@ -310,6 +380,11 @@ function completedResultsCacheDigest(run, task) {
310
380
  return undefined;
311
381
  return (0, execution_backend_1.sha256)(JSON.stringify(records));
312
382
  }
383
+ function completedResultsCacheDigest(run, task) {
384
+ if (task.resultCache?.includeCompletedResults !== "previous-phases")
385
+ return "";
386
+ return previousPhaseResultsDigest(run, task);
387
+ }
313
388
  function writeResultCache(file, content) {
314
389
  node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
315
390
  const tmp = `${file}.${process.pid}.tmp`;
@@ -393,7 +468,7 @@ function prepareConcurrentOutcomes(ctx, batch) {
393
468
  continue;
394
469
  }
395
470
  const manifest = runner.showWorkerManifest(runId, workerId);
396
- const cachePath = resultCachePath(run, task, (0, execution_backend_1.sha256)(task.prompt));
471
+ const cachePath = resultCachePath(run, task, (0, execution_backend_1.sha256)(task.prompt), ctx.incremental, ctx.incremental ? incrementalDelegationDigest(task, manifest, ctx.config) : "");
397
472
  if (cachePath && node_fs_1.default.existsSync(cachePath))
398
473
  continue;
399
474
  const job = (0, execution_backend_1.prepareAgentSpawn)(buildAgentRequest(ctx, run, task, manifest));
@@ -458,19 +533,150 @@ function handleHop(ctx, task, workerId, reason) {
458
533
  function step(action, status, fields) {
459
534
  return { schemaVersion: 1, action, status, ...fields };
460
535
  }
536
+ function errMessage(error) {
537
+ return error instanceof Error ? error.message : String(error);
538
+ }
539
+ /** Render a sub-workflow's input templates against the PARENT run's inputs, so the
540
+ * child inputs are a pure function of recorded parent inputs (deterministic). */
541
+ function renderSubInputs(spec, parentInputs) {
542
+ const out = {};
543
+ for (const [key, template] of Object.entries(spec.inputs || {})) {
544
+ out[key] = String(template).replace(/\{\{(\w+)\}\}/g, (_, name) => String(parentInputs[name] ?? ""));
545
+ }
546
+ return out;
547
+ }
548
+ /** Fulfill a sub-workflow task: plan + drive a CHILD run, then bind its report (or
549
+ * verdict result) back as the parent task's result through the SAME accept path, so
550
+ * the parent's verifier/schema/evidence gate and downstream tasks treat it like any
551
+ * other result. Fail-closed: bounded recursion + cycle detection BEFORE any child
552
+ * state is minted; a child that does not complete parks the parent hop. The child's
553
+ * own telemetry/audit live in the child run; the parent records ONE honest
554
+ * `worker.sub-workflow` cross-link (child run id + report digest + child audit
555
+ * verdict) — nothing is summed or fabricated. */
556
+ function runSubWorkflow(ctx, run, selected, workerId, manifest) {
557
+ const spec = selected.subWorkflow;
558
+ const parentApp = run.workflow.id;
559
+ // Fail-closed BEFORE planning a child (no child state minted when refused).
560
+ if (ctx.depth + 1 > exports.MAX_SUB_WORKFLOW_DEPTH) {
561
+ return handleHop(ctx, selected, workerId, `sub-workflow depth limit exceeded (> ${exports.MAX_SUB_WORKFLOW_DEPTH})`);
562
+ }
563
+ // Include the CURRENT app on the path, so a direct self-cycle (A→A) is caught at
564
+ // depth 0 — before any child run dir is minted.
565
+ if ([...ctx.visitedAppIds, parentApp].includes(spec.appId)) {
566
+ return handleHop(ctx, selected, workerId, `sub-workflow cycle detected: ${[...ctx.visitedAppIds, parentApp, spec.appId].join(" -> ")}`);
567
+ }
568
+ // Deterministic child run id derived from the parent run + task (no clock/random).
569
+ const childRunId = `sub-${run.id}-${(0, state_1.safeFileName)(selected.id)}`;
570
+ const childInputs = {
571
+ repo: run.inputs.repo ?? run.cwd,
572
+ cwd: run.cwd,
573
+ question: run.inputs.question ?? "",
574
+ ...renderSubInputs(spec, run.inputs),
575
+ runId: childRunId
576
+ };
577
+ emitProgress(`⧉ ${selected.label || selected.id} (${selected.phase}) — sub-workflow ${spec.appId}…`);
578
+ let childRun;
579
+ try {
580
+ childRun = ctx.runner.plan(spec.appId, childInputs);
581
+ }
582
+ catch (error) {
583
+ return handleHop(ctx, selected, workerId, `sub-workflow plan failed (${spec.appId}): ${errMessage(error)}`);
584
+ }
585
+ const childResult = drive(ctx.runner, childRun.id, {
586
+ now: ctx.now,
587
+ agentConfig: ctx.config,
588
+ policy: ctx.policy,
589
+ incremental: ctx.incremental,
590
+ depth: ctx.depth + 1,
591
+ visitedAppIds: [...ctx.visitedAppIds, parentApp]
592
+ });
593
+ if (childResult.status !== "complete") {
594
+ return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} did not complete (status: ${childResult.status})`);
595
+ }
596
+ // Bind the child's bytes: the rendered report (default) or the verdict result.
597
+ const finalChild = ctx.runner.loadRun(childRun.id);
598
+ let childBytes;
599
+ if (spec.bindResult === "verdict-result") {
600
+ const verdict = finalChild.tasks.find((t) => /^verdict[:/]|^synthesis[:/]/i.test(t.id) && t.status === "completed");
601
+ childBytes = verdict?.resultPath && node_fs_1.default.existsSync(verdict.resultPath) ? node_fs_1.default.readFileSync(verdict.resultPath, "utf8") : undefined;
602
+ }
603
+ else {
604
+ childBytes = node_fs_1.default.existsSync(finalChild.paths.report) ? node_fs_1.default.readFileSync(finalChild.paths.report, "utf8") : undefined;
605
+ }
606
+ if (childBytes === undefined) {
607
+ return handleHop(ctx, selected, workerId, `sub-workflow ${spec.appId} produced no ${spec.bindResult || "report"}`);
608
+ }
609
+ // Accept through the SAME path as any other result (verifier/schema/evidence gate).
610
+ try {
611
+ node_fs_1.default.writeFileSync(manifest.resultPath, childBytes, "utf8");
612
+ ctx.runner.recordWorkerOutput(run.id, workerId, manifest.resultPath, {});
613
+ }
614
+ catch (error) {
615
+ return handleHop(ctx, selected, workerId, `sub-workflow result rejected by parent gate: ${errMessage(error)}`);
616
+ }
617
+ // Honest cross-link (provenance only — never fails the accepted hop): one
618
+ // worker.sub-workflow audit event on the parent pins the child run + report digest
619
+ // + the child's own audit-chain verdict, and the task points at the child run dir.
620
+ try {
621
+ const afterAccept = ctx.runner.loadRun(run.id);
622
+ const task = afterAccept.tasks.find((t) => t.id === selected.id);
623
+ const childAudit = (0, trust_audit_1.verifyTrustAudit)(finalChild);
624
+ (0, trust_audit_1.recordTrustAuditEvent)(afterAccept, {
625
+ kind: "worker.sub-workflow",
626
+ decision: "recorded",
627
+ source: "runtime-derived",
628
+ workerId,
629
+ taskId: selected.id,
630
+ nodeId: task?.resultNodeId,
631
+ metadata: {
632
+ subWorkflowAppId: spec.appId,
633
+ subRunId: childRun.id,
634
+ childReportDigest: (0, execution_backend_1.sha256)(childBytes),
635
+ childAuditVerified: childAudit.verified,
636
+ bindResult: spec.bindResult || "report"
637
+ }
638
+ });
639
+ if (task) {
640
+ task.subRunId = childRun.id;
641
+ task.subRunDir = finalChild.paths.runDir;
642
+ }
643
+ (0, state_1.saveCheckpoint)(afterAccept);
644
+ }
645
+ catch {
646
+ /* the cross-link is provenance; a failure here must not undo an accepted hop */
647
+ }
648
+ return step("accept", "ok", {
649
+ runId: run.id,
650
+ taskId: selected.id,
651
+ phase: selected.phase,
652
+ handleKind: "sub-workflow",
653
+ reason: `sub-workflow ${spec.appId} → ${childRun.id}`
654
+ });
655
+ }
461
656
  /** Drive a run: `--once` advances exactly one step; otherwise run to completion,
462
657
  * park, or a blocked stop. Composes the existing verbs + the agent backend only. */
463
658
  function drive(runner, runId, options = {}) {
464
659
  const now = options.now || new Date().toISOString();
465
660
  const policy = (0, scheduling_1.normalizeSchedulingPolicy)(options.policy || scheduling_1.DEFAULT_SCHEDULING_POLICY);
466
661
  const config = options.agentConfig || (0, agent_config_1.resolveAgentConfig)(options.args || {});
467
- const ctx = { runner, runId, now, policy, config, attempts: new Map() };
662
+ const ctx = {
663
+ runner, runId, now, policy, config, attempts: new Map(),
664
+ incremental: Boolean(options.incremental),
665
+ depth: Math.max(0, Math.floor(options.depth || 0)),
666
+ visitedAppIds: options.visitedAppIds || []
667
+ };
468
668
  const steps = [];
469
669
  const run0 = runner.loadRun(runId);
470
670
  const plannedWorkers = run0.tasks.length;
471
- // Safety bound: every worker, every retry, plus the terminal commit + slack.
472
- // Each concurrent round retires >=1 worker, so this bounds rounds too.
473
- const maxIterations = plannedWorkers * (policy.maxAttempts + 1) + 5;
671
+ // Safety bound: every worker, every retry, plus the terminal commit + slack. Each
672
+ // concurrent round retires >=1 worker, so this bounds rounds too. A bounded dynamic
673
+ // loop can append up to (maxRounds-1)×templateTasks MORE tasks at runtime, so the
674
+ // iteration bound (NOT plannedWorkers, which stays the initial count for status) adds
675
+ // the worst-case expansion derived STATICALLY from the declaration — a pure function
676
+ // of the workflow, never of runtime results — so the bound is replay-stable and the
677
+ // drive is provably terminating; it reduces to the original value when there are no
678
+ // loop phases.
679
+ const maxIterations = (plannedWorkers + (0, loop_expansion_1.maxLoopExpansion)(run0)) * (policy.maxAttempts + 1) + 5;
474
680
  const concurrency = Math.max(1, Math.floor(options.concurrency || 1));
475
681
  // The parallel() on-ramp: a phase authored with mode "parallel" is fulfilled
476
682
  // concurrently through EVERY shipping surface (run --drive, quickstart) — no
@@ -678,10 +678,10 @@ function runAgentProcess(descriptor, policy, request, label, handle, attestation
678
678
  outcome = request.preparedAgentOutcome;
679
679
  }
680
680
  else {
681
- // Live output is opt-in (POLA): stdout is always captured as data, while
682
- // stderr is forwarded only when the operator explicitly asks for a stream
683
- // and this process is attached to a terminal. CI/pipes stay silent.
684
- const streamStderr = process.env.CW_AGENT_STREAM === "1" && Boolean(process.stderr.isTTY) && process.env.CW_NO_STREAM !== "1";
681
+ // Live output on by default when stderr is a TTY. stdout is always
682
+ // captured as data. CI/pipes stay silent. CW_AGENT_STREAM=0 or
683
+ // CW_NO_STREAM=1 forces off; CW_AGENT_STREAM=1 forces on.
684
+ const streamStderr = process.env.CW_AGENT_STREAM !== "0" && Boolean(process.stderr.isTTY) && process.env.CW_NO_STREAM !== "1";
685
685
  const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
686
686
  cwd: request.cwd,
687
687
  env: { ...process.env },
@@ -0,0 +1,60 @@
1
+ "use strict";
2
+ // Bounded dynamic control flow (#2) — a declarative LOOP phase whose tasks are a
3
+ // per-round TEMPLATE: after each round completes, a registered PURE predicate decides
4
+ // whether to run another round (a fresh phase appended after this one, with the same
5
+ // tasks under round-suffixed ids) or stop. Hard-capped at `maxRounds`.
6
+ //
7
+ // REPLAY-DETERMINISM: the predicate is a pure function of RECORDED round results +
8
+ // recorded usage — no IO/clock/random in its context by construction. The engine
9
+ // RECORDS each decision as a `loop-control` state node, so a replay walks the recorded
10
+ // round sequence; re-running the predicate is a verification cross-check, not the
11
+ // source of truth. Predicates are NAMED (registry refs), never inline closures —
12
+ // closures cannot be serialized into state or re-evaluated byte-identically on replay.
13
+ //
14
+ // The materialization (cloning tasks, appending phases, recording the loop-control
15
+ // node) lives in the orchestrator (maybeExpandLoop), which owns writeTaskFiles + the
16
+ // plan pipeline; THIS module is just the pure registry + the STATIC expansion bound.
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.registerLoopPredicate = registerLoopPredicate;
19
+ exports.getLoopPredicate = getLoopPredicate;
20
+ exports.hasLoopPredicate = hasLoopPredicate;
21
+ exports.maxLoopExpansion = maxLoopExpansion;
22
+ const REGISTRY = new Map();
23
+ /** Register a named pure loop predicate (apps register theirs at load; CW ships a few
24
+ * built-ins below). Re-registering a name overwrites it. */
25
+ function registerLoopPredicate(name, fn) {
26
+ REGISTRY.set(name, fn);
27
+ }
28
+ function getLoopPredicate(name) {
29
+ return REGISTRY.get(name);
30
+ }
31
+ function hasLoopPredicate(name) {
32
+ return REGISTRY.has(name);
33
+ }
34
+ // ---- Built-in predicates ---------------------------------------------------------
35
+ /** Stop once a round produced no findings (a convergence loop: keep going while the
36
+ * agent still reports findings; stop when a round comes back empty). */
37
+ registerLoopPredicate("no-new-findings", (ctx) => {
38
+ const empty = ctx.roundResults.every((r) => !r || !Array.isArray(r.findings) || r.findings.length === 0);
39
+ return empty
40
+ ? { done: true, reason: "no-new-findings: the latest round produced no findings" }
41
+ : { done: false, reason: "no-new-findings: the latest round still has findings" };
42
+ });
43
+ /** Always stop after the first round (a degenerate loop ≈ a normal phase; useful as a
44
+ * default / for tests). */
45
+ registerLoopPredicate("single-round", () => ({ done: true, reason: "single-round: stop after one round" }));
46
+ /** Static worst-case number of EXTRA tasks a fully-expanded run could mint, derived
47
+ * purely from the workflow DECLARATION (every loop origin phase contributes
48
+ * (maxRounds-1) × its round-1 task count). Used to keep the drive's iteration bound
49
+ * safe as tasks grow, WITHOUT reading any runtime result — so the bound is itself
50
+ * replay-stable and the loop is provably bounded. Zero when there are no loop phases. */
51
+ function maxLoopExpansion(run) {
52
+ let extra = 0;
53
+ for (const phase of run.phases) {
54
+ if (phase.loop && typeof phase.loop.maxRounds === "number" && phase.loop.maxRounds > 1) {
55
+ const templateTaskCount = phase.taskIds.length;
56
+ extra += (phase.loop.maxRounds - 1) * templateTaskCount;
57
+ }
58
+ }
59
+ return extra;
60
+ }
package/dist/onramp.js CHANGED
@@ -131,6 +131,12 @@ function buildDoctorOnramp(options = {}) {
131
131
  command: "cw demo tamper",
132
132
  reason: "Shows the core trust check without an agent or a repo."
133
133
  },
134
+ {
135
+ id: "demo-bundle",
136
+ title: "Prove portable checks",
137
+ command: "cw demo bundle",
138
+ reason: "Shows the bundle proof in one step — create, check, forge, detect. No agent needed."
139
+ },
134
140
  {
135
141
  id: "setup",
136
142
  title: "Check setup",
@@ -163,6 +169,25 @@ function buildDoctorOnramp(options = {}) {
163
169
  }
164
170
  ]
165
171
  },
172
+ {
173
+ id: "no-agent",
174
+ title: "No Agent?",
175
+ summary: "The demo steps above need no agent. A real report needs one. Pick and install one agent before the next step.",
176
+ actions: [
177
+ {
178
+ id: "agent-claude",
179
+ title: "Claude Code (npm)",
180
+ command: "npm install -g @anthropic-ai/claude-code",
181
+ reason: "First-party Claude Code tool; CW works with Claude Code, Codex, Gemini CLI, and OpenCode."
182
+ },
183
+ {
184
+ id: "agent-check",
185
+ title: "Check agent setup",
186
+ command: "cw doctor",
187
+ reason: "Doctor names missing agent or setup trouble and tells you what to fix."
188
+ }
189
+ ]
190
+ },
166
191
  {
167
192
  id: "change-loop",
168
193
  title: "Change Loop",
@@ -27,6 +27,8 @@ const harness_1 = require("../harness");
27
27
  const workflow_app_framework_1 = require("../workflow-app-framework");
28
28
  const workflow_api_1 = require("../workflow-api");
29
29
  const observability_1 = require("../observability");
30
+ const compare_1 = require("../compare");
31
+ const loop_expansion_1 = require("../loop-expansion");
30
32
  const dispatch_1 = require("../dispatch");
31
33
  const verifier_1 = require("../verifier");
32
34
  const trust_audit_1 = require("../trust-audit");
@@ -53,7 +55,13 @@ function plan(appRecord, options) {
53
55
  inputs[declared.name] = declared.default ?? "";
54
56
  }
55
57
  const cwd = node_path_1.default.resolve(String(inputs.cwd || inputs.repo || process.cwd()));
56
- const runId = createRunId(workflow.id);
58
+ // A caller (e.g. an inline sub-workflow task) may inject a DETERMINISTIC run id so
59
+ // the child run id is reproducible across re-runs; otherwise mint one. `runId` is
60
+ // never a declared workflow input, so strip it from inputs to keep run.inputs (and
61
+ // the digests derived from it) clean — POLA for every normal plan.
62
+ const injectedRunId = typeof options.runId === "string" && options.runId.trim() ? options.runId.trim() : undefined;
63
+ delete inputs.runId;
64
+ const runId = injectedRunId || createRunId(workflow.id);
57
65
  const runDir = node_path_1.default.join(cwd, ".cw", "runs", runId);
58
66
  const paths = (0, state_1.createRunPaths)(runDir);
59
67
  (0, state_1.ensureRunDirs)(paths);
@@ -79,7 +87,10 @@ function plan(appRecord, options) {
79
87
  status: "pending",
80
88
  taskIds: phase.tasks.map((task) => task.id),
81
89
  // parallel() DSL: the drive loop reads this to size its concurrent round.
82
- ...(phase.mode ? { mode: phase.mode } : {})
90
+ ...(phase.mode ? { mode: phase.mode } : {}),
91
+ // loop() DSL: the ORIGIN phase carries the loop spec + round 1; the expander
92
+ // appends round-2+ phases after each round (loop-expansion / maybeExpandLoop).
93
+ ...(phase.loop ? { loop: phase.loop, loopRound: 1 } : {})
83
94
  })),
84
95
  tasks,
85
96
  dispatches: [],
@@ -323,6 +334,9 @@ function recordWorkerOutput(run, workerId, resultPath, options = {}) {
323
334
  }
324
335
  run.loopStage = "observe";
325
336
  (0, dispatch_1.updatePhaseStatuses)(run);
337
+ // Bounded dynamic loops: after a round's tasks complete, evaluate the predicate
338
+ // and either append the next round or mark the loop done (no-op for non-loop runs).
339
+ maybeExpandLoop(run);
326
340
  (0, verifier_1.validateRunGates)(run);
327
341
  (0, commit_1.commitState)(run, `worker:${workerId}:result`);
328
342
  (0, report_1.writeReport)(run);
@@ -410,6 +424,120 @@ function validateInputs(workflow, inputs) {
410
424
  }
411
425
  }
412
426
  }
427
+ /** Bounded dynamic loop expansion. After a worker result is recorded: if the just-
428
+ * completed phase is the LATEST round of a loop whose origin is not yet done, evaluate
429
+ * the registered predicate over the round's recorded results and either append the
430
+ * next round (clone the round-1 template tasks into a fresh phase, materialized like
431
+ * plan() does) or mark the loop done. One deterministic `loop-control` node is recorded
432
+ * per round boundary — the replay source of truth. No-op when the run has no loop
433
+ * phases (POLA). Expands at most ONE loop boundary per call; the next accept handles
434
+ * the next. Bounded: a loop never exceeds `maxRounds` (fail-closed); an unregistered
435
+ * predicate stops the loop rather than spinning. */
436
+ function maybeExpandLoop(run) {
437
+ for (const phase of [...run.phases]) {
438
+ const originId = phase.loop ? phase.id : phase.loopOrigin;
439
+ if (!originId)
440
+ continue;
441
+ const origin = run.phases.find((p) => p.id === originId);
442
+ if (!origin || !origin.loop || origin.loopDone)
443
+ continue;
444
+ // Act only from the LATEST round phase of this loop.
445
+ const loopPhases = run.phases.filter((p) => p.id === originId || p.loopOrigin === originId);
446
+ const latest = loopPhases.reduce((a, b) => ((b.loopRound || 1) >= (a.loopRound || 1) ? b : a));
447
+ if (phase.id !== latest.id)
448
+ continue;
449
+ const roundTasks = run.tasks.filter((t) => latest.taskIds.includes(t.id));
450
+ if (roundTasks.length === 0 || !roundTasks.every((t) => t.status === "completed"))
451
+ continue;
452
+ const round = latest.loopRound || 1;
453
+ const ordered = (tasks) => tasks.slice().sort((a, b) => (0, compare_1.compareBytes)(a.id, b.id)).map((t) => t.result);
454
+ const roundResults = ordered(roundTasks);
455
+ const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopPhases.some((p) => p.taskIds.includes(t.id)));
456
+ const allResults = ordered(allLoopTasks);
457
+ const ctx = { round, roundResults, allResults, usageTotals: (0, observability_1.deriveUsageTotals)(run).totals, inputs: run.inputs };
458
+ const until = origin.loop.until;
459
+ let decision;
460
+ if (until.kind === "budget-target") {
461
+ // Budget-aware scaling: keep spawning rounds while RECORDED (attested-only) usage
462
+ // stays under the target. Composes with the fail-closed cap (limits.tokenBudget),
463
+ // which the drive enforces before each spawn and which remains the absolute
464
+ // backstop — whichever fires first wins, and the cap can never be overshot.
465
+ const spent = ctx.usageTotals.totalTokens;
466
+ decision = { done: spent >= until.target, reason: `budget-target: ${spent}/${until.target} recorded tokens` };
467
+ }
468
+ else {
469
+ const predicate = (0, loop_expansion_1.getLoopPredicate)(until.ref);
470
+ decision = predicate
471
+ ? predicate(ctx)
472
+ : { done: true, reason: `loop predicate "${until.ref}" not registered — stopping fail-closed` };
473
+ }
474
+ const atCap = round >= origin.loop.maxRounds;
475
+ const done = decision.done || atCap;
476
+ // Record the decision under a deterministic id (the replay source of truth).
477
+ (0, state_node_1.appendRunNode)(run, (0, state_node_1.createStateNode)({
478
+ id: `${run.id}:loop-control:${originId}:r${round}`,
479
+ kind: "loop-control",
480
+ status: "completed",
481
+ loopStage: "adjust",
482
+ outputs: { round, done, atCap, reason: decision.reason },
483
+ metadata: { originPhaseId: originId, until: until.kind === "predicate" ? until.ref : `budget-target:${until.target}`, round, done, atCap, reason: decision.reason }
484
+ }));
485
+ if (done) {
486
+ origin.loopDone = true;
487
+ return;
488
+ }
489
+ // Expand: clone the ROUND-1 template tasks into a fresh phase appended right after.
490
+ const nextRound = round + 1;
491
+ const nextPhaseName = `${origin.name} (round ${nextRound})`;
492
+ const templateTasks = run.tasks.filter((t) => origin.taskIds.includes(t.id));
493
+ const newTasks = templateTasks.map((t) => ({
494
+ id: `${t.id.replace(/@r\d+$/, "")}@r${nextRound}`,
495
+ kind: t.kind,
496
+ phase: nextPhaseName,
497
+ status: "pending",
498
+ requiresEvidence: t.requiresEvidence,
499
+ prompt: t.prompt,
500
+ taskPath: "",
501
+ resultPath: "",
502
+ loopStage: "interpret",
503
+ loopRound: nextRound,
504
+ ...(t.sandboxProfileId ? { sandboxProfileId: t.sandboxProfileId } : {}),
505
+ ...(t.label ? { label: t.label } : {}),
506
+ ...(t.model ? { model: t.model } : {}),
507
+ ...(t.agentType ? { agentType: t.agentType } : {}),
508
+ ...(t.schema ? { schema: t.schema } : {})
509
+ }));
510
+ const nextPhase = {
511
+ id: `${originId}@r${nextRound}`,
512
+ name: nextPhaseName,
513
+ status: "pending",
514
+ taskIds: newTasks.map((t) => t.id),
515
+ loopOrigin: originId,
516
+ loopRound: nextRound,
517
+ ...(origin.mode ? { mode: origin.mode } : {})
518
+ };
519
+ const insertAt = run.phases.findIndex((p) => p.id === latest.id);
520
+ run.phases.splice(insertAt + 1, 0, nextPhase);
521
+ run.tasks.push(...newTasks);
522
+ // Materialize: task files + a plan-stage contract node per new task (mirrors plan()).
523
+ (0, harness_1.writeTaskFiles)(run);
524
+ const contractId = run.contracts && run.contracts[0] ? run.contracts[0].id : undefined;
525
+ const inputNodeId = `${run.id}:input`;
526
+ const pipeline = (0, pipeline_runner_1.createPipelineRunner)({ contractId, persist: false });
527
+ for (const t of newTasks) {
528
+ const result = pipeline.runPipelineStage(run, "plan", inputNodeId, {
529
+ outputNodeId: `${run.id}:task:${t.id}`,
530
+ outputStatus: "pending",
531
+ loopStage: "interpret",
532
+ artifacts: [{ id: "task", kind: "markdown", path: t.taskPath }],
533
+ metadata: { workflowId: run.workflow.id, taskId: t.id, phase: t.phase, taskKind: t.kind, requiresEvidence: t.requiresEvidence, sandboxProfileId: t.sandboxProfileId }
534
+ });
535
+ t.stateNodeId = result.outputNodeId;
536
+ }
537
+ (0, dispatch_1.updatePhaseStatuses)(run);
538
+ return;
539
+ }
540
+ }
413
541
  function flattenTasks(workflow, inputs) {
414
542
  const seen = new Set();
415
543
  const tasks = [];
@@ -437,7 +565,10 @@ function flattenTasks(workflow, inputs) {
437
565
  ...(task.label ? { label: task.label } : {}),
438
566
  ...(task.model ? { model: task.model } : {}),
439
567
  ...(task.agentType ? { agentType: task.agentType } : {}),
440
- ...(task.resultCache ? { resultCache: task.resultCache } : {})
568
+ ...(task.resultCache ? { resultCache: task.resultCache } : {}),
569
+ ...(task.subWorkflow ? { subWorkflow: task.subWorkflow } : {}),
570
+ // A loop phase's tasks are round 1 of the loop; the expander clones them.
571
+ ...(phase.loop ? { loopRound: 1 } : {})
441
572
  });
442
573
  }
443
574
  }