cool-workflow 0.2.4 → 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 (132) 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 +32 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/parseargv.js +7 -1
  18. package/dist/core/capability-data.js +337 -8
  19. package/dist/core/format/help.js +73 -3
  20. package/dist/core/format/recovery-hint.js +32 -0
  21. package/dist/core/multi-agent/collaboration.js +35 -6
  22. package/dist/core/multi-agent/runtime.js +7 -0
  23. package/dist/core/multi-agent/trust-policy.js +7 -1
  24. package/dist/core/pipeline/contract.js +7 -0
  25. package/dist/core/pipeline/error-feedback.js +2 -2
  26. package/dist/core/trust/evidence-grounding.js +13 -1
  27. package/dist/core/util/cli-args.js +22 -0
  28. package/dist/core/version.js +1 -1
  29. package/dist/mcp/dispatch.js +22 -2
  30. package/dist/mcp/server.js +124 -13
  31. package/dist/mcp-server.js +20 -0
  32. package/dist/shell/commit.js +8 -2
  33. package/dist/shell/coordinator-io.js +73 -1
  34. package/dist/shell/drive.js +120 -63
  35. package/dist/shell/error-feedback-io.js +6 -0
  36. package/dist/shell/execution-backend/agent.js +195 -23
  37. package/dist/shell/execution-backend/container.js +44 -10
  38. package/dist/shell/execution-backend/local.js +32 -0
  39. package/dist/shell/fs-atomic.js +93 -12
  40. package/dist/shell/ledger-cli.js +9 -2
  41. package/dist/shell/multi-agent-cli.js +5 -1
  42. package/dist/shell/onramp.js +48 -5
  43. package/dist/shell/pipeline.js +2 -1
  44. package/dist/shell/reclamation-io.js +10 -9
  45. package/dist/shell/run-export.js +52 -4
  46. package/dist/shell/run-store.js +156 -0
  47. package/dist/shell/scheduler-io.js +101 -10
  48. package/dist/shell/telemetry-ledger-io.js +36 -24
  49. package/dist/shell/trust-audit.js +104 -10
  50. package/dist/shell/workbench-host.js +121 -10
  51. package/dist/shell/workbench.js +79 -5
  52. package/dist/shell/worker-isolation.js +1 -1
  53. package/dist/wiring/capability-table/basics.js +5 -0
  54. package/dist/wiring/capability-table/exec-backend.js +40 -22
  55. package/dist/wiring/capability-table/pipeline.js +32 -0
  56. package/dist/wiring/capability-table/registry-core.js +26 -3
  57. package/dist/wiring/capability-table/reporting.js +7 -1
  58. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  59. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  60. package/docs/agent-delegation-drive.7.md +15 -0
  61. package/docs/cli-mcp-parity.7.md +25 -0
  62. package/docs/contract-migration-tooling.7.md +2 -0
  63. package/docs/control-plane-scheduling.7.md +36 -0
  64. package/docs/cross-agent-ledger.7.md +20 -8
  65. package/docs/durable-state-and-locking.7.md +2 -0
  66. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  67. package/docs/execution-backends.7.md +2 -0
  68. package/docs/fix.7.md +4 -4
  69. package/docs/getting-started.md +40 -32
  70. package/docs/index.md +17 -0
  71. package/docs/launch/demo.tape +4 -3
  72. package/docs/mcp-app-surface.7.md +6 -0
  73. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  74. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  75. package/docs/multi-agent-operator-ux.7.md +2 -0
  76. package/docs/node-snapshot-diff-replay.7.md +2 -0
  77. package/docs/observability-cost-accounting.7.md +2 -0
  78. package/docs/project-index.md +39 -9
  79. package/docs/real-execution-backends.7.md +2 -0
  80. package/docs/release-and-migration.7.md +2 -0
  81. package/docs/release-tooling.7.md +2 -0
  82. package/docs/routine.7.md +22 -0
  83. package/docs/run-registry-control-plane.7.md +2 -0
  84. package/docs/run-retention-reclamation.7.md +10 -4
  85. package/docs/state-explosion-management.7.md +2 -0
  86. package/docs/team-collaboration.7.md +2 -0
  87. package/docs/trust-audit-anchor.7.md +2 -0
  88. package/docs/web-desktop-workbench.7.md +24 -2
  89. package/docs/workflow-app-framework.7.md +31 -0
  90. package/manifest/plugin.manifest.json +1 -1
  91. package/manifest/source-context-profiles.json +4 -3
  92. package/package.json +4 -2
  93. package/scripts/agents/agent-adapter-core.js +26 -0
  94. package/scripts/agents/claude-p-agent.js +4 -1
  95. package/scripts/agents/codex-agent.js +4 -0
  96. package/scripts/agents/cw-attest-wrap.js +1 -1
  97. package/scripts/agents/gemini-agent.js +4 -0
  98. package/scripts/agents/opencode-agent.js +5 -0
  99. package/scripts/block-unapproved-tag.js +160 -0
  100. package/scripts/canonical-apps.js +4 -4
  101. package/scripts/children/batch-delegate-child.js +72 -4
  102. package/scripts/children/http-batch-delegate-child.js +132 -0
  103. package/scripts/children/http-delegate-child.js +8 -0
  104. package/scripts/dogfood-release.js +2 -2
  105. package/scripts/fake-date-for-reproduction.js +1 -1
  106. package/scripts/golden-path.js +4 -4
  107. package/scripts/lang-policy-check.js +82 -0
  108. package/scripts/purity-gate.js +16 -3
  109. package/scripts/release-check.js +2 -1
  110. package/scripts/release-flow.js +128 -30
  111. package/scripts/release-gate.js +184 -0
  112. package/scripts/release-oneclick.js +38 -7
  113. package/scripts/release-tags.js +47 -0
  114. package/scripts/verdict-keygen.js +2 -2
  115. package/scripts/verify-bump-reproduction.js +193 -0
  116. package/scripts/verify-verdict-signature.js +1 -1
  117. package/ui/workbench/app.css +41 -9
  118. package/ui/workbench/app.js +160 -27
  119. package/ui/workbench/index.html +2 -2
  120. package/docs/agent-framework.md +0 -177
  121. package/docs/designs/handoff-ledger.md +0 -145
  122. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  123. package/docs/launch/launch-kit.md +0 -195
  124. package/docs/launch/pre-launch-checklist.md +0 -53
  125. package/docs/readme-v0.1.87-full.md +0 -301
  126. package/docs/routines.md +0 -101
  127. package/docs/scheduled-tasks.md +0 -80
  128. package/scripts/agents/claude-p-agent.sh +0 -9
  129. package/scripts/block-unapproved-tag.sh +0 -75
  130. package/scripts/release-gate.sh +0 -94
  131. package/scripts/verify-bump-reproduction.sh +0 -148
  132. package/scripts/verify-container-selfref.js +0 -64
