cool-workflow 0.1.82 → 0.1.84

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 (92) hide show
  1. package/.claude-plugin/plugin.json +2 -2
  2. package/.codex-plugin/plugin.json +4 -4
  3. package/README.md +128 -120
  4. package/apps/architecture-review/app.json +1 -1
  5. package/apps/architecture-review-fast/app.json +1 -1
  6. package/apps/end-to-end-golden-path/app.json +1 -1
  7. package/apps/pr-review-fix-ci/app.json +1 -1
  8. package/apps/release-cut/app.json +1 -1
  9. package/apps/research-synthesis/app.json +1 -1
  10. package/dist/capability-core.js +16 -8
  11. package/dist/capability-registry.js +270 -0
  12. package/dist/cli/command-surface.js +1320 -0
  13. package/dist/cli.js +2 -1307
  14. package/dist/commit.js +5 -1
  15. package/dist/doctor.js +153 -0
  16. package/dist/mcp-server.js +15 -1451
  17. package/dist/mcp-surface.js +1441 -0
  18. package/dist/orchestrator.js +13 -0
  19. package/dist/reclamation/hash.js +72 -0
  20. package/dist/reclamation.js +25 -78
  21. package/dist/run-registry/queue.js +6 -7
  22. package/dist/run-registry.js +35 -24
  23. package/dist/scheduler.js +78 -53
  24. package/dist/version.js +1 -1
  25. package/dist/worker-accept/acceptance.js +114 -0
  26. package/dist/worker-accept/blackboard-fanout.js +80 -0
  27. package/dist/worker-accept/blackboard-linkage.js +19 -0
  28. package/dist/worker-accept/context.js +2 -0
  29. package/dist/worker-accept/telemetry-ledger.js +116 -0
  30. package/dist/worker-accept/validation.js +77 -0
  31. package/dist/worker-accept/verifier-completion.js +73 -0
  32. package/dist/worker-isolation.js +41 -446
  33. package/docs/agent-delegation-drive.7.md +94 -86
  34. package/docs/agent-framework.md +33 -32
  35. package/docs/candidate-scoring.7.md +26 -24
  36. package/docs/canonical-workflow-apps.7.md +40 -40
  37. package/docs/capability-topology-registry.7.md +24 -24
  38. package/docs/cli-mcp-parity.7.md +230 -154
  39. package/docs/contract-migration-tooling.7.md +52 -41
  40. package/docs/control-plane-scheduling.7.md +49 -41
  41. package/docs/coordinator-blackboard.7.md +30 -30
  42. package/docs/dogfood-one-real-repo.7.md +44 -44
  43. package/docs/durable-state-and-locking.7.md +38 -30
  44. package/docs/end-to-end-golden-path.7.md +29 -29
  45. package/docs/error-feedback.7.md +27 -27
  46. package/docs/evidence-adoption-reasoning-chain.7.md +66 -58
  47. package/docs/execution-backends.7.md +88 -80
  48. package/docs/getting-started.md +35 -18
  49. package/docs/index.md +3 -3
  50. package/docs/mcp-app-surface.7.md +64 -64
  51. package/docs/multi-agent-cli-mcp-surface.7.md +86 -77
  52. package/docs/multi-agent-eval-replay-harness.7.md +63 -55
  53. package/docs/multi-agent-operator-ux.7.md +73 -65
  54. package/docs/multi-agent-runtime-core.7.md +39 -39
  55. package/docs/multi-agent-topologies.7.md +24 -24
  56. package/docs/multi-agent-trust-policy-audit.7.md +38 -38
  57. package/docs/node-snapshot-diff-replay.7.md +30 -22
  58. package/docs/observability-cost-accounting.7.md +53 -45
  59. package/docs/operator-ux.7.md +30 -30
  60. package/docs/pipeline-runner.7.md +31 -31
  61. package/docs/project-index.md +16 -5
  62. package/docs/real-execution-backends.7.md +51 -43
  63. package/docs/release-and-migration.7.md +46 -38
  64. package/docs/release-tooling.7.md +67 -50
  65. package/docs/routines.md +16 -16
  66. package/docs/run-registry-control-plane.7.md +124 -116
  67. package/docs/run-retention-reclamation.7.md +49 -41
  68. package/docs/sandbox-profiles.7.md +32 -32
  69. package/docs/scheduled-tasks.md +14 -14
  70. package/docs/security-trust-hardening.7.md +29 -29
  71. package/docs/source-context-profiles.7.md +28 -28
  72. package/docs/state-explosion-management.7.md +67 -59
  73. package/docs/state-node.7.md +8 -8
  74. package/docs/team-collaboration.7.md +66 -58
  75. package/docs/trust-model.md +126 -126
  76. package/docs/unix-principles.md +80 -80
  77. package/docs/vendor-manifest-loadability.7.md +20 -20
  78. package/docs/verifier-gated-commit.7.md +16 -16
  79. package/docs/web-desktop-workbench.7.md +73 -65
  80. package/docs/worker-isolation.7.md +34 -37
  81. package/docs/workflow-app-framework.7.md +38 -38
  82. package/manifest/plugin.manifest.json +4 -4
  83. package/package.json +3 -2
  84. package/scripts/bump-version.js +9 -1
  85. package/scripts/canonical-apps.js +4 -4
  86. package/scripts/dogfood-release.js +1 -1
  87. package/scripts/gen-parity-doc.js +106 -0
  88. package/scripts/golden-path.js +4 -4
  89. package/scripts/parity-check.js +27 -57
  90. package/scripts/release-flow.js +7 -6
  91. package/scripts/sync-project-index.js +1 -1
  92. package/dist/verifier-registry.js +0 -46
@@ -821,6 +821,7 @@ function formatHelp() {
821
821
  "",
822
822
  "Commands:",
823
823
  " list",
824
+ " doctor [--json] (check your setup: Node, agent backend, git, writable state)",
824
825
  " init <workflow-id> [--title TEXT] [--output PATH]",
825
826
  " quickstart [app-id] [--repo PATH] [--question TEXT] [--agent-command CMD] [--once] [--preview]",
826
827
  " plan <workflow-id> [--repo PATH] [--question TEXT] [--invariant TEXT]",
@@ -873,6 +874,18 @@ function formatHelp() {
873
874
  " run search|list|show|resume|archive|rerun|export|import|verify-import [run-id|archive] [--scope repo|home] [--json]",
874
875
  " queue add|list|drain|show [queue-id] [--repo PATH] [--priority N]",
875
876
  " history [--scope repo|home] [--app ID] [--status STATE] [--json]",
877
+ " audit-run <app-id> [--repo PATH] [--question TEXT] [--agent-command CMD]",
878
+ " metrics show|summary <run-id> [--scope repo|home] [--json]",
879
+ " telemetry verify <run-id> [--pubkey PEM|PATH] [--json]",
880
+ " gc plan|run|verify [run-id] [--json]",
881
+ " sched plan|lease|release|complete|reclaim|reset|policy [--json]",
882
+ " migration list|check|prove [target] [--json]",
883
+ " operator status|report <run-id> [--json]",
884
+ " review status|policy <run-id> [--json]",
885
+ " approve|reject|comment <kind> <run-id> <target-id> [--reason TEXT]",
886
+ " handoff <kind> <run-id> <target-id> [--to ROLE]",
887
+ " loop --prompt TEXT [--interval-minutes N]",
888
+ " demo tamper",
876
889
  " workbench view <run-id> [--json]",
877
890
  " workbench serve [--port N] [--scope repo|home] [--once|--json]",
878
891
  ""
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.sha256OfString = sha256OfString;
7
+ exports.sha256OfFile = sha256OfFile;
8
+ exports.dirBytes = dirBytes;
9
+ exports.contentDigest = contentDigest;
10
+ // Content addressing + byte measurement for run reclamation (NO `du` — in-process
11
+ // only). Carved out of reclamation.ts (FreeBSD-audit god-module carve) so the pure
12
+ // content-addressing leaf no longer sits inside the write-ahead reclamation
13
+ // transaction. These are pure functions of their path/string inputs — no run
14
+ // state, no module-level mutable state.
15
+ //
16
+ // BEHAVIOR-PRESERVING — pure code movement, zero logic change. reclamation.ts
17
+ // re-exports the public symbols (sha256OfString/sha256OfFile/dirBytes) so the
18
+ // module's surface stays byte-identical.
19
+ const node_crypto_1 = __importDefault(require("node:crypto"));
20
+ const node_fs_1 = __importDefault(require("node:fs"));
21
+ const node_path_1 = __importDefault(require("node:path"));
22
+ const compare_1 = require("../compare");
23
+ function sha256Hex(value) {
24
+ return node_crypto_1.default.createHash("sha256").update(value).digest("hex");
25
+ }
26
+ function sha256OfString(value) {
27
+ return `sha256:${sha256Hex(value)}`;
28
+ }
29
+ function sha256OfFile(file) {
30
+ return `sha256:${sha256Hex(node_fs_1.default.readFileSync(file))}`;
31
+ }
32
+ /** Walk a path and sum file sizes IN-PROCESS (no `du`). Returns 0 if absent. A
33
+ * file returns its own size; a dir returns the recursive sum. */
34
+ function dirBytes(p) {
35
+ let total = 0;
36
+ let stat;
37
+ try {
38
+ stat = node_fs_1.default.statSync(p);
39
+ }
40
+ catch {
41
+ return 0;
42
+ }
43
+ if (stat.isFile())
44
+ return stat.size;
45
+ if (!stat.isDirectory())
46
+ return 0;
47
+ for (const entry of node_fs_1.default.readdirSync(p, { withFileTypes: true })) {
48
+ total += dirBytes(node_path_1.default.join(p, entry.name));
49
+ }
50
+ return total;
51
+ }
52
+ /** Stable content digest of a path (file = its bytes; dir = digest over each
53
+ * member's relative path + bytes, sorted). Lets the freed-manifest record a
54
+ * single sha per freed dir. */
55
+ function contentDigest(p) {
56
+ const stat = node_fs_1.default.statSync(p);
57
+ if (stat.isFile())
58
+ return sha256OfFile(p);
59
+ const parts = [];
60
+ const walk = (dir, rel) => {
61
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (0, compare_1.compareBytes)(a.name, b.name))) {
62
+ const abs = node_path_1.default.join(dir, entry.name);
63
+ const r = node_path_1.default.join(rel, entry.name);
64
+ if (entry.isDirectory())
65
+ walk(abs, r);
66
+ else
67
+ parts.push(`${r}:${sha256OfFile(abs)}`);
68
+ }
69
+ };
70
+ walk(p, "");
71
+ return sha256OfString(parts.join("\n"));
72
+ }
@@ -29,10 +29,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
29
29
  return (mod && mod.__esModule) ? mod : { "default": mod };
30
30
  };
31
31
  Object.defineProperty(exports, "__esModule", { value: true });
32
- exports.ReclamationError = exports.ReclamationAbort = exports.SKELETON_REQUIRED_KEYS = void 0;
33
- exports.sha256OfString = sha256OfString;
34
- exports.sha256OfFile = sha256OfFile;
35
- exports.dirBytes = dirBytes;
32
+ exports.dirBytes = exports.sha256OfFile = exports.sha256OfString = exports.ReclamationError = exports.ReclamationAbort = exports.SKELETON_REQUIRED_KEYS = void 0;
36
33
  exports.reclaimedLogPath = reclaimedLogPath;
37
34
  exports.loadReclamationLog = loadReclamationLog;
38
35
  exports.extractSkeleton = extractSkeleton;
@@ -49,9 +46,12 @@ exports.runReclamation = runReclamation;
49
46
  exports.reconstructArtifact = reconstructArtifact;
50
47
  exports.verifyReclamation = verifyReclamation;
51
48
  exports.dominantFailureCode = dominantFailureCode;
52
- const node_crypto_1 = __importDefault(require("node:crypto"));
53
49
  const node_fs_1 = __importDefault(require("node:fs"));
54
50
  const node_path_1 = __importDefault(require("node:path"));
51
+ const hash_1 = require("./reclamation/hash");
52
+ Object.defineProperty(exports, "dirBytes", { enumerable: true, get: function () { return hash_1.dirBytes; } });
53
+ Object.defineProperty(exports, "sha256OfFile", { enumerable: true, get: function () { return hash_1.sha256OfFile; } });
54
+ Object.defineProperty(exports, "sha256OfString", { enumerable: true, get: function () { return hash_1.sha256OfString; } });
55
55
  const multi_agent_eval_1 = require("./multi-agent-eval");
56
56
  const node_projection_1 = require("./node-projection");
57
57
  const node_snapshot_1 = require("./node-snapshot");
@@ -95,59 +95,6 @@ class ReclamationError extends Error {
95
95
  }
96
96
  }
97
97
  exports.ReclamationError = ReclamationError;
98
- // ---------------------------------------------------------------------------
99
- // Content addressing + byte measurement (NO `du` — in-process only).
100
- // ---------------------------------------------------------------------------
101
- function sha256Hex(value) {
102
- return node_crypto_1.default.createHash("sha256").update(value).digest("hex");
103
- }
104
- function sha256OfString(value) {
105
- return `sha256:${sha256Hex(value)}`;
106
- }
107
- function sha256OfFile(file) {
108
- return `sha256:${sha256Hex(node_fs_1.default.readFileSync(file))}`;
109
- }
110
- /** Walk a path and sum file sizes IN-PROCESS (no `du`). Returns 0 if absent. A
111
- * file returns its own size; a dir returns the recursive sum. */
112
- function dirBytes(p) {
113
- let total = 0;
114
- let stat;
115
- try {
116
- stat = node_fs_1.default.statSync(p);
117
- }
118
- catch {
119
- return 0;
120
- }
121
- if (stat.isFile())
122
- return stat.size;
123
- if (!stat.isDirectory())
124
- return 0;
125
- for (const entry of node_fs_1.default.readdirSync(p, { withFileTypes: true })) {
126
- total += dirBytes(node_path_1.default.join(p, entry.name));
127
- }
128
- return total;
129
- }
130
- /** Stable content digest of a path (file = its bytes; dir = digest over each
131
- * member's relative path + bytes, sorted). Lets the freed-manifest record a
132
- * single sha per freed dir. */
133
- function contentDigest(p) {
134
- const stat = node_fs_1.default.statSync(p);
135
- if (stat.isFile())
136
- return sha256OfFile(p);
137
- const parts = [];
138
- const walk = (dir, rel) => {
139
- for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (0, compare_1.compareBytes)(a.name, b.name))) {
140
- const abs = node_path_1.default.join(dir, entry.name);
141
- const r = node_path_1.default.join(rel, entry.name);
142
- if (entry.isDirectory())
143
- walk(abs, r);
144
- else
145
- parts.push(`${r}:${sha256OfFile(abs)}`);
146
- }
147
- };
148
- walk(p, "");
149
- return sha256OfString(parts.join("\n"));
150
- }
151
98
  /** Persist a run's authoritative state.json DURABLY (atomic temp → fsync →
152
99
  * rename). The re-point that scratch reclamation depends on MUST be persisted
153
100
  * this way BEFORE any byte is freed — see prepareFree(). */
@@ -220,13 +167,13 @@ function digestEvidenceEntry(entry) {
220
167
  try {
221
168
  const stat = node_fs_1.default.statSync(candidatePath);
222
169
  if (stat.isFile())
223
- return { ref, digest: sha256OfFile(candidatePath) };
170
+ return { ref, digest: (0, hash_1.sha256OfFile)(candidatePath) };
224
171
  }
225
172
  catch {
226
173
  /* fall through to locator digest */
227
174
  }
228
175
  }
229
- return { ref, digest: sha256OfString(ref) };
176
+ return { ref, digest: (0, hash_1.sha256OfString)(ref) };
230
177
  }
231
178
  /** STEP 1: extract + seal the skeleton. Pure read over the run; never mutates. */
232
179
  function extractSkeleton(run) {
@@ -264,7 +211,7 @@ function extractSkeleton(run) {
264
211
  .map(([ref, digest]) => ({ ref, digest }))
265
212
  .sort((a, b) => (0, compare_1.compareBytes)(a.ref, b.ref));
266
213
  const eventLog = auditEventLogPath(run);
267
- const auditLogDigest = node_fs_1.default.existsSync(eventLog) ? sha256OfFile(eventLog) : sha256OfString("");
214
+ const auditLogDigest = node_fs_1.default.existsSync(eventLog) ? (0, hash_1.sha256OfFile)(eventLog) : (0, hash_1.sha256OfString)("");
268
215
  const events = node_fs_1.default.existsSync(eventLog)
269
216
  ? node_fs_1.default
270
217
  .readFileSync(eventLog, "utf8")
@@ -284,18 +231,18 @@ function extractSkeleton(run) {
284
231
  const metricsReport = node_path_1.default.join(run.paths.runDir, "metrics", "metrics-report.json");
285
232
  const costRecord = {
286
233
  tasks: (run.tasks || []).map((task) => ({ taskId: task.id, model: task.usage?.model, source: task.usage?.source })),
287
- metricsDigest: node_fs_1.default.existsSync(metricsReport) ? sha256OfFile(metricsReport) : undefined
234
+ metricsDigest: node_fs_1.default.existsSync(metricsReport) ? (0, hash_1.sha256OfFile)(metricsReport) : undefined
288
235
  };
289
236
  const collaboration = run.collaboration;
290
237
  const collaborationLog = {
291
- digest: sha256OfString((0, multi_agent_eval_1.replayStableStringify)(collaboration || {})),
238
+ digest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(collaboration || {})),
292
239
  approvals: collaboration?.approvals?.length || 0,
293
240
  comments: collaboration?.comments?.length || 0,
294
241
  handoffs: collaboration?.handoffs?.length || 0
295
242
  };
296
243
  // Empty (not a hash-of-empty) when state.json is absent, so the skeleton fails
297
244
  // closed — you cannot seal a run whose authoritative state is gone.
298
- const stateDigest = node_fs_1.default.existsSync(run.paths.state) ? sha256OfFile(run.paths.state) : "";
245
+ const stateDigest = node_fs_1.default.existsSync(run.paths.state) ? (0, hash_1.sha256OfFile)(run.paths.state) : "";
299
246
  return {
300
247
  schemaVersion: 1,
301
248
  runId: run.id,
@@ -402,13 +349,13 @@ function buildReferenceGraph(run) {
402
349
  * node-projection field set (node-projection.ts) so reconstruction matches
403
350
  * node-snapshot.ts's body byte-for-byte — the projection can no longer drift. */
404
351
  function snapshotProjectionDigest(node) {
405
- return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
352
+ return (0, hash_1.sha256OfString)((0, node_projection_1.nodeProjectionDigestInput)(node));
406
353
  }
407
354
  /** Body digest of the RETAINED node (lives in state.json). The reconstruction
408
355
  * verifier re-derives the projection from this retained input. Same shared field
409
356
  * set / canonical bytes as snapshotProjectionDigest. */
410
357
  function nodeBodyDigest(node) {
411
- return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
358
+ return (0, hash_1.sha256OfString)((0, node_projection_1.nodeProjectionDigestInput)(node));
412
359
  }
413
360
  /** Build the retention plan: which paths are freeable under `policy`, of what
414
361
  * kind, how many bytes, and the resulting capability downgrade. */
@@ -431,7 +378,7 @@ function planReclamation(run, policy = {}) {
431
378
  const resultsCopy = task?.resultPath;
432
379
  if (!resultNodeId || !resultsCopy || !node_fs_1.default.existsSync(resultsCopy))
433
380
  continue;
434
- const bytes = dirBytes(workerDir);
381
+ const bytes = (0, hash_1.dirBytes)(workerDir);
435
382
  if (bytes <= 0)
436
383
  continue;
437
384
  freeable.push({
@@ -478,14 +425,14 @@ function planReclamation(run, policy = {}) {
478
425
  continue; // source node gone → cannot reconstruct → retain
479
426
  if (repointNodeIds.has(node.id))
480
427
  continue; // body will be re-pointed → retain
481
- const bytes = dirBytes(snapFile);
428
+ const bytes = (0, hash_1.dirBytes)(snapFile);
482
429
  if (bytes <= 0)
483
430
  continue;
484
431
  const inputDigest = nodeBodyDigest(node);
485
432
  const recipe = {
486
433
  recipeKind: "node-snapshot-projection",
487
434
  inputDigests: [inputDigest],
488
- inputsDigest: sha256OfString((0, multi_agent_eval_1.replayStableStringify)([inputDigest])),
435
+ inputsDigest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)([inputDigest])),
489
436
  expectDigest: snapshotProjectionDigest(node),
490
437
  sourceRef: node.id
491
438
  };
@@ -534,11 +481,11 @@ function planReclamation(run, policy = {}) {
534
481
  return { freeable, bytesToFree, byKind, capability, capabilityReason };
535
482
  }
536
483
  function policyDigestOf(policy) {
537
- return sha256OfString((0, multi_agent_eval_1.replayStableStringify)(policy));
484
+ return (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(policy));
538
485
  }
539
486
  /** genesis prevTombstoneHash = sha256 of the sealed skeleton. */
540
487
  function genesisPrevHash(skeleton) {
541
- return sha256OfString((0, multi_agent_eval_1.replayStableStringify)(skeleton));
488
+ return (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(skeleton));
542
489
  }
543
490
  /** The canonical bytes a tombstoneHash binds: freed-manifest + sealed skeleton +
544
491
  * prevTombstoneHash + capability. Recomputed independently by `gc verify`. */
@@ -551,14 +498,14 @@ function tombstoneHashInput(t) {
551
498
  policyDigest: t.policyDigest,
552
499
  freed: t.freed.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes, sha256: f.sha256, recipe: f.recipe || null })),
553
500
  bytesFreed: t.bytesFreed,
554
- skeletonDigest: sha256OfString((0, multi_agent_eval_1.replayStableStringify)(t.skeleton)),
501
+ skeletonDigest: (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)(t.skeleton)),
555
502
  capability: t.capability,
556
503
  capabilityReason: t.capabilityReason,
557
504
  prevTombstoneHash: t.prevTombstoneHash
558
505
  });
559
506
  }
560
507
  function computeTombstoneHash(t) {
561
- return sha256OfString(tombstoneHashInput(t));
508
+ return (0, hash_1.sha256OfString)(tombstoneHashInput(t));
562
509
  }
563
510
  function tombstoneId(seq) {
564
511
  // Deterministic (FreeBSD-audit L13): the chain POSITION, not a process-global
@@ -576,7 +523,7 @@ function buildTombstone(run, skeleton, plan, options = {}) {
576
523
  path: entry.path,
577
524
  kind: entry.kind,
578
525
  bytes: entry.bytes,
579
- sha256: contentDigest(entry.absPath),
526
+ sha256: (0, hash_1.contentDigest)(entry.absPath),
580
527
  recipe: entry.recipe
581
528
  }));
582
529
  const base = {
@@ -685,7 +632,7 @@ function freeBulk(run, tombstone) {
685
632
  let freedBytes = 0;
686
633
  for (const entry of tombstone.freed) {
687
634
  const abs = node_path_1.default.join(runDir, entry.path);
688
- const before = dirBytes(abs);
635
+ const before = (0, hash_1.dirBytes)(abs);
689
636
  node_fs_1.default.rmSync(abs, { recursive: true, force: true });
690
637
  freedBytes += before;
691
638
  }
@@ -774,15 +721,15 @@ function reconstructArtifact(run, recipe) {
774
721
  if (recipe.recipeKind === "node-snapshot-projection") {
775
722
  const node = (run.nodes || []).find((n) => n.id === recipe.sourceRef);
776
723
  if (!node) {
777
- return { inputsDigest: sha256OfString("absent"), expectDigest: sha256OfString("absent") };
724
+ return { inputsDigest: (0, hash_1.sha256OfString)("absent"), expectDigest: (0, hash_1.sha256OfString)("absent") };
778
725
  }
779
726
  const inputDigest = nodeBodyDigest(node);
780
- const inputsDigest = sha256OfString((0, multi_agent_eval_1.replayStableStringify)([inputDigest]));
727
+ const inputsDigest = (0, hash_1.sha256OfString)((0, multi_agent_eval_1.replayStableStringify)([inputDigest]));
781
728
  const expectDigest = snapshotProjectionDigest(node);
782
729
  return { inputsDigest, expectDigest };
783
730
  }
784
731
  // Unknown recipe kind → fail closed (digest can't match expectDigest).
785
- return { inputsDigest: sha256OfString("unknown-recipe"), expectDigest: sha256OfString("unknown-recipe") };
732
+ return { inputsDigest: (0, hash_1.sha256OfString)("unknown-recipe"), expectDigest: (0, hash_1.sha256OfString)("unknown-recipe") };
786
733
  }
787
734
  /** Re-prove the whole reclamation chain for a run: skeleton schema-complete,
788
735
  * tombstoneHash/prevTombstoneHash chain recomputed-and-untampered, and each
@@ -33,15 +33,14 @@ function queueFilePath(host) {
33
33
  }
34
34
  function loadQueue(host) {
35
35
  const file = queueFilePath(host);
36
+ // Absent => empty queue. A present-but-corrupt queue must FAIL CLOSED rather
37
+ // than read as empty: silently draining to [] would lose every queued run and
38
+ // let scheduling/lease ops proceed as if the store were clean. readJson throws
39
+ // `Invalid JSON in <file>` on a present, unparseable store; let it propagate.
36
40
  if (!node_fs_1.default.existsSync(file))
37
41
  return [];
38
- try {
39
- const parsed = (0, state_1.readJson)(file);
40
- return Array.isArray(parsed.entries) ? parsed.entries : [];
41
- }
42
- catch {
43
- return [];
44
- }
42
+ const parsed = (0, state_1.readJson)(file);
43
+ return Array.isArray(parsed.entries) ? parsed.entries : [];
45
44
  }
46
45
  function saveQueue(host, entries) {
47
46
  (0, state_1.writeJson)(queueFilePath(host), { schemaVersion: 1, entries }, { durable: true });
@@ -162,29 +162,36 @@ class RunRegistry {
162
162
  return node_path_1.default.join(this.homeRoot, "registry");
163
163
  }
164
164
  // ---- per-repo overlays (plain files) ------------------------------------
165
+ // Overlay reads distinguish ABSENT (clean default) from PRESENT-but-corrupt
166
+ // (fail closed). readJson throws `Invalid JSON in <file>` on a present file
167
+ // that won't parse; we let that propagate instead of swallowing it. Swallowing
168
+ // is the absent-vs-corrupt conflation telemetry-ledger.ts flags as the bug that
169
+ // "let a corrupt overlay verify green" — here it would silently un-archive every
170
+ // archived run / drop every provenance link. This is authoritative durable state.
171
+ // A present overlay must parse to a JSON OBJECT. readJson already fails closed
172
+ // on unparseable bytes; this catches the next shape over: valid JSON that is
173
+ // `null`, an array, or a scalar. Without it `parsed.archived` throws a cryptic
174
+ // TypeError (null) or silently reads `undefined` (array) and the whole registry
175
+ // scan breaks. Fail closed with a clear message instead.
176
+ requireOverlayObject(parsed, file) {
177
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
178
+ throw new Error(`Corrupt overlay ${file}: expected a JSON object, got ${Array.isArray(parsed) ? "array" : parsed === null ? "null" : typeof parsed}`);
179
+ }
180
+ return parsed;
181
+ }
165
182
  loadArchiveOverlay(repo) {
166
183
  const file = node_path_1.default.join(this.repoRegistryDir(repo), "archive.json");
167
184
  if (!node_fs_1.default.existsSync(file))
168
185
  return { schemaVersion: 1, archived: {} };
169
- try {
170
- const parsed = (0, state_1.readJson)(file);
171
- return { schemaVersion: 1, archived: parsed.archived || {} };
172
- }
173
- catch {
174
- return { schemaVersion: 1, archived: {} };
175
- }
186
+ const parsed = this.requireOverlayObject((0, state_1.readJson)(file), file);
187
+ return { schemaVersion: 1, archived: parsed.archived || {} };
176
188
  }
177
189
  loadProvenanceOverlay(repo) {
178
190
  const file = node_path_1.default.join(this.repoRegistryDir(repo), "provenance.json");
179
191
  if (!node_fs_1.default.existsSync(file))
180
192
  return { schemaVersion: 1, links: {} };
181
- try {
182
- const parsed = (0, state_1.readJson)(file);
183
- return { schemaVersion: 1, links: parsed.links || {} };
184
- }
185
- catch {
186
- return { schemaVersion: 1, links: {} };
187
- }
193
+ const parsed = this.requireOverlayObject((0, state_1.readJson)(file), file);
194
+ return { schemaVersion: 1, links: parsed.links || {} };
188
195
  }
189
196
  loadRepoOverlays(repo) {
190
197
  return {
@@ -203,15 +210,14 @@ class RunRegistry {
203
210
  }
204
211
  loadRepos() {
205
212
  const file = this.reposFilePath();
213
+ // Absent => no registered repos. Present-but-corrupt must fail closed: a
214
+ // swallowed parse error here silently drops every cross-repo root the
215
+ // operator registered, shrinking the home index to the current repo with no
216
+ // signal. Let readJson's `Invalid JSON` throw surface the corruption.
206
217
  if (!node_fs_1.default.existsSync(file))
207
218
  return { schemaVersion: 1, repos: [] };
208
- try {
209
- const parsed = (0, state_1.readJson)(file);
210
- return { schemaVersion: 1, repos: Array.isArray(parsed.repos) ? parsed.repos : [] };
211
- }
212
- catch {
213
- return { schemaVersion: 1, repos: [] };
214
- }
219
+ const parsed = this.requireOverlayObject((0, state_1.readJson)(file), file);
220
+ return { schemaVersion: 1, repos: Array.isArray(parsed.repos) ? parsed.repos : [] };
215
221
  }
216
222
  /** Persisted union of registered repo roots and the current repo, deduped and
217
223
  * sorted. Read-only: does NOT write repos.json (reads stay pure). */
@@ -745,9 +751,14 @@ class RunRegistry {
745
751
  // Record provenance in the per-repo overlay (derived metadata), NOT in the
746
752
  // original run's source state — the past is never mutated.
747
753
  const provFile = node_path_1.default.join(this.repoRegistryDir(original.repo), "provenance.json");
748
- const provOverlay = this.loadProvenanceOverlay(original.repo);
749
- provOverlay.links[newRun.id] = provenance;
750
- (0, state_1.writeJson)(provFile, provOverlay, { durable: true });
754
+ // Lock the read-modify-write: a concurrent rerun/archive on the same repo
755
+ // overlay would otherwise last-writer-wins and drop a provenance link. The
756
+ // sibling writers (registerRepo, archive) already serialize via withFileLock.
757
+ (0, state_1.withFileLock)(provFile, () => {
758
+ const provOverlay = this.loadProvenanceOverlay(original.repo);
759
+ provOverlay.links[newRun.id] = provenance;
760
+ (0, state_1.writeJson)(provFile, provOverlay, { durable: true });
761
+ });
751
762
  return {
752
763
  schemaVersion: 1,
753
764
  originalRunId: runId,
package/dist/scheduler.js CHANGED
@@ -16,6 +16,16 @@ class Scheduler {
16
16
  this.cwd = node_path_1.default.resolve(cwd);
17
17
  this.storePath = node_path_1.default.join(this.cwd, ".cw", "schedules", "tasks.json");
18
18
  }
19
+ // Every mutation is a cross-process read-modify-write of the one store file
20
+ // (the daemon polls `due` while CLI calls create/complete/delete concurrently).
21
+ // Without serialization two writers both load, both atomically rename their
22
+ // copy back, and the second silently clobbers the first's new task / status /
23
+ // history record. `locked` holds the same advisory lock the queue and
24
+ // reclamation stores already use. Reads (list/history) need no lock: the atomic
25
+ // rename means a reader always sees a whole old-or-new store.
26
+ locked(fn) {
27
+ return (0, state_1.withFileLock)(this.storePath, fn);
28
+ }
19
29
  create(options) {
20
30
  const kind = normalizeKind(options.kind);
21
31
  const now = new Date();
@@ -43,23 +53,30 @@ class Scheduler {
43
53
  maxRuns: numberOption(options.maxRuns),
44
54
  runCount: 0
45
55
  };
46
- const store = this.load();
47
- store.tasks.push(task);
48
- this.save(store);
49
- return task;
56
+ return this.locked(() => {
57
+ const store = this.load();
58
+ store.tasks.push(task);
59
+ this.save(store);
60
+ return task;
61
+ });
50
62
  }
51
63
  list(status) {
52
64
  const store = this.load();
53
65
  return status ? store.tasks.filter((task) => task.status === status) : store.tasks;
54
66
  }
55
67
  delete(id) {
56
- const store = this.load();
57
- const before = store.tasks.length;
58
- store.tasks = store.tasks.filter((task) => task.id !== id);
59
- this.save(store);
60
- return { deleted: store.tasks.length !== before, id };
68
+ return this.locked(() => {
69
+ const store = this.load();
70
+ const before = store.tasks.length;
71
+ store.tasks = store.tasks.filter((task) => task.id !== id);
72
+ this.save(store);
73
+ return { deleted: store.tasks.length !== before, id };
74
+ });
61
75
  }
62
76
  due(now = new Date()) {
77
+ return this.locked(() => this.dueLocked(now));
78
+ }
79
+ dueLocked(now) {
63
80
  const store = this.load();
64
81
  let changed = false;
65
82
  for (const task of store.tasks) {
@@ -87,63 +104,71 @@ class Scheduler {
87
104
  return dueTasks;
88
105
  }
89
106
  complete(id, options = {}) {
90
- const store = this.load();
91
- const task = store.tasks.find((candidate) => candidate.id === id);
92
- if (!task)
93
- throw new Error(`Scheduled task not found: ${id}`);
94
- const now = new Date();
95
- task.runCount += 1;
96
- task.lastRunAt = now.toISOString();
97
- task.updatedAt = now.toISOString();
98
- const maxRuns = numberOption(options.maxRuns) ?? task.maxRuns;
99
- if (maxRuns !== undefined)
100
- task.maxRuns = maxRuns;
101
- if (task.kind === "reminder" || (task.maxRuns !== undefined && task.runCount >= task.maxRuns)) {
102
- task.status = "completed";
103
- }
104
- else {
105
- task.nextRunAt = computeNextRunAt(task, now).toISOString();
106
- }
107
- this.save(store);
108
- return task;
107
+ return this.locked(() => {
108
+ const store = this.load();
109
+ const task = store.tasks.find((candidate) => candidate.id === id);
110
+ if (!task)
111
+ throw new Error(`Scheduled task not found: ${id}`);
112
+ const now = new Date();
113
+ task.runCount += 1;
114
+ task.lastRunAt = now.toISOString();
115
+ task.updatedAt = now.toISOString();
116
+ const maxRuns = numberOption(options.maxRuns) ?? task.maxRuns;
117
+ if (maxRuns !== undefined)
118
+ task.maxRuns = maxRuns;
119
+ if (task.kind === "reminder" || (task.maxRuns !== undefined && task.runCount >= task.maxRuns)) {
120
+ task.status = "completed";
121
+ }
122
+ else {
123
+ task.nextRunAt = computeNextRunAt(task, now).toISOString();
124
+ }
125
+ this.save(store);
126
+ return task;
127
+ });
109
128
  }
110
129
  pause(id) {
111
130
  return this.setStatus(id, "paused");
112
131
  }
113
132
  resume(id) {
114
- const store = this.load();
115
- const task = findTask(store, id);
116
- const now = new Date();
117
- task.status = "active";
118
- task.updatedAt = now.toISOString();
119
- if (new Date(task.nextRunAt).getTime() <= now.getTime()) {
120
- task.nextRunAt = computeNextRunAt(task, now).toISOString();
121
- }
122
- this.save(store);
123
- return task;
133
+ return this.locked(() => {
134
+ const store = this.load();
135
+ const task = findTask(store, id);
136
+ const now = new Date();
137
+ task.status = "active";
138
+ task.updatedAt = now.toISOString();
139
+ if (new Date(task.nextRunAt).getTime() <= now.getTime()) {
140
+ task.nextRunAt = computeNextRunAt(task, now).toISOString();
141
+ }
142
+ this.save(store);
143
+ return task;
144
+ });
124
145
  }
125
146
  runNow(id) {
126
- const store = this.load();
127
- const task = findTask(store, id);
128
- const now = new Date();
129
- task.lastDueAt = now.toISOString();
130
- task.updatedAt = now.toISOString();
131
- const record = createHistoryRecord(task, "started", this.cwd, now);
132
- store.history.push(record);
133
- this.save(store);
134
- return record;
147
+ return this.locked(() => {
148
+ const store = this.load();
149
+ const task = findTask(store, id);
150
+ const now = new Date();
151
+ task.lastDueAt = now.toISOString();
152
+ task.updatedAt = now.toISOString();
153
+ const record = createHistoryRecord(task, "started", this.cwd, now);
154
+ store.history.push(record);
155
+ this.save(store);
156
+ return record;
157
+ });
135
158
  }
136
159
  history(id) {
137
160
  const store = this.load();
138
161
  return id ? store.history.filter((record) => record.scheduleId === id) : store.history;
139
162
  }
140
163
  setStatus(id, status) {
141
- const store = this.load();
142
- const task = findTask(store, id);
143
- task.status = status;
144
- task.updatedAt = new Date().toISOString();
145
- this.save(store);
146
- return task;
164
+ return this.locked(() => {
165
+ const store = this.load();
166
+ const task = findTask(store, id);
167
+ task.status = status;
168
+ task.updatedAt = new Date().toISOString();
169
+ this.save(store);
170
+ return task;
171
+ });
147
172
  }
148
173
  load() {
149
174
  if (!node_fs_1.default.existsSync(this.storePath))
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.MIN_SUPPORTED_RUN_STATE_SCHEMA_VERSION = exports.LEGACY_RUN_STATE_SCHEMA_VERSION = exports.CURRENT_RUN_STATE_SCHEMA_VERSION = exports.WORKFLOW_APP_SCHEMA_VERSION = exports.CURRENT_COOL_WORKFLOW_VERSION = void 0;
4
- exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.82";
4
+ exports.CURRENT_COOL_WORKFLOW_VERSION = "0.1.84";
5
5
  exports.WORKFLOW_APP_SCHEMA_VERSION = 1;
6
6
  exports.CURRENT_RUN_STATE_SCHEMA_VERSION = 1;
7
7
  exports.LEGACY_RUN_STATE_SCHEMA_VERSION = 0;