cool-workflow 0.2.2 → 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 (136) 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 +11 -5
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +1 -0
  13. package/dist/core/capability-data.js +1 -0
  14. package/dist/core/format/completion.js +68 -0
  15. package/dist/core/format/help.js +25 -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/numeric-flag.js +40 -0
  30. package/dist/core/version.js +1 -1
  31. package/dist/mcp/server.js +99 -11
  32. package/dist/shell/audit-cli.js +57 -25
  33. package/dist/shell/coordinator-io.js +73 -13
  34. package/dist/shell/dispatch.js +1 -1
  35. package/dist/shell/doctor.js +80 -1
  36. package/dist/shell/drive.js +243 -49
  37. package/dist/shell/eval-text.js +2 -2
  38. package/dist/shell/evidence-reasoning.js +4 -4
  39. package/dist/shell/execution-backend/agent.js +20 -1
  40. package/dist/shell/execution-backend/container.js +4 -1
  41. package/dist/shell/execution-backend/local.js +4 -1
  42. package/dist/shell/feedback-cli.js +6 -6
  43. package/dist/shell/fs-atomic.js +218 -29
  44. package/dist/shell/man-cli.js +6 -0
  45. package/dist/shell/metrics-cli.js +2 -1
  46. package/dist/shell/multi-agent-cli.js +367 -323
  47. package/dist/shell/multi-agent-host.js +9 -9
  48. package/dist/shell/multi-agent-operator-ux.js +80 -38
  49. package/dist/shell/node-store.js +10 -4
  50. package/dist/shell/observability.js +1 -1
  51. package/dist/shell/operator-ux-text.js +22 -22
  52. package/dist/shell/operator-ux.js +15 -14
  53. package/dist/shell/orchestrator.js +49 -38
  54. package/dist/shell/pipeline-cli.js +87 -40
  55. package/dist/shell/reclamation-io.js +14 -4
  56. package/dist/shell/registry-cli.js +19 -17
  57. package/dist/shell/remote-source.js +13 -8
  58. package/dist/shell/report-cli.js +45 -0
  59. package/dist/shell/report.js +2 -1
  60. package/dist/shell/run-registry-io.js +77 -19
  61. package/dist/shell/run-store.js +74 -2
  62. package/dist/shell/scheduling-io.js +12 -13
  63. package/dist/shell/state-cli.js +2 -7
  64. package/dist/shell/state-explosion-cli.js +17 -9
  65. package/dist/shell/topology-io.js +36 -5
  66. package/dist/shell/trust-audit.js +224 -22
  67. package/dist/shell/trust-policy-io.js +1 -1
  68. package/dist/shell/worker-cli.js +35 -31
  69. package/dist/wiring/capability-table/basics.js +33 -8
  70. package/dist/wiring/capability-table/exec-backend.js +20 -11
  71. package/dist/wiring/capability-table/multi-agent.js +172 -165
  72. package/dist/wiring/capability-table/parity.js +1 -0
  73. package/dist/wiring/capability-table/pipeline.js +54 -42
  74. package/dist/wiring/capability-table/registry-core.js +29 -10
  75. package/dist/wiring/capability-table/reporting.js +137 -96
  76. package/dist/wiring/capability-table/scheduling-registry.js +195 -161
  77. package/dist/wiring/capability-table/state.js +36 -28
  78. package/dist/wiring/capability-table/trust-ledger.js +46 -22
  79. package/dist/wiring/capability-table/workflow-apps.js +83 -68
  80. package/docs/agent-delegation-drive.7.md +2 -0
  81. package/docs/candidate-scoring.7.md +1 -1
  82. package/docs/canonical-workflow-apps.7.md +7 -7
  83. package/docs/cli-mcp-parity.7.md +8 -3
  84. package/docs/contract-migration-tooling.7.md +2 -0
  85. package/docs/control-plane-scheduling.7.md +2 -0
  86. package/docs/coordinator-blackboard.7.md +17 -17
  87. package/docs/dogfood-one-real-repo.7.md +11 -11
  88. package/docs/durable-state-and-locking.7.md +2 -0
  89. package/docs/end-to-end-golden-path.7.md +14 -14
  90. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  91. package/docs/execution-backends.7.md +2 -0
  92. package/docs/getting-started.md +37 -37
  93. package/docs/multi-agent-cli-mcp-surface.7.md +19 -17
  94. package/docs/multi-agent-eval-replay-harness.7.md +15 -13
  95. package/docs/multi-agent-operator-ux.7.md +21 -19
  96. package/docs/multi-agent-runtime-core.7.md +22 -22
  97. package/docs/multi-agent-topologies.7.md +6 -6
  98. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  99. package/docs/node-snapshot-diff-replay.7.md +2 -0
  100. package/docs/observability-cost-accounting.7.md +2 -0
  101. package/docs/operator-ux.7.md +34 -34
  102. package/docs/pipeline-runner.7.md +4 -4
  103. package/docs/project-index.md +27 -4
  104. package/docs/real-execution-backends.7.md +2 -0
  105. package/docs/release-and-migration.7.md +3 -1
  106. package/docs/release-tooling.7.md +2 -0
  107. package/docs/routines.md +4 -4
  108. package/docs/run-registry-control-plane.7.md +21 -19
  109. package/docs/run-retention-reclamation.7.md +2 -0
  110. package/docs/scheduled-tasks.md +14 -14
  111. package/docs/security-trust-hardening.7.md +15 -15
  112. package/docs/state-explosion-management.7.md +12 -10
  113. package/docs/team-collaboration.7.md +2 -0
  114. package/docs/trust-audit-anchor.7.md +2 -0
  115. package/docs/unix-principles.md +3 -1
  116. package/docs/verifier-gated-commit.7.md +1 -1
  117. package/docs/web-desktop-workbench.7.md +2 -0
  118. package/docs/workflow-app-framework.7.md +13 -13
  119. package/manifest/plugin.manifest.json +1 -1
  120. package/package.json +2 -2
  121. package/scripts/agents/claude-p-agent.js +2 -2
  122. package/scripts/block-unapproved-tag.sh +23 -2
  123. package/scripts/canonical-apps.js +4 -4
  124. package/scripts/children/batch-delegate-child.js +52 -2
  125. package/scripts/dogfood-release.js +1 -1
  126. package/scripts/fake-date-for-reproduction.js +44 -0
  127. package/scripts/golden-path.js +4 -4
  128. package/scripts/purity-baseline.json +3 -0
  129. package/scripts/release-flow.js +57 -1
  130. package/scripts/verdict-keygen.js +83 -0
  131. package/scripts/verify-bump-reproduction.sh +148 -0
  132. package/scripts/verify-verdict-signature.js +61 -0
  133. package/skills/cool-workflow/SKILL.md +9 -9
  134. package/skills/cool-workflow/references/commands.md +89 -88
  135. package/ui/workbench/app.css +37 -1
  136. 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");
