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
@@ -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");
@@ -157,6 +159,24 @@ function planInputsFor(args) {
157
159
  function invocationCwd(args) {
158
160
  return typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd) : process.cwd();
159
161
  }
162
+ /** Same as invocationCwd, but ALSO falls back to `--repo` before
163
+ * process.cwd() -- for resolving an EXISTING run's directory specifically
164
+ * (loadRunFromCwd's target), never for a fresh plan()'s repo input (see
165
+ * planRun's own comment on why THAT path deliberately never auto-fills
166
+ * repo from cwd). A caller resuming a run they didn't originally start
167
+ * from this exact directory (the common case: `cw run resume <id>
168
+ * --drive` or `cw quickstart <app> --resume --run <id>` typed from
169
+ * wherever the terminal happens to be, not necessarily the run's own
170
+ * --repo) must not have that already-known fact silently discarded in
171
+ * favor of process.cwd() -- this mirrors the --preview branch's own
172
+ * repoCwd fallback a few lines below, which already gets this right. */
173
+ function existingRunCwd(args) {
174
+ if (typeof args.cwd === "string" && args.cwd.trim())
175
+ return path.resolve(args.cwd);
176
+ if (typeof args.repo === "string" && args.repo.trim())
177
+ return path.resolve(args.repo);
178
+ return process.cwd();
179
+ }
160
180
  /** True for a value that counts as "not supplied" — byte-exact to the old
161
181
  * build's cli-options.isMissing (undefined / null / empty string). */
