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
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
@@ -411,9 +411,17 @@ function executeLocal(descriptor, policy, request, label, attestation) {
411
411
  // shell backend runs via /bin/sh -c; node/bun run the command directly
412
412
  // (bun is Node-compatible by default so evidence stays byte-stable with node).
413
413
  // spawnStyle comes from the registered driver, not a hardcoded id check.
414
+ const isTTY = process.stderr.isTTY;
415
+ const shortLabel = command.split("/").pop() || command;
416
+ if (isTTY)
417
+ process.stderr.write(`● Running ${shortLabel}...\n`);
418
+ const startedAt = process.hrtime.bigint();
414
419
  const result = getBackendDriver(descriptor.id)?.spawnStyle === "shell"
415
420
  ? (0, node_child_process_1.spawnSync)([command, ...args].join(" "), { ...options, shell: true })
416
421
  : (0, node_child_process_1.spawnSync)(command, args, { ...options, shell: false });
422
+ const elapsedMs = Number((process.hrtime.bigint() - startedAt) / 1000000n);
423
+ if (isTTY)
424
+ process.stderr.write(`✓ Done (${elapsedMs}ms)\n`);
417
425
  const exitCode = typeof result.status === "number" ? result.status : null;
418
426
  const spawnError = result.error ? (0, util_1.messageOf)(result.error) : undefined;
419
427
  const stdout = String(result.stdout || "");
@@ -670,10 +678,10 @@ function runAgentProcess(descriptor, policy, request, label, handle, attestation
670
678
  outcome = request.preparedAgentOutcome;
671
679
  }
672
680
  else {
673
- // Live output is opt-in (POLA): stdout is always captured as data, while
674
- // stderr is forwarded only when the operator explicitly asks for a stream
675
- // and this process is attached to a terminal. CI/pipes stay silent.
676
- 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";
677
685
  const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
678
686
  cwd: request.cwd,
679
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",
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.formatOperatorStatus = formatOperatorStatus;
4
+ exports.formatOperatorSummary = formatOperatorSummary;
4
5
  exports.formatOperatorReport = formatOperatorReport;
5
6
  exports.formatOperatorGraph = formatOperatorGraph;
6
7
  exports.formatWorkerSummary = formatWorkerSummary;
@@ -11,18 +12,10 @@ exports.formatMultiAgentSummary = formatMultiAgentSummary;
11
12
  exports.formatTopologySummary = formatTopologySummary;
12
13
  exports.formatMultiAgentTrustAudit = formatMultiAgentTrustAudit;
13
14
  const multi_agent_operator_ux_1 = require("../multi-agent-operator-ux");
15
+ const term_1 = require("../term");
14
16
  function formatOperatorStatus(summary) {
15
- const operator = summary.multiAgentOperator;
16
17
  return [
17
- `Run: ${summary.runId}`,
18
- `Workflow: ${summary.workflowId}${summary.appId ? ` (${summary.appId}@${summary.appVersion || "unknown"})` : ""}`,
19
- `Loop Stage: ${summary.loopStage}`,
20
- `Active Phase: ${summary.activePhase || "none"}`,
21
- `Blocked: ${summary.blocked ? summary.blockedReasons.join("; ") : "no"}`,
22
- `Tasks: ${formatCounts(summary.tasks.byStatus)}; total=${summary.tasks.total}`,
23
- "",
24
- "Phases",
25
- ...summary.phases.map((phase) => ` ${phase.name}: ${phase.status} (${phase.tasks.completed}/${phase.tasks.total} completed)`),
18
+ formatOperatorSummary(summary),
26
19
  "",
27
20
  formatWorkerPanel(summary.workers),
28
21
  "",
@@ -37,9 +30,9 @@ function formatOperatorStatus(summary) {
37
30
  formatMultiAgentPanel(summary.multiAgent),
38
31
  "",
39
32
  "Multi-Agent Operator UX",
40
- ` active=${operator.activeMultiAgentRunIds.join(", ") || "none"}; topologies=${operator.topologyRunIds.join(", ") || "none"}; blocked=${operator.blocked ? "yes" : "no"}`,
41
- ` dependencies=${operator.dependencies.length}; failures=${operator.failures.length}; adoptedEvidence=${operator.adoptedEvidence.length}; missingEvidence=${operator.missingEvidence.length}${operator.inspectableEvidence.length ? ` (inspectable=${operator.inspectableEvidence.length})` : ""}`,
42
- ` next=${operator.nextAction}`,
33
+ ` active=${operator(summary).activeMultiAgentRunIds.join(", ") || "none"}; topologies=${operator(summary).topologyRunIds.join(", ") || "none"}; blocked=${operator(summary).blocked ? "yes" : "no"}`,
34
+ ` dependencies=${operator(summary).dependencies.length}; failures=${operator(summary).failures.length}; adoptedEvidence=${operator(summary).adoptedEvidence.length}; missingEvidence=${operator(summary).missingEvidence.length}${operator(summary).inspectableEvidence.length ? ` (inspectable=${operator(summary).inspectableEvidence.length})` : ""}`,
35
+ ` next=${operator(summary).nextAction}`,
43
36
  "",
44
37
  formatBlackboardPanel(summary.blackboard),
45
38
  "",
@@ -47,10 +40,23 @@ function formatOperatorStatus(summary) {
47
40
  "",
48
41
  formatMultiAgentTrustAudit(summary.multiAgentTrust),
49
42
  "",
50
- `Report: ${summary.reportPath}`,
43
+ `Report: ${summary.reportPath}`
44
+ ].join("\n");
45
+ }
46
+ function operator(summary) { return summary.multiAgentOperator; }
47
+ /** Compact summary — the default `cw status` output. `cw status --verbose` shows the full panel. */
48
+ function formatOperatorSummary(summary) {
49
+ return [
50
+ `Run: ${summary.runId}`,
51
+ `Workflow: ${summary.workflowId}${summary.appId ? ` (${summary.appId}@${summary.appVersion || "unknown"})` : ""}`,
52
+ `Phase: ${summary.activePhase || "none"} | Stage: ${summary.loopStage} | Blocked: ${summary.blocked ? summary.blockedReasons.join("; ") : "no"}`,
53
+ `Tasks: ${formatCounts(summary.tasks.byStatus)}; total=${summary.tasks.total}`,
54
+ ...summary.phases.map((phase) => ` ${phase.name}: ${phase.status} (${phase.tasks.completed}/${phase.tasks.total} completed)`),
51
55
  "",
52
56
  "Next Action",
53
- ...formatRecommendations(summary.nextActions)
57
+ ...formatRecommendations(summary.nextActions),
58
+ "",
59
+ (0, term_1.dim)(`(use --verbose for full worker/candidate/feedback/commit/trust panels)`)
54
60
  ].join("\n");
55
61
  }
56
62
  function formatOperatorReport(summary) {
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.formatMultiAgentTrustAudit = exports.formatTopologySummary = exports.formatMultiAgentSummary = exports.formatCommitSummary = exports.formatFeedbackSummary = exports.formatCandidateSummary = exports.formatWorkerSummary = exports.formatOperatorGraph = exports.formatOperatorReport = exports.formatOperatorStatus = void 0;
6
+ exports.formatMultiAgentTrustAudit = exports.formatTopologySummary = exports.formatMultiAgentSummary = exports.formatCommitSummary = exports.formatFeedbackSummary = exports.formatCandidateSummary = exports.formatWorkerSummary = exports.formatOperatorGraph = exports.formatOperatorReport = exports.formatOperatorSummary = exports.formatOperatorStatus = void 0;
7
7
  exports.summarizeOperatorRun = summarizeOperatorRun;
8
8
  exports.adviseNoRun = adviseNoRun;
9
9
  exports.summarizeOperatorWorkers = summarizeOperatorWorkers;
@@ -619,6 +619,7 @@ function safeId(value) {
619
619
  // surface.
620
620
  var format_1 = require("./operator-ux/format");
621
621
  Object.defineProperty(exports, "formatOperatorStatus", { enumerable: true, get: function () { return format_1.formatOperatorStatus; } });
622
+ Object.defineProperty(exports, "formatOperatorSummary", { enumerable: true, get: function () { return format_1.formatOperatorSummary; } });
622
623
  Object.defineProperty(exports, "formatOperatorReport", { enumerable: true, get: function () { return format_1.formatOperatorReport; } });
623
624
  Object.defineProperty(exports, "formatOperatorGraph", { enumerable: true, get: function () { return format_1.formatOperatorGraph; } });
624
625
  Object.defineProperty(exports, "formatWorkerSummary", { enumerable: true, get: function () { return format_1.formatWorkerSummary; } });