cool-workflow 0.2.2 → 0.2.4

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 (141) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/cli/dispatch.js +13 -6
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +12 -22
  12. package/dist/cli/parseargv.js +1 -0
  13. package/dist/core/capability-data.js +3 -2
  14. package/dist/core/format/completion.js +68 -0
  15. package/dist/core/format/help.js +31 -6
  16. package/dist/core/format/safe-json.js +73 -0
  17. package/dist/core/format/state-explosion-text.js +1 -1
  18. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  19. package/dist/core/multi-agent/collaboration.js +3 -3
  20. package/dist/core/multi-agent/coordinator.js +5 -5
  21. package/dist/core/multi-agent/runtime.js +4 -4
  22. package/dist/core/multi-agent/topology.js +3 -3
  23. package/dist/core/pipeline/dispatch.js +18 -4
  24. package/dist/core/pipeline/drive-decide.js +2 -1
  25. package/dist/core/state/migrations.js +16 -1
  26. package/dist/core/state/state-explosion/digest.js +13 -13
  27. package/dist/core/state/state-explosion/graph.js +17 -11
  28. package/dist/core/state/state-explosion/report.js +6 -6
  29. package/dist/core/util/cli-args.js +33 -0
  30. package/dist/core/util/numeric-flag.js +40 -0
  31. package/dist/core/version.js +1 -1
  32. package/dist/mcp/server.js +99 -11
  33. package/dist/shell/audit-cli.js +57 -25
  34. package/dist/shell/coordinator-io.js +73 -13
  35. package/dist/shell/dispatch.js +1 -1
  36. package/dist/shell/doctor.js +80 -1
  37. package/dist/shell/drive.js +251 -49
  38. package/dist/shell/eval-text.js +2 -2
  39. package/dist/shell/evidence-reasoning.js +4 -4
  40. package/dist/shell/execution-backend/agent.js +30 -2
  41. package/dist/shell/execution-backend/container.js +4 -1
  42. package/dist/shell/execution-backend/local.js +19 -11
  43. package/dist/shell/feedback-cli.js +6 -6
  44. package/dist/shell/fs-atomic.js +218 -29
  45. package/dist/shell/man-cli.js +6 -0
  46. package/dist/shell/metrics-cli.js +2 -1
  47. package/dist/shell/multi-agent-cli.js +367 -323
  48. package/dist/shell/multi-agent-host.js +9 -9
  49. package/dist/shell/multi-agent-operator-ux.js +80 -38
  50. package/dist/shell/node-store.js +10 -4
  51. package/dist/shell/observability.js +1 -1
  52. package/dist/shell/operator-ux-text.js +22 -22
  53. package/dist/shell/operator-ux.js +15 -14
  54. package/dist/shell/orchestrator.js +49 -38
  55. package/dist/shell/pipeline-cli.js +107 -42
  56. package/dist/shell/reclamation-io.js +88 -10
  57. package/dist/shell/registry-cli.js +23 -17
  58. package/dist/shell/remote-source.js +13 -8
  59. package/dist/shell/report-cli.js +45 -0
  60. package/dist/shell/report.js +2 -1
  61. package/dist/shell/run-registry-io.js +78 -19
  62. package/dist/shell/run-store.js +74 -2
  63. package/dist/shell/scheduling-io.js +12 -13
  64. package/dist/shell/state-cli.js +2 -7
  65. package/dist/shell/state-explosion-cli.js +17 -9
  66. package/dist/shell/topology-io.js +36 -5
  67. package/dist/shell/trust-audit.js +224 -22
  68. package/dist/shell/trust-policy-io.js +1 -1
  69. package/dist/shell/worker-cli.js +35 -31
  70. package/dist/shell/workflow-app-loader.js +67 -1
  71. package/dist/wiring/capability-table/basics.js +33 -8
  72. package/dist/wiring/capability-table/exec-backend.js +28 -19
  73. package/dist/wiring/capability-table/multi-agent.js +187 -180
  74. package/dist/wiring/capability-table/parity.js +1 -0
  75. package/dist/wiring/capability-table/pipeline.js +64 -52
  76. package/dist/wiring/capability-table/registry-core.js +30 -11
  77. package/dist/wiring/capability-table/reporting.js +147 -106
  78. package/dist/wiring/capability-table/scheduling-registry.js +201 -167
  79. package/dist/wiring/capability-table/state.js +59 -51
  80. package/dist/wiring/capability-table/trust-ledger.js +52 -28
  81. package/dist/wiring/capability-table/workflow-apps.js +85 -70
  82. package/docs/agent-delegation-drive.7.md +4 -0
  83. package/docs/candidate-scoring.7.md +1 -1
  84. package/docs/canonical-workflow-apps.7.md +7 -7
  85. package/docs/cli-mcp-parity.7.md +26 -16
  86. package/docs/contract-migration-tooling.7.md +4 -0
  87. package/docs/control-plane-scheduling.7.md +4 -0
  88. package/docs/coordinator-blackboard.7.md +17 -17
  89. package/docs/dogfood-one-real-repo.7.md +11 -11
  90. package/docs/durable-state-and-locking.7.md +4 -0
  91. package/docs/end-to-end-golden-path.7.md +14 -14
  92. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  93. package/docs/execution-backends.7.md +4 -0
  94. package/docs/getting-started.md +37 -37
  95. package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
  96. package/docs/multi-agent-eval-replay-harness.7.md +17 -13
  97. package/docs/multi-agent-operator-ux.7.md +23 -19
  98. package/docs/multi-agent-runtime-core.7.md +22 -22
  99. package/docs/multi-agent-topologies.7.md +6 -6
  100. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  101. package/docs/node-snapshot-diff-replay.7.md +4 -0
  102. package/docs/observability-cost-accounting.7.md +4 -0
  103. package/docs/operator-ux.7.md +34 -34
  104. package/docs/pipeline-runner.7.md +4 -4
  105. package/docs/project-index.md +31 -4
  106. package/docs/real-execution-backends.7.md +4 -0
  107. package/docs/release-and-migration.7.md +5 -1
  108. package/docs/release-tooling.7.md +22 -0
  109. package/docs/routines.md +4 -4
  110. package/docs/run-registry-control-plane.7.md +23 -19
  111. package/docs/run-retention-reclamation.7.md +24 -3
  112. package/docs/scheduled-tasks.md +14 -14
  113. package/docs/security-trust-hardening.7.md +15 -15
  114. package/docs/state-explosion-management.7.md +14 -10
  115. package/docs/team-collaboration.7.md +4 -0
  116. package/docs/trust-audit-anchor.7.md +4 -0
  117. package/docs/unix-principles.md +3 -1
  118. package/docs/verifier-gated-commit.7.md +1 -1
  119. package/docs/web-desktop-workbench.7.md +4 -0
  120. package/docs/workflow-app-framework.7.md +13 -13
  121. package/manifest/plugin.manifest.json +1 -1
  122. package/package.json +3 -2
  123. package/scripts/agents/claude-p-agent.js +2 -2
  124. package/scripts/block-unapproved-tag.sh +42 -6
  125. package/scripts/bump-version.js +27 -10
  126. package/scripts/canonical-apps.js +4 -4
  127. package/scripts/children/batch-delegate-child.js +52 -2
  128. package/scripts/dogfood-release.js +1 -1
  129. package/scripts/fake-date-for-reproduction.js +44 -0
  130. package/scripts/golden-path.js +4 -4
  131. package/scripts/purity-baseline.json +0 -27
  132. package/scripts/release-flow.js +194 -13
  133. package/scripts/release-oneclick.js +407 -0
  134. package/scripts/verdict-keygen.js +83 -0
  135. package/scripts/verify-bump-reproduction.sh +148 -0
  136. package/scripts/verify-verdict-signature.js +61 -0
  137. package/scripts/version-sync-check.js +39 -22
  138. package/skills/cool-workflow/SKILL.md +9 -9
  139. package/skills/cool-workflow/references/commands.md +89 -88
  140. package/ui/workbench/app.css +37 -1
  141. package/ui/workbench/app.js +124 -6
