cool-workflow 0.2.1 → 0.2.3

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 (156) 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 +29 -91
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +6 -2
  13. package/dist/core/capability-data.js +271 -0
  14. package/dist/core/capability-table.js +16 -3504
  15. package/dist/core/format/completion.js +68 -0
  16. package/dist/core/format/help.js +50 -8
  17. package/dist/core/format/safe-json.js +73 -0
  18. package/dist/core/format/state-explosion-text.js +1 -1
  19. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  20. package/dist/core/multi-agent/collaboration.js +6 -5
  21. package/dist/core/multi-agent/coordinator.js +9 -8
  22. package/dist/core/multi-agent/runtime.js +4 -4
  23. package/dist/core/multi-agent/topology.js +3 -3
  24. package/dist/core/pipeline/commit-gate.js +2 -1
  25. package/dist/core/pipeline/dispatch.js +18 -4
  26. package/dist/core/pipeline/drive-decide.js +2 -1
  27. package/dist/core/state/migrations.js +16 -1
  28. package/dist/core/state/state-explosion/digest.js +16 -15
  29. package/dist/core/state/state-explosion/graph.js +26 -19
  30. package/dist/core/state/state-explosion/helpers.js +2 -1
  31. package/dist/core/state/state-explosion/report.js +6 -6
  32. package/dist/core/trust/ledger.js +2 -1
  33. package/dist/core/types/execution-backend.js +18 -0
  34. package/dist/core/types/observability.js +7 -0
  35. package/dist/core/util/collate.js +23 -0
  36. package/dist/core/util/numeric-flag.js +40 -0
  37. package/dist/core/version.js +1 -1
  38. package/dist/mcp/server.js +99 -11
  39. package/dist/shell/audit-cli.js +99 -23
  40. package/dist/shell/commit-summary.js +2 -1
  41. package/dist/shell/coordinator-io.js +73 -13
  42. package/dist/shell/dispatch.js +1 -1
  43. package/dist/shell/doctor.js +80 -1
  44. package/dist/shell/drive.js +269 -59
  45. package/dist/shell/eval-text.js +2 -2
  46. package/dist/shell/evidence-reasoning.js +8 -7
  47. package/dist/shell/execution-backend/agent.js +20 -1
  48. package/dist/shell/execution-backend/container.js +4 -1
  49. package/dist/shell/execution-backend/local.js +4 -1
  50. package/dist/shell/execution-backend/registry.js +2 -1
  51. package/dist/shell/execution-backend/types.js +0 -9
  52. package/dist/shell/feedback-cli.js +6 -6
  53. package/dist/shell/fs-atomic.js +232 -30
  54. package/dist/shell/man-cli.js +6 -0
  55. package/dist/shell/metrics-cli.js +2 -1
  56. package/dist/shell/multi-agent-cli.js +367 -323
  57. package/dist/shell/multi-agent-host.js +9 -9
  58. package/dist/shell/multi-agent-operator-ux.js +82 -39
  59. package/dist/shell/node-store.js +10 -4
  60. package/dist/shell/observability.js +13 -12
  61. package/dist/shell/onramp.js +17 -1
  62. package/dist/shell/operator-ux-text.js +24 -23
  63. package/dist/shell/operator-ux.js +22 -20
  64. package/dist/shell/orchestrator.js +49 -38
  65. package/dist/shell/pipeline-cli.js +168 -111
  66. package/dist/shell/reclamation-io.js +16 -5
  67. package/dist/shell/registry-cli.js +19 -17
  68. package/dist/shell/remote-source.js +13 -8
  69. package/dist/shell/report-cli.js +45 -0
  70. package/dist/shell/report.js +4 -2
  71. package/dist/shell/run-registry-io.js +77 -19
  72. package/dist/shell/run-store.js +91 -2
  73. package/dist/shell/scheduling-io.js +12 -13
  74. package/dist/shell/state-cli.js +2 -7
  75. package/dist/shell/state-explosion-cli.js +19 -10
  76. package/dist/shell/topology-io.js +38 -6
  77. package/dist/shell/trust-audit.js +264 -23
  78. package/dist/shell/trust-policy-io.js +1 -1
  79. package/dist/shell/worker-cli.js +41 -29
  80. package/dist/shell/worker-isolation.js +34 -9
  81. package/dist/shell/workflow-app-loader.js +3 -2
  82. package/dist/wiring/capability-table/basics.js +91 -0
  83. package/dist/wiring/capability-table/exec-backend.js +171 -0
  84. package/dist/wiring/capability-table/index.js +42 -0
  85. package/dist/wiring/capability-table/multi-agent.js +630 -0
  86. package/dist/wiring/capability-table/parity.js +467 -0
  87. package/dist/wiring/capability-table/pipeline.js +292 -0
  88. package/dist/wiring/capability-table/registry-core.js +208 -0
  89. package/dist/wiring/capability-table/reporting.js +435 -0
  90. package/dist/wiring/capability-table/scheduling-registry.js +592 -0
  91. package/dist/wiring/capability-table/state.js +181 -0
  92. package/dist/wiring/capability-table/trust-ledger.js +158 -0
  93. package/dist/wiring/capability-table/workflow-apps.js +381 -0
  94. package/docs/agent-delegation-drive.7.md +4 -0
  95. package/docs/candidate-scoring.7.md +1 -1
  96. package/docs/canonical-workflow-apps.7.md +7 -7
  97. package/docs/cli-mcp-parity.7.md +13 -3
  98. package/docs/contract-migration-tooling.7.md +4 -0
  99. package/docs/control-plane-scheduling.7.md +4 -0
  100. package/docs/coordinator-blackboard.7.md +17 -17
  101. package/docs/dogfood-one-real-repo.7.md +11 -11
  102. package/docs/durable-state-and-locking.7.md +24 -0
  103. package/docs/end-to-end-golden-path.7.md +14 -14
  104. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  105. package/docs/execution-backends.7.md +4 -0
  106. package/docs/getting-started.md +37 -37
  107. package/docs/index.md +1 -0
  108. package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
  109. package/docs/multi-agent-eval-replay-harness.7.md +17 -13
  110. package/docs/multi-agent-operator-ux.7.md +23 -19
  111. package/docs/multi-agent-runtime-core.7.md +22 -22
  112. package/docs/multi-agent-topologies.7.md +6 -6
  113. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  114. package/docs/node-snapshot-diff-replay.7.md +4 -0
  115. package/docs/observability-cost-accounting.7.md +4 -0
  116. package/docs/operator-ux.7.md +34 -34
  117. package/docs/pipeline-runner.7.md +4 -4
  118. package/docs/project-index.md +48 -5
  119. package/docs/real-execution-backends.7.md +4 -0
  120. package/docs/release-and-migration.7.md +5 -1
  121. package/docs/release-tooling.7.md +4 -0
  122. package/docs/routines.md +4 -4
  123. package/docs/run-registry-control-plane.7.md +23 -19
  124. package/docs/run-retention-reclamation.7.md +4 -0
  125. package/docs/scheduled-tasks.md +14 -14
  126. package/docs/security-trust-hardening.7.md +43 -13
  127. package/docs/state-explosion-management.7.md +14 -10
  128. package/docs/team-collaboration.7.md +4 -0
  129. package/docs/trust-audit-anchor.7.md +71 -0
  130. package/docs/unix-principles.md +3 -1
  131. package/docs/verifier-gated-commit.7.md +1 -1
  132. package/docs/web-desktop-workbench.7.md +4 -0
  133. package/docs/workflow-app-framework.7.md +13 -13
  134. package/manifest/plugin.manifest.json +1 -1
  135. package/package.json +4 -2
  136. package/scripts/agents/claude-p-agent.js +2 -2
  137. package/scripts/block-unapproved-tag.sh +23 -2
  138. package/scripts/bump-version.js +24 -2
  139. package/scripts/canonical-apps.js +4 -4
  140. package/scripts/children/batch-delegate-child.js +52 -2
  141. package/scripts/dogfood-release.js +1 -1
  142. package/scripts/fake-date-for-reproduction.js +44 -0
  143. package/scripts/golden-path.js +4 -4
  144. package/scripts/purity-baseline.json +71 -0
  145. package/scripts/purity-gate.js +239 -0
  146. package/scripts/release-check.js +8 -1
  147. package/scripts/release-flow.js +57 -1
  148. package/scripts/verdict-keygen.js +83 -0
  149. package/scripts/verify-bump-reproduction.sh +148 -0
  150. package/scripts/verify-verdict-signature.js +61 -0
  151. package/scripts/version-sync-check.js +33 -12
  152. package/skills/cool-workflow/SKILL.md +9 -9
  153. package/skills/cool-workflow/references/commands.md +89 -88
  154. package/ui/workbench/app.css +37 -1
  155. package/ui/workbench/app.js +124 -6
  156. package/workflows/README.md +19 -0