@@ -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
@@ -560,7 +603,7 @@ function dispatchRun(args) {
560
603
  const v = args[kebab] ?? args[camel];
561
604
  return typeof v === "string" && v.trim() ? v : undefined;
562
605
  };
563
- const manifest = (0, dispatch_1.createDispatchManifest)(run, args.limit !== undefined ? Number(args.limit) : undefined, {
606
+ const manifest = (0, dispatch_1.createDispatchManifest)(run, (0, numeric_flag_1.requiredNumberFlag)(args.limit, "--limit"), {
564
607
  sandboxProfileId: typeof args.sandbox === "string" ? args.sandbox : undefined,
565
608
  sandbox: typeof args.sandbox === "string" ? args.sandbox : undefined,
566
609
  backendId: typeof args.backend === "string" ? args.backend : undefined,
@@ -649,29 +692,33 @@ function recordResultRun(args) {
649
692
  * still leaves the run's report/state current on disk. */
650
693
  function commitRun(args) {
651
694
  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
- }
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
+ });
677
724
  }
@@ -639,6 +639,16 @@ function freeBulk(run, tombstone) {
639
639
  let freedBytes = 0;
640
640
  for (const entry of tombstone.freed) {
641
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
+ }
642
652
  const before = dirBytes(abs);
643
653
  fs.rmSync(abs, { recursive: true, force: true });
644
654
  freedBytes += before;
@@ -867,7 +877,7 @@ function gcPlan(host, options = {}) {
867
877
  eligibleCount,
868
878
  bytesToFree,
869
879
  entries,
870
- nextAction: eligibleCount ? "node scripts/cw.js gc run" : "node scripts/cw.js run search",
880
+ nextAction: eligibleCount ? "cw gc run" : "cw run search",
871
881
  };
872
882
  }
873
883
  function gcRun(host, options = {}) {
@@ -936,7 +946,7 @@ function gcRun(host, options = {}) {
936
946
  reclaimed,
937
947
  refused,
938
948
  totalBytesFreed,
939
- 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",
940
950
  };
941
951
  }
942
952
  function gcVerify(host, runId, options = {}) {
@@ -952,7 +962,7 @@ function gcVerify(host, runId, options = {}) {
952
962
  capability: "re-runnable",
953
963
  chainLength: 0,
954
964
  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" : ""),
965
+ nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
956
966
  };
957
967
  }
958
968
  const run = host.loadRun(located.record.repo, runId);
@@ -985,7 +995,7 @@ function gcVerify(host, runId, options = {}) {
985
995
  tombstoneHash: last?.tombstoneHash,
986
996
  chainLength: result.tombstones.length,
987
997
  checks,
988
- 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",
989
999
  };
990
1000
  }
991
1001
  // ---------------------------------------------------------------------------
@@ -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)
@@ -316,7 +318,7 @@ function gcRunCli(runId, options = {}) {
316
318
  policy: gcPolicyOverridesFrom(options),
317
319
  now: optionalString(options.now),
318
320
  actor: optionalString(options.actor),
319
- limit: options.limit === undefined ? undefined : Number(options.limit),
321
+ limit: (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit"),
320
322
  });
321
323
  }