@@ -113,6 +113,7 @@ exports.evalScoreCli = evalScoreCli;
113
113
  exports.evalGateCli = evalGateCli;
114
114
  exports.evalReportCli = evalReportCli;
115
115
  const path = __importStar(require("node:path"));
116
+ const cli_args_1 = require("../core/util/cli-args");
116
117
  const numeric_flag_1 = require("../core/util/numeric-flag");
117
118
  const run_store_1 = require("./run-store");
118
119
  const report_1 = require("./report");
@@ -188,7 +189,10 @@ function numberArg(value) {
188
189
  return Number.isFinite(parsed) ? parsed : undefined;
189
190
  }
190
191
  function boolArg(value) {
191
- return Boolean(value);
192
+ // parseBoolFlag reads "false"/"0"/"no"/"off" as false and throws on an
193
+ // unrecognized string — Boolean("false") is true, which silently
194
+ // ENABLED flags like `--allow-self-approval false` (fail-open).
195
+ return (0, cli_args_1.parseBoolFlag)(value, "flag") ?? false;
192
196
  }
193
197
  /** `--multi-agent-run <id>` — parseArgv keeps kebab-case option keys
194
198
  * verbatim (no camelCase folding), so this must check the literal
@@ -278,9 +278,9 @@ function resolveChangedFiles(options = {}) {
278
278
  const root = gitRoot(cwd);
279
279
  const baseRef = resolveBaseRef(root, options.changedFrom, options.env || process.env);
280
280
  const files = new Set();
281
- for (const file of gitLines(root, ["diff", "--name-only", baseRef, "--"]))
281
+ for (const file of gitLinesOrThrow(root, ["diff", "--name-only", baseRef, "--"]))
282
282
  files.add(normalizeChangedPath(file));
283
- for (const file of gitLines(root, ["ls-files", "--others", "--exclude-standard"]))
283
+ for (const file of gitLinesOrThrow(root, ["ls-files", "--others", "--exclude-standard"]))
284
284
  files.add(normalizeChangedPath(file));
285
285
  return { baseRef, files: [...files].filter(Boolean).sort() };
286
286
  }
@@ -406,8 +406,26 @@ function resolveBaseRef(root, changedFrom, env) {
406
406
  return verifyRef(root, changedFrom);
407
407
  if (env.CW_ONRAMP_BASE)
408
408
  return verifyRef(root, env.CW_ONRAMP_BASE);
409
- const baseBranch = env.GITHUB_BASE_REF ? `origin/${env.GITHUB_BASE_REF}` : "origin/main";
410
- const mergeBase = gitOne(root, ["merge-base", "HEAD", baseBranch]);
409
+ // A pull_request CI context sets GITHUB_BASE_REF, so a base ref is EXPECTED:
410
+ // the diff must run against the PR's own base branch. If merge-base cannot
411
+ // resolve there (a shallow clone that never fetched the base, a missing
412
+ // origin ref), we must NOT quietly degrade to HEAD -- a HEAD..HEAD diff on a
413
+ // clean, already-committed tree is empty, and an empty change set has no
414
+ // issues, so onramp:check would report ok:true on a real, unseen change (the
415
+ // same vacuous-pass class PR #446 closed one layer below). Fail closed.
416
+ if (env.GITHUB_BASE_REF) {
417
+ const baseBranch = `origin/${env.GITHUB_BASE_REF}`;
418
+ const mergeBase = gitOne(root, ["merge-base", "HEAD", baseBranch]);
419
+ if (mergeBase)
420
+ return mergeBase;
421
+ throw new Error(`onramp: cannot resolve a base ref -- git merge-base HEAD ${baseBranch} found no common commit (fail closed, not treated as zero changes). Fetch the base branch with a full (non-shallow) clone, or pass --changed-from / CW_ONRAMP_BASE.`);
422
+ }
423
+ // No base ref was requested (--changed-from / CW_ONRAMP_BASE) or expected
424
+ // (GITHUB_BASE_REF): this is the local "show my own changes" use. merge-base
425
+ // against origin/main narrows the diff to this branch's commits when the
426
+ // remote is present; with no remote we fall back to HEAD so `git diff HEAD`
427
+ // still surfaces the working-tree (uncommitted) changes.
428
+ const mergeBase = gitOne(root, ["merge-base", "HEAD", "origin/main"]);
411
429
  if (mergeBase)
412
430
  return mergeBase;
413
431
  return verifyRef(root, "HEAD");
@@ -423,12 +441,37 @@ function verifyRef(root, ref) {
423
441
  function gitRoot(cwd) {
424
442
  return gitOne(node_path_1.default.resolve(cwd), ["rev-parse", "--show-toplevel"]) || node_path_1.default.resolve(cwd);
425
443
  }
444
+ // Every onramp git call is a quick metadata read (rev-parse, merge-base, diff
445
+ // --name-only, ls-files). A finite timeout keeps a HUNG git -- a cold fsmonitor
446
+ // daemon, a credential prompt on a misconfigured remote -- from blocking the
447
+ // gate forever; 5s matches the git-metadata timeout already used in
448
+ // shell/commit.ts and shell/doctor.ts. On a timeout spawnSync sets
449
+ // `result.error` (and a null status), so gitLinesOrThrow fails closed while
450
+ // gitLines/gitOne fall back the same way they do for any other git failure.
451
+ const GIT_TIMEOUT_MS = 5000;
426
452
  function gitLines(cwd, args) {
427
- const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
453
+ const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
428
454
  if (result.status !== 0)
429
455
  return [];
430
456
  return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
431
457
  }
458
+ /** Same as gitLines, but a failed git invocation THROWS instead of silently
459
+ * becoming []. resolveChangedFiles must never mistake "git could not run
460
+ * this diff" for "there is nothing to diff" -- that turns a transient git
461
+ * failure into a vacuous onramp-contract pass (a 2026-07-12 security audit
462
+ * finding: a broken base ref or a git error made the changed-file set
463
+ * empty, and an empty set has no issues, so the gate reported ok:true on a
464
+ * real, unseen change). Callers that WANT a soft fallback (merge-base
465
+ * probing, optional discovery) should keep using gitLines/gitOne. */
466
+ function gitLinesOrThrow(cwd, args) {
467
+ const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
468
+ if (result.error)
469
+ throw new Error(`onramp: git ${args.join(" ")} failed to run: ${result.error.message}`);
470
+ if (result.status !== 0) {
471
+ throw new Error(`onramp: git ${args.join(" ")} exited ${result.status} -- cannot resolve changed files (fail closed, not treated as zero changes): ${String(result.stderr || "").trim()}`);
472
+ }
473
+ return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
474
+ }
432
475
  function gitOne(cwd, args) {
433
476
  return gitLines(cwd, args)[0] || "";
434
477
  }
