cool-workflow 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/cli/dispatch.js +29 -91
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +6 -2
  13. package/dist/core/capability-data.js +271 -0
  14. package/dist/core/capability-table.js +16 -3504
  15. package/dist/core/format/completion.js +68 -0
  16. package/dist/core/format/help.js +50 -8
  17. package/dist/core/format/safe-json.js +73 -0
  18. package/dist/core/format/state-explosion-text.js +1 -1
  19. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  20. package/dist/core/multi-agent/collaboration.js +6 -5
  21. package/dist/core/multi-agent/coordinator.js +9 -8
  22. package/dist/core/multi-agent/runtime.js +4 -4
  23. package/dist/core/multi-agent/topology.js +3 -3
  24. package/dist/core/pipeline/commit-gate.js +2 -1
  25. package/dist/core/pipeline/dispatch.js +18 -4
  26. package/dist/core/pipeline/drive-decide.js +2 -1
  27. package/dist/core/state/migrations.js +16 -1
  28. package/dist/core/state/state-explosion/digest.js +16 -15
  29. package/dist/core/state/state-explosion/graph.js +26 -19
  30. package/dist/core/state/state-explosion/helpers.js +2 -1
  31. package/dist/core/state/state-explosion/report.js +6 -6
  32. package/dist/core/trust/ledger.js +2 -1
  33. package/dist/core/types/execution-backend.js +18 -0
  34. package/dist/core/types/observability.js +7 -0
  35. package/dist/core/util/collate.js +23 -0
  36. package/dist/core/util/numeric-flag.js +40 -0
  37. package/dist/core/version.js +1 -1
  38. package/dist/mcp/server.js +99 -11
  39. package/dist/shell/audit-cli.js +99 -23
  40. package/dist/shell/commit-summary.js +2 -1
  41. package/dist/shell/coordinator-io.js +73 -13
  42. package/dist/shell/dispatch.js +1 -1
  43. package/dist/shell/doctor.js +80 -1
  44. package/dist/shell/drive.js +269 -59
  45. package/dist/shell/eval-text.js +2 -2
  46. package/dist/shell/evidence-reasoning.js +8 -7
  47. package/dist/shell/execution-backend/agent.js +20 -1
  48. package/dist/shell/execution-backend/container.js +4 -1
  49. package/dist/shell/execution-backend/local.js +4 -1
  50. package/dist/shell/execution-backend/registry.js +2 -1
  51. package/dist/shell/execution-backend/types.js +0 -9
  52. package/dist/shell/feedback-cli.js +6 -6
  53. package/dist/shell/fs-atomic.js +232 -30
  54. package/dist/shell/man-cli.js +6 -0
  55. package/dist/shell/metrics-cli.js +2 -1
  56. package/dist/shell/multi-agent-cli.js +367 -323
  57. package/dist/shell/multi-agent-host.js +9 -9
  58. package/dist/shell/multi-agent-operator-ux.js +82 -39
  59. package/dist/shell/node-store.js +10 -4
  60. package/dist/shell/observability.js +13 -12
  61. package/dist/shell/onramp.js +17 -1
  62. package/dist/shell/operator-ux-text.js +24 -23
  63. package/dist/shell/operator-ux.js +22 -20
  64. package/dist/shell/orchestrator.js +49 -38
  65. package/dist/shell/pipeline-cli.js +168 -111
  66. package/dist/shell/reclamation-io.js +16 -5
  67. package/dist/shell/registry-cli.js +19 -17
  68. package/dist/shell/remote-source.js +13 -8
  69. package/dist/shell/report-cli.js +45 -0
  70. package/dist/shell/report.js +4 -2
  71. package/dist/shell/run-registry-io.js +77 -19
  72. package/dist/shell/run-store.js +91 -2
  73. package/dist/shell/scheduling-io.js +12 -13
  74. package/dist/shell/state-cli.js +2 -7
  75. package/dist/shell/state-explosion-cli.js +19 -10
  76. package/dist/shell/topology-io.js +38 -6
  77. package/dist/shell/trust-audit.js +264 -23
  78. package/dist/shell/trust-policy-io.js +1 -1
  79. package/dist/shell/worker-cli.js +41 -29
  80. package/dist/shell/worker-isolation.js +34 -9
  81. package/dist/shell/workflow-app-loader.js +3 -2
  82. package/dist/wiring/capability-table/basics.js +91 -0
  83. package/dist/wiring/capability-table/exec-backend.js +171 -0
  84. package/dist/wiring/capability-table/index.js +42 -0
  85. package/dist/wiring/capability-table/multi-agent.js +630 -0
  86. package/dist/wiring/capability-table/parity.js +467 -0
  87. package/dist/wiring/capability-table/pipeline.js +292 -0
  88. package/dist/wiring/capability-table/registry-core.js +208 -0
  89. package/dist/wiring/capability-table/reporting.js +435 -0
  90. package/dist/wiring/capability-table/scheduling-registry.js +592 -0
  91. package/dist/wiring/capability-table/state.js +181 -0
  92. package/dist/wiring/capability-table/trust-ledger.js +158 -0
  93. package/dist/wiring/capability-table/workflow-apps.js +381 -0
  94. package/docs/agent-delegation-drive.7.md +4 -0
  95. package/docs/candidate-scoring.7.md +1 -1
  96. package/docs/canonical-workflow-apps.7.md +7 -7
  97. package/docs/cli-mcp-parity.7.md +13 -3
  98. package/docs/contract-migration-tooling.7.md +4 -0
  99. package/docs/control-plane-scheduling.7.md +4 -0
  100. package/docs/coordinator-blackboard.7.md +17 -17
  101. package/docs/dogfood-one-real-repo.7.md +11 -11
  102. package/docs/durable-state-and-locking.7.md +24 -0
  103. package/docs/end-to-end-golden-path.7.md +14 -14
  104. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  105. package/docs/execution-backends.7.md +4 -0
  106. package/docs/getting-started.md +37 -37
  107. package/docs/index.md +1 -0
  108. package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
  109. package/docs/multi-agent-eval-replay-harness.7.md +17 -13
  110. package/docs/multi-agent-operator-ux.7.md +23 -19
  111. package/docs/multi-agent-runtime-core.7.md +22 -22
  112. package/docs/multi-agent-topologies.7.md +6 -6
  113. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  114. package/docs/node-snapshot-diff-replay.7.md +4 -0
  115. package/docs/observability-cost-accounting.7.md +4 -0
  116. package/docs/operator-ux.7.md +34 -34
  117. package/docs/pipeline-runner.7.md +4 -4
  118. package/docs/project-index.md +48 -5
  119. package/docs/real-execution-backends.7.md +4 -0
  120. package/docs/release-and-migration.7.md +5 -1
  121. package/docs/release-tooling.7.md +4 -0
  122. package/docs/routines.md +4 -4
  123. package/docs/run-registry-control-plane.7.md +23 -19
  124. package/docs/run-retention-reclamation.7.md +4 -0
  125. package/docs/scheduled-tasks.md +14 -14
  126. package/docs/security-trust-hardening.7.md +43 -13
  127. package/docs/state-explosion-management.7.md +14 -10
  128. package/docs/team-collaboration.7.md +4 -0
  129. package/docs/trust-audit-anchor.7.md +71 -0
  130. package/docs/unix-principles.md +3 -1
  131. package/docs/verifier-gated-commit.7.md +1 -1
  132. package/docs/web-desktop-workbench.7.md +4 -0
  133. package/docs/workflow-app-framework.7.md +13 -13
  134. package/manifest/plugin.manifest.json +1 -1
  135. package/package.json +4 -2
  136. package/scripts/agents/claude-p-agent.js +2 -2
  137. package/scripts/block-unapproved-tag.sh +23 -2
  138. package/scripts/bump-version.js +24 -2
  139. package/scripts/canonical-apps.js +4 -4
  140. package/scripts/children/batch-delegate-child.js +52 -2
  141. package/scripts/dogfood-release.js +1 -1
  142. package/scripts/fake-date-for-reproduction.js +44 -0
  143. package/scripts/golden-path.js +4 -4
  144. package/scripts/purity-baseline.json +71 -0
  145. package/scripts/purity-gate.js +239 -0
  146. package/scripts/release-check.js +8 -1
  147. package/scripts/release-flow.js +57 -1
  148. package/scripts/verdict-keygen.js +83 -0
  149. package/scripts/verify-bump-reproduction.sh +148 -0
  150. package/scripts/verify-verdict-signature.js +61 -0
  151. package/scripts/version-sync-check.js +33 -12
  152. package/skills/cool-workflow/SKILL.md +9 -9
  153. package/skills/cool-workflow/references/commands.md +89 -88
  154. package/ui/workbench/app.css +37 -1
  155. package/ui/workbench/app.js +124 -6
  156. package/workflows/README.md +19 -0