322
324
  function gcVerifyCli(runId, options = {}) {
@@ -331,7 +333,7 @@ function orphansGcCli(options = {}) {
331
333
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
332
334
  return (0, reclamation_io_1.gcOrphanRuns)(registry, {
333
335
  scope: scopeOf(options, "home"),
334
- minAgeMinutes: options.minAgeMinutes !== undefined ? Number(options.minAgeMinutes) : options["min-age-minutes"] !== undefined ? Number(options["min-age-minutes"]) : undefined,
336
+ minAgeMinutes: (0, numeric_flag_1.requiredNumberFlag)(options.minAgeMinutes ?? options["min-age-minutes"], "--min-age-minutes"),
335
337
  all: Boolean(options.all),
336
338
  now: optionalString(options.now),
337
339
  });
@@ -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.
@@ -43,9 +43,11 @@ var __importStar = (this && this.__importStar) || (function () {
43
43
  Object.defineProperty(exports, "__esModule", { value: true });
44
44
  exports.reportBundleCli = reportBundleCli;
45
45
  exports.reportVerifyBundleCli = reportVerifyBundleCli;
46
+ exports.formatReportVerifyBundle = formatReportVerifyBundle;
46
47
  const path = __importStar(require("node:path"));
47
48
  const run_export_1 = require("./run-export");
48
49
  const run_store_1 = require("./run-store");
50
+ const term_1 = require("./term");
49
51
  function optionalString(value) {
50
52
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
51
53
  }
@@ -99,3 +101,46 @@ function reportVerifyBundleCli(args) {
99
101
  requireSignatures: Boolean(args["require-signatures"] || args.requireSignatures || args.requireSigs),
100
102
  });
101
103
  }
104
+ /** `cw report verify-bundle`'s human render (default; `--json` prints
105
+ * `ReportBundleVerification` verbatim instead). Mirrors `doctor`'s
106
+ * checks-list-then-verdict shape (`shell/doctor.ts`'s
107
+ * `formatDoctorReport`) rather than `demo bundle`'s narrative one
108
+ * (`shell/telemetry-demo.ts`'s `formatBundleDemo`) — verify-bundle is a
109
+ * flat set of pass/fail checks on one already-sealed archive, not a
110
+ * multi-step forge-and-catch demonstration. */
111
+ function formatReportVerifyBundle(r) {
112
+ const lines = [(0, term_1.bold)(`cw report verify-bundle ${r.archivePath}`), ""];
113
+ const checks = [
114
+ [r.archiveOk, "archive intact — file digests match, no tamper"],
115
+ [r.telemetryVerified, "telemetry hash chain verifies"],
116
+ [r.trustAuditVerified, "trust-audit chain verifies"],
117
+ [r.reportFindingsVerified, "report.md matches every signed result"],
118
+ ];
119
+ for (const [ok, label] of checks)
120
+ lines.push(` ${(0, term_1.doctorGlyph)(ok ? "ok" : "fail")} ${label}`);
121
+ const sigDetail = r.signatureKeyProvided
122
+ ? `${r.signaturesReverified}/${r.signaturesChecked} signature(s) reverified (key source: ${r.trustKeySource})`
123
+ : "no public key available — signatures not checked";
124
+ lines.push(` trust: ${r.trustLevel} — ${sigDetail}`);
125
+ if (r.reportExtractedTo)
126
+ lines.push(` report.md extracted to: ${r.reportExtractedTo}`);
127
+ if (r.failedChecks.length > 0) {
128
+ lines.push("");
129
+ lines.push(" Failed checks");
130
+ for (const c of r.failedChecks) {
131
+ // Most check sites in run-export.ts's verifyReportBundle set `code`
132
+ // to a short slug (e.g. "digest-mismatch"); the top-level restore
133
+ // catch-all sets it to the caught error's full message instead
134
+ // (run-export.ts's "restore" failedChecks.push) — too long/sentence-
135
+ // shaped to cram into "name [code]" without reading as a run-on.
136
+ // Give it its own indented line instead.
137
+ const isSlug = c.code !== undefined && c.code.length <= 40 && !c.code.includes(" ");
138
+ lines.push(` - ${c.name}${isSlug ? ` [${c.code}]` : ""}`);
139
+ if (c.code !== undefined && !isSlug)
140
+ lines.push(` ${c.code}`);
141
+ }
142
+ }
143
+ lines.push("");
144
+ lines.push(`${(0, term_1.doctorGlyph)(r.ok ? "ok" : "fail")} ${r.ok ? "bundle verifies" : "bundle verification FAILED"}`);
145
+ return lines.join("\n");
146
+ }
@@ -338,7 +338,8 @@ function writeReport(run) {
338
338
  "| Phase | Status | Completed | Total |",
339
339
  "| --- | --- | ---: | ---: |",
340
340
  ...run.phases.map((phase) => {
341
- const phaseTasks = run.tasks.filter((t) => phase.taskIds.includes(t.id));
341
+ const taskIds = new Set(phase.taskIds);
342
+ const phaseTasks = run.tasks.filter((t) => taskIds.has(t.id));
342
343
  const completed = phaseTasks.filter((t) => t.status === "completed").length;
343
344
  return `| ${phase.name} | ${phase.status} | ${completed} | ${phaseTasks.length} |`;
344
345
  }),