@@ -51,6 +51,7 @@ exports.MAX_SUB_WORKFLOW_DEPTH = exports.DRIVE_SCHEMA_VERSION = void 0;
51
51
  exports.maybeExpandLoop = maybeExpandLoop;
52
52
  exports.driveStep = driveStep;
53
53
  exports.drive = drive;
54
+ exports.driveAsync = driveAsync;
54
55
  exports.drivePreview = drivePreview;
55
56
  const fs = __importStar(require("node:fs"));
56
57
  const path = __importStar(require("node:path"));
@@ -153,15 +154,22 @@ function loadRun(ctx) {
153
154
  /** Runs `fn` with `runId`'s loadRun calls served from one shared cached
154
155
  * object (seeded fresh from disk), and always clears the cache entry
155
156
  * afterward — even on throw — so a round never leaks its cache into a
156
- * later, unrelated drive call. */
157
+ * later, unrelated drive call. Re-entrant: a nested call (e.g. the
158
+ * serial drive loop's own round-cache wrapping a driveStep that itself
159
+ * runs inside a caller's round cache) reuses the outer seed instead of
160
+ * re-reading disk, and only the OUTERMOST call clears the entry — so
161
+ * nesting never re-reads a run mid-round nor drops the cache early out
162
+ * from under an enclosing scope. */
157
163
  function withRoundCache(ctx, fn) {
158
- const seed = (0, run_store_1.loadRunFromCwd)(ctx.runId, ctx.cwd);
159
- roundCache.set(ctx.runId, seed);
164
+ const alreadyActive = roundCache.has(ctx.runId);
165
+ if (!alreadyActive)
166
+ roundCache.set(ctx.runId, (0, run_store_1.loadRunFromCwd)(ctx.runId, ctx.cwd));
160
167
  try {
161
168
  return fn();
162
169
  }
163
170
  finally {
164
- roundCache.delete(ctx.runId);
171
+ if (!alreadyActive)
172
+ roundCache.delete(ctx.runId);
165
173
  }
166
174
  }
167
175
  function resultCachePath(run, task, promptDigest, incremental, delegationDigest) {
@@ -551,7 +559,8 @@ function maybeExpandLoop(run) {
551
559
  const latest = loopPhases.reduce((a, b) => ((b.loopRound || 1) >= (a.loopRound || 1) ? b : a));
552
560
  if (phase.id !== latest.id)
553
561
  continue;
554
- const roundTasks = run.tasks.filter((t) => latest.taskIds.includes(t.id));
562
+ const latestTaskIds = new Set(latest.taskIds);
563
+ const roundTasks = run.tasks.filter((t) => latestTaskIds.has(t.id));
555
564
  if (roundTasks.length === 0 || !roundTasks.every((t) => t.status === "completed"))
556
565
  continue;
557
566
  const round = latest.loopRound || 1;
@@ -560,7 +569,8 @@ function maybeExpandLoop(run) {
560
569
  .sort((a, b) => compareBytes(a.id, b.id))
561
570
  .map((t) => t.result);
562
571
  const roundResults = ordered(roundTasks);
563
- const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopPhases.some((p) => p.taskIds.includes(t.id)));
572
+ const loopTaskIds = new Set(loopPhases.flatMap((p) => p.taskIds));
573
+ const allLoopTasks = run.tasks.filter((t) => t.status === "completed" && loopTaskIds.has(t.id));
564
574
  const allResults = ordered(allLoopTasks);
565
575
  const ctx = {
566
576
  round,
@@ -597,7 +607,8 @@ function maybeExpandLoop(run) {
597
607
  // Expand: clone the ROUND-1 template tasks into a fresh phase appended
598
608
  // right after the latest round.
599
609
  const nextRound = round + 1;
600
- const templateTasks = run.tasks.filter((t) => origin.taskIds.includes(t.id));
610
+ const originTaskIds = new Set(origin.taskIds);
611
+ const templateTasks = run.tasks.filter((t) => originTaskIds.has(t.id));
601
612
  const { phase: nextPhase, tasks: newTasks } = (0, loop_expansion_1.cloneLoopRoundTasks)(origin, templateTasks, nextRound);
602
613
  const insertAt = run.phases.findIndex((p) => p.id === latest.id);
603
614
  run.phases.splice(insertAt + 1, 0, nextPhase);
@@ -706,6 +717,14 @@ function prepareConcurrentOutcomes(ctx, batch) {
706
717
  }
707
718
  if (jobs.length) {
708
719
  emitProgress(`⇉ concurrent round: ${jobs.length} agent${jobs.length > 1 ? "s" : ""} spawning in parallel, may take minutes…`);
720
+ // Every task above that reached "pending" got dispatched (workerId
721
+ // assigned) in the round-cached run object, but nothing durable was
722
+ // written for it — unlike the serial path's own dispatch branch, which
723
+ // always checkpoints immediately. Flush now, BEFORE the batch's long
724
+ // spawn window opens: a crash mid-spawn (which can run for minutes)
725
+ // then leaves state.json correctly showing these tasks as dispatched,
726
+ // instead of losing the dispatch entirely.
727
+ (0, run_store_1.saveCheckpoint)(loadRun(ctx));
709
728
  }
710
729
  const settled = (0, agent_1.runAgentBatchOutcomes)(jobs);
711
730
  const outcomes = new Map();
@@ -732,8 +751,9 @@ function driveConcurrentRound(ctx, limit) {
732
751
  return [driveStep(ctx)];
733
752
  const phase = (0, dispatch_1.firstRunnablePhase)(run);
734
753
  const width = Math.max(1, Math.floor(limit) || 1);
754
+ const phaseTaskIds = new Set(phase.taskIds);
735
755
  const batch = run.tasks
736
- .filter((task) => phase.taskIds.includes(task.id) && (task.status === "pending" || task.status === "running"))
756
+ .filter((task) => phaseTaskIds.has(task.id) && (task.status === "pending" || task.status === "running"))
737
757
  .slice(0, width)
738
758
  .map((task) => task.id);
739
759
  const prepared = prepareConcurrentOutcomes(ctx, batch);
@@ -762,13 +782,37 @@ function driveConcurrentRound(ctx, limit) {
762
782
  return steps.length > 0 ? steps : [driveStep(ctx)];
763
783
  });
764
784
  }
765
- /** Drive a run: `--once` advances exactly one step; otherwise run to
766
- * completion, park, or a blocked stop. */
767
- function drive(runId, cwd, options = {}) {
785
+ // A bare Ctrl-C (SIGINT) or an external SIGTERM used to kill the process
786
+ // outright between steps -- no handler was ever installed, so the default
787
+ // kernel action ran: instant termination, no `finally` anywhere gets a
788
+ // chance to run. If that landed mid-`withFileLock` critical section (e.g.
789
+ // the state.json or trust-audit-log lock in run-store.ts/trust-audit.ts),
790
+ // the lock's release `finally` never ran either, leaving a stale `.lock`
791
+ // file that makes the NEXT command retry for ~6s before failing outright
792
+ // (well under the 30s stale-lock steal window).
793
+ //
794
+ // The FIRST fix for this (installing onStopSignal below) assumed the
795
+ // signal would at least be caught BETWEEN rounds, only missing it
796
+ // mid-spawnSync. That assumption was wrong: Node.js does not dispatch a
797
+ // queued POSIX signal to a JS `process.on(signal, ...)` listener until
798
+ // the event loop actually gets a turn, and drive()'s whole multi-round
799
+ // loop -- every round's agent spawn uses spawnSync, and nothing between
800
+ // rounds ever awaits anything -- is ONE continuous synchronous span with
801
+ // no such turn anywhere inside it. A signal landing at any point during
802
+ // that whole span (confirmed live: even a plain CPU-only busy loop with
803
+ // no spawnSync at all drops it the same way) sits queued until the loop
804
+ // finishes on its own, by which point the `finally` below has already
805
+ // removed the listener -- so it is never invoked at all. `drive()` keeps
806
+ // this exact (buggy but byte-identical) shape for full backward
807
+ // compatibility; `driveAsync()` below is the actual fix, for callers
808
+ // that need a live run to really respond to Ctrl-C/SIGTERM.
809
+ const DRIVE_STOP_SIGNALS = ["SIGINT", "SIGTERM"];
810
+ const DRIVE_STOP_EXIT_CODE = { SIGINT: 130, SIGTERM: 143 };
811
+ function buildDriveContext(runId, cwd, options) {
768
812
  const now = options.now || new Date().toISOString();
769
813
  const config = options.agentConfig || (0, agent_config_1.resolveAgentConfig)(options.args || {});
770
814
  const policy = { ...drive_decide_1.DEFAULT_SCHEDULING_POLICY, ...(options.policy || {}) };
771
- const ctx = {
815
+ return {
772
816
  runId,
773
817
  cwd,
774
818
  now,
@@ -779,22 +823,22 @@ function drive(runId, cwd, options = {}) {
779
823
  visitedAppIds: options.visitedAppIds || [],
780
824
  policy,
781
825
  };
782
- const steps = [];
783
- const run0 = loadRun(ctx);
784
- const plannedWorkers = run0.tasks.length;
785
- const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), policy);
786
- // Phase-boundary progress (brew-style): announce each phase when it
787
- // becomes active and when it finishes — `==> Map ✓ (6/6)` / `==> Assess
788
- // (3/6)`. Describes CW's OWN phases (vendor-neutral); goes to stderr
789
- // via emitProgress so stdout stays clean data. Byte-exact port of the
790
- // old build's src/drive.ts emitPhaseProgress. term.phaseProgressLine
791
- // renders the line; this closure decides WHEN to emit each boundary.
826
+ }
827
+ // Phase-boundary progress (brew-style): announce each phase when it becomes
828
+ // active and when it finishes — `==> Map ✓ (6/6)` / `==> Assess ⇉ (3/6)`.
829
+ // Describes CW's OWN phases (vendor-neutral); goes to stderr via
830
+ // emitProgress so stdout stays clean data. Byte-exact port of the old
831
+ // build's src/drive.ts emitPhaseProgress. term.phaseProgressLine renders
832
+ // the line; the returned closure decides WHEN to emit each boundary.
833
+ // Shared by drive() and driveAsync() so the two never drift apart.
834
+ function createPhaseProgressEmitter() {
792
835
  const announcedPhaseComplete = new Set();
793
836
  let activePhaseId;
794
837
  const titleCase = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s);
795
- const emitPhaseProgress = (run) => {
838
+ return (run) => {
796
839
  for (const ph of run.phases || []) {
797
- const phaseTasks = run.tasks.filter((task) => ph.taskIds.includes(task.id));
840
+ const phaseTaskIds = new Set(ph.taskIds);
841
+ const phaseTasks = run.tasks.filter((task) => phaseTaskIds.has(task.id));
798
842
  const total = phaseTasks.length;
799
843
  if (total === 0)
800
844
  continue;
@@ -814,38 +858,113 @@ function drive(runId, cwd, options = {}) {
814
858
  return; // only the first not-yet-complete phase is "active"
815
859
  }
816
860
  };
817
- let exhaustedMaxIterations = !options.once;
818
- for (let i = 0; i < maxIter; i++) {
861
+ }
862
+ /** Runs exactly one round -- a single driveStep, or a full concurrent
863
+ * round when width>1 -- and records its steps. Returns false when the
864
+ * round loop should stop (a terminal step, or `--once`'s single-round
865
+ * contract), true to go on to another round. Shared by drive()'s plain
866
+ * loop and driveAsync()'s interruptible one so the two never drift
867
+ * apart. */
868
+ function driveOneRound(ctx, options, steps, emitPhaseProgress) {
869
+ // One round-cache scope for the WHOLE round, not just driveConcurrentRound's
870
+ // own batch: the width check and (for a serial round) driveStep/
871
+ // processSelectedTask's several loadRun(ctx) calls otherwise each
872
+ // re-read+re-parse state.json even though nothing on disk changed
873
+ // between them — reads that only reflect the mutations THIS round's
874
+ // own steps make, which the shared in-memory object already carries.
875
+ const roundSteps = withRoundCache(ctx, () => {
819
876
  const width = (0, drive_decide_1.roundWidth)(loadRun(ctx), options.concurrency);
820
877
  // width>1 (an explicit --concurrency>1, or an auto-width parallel
821
878
  // phase) runs the whole round through driveConcurrentRound — one or
822
- // more steps recorded in deterministic batch order, one flush at
823
- // round end. `--once` still stops after this ONE outer-loop
824
- // iteration even though a round can yield multiple steps.
825
- const roundSteps = width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
826
- for (const stepResult of roundSteps)
827
- steps.push(stepResult);
828
- // Brew-style phase boundaries: after each round, announce a
829
- // newly-active phase and any phase that just finished. Cheap — reuses
830
- // the run we just advanced; goes to stderr so stdout stays clean.
831
- emitPhaseProgress(loadRun(ctx));
832
- const last = roundSteps[roundSteps.length - 1];
833
- if (options.once) {
834
- exhaustedMaxIterations = false;
835
- break;
836
- }
837
- if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked")) {
838
- exhaustedMaxIterations = false;
839
- break;
879
+ // more steps recorded in deterministic batch order, one flush at round
880
+ // end. `--once` still stops after this ONE round even though a round
881
+ // can yield multiple steps.
882
+ return width > 1 ? driveConcurrentRound(ctx, width) : [driveStep(ctx)];
883
+ });
884
+ for (const stepResult of roundSteps)
885
+ steps.push(stepResult);
886
+ // Brew-style phase boundaries: after each round, announce a newly-active
887
+ // phase and any phase that just finished. Cheap reuses the run we just
888
+ // advanced; goes to stderr so stdout stays clean.
889
+ emitPhaseProgress(loadRun(ctx));
890
+ const last = roundSteps[roundSteps.length - 1];
891
+ if (options.once)
892
+ return false;
893
+ if (last && (last.status === "complete" || last.status === "parked" || last.status === "blocked"))
894
+ return false;
895
+ return true;
896
+ }
897
+ /** One JS EventEmitter listener per SIGINT/SIGTERM, installed for the span
898
+ * of one drive()/driveAsync() call and removed by that caller's own
899
+ * `finally` (this function recurses for nested sub-workflow runs, and
900
+ * many tests call it repeatedly in one process, so nothing must
901
+ * accumulate on `process`'s listener list across calls). The FIRST
902
+ * signal sets a flag the round loop checks at the top of its next
903
+ * iteration -- never mid-round -- so the in-flight round always
904
+ * finishes its own bookkeeping first. A SECOND signal means the caller
905
+ * wants out right now, not a graceful stop, and force-exits immediately
906
+ * with the conventional 128+signum code. */
907
+ function createStopSignalController(runId) {
908
+ let interruptedBy;
909
+ let stopSignalHits = 0;
910
+ const onStopSignal = (signal) => {
911
+ stopSignalHits += 1;
912
+ if (stopSignalHits === 1) {
913
+ interruptedBy = signal;
914
+ emitProgress(`received ${signal} — stopping after the current step (resume: cw run resume ${runId} --drive)`);
915
+ return;
840
916
  }
841
- }
917
+ // A second signal means the caller wants out right now, not a graceful stop.
918
+ emitProgress(`received a second ${signal} — exiting immediately`);
919
+ process.exit(DRIVE_STOP_EXIT_CODE[signal] ?? 1);
920
+ };
921
+ return {
922
+ install: () => {
923
+ for (const signal of DRIVE_STOP_SIGNALS)
924
+ process.on(signal, onStopSignal);
925
+ },
926
+ remove: () => {
927
+ for (const signal of DRIVE_STOP_SIGNALS)
928
+ process.off(signal, onStopSignal);
929
+ },
930
+ getInterruptedBy: () => interruptedBy,
931
+ };
932
+ }
933
+ /** Forces one real turn of the Node.js event loop. `setImmediate` (a
934
+ * genuine macrotask, unlike a bare Promise microtask, which never leaves
935
+ * the current turn) is the only thing that actually lets libuv's poll
936
+ * phase run and dispatch a queued SIGINT/SIGTERM to its JS listener --
937
+ * confirmed live: a real external `kill -INT` sent to a busy synchronous
938
+ * process is dropped for as long as the process never yields, and fires
939
+ * within ~1ms of the very next event-loop turn once it does. */
940
+ function yieldToEventLoop() {
941
+ return new Promise((resolve) => setImmediate(resolve));
942
+ }
943
+ /** Everything drive()/driveAsync() do once their round loop has stopped:
944
+ * fold the interrupted/exhausted-iterations signal into the right
945
+ * "blocked" step, then assemble the DriveResult. Shared so the two
946
+ * loops can never report status differently for the same outcome. */
947
+ function finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterationsAtLoopExit, interruptedBy) {
948
+ let exhaustedMaxIterations = exhaustedMaxIterationsAtLoopExit;
842
949
  const run = loadRun(ctx);
843
950
  const completedWorkers = (0, drive_decide_1.countCompleted)(run);
844
951
  const parkedWorkers = (0, drive_decide_1.countParked)(run);
845
952
  const committed = (0, drive_decide_1.hasTerminalCommit)(run);
846
953
  const last = steps[steps.length - 1];
847
- if (exhaustedMaxIterations) {
848
- steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId, reason: `drive reached max iteration limit (${maxIter}) before a terminal state` }));
954
+ // A signal can land on the very last step (the terminal commit itself) --
955
+ // the run is already fully done, same "complete" check the `once` branch
956
+ // of finalDriveStatus below already uses. Reporting "blocked" here anyway
957
+ // would be a real user-visible lie (e.g. pipeline-cli.ts's `--bundle` gate
958
+ // reads status, so a genuinely finished run would wrongly skip sealing).
959
+ const alreadyComplete = completedWorkers === plannedWorkers && committed;
960
+ if (interruptedBy) {
961
+ exhaustedMaxIterations = false;
962
+ if (!alreadyComplete) {
963
+ steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive interrupted by ${interruptedBy} — resume: cw run resume ${ctx.runId} --drive` }));
964
+ }
965
+ }
966
+ else if (exhaustedMaxIterations) {
967
+ steps.push((0, drive_decide_1.makeStep)("blocked", "blocked", { runId: ctx.runId, reason: `drive reached max iteration limit (${maxIter}) before a terminal state — resume: cw run resume ${ctx.runId} --drive` }));
849
968
  }
850
969
  const statusInputs = {
851
970
  once: Boolean(options.once),
@@ -862,7 +981,7 @@ function drive(runId, cwd, options = {}) {
862
981
  const committedCommit = (run.commits || []).find((c) => c.reason && c.reason.startsWith("agent-delegation-drive"));
863
982
  return {
864
983
  schemaVersion: 1,
865
- runId,
984
+ runId: ctx.runId,
866
985
  workflowId: run.workflow.id,
867
986
  status,
868
987
  steps,
@@ -872,9 +991,92 @@ function drive(runId, cwd, options = {}) {
872
991
  commitId: committedCommit?.id,
873
992
  reportPath: run.paths.report,
874
993
  statePath: run.paths.state,
875
- agentConfigured: agentConfigured(config),
994
+ agentConfigured: agentConfigured(ctx.config),
876
995
  };
877
996
  }
997
+ /** Drive a run: `--once` advances exactly one step; otherwise run to
998
+ * completion, park, or a blocked stop. Fully synchronous, byte-identical
999
+ * to every prior release -- see the file comment above DRIVE_STOP_SIGNALS
1000
+ * for why a real SIGINT/SIGTERM sent during a live multi-round call is
1001
+ * queued but never actually reaches onStopSignal below; that limitation
1002
+ * is deliberately kept here for backward compatibility (recursive
1003
+ * sub-workflow runs, and every existing caller/test that depends on this
1004
+ * exact synchronous shape) and fixed only in driveAsync(). */
1005
+ function drive(runId, cwd, options = {}) {
1006
+ const ctx = buildDriveContext(runId, cwd, options);
1007
+ const steps = [];
1008
+ const run0 = loadRun(ctx);
1009
+ const plannedWorkers = run0.tasks.length;
1010
+ const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1011
+ const emitPhaseProgress = createPhaseProgressEmitter();
1012
+ let exhaustedMaxIterations = !options.once;
1013
+ const stopSignal = createStopSignalController(runId);
1014
+ stopSignal.install();
1015
+ try {
1016
+ for (let i = 0; i < maxIter; i++) {
1017
+ if (stopSignal.getInterruptedBy())
1018
+ break;
1019
+ if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1020
+ exhaustedMaxIterations = false;
1021
+ break;
1022
+ }
1023
+ }
1024
+ }
1025
+ finally {
1026
+ stopSignal.remove();
1027
+ }
1028
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1029
+ }
1030
+ /** Same contract and same DriveResult as drive() -- but actually
1031
+ * interruptible. After every round it awaits yieldToEventLoop(), a real
1032
+ * setImmediate-based turn of the event loop, giving Node.js an actual
1033
+ * chance to dispatch a queued SIGINT/SIGTERM to onStopSignal before the
1034
+ * next round's synchronous spawnSync work begins; the loop then sees
1035
+ * the interrupted flag at the top of its next iteration exactly like
1036
+ * drive() already checks it. Shares driveOneRound/createStopSignalController/
1037
+ * finalizeDriveResult with drive(), so the two can only ever differ in
1038
+ * this one respect. Intended for the live, potentially-long-running
1039
+ * entry points (CLI `--drive`, the MCP run.drive.step tool) where a
1040
+ * user's Ctrl-C or a supervisor's SIGTERM must actually be able to stop
1041
+ * the run; internal/recursive/test callers that don't need this keep
1042
+ * calling the plain drive() above, unaffected.
1043
+ *
1044
+ * A signal arriving while a NESTED sub-workflow's own drive() call
1045
+ * (runSubWorkflow, below) is running is still not caught until that
1046
+ * nested call finishes on its own -- an accepted, documented limitation:
1047
+ * the same "cannot interrupt an already in-flight blocking operation,
1048
+ * only checked at explicit boundaries" shape as the pre-existing
1049
+ * cannot-interrupt-mid-spawnSync limit, just one level of recursion
1050
+ * deeper. Fixing that would require threading interruptibility through
1051
+ * the entire recursive call graph (driveStep/processSelectedTask/
1052
+ * runSubWorkflow/handleHop), a materially larger change left for a
1053
+ * future cycle if it proves needed in practice. */
1054
+ async function driveAsync(runId, cwd, options = {}) {
1055
+ const ctx = buildDriveContext(runId, cwd, options);
1056
+ const steps = [];
1057
+ const run0 = loadRun(ctx);
1058
+ const plannedWorkers = run0.tasks.length;
1059
+ const maxIter = (0, drive_decide_1.maxIterations)(plannedWorkers, (0, loop_expansion_1.maxLoopExpansion)(run0), ctx.policy);
1060
+ const emitPhaseProgress = createPhaseProgressEmitter();
1061
+ let exhaustedMaxIterations = !options.once;
1062
+ const stopSignal = createStopSignalController(runId);
1063
+ stopSignal.install();
1064
+ try {
1065
+ for (let i = 0; i < maxIter; i++) {
1066
+ if (stopSignal.getInterruptedBy())
1067
+ break;
1068
+ if (!driveOneRound(ctx, options, steps, emitPhaseProgress)) {
1069
+ exhaustedMaxIterations = false;
1070
+ break;
1071
+ }
1072
+ await yieldToEventLoop();
1073
+ }
1074
+ }
1075
+ finally {
1076
+ stopSignal.remove();
1077
+ }
1078
+ return finalizeDriveResult(ctx, options, steps, plannedWorkers, maxIter, exhaustedMaxIterations, stopSignal.getInterruptedBy());
1079
+ }
878
1080
  function drivePreview(runId, cwd, args = {}) {
879
1081
  const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
880
1082
  const config = (0, agent_config_1.resolveAgentConfig)(args);
@@ -167,7 +167,7 @@ function formatMultiAgentEval(value) {
167
167
  ` replay=${value.paths.replayRunPath}`,
168
168
  "",
169
169
  "Next Action",
170
- ` node scripts/cw.js eval compare ${value.paths.snapshotPath} ${value.paths.replayRunPath}`,
170
+ ` cw eval compare ${value.paths.snapshotPath} ${value.paths.replayRunPath}`,
171
171
  ].join("\n");
172
172
  }
173
173
  if (isSnapshot(value)) {
@@ -201,7 +201,7 @@ function formatMultiAgentEval(value) {
201
201
  " snapshot-ready",
202
202
  "",
203
203
  "Next Action",
204
- ` node scripts/cw.js eval replay ${value.paths.snapshotPath}`,
204
+ ` cw eval replay ${value.paths.snapshotPath}`,
205
205
  ].join("\n");
206
206
  }
207
207
  if (isReport(value)) {
@@ -65,10 +65,10 @@ function buildEvidenceReasoningReport(run, options = {}) {
65
65
  if (persisted && persisted.sourceFingerprint !== currentFingerprint)
66
66
  status = "stale";
67
67
  const nextAction = status === "stale" || status === "absent"
68
- ? `node scripts/cw.js multi-agent reasoning ${run.id} --refresh`
68
+ ? `cw multi-agent reasoning ${run.id} --refresh`
69
69
  : totals.unexplained > 0
70
- ? `node scripts/cw.js multi-agent reasoning ${run.id} --json`
71
- : `node scripts/cw.js multi-agent evidence ${run.id} --json`;
70
+ ? `cw multi-agent reasoning ${run.id} --json`
71
+ : `cw multi-agent evidence ${run.id} --json`;
72
72
  return {
73
73
  schemaVersion: exports.EVIDENCE_REASONING_SCHEMA_VERSION,
74
74
  runId: run.id,
@@ -343,7 +343,7 @@ function refreshEvidenceReasoning(run) {
343
343
  totals: report.totals,
344
344
  entries: entries.sort((a, b) => (0, collate_1.stableCompare)(a.id, b.id)),
345
345
  paths: { reasoningDir: dir, indexPath, reportPath },
346
- nextAction: `node scripts/cw.js multi-agent reasoning ${run.id}`,
346
+ nextAction: `cw multi-agent reasoning ${run.id}`,
347
347
  };
348
348
  (0, fs_atomic_1.writeJson)(indexPath, index);
349
349
  (0, fs_atomic_1.writeJson)(reportPath, { ...report, freshness: { ...report.freshness, status: "valid", persistedFingerprint: report.sourceFingerprint } });
@@ -87,14 +87,23 @@ const AGENT_PROVIDER_KEY_ENV_RE = /^(CW_|ANTHROPIC_|OPENAI_|GEMINI_|DEEPSEEK_|CO
87
87
  * worker.agent-env trust-audit event. */
88
88
  function buildAgentChildEnv(policy, baseEnv = process.env) {
89
89
  const env = (0, local_1.buildChildEnv)(policy, baseEnv);
90
+ // deny must win here too: buildChildEnv already applied it above, but the
91
+ // provider-key/USER re-add below exists specifically to put vars BACK
92
+ // that buildChildEnv's readonly/locked-down defaults strip — without this
93
+ // check that re-add silently overrode an operator's explicit deny (e.g.
94
+ // deny:["AWS_SECRET_ACCESS_KEY"] still forwarded it, since AWS_ matches
95
+ // AGENT_PROVIDER_KEY_ENV_RE).
96
+ const denied = new Set(policy.env.deny || []);
90
97
  const forwarded = [];
91
98
  for (const key of Object.keys(baseEnv)) {
99
+ if (denied.has(key))
100
+ continue;
92
101
  if (AGENT_PROVIDER_KEY_ENV_RE.test(key)) {
93
102
  env[key] = baseEnv[key];
94
103
  forwarded.push(key);
95
104
  }
96
105
  }
97
- if (baseEnv.USER !== undefined && env.USER === undefined) {
106
+ if (!denied.has("USER") && baseEnv.USER !== undefined && env.USER === undefined) {
98
107
  env.USER = baseEnv.USER;
99
108
  forwarded.push("USER");
100
109
  }
@@ -408,15 +417,34 @@ function runAgentProcess(descriptor, policy, request, label, attestation) {
408
417
  const built = buildAgentChildEnv(policy);
409
418
  const childEnv = built.env;
410
419
  forwardedEnvVars = built.forwarded;
420
+ const timeoutMs = resolved.timeoutMs || 600000;
411
421
  const child = (0, node_child_process_1.spawnSync)(resolved.binary, realArgs, {
412
422
  cwd: request.cwd,
413
423
  env: childEnv,
414
424
  encoding: "utf8",
415
- timeout: resolved.timeoutMs || 600000,
425
+ timeout: timeoutMs,
416
426
  maxBuffer: 32 * 1024 * 1024,
417
427
  shell: false,
428
+ // SIGKILL, not the default SIGTERM: an agent wrapper that ignores
429
+ // SIGTERM would leave this blocking spawnSync waiting forever (no
430
+ // second-stage escalation is possible from inside a sync call), and
431
+ // a timed-out agent's output is discarded by the refusal below
432
+ // anyway — so a hard kill loses nothing and cannot wedge cw.
433
+ killSignal: "SIGKILL",
418
434
  stdio: ["ignore", "pipe", streamStderr ? "inherit" : "pipe"],
419
435
  });
436
+ // A timeout surfaces as child.error with code ETIMEDOUT (status null,
437
+ // signal set). Classify it FIRST: without this, the generic spawnError
438
+ // branch below reported "agent process failed to spawn: ...ETIMEDOUT"
439
+ // and the null-exit-code "timed out or killed" branch was dead code
440
+ // for real timeouts.
441
+ if (child.error && child.error.code === "ETIMEDOUT") {
442
+ const handleOut = recordedAgentHandle(resolved.binary, undefined, recordedArgs, resolved.model, "unreported", undefined, undefined, forwardedEnvVars);
443
+ return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `agent process timed out after ${timeoutMs}ms and was killed (SIGKILL)`, {
444
+ ...attestation,
445
+ handle: handleOut,
446
+ });
447
+ }
420
448
  outcome = {
421
449
  ...(child.error ? { spawnError: messageOf(child.error) } : {}),
422
450
  exitCode: typeof child.status === "number" ? child.status : null,
@@ -56,7 +56,10 @@ function runContainer(descriptor, policy, request, label, handle, attestation) {
56
56
  }
57
57
  }
58
58
  runArgs.push(handle.ref, command, ...args);
59
- const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs, maxBuffer: 32 * 1024 * 1024 });
59
+ // An unset timeoutMs must not mean "no timeout" spawnSync would then
60
+ // block forever on a hung container with no kill path. 600000 matches the
61
+ // agent backend's own default fallback (execution-backend/agent.ts).
62
+ const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024 });
60
63
  if (result.error) {
61
64
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `${runtime} run failed: ${messageOf(result.error)}`, attestation);
62
65
  }
@@ -19,16 +19,21 @@ function messageOf(error) {
19
19
  return error instanceof Error ? error.message : String(error);
20
20
  }
21
21
  function buildChildEnv(policy, baseEnv = process.env) {
22
- if (policy.env.inherit)
23
- return { ...baseEnv };
24
- const env = {};
25
- if (baseEnv.PATH !== undefined)
26
- env.PATH = baseEnv.PATH;
27
- if (baseEnv.HOME !== undefined)
28
- env.HOME = baseEnv.HOME;
29
- for (const name of policy.env.expose || []) {
30
- if (baseEnv[name] !== undefined)
31
- env[name] = baseEnv[name];
22
+ // deny must win regardless of inherit: a custom profile combining
23
+ // inherit:true with deny:[...] (a valid, normalized combination — see
24
+ // sandbox-profile.ts's normalizeEnv) means "everything EXCEPT these".
25
+ // An early return on inherit before this loop ran meant deny was
26
+ // silently skipped whenever inherit was true.
27
+ const env = policy.env.inherit ? { ...baseEnv } : {};
28
+ if (!policy.env.inherit) {
29
+ if (baseEnv.PATH !== undefined)
30
+ env.PATH = baseEnv.PATH;
31
+ if (baseEnv.HOME !== undefined)
32
+ env.HOME = baseEnv.HOME;
33
+ for (const name of policy.env.expose || []) {
34
+ if (baseEnv[name] !== undefined)
35
+ env[name] = baseEnv[name];
36
+ }
32
37
  }
33
38
  for (const name of policy.env.deny || []) {
34
39
  delete env[name];
@@ -65,7 +70,10 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
65
70
  cwd: request.cwd,
66
71
  env,
67
72
  encoding: "utf8",
68
- timeout: request.timeoutMs,
73
+ // An unset timeoutMs must not mean "no timeout" — spawnSync would then
74
+ // block forever on a hung child with no kill path. 600000 matches the
75
+ // agent backend's own default fallback (execution-backend/agent.ts).
76
+ timeout: request.timeoutMs || 600000,
69
77
  maxBuffer: 32 * 1024 * 1024,
70
78
  };
71
79
  if (spawnStyle === "shell") {
@@ -67,15 +67,15 @@ function feedbackSummaryCli(args) {
67
67
  const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
68
68
  return (0, error_feedback_1.summarizeFeedback)((0, feedback_operations_1.listFeedback)(run));
69
69
  }
70
+ // collect/task/resolve mutate the run and saveCheckpoint (transitively,
71
+ // in feedback-operations -> error-feedback-io), so they hold the state.json
72
+ // lock across the whole load -> change -> save cycle (lost-update class).
70
73
  function feedbackCollectCli(args) {
71
- const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
72
- return (0, feedback_operations_1.collectFeedback)(run);
74
+ return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.collectFeedback)(run));
73
75
  }
74
76
  function feedbackTaskCli(args) {
75
- const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
76
- return (0, feedback_operations_1.createFeedbackTask)(run, req(args.feedbackId, "feedback id"), args);
77
+ return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.createFeedbackTask)(run, req(args.feedbackId, "feedback id"), args));
77
78
  }
78
79
  function feedbackResolveCli(args) {
79
- const run = (0, run_store_1.loadRunFromCwd)(req(args.runId, "run id"), cwdFor(args));
80
- return (0, feedback_operations_1.resolveFeedback)(run, req(args.feedbackId, "feedback id"), args);
80
+ return (0, run_store_1.withRunStateLock)(req(args.runId, "run id"), cwdFor(args), (run) => (0, feedback_operations_1.resolveFeedback)(run, req(args.feedbackId, "feedback id"), args));
81
81
  }