@@ -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
+ }
@@ -59,13 +59,14 @@ const telemetry_ledger_io_1 = require("./telemetry-ledger-io");
59
59
  const multi_agent_io_1 = require("./multi-agent-io");
60
60
  const coordinator_io_1 = require("./coordinator-io");
61
61
  const multi_agent_operator_ux_1 = require("./multi-agent-operator-ux");
62
+ const collate_1 = require("../core/util/collate");
62
63
  function formatInputList(value) {
63
64
  if (Array.isArray(value))
64
65
  return value.join("; ");
65
66
  return value ? String(value) : "";
66
67
  }
67
68
  function formatCounts(counts) {
68
- const entries = Object.entries(counts).sort(([a], [b]) => a.localeCompare(b));
69
+ const entries = Object.entries(counts).sort(([a], [b]) => (0, collate_1.stableCompare)(a, b));
69
70
  if (!entries.length)
70
71
  return "none";
71
72
  return entries.map(([k, v]) => `${k}=${v}`).join(", ");
@@ -337,7 +338,8 @@ function writeReport(run) {
337
338
  "| Phase | Status | Completed | Total |",
338
339
  "| --- | --- | ---: | ---: |",
339
340
  ...run.phases.map((phase) => {
340
- 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));
341
343
  const completed = phaseTasks.filter((t) => t.status === "completed").length;
342
344
  return `| ${phase.name} | ${phase.status} | ${completed} | ${phaseTasks.length} |`;
343
345
  }),