@@ -48,6 +48,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.pureAppendRunNode = exports.writeJson = void 0;
49
49
  exports.plan = plan;
50
50
  const crypto = __importStar(require("node:crypto"));
51
+ const fs = __importStar(require("node:fs"));
51
52
  const path = __importStar(require("node:path"));
52
53
  const run_paths_1 = require("../core/state/run-paths");
53
54
  const migrations_1 = require("../core/state/migrations");
@@ -207,7 +208,7 @@ function plan(app, options) {
207
208
  loopStage: "interpret",
208
209
  artifacts: [{ id: "task", kind: "markdown", path: task.taskPath }],
209
210
  metadata: { workflowId: app.workflow.id, appId: app.id, appVersion: app.version, taskId: task.id, phase: task.phase, taskKind: task.kind, requiresEvidence: task.requiresEvidence, sandboxProfileId: task.sandboxProfileId },
210
- }, { persist: false, persistNode: node_store_1.writeRunNode });
211
+ }, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
211
212
  task.stateNodeId = taskResult.outputNodeId;
212
213
  }
213
214
  (0, report_1.writeReport)(run);
@@ -473,14 +473,15 @@ function planReclamation(run, policy = {}) {
473
473
  }
474
474
  }
475
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.
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.
484
485
  let reclaimedCommitSnapshot = false;
485
486
  if (!policy.keepCommits) {
486
487
  const commits = run.commits || [];
@@ -663,7 +664,7 @@ function prepareFree(run, tombstone) {
663
664
  for (const nodeId of repointed) {
664
665
  try {
665
666
  const fresh = (0, node_store_1.snapshotNode)(run, nodeId, { persist: false });
666
- const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
667
+ const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh, fs.existsSync);
667
668
  if (freshness === "absent") {
668
669
  throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
669
670
  }
@@ -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
  }
@@ -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);