162
182
  function isMissingInput(value) {
@@ -223,8 +243,11 @@ function runDrivePreview(args) {
223
243
  return (0, drive_1.drivePreview)(runId, cwd, args);
224
244
  }
225
245
  /** `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) {
246
+ * `--run` continues an existing one) and drives it. Uses driveAsync (not
247
+ * the plain synchronous drive()) so a live multi-round run actually
248
+ * responds to Ctrl-C/SIGTERM instead of silently ignoring it — see
249
+ * shell/drive.ts's driveAsync doc comment. */
250
+ async function runDriveStep(args) {
228
251
  const existingRunId = String(args.runId || args.run || "");
229
252
  const options = {
230
253
  once: Boolean(args.once),
@@ -234,9 +257,9 @@ function runDriveStep(args) {
234
257
  incremental: Boolean(args.incremental),
235
258
  };
236
259
  if (existingRunId) {
237
- const cwd = invocationCwd(args);
260
+ const cwd = existingRunCwd(args);
238
261
  const run = (0, run_store_1.loadRunFromCwd)(existingRunId, cwd);
239
- return (0, drive_1.drive)(existingRunId, run.cwd, options);
262
+ return (0, drive_1.driveAsync)(existingRunId, run.cwd, options);
240
263
  }
241
264
  const appId = String(args.appId || args.app || args.positionalApp || "");
242
265
  if (!appId)
@@ -245,7 +268,7 @@ function runDriveStep(args) {
245
268
  args.repo = invocationCwd(args);
246
269
  const app = (0, workflow_app_loader_1.loadWorkflowApp)(appId);
247
270
  const run = (0, pipeline_1.plan)(app, planInputsFor(args));
248
- return (0, drive_1.drive)(run.id, run.cwd, options);
271
+ return (0, drive_1.driveAsync)(run.id, run.cwd, options);
249
272
  }
250
273
  /** `cw quickstart [app] --check` — read-only preflight: does the app
251
274
  * resolve, is the repo readable/writable, is a question set, is an
@@ -385,6 +408,21 @@ function remoteQuickstartCheck(appId, args, candidate) {
385
408
  const nextCommand = `cw quickstart ${shellWord(appId)} --link ${shellWord(validation.url)}${question ? ` --question ${shellWord(question)}` : ""}`;
386
409
  return { schemaVersion: 1, mode: "check", ok, appId, repo: validation.url, checks, nextCommand };
387
410
  }
411
+ /** Prompts `Question: ` on STDERR (stdout stays data-only, matching the
412
+ * Rule of Silence) and reads one line from stdin. Returns the trimmed
413
+ * answer, or `undefined` for a blank line. Callers must only invoke this
414
+ * when `process.stdin.isTTY` — it would otherwise block forever on a
415
+ * piped/CI invocation. */
416
+ async function promptForQuestion() {
417
+ const rl = readlinePromises.createInterface({ input: process.stdin, output: process.stderr });
418
+ try {
419
+ const answer = await rl.question("Question: ");
420
+ return answer.trim() || undefined;
421
+ }
422
+ finally {
423
+ rl.close();
424
+ }
425
+ }
388
426
  /** `cw quickstart [app] --question ...` — composes plan -> runDrive ->
389
427
  * report in one call. Default app is architecture-review. `--check` is a
390
428
  * read-only preflight that never plans/drives/writes (see
@@ -392,7 +430,7 @@ function remoteQuickstartCheck(appId, args, candidate) {
392
430
  * projection (never drives), `--resume` advances one step (no --run) or
393
431
  * continues a named run to completion (--run <id>) — both ported byte-for-
394
432
  * byte from the old build's src/capability-core.ts quickstart(). */
395
- function quickstartRun(args) {
433
+ async function quickstartRun(args) {
396
434
  const appId = String(args.appId || args.app || args.workflowId || QUICKSTART_DEFAULT_APP);
397
435
  // Remote source: a `--link <url>` — or a URL passed to `--repo`/`-dir` — is
398
436
  // materialized to a LOCAL checkout HERE (capability/shell layer). Cloning is
@@ -406,6 +444,37 @@ function quickstartRun(args) {
406
444
  args.repo = invocationCwd(args);
407
445
  if (Boolean(args.check))
408
446
  return quickstartCheck(appId, args, remoteCandidate);
447
+ // `--resume`: a discoverability flag over the existing continuation. With no
448
+ // `--run`, advance exactly ONE step (reuse the `--once` path) and print a
449
+ // copy-pasteable continue line; with `--run <id>`, continue that run to
450
+ // completion (the default drive). It adds no new execution path. Byte-exact to
451
+ // the old build's src/capability-core.ts quickstart(). Hoisted above the TTY
452
+ // prompt below: `existingRunId` is the one fact (mirrored at both the
453
+ // `--preview` branch's `if (!previewRunId)` and the main path's `if
454
+ // (existingRunId) {...} else { run = plan(...) }`) that decides whether THIS
455
+ // invocation plans a fresh run at all — an existing run never needs a
456
+ // question, no matter which mode (--preview/--resume/plain) is asking for it.
457
+ const resume = Boolean(args.resume);
458
+ const existingRunId = String(args.runId || args.run || "");
459
+ const resumeRunId = resume && existingRunId ? existingRunId : undefined;
460
+ // On an interactive TTY with no --question, ask for one instead of
461
+ // failing closed with "Missing required input: question" (byte-behavior
462
+ // port of the old build's TTY prompt — SPEC/cli-surface.md:34,502). A
463
+ // blank/empty answer leaves `question` unset, same as never passing
464
+ // --question at all — plan()'s own required-input check still fails
465
+ // closed for an app that declares `question` required. Never fires
466
+ // under --check (returned above; --check reports a missing question as
467
+ // a preflight issue, not something to prompt for), off a TTY (a piped
468
+ // invocation must never block on stdin), or when `existingRunId` is set
469
+ // (a `--preview --run <id>` or `--resume --run <id>` continues an
470
+ // already-planned run and never calls `plan()` again, so it never
471
+ // needs a question — prompting there would block on stdin for no
472
+ // reason; a review on this cycle caught exactly that regression).
473
+ if (process.stdin.isTTY && !existingRunId && !(typeof args.question === "string" && args.question.trim())) {
474
+ const answer = await promptForQuestion();
475
+ if (answer)
476
+ args.question = answer;
477
+ }
409
478
  // Materialize the remote NOW — after `--check` (never fetches) and before any
410
479
  // plan/drive — so the core only ever sees the local checkout. Fails closed: a
411
480
  // bad URL / blocked scheme / missing git / fetch failure throws here.
@@ -424,14 +493,6 @@ function quickstartRun(args) {
424
493
  if (remoteSource.ref)
425
494
  args.sourceRef = remoteSource.ref;
426
495
  }
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
496
  // `--preview`: read-only, deterministic next-step projection (no spawn, no
436
497
  // commit). Plan a fresh run (the read-only first verb) then project the next
437
498
  // drive step. Never drives.
@@ -459,12 +520,12 @@ function quickstartRun(args) {
459
520
  };
460
521
  let run;
461
522
  if (existingRunId) {
462
- run = (0, run_store_1.loadRunFromCwd)(existingRunId, invocationCwd(args));
523
+ run = (0, run_store_1.loadRunFromCwd)(existingRunId, existingRunCwd(args));
463
524
  }
464
525
  else {
465
526
  run = (0, pipeline_1.plan)(resolveWorkflowAppForPlan(appId), planInputsFor(args));
466
527
  }
467
- const result = (0, drive_1.drive)(run.id, run.cwd, options);
528
+ const result = await (0, drive_1.driveAsync)(run.id, run.cwd, options);
468
529
  const finalRun = (0, run_store_1.loadRunFromCwd)(run.id, run.cwd);
469
530
  (0, report_1.writeReport)(finalRun);
470
531
  // Tamper-evident provenance: bind the remote origin (url@sha) into the run's
@@ -560,7 +621,7 @@ function dispatchRun(args) {
560
621
  const v = args[kebab] ?? args[camel];
561
622
  return typeof v === "string" && v.trim() ? v : undefined;
562
623
  };
563
- const manifest = (0, dispatch_1.createDispatchManifest)(run, args.limit !== undefined ? Number(args.limit) : undefined, {
624
+ const manifest = (0, dispatch_1.createDispatchManifest)(run, (0, numeric_flag_1.requiredNumberFlag)(args.limit, "--limit"), {
564
625
  sandboxProfileId: typeof args.sandbox === "string" ? args.sandbox : undefined,
565
626
  sandbox: typeof args.sandbox === "string" ? args.sandbox : undefined,
566
627
  backendId: typeof args.backend === "string" ? args.backend : undefined,
@@ -649,29 +710,33 @@ function recordResultRun(args) {
649
710
  * still leaves the run's report/state current on disk. */
650
711
  function commitRun(args) {
651
712
  const runId = String(args.runId);
652
- const run = (0, run_store_1.loadRunFromCwd)(runId, invocationCwd(args));
653
- const allowCheckpoint = Boolean(args.allowUnverifiedCheckpoint || args["allow-unverified-checkpoint"]);
654
- const hasGateOption = Boolean(args.verifier || args.verifierNode || args["verifier-node"] || args.candidate || args.selection);
655
- try {
656
- const commit = (0, commit_1.commitState)(run, {
657
- reason: typeof args.reason === "string" && args.reason ? args.reason : "manual",
658
- verifierNodeId: (typeof args.verifier === "string" && args.verifier) ||
659
- (typeof args.verifierNode === "string" && args.verifierNode) ||
660
- (typeof args["verifier-node"] === "string" && args["verifier-node"]) ||
661
- undefined,
662
- candidateId: typeof args.candidate === "string" ? args.candidate : undefined,
663
- selectionId: typeof args.selection === "string" ? args.selection : undefined,
664
- verifierGated: hasGateOption || !allowCheckpoint,
665
- allowUnverifiedCheckpoint: allowCheckpoint,
666
- source: "cli",
667
- });
668
- (0, report_1.writeReport)(run);
669
- (0, run_store_1.saveCheckpoint)(run);
670
- return { runId: run.id, commit };
671
- }
672
- catch (error) {
673
- (0, report_1.writeReport)(run);
674
- (0, run_store_1.saveCheckpoint)(run);
675
- throw error;
676
- }
713
+ // Hold the state.json lock across the whole load -> commit -> save (both the
714
+ // success and the fail-closed catch persist) so a concurrent run mutation
715
+ // cannot drop this commit (lost-update class, matching dispatchRun/recordResultRun).
716
+ return (0, run_store_1.withRunStateLock)(runId, invocationCwd(args), (run) => {
717
+ const allowCheckpoint = Boolean(args.allowUnverifiedCheckpoint || args["allow-unverified-checkpoint"]);
718
+ const hasGateOption = Boolean(args.verifier || args.verifierNode || args["verifier-node"] || args.candidate || args.selection);
719
+ try {
720
+ const commit = (0, commit_1.commitState)(run, {
721
+ reason: typeof args.reason === "string" && args.reason ? args.reason : "manual",
722
+ verifierNodeId: (typeof args.verifier === "string" && args.verifier) ||
723
+ (typeof args.verifierNode === "string" && args.verifierNode) ||
724
+ (typeof args["verifier-node"] === "string" && args["verifier-node"]) ||
725
+ undefined,
726
+ candidateId: typeof args.candidate === "string" ? args.candidate : undefined,
727
+ selectionId: typeof args.selection === "string" ? args.selection : undefined,
728
+ verifierGated: hasGateOption || !allowCheckpoint,
729
+ allowUnverifiedCheckpoint: allowCheckpoint,
730
+ source: "cli",
731
+ });
732
+ (0, report_1.writeReport)(run);
733
+ (0, run_store_1.saveCheckpoint)(run);
734
+ return { runId: run.id, commit };
735
+ }
736
+ catch (error) {
737
+ (0, report_1.writeReport)(run);
738
+ (0, run_store_1.saveCheckpoint)(run);
739
+ throw error;
740
+ }
741
+ });
677
742
  }
@@ -472,6 +472,31 @@ function planReclamation(run, policy = {}) {
472
472
  }
473
473
  }
474
474
  }
475
+ // (3) Superseded, non-verifier-gated commit snapshots. Each commitState()
476
+ // call embeds the FULL run into commits/<id>.json, so these grow without
477
+ // bound (both in count and per-file size) with no reclamation path today.
478
+ // Only the run's LATEST commit and any verifier-gated commit (the actual
479
+ // audit-significant milestones) are kept — an intermediate, non-gated
480
+ // "checkpoint" commit's only value is as a point-in-time snapshot, and
481
+ // state.json (not commits/) is the source of truth for resume. Not
482
+ // reconstructable (no recipe): a commit snapshot is a genuine
483
+ // point-in-time capture, not a projection derivable from retained data.
484
+ let reclaimedCommitSnapshot = false;
485
+ if (!policy.keepCommits) {
486
+ const commits = run.commits || [];
487
+ for (let i = 0; i < commits.length - 1; i++) {
488
+ const commit = commits[i];
489
+ if (commit.verifierGated)
490
+ continue;
491
+ if (!commit.snapshotPath || !fs.existsSync(commit.snapshotPath))
492
+ continue;
493
+ const bytes = dirBytes(commit.snapshotPath);
494
+ if (bytes <= 0)
495
+ continue;
496
+ freeable.push({ path: rel(commit.snapshotPath), absPath: commit.snapshotPath, kind: "commit-snapshot", bytes });
497
+ reclaimedCommitSnapshot = true;
498
+ }
499
+ }
475
500
  // Determinism (HARD constraint): sort by path BEFORE anything hashes it,
476
501
  // so tombstoneHash is reproducible across hosts regardless of
477
502
  // fs.readdirSync's filesystem-dependent order.
@@ -484,7 +509,15 @@ function planReclamation(run, policy = {}) {
484
509
  }
485
510
  let capability = "re-runnable";
486
511
  let capabilityReason = "scratch-only-reclaimed";
487
- if (reclaimedSnapshot && reconstructableSnapshot) {
512
+ // A reclaimed commit snapshot is never reconstructable (a genuine
513
+ // point-in-time capture, no recipe) — it caps capability at "verify-only"
514
+ // regardless of what node-snapshot reclamation achieved, same as an
515
+ // unreconstructable node snapshot would.
516
+ if (reclaimedCommitSnapshot) {
517
+ capability = "verify-only";
518
+ capabilityReason = "snapshot-reclaimed-no-reconstruction";
519
+ }
520
+ else if (reclaimedSnapshot && reconstructableSnapshot) {
488
521
  capability = "re-runnable-by-reconstruction";
489
522
  capabilityReason = "inputs-and-expectdigest-retained";
490
523
  }
@@ -595,13 +628,22 @@ function commitTombstone(run, tombstone) {
595
628
  function prepareFree(run, tombstone) {
596
629
  const runDir = run.paths.runDir;
597
630
  const scratchDirs = tombstone.freed.filter((f) => f.kind === "scratch").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
598
- if (!scratchDirs.length)
631
+ const commitSnapshotPaths = tombstone.freed.filter((f) => f.kind === "commit-snapshot").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
632
+ if (!scratchDirs.length && !commitSnapshotPaths.length)
599
633
  return;
600
634
  const repointed = new Set();
601
635
  for (const scratchDir of scratchDirs) {
602
636
  for (const id of repointResultNodeArtifacts(run, scratchDir))
603
637
  repointed.add(id);
604
638
  }
639
+ // Unlike scratch (which has a retained "result" artifact to repoint to),
640
+ // a reclaimed commit snapshot has no surviving alternative — its OWN
641
+ // StateNode's "snapshot" artifact (recordCommitNode, shell/commit.ts) is
642
+ // the only reference to it, so it is stripped outright rather than
643
+ // repointed. node.outputs.snapshotPath (a plain metadata string, not an
644
+ // artifact the check below inspects) is left as a historical record,
645
+ // same as commit.snapshotPath itself staying in state.json.
646
+ stripCommitSnapshotArtifacts(run, commitSnapshotPaths);
605
647
  persistRunDurable(run);
606
648
  for (const node of run.nodes || []) {
607
649
  for (const artifact of node.artifacts || []) {
@@ -613,6 +655,9 @@ function prepareFree(run, tombstone) {
613
655
  throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed scratch path ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
614
656
  }
615
657
  }
658
+ if (commitSnapshotPaths.includes(resolved)) {
659
+ throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed commit snapshot ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
660
+ }
616
661
  }
617
662
  }
618
663
  for (const nodeId of repointed) {
@@ -639,6 +684,16 @@ function freeBulk(run, tombstone) {
639
684
  let freedBytes = 0;
640
685
  for (const entry of tombstone.freed) {
641
686
  const abs = path.join(runDir, entry.path);
687
+ // planReclamation always derives entry.path as a relative path already
688
+ // confined under runDir, but a tampered/imported state.json could carry a
689
+ // worker/artifact path that resolves outside it — re-check containment
690
+ // right before the recursive delete so that can never turn into an
691
+ // out-of-tree rmSync.
692
+ if (!(0, fs_atomic_1.isContainedPath)(abs, runDir)) {
693
+ throw new ReclamationError("unsafe-free-path", `refusing to free path outside the run directory: ${entry.path}`, {
694
+ path: entry.path,
695
+ });
696
+ }
642
697
  const before = dirBytes(abs);
643
698
  fs.rmSync(abs, { recursive: true, force: true });
644
699
  freedBytes += before;
@@ -672,6 +727,28 @@ function repointResultNodeArtifacts(run, freedScratchDir) {
672
727
  }
673
728
  return changedIds;
674
729
  }
730
+ /** Removes the "snapshot" artifact entry from any node that references one
731
+ * of `freedCommitSnapshotPaths` — there is no retained alternative to
732
+ * repoint to (unlike a scratch dir's "result" copy), so the reference is
733
+ * dropped outright. StateArtifact.path is a required string, so the
734
+ * artifact entry is filtered out rather than nulled. */
735
+ function stripCommitSnapshotArtifacts(run, freedCommitSnapshotPaths) {
736
+ if (!freedCommitSnapshotPaths.length)
737
+ return [];
738
+ const freedSet = new Set(freedCommitSnapshotPaths);
739
+ const changedIds = [];
740
+ for (const node of run.nodes || []) {
741
+ if (!node.artifacts || !node.artifacts.length)
742
+ continue;
743
+ const before = node.artifacts.length;
744
+ node.artifacts = node.artifacts.filter((artifact) => !artifact.path || !freedSet.has((0, fs_atomic_1.realResolve)(artifact.path)));
745
+ if (node.artifacts.length !== before) {
746
+ node.updatedAt = new Date().toISOString();
747
+ changedIds.push(node.id);
748
+ }
749
+ }
750
+ return changedIds;
751
+ }
675
752
  /** Execute the write-ahead, fail-closed reclamation transaction. */
676
753
  function runReclamation(run, options = {}) {
677
754
  const skeleton = extractSkeleton(run);
@@ -817,7 +894,7 @@ function gcPlan(host, options = {}) {
817
894
  let plan;
818
895
  try {
819
896
  const run = host.loadRun(record.repo, record.runId);
820
- plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots });
897
+ plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits });
821
898
  }
822
899
  catch {
823
900
  entries.push({
@@ -861,13 +938,14 @@ function gcPlan(host, options = {}) {
861
938
  reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays ?? 0,
862
939
  keepSnapshots: Boolean(policy.keepSnapshots),
863
940
  keepScratch: Boolean(policy.keepScratch),
941
+ keepCommits: Boolean(policy.keepCommits),
864
942
  reclaimStates: policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"],
865
943
  },
866
944
  total: entries.length,
867
945
  eligibleCount,
868
946
  bytesToFree,
869
947
  entries,
870
- nextAction: eligibleCount ? "node scripts/cw.js gc run" : "node scripts/cw.js run search",
948
+ nextAction: eligibleCount ? "cw gc run" : "cw run search",
871
949
  };
872
950
  }
873
951
  function gcRun(host, options = {}) {
@@ -901,8 +979,8 @@ function gcRun(host, options = {}) {
901
979
  const result = runReclamation(run, {
902
980
  now: nowIso,
903
981
  actor: options.actor,
904
- policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
905
- reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
982
+ policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
983
+ reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
906
984
  });
907
985
  reclaimed.push({
908
986
  runId: record.runId,
@@ -936,7 +1014,7 @@ function gcRun(host, options = {}) {
936
1014
  reclaimed,
937
1015
  refused,
938
1016
  totalBytesFreed,
939
- nextAction: reclaimed.length ? "node scripts/cw.js gc verify <run-id>" : "node scripts/cw.js gc plan",
1017
+ nextAction: reclaimed.length ? "cw gc verify <run-id>" : "cw gc plan",
940
1018
  };
941
1019
  }
942
1020
  function gcVerify(host, runId, options = {}) {
@@ -952,7 +1030,7 @@ function gcVerify(host, runId, options = {}) {
952
1030
  capability: "re-runnable",
953
1031
  chainLength: 0,
954
1032
  checks: [{ name: "located", pass: false, code: "not-reclaimed", detail: "run source not found" }],
955
- nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : ""),
1033
+ nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
956
1034
  };
957
1035
  }
958
1036
  const run = host.loadRun(located.record.repo, runId);
@@ -985,7 +1063,7 @@ function gcVerify(host, runId, options = {}) {
985
1063
  tombstoneHash: last?.tombstoneHash,
986
1064
  chainLength: result.tombstones.length,
987
1065
  checks,
988
- nextAction: verified ? "node scripts/cw.js run show " + runId : "node scripts/cw.js gc plan",
1066
+ nextAction: verified ? "cw run show " + runId : "cw gc plan",
989
1067
  };
990
1068
  }
991
1069
  // ---------------------------------------------------------------------------
@@ -994,7 +1072,7 @@ function gcVerify(host, runId, options = {}) {
994
1072
  function formatGcPlan(result) {
995
1073
  const lines = [
996
1074
  `GC Plan (${result.scope}): ${result.eligibleCount}/${result.total} eligible, ${result.bytesToFree} byte(s) would be freed [DRY-RUN, frees nothing]`,
997
- ` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots}`,
1075
+ ` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots} keepCommits=${result.policy.keepCommits}`,
998
1076
  ];
999
1077
  for (const entry of result.entries) {
1000
1078
  if (entry.eligible) {
@@ -82,6 +82,7 @@ exports.clonesListCli = clonesListCli;
82
82
  exports.clonesGcCli = clonesGcCli;
83
83
  const fs = __importStar(require("node:fs"));
84
84
  const path = __importStar(require("node:path"));
85
+ const numeric_flag_1 = require("../core/util/numeric-flag");
85
86
  const run_registry_io_1 = require("./run-registry-io");
86
87
  const pipeline_1 = require("./pipeline");
87
88
  const workflow_app_loader_1 = require("./workflow-app-loader");
@@ -186,16 +187,16 @@ function runSearchCli(options = {}) {
186
187
  since: optionalString(options.since),
187
188
  until: optionalString(options.until),
188
189
  includeArchived: options.includeArchived === undefined ? undefined : Boolean(options.includeArchived),
189
- limit: options.limit === undefined ? undefined : Number(options.limit),
190
- offset: options.offset === undefined ? undefined : Number(options.offset),
190
+ limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
191
+ offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
191
192
  });
192
193
  }
193
194
  function runListCli(options = {}) {
194
195
  return new run_registry_io_1.RunRegistry(resolveCwd(options)).list({
195
196
  scope: scopeOf(options, "home"),
196
197
  includeArchived: options.includeArchived === undefined ? undefined : Boolean(options.includeArchived),
197
- limit: options.limit === undefined ? undefined : Number(options.limit),
198
- offset: options.offset === undefined ? undefined : Number(options.offset),
198
+ limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
199
+ offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
199
200
  });
200
201
  }
201
202
  function runShowCli(runId, options = {}) {
@@ -205,21 +206,21 @@ function runShowCli(runId, options = {}) {
205
206
  * is read-only and byte-identical to the registry resume payload; with
206
207
  * `--drive`/`--once` the SAME run (nothing re-planned) is handed to the
207
208
  * real drive loop and the payload gains a `drive: DriveResult` field. */
208
- function runResumeCli(runId, options = {}) {
209
+ async function runResumeCli(runId, options = {}) {
209
210
  const base = new run_registry_io_1.RunRegistry(resolveCwd(options)).resume(runId, {
210
211
  scope: scopeOf(options, "home"),
211
- limit: options.limit === undefined ? undefined : Number(options.limit),
212
+ limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
212
213
  });
213
214
  if (!options.drive && !options.once)
214
215
  return base;
215
- const drive = (0, pipeline_cli_1.runDriveStep)({ ...options, runId: base.runId, repo: base.repo, once: Boolean(options.once) });
216
+ const drive = await (0, pipeline_cli_1.runDriveStep)({ ...options, runId: base.runId, repo: base.repo, once: Boolean(options.once) });
216
217
  return { ...base, drive };
217
218
  }
218
219
  function runArchiveCli(runId, options = {}) {
219
220
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
220
221
  if (!runId) {
221
- const olderThanDays = Number(options.olderThanDays ?? options["older-than-days"]);
222
- if (!Number.isFinite(olderThanDays))
222
+ const olderThanDays = (0, numeric_flag_1.requiredNumberFlag)(options.olderThanDays ?? options["older-than-days"], "--older-than-days");
223
+ if (olderThanDays === undefined)
223
224
  throw new Error("Missing run id (or --older-than-days N for the retention policy path).");
224
225
  const states = Array.isArray(options.state) ? options.state : options.state ? [options.state] : undefined;
225
226
  return registry.archiveByPolicy({
@@ -253,8 +254,8 @@ function historyCli(options = {}) {
253
254
  scope: scopeOf(options, "home"),
254
255
  app: optionalString(options.app),
255
256
  status: optionalString(options.status),
256
- limit: options.limit === undefined ? undefined : Number(options.limit),
257
- offset: options.offset === undefined ? undefined : Number(options.offset),
257
+ limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
258
+ offset: (0, numeric_flag_1.requiredNumberFlag)(options.offset, "--offset"),
258
259
  });
259
260
  }
260
261
  function queueAddCli(options = {}) {
@@ -263,7 +264,7 @@ function queueAddCli(options = {}) {
263
264
  appId: optionalString(options.app || options.appId),
264
265
  workflowId: optionalString(options.workflow || options.workflowId),
265
266
  repo: optionalString(options.repo),
266
- priority: options.priority === undefined ? undefined : Number(options.priority),
267
+ priority: (0, numeric_flag_1.requiredNumberFlag)(options.priority, "--priority"),
267
268
  note: optionalString(options.note),
268
269
  id: optionalString(options.id),
269
270
  });
@@ -276,7 +277,7 @@ function queueShowCli(id, options = {}) {
276
277
  }
277
278
  function queueDrainCli(options = {}) {
278
279
  return new run_registry_io_1.RunRegistry(resolveCwd(options)).queueDrain({
279
- limit: options.limit === undefined ? undefined : Number(options.limit),
280
+ limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
280
281
  repo: optionalString(options.repo),
281
282
  });
282
283
  }
@@ -285,8 +286,9 @@ function queueDrainCli(options = {}) {
285
286
  // ---------------------------------------------------------------------
286
287
  function gcPolicyOverridesFrom(options) {
287
288
  const overrides = {};
288
- if (options.reclaimAfterArchiveDays !== undefined)
289
- overrides.reclaimAfterArchiveDays = Number(options.reclaimAfterArchiveDays);
289
+ const reclaimAfterArchiveDays = (0, numeric_flag_1.requiredNumberFlag)(options.reclaimAfterArchiveDays, "--reclaimAfterArchiveDays");
290
+ if (reclaimAfterArchiveDays !== undefined)
291
+ overrides.reclaimAfterArchiveDays = reclaimAfterArchiveDays;
290
292
  if (options.keepScratch !== undefined)
291
293
  overrides.keepScratch = Boolean(options.keepScratch);
292
294
  if (options["keep-scratch"] !== undefined)
@@ -295,6 +297,10 @@ function gcPolicyOverridesFrom(options) {
295
297
  overrides.keepSnapshots = Boolean(options.keepSnapshots);
296
298
  if (options["keep-snapshots"] !== undefined)
297
299
  overrides.keepSnapshots = Boolean(options["keep-snapshots"]);
300
+ if (options.keepCommits !== undefined)
301
+ overrides.keepCommits = Boolean(options.keepCommits);
302
+ if (options["keep-commits"] !== undefined)
303
+ overrides.keepCommits = Boolean(options["keep-commits"]);
298
304
  if (options.state !== undefined)
299
305
  overrides.reclaimStates = Array.isArray(options.state) ? options.state : [options.state];
300
306
  return overrides;
@@ -316,7 +322,7 @@ function gcRunCli(runId, options = {}) {
316
322
  policy: gcPolicyOverridesFrom(options),
317
323
  now: optionalString(options.now),
318
324
  actor: optionalString(options.actor),
319
- limit: options.limit === undefined ? undefined : Number(options.limit),
325
+ limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
320
326
  });
321
327
  }
322
328
  function gcVerifyCli(runId, options = {}) {
@@ -331,7 +337,7 @@ function orphansGcCli(options = {}) {
331
337
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
332
338
  return (0, reclamation_io_1.gcOrphanRuns)(registry, {
333
339
  scope: scopeOf(options, "home"),
334
- minAgeMinutes: options.minAgeMinutes !== undefined ? Number(options.minAgeMinutes) : options["min-age-minutes"] !== undefined ? Number(options["min-age-minutes"]) : undefined,
340
+ minAgeMinutes: (0, numeric_flag_1.requiredNumberFlag)(options.minAgeMinutes ?? options["min-age-minutes"], "--min-age-minutes"),
335
341
  all: Boolean(options.all),
336
342
  now: optionalString(options.now),
337
343
  });
@@ -310,9 +310,12 @@ function fetchArchiveBytes(rawUrl, sanitizedUrl, dest, opts) {
310
310
  }
311
311
  /** List an archive's entry NAMES WITHOUT extracting (the zip-slip/tar-slip name guard runs on
312
312
  * this). Symlinks/specials and decompression bombs are caught separately (below). */
313
- function listArchive(file, isZip) {
313
+ function listArchive(file, isZip, timeoutMs) {
314
314
  const cmd = isZip ? ["unzip", "-Z1", "--", file] : ["tar", "-tf", file];
315
- const result = (0, node_child_process_1.spawnSync)(cmd[0], cmd.slice(1), { encoding: "utf8" });
315
+ // Same unset-timeout hazard as a hung local/container task (post-v0.2.2
316
+ // robustness hardening): a pathological archive must not hang the CLI
317
+ // forever with no kill path. Matches git()'s own fallback below.
318
+ const result = (0, node_child_process_1.spawnSync)(cmd[0], cmd.slice(1), { encoding: "utf8", timeout: timeoutMs || 120000 });
316
319
  if (result.status !== 0) {
317
320
  // Distinguish "unzip not installed" (ENOENT) from "archive is corrupt" — never conflate the
318
321
  // two (a corrupt .tar mislabeled .zip used to surface a bogus "unzip not found").
@@ -338,10 +341,10 @@ function assertNoTraversal(entries, sanitizedUrl) {
338
341
  /** Declared uncompressed size, read WITHOUT extracting — gzip's ISIZE trailer for `.tar.gz`/
339
342
  * `.tgz`, or `unzip -l`'s total for `.zip`. Best-effort (undefined when unknown); the
340
343
  * post-extraction walk is authoritative. Lets us reject a bomb BEFORE it fills the disk. */
341
- function declaredUncompressedSize(file, isZip) {
344
+ function declaredUncompressedSize(file, isZip, timeoutMs) {
342
345
  try {
343
346
  if (isZip) {
344
- const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8" });
347
+ const r = (0, node_child_process_1.spawnSync)("unzip", ["-l", "--", file], { encoding: "utf8", timeout: timeoutMs || 120000 });
345
348
  if (r.status !== 0)
346
349
  return undefined;
347
350
  const lines = String(r.stdout || "").trim().split(/\r?\n/);
@@ -421,15 +424,17 @@ function downloadArchive(rawUrl, sanitizedUrl, opts) {
421
424
  const commit = (0, node_crypto_1.createHash)("sha256").update(node_fs_1.default.readFileSync(tmpFile)).digest("hex"); // content address
422
425
  // Bomb defense (BEFORE extracting, to avoid filling the disk): reject a declared
423
426
  // uncompressed size over the cap. assertSafeTree re-checks the ACTUAL size afterward.
424
- const declared = declaredUncompressedSize(tmpFile, isZip);
427
+ const declared = declaredUncompressedSize(tmpFile, isZip, opts.timeoutMs);
425
428
  if (declared !== undefined && declared > MAX_EXTRACTED_BYTES) {
426
429
  throw new Error(`refusing archive ${sanitizedUrl}: declared uncompressed size ${declared} exceeds ${MAX_EXTRACTED_BYTES} bytes (possible decompression bomb)`);
427
430
  }
428
- assertNoTraversal(listArchive(tmpFile, isZip), sanitizedUrl);
431
+ assertNoTraversal(listArchive(tmpFile, isZip, opts.timeoutMs), sanitizedUrl);
429
432
  const extractDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(staging, "x-"));
433
+ // Same unset-timeout hazard as above: extraction runs on downloaded,
434
+ // attacker-influenced bytes and must not hang forever.
430
435
  const ex = isZip
431
- ? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8" })
432
- : (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8" });
436
+ ? (0, node_child_process_1.spawnSync)("unzip", ["-q", "-o", "-d", extractDir, "--", tmpFile], { encoding: "utf8", timeout: opts.timeoutMs || 120000 })
437
+ : (0, node_child_process_1.spawnSync)("tar", ["-xf", tmpFile, "-C", extractDir], { encoding: "utf8", timeout: opts.timeoutMs || 120000 });
433
438
  if (ex.status !== 0)
434
439
  throw new Error(`could not extract archive ${sanitizedUrl}: ${String(ex.stderr || "").trim() || `exit ${ex.status}`}`);
435
440
  // Fail closed on symlinks/specials and on an over-cap actual extracted size.