@@ -207,10 +207,25 @@ function clampInt(value, fallback, min) {
207
207
  return Math.max(min, Math.floor(n));
208
208
  }
209
209
  let queueCounter = 0;
210
- function queueId() {
211
- queueCounter += 1;
210
+ /** Byte format is `q-${stamp14}-${NNN}` (SPEC/scheduling-registry.md "Id
211
+ * formats"), unchanged. `queueCounter` alone is not enough to keep ids
212
+ * unique: it resets to 0 for every fresh `cw` process, so two SEPARATE
213
+ * processes calling `queue add` within the same second both compute
214
+ * `queueCounter === 1` and mint the IDENTICAL id — `sched lease` then
215
+ * keys grants by id, so both queue entries get treated as one, breaking
216
+ * through `maxConcurrent`. `existingIds` is the current queue file's ids
217
+ * (read inside queueAdd's own file lock, so this sees every entry any
218
+ * earlier-completed `queue add` — same process or not — has already
219
+ * saved); bumping past any collision against that real, shared state
220
+ * keeps ids unique across processes without changing the format. */
221
+ function queueId(existingIds) {
212
222
  const stamp = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
213
- return `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
223
+ let candidate;
224
+ do {
225
+ queueCounter += 1;
226
+ candidate = `q-${stamp}-${String(queueCounter).padStart(3, "0")}`;
227
+ } while (existingIds && existingIds.has(candidate));
228
+ return candidate;
214
229
  }
215
230
  function isRunLifecycleState(value) {
216
231
  return (typeof value === "string" &&
@@ -397,7 +412,7 @@ class RunRegistry {
397
412
  const entries = this.loadQueueEntries();
398
413
  const entry = {
399
414
  schemaVersion: 1,
400
- id: options.id || queueId(),
415
+ id: options.id || queueId(new Set(entries.map((e) => e.id))),
401
416
  runId: options.runId,
402
417
  appId: options.appId,
403
418
  workflowId: options.workflowId,
@@ -462,6 +477,13 @@ class RunRegistry {
462
477
  const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
463
478
  if (result.report.status === "unsupported")
464
479
  return null;
480
+ // Bulk scans (refreshRegistry, below) call deriveRecord directly and
481
+ // tolerate a null the same way as missing/unsupported — one bad run
482
+ // must not stop a listing of everything else. Single-run lookups
483
+ // (deriveRecordForRun, below) re-check this same condition WITHOUT
484
+ // swallowing it, so `cw run show`/`archive`/`rerun` on THIS run
485
+ // specifically get a clear reason instead of a misleading "not found."
486
+ (0, run_store_1.assertNotSuspectedDataLoss)(path.basename(runDir), result);
465
487
  run = result.run;
466
488
  }
467
489
  catch {
@@ -609,7 +631,7 @@ class RunRegistry {
609
631
  if (staleRuns.length || missingRuns.length)
610
632
  status = "stale";
611
633
  }
612
- const refreshCmd = scope === "home" ? "node scripts/cw.js registry refresh --scope home" : "node scripts/cw.js registry refresh";
634
+ const refreshCmd = scope === "home" ? "cw registry refresh --scope home" : "cw registry refresh";
613
635
  return {
614
636
  schemaVersion: 1,
615
637
  scope,
@@ -624,7 +646,7 @@ class RunRegistry {
624
646
  },
625
647
  index: current,
626
648
  counts: current.counts,
627
- nextAction: status === "valid" ? "node scripts/cw.js run search" : refreshCmd,
649
+ nextAction: status === "valid" ? "cw run search" : refreshCmd,
628
650
  };
629
651
  }
630
652
  search(raw = {}) {
@@ -657,7 +679,7 @@ class RunRegistry {
657
679
  offset: query.offset,
658
680
  limit: query.limit,
659
681
  records: page,
660
- nextAction: report.freshness.status === "valid" ? "node scripts/cw.js run show <run-id>" : "node scripts/cw.js registry refresh",
682
+ nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh",
661
683
  };
662
684
  }
663
685
  list(options = {}) {
@@ -680,7 +702,7 @@ class RunRegistry {
680
702
  resolvedFrom: located.from,
681
703
  repo: located.record.repo,
682
704
  record: located.record,
683
- nextAction: located.record.archived ? "node scripts/cw.js run resume " + runId : "node scripts/cw.js run show " + runId,
705
+ nextAction: located.record.archived ? "cw run resume " + runId : "cw run show " + runId,
684
706
  };
685
707
  }
686
708
  const persisted = this.findPersisted(runId, scope);
@@ -691,10 +713,11 @@ class RunRegistry {
691
713
  freshness: "missing",
692
714
  repo: persisted?.repo,
693
715
  persisted,
694
- nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : ""),
716
+ nextAction: "cw registry refresh" + (scope === "home" ? " --scope home" : ""),
695
717
  };
696
718
  }
697
719
  locate(runId, scope) {
720
+ (0, fs_atomic_1.assertSafeRunId)(runId);
698
721
  const here = this.deriveRecordForRun(this.repoRoot, runId);
699
722
  if (here)
700
723
  return { record: here, from: "repo" };
@@ -711,9 +734,35 @@ class RunRegistry {
711
734
  }
712
735
  deriveRecordForRun(repo, runId) {
713
736
  const runDir = path.join(this.repoRunsDir(repo), runId);
714
- if (!fs.existsSync(path.join(runDir, "state.json")))
737
+ const statePath = path.join(runDir, "state.json");
738
+ if (!fs.existsSync(statePath))
715
739
  return null;
716
- return this.deriveRecord(repo, runDir);
740
+ const record = this.deriveRecord(repo, runDir);
741
+ if (record)
742
+ return record;
743
+ // deriveRecord returned null even though state.json existed a moment
744
+ // ago: it failed to parse, was flagged unsupported, tripped
745
+ // suspected-data-loss corroboration, or (a narrow race) a concurrent
746
+ // process deleted it in between. Re-derive without swallowing so a
747
+ // genuine corruption reaches THIS single-run lookup (backing `cw run
748
+ // show`/`archive`/`rerun`) instead of the misleading "not found" a bulk
749
+ // scan is content with — but if the file is ACTUALLY gone by the time
750
+ // we check again, that is genuinely missing, not corrupt, and must
751
+ // resolve to null like everywhere else, never a raw "File not found"
752
+ // exception surfacing from this fallback re-read.
753
+ try {
754
+ const result = (0, run_store_1.loadRunStateFile)(statePath, { dryRun: true });
755
+ if (result.report.status === "unsupported") {
756
+ throw new Error(`Run state for ${runId} is corrupt (fail closed): ${result.report.errors.join("; ") || "unsupported run state"}. Restore ${statePath} from a backup, or remove the run directory to start over.`);
757
+ }
758
+ (0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
759
+ return record;
760
+ }
761
+ catch (error) {
762
+ if (!fs.existsSync(statePath))
763
+ return null;
764
+ throw error;
765
+ }
717
766
  }
718
767
  findPersisted(runId, scope) {
719
768
  for (const s of scope === "home" ? ["home", "repo"] : ["repo"]) {
@@ -725,6 +774,7 @@ class RunRegistry {
725
774
  return undefined;
726
775
  }
727
776
  loadRun(repo, runId) {
777
+ (0, fs_atomic_1.assertSafeRunId)(runId);
728
778
  const statePath = path.join(this.repoRunsDir(repo), runId, "state.json");
729
779
  if (!fs.existsSync(statePath))
730
780
  throw new Error(`Run not found: ${runId}`);
@@ -732,6 +782,7 @@ class RunRegistry {
732
782
  if (result.report.status === "unsupported") {
733
783
  throw new Error(`Unsupported run state for ${runId}: ${result.report.errors.join("; ")}`);
734
784
  }
785
+ (0, run_store_1.assertNotSuspectedDataLoss)(runId, result);
735
786
  return result.run;
736
787
  }
737
788
  resume(runId, options = {}) {
@@ -752,23 +803,23 @@ class RunRegistry {
752
803
  const nextActions = [];
753
804
  if (nextTasks.length) {
754
805
  nextActions.push({
755
- command: `node scripts/cw.js dispatch ${runId} --cwd ${record.repo}`,
806
+ command: `cw dispatch ${runId} --cwd ${record.repo}`,
756
807
  reason: `Continue ${nextTasks.length} pending/running task(s) from durable state.`,
757
808
  });
758
809
  nextActions.push({
759
- command: `node scripts/cw.js multi-agent step ${runId} --cwd ${record.repo}`,
810
+ command: `cw multi-agent step ${runId} --cwd ${record.repo}`,
760
811
  reason: "Take one deterministic host step without spawning agents.",
761
812
  });
762
813
  }
763
814
  else if (record.derivedLifecycle === "failed") {
764
815
  nextActions.push({
765
- command: `node scripts/cw.js run rerun ${runId}`,
816
+ command: `cw run rerun ${runId}`,
766
817
  reason: "Run terminated as failed with no runnable tasks; rerun as a new linked run.",
767
818
  });
768
819
  }
769
820
  else {
770
821
  nextActions.push({
771
- command: `node scripts/cw.js status ${runId} --cwd ${record.repo} --json`,
822
+ command: `cw status ${runId} --cwd ${record.repo} --json`,
772
823
  reason: "No runnable tasks remain; inspect status.",
773
824
  });
774
825
  }
@@ -807,7 +858,14 @@ class RunRegistry {
807
858
  }
808
859
  (0, fs_atomic_1.writeJson)(file, overlay, { durable: true });
809
860
  });
810
- const record = this.deriveRecord(repo, located.record.runDir);
861
+ // deriveRecordForRun (not a bare deriveRecord(...)!) so a run that
862
+ // became corrupt/unsupported/suspected-data-loss in the narrow window
863
+ // between locate() above and this re-fetch gets the same clear,
864
+ // specific error every other single-run lookup gets, instead of a raw
865
+ // TypeError from a non-null assertion on a surprise null.
866
+ const record = this.deriveRecordForRun(repo, runId);
867
+ if (!record)
868
+ throw new Error(`Cannot archive: run ${runId} state became unavailable while archiving (fail closed).`);
811
869
  return {
812
870
  runId,
813
871
  repo,
@@ -877,8 +935,8 @@ class RunRegistry {
877
935
  pendingTasks: newRun.tasks.filter((t) => t.status === "pending").length,
878
936
  provenance,
879
937
  nextActions: [
880
- { command: `node scripts/cw.js run resume ${newRun.id}`, reason: "Continue the new linked run." },
881
- { command: `node scripts/cw.js run show ${runId}`, reason: "The original failed run is preserved for audit." },
938
+ { command: `cw run resume ${newRun.id}`, reason: "Continue the new linked run." },
939
+ { command: `cw run show ${runId}`, reason: "The original failed run is preserved for audit." },
882
940
  ],
883
941
  };
884
942
  }
@@ -918,7 +976,7 @@ class RunRegistry {
918
976
  limit,
919
977
  repos: index.repos,
920
978
  entries,
921
- nextAction: report.freshness.status === "valid" ? "node scripts/cw.js run show <run-id>" : "node scripts/cw.js registry refresh --scope home",
979
+ nextAction: report.freshness.status === "valid" ? "cw run show <run-id>" : "cw registry refresh --scope home",
922
980
  };
923
981
  }
924
982
  }
@@ -49,7 +49,9 @@ exports.hashArtifactFile = hashArtifactFile;
49
49
  exports.loadRunStateFile = loadRunStateFile;
50
50
  exports.checkRunStateFile = checkRunStateFile;
51
51
  exports.migrateRunStateFile = migrateRunStateFile;
52
+ exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
52
53
  exports.loadRunFromCwd = loadRunFromCwd;
54
+ exports.withRunStateLock = withRunStateLock;
53
55
  exports.saveCheckpoint = saveCheckpoint;
54
56
  exports.compactCheckpoint = compactCheckpoint;
55
57
  exports.createRun = createRun;
@@ -97,21 +99,108 @@ function migrateRunStateFile(statePath, options = {}) {
97
99
  }
98
100
  return result;
99
101
  }
102
+ /** ONLY tasksDir/commitsDir (plus the audit event log, checked separately
103
+ * below) — deliberately NOT every directory a run's `paths` can carry.
104
+ * Verified empirically (via test/run-fixture-compat-smoke.js's real
105
+ * fixtures) that several other candidate directories are unsafe signals:
106
+ * a plain `cw status`/`cw graph` READ already writes cache/derived files
107
+ * as a side effect — `audit/summary.json` + `audit/index.json`
108
+ * (summarizeTrustAudit, called from the status/report path) are non-empty
109
+ * the moment ANY read touches the run, and `blackboard/`/`candidates/`/
110
+ * `topologies/` subdirectories get created (though left empty) the same
111
+ * way. Using those as "real content" signals would refuse to load a
112
+ * perfectly healthy, merely-already-viewed-once run — report.md is the
113
+ * same story (a derived rendering of state, not independent evidence).
114
+ * tasksDir/commitsDir are the two directories confirmed to hold content
115
+ * ONLY from genuine task-dispatch/commit actions, never from an
116
+ * otherwise-read-only command. */
117
+ function contentDirs(paths) {
118
+ return [paths.tasksDir, paths.commitsDir];
119
+ }
120
+ /** True when the run directory already holds real task/commit files, or a
121
+ * non-empty audit event log — i.e. this was NOT a brand-new run dir. Used
122
+ * only to corroborate `report.suspectedDataLoss`: a bare
123
+ * `{workflow, paths}`-less state.json is unremarkable for a run dir that
124
+ * has nothing else in it either (e.g. a run whose creation crashed before
125
+ * anything else was written), but is a strong corruption signal when real
126
+ * content already sits next to it. A directory entry must not START WITH
127
+ * "." to count — cw never writes dot-prefixed names into these
128
+ * directories, so incidental filesystem debris (a stray `.DS_Store`) never
129
+ * by itself makes a genuinely fresh run look corrupted. */
130
+ function hasPreexistingRunContent(run) {
131
+ for (const dir of contentDirs(run.paths)) {
132
+ try {
133
+ if (fs.readdirSync(dir).some((name) => !name.startsWith(".")))
134
+ return true;
135
+ }
136
+ catch {
137
+ /* missing dir — not a signal either way */
138
+ }
139
+ }
140
+ try {
141
+ const eventLogPath = run.audit?.eventLogPath;
142
+ if (eventLogPath && fs.statSync(eventLogPath).size > 0)
143
+ return true;
144
+ }
145
+ catch {
146
+ /* missing/empty audit log — not a signal */
147
+ }
148
+ return false;
149
+ }
150
+ /** Throws when `result.report.suspectedDataLoss` is true AND the run
151
+ * directory already has real content on disk — see
152
+ * `hasPreexistingRunContent`. Shared by every state.json reader
153
+ * (`loadRunFromCwd` here, `RunRegistry.loadRun` in run-registry-io.ts) so
154
+ * a corrupted/wiped state.json is refused the same way regardless of
155
+ * which entry point reached it. */
156
+ function assertNotSuspectedDataLoss(runId, result) {
157
+ if (result.report.suspectedDataLoss && hasPreexistingRunContent(result.run)) {
158
+ throw new Error(`Refusing to load run ${runId}: state.json is missing its core fields (workflow, paths), but the run directory already has task, commit, or other content on disk. This looks like state.json was corrupted, truncated, or replaced by something outside cw, not a new run. Restore state.json from a backup, or remove the run directory to start over.`);
159
+ }
160
+ }
100
161
  /** Refuses an empty id with `Missing run id`; loads
101
162
  * `<cwd>/.cw/runs/<runId>/state.json` (dry-run — never writes); throws
102
- * `Unsupported CW run state: <errors joined by "; ">` on an unsupported
103
- * verdict; else returns the migrated WorkflowRun in memory. */
163
+ * `Run not found: <runId>` when no such run directory has a state.json
164
+ * (every caller report, status, audit, eval snapshot, and more — used
165
+ * to leak the raw `readJson` message, "File not found: <absolute
166
+ * path>/state.json", which named neither the run nor a next step, and
167
+ * didn't contain any of the words `cli/entry.ts`'s `recoveryHint` scans
168
+ * for, so no `Try: cw run list` line ever appeared); throws `Unsupported
169
+ * CW run state: <errors joined by "; ">` on an unsupported verdict;
170
+ * refuses a state.json that lost its core fields (workflow, paths) while
171
+ * the run dir still has real content next to it, rather than silently
172
+ * returning it as a fresh empty run; else returns the migrated
173
+ * WorkflowRun in memory. */
104
174
  function loadRunFromCwd(runId, cwd = process.cwd()) {
105
175
  if (!runId)
106
176
  throw new Error("Missing run id");
107
177
  (0, fs_atomic_1.assertSafeRunId)(runId);
108
178
  const statePath = path.join(cwd, ".cw", "runs", runId, "state.json");
179
+ if (!fs.existsSync(statePath))
180
+ throw new Error(`Run not found: ${runId}`);
109
181
  const result = loadRunStateFile(statePath, { dryRun: true });
110
182
  if (result.report.status === "unsupported") {
111
183
  throw new Error(`Unsupported CW run state: ${result.report.errors.join("; ")}`);
112
184
  }
185
+ assertNotSuspectedDataLoss(runId, result);
113
186
  return result.run;
114
187
  }
188
+ /** Hold the state.json lock over a WHOLE load -> change -> save cycle.
189
+ * A bare loadRunFromCwd + saveCheckpoint pair leaves a window where two
190
+ * processes both load the same state and the later save silently drops
191
+ * the earlier change (the same lost-update class PR #339 fixed for
192
+ * queue.json / triggers.json). `fn` gets the run loaded UNDER the lock;
193
+ * saveCheckpoint calls inside `fn` re-enter the same lock (withFileLock
194
+ * is re-entrant in-process) and write exactly as before. The probe load
195
+ * runs BEFORE the lock so an unknown run id throws the exact
196
+ * loadRunFromCwd error without first creating the run directory as a
197
+ * lock-file side effect — and it supplies `paths.state`, the same lock
198
+ * target saveCheckpoint uses. Keep `fn` short: a critical section past
199
+ * 30s can be stolen as a stale lock. */
200
+ function withRunStateLock(runId, cwd, fn) {
201
+ const probe = loadRunFromCwd(runId, cwd);
202
+ return (0, fs_atomic_1.withFileLock)(probe.paths.state, () => fn(loadRunFromCwd(runId, cwd)));
203
+ }
115
204
  /** state.json is the single source of truth — set `updatedAt`, then write
116
205
  * it DURABLY with a lock so concurrent processes never lose an update. */
117
206
  function saveCheckpoint(run) {
@@ -67,6 +67,7 @@ const fs = __importStar(require("node:fs"));
67
67
  const path = __importStar(require("node:path"));
68
68
  const fs_atomic_1 = require("./fs-atomic");
69
69
  const run_registry_io_1 = require("./run-registry-io");
70
+ const numeric_flag_1 = require("../core/util/numeric-flag");
70
71
  exports.SCHEDULING_SCHEMA_VERSION = 1;
71
72
  /** Conservative fail-closed defaults: serial, bounded retries, exponential backoff. */
72
73
  exports.DEFAULT_SCHEDULING_POLICY = {
@@ -232,14 +233,7 @@ function loadSchedulingPolicy(registry) {
232
233
  return { schemaVersion: 1, policy: normalizeSchedulingPolicy(parsed), source: "file" };
233
234
  }
234
235
  function numericFlag(options, key) {
235
- if (!(key in options))
236
- return undefined;
237
- const raw = options[key];
238
- const n = Number(raw);
239
- if (!Number.isFinite(n)) {
240
- throw new Error(`Invalid --${key} "${raw}": expected a number (e.g. --${key} 4)`);
241
- }
242
- return n;
236
+ return (0, numeric_flag_1.requiredNumberFlag)(options[key], `--${key}`);
243
237
  }
244
238
  function schedPlanCli(options = {}) {
245
239
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
@@ -250,7 +244,7 @@ function schedLeaseCli(options = {}) {
250
244
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
251
245
  const now = nowIso(options);
252
246
  const policy = loadSchedulingPolicy(registry).policy;
253
- const limit = options.limit === undefined ? undefined : Number(options.limit);
247
+ const limit = (0, numeric_flag_1.requiredNumberFlag)(options.limit, "--limit");
254
248
  return (0, fs_atomic_1.withFileLock)(registry.queueFilePath(), () => {
255
249
  const { entries, leases } = applyLease(registry.loadQueueEntries(), policy, now, limit);
256
250
  registry.saveQueueEntries(entries);
@@ -305,16 +299,21 @@ function schedPolicyShowCli(options = {}) {
305
299
  }
306
300
  function schedPolicySetCli(options = {}) {
307
301
  const registry = new run_registry_io_1.RunRegistry(resolveCwd(options));
308
- const current = loadSchedulingPolicy(registry).policy;
309
302
  const patch = {};
310
303
  for (const key of ["maxConcurrent", "maxAttempts", "leaseTtlMs", "backoffBaseMs", "backoffFactor", "backoffCapMs"]) {
311
304
  const value = numericFlag(options, key);
312
305
  if (value !== undefined)
313
306
  patch[key] = value;
314
307
  }
315
- const policy = normalizeSchedulingPolicy({ ...current, ...patch });
316
- (0, fs_atomic_1.writeJson)(registry.schedulingPolicyPath(), policy);
317
- return { schemaVersion: 1, policy, source: "file" };
308
+ // Read-modify-write under the policy file's own lock: two concurrent
309
+ // `sched policy set` calls patching different fields must not drop each
310
+ // other's write.
311
+ return (0, fs_atomic_1.withFileLock)(registry.schedulingPolicyPath(), () => {
312
+ const current = loadSchedulingPolicy(registry).policy;
313
+ const policy = normalizeSchedulingPolicy({ ...current, ...patch });
314
+ (0, fs_atomic_1.writeJson)(registry.schedulingPolicyPath(), policy);
315
+ return { schemaVersion: 1, policy, source: "file" };
316
+ });
318
317
  }
319
318
  function isTrue(value) {
320
319
  return value === true || value === "true" || value === "1";