@@ -102,18 +102,22 @@ class CoolWorkflowRunner {
102
102
  /** `dispatch` — build the next dispatch manifest (persisting through
103
103
  * createDispatchManifest's own writes) and checkpoint. */
104
104
  dispatch(runId, options = {}) {
105
- const run = this.loadRun(runId);
106
- const limit = numberOption(options.limit);
107
- const manifest = (0, dispatch_1.createDispatchManifest)(run, limit, {
108
- sandboxProfileId: stringOption(options.sandbox) || stringOption(options.sandboxProfile) || stringOption(options.sandboxProfileId),
109
- backendId: stringOption(options.backend) || stringOption(options.backendId) || stringOption(options.executionBackend),
110
- multiAgentRunId: stringOption(options.multiAgentRun || options.multiAgentRunId || options["multi-agent-run"]),
111
- multiAgentGroupId: stringOption(options.multiAgentGroup || options.multiAgentGroupId || options.group || options["multi-agent-group"]),
112
- multiAgentRoleId: stringOption(options.multiAgentRole || options.multiAgentRoleId || options.role || options["multi-agent-role"]),
113
- multiAgentFanoutId: stringOption(options.multiAgentFanout || options.multiAgentFanoutId || options.fanout || options["multi-agent-fanout"]),
105
+ // Hold the state.json lock across the whole load -> change -> save so a
106
+ // concurrent run mutation cannot drop this dispatch (lost-update class,
107
+ // matching pipeline-cli.ts's dispatchRun).
108
+ return (0, run_store_2.withRunStateLock)(runId, this.cwd(), (run) => {
109
+ const limit = numberOption(options.limit);
110
+ const manifest = (0, dispatch_1.createDispatchManifest)(run, limit, {
111
+ sandboxProfileId: stringOption(options.sandbox) || stringOption(options.sandboxProfile) || stringOption(options.sandboxProfileId),
112
+ backendId: stringOption(options.backend) || stringOption(options.backendId) || stringOption(options.executionBackend),
113
+ multiAgentRunId: stringOption(options.multiAgentRun || options.multiAgentRunId || options["multi-agent-run"]),
114
+ multiAgentGroupId: stringOption(options.multiAgentGroup || options.multiAgentGroupId || options.group || options["multi-agent-group"]),
115
+ multiAgentRoleId: stringOption(options.multiAgentRole || options.multiAgentRoleId || options.role || options["multi-agent-role"]),
116
+ multiAgentFanoutId: stringOption(options.multiAgentFanout || options.multiAgentFanoutId || options.fanout || options["multi-agent-fanout"]),
117
+ });
118
+ (0, run_store_2.saveCheckpoint)(run);
119
+ return manifest;
114
120
  });
115
- (0, run_store_2.saveCheckpoint)(run);
116
- return manifest;
117
121
  }
118
122
  /** `showWorkerManifest` — write + return a worker's manifest. */
119
123
  showWorkerManifest(runId, workerId) {
@@ -126,10 +130,13 @@ class CoolWorkflowRunner {
126
130
  /** `recordWorkerOutput` — accept a worker's result and checkpoint. Mirrors
127
131
  * v2's workerOutputCli: recordWorkerOutput + saveCheckpoint. */
128
132
  recordWorkerOutput(runId, workerId, resultPath, options = {}) {
129
- const run = this.loadRun(runId);
130
- const output = (0, worker_isolation_1.recordWorkerOutput)(run, workerId, this.resolveFromBase(resultPath), options);
131
- (0, run_store_2.saveCheckpoint)(run);
132
- return output;
133
+ // Hold the state.json lock across the whole load -> change -> save so a
134
+ // concurrent run mutation cannot drop this worker output (lost-update class).
135
+ return (0, run_store_2.withRunStateLock)(runId, this.cwd(), (run) => {
136
+ const output = (0, worker_isolation_1.recordWorkerOutput)(run, workerId, this.resolveFromBase(resultPath), options);
137
+ (0, run_store_2.saveCheckpoint)(run);
138
+ return output;
139
+ });
133
140
  }
134
141
  /** `auditSummary` — the trust-audit rollup. */
135
142
  auditSummary(runId) {
@@ -172,29 +179,33 @@ class CoolWorkflowRunner {
172
179
  * `{ runId, commit }` to match the old orchestrator's shape (the scripts
173
180
  * read `commitResult.commit`). */
174
181
  commit(runId, input = {}) {
175
- const run = this.loadRun(runId);
176
- const options = typeof input === "string" ? { reason: input } : input;
177
- const allowCheckpoint = Boolean(options.allowUnverifiedCheckpoint || options["allow-unverified-checkpoint"]);
178
- const hasGateOption = Boolean(options.verifier || options.verifierNode || options["verifier-node"] || options.candidate || options.selection);
179
- try {
180
- const commit = (0, commit_1.commitState)(run, {
181
- reason: stringOption(options.reason) || "manual",
182
- verifierNodeId: stringOption(options.verifier) || stringOption(options.verifierNode) || stringOption(options["verifier-node"]),
183
- candidateId: stringOption(options.candidate),
184
- selectionId: stringOption(options.selection),
185
- verifierGated: hasGateOption || !allowCheckpoint,
186
- allowUnverifiedCheckpoint: allowCheckpoint,
187
- source: "cli",
188
- });
189
- (0, report_1.writeReport)(run);
190
- (0, run_store_2.saveCheckpoint)(run);
191
- return { runId: run.id, commit };
192
- }
193
- catch (error) {
194
- (0, report_1.writeReport)(run);
195
- (0, run_store_2.saveCheckpoint)(run);
196
- throw error;
197
- }
182
+ // Hold the state.json lock across the whole load -> commit -> save (both
183
+ // the success and the fail-closed catch persist) so a concurrent run
184
+ // mutation cannot drop this commit (lost-update class).
185
+ return (0, run_store_2.withRunStateLock)(runId, this.cwd(), (run) => {
186
+ const options = typeof input === "string" ? { reason: input } : input;
187
+ const allowCheckpoint = Boolean(options.allowUnverifiedCheckpoint || options["allow-unverified-checkpoint"]);
188
+ const hasGateOption = Boolean(options.verifier || options.verifierNode || options["verifier-node"] || options.candidate || options.selection);
189
+ try {
190
+ const commit = (0, commit_1.commitState)(run, {
191
+ reason: stringOption(options.reason) || "manual",
192
+ verifierNodeId: stringOption(options.verifier) || stringOption(options.verifierNode) || stringOption(options["verifier-node"]),
193
+ candidateId: stringOption(options.candidate),
194
+ selectionId: stringOption(options.selection),
195
+ verifierGated: hasGateOption || !allowCheckpoint,
196
+ allowUnverifiedCheckpoint: allowCheckpoint,
197
+ source: "cli",
198
+ });
199
+ (0, report_1.writeReport)(run);
200
+ (0, run_store_2.saveCheckpoint)(run);
201
+ return { runId: run.id, commit };
202
+ }
203
+ catch (error) {
204
+ (0, report_1.writeReport)(run);
205
+ (0, run_store_2.saveCheckpoint)(run);
206
+ throw error;
207
+ }
208
+ });
198
209
  }
199
210
  /** `report` — write the run's report.md; returns `{ path }` (old shape). */
200
211
  report(runId) {
@@ -52,6 +52,8 @@ exports.recordResultRun = recordResultRun;
52
52
  exports.commitRun = commitRun;
53
53
  const fs = __importStar(require("node:fs"));
54
54
  const path = __importStar(require("node:path"));
55
+ const readlinePromises = __importStar(require("node:readline/promises"));
56
+ const numeric_flag_1 = require("../core/util/numeric-flag");
55
57
  const pipeline_1 = require("./pipeline");
56
58
  const workflow_app_loader_1 = require("./workflow-app-loader");
57
59
  const drive_1 = require("./drive");
@@ -223,8 +225,11 @@ function runDrivePreview(args) {
223
225
  return (0, drive_1.drivePreview)(runId, cwd, args);
224
226
  }
225
227
  /** `cw run <app|--run id> --drive [--once]` — plans a fresh run (unless
226
- * `--run` continues an existing one) and drives it. */
227
- function runDriveStep(args) {
228
+ * `--run` continues an existing one) and drives it. Uses driveAsync (not
229
+ * the plain synchronous drive()) so a live multi-round run actually
230
+ * responds to Ctrl-C/SIGTERM instead of silently ignoring it — see
231
+ * shell/drive.ts's driveAsync doc comment. */
232
+ async function runDriveStep(args) {
228
233
  const existingRunId = String(args.runId || args.run || "");
229
234
  const options = {
230
235
  once: Boolean(args.once),
@@ -236,7 +241,7 @@ function runDriveStep(args) {
236
241
  if (existingRunId) {
237
242
  const cwd = invocationCwd(args);
238
243
  const run = (0, run_store_1.loadRunFromCwd)(existingRunId, cwd);
239
- return (0, drive_1.drive)(existingRunId, run.cwd, options);
244
+ return (0, drive_1.driveAsync)(existingRunId, run.cwd, options);
240
245
  }
241
246
  const appId = String(args.appId || args.app || args.positionalApp || "");
242
247
  if (!appId)
@@ -245,7 +250,7 @@ function runDriveStep(args) {
245
250
  args.repo = invocationCwd(args);
246
251
  const app = (0, workflow_app_loader_1.loadWorkflowApp)(appId);
247
252
  const run = (0, pipeline_1.plan)(app, planInputsFor(args));
248
- return (0, drive_1.drive)(run.id, run.cwd, options);
253
+ return (0, drive_1.driveAsync)(run.id, run.cwd, options);
249
254
  }
250
255
  /** `cw quickstart [app] --check` — read-only preflight: does the app
251
256
  * resolve, is the repo readable/writable, is a question set, is an
@@ -385,6 +390,21 @@ function remoteQuickstartCheck(appId, args, candidate) {
385
390
  const nextCommand = `cw quickstart ${shellWord(appId)} --link ${shellWord(validation.url)}${question ? ` --question ${shellWord(question)}` : ""}`;
386
391
  return { schemaVersion: 1, mode: "check", ok, appId, repo: validation.url, checks, nextCommand };
387
392
  }
393
+ /** Prompts `Question: ` on STDERR (stdout stays data-only, matching the
394
+ * Rule of Silence) and reads one line from stdin. Returns the trimmed
395
+ * answer, or `undefined` for a blank line. Callers must only invoke this
396
+ * when `process.stdin.isTTY` — it would otherwise block forever on a
397
+ * piped/CI invocation. */
398
+ async function promptForQuestion() {
399
+ const rl = readlinePromises.createInterface({ input: process.stdin, output: process.stderr });
400
+ try {
401
+ const answer = await rl.question("Question: ");
402
+ return answer.trim() || undefined;
403
+ }
404
+ finally {
405
+ rl.close();
406
+ }
407
+ }
388
408
  /** `cw quickstart [app] --question ...` — composes plan -> runDrive ->
389
409
  * report in one call. Default app is architecture-review. `--check` is a
390
410
  * read-only preflight that never plans/drives/writes (see
@@ -392,7 +412,7 @@ function remoteQuickstartCheck(appId, args, candidate) {
392
412
  * projection (never drives), `--resume` advances one step (no --run) or
393
413
  * continues a named run to completion (--run <id>) — both ported byte-for-
394
414
  * byte from the old build's src/capability-core.ts quickstart(). */
395
- function quickstartRun(args) {
415
+ async function quickstartRun(args) {
396
416
  const appId = String(args.appId || args.app || args.workflowId || QUICKSTART_DEFAULT_APP);
397
417
  // Remote source: a `--link <url>` — or a URL passed to `--repo`/`-dir` — is
398
418
  // materialized to a LOCAL checkout HERE (capability/shell layer). Cloning is
@@ -406,6 +426,37 @@ function quickstartRun(args) {
406
426
  args.repo = invocationCwd(args);
407
427
  if (Boolean(args.check))
408
428
  return quickstartCheck(appId, args, remoteCandidate);
429
+ // `--resume`: a discoverability flag over the existing continuation. With no
430
+ // `--run`, advance exactly ONE step (reuse the `--once` path) and print a
431
+ // copy-pasteable continue line; with `--run <id>`, continue that run to
432
+ // completion (the default drive). It adds no new execution path. Byte-exact to
433
+ // the old build's src/capability-core.ts quickstart(). Hoisted above the TTY
434
+ // prompt below: `existingRunId` is the one fact (mirrored at both the
435
+ // `--preview` branch's `if (!previewRunId)` and the main path's `if
436
+ // (existingRunId) {...} else { run = plan(...) }`) that decides whether THIS
437
+ // invocation plans a fresh run at all — an existing run never needs a
438
+ // question, no matter which mode (--preview/--resume/plain) is asking for it.
439
+ const resume = Boolean(args.resume);
440
+ const existingRunId = String(args.runId || args.run || "");
441
+ const resumeRunId = resume && existingRunId ? existingRunId : undefined;
442
+ // On an interactive TTY with no --question, ask for one instead of
443
+ // failing closed with "Missing required input: question" (byte-behavior
444
+ // port of the old build's TTY prompt — SPEC/cli-surface.md:34,502). A
445
+ // blank/empty answer leaves `question` unset, same as never passing
446
+ // --question at all — plan()'s own required-input check still fails
447
+ // closed for an app that declares `question` required. Never fires
448
+ // under --check (returned above; --check reports a missing question as
449
+ // a preflight issue, not something to prompt for), off a TTY (a piped
450
+ // invocation must never block on stdin), or when `existingRunId` is set
451
+ // (a `--preview --run <id>` or `--resume --run <id>` continues an
452
+ // already-planned run and never calls `plan()` again, so it never
453
+ // needs a question — prompting there would block on stdin for no
454
+ // reason; a review on this cycle caught exactly that regression).
455
+ if (process.stdin.isTTY && !existingRunId && !(typeof args.question === "string" && args.question.trim())) {
456
+ const answer = await promptForQuestion();
457
+ if (answer)
458
+ args.question = answer;
459
+ }
409
460
  // Materialize the remote NOW — after `--check` (never fetches) and before any
410
461
  // plan/drive — so the core only ever sees the local checkout. Fails closed: a
411
462
  // bad URL / blocked scheme / missing git / fetch failure throws here.
@@ -424,14 +475,6 @@ function quickstartRun(args) {
424
475
  if (remoteSource.ref)
425
476
  args.sourceRef = remoteSource.ref;
426
477
  }
427
- // `--resume`: a discoverability flag over the existing continuation. With no
428
- // `--run`, advance exactly ONE step (reuse the `--once` path) and print a
429
- // copy-pasteable continue line; with `--run <id>`, continue that run to
430
- // completion (the default drive). It adds no new execution path. Byte-exact to
431
- // the old build's src/capability-core.ts quickstart().
432
- const resume = Boolean(args.resume);
433
- const existingRunId = String(args.runId || args.run || "");
434
- const resumeRunId = resume && existingRunId ? existingRunId : undefined;
435
478
  // `--preview`: read-only, deterministic next-step projection (no spawn, no
436
479
  // commit). Plan a fresh run (the read-only first verb) then project the next
437
480
  // drive step. Never drives.
@@ -464,7 +507,7 @@ function quickstartRun(args) {
464
507
  else {
465
508
  run = (0, pipeline_1.plan)(resolveWorkflowAppForPlan(appId), planInputsFor(args));
466
509
  }
467
- const result = (0, drive_1.drive)(run.id, run.cwd, options);
510
+ const result = await (0, drive_1.driveAsync)(run.id, run.cwd, options);
468
511
  const finalRun = (0, run_store_1.loadRunFromCwd)(run.id, run.cwd);
469
512
  (0, report_1.writeReport)(finalRun);
470
513
  // Tamper-evident provenance: bind the remote origin (url@sha) into the run's
@@ -552,84 +595,94 @@ function quickstartRun(args) {
552
595
  }
553
596
  function dispatchRun(args) {
554
597
  const runId = String(args.runId);
555
- const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
556
- // parseArgv keys long flags in kebab-case; accept camelCase as a fallback.
557
- const flag = (kebab, camel) => {
558
- const v = args[kebab] ?? args[camel];
559
- return typeof v === "string" && v.trim() ? v : undefined;
560
- };
561
- const manifest = (0, dispatch_1.createDispatchManifest)(run, args.limit !== undefined ? Number(args.limit) : undefined, {
562
- sandboxProfileId: typeof args.sandbox === "string" ? args.sandbox : undefined,
563
- sandbox: typeof args.sandbox === "string" ? args.sandbox : undefined,
564
- backendId: typeof args.backend === "string" ? args.backend : undefined,
565
- multiAgentRunId: flag("multi-agent-run", "multiAgentRun"),
566
- multiAgentGroupId: flag("multi-agent-group", "multiAgentGroup"),
567
- multiAgentRoleId: flag("multi-agent-role", "multiAgentRole"),
568
- multiAgentFanoutId: flag("multi-agent-fanout", "multiAgentFanout"),
598
+ // The whole load -> change -> save cycle holds the state.json lock so a
599
+ // concurrent dispatch/result on the same run cannot drop this update.
600
+ return (0, run_store_1.withRunStateLock)(runId, invocationCwd(args), (run) => {
601
+ // parseArgv keys long flags in kebab-case; accept camelCase as a fallback.
602
+ const flag = (kebab, camel) => {
603
+ const v = args[kebab] ?? args[camel];
604
+ return typeof v === "string" && v.trim() ? v : undefined;
605
+ };
606
+ const manifest = (0, dispatch_1.createDispatchManifest)(run, (0, numeric_flag_1.requiredNumberFlag)(args.limit, "--limit"), {
607
+ sandboxProfileId: typeof args.sandbox === "string" ? args.sandbox : undefined,
608
+ sandbox: typeof args.sandbox === "string" ? args.sandbox : undefined,
609
+ backendId: typeof args.backend === "string" ? args.backend : undefined,
610
+ multiAgentRunId: flag("multi-agent-run", "multiAgentRun"),
611
+ multiAgentGroupId: flag("multi-agent-group", "multiAgentGroup"),
612
+ multiAgentRoleId: flag("multi-agent-role", "multiAgentRole"),
613
+ multiAgentFanoutId: flag("multi-agent-fanout", "multiAgentFanout"),
614
+ });
615
+ if (manifest.dispatchId) {
616
+ (0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
617
+ (0, run_store_1.saveCheckpoint)(run);
618
+ (0, report_1.writeReport)(run);
619
+ }
620
+ return manifest;
569
621
  });
570
- if (manifest.dispatchId) {
571
- (0, commit_1.commitState)(run, `dispatch:${manifest.dispatchId}`);
572
- (0, run_store_1.saveCheckpoint)(run);
573
- (0, report_1.writeReport)(run);
574
- }
575
- return manifest;
576
622
  }
577
623
  function recordResultRun(args) {
578
624
  const runId = String(args.runId);
579
625
  const taskId = String(args.taskId);
580
626
  const resultPath = String(args.resultPath);
581
- const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
582
- const task = run.tasks.find((t) => t.id === taskId);
583
- if (!task || !task.workerId)
584
- throw new Error(`Unknown task id for run ${runId}: ${taskId}`);
585
- const absolute = path.resolve(resultPath);
586
- // A result path inside a system directory is never accepted (POLA): the
587
- // operator file gets copied into the worker's result.md, so a /etc/passwd
588
- // source would smuggle system content into a run. Byte-behavior port of the
589
- // old build's recordResult system-directory blacklist.
590
- if (/^\/(etc|bin|sbin|usr|Library|System|Applications|boot|dev|proc|sys|root|var\/log|var\/run)\//.test(absolute)) {
591
- throw new Error(`Result path must not be a system directory: ${resultPath}`);
592
- }
593
- if (!fs.existsSync(absolute))
594
- throw new Error(`Result file does not exist: ${resultPath}`);
595
- const workerId = String(task.workerId);
596
- // Host-attested `cw result <run> <task> <file>` intake: the operator hands CW
597
- // an EXTERNAL result file that lives OUTSIDE the worker's read-only write
598
- // boundary. The old task-level recordResult (lifecycle-operations.ts:279-280)
599
- // COPIED that external file into the run's results area and recorded the
600
- // internal path it never ran the external path through validateSandboxWrite.
601
- // v2 collapsed the two intakes into recordWorkerOutput, which sandbox-validates
602
- // its input against the worker boundary, so a bare external path is rejected
603
- // ("write path is outside sandbox profile <id>"). Restore the copy-in: stage
604
- // the operator file at the worker's OWN result.md (which IS inside the write
605
- // boundary), then record that internal path exactly like a driven worker.
606
- const manifest = (0, worker_isolation_1.showWorkerManifest)(run, workerId);
607
- fs.mkdirSync(path.dirname(manifest.resultPath), { recursive: true });
608
- fs.copyFileSync(absolute, manifest.resultPath);
609
- const output = (0, worker_isolation_1.recordWorkerOutput)(run, workerId, manifest.resultPath);
610
- // Host-attested token usage (v0.1.31): record it verbatim as provenance when
611
- // the operator supplied `--usage-*` flags; CW never synthesizes usage. The old
612
- // task-level recordResult set `task.usage = usage` (lifecycle-operations.ts:286)
613
- // and its unit was the TASK. v2 records through recordWorkerOutput, which gives
614
- // the worker an `output` record — so the observability usage UNIT becomes the
615
- // WORKER (deriveUsageTotals reads worker.usage for workers with output, and
616
- // EXCLUDES that task). Attach the usage to the worker scope so the report counts
617
- // it as an attested unit; also stamp task.usage for byte-parity with the old
618
- // task-level record.
619
- const usage = (0, observability_1.parseUsageFromArgs)(args, new Date().toISOString());
620
- if (usage) {
621
- task.usage = usage;
622
- const scope = (0, worker_isolation_1.getWorkerScope)(run, workerId);
623
- if (scope)
624
- scope.usage = usage;
625
- }
626
- // Byte-exact to the old build's orchestrator recordWorkerOutput()
627
- // wrapper: an accepted result is its own checkpoint commit, not just a
628
- // bare saveCheckpoint (SPEC/pipeline-run.md's persist-ordering rule).
629
- (0, commit_1.commitState)(run, `worker:${workerId}:result`);
630
- (0, run_store_1.saveCheckpoint)(run);
631
- (0, report_1.writeReport)(run);
632
- return output;
627
+ // Two processes recording results for two tasks of the SAME run used to
628
+ // race: both loaded, and the later saveCheckpoint dropped the earlier
629
+ // task's completion. The lock now covers the whole cycle.
630
+ return (0, run_store_1.withRunStateLock)(runId, invocationCwd(args), (run) => {
631
+ const task = run.tasks.find((t) => t.id === taskId);
632
+ if (!task || !task.workerId)
633
+ throw new Error(`Unknown task id for run ${runId}: ${taskId}`);
634
+ const absolute = path.resolve(resultPath);
635
+ // A result path inside a system directory is never accepted (POLA): the
636
+ // operator file gets copied into the worker's result.md, so a /etc/passwd
637
+ // source would smuggle system content into a run. Byte-behavior port of the
638
+ // old build's recordResult system-directory blacklist.
639
+ if (/^\/(etc|bin|sbin|usr|Library|System|Applications|boot|dev|proc|sys|root|var\/log|var\/run)\//.test(absolute)) {
640
+ throw new Error(`Result path must not be a system directory: ${resultPath}`);
641
+ }
642
+ if (!fs.existsSync(absolute))
643
+ throw new Error(`Result file does not exist: ${resultPath}`);
644
+ const workerId = String(task.workerId);
645
+ // Host-attested `cw result <run> <task> <file>` intake: the operator hands CW
646
+ // an EXTERNAL result file that lives OUTSIDE the worker's read-only write
647
+ // boundary. The old task-level recordResult (lifecycle-operations.ts:279-280)
648
+ // COPIED that external file into the run's results area and recorded the
649
+ // internal path it never ran the external path through validateSandboxWrite.
650
+ // v2 collapsed the two intakes into recordWorkerOutput, which sandbox-validates
651
+ // its input against the worker boundary, so a bare external path is rejected
652
+ // ("write path is outside sandbox profile <id>"). Restore the copy-in: stage
653
+ // the operator file at the worker's OWN result.md (which IS inside the write
654
+ // boundary), then record that internal path exactly like a driven worker.
655
+ const manifest = (0, worker_isolation_1.showWorkerManifest)(run, workerId);
656
+ fs.mkdirSync(path.dirname(manifest.resultPath), { recursive: true });
657
+ fs.copyFileSync(absolute, manifest.resultPath);
658
+ const output = (0, worker_isolation_1.recordWorkerOutput)(run, workerId, manifest.resultPath, {
659
+ requireAttestedTelemetry: (0, agent_config_1.resolveAgentConfig)(args).requireAttestedTelemetry,
660
+ allowUnattested: Boolean(args.allowUnattested ?? args["allow-unattested"]),
661
+ });
662
+ // Host-attested token usage (v0.1.31): record it verbatim as provenance when
663
+ // the operator supplied `--usage-*` flags; CW never synthesizes usage. The old
664
+ // task-level recordResult set `task.usage = usage` (lifecycle-operations.ts:286)
665
+ // and its unit was the TASK. v2 records through recordWorkerOutput, which gives
666
+ // the worker an `output` record — so the observability usage UNIT becomes the
667
+ // WORKER (deriveUsageTotals reads worker.usage for workers with output, and
668
+ // EXCLUDES that task). Attach the usage to the worker scope so the report counts
669
+ // it as an attested unit; also stamp task.usage for byte-parity with the old
670
+ // task-level record.
671
+ const usage = (0, observability_1.parseUsageFromArgs)(args, new Date().toISOString());
672
+ if (usage) {
673
+ task.usage = usage;
674
+ const scope = (0, worker_isolation_1.getWorkerScope)(run, workerId);
675
+ if (scope)
676
+ scope.usage = usage;
677
+ }
678
+ // Byte-exact to the old build's orchestrator recordWorkerOutput()
679
+ // wrapper: an accepted result is its own checkpoint commit, not just a
680
+ // bare saveCheckpoint (SPEC/pipeline-run.md's persist-ordering rule).
681
+ (0, commit_1.commitState)(run, `worker:${workerId}:result`);
682
+ (0, run_store_1.saveCheckpoint)(run);
683
+ (0, report_1.writeReport)(run);
684
+ return output;
685
+ });
633
686
  }
634
687
  /** `cw commit <run-id>` — byte-exact port of the old build's
635
688
  * `orchestrator/lifecycle-operations.ts`'s `commit()`: the CLI/MCP
@@ -639,29 +692,33 @@ function recordResultRun(args) {
639
692
  * still leaves the run's report/state current on disk. */
640
693
  function commitRun(args) {
641
694
  const runId = String(args.runId);
642
- const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
643
- const allowCheckpoint = Boolean(args.allowUnverifiedCheckpoint || args["allow-unverified-checkpoint"]);
644
- const hasGateOption = Boolean(args.verifier || args.verifierNode || args["verifier-node"] || args.candidate || args.selection);
645
- try {
646
- const commit = (0, commit_1.commitState)(run, {
647
- reason: typeof args.reason === "string" && args.reason ? args.reason : "manual",
648
- verifierNodeId: (typeof args.verifier === "string" && args.verifier) ||
649
- (typeof args.verifierNode === "string" && args.verifierNode) ||
650
- (typeof args["verifier-node"] === "string" && args["verifier-node"]) ||
651
- undefined,
652
- candidateId: typeof args.candidate === "string" ? args.candidate : undefined,
653
- selectionId: typeof args.selection === "string" ? args.selection : undefined,
654
- verifierGated: hasGateOption || !allowCheckpoint,
655
- allowUnverifiedCheckpoint: allowCheckpoint,
656
- source: "cli",
657
- });
658
- (0, report_1.writeReport)(run);
659
- (0, run_store_1.saveCheckpoint)(run);
660
- return { runId: run.id, commit };
661
- }
662
- catch (error) {
663
- (0, report_1.writeReport)(run);
664
- (0, run_store_1.saveCheckpoint)(run);
665
- throw error;
666
- }
695
+ // Hold the state.json lock across the whole load -> commit -> save (both the
696
+ // success and the fail-closed catch persist) so a concurrent run mutation
697
+ // cannot drop this commit (lost-update class, matching dispatchRun/recordResultRun).
698
+ return (0, run_store_1.withRunStateLock)(runId, invocationCwd(args), (run) => {
699
+ const allowCheckpoint = Boolean(args.allowUnverifiedCheckpoint || args["allow-unverified-checkpoint"]);
700
+ const hasGateOption = Boolean(args.verifier || args.verifierNode || args["verifier-node"] || args.candidate || args.selection);
701
+ try {
702
+ const commit = (0, commit_1.commitState)(run, {
703
+ reason: typeof args.reason === "string" && args.reason ? args.reason : "manual",
704
+ verifierNodeId: (typeof args.verifier === "string" && args.verifier) ||
705
+ (typeof args.verifierNode === "string" && args.verifierNode) ||
706
+ (typeof args["verifier-node"] === "string" && args["verifier-node"]) ||
707
+ undefined,
708
+ candidateId: typeof args.candidate === "string" ? args.candidate : undefined,
709
+ selectionId: typeof args.selection === "string" ? args.selection : undefined,
710
+ verifierGated: hasGateOption || !allowCheckpoint,
711
+ allowUnverifiedCheckpoint: allowCheckpoint,
712
+ source: "cli",
713
+ });
714
+ (0, report_1.writeReport)(run);
715
+ (0, run_store_1.saveCheckpoint)(run);
716
+ return { runId: run.id, commit };
717
+ }
718
+ catch (error) {
719
+ (0, report_1.writeReport)(run);
720
+ (0, run_store_1.saveCheckpoint)(run);
721
+ throw error;
722
+ }
723
+ });
667
724
  }
@@ -106,6 +106,7 @@ const node_store_1 = require("./node-store");
106
106
  const node_snapshot_1 = require("../core/state/node-snapshot");
107
107
  const node_projection_1 = require("../core/state/node-projection");
108
108
  const hash_1 = require("../core/hash");
109
+ const collate_1 = require("../core/util/collate");
109
110
  const run_registry_io_1 = require("./run-registry-io");
110
111
  // ---------------------------------------------------------------------------
111
112
  // Content addressing + byte measurement (in-process, no `du`) — carried
@@ -638,6 +639,16 @@ function freeBulk(run, tombstone) {
638
639
  let freedBytes = 0;
639
640
  for (const entry of tombstone.freed) {
640
641
  const abs = path.join(runDir, entry.path);
642
+ // planReclamation always derives entry.path as a relative path already
643
+ // confined under runDir, but a tampered/imported state.json could carry a
644
+ // worker/artifact path that resolves outside it — re-check containment
645
+ // right before the recursive delete so that can never turn into an
646
+ // out-of-tree rmSync.
647
+ if (!(0, fs_atomic_1.isContainedPath)(abs, runDir)) {
648
+ throw new ReclamationError("unsafe-free-path", `refusing to free path outside the run directory: ${entry.path}`, {
649
+ path: entry.path,
650
+ });
651
+ }
641
652
  const before = dirBytes(abs);
642
653
  fs.rmSync(abs, { recursive: true, force: true });
643
654
  freedBytes += before;
@@ -866,7 +877,7 @@ function gcPlan(host, options = {}) {
866
877
  eligibleCount,
867
878
  bytesToFree,
868
879
  entries,
869
- nextAction: eligibleCount ? "node scripts/cw.js gc run" : "node scripts/cw.js run search",
880
+ nextAction: eligibleCount ? "cw gc run" : "cw run search",
870
881
  };
871
882
  }
872
883
  function gcRun(host, options = {}) {
@@ -935,7 +946,7 @@ function gcRun(host, options = {}) {
935
946
  reclaimed,
936
947
  refused,
937
948
  totalBytesFreed,
938
- nextAction: reclaimed.length ? "node scripts/cw.js gc verify <run-id>" : "node scripts/cw.js gc plan",
949
+ nextAction: reclaimed.length ? "cw gc verify <run-id>" : "cw gc plan",
939
950
  };
940
951
  }
941
952
  function gcVerify(host, runId, options = {}) {
@@ -951,7 +962,7 @@ function gcVerify(host, runId, options = {}) {
951
962
  capability: "re-runnable",
952
963
  chainLength: 0,
953
964
  checks: [{ name: "located", pass: false, code: "not-reclaimed", detail: "run source not found" }],
954
- nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : ""),
965
+ nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
955
966
  };
956
967
  }
957
968
  const run = host.loadRun(located.record.repo, runId);
@@ -984,7 +995,7 @@ function gcVerify(host, runId, options = {}) {
984
995
  tombstoneHash: last?.tombstoneHash,
985
996
  chainLength: result.tombstones.length,
986
997
  checks,
987
- nextAction: verified ? "node scripts/cw.js run show " + runId : "node scripts/cw.js gc plan",
998
+ nextAction: verified ? "cw run show " + runId : "cw gc plan",
988
999
  };
989
1000
  }
990
1001
  // ---------------------------------------------------------------------------
@@ -1287,7 +1298,7 @@ function readCloneEntries(root) {
1287
1298
  bytes: dirSize(dir),
1288
1299
  });
1289
1300
  }
1290
- entries.sort((a, b) => (a.fetchedAt || "").localeCompare(b.fetchedAt || ""));
1301
+ entries.sort((a, b) => (0, collate_1.stableCompare)(a.fetchedAt || "", b.fetchedAt || ""));
1291
1302
  return entries;
1292
1303
  }
1293
1304
  /** `cw clones list` — every cached remote checkout with its origin,