cool-workflow 0.2.3 → 0.2.5

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 (143) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +33 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/io.js +6 -20
  18. package/dist/cli/parseargv.js +7 -1
  19. package/dist/core/capability-data.js +339 -10
  20. package/dist/core/format/help.js +80 -4
  21. package/dist/core/format/recovery-hint.js +32 -0
  22. package/dist/core/multi-agent/collaboration.js +35 -6
  23. package/dist/core/multi-agent/runtime.js +7 -0
  24. package/dist/core/multi-agent/trust-policy.js +7 -1
  25. package/dist/core/pipeline/contract.js +7 -0
  26. package/dist/core/pipeline/error-feedback.js +2 -2
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +55 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +124 -13
  32. package/dist/mcp-server.js +20 -0
  33. package/dist/shell/commit.js +8 -2
  34. package/dist/shell/coordinator-io.js +73 -1
  35. package/dist/shell/drive.js +129 -64
  36. package/dist/shell/error-feedback-io.js +6 -0
  37. package/dist/shell/execution-backend/agent.js +205 -24
  38. package/dist/shell/execution-backend/container.js +44 -10
  39. package/dist/shell/execution-backend/local.js +47 -10
  40. package/dist/shell/fs-atomic.js +93 -12
  41. package/dist/shell/ledger-cli.js +9 -2
  42. package/dist/shell/multi-agent-cli.js +5 -1
  43. package/dist/shell/onramp.js +48 -5
  44. package/dist/shell/pipeline-cli.js +20 -2
  45. package/dist/shell/pipeline.js +2 -1
  46. package/dist/shell/reclamation-io.js +76 -7
  47. package/dist/shell/registry-cli.js +4 -0
  48. package/dist/shell/run-export.js +52 -4
  49. package/dist/shell/run-registry-io.js +1 -0
  50. package/dist/shell/run-store.js +156 -0
  51. package/dist/shell/scheduler-io.js +101 -10
  52. package/dist/shell/telemetry-ledger-io.js +36 -24
  53. package/dist/shell/trust-audit.js +104 -10
  54. package/dist/shell/workbench-host.js +121 -10
  55. package/dist/shell/workbench.js +79 -5
  56. package/dist/shell/worker-isolation.js +1 -1
  57. package/dist/shell/workflow-app-loader.js +67 -1
  58. package/dist/wiring/capability-table/basics.js +7 -2
  59. package/dist/wiring/capability-table/exec-backend.js +42 -24
  60. package/dist/wiring/capability-table/multi-agent.js +69 -69
  61. package/dist/wiring/capability-table/pipeline.js +60 -28
  62. package/dist/wiring/capability-table/registry-core.js +29 -6
  63. package/dist/wiring/capability-table/reporting.js +45 -39
  64. package/dist/wiring/capability-table/scheduling-registry.js +66 -60
  65. package/dist/wiring/capability-table/state.js +34 -34
  66. package/dist/wiring/capability-table/trust-ledger.js +66 -24
  67. package/dist/wiring/capability-table/workflow-apps.js +15 -15
  68. package/docs/agent-delegation-drive.7.md +17 -0
  69. package/docs/cli-mcp-parity.7.md +43 -13
  70. package/docs/contract-migration-tooling.7.md +4 -0
  71. package/docs/control-plane-scheduling.7.md +38 -0
  72. package/docs/cross-agent-ledger.7.md +20 -8
  73. package/docs/durable-state-and-locking.7.md +4 -0
  74. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  75. package/docs/execution-backends.7.md +4 -0
  76. package/docs/fix.7.md +4 -4
  77. package/docs/getting-started.md +40 -32
  78. package/docs/index.md +17 -0
  79. package/docs/launch/demo.tape +4 -3
  80. package/docs/mcp-app-surface.7.md +6 -0
  81. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  82. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  83. package/docs/multi-agent-operator-ux.7.md +4 -0
  84. package/docs/node-snapshot-diff-replay.7.md +4 -0
  85. package/docs/observability-cost-accounting.7.md +4 -0
  86. package/docs/project-index.md +43 -9
  87. package/docs/real-execution-backends.7.md +4 -0
  88. package/docs/release-and-migration.7.md +4 -0
  89. package/docs/release-tooling.7.md +22 -0
  90. package/docs/routine.7.md +22 -0
  91. package/docs/run-registry-control-plane.7.md +4 -0
  92. package/docs/run-retention-reclamation.7.md +28 -3
  93. package/docs/state-explosion-management.7.md +4 -0
  94. package/docs/team-collaboration.7.md +4 -0
  95. package/docs/trust-audit-anchor.7.md +4 -0
  96. package/docs/web-desktop-workbench.7.md +26 -2
  97. package/docs/workflow-app-framework.7.md +31 -0
  98. package/manifest/plugin.manifest.json +1 -1
  99. package/manifest/source-context-profiles.json +4 -3
  100. package/package.json +5 -2
  101. package/scripts/agents/agent-adapter-core.js +26 -0
  102. package/scripts/agents/claude-p-agent.js +4 -1
  103. package/scripts/agents/codex-agent.js +4 -0
  104. package/scripts/agents/cw-attest-wrap.js +1 -1
  105. package/scripts/agents/gemini-agent.js +4 -0
  106. package/scripts/agents/opencode-agent.js +5 -0
  107. package/scripts/block-unapproved-tag.js +160 -0
  108. package/scripts/bump-version.js +27 -10
  109. package/scripts/canonical-apps.js +4 -4
  110. package/scripts/children/batch-delegate-child.js +72 -4
  111. package/scripts/children/http-batch-delegate-child.js +132 -0
  112. package/scripts/children/http-delegate-child.js +8 -0
  113. package/scripts/dogfood-release.js +2 -2
  114. package/scripts/fake-date-for-reproduction.js +1 -1
  115. package/scripts/golden-path.js +4 -4
  116. package/scripts/lang-policy-check.js +82 -0
  117. package/scripts/purity-baseline.json +0 -30
  118. package/scripts/purity-gate.js +16 -3
  119. package/scripts/release-check.js +2 -1
  120. package/scripts/release-flow.js +264 -41
  121. package/scripts/release-gate.js +184 -0
  122. package/scripts/release-oneclick.js +438 -0
  123. package/scripts/release-tags.js +47 -0
  124. package/scripts/verdict-keygen.js +2 -2
  125. package/scripts/verify-bump-reproduction.js +193 -0
  126. package/scripts/verify-verdict-signature.js +1 -1
  127. package/scripts/version-sync-check.js +39 -22
  128. package/ui/workbench/app.css +41 -9
  129. package/ui/workbench/app.js +160 -27
  130. package/ui/workbench/index.html +2 -2
  131. package/docs/agent-framework.md +0 -177
  132. package/docs/designs/handoff-ledger.md +0 -145
  133. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  134. package/docs/launch/launch-kit.md +0 -195
  135. package/docs/launch/pre-launch-checklist.md +0 -53
  136. package/docs/readme-v0.1.87-full.md +0 -301
  137. package/docs/routines.md +0 -101
  138. package/docs/scheduled-tasks.md +0 -80
  139. package/scripts/agents/claude-p-agent.sh +0 -9
  140. package/scripts/block-unapproved-tag.sh +0 -60
  141. package/scripts/release-gate.sh +0 -94
  142. package/scripts/verify-bump-reproduction.sh +0 -148
  143. package/scripts/verify-container-selfref.js +0 -64
@@ -472,6 +472,32 @@ function planReclamation(run, policy = {}) {
472
472
  }
473
473
  }
474
474
  }
475
+ // (3) Superseded, non-verifier-gated commit snapshots. Each commitState()
476
+ // call writes only the commit's own small record into commits/<id>.json
477
+ // (not the whole run), but these files still add up over a long run with
478
+ // no reclamation path today. Only the run's LATEST commit and any
479
+ // verifier-gated commit (the actual audit-significant milestones) are
480
+ // kept — an intermediate, non-gated "checkpoint" commit's only value is
481
+ // as a point-in-time snapshot, and state.json (not commits/) is the
482
+ // source of truth for resume. Treated as not reconstructable (no recipe)
483
+ // on purpose, kept conservative: a commit snapshot is not offered a
484
+ // projection path derivable from retained data.
485
+ let reclaimedCommitSnapshot = false;
486
+ if (!policy.keepCommits) {
487
+ const commits = run.commits || [];
488
+ for (let i = 0; i < commits.length - 1; i++) {
489
+ const commit = commits[i];
490
+ if (commit.verifierGated)
491
+ continue;
492
+ if (!commit.snapshotPath || !fs.existsSync(commit.snapshotPath))
493
+ continue;
494
+ const bytes = dirBytes(commit.snapshotPath);
495
+ if (bytes <= 0)
496
+ continue;
497
+ freeable.push({ path: rel(commit.snapshotPath), absPath: commit.snapshotPath, kind: "commit-snapshot", bytes });
498
+ reclaimedCommitSnapshot = true;
499
+ }
500
+ }
475
501
  // Determinism (HARD constraint): sort by path BEFORE anything hashes it,
476
502
  // so tombstoneHash is reproducible across hosts regardless of
477
503
  // fs.readdirSync's filesystem-dependent order.
@@ -484,7 +510,15 @@ function planReclamation(run, policy = {}) {
484
510
  }
485
511
  let capability = "re-runnable";
486
512
  let capabilityReason = "scratch-only-reclaimed";
487
- if (reclaimedSnapshot && reconstructableSnapshot) {
513
+ // A reclaimed commit snapshot is never reconstructable (a genuine
514
+ // point-in-time capture, no recipe) — it caps capability at "verify-only"
515
+ // regardless of what node-snapshot reclamation achieved, same as an
516
+ // unreconstructable node snapshot would.
517
+ if (reclaimedCommitSnapshot) {
518
+ capability = "verify-only";
519
+ capabilityReason = "snapshot-reclaimed-no-reconstruction";
520
+ }
521
+ else if (reclaimedSnapshot && reconstructableSnapshot) {
488
522
  capability = "re-runnable-by-reconstruction";
489
523
  capabilityReason = "inputs-and-expectdigest-retained";
490
524
  }
@@ -595,13 +629,22 @@ function commitTombstone(run, tombstone) {
595
629
  function prepareFree(run, tombstone) {
596
630
  const runDir = run.paths.runDir;
597
631
  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)
632
+ const commitSnapshotPaths = tombstone.freed.filter((f) => f.kind === "commit-snapshot").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
633
+ if (!scratchDirs.length && !commitSnapshotPaths.length)
599
634
  return;
600
635
  const repointed = new Set();
601
636
  for (const scratchDir of scratchDirs) {
602
637
  for (const id of repointResultNodeArtifacts(run, scratchDir))
603
638
  repointed.add(id);
604
639
  }
640
+ // Unlike scratch (which has a retained "result" artifact to repoint to),
641
+ // a reclaimed commit snapshot has no surviving alternative — its OWN
642
+ // StateNode's "snapshot" artifact (recordCommitNode, shell/commit.ts) is
643
+ // the only reference to it, so it is stripped outright rather than
644
+ // repointed. node.outputs.snapshotPath (a plain metadata string, not an
645
+ // artifact the check below inspects) is left as a historical record,
646
+ // same as commit.snapshotPath itself staying in state.json.
647
+ stripCommitSnapshotArtifacts(run, commitSnapshotPaths);
605
648
  persistRunDurable(run);
606
649
  for (const node of run.nodes || []) {
607
650
  for (const artifact of node.artifacts || []) {
@@ -613,12 +656,15 @@ function prepareFree(run, tombstone) {
613
656
  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
657
  }
615
658
  }
659
+ if (commitSnapshotPaths.includes(resolved)) {
660
+ 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 });
661
+ }
616
662
  }
617
663
  }
618
664
  for (const nodeId of repointed) {
619
665
  try {
620
666
  const fresh = (0, node_store_1.snapshotNode)(run, nodeId, { persist: false });
621
- const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
667
+ const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh, fs.existsSync);
622
668
  if (freshness === "absent") {
623
669
  throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
624
670
  }
@@ -682,6 +728,28 @@ function repointResultNodeArtifacts(run, freedScratchDir) {
682
728
  }
683
729
  return changedIds;
684
730
  }
731
+ /** Removes the "snapshot" artifact entry from any node that references one
732
+ * of `freedCommitSnapshotPaths` — there is no retained alternative to
733
+ * repoint to (unlike a scratch dir's "result" copy), so the reference is
734
+ * dropped outright. StateArtifact.path is a required string, so the
735
+ * artifact entry is filtered out rather than nulled. */
736
+ function stripCommitSnapshotArtifacts(run, freedCommitSnapshotPaths) {
737
+ if (!freedCommitSnapshotPaths.length)
738
+ return [];
739
+ const freedSet = new Set(freedCommitSnapshotPaths);
740
+ const changedIds = [];
741
+ for (const node of run.nodes || []) {
742
+ if (!node.artifacts || !node.artifacts.length)
743
+ continue;
744
+ const before = node.artifacts.length;
745
+ node.artifacts = node.artifacts.filter((artifact) => !artifact.path || !freedSet.has((0, fs_atomic_1.realResolve)(artifact.path)));
746
+ if (node.artifacts.length !== before) {
747
+ node.updatedAt = new Date().toISOString();
748
+ changedIds.push(node.id);
749
+ }
750
+ }
751
+ return changedIds;
752
+ }
685
753
  /** Execute the write-ahead, fail-closed reclamation transaction. */
686
754
  function runReclamation(run, options = {}) {
687
755
  const skeleton = extractSkeleton(run);
@@ -827,7 +895,7 @@ function gcPlan(host, options = {}) {
827
895
  let plan;
828
896
  try {
829
897
  const run = host.loadRun(record.repo, record.runId);
830
- plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots });
898
+ plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits });
831
899
  }
832
900
  catch {
833
901
  entries.push({
@@ -871,6 +939,7 @@ function gcPlan(host, options = {}) {
871
939
  reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays ?? 0,
872
940
  keepSnapshots: Boolean(policy.keepSnapshots),
873
941
  keepScratch: Boolean(policy.keepScratch),
942
+ keepCommits: Boolean(policy.keepCommits),
874
943
  reclaimStates: policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"],
875
944
  },
876
945
  total: entries.length,
@@ -911,8 +980,8 @@ function gcRun(host, options = {}) {
911
980
  const result = runReclamation(run, {
912
981
  now: nowIso,
913
982
  actor: options.actor,
914
- policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
915
- reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
983
+ policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
984
+ reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots, keepCommits: policy.keepCommits },
916
985
  });
917
986
  reclaimed.push({
918
987
  runId: record.runId,
@@ -1004,7 +1073,7 @@ function gcVerify(host, runId, options = {}) {
1004
1073
  function formatGcPlan(result) {
1005
1074
  const lines = [
1006
1075
  `GC Plan (${result.scope}): ${result.eligibleCount}/${result.total} eligible, ${result.bytesToFree} byte(s) would be freed [DRY-RUN, frees nothing]`,
1007
- ` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots}`,
1076
+ ` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots} keepCommits=${result.policy.keepCommits}`,
1008
1077
  ];
1009
1078
  for (const entry of result.entries) {
1010
1079
  if (entry.eligible) {
@@ -297,6 +297,10 @@ function gcPolicyOverridesFrom(options) {
297
297
  overrides.keepSnapshots = Boolean(options.keepSnapshots);
298
298
  if (options["keep-snapshots"] !== undefined)
299
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"]);
300
304
  if (options.state !== undefined)
301
305
  overrides.reclaimStates = Array.isArray(options.state) ? options.state : [options.state];
302
306
  return overrides;
@@ -113,6 +113,17 @@ function importRun(exportPath, targetDir) {
113
113
  if (!(0, fs_atomic_1.isContainedPath)(runDir, runsRoot)) {
114
114
  throw new Error(`Run id escapes the runs directory: ${JSON.stringify(raw.run.id)}`);
115
115
  }
116
+ // Validate the run shape BEFORE the deref and before any dir/file is written.
117
+ // A truncated archive can be missing run.paths (or carry non-string
118
+ // runDir/cwd); without this guard the deref throws a raw TypeError, or a
119
+ // present-but-shapeless paths half-restores the run dir with a broken rebase.
120
+ // Fail closed as an invalid archive instead.
121
+ if (!raw.run.paths ||
122
+ typeof raw.run.paths !== "object" ||
123
+ typeof raw.run.paths.runDir !== "string" ||
124
+ typeof raw.run.cwd !== "string") {
125
+ throw new Error("Invalid run export: run.paths.runDir and run.cwd must be strings");
126
+ }
116
127
  const oldRunDir = raw.run.paths.runDir;
117
128
  const oldCwd = raw.run.cwd;
118
129
  const paths = (0, run_paths_1.createRunPaths)(runDir);
@@ -293,7 +304,7 @@ function reportSectionEmbedsResult(reportMd, taskId, expected) {
293
304
  * (with the bundle's embedded public key) the ed25519 signatures.
294
305
  * Never throws — every failure is a structured check and a false `ok`.
295
306
  *
296
- * Key precedence is bundle > argument > environment. */
307
+ * Key precedence is argument > bundle > environment. */
297
308
  function verifyReportBundle(archivePath, options = {}) {
298
309
  const inspect = inspectArchive(archivePath);
299
310
  const failedChecks = inspect.checks.filter((check) => !check.pass).map((check) => ({ name: check.name, code: check.code }));
@@ -333,8 +344,39 @@ function verifyReportBundle(archivePath, options = {}) {
333
344
  catch {
334
345
  /* inspect already recorded the parse failure; treat key as absent */
335
346
  }
336
- const trustKeySource = bundleKey ? "bundle" : options.pubkey ? "argument" : process.env.CW_AGENT_ATTEST_PUBKEY ? "environment" : "none";
337
- const trustKey = (0, telemetry_attestation_1.resolveTrustPublicKey)(bundleKey || options.pubkey || process.env.CW_AGENT_ATTEST_PUBKEY);
347
+ // Key precedence: an explicit operator --pubkey/options.pubkey WINS over a
348
+ // bundle-embedded key. A bundle carries its own key so it verifies OFFLINE,
349
+ // but that key must never OVERRIDE a key the operator pinned by hand — else a
350
+ // bundle re-signed with an attacker's OWN key (and embedding that key) would
351
+ // verify green against itself. When the operator pins a key AND the bundle
352
+ // embeds a DIFFERENT one, fail closed with a clear trust-key-mismatch rather
353
+ // than silently trusting the bundle's own key.
354
+ const resolvedArg = (0, telemetry_attestation_1.resolveTrustPublicKey)(options.pubkey);
355
+ const resolvedBundle = (0, telemetry_attestation_1.resolveTrustPublicKey)(bundleKey);
356
+ const resolvedEnv = (0, telemetry_attestation_1.resolveTrustPublicKey)(process.env.CW_AGENT_ATTEST_PUBKEY);
357
+ let trustKey;
358
+ let trustKeySource;
359
+ let trustKeyConflict = false;
360
+ if (options.pubkey) {
361
+ trustKey = resolvedArg;
362
+ trustKeySource = "argument";
363
+ if (resolvedArg && resolvedBundle && normalizePem(resolvedArg) !== normalizePem(resolvedBundle)) {
364
+ trustKeyConflict = true;
365
+ failedChecks.push({ name: "trust-key", code: "trust-key-mismatch" });
366
+ }
367
+ }
368
+ else if (bundleKey) {
369
+ trustKey = resolvedBundle;
370
+ trustKeySource = "bundle";
371
+ }
372
+ else if (process.env.CW_AGENT_ATTEST_PUBKEY) {
373
+ trustKey = resolvedEnv;
374
+ trustKeySource = "environment";
375
+ }
376
+ else {
377
+ trustKey = undefined;
378
+ trustKeySource = "none";
379
+ }
338
380
  const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-verify-bundle-"));
339
381
  let telemetryVerified = false;
340
382
  let trustAuditVerified = false;
@@ -421,7 +463,7 @@ function verifyReportBundle(archivePath, options = {}) {
421
463
  schemaVersion: 1,
422
464
  archivePath,
423
465
  runId: inspect.runId,
424
- ok: inspect.ok && telemetryVerified && trustAuditVerified && signaturesFailed === 0 && reportFindingsOk && !strictShortfall && !extractShortfall && !unsignedShortfall,
466
+ ok: inspect.ok && telemetryVerified && trustAuditVerified && signaturesFailed === 0 && reportFindingsOk && !strictShortfall && !extractShortfall && !unsignedShortfall && !trustKeyConflict,
425
467
  archiveOk: inspect.ok,
426
468
  telemetryVerified,
427
469
  trustAuditVerified,
@@ -675,6 +717,12 @@ function toArchivePath(value) {
675
717
  function safeArchiveBasename(value) {
676
718
  return value.replace(/[^A-Za-z0-9._-]/g, "_") || "artifact";
677
719
  }
720
+ /** Compare two PEM public keys byte-for-byte after dropping whitespace, so a
721
+ * benign line-wrap or trailing-newline difference does not read as a key
722
+ * change, but a truly different key (different base64 body) does. */
723
+ function normalizePem(pem) {
724
+ return (pem || "").replace(/\s+/g, "");
725
+ }
678
726
  function messageOf(error) {
679
727
  return error instanceof Error ? error.message : String(error);
680
728
  }
@@ -87,6 +87,7 @@ exports.DEFAULT_RUN_REGISTRY_POLICY = {
87
87
  reclaimStates: ["completed", "failed"],
88
88
  keepSnapshots: false,
89
89
  keepScratch: false,
90
+ keepCommits: false,
90
91
  maxReclaimRuns: 0,
91
92
  maxReclaimBytes: 0,
92
93
  };
@@ -51,7 +51,10 @@ exports.checkRunStateFile = checkRunStateFile;
51
51
  exports.migrateRunStateFile = migrateRunStateFile;
52
52
  exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
53
53
  exports.loadRunFromCwd = loadRunFromCwd;
54
+ exports.resolveRunDir = resolveRunDir;
54
55
  exports.withRunStateLock = withRunStateLock;
56
+ exports.withDriveLock = withDriveLock;
57
+ exports.withDriveLockAsync = withDriveLockAsync;
55
58
  exports.saveCheckpoint = saveCheckpoint;
56
59
  exports.compactCheckpoint = compactCheckpoint;
57
60
  exports.createRun = createRun;
@@ -185,6 +188,22 @@ function loadRunFromCwd(runId, cwd = process.cwd()) {
185
188
  assertNotSuspectedDataLoss(runId, result);
186
189
  return result.run;
187
190
  }
191
+ /** The canonical run directory for `runId` under `cwd` — the SAME
192
+ * deterministic location loadRunFromCwd resolves `state.json` in (it too
193
+ * ignores any stored `paths.runDir`). Guards an empty/unsafe id and throws
194
+ * the exact `Run not found: <runId>` loadRunFromCwd throws, but via a plain
195
+ * `existsSync` so it adds no `state.json` READ and never creates the run dir
196
+ * for a nonexistent run. Used by the drive mutex, which needs the directory
197
+ * before it can acquire, without perturbing read accounting. */
198
+ function resolveRunDir(runId, cwd = process.cwd()) {
199
+ if (!runId)
200
+ throw new Error("Missing run id");
201
+ (0, fs_atomic_1.assertSafeRunId)(runId);
202
+ const runDir = path.join(cwd, ".cw", "runs", runId);
203
+ if (!fs.existsSync(path.join(runDir, "state.json")))
204
+ throw new Error(`Run not found: ${runId}`);
205
+ return runDir;
206
+ }
188
207
  /** Hold the state.json lock over a WHOLE load -> change -> save cycle.
189
208
  * A bare loadRunFromCwd + saveCheckpoint pair leaves a window where two
190
209
  * processes both load the same state and the later save silently drops
@@ -201,6 +220,143 @@ function withRunStateLock(runId, cwd, fn) {
201
220
  const probe = loadRunFromCwd(runId, cwd);
202
221
  return (0, fs_atomic_1.withFileLock)(probe.paths.state, () => fn(loadRunFromCwd(runId, cwd)));
203
222
  }
223
+ // ---------------------------------------------------------------------------
224
+ // withDriveLock — a run-scoped DRIVE mutex, held across a WHOLE drive() call.
225
+ //
226
+ // The state.json write lock (withFileLock) covers only the instant of a
227
+ // write; a concurrent round loads the run once, spawns agents for MINUTES,
228
+ // then flushes the object it loaded at round start. A second drive on the
229
+ // same run therefore clobbers this one's flush (lost update) and both drives
230
+ // mint the same worker id from an in-memory count (double dispatch). This
231
+ // mutex closes both: only one process advances a run at a time.
232
+ //
233
+ // It is held far too long for withFileLock's 30s stale-steal window, so it is
234
+ // NEVER time-stale-stolen. A lock whose recorded pid is a LIVE process refuses
235
+ // (fail closed — never a silent double-drive); a lock whose owner is GONE (a
236
+ // crashed prior drive) or this process's own leaked lock is stolen so a crash
237
+ // can never wedge the run. Lock body is `"<pid>@<ISO>\n"`, the same shape as
238
+ // the fs-atomic lock, acquired with the same single-winner `link(2)` idiom.
239
+ // ---------------------------------------------------------------------------
240
+ /** Drive-lock paths this process holds right now. A nested drive() on the
241
+ * SAME run id (sub-workflows use a DIFFERENT run id, so this should not
242
+ * happen — defensive) re-enters instead of self-refusing. */
243
+ const HELD_DRIVE_LOCKS = new Set();
244
+ function driveLockPath(runDir) {
245
+ return path.join(runDir, "drive.lock");
246
+ }
247
+ /** True when `pid` names a live process on this machine. `process.kill(pid,
248
+ * 0)` delivers no signal — it only probes existence/permission. `EPERM` (the
249
+ * pid exists but is not signalable by us) counts as ALIVE, the conservative
250
+ * never-wrongly-steal direction; a malformed/absent pid is "not alive". */
251
+ function driveLockOwnerAlive(pid) {
252
+ if (!Number.isInteger(pid) || pid <= 0)
253
+ return false;
254
+ try {
255
+ process.kill(pid, 0);
256
+ return true;
257
+ }
258
+ catch (error) {
259
+ return Boolean(error && typeof error === "object" && error.code === "EPERM");
260
+ }
261
+ }
262
+ function readDriveLockPid(lock) {
263
+ try {
264
+ const match = /^(\d+)@/.exec(fs.readFileSync(lock, "utf8"));
265
+ return match ? Number(match[1]) : 0;
266
+ }
267
+ catch {
268
+ return 0; // vanished or unreadable — treat as no owner
269
+ }
270
+ }
271
+ /** Acquire the drive mutex for `runId`'s run directory. Returns a release
272
+ * function (a no-op when this call re-entered a lock already held in-process).
273
+ * Throws a fail-closed refusal — naming the resume command — when another
274
+ * LIVE process holds it. */
275
+ function acquireDriveLock(runDir, runId) {
276
+ const lock = driveLockPath(runDir);
277
+ const key = path.resolve(lock);
278
+ if (HELD_DRIVE_LOCKS.has(key))
279
+ return () => { };
280
+ fs.mkdirSync(runDir, { recursive: true });
281
+ const pid = process.pid;
282
+ const body = `${pid}@${new Date().toISOString()}\n`;
283
+ const refuse = (ownerPid) => new Error(`Run ${runId} is already being driven by another process (pid ${ownerPid || "unknown"}). ` +
284
+ `Wait for it to finish, or if it has crashed remove ${lock}, then resume: cw run resume ${runId} --drive`);
285
+ let acquired = false;
286
+ // A live owner refuses at once; only a stealable (dead-owner / own-leak) lock
287
+ // loops to re-acquire, so this never hot-spins for a genuinely busy run.
288
+ for (let attempt = 0; attempt < 3 && !acquired; attempt++) {
289
+ const tmp = `${lock}.${pid}.${attempt}.tmp`;
290
+ try {
291
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
292
+ fs.linkSync(tmp, lock);
293
+ acquired = true;
294
+ }
295
+ catch (error) {
296
+ if (!(error && typeof error === "object" && error.code === "EEXIST")) {
297
+ throw error;
298
+ }
299
+ const ownerPid = readDriveLockPid(lock);
300
+ // Stealable: our own leaked lock, or an owner that is no longer alive.
301
+ if (ownerPid === pid || !driveLockOwnerAlive(ownerPid)) {
302
+ try {
303
+ fs.unlinkSync(lock);
304
+ }
305
+ catch {
306
+ /* another process took it first — the next attempt re-judges */
307
+ }
308
+ continue;
309
+ }
310
+ throw refuse(ownerPid);
311
+ }
312
+ finally {
313
+ try {
314
+ fs.unlinkSync(tmp);
315
+ }
316
+ catch {
317
+ /* the link consumed it, or it is already gone */
318
+ }
319
+ }
320
+ }
321
+ if (!acquired)
322
+ throw refuse(readDriveLockPid(lock));
323
+ HELD_DRIVE_LOCKS.add(key);
324
+ return () => {
325
+ HELD_DRIVE_LOCKS.delete(key);
326
+ try {
327
+ // Release only while we still own it (a force-stale steal by a later
328
+ // process must not have its lock removed by us).
329
+ if (fs.readFileSync(lock, "utf8").startsWith(`${pid}@`))
330
+ fs.rmSync(lock, { force: true });
331
+ }
332
+ catch {
333
+ /* already released/removed */
334
+ }
335
+ };
336
+ }
337
+ /** Run `fn` while holding the run-scoped drive mutex (see the block comment
338
+ * above); always released, even on throw. Synchronous callers (drive()). */
339
+ function withDriveLock(runDir, runId, fn) {
340
+ const release = acquireDriveLock(runDir, runId);
341
+ try {
342
+ return fn();
343
+ }
344
+ finally {
345
+ release();
346
+ }
347
+ }
348
+ /** Async form of `withDriveLock` — releases only AFTER `fn`'s promise
349
+ * settles, so driveAsync()'s awaited multi-round loop stays inside the lock
350
+ * for its whole lifetime. */
351
+ async function withDriveLockAsync(runDir, runId, fn) {
352
+ const release = acquireDriveLock(runDir, runId);
353
+ try {
354
+ return await fn();
355
+ }
356
+ finally {
357
+ release();
358
+ }
359
+ }
204
360
  /** state.json is the single source of truth — set `updatedAt`, then write
205
361
  * it DURABLY with a lock so concurrent processes never lose an update. */
206
362
  function saveCheckpoint(run) {
@@ -74,12 +74,18 @@ class Scheduler {
74
74
  create(options) {
75
75
  const kind = normalizeKind(options.kind);
76
76
  const now = new Date();
77
- const intervalMinutes = numberOption(options.intervalMinutes || options.interval);
77
+ // Fail closed on a bad number rather than silently clamping a typo into a
78
+ // runaway: interval is a whole number of 0 or more (0 = due now, a real
79
+ // supported value), maxRuns a whole number more than 0, jitter/delay 0 or
80
+ // more, ttlDays more than 0. Use `??` (not `||`) when picking the option so
81
+ // an explicit 0 is kept -- for interval a valid "due now", not swallowed
82
+ // into the default 1; for the others kept and then rejected.
83
+ const intervalMinutes = requireNonNegativeInt(options.intervalMinutes ?? options.interval, "interval");
78
84
  const cron = stringOption(options.cron);
79
- const delayMinutes = numberOption(options.delayMinutes || options.delay);
80
- const jitterSeconds = numberOption(options.jitterSeconds) ?? 0;
85
+ const delayMinutes = requireNonNegative(options.delayMinutes ?? options.delay, "delay");
86
+ const jitterSeconds = requireNonNegative(options.jitterSeconds, "jitterSeconds") ?? 0;
81
87
  const nextRunAt = computeInitialNextRunAt({ kind, now, intervalMinutes, cron, delayMinutes, jitterSeconds });
82
- const ttlDays = numberOption(options.ttlDays) ?? DEFAULT_TTL_DAYS;
88
+ const ttlDays = requirePositive(options.ttlDays, "ttlDays") ?? DEFAULT_TTL_DAYS;
83
89
  const task = {
84
90
  id: createScheduleId(kind),
85
91
  kind,
@@ -95,7 +101,7 @@ class Scheduler {
95
101
  intervalMinutes,
96
102
  cron,
97
103
  jitterSeconds,
98
- maxRuns: numberOption(options.maxRuns),
104
+ maxRuns: requirePositiveInt(options.maxRuns, "maxRuns"),
99
105
  runCount: 0,
100
106
  };
101
107
  return this.locked(() => {
@@ -125,7 +131,7 @@ class Scheduler {
125
131
  const store = this.load();
126
132
  let changed = false;
127
133
  for (const task of store.tasks) {
128
- if (task.status === "active" && new Date(task.expiresAt).getTime() <= now.getTime()) {
134
+ if (task.status === "active" && reachedBy(task.expiresAt, now)) {
129
135
  task.status = "expired";
130
136
  task.updatedAt = now.toISOString();
131
137
  changed = true;
@@ -133,7 +139,7 @@ class Scheduler {
133
139
  }
134
140
  if (changed)
135
141
  this.save(store);
136
- const dueTasks = store.tasks.filter((task) => task.status === "active" && new Date(task.nextRunAt).getTime() <= now.getTime());
142
+ const dueTasks = store.tasks.filter((task) => task.status === "active" && reachedBy(task.nextRunAt, now));
137
143
  if (dueTasks.length) {
138
144
  for (const task of dueTasks) {
139
145
  const alreadyRecorded = task.lastDueAt && new Date(task.lastDueAt).getTime() >= new Date(task.nextRunAt).getTime();
@@ -219,6 +225,7 @@ class Scheduler {
219
225
  if (!fs.existsSync(this.storePath))
220
226
  return { schemaVersion: 1, tasks: [], history: [] };
221
227
  const value = (0, fs_atomic_1.readJson)(this.storePath);
228
+ ensureKnownSchemaVersion(value, "schedule");
222
229
  return {
223
230
  schemaVersion: 1,
224
231
  tasks: Array.isArray(value.tasks) ? value.tasks : [],
@@ -353,6 +360,64 @@ function numberOption(value) {
353
360
  const parsed = Number(value);
354
361
  return Number.isFinite(parsed) ? parsed : undefined;
355
362
  }
363
+ // Number guards for create(): an absent option (undefined/null/bare flag) is
364
+ // left undefined for the caller's default; a GIVEN option that is out of
365
+ // bounds throws a clear, named refusal (fail closed) instead of being clamped.
366
+ function requirePositiveInt(value, name) {
367
+ if (value === undefined || value === null || value === true)
368
+ return undefined;
369
+ const parsed = Number(value);
370
+ if (!Number.isInteger(parsed) || parsed <= 0) {
371
+ throw new Error(`${name} must be a whole number more than 0`);
372
+ }
373
+ return parsed;
374
+ }
375
+ // interval only: a whole number of 0 or more. 0 means "due now" (nextRunAt is
376
+ // this moment) and is a real, supported value, so it must pass; a negative or
377
+ // non-whole interval is the typo we fail closed on.
378
+ function requireNonNegativeInt(value, name) {
379
+ if (value === undefined || value === null || value === true)
380
+ return undefined;
381
+ const parsed = Number(value);
382
+ if (!Number.isInteger(parsed) || parsed < 0) {
383
+ throw new Error(`${name} must be a whole number of 0 or more`);
384
+ }
385
+ return parsed;
386
+ }
387
+ function requirePositive(value, name) {
388
+ if (value === undefined || value === null || value === true)
389
+ return undefined;
390
+ const parsed = Number(value);
391
+ if (!Number.isFinite(parsed) || parsed <= 0) {
392
+ throw new Error(`${name} must be a number more than 0`);
393
+ }
394
+ return parsed;
395
+ }
396
+ function requireNonNegative(value, name) {
397
+ if (value === undefined || value === null || value === true)
398
+ return undefined;
399
+ const parsed = Number(value);
400
+ if (!Number.isFinite(parsed) || parsed < 0) {
401
+ throw new Error(`${name} must be a number of 0 or more`);
402
+ }
403
+ return parsed;
404
+ }
405
+ // Fail closed on a store written by a newer/unknown runtime: forcing it to v1
406
+ // would silently drop fields it does not understand. A missing schemaVersion is
407
+ // treated as legacy v1 for backward tolerance.
408
+ function ensureKnownSchemaVersion(value, store) {
409
+ const version = value.schemaVersion;
410
+ if (version !== undefined && version !== 1) {
411
+ throw new Error(`Unsupported ${store} store schemaVersion: ${String(version)}`);
412
+ }
413
+ }
414
+ // A stored date that parses to NaN is corrupt. `NaN <= now` is false, which
415
+ // would make such a task silently never fire and never expire; fail closed by
416
+ // treating a corrupt date as already reached (due / expired), never inert.
417
+ function reachedBy(iso, now) {
418
+ const at = new Date(iso).getTime();
419
+ return Number.isNaN(at) || at <= now.getTime();
420
+ }
356
421
  class DesktopSchedulerDaemon {
357
422
  cwd;
358
423
  intervalSeconds;
@@ -369,11 +434,36 @@ class DesktopSchedulerDaemon {
369
434
  (0, fs_atomic_1.writeJson)(inboxPath, { schemaVersion: 1, checkedAt, due });
370
435
  return { checkedAt, dueCount: due.length, dueIds: due.map((task) => task.id), inboxPath };
371
436
  }
437
+ /** One tick, guarded. A long-running daemon must survive a transient
438
+ * failure — chiefly file-lock contention when a concurrent `cw schedule`
439
+ * command holds tasks.json (withFileLock gives up after ~6s). Returns
440
+ * true to keep going, false to stop the daemon cleanly.
441
+ * - lock contention → warn to STDERR (stdout stays pure NDJSON), keep going;
442
+ * - any other error (e.g. a corrupt tasks.json) → one `cw:` line + stop,
443
+ * a clean fail-closed shutdown instead of a raw stack dump. */
444
+ safeTick() {
445
+ try {
446
+ process.stdout.write(`${JSON.stringify(this.tick())}\n`);
447
+ return true;
448
+ }
449
+ catch (error) {
450
+ const message = error instanceof Error ? error.message : String(error);
451
+ if (/could not acquire file lock/i.test(message)) {
452
+ process.stderr.write(`cw: schedule daemon: skipped a tick (${message})\n`);
453
+ return true;
454
+ }
455
+ process.stderr.write(`cw: ${message}\n`);
456
+ process.exitCode = 1;
457
+ return false;
458
+ }
459
+ }
372
460
  async run() {
373
461
  fs.mkdirSync(path.join(this.cwd, ".cw", "schedules"), { recursive: true });
374
- process.stdout.write(`${JSON.stringify(this.tick())}\n`);
375
- setInterval(() => {
376
- process.stdout.write(`${JSON.stringify(this.tick())}\n`);
462
+ if (!this.safeTick())
463
+ return;
464
+ const timer = setInterval(() => {
465
+ if (!this.safeTick())
466
+ clearInterval(timer);
377
467
  }, Math.max(1, this.intervalSeconds) * 1000);
378
468
  }
379
469
  }
@@ -465,6 +555,7 @@ class RoutineTriggerBridge {
465
555
  if (!fs.existsSync(this.storePath))
466
556
  return { schemaVersion: 1, triggers: [], events: [], nextTriggerSeq: 0 };
467
557
  const value = (0, fs_atomic_1.readJson)(this.storePath);
558
+ ensureKnownSchemaVersion(value, "routine");
468
559
  const triggers = Array.isArray(value.triggers) ? value.triggers : [];
469
560
  const maxExisting = triggers.reduce((max, trigger) => {
470
561
  const n = Number((String(trigger.id).match(/(\d+)$/) || [])[1] || 0);