cool-workflow 0.2.4 → 0.2.6

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 (155) 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 +204 -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/state/run-paths.js +3 -30
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +22 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +215 -36
  32. package/dist/mcp/tool-process.js +181 -0
  33. package/dist/mcp-server.js +28 -1
  34. package/dist/shell/agent-config.js +11 -2
  35. package/dist/shell/audit-cli.js +1 -1
  36. package/dist/shell/commit.js +8 -2
  37. package/dist/shell/coordinator-io.js +73 -1
  38. package/dist/shell/drive.js +195 -128
  39. package/dist/shell/error-feedback-io.js +6 -0
  40. package/dist/shell/execution-backend/agent.js +201 -26
  41. package/dist/shell/execution-backend/container.js +44 -10
  42. package/dist/shell/execution-backend/local.js +33 -0
  43. package/dist/shell/fs-atomic.js +96 -12
  44. package/dist/shell/ledger-cli.js +9 -2
  45. package/dist/shell/metrics-cli.js +1 -1
  46. package/dist/shell/multi-agent-cli.js +9 -2
  47. package/dist/shell/multi-agent-operator-ux.js +1 -1
  48. package/dist/shell/observability.js +7 -4
  49. package/dist/shell/onramp.js +48 -5
  50. package/dist/shell/operator-ux.js +1 -1
  51. package/dist/shell/perf-trace.js +136 -0
  52. package/dist/shell/pipeline.js +3 -2
  53. package/dist/shell/reclamation-io.js +10 -9
  54. package/dist/shell/report-view-cli.js +2 -0
  55. package/dist/shell/run-export-cli.js +5 -2
  56. package/dist/shell/run-export.js +293 -31
  57. package/dist/shell/run-registry-io.js +8 -0
  58. package/dist/shell/run-store.js +183 -3
  59. package/dist/shell/scheduler-io.js +101 -10
  60. package/dist/shell/state-explosion-cli.js +4 -1
  61. package/dist/shell/telemetry-demo.js +1 -1
  62. package/dist/shell/telemetry-ledger-io.js +36 -24
  63. package/dist/shell/trust-audit.js +178 -36
  64. package/dist/shell/workbench-host.js +121 -10
  65. package/dist/shell/workbench.js +83 -6
  66. package/dist/shell/worker-isolation.js +1 -1
  67. package/dist/wiring/capability-table/basics.js +5 -0
  68. package/dist/wiring/capability-table/exec-backend.js +40 -22
  69. package/dist/wiring/capability-table/parity.js +10 -5
  70. package/dist/wiring/capability-table/pipeline.js +32 -0
  71. package/dist/wiring/capability-table/registry-core.js +26 -3
  72. package/dist/wiring/capability-table/reporting.js +7 -1
  73. package/dist/wiring/capability-table/scheduling-registry.js +8 -2
  74. package/dist/wiring/capability-table/trust-ledger.js +54 -12
  75. package/docs/agent-delegation-drive.7.md +17 -0
  76. package/docs/cli-mcp-parity.7.md +46 -5
  77. package/docs/contract-migration-tooling.7.md +4 -0
  78. package/docs/control-plane-scheduling.7.md +38 -0
  79. package/docs/cross-agent-ledger.7.md +20 -8
  80. package/docs/durable-state-and-locking.7.md +4 -0
  81. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  82. package/docs/execution-backends.7.md +4 -0
  83. package/docs/fix.7.md +4 -4
  84. package/docs/getting-started.md +40 -32
  85. package/docs/index.md +17 -0
  86. package/docs/launch/demo.tape +4 -3
  87. package/docs/mcp-app-surface.7.md +27 -0
  88. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  89. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  90. package/docs/multi-agent-operator-ux.7.md +4 -0
  91. package/docs/node-snapshot-diff-replay.7.md +4 -0
  92. package/docs/observability-cost-accounting.7.md +4 -0
  93. package/docs/project-index.md +53 -9
  94. package/docs/real-execution-backends.7.md +4 -0
  95. package/docs/release-and-migration.7.md +4 -0
  96. package/docs/release-tooling.7.md +12 -0
  97. package/docs/routine.7.md +22 -0
  98. package/docs/run-registry-control-plane.7.md +37 -9
  99. package/docs/run-retention-reclamation.7.md +12 -4
  100. package/docs/state-explosion-management.7.md +4 -0
  101. package/docs/team-collaboration.7.md +4 -0
  102. package/docs/trust-audit-anchor.7.md +4 -0
  103. package/docs/web-desktop-workbench.7.md +72 -2
  104. package/docs/workflow-app-framework.7.md +31 -0
  105. package/manifest/plugin.manifest.json +1 -1
  106. package/manifest/source-context-profiles.json +4 -3
  107. package/package.json +6 -3
  108. package/scripts/agents/agent-adapter-core.js +26 -0
  109. package/scripts/agents/claude-p-agent.js +4 -1
  110. package/scripts/agents/codex-agent.js +4 -0
  111. package/scripts/agents/cw-attest-wrap.js +1 -1
  112. package/scripts/agents/gemini-agent.js +4 -0
  113. package/scripts/agents/opencode-agent.js +5 -0
  114. package/scripts/block-unapproved-tag.js +160 -0
  115. package/scripts/canonical-apps.js +4 -4
  116. package/scripts/children/batch-delegate-child.js +72 -4
  117. package/scripts/children/http-batch-delegate-child.js +132 -0
  118. package/scripts/children/http-delegate-child.js +8 -0
  119. package/scripts/dogfood-release.js +2 -2
  120. package/scripts/fake-date-for-reproduction.js +1 -1
  121. package/scripts/gen-manifests.js +32 -61
  122. package/scripts/golden-path.js +4 -4
  123. package/scripts/lang-policy-check.js +82 -0
  124. package/scripts/parity-check.js +42 -23
  125. package/scripts/purity-baseline.json +0 -3
  126. package/scripts/purity-gate.js +16 -3
  127. package/scripts/release-check.js +2 -1
  128. package/scripts/release-flow.js +169 -41
  129. package/scripts/release-gate.js +208 -0
  130. package/scripts/release-oneclick.js +38 -7
  131. package/scripts/release-tags.js +47 -0
  132. package/scripts/schema-version-inventory.json +31 -0
  133. package/scripts/validate-run-state-schema.js +95 -4
  134. package/scripts/verdict-keygen.js +2 -2
  135. package/scripts/verify-bump-reproduction.js +193 -0
  136. package/scripts/verify-release-verdict.js +139 -0
  137. package/scripts/verify-verdict-signature.js +1 -1
  138. package/ui/workbench/app.css +50 -10
  139. package/ui/workbench/app.js +269 -34
  140. package/ui/workbench/index.html +4 -2
  141. package/ui/workbench/inspection.js +51 -0
  142. package/ui/workbench/navigation.js +44 -0
  143. package/docs/agent-framework.md +0 -177
  144. package/docs/designs/handoff-ledger.md +0 -145
  145. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  146. package/docs/launch/launch-kit.md +0 -195
  147. package/docs/launch/pre-launch-checklist.md +0 -53
  148. package/docs/readme-v0.1.87-full.md +0 -301
  149. package/docs/routines.md +0 -101
  150. package/docs/scheduled-tasks.md +0 -80
  151. package/scripts/agents/claude-p-agent.sh +0 -9
  152. package/scripts/block-unapproved-tag.sh +0 -75
  153. package/scripts/release-gate.sh +0 -94
  154. package/scripts/verify-bump-reproduction.sh +0 -148
  155. package/scripts/verify-container-selfref.js +0 -64
@@ -44,14 +44,18 @@ var __importStar = (this && this.__importStar) || (function () {
44
44
  };
45
45
  })();
46
46
  Object.defineProperty(exports, "__esModule", { value: true });
47
- exports.ensureRunDirs = exports.createRunPaths = void 0;
47
+ exports.createRunPaths = void 0;
48
+ exports.ensureRunDirs = ensureRunDirs;
48
49
  exports.hashArtifactFile = hashArtifactFile;
49
50
  exports.loadRunStateFile = loadRunStateFile;
50
51
  exports.checkRunStateFile = checkRunStateFile;
51
52
  exports.migrateRunStateFile = migrateRunStateFile;
52
53
  exports.assertNotSuspectedDataLoss = assertNotSuspectedDataLoss;
53
54
  exports.loadRunFromCwd = loadRunFromCwd;
55
+ exports.resolveRunDir = resolveRunDir;
54
56
  exports.withRunStateLock = withRunStateLock;
57
+ exports.withDriveLock = withDriveLock;
58
+ exports.withDriveLockAsync = withDriveLockAsync;
55
59
  exports.saveCheckpoint = saveCheckpoint;
56
60
  exports.compactCheckpoint = compactCheckpoint;
57
61
  exports.createRun = createRun;
@@ -60,9 +64,32 @@ const path = __importStar(require("node:path"));
60
64
  const fs_atomic_1 = require("./fs-atomic");
61
65
  const run_paths_1 = require("../core/state/run-paths");
62
66
  Object.defineProperty(exports, "createRunPaths", { enumerable: true, get: function () { return run_paths_1.createRunPaths; } });
63
- Object.defineProperty(exports, "ensureRunDirs", { enumerable: true, get: function () { return run_paths_1.ensureRunDirs; } });
64
67
  const migrations_1 = require("../core/state/migrations");
65
68
  const hash_1 = require("../core/hash");
69
+ /** `mkdirSync` (recursive) every dir this run needs. Missing optional dir
70
+ * fields fall back to `path.join(runDir, "<name>")`, matching the old
71
+ * build's defensive default (a RunPaths loaded from an old/partial
72
+ * state.json may be missing an optional key). */
73
+ function ensureRunDirs(paths) {
74
+ const dirs = [
75
+ paths.runDir,
76
+ paths.tasksDir,
77
+ paths.resultsDir,
78
+ paths.dispatchesDir,
79
+ paths.artifactsDir,
80
+ paths.commitsDir,
81
+ paths.stateNodesDir,
82
+ paths.feedbackDir,
83
+ paths.auditDir || path.join(paths.runDir, "audit"),
84
+ paths.workersDir || path.join(paths.runDir, "workers"),
85
+ paths.candidatesDir || path.join(paths.runDir, "candidates"),
86
+ paths.multiAgentDir || path.join(paths.runDir, "multi-agent"),
87
+ paths.blackboardDir || path.join(paths.runDir, "blackboard"),
88
+ paths.topologiesDir || path.join(paths.runDir, "topologies"),
89
+ ];
90
+ for (const dir of dirs)
91
+ fs.mkdirSync(dir, { recursive: true });
92
+ }
66
93
  /** Read the file at artifact.path and stamp sha256 (the core `sha256:`+hex
67
94
  * form) + sizeBytes onto the StateArtifact. A missing/unreadable file is
68
95
  * silently skipped so hashing an absent artifact never throws. Byte-exact
@@ -185,6 +212,22 @@ function loadRunFromCwd(runId, cwd = process.cwd()) {
185
212
  assertNotSuspectedDataLoss(runId, result);
186
213
  return result.run;
187
214
  }
215
+ /** The canonical run directory for `runId` under `cwd` — the SAME
216
+ * deterministic location loadRunFromCwd resolves `state.json` in (it too
217
+ * ignores any stored `paths.runDir`). Guards an empty/unsafe id and throws
218
+ * the exact `Run not found: <runId>` loadRunFromCwd throws, but via a plain
219
+ * `existsSync` so it adds no `state.json` READ and never creates the run dir
220
+ * for a nonexistent run. Used by the drive mutex, which needs the directory
221
+ * before it can acquire, without perturbing read accounting. */
222
+ function resolveRunDir(runId, cwd = process.cwd()) {
223
+ if (!runId)
224
+ throw new Error("Missing run id");
225
+ (0, fs_atomic_1.assertSafeRunId)(runId);
226
+ const runDir = path.join(cwd, ".cw", "runs", runId);
227
+ if (!fs.existsSync(path.join(runDir, "state.json")))
228
+ throw new Error(`Run not found: ${runId}`);
229
+ return runDir;
230
+ }
188
231
  /** Hold the state.json lock over a WHOLE load -> change -> save cycle.
189
232
  * A bare loadRunFromCwd + saveCheckpoint pair leaves a window where two
190
233
  * processes both load the same state and the later save silently drops
@@ -201,6 +244,143 @@ function withRunStateLock(runId, cwd, fn) {
201
244
  const probe = loadRunFromCwd(runId, cwd);
202
245
  return (0, fs_atomic_1.withFileLock)(probe.paths.state, () => fn(loadRunFromCwd(runId, cwd)));
203
246
  }
247
+ // ---------------------------------------------------------------------------
248
+ // withDriveLock — a run-scoped DRIVE mutex, held across a WHOLE drive() call.
249
+ //
250
+ // The state.json write lock (withFileLock) covers only the instant of a
251
+ // write; a concurrent round loads the run once, spawns agents for MINUTES,
252
+ // then flushes the object it loaded at round start. A second drive on the
253
+ // same run therefore clobbers this one's flush (lost update) and both drives
254
+ // mint the same worker id from an in-memory count (double dispatch). This
255
+ // mutex closes both: only one process advances a run at a time.
256
+ //
257
+ // It is held far too long for withFileLock's 30s stale-steal window, so it is
258
+ // NEVER time-stale-stolen. A lock whose recorded pid is a LIVE process refuses
259
+ // (fail closed — never a silent double-drive); a lock whose owner is GONE (a
260
+ // crashed prior drive) or this process's own leaked lock is stolen so a crash
261
+ // can never wedge the run. Lock body is `"<pid>@<ISO>\n"`, the same shape as
262
+ // the fs-atomic lock, acquired with the same single-winner `link(2)` idiom.
263
+ // ---------------------------------------------------------------------------
264
+ /** Drive-lock paths this process holds right now. A nested drive() on the
265
+ * SAME run id (sub-workflows use a DIFFERENT run id, so this should not
266
+ * happen — defensive) re-enters instead of self-refusing. */
267
+ const HELD_DRIVE_LOCKS = new Set();
268
+ function driveLockPath(runDir) {
269
+ return path.join(runDir, "drive.lock");
270
+ }
271
+ /** True when `pid` names a live process on this machine. `process.kill(pid,
272
+ * 0)` delivers no signal — it only probes existence/permission. `EPERM` (the
273
+ * pid exists but is not signalable by us) counts as ALIVE, the conservative
274
+ * never-wrongly-steal direction; a malformed/absent pid is "not alive". */
275
+ function driveLockOwnerAlive(pid) {
276
+ if (!Number.isInteger(pid) || pid <= 0)
277
+ return false;
278
+ try {
279
+ process.kill(pid, 0);
280
+ return true;
281
+ }
282
+ catch (error) {
283
+ return Boolean(error && typeof error === "object" && error.code === "EPERM");
284
+ }
285
+ }
286
+ function readDriveLockPid(lock) {
287
+ try {
288
+ const match = /^(\d+)@/.exec(fs.readFileSync(lock, "utf8"));
289
+ return match ? Number(match[1]) : 0;
290
+ }
291
+ catch {
292
+ return 0; // vanished or unreadable — treat as no owner
293
+ }
294
+ }
295
+ /** Acquire the drive mutex for `runId`'s run directory. Returns a release
296
+ * function (a no-op when this call re-entered a lock already held in-process).
297
+ * Throws a fail-closed refusal — naming the resume command — when another
298
+ * LIVE process holds it. */
299
+ function acquireDriveLock(runDir, runId) {
300
+ const lock = driveLockPath(runDir);
301
+ const key = path.resolve(lock);
302
+ if (HELD_DRIVE_LOCKS.has(key))
303
+ return () => { };
304
+ fs.mkdirSync(runDir, { recursive: true });
305
+ const pid = process.pid;
306
+ const body = `${pid}@${new Date().toISOString()}\n`;
307
+ const refuse = (ownerPid) => new Error(`Run ${runId} is already being driven by another process (pid ${ownerPid || "unknown"}). ` +
308
+ `Wait for it to finish, or if it has crashed remove ${lock}, then resume: cw run resume ${runId} --drive`);
309
+ let acquired = false;
310
+ // A live owner refuses at once; only a stealable (dead-owner / own-leak) lock
311
+ // loops to re-acquire, so this never hot-spins for a genuinely busy run.
312
+ for (let attempt = 0; attempt < 3 && !acquired; attempt++) {
313
+ const tmp = `${lock}.${pid}.${attempt}.tmp`;
314
+ try {
315
+ fs.writeFileSync(tmp, body, { mode: 0o600 });
316
+ fs.linkSync(tmp, lock);
317
+ acquired = true;
318
+ }
319
+ catch (error) {
320
+ if (!(error && typeof error === "object" && error.code === "EEXIST")) {
321
+ throw error;
322
+ }
323
+ const ownerPid = readDriveLockPid(lock);
324
+ // Stealable: our own leaked lock, or an owner that is no longer alive.
325
+ if (ownerPid === pid || !driveLockOwnerAlive(ownerPid)) {
326
+ try {
327
+ fs.unlinkSync(lock);
328
+ }
329
+ catch {
330
+ /* another process took it first — the next attempt re-judges */
331
+ }
332
+ continue;
333
+ }
334
+ throw refuse(ownerPid);
335
+ }
336
+ finally {
337
+ try {
338
+ fs.unlinkSync(tmp);
339
+ }
340
+ catch {
341
+ /* the link consumed it, or it is already gone */
342
+ }
343
+ }
344
+ }
345
+ if (!acquired)
346
+ throw refuse(readDriveLockPid(lock));
347
+ HELD_DRIVE_LOCKS.add(key);
348
+ return () => {
349
+ HELD_DRIVE_LOCKS.delete(key);
350
+ try {
351
+ // Release only while we still own it (a force-stale steal by a later
352
+ // process must not have its lock removed by us).
353
+ if (fs.readFileSync(lock, "utf8").startsWith(`${pid}@`))
354
+ fs.rmSync(lock, { force: true });
355
+ }
356
+ catch {
357
+ /* already released/removed */
358
+ }
359
+ };
360
+ }
361
+ /** Run `fn` while holding the run-scoped drive mutex (see the block comment
362
+ * above); always released, even on throw. Synchronous callers (drive()). */
363
+ function withDriveLock(runDir, runId, fn) {
364
+ const release = acquireDriveLock(runDir, runId);
365
+ try {
366
+ return fn();
367
+ }
368
+ finally {
369
+ release();
370
+ }
371
+ }
372
+ /** Async form of `withDriveLock` — releases only AFTER `fn`'s promise
373
+ * settles, so driveAsync()'s awaited multi-round loop stays inside the lock
374
+ * for its whole lifetime. */
375
+ async function withDriveLockAsync(runDir, runId, fn) {
376
+ const release = acquireDriveLock(runDir, runId);
377
+ try {
378
+ return await fn();
379
+ }
380
+ finally {
381
+ release();
382
+ }
383
+ }
204
384
  /** state.json is the single source of truth — set `updatedAt`, then write
205
385
  * it DURABLY with a lock so concurrent processes never lose an update. */
206
386
  function saveCheckpoint(run) {
@@ -237,7 +417,7 @@ function compactCheckpoint(run) {
237
417
  * same primitive. */
238
418
  function createRun(runDir, runId, workflowId, cwd) {
239
419
  const paths = (0, run_paths_1.createRunPaths)(runDir);
240
- (0, run_paths_1.ensureRunDirs)(paths);
420
+ ensureRunDirs(paths);
241
421
  const seed = {
242
422
  id: runId,
243
423
  cwd,
@@ -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);
@@ -176,7 +176,10 @@ function showStateExplosionSummary(run, options = {}) {
176
176
  // ---------------------------------------------------------------------
177
177
  function loadRun(runId, options = {}) {
178
178
  const cwd = options.cwd ? path.resolve(String(options.cwd)) : process.cwd();
179
- return (0, run_store_1.loadRunFromCwd)(runId, cwd);
179
+ const run = (0, run_store_1.loadRunFromCwd)(runId, cwd);
180
+ if (options.__cwWorkbenchReadOnlyProjection === true)
181
+ run.__cwWorkbenchReadOnlyProjection = true;
182
+ return run;
180
183
  }
181
184
  /** `cw summary refresh <run-id> [--json]` — refresh also runs `writeReport`
182
185
  * + `saveCheckpoint` in the old build; `writeReport` (report.md) is a
@@ -219,7 +219,7 @@ function runBundleDemo(options = {}) {
219
219
  const runId = "demo-bundle-run";
220
220
  const runDir = path.join(workdir, ".cw", "runs", runId);
221
221
  const paths = (0, run_paths_1.createRunPaths)(runDir);
222
- (0, run_paths_1.ensureRunDirs)(paths);
222
+ (0, run_store_1.ensureRunDirs)(paths);
223
223
  const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
224
224
  const publicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
225
225
  const privateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
@@ -94,31 +94,43 @@ function loadTelemetryLedger(run) {
94
94
  /** Append one attestation record DURABLY to the append-only chain,
95
95
  * linking it to the prior record (or genesis). Returns the committed
96
96
  * record. Throws `TelemetryLedgerCorruptError` if the file on disk is
97
- * present but unparseable — never silently re-genesis. */
97
+ * present but unparseable — never silently re-genesis.
98
+ *
99
+ * The whole load->append->write is held under `withFileLock` (like every
100
+ * other read-modify-write in this codebase — see `recordTrustAuditEvent`).
101
+ * `writeJson` REPLACES the file, so without the lock two processes
102
+ * appending for the same run at once both read the same N-record ledger,
103
+ * both compute a record at chain position N+1, and the last atomic rename
104
+ * WINS — silently dropping the loser's record. The surviving chain still
105
+ * links correctly, so the loss is invisible to `verifyTelemetryLedger`.
106
+ * The `prevHash` and chain position MUST be read from the ledger loaded
107
+ * INSIDE the lock so the record links to the true current tail. */
98
108
  function appendTelemetryAttestation(run, input) {
99
- const ledger = loadTelemetryLedger(run);
100
- const now = input.now || new Date().toISOString();
101
- const prevHash = ledger.records.length ? ledger.records[ledger.records.length - 1].recordHash : (0, telemetry_ledger_1.genesisPrevHash)(run.id);
102
- const base = {
103
- schemaVersion: 1,
104
- runId: run.id,
105
- recordId: (0, telemetry_ledger_1.recordId)(ledger.records.length + 1),
106
- recordedAt: now,
107
- workerId: input.workerId,
108
- taskId: input.taskId,
109
- promptDigest: input.promptDigest,
110
- reportedUsageDigest: (0, telemetry_ledger_1.reportedUsageDigest)(input.reportedUsage),
111
- ...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
112
- usageSignature: input.usageSignature,
113
- ...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
114
- attestation: input.attestation,
115
- attestationReason: input.attestationReason,
116
- prevHash,
117
- };
118
- const record = { ...base, recordHash: (0, telemetry_ledger_1.computeRecordHash)(base) };
119
- ledger.records.push(record);
120
- (0, fs_atomic_1.writeJson)(telemetryLedgerPath(run), ledger, { durable: true });
121
- return record;
109
+ return (0, fs_atomic_1.withFileLock)(telemetryLedgerPath(run), () => {
110
+ const ledger = loadTelemetryLedger(run);
111
+ const now = input.now || new Date().toISOString();
112
+ const prevHash = ledger.records.length ? ledger.records[ledger.records.length - 1].recordHash : (0, telemetry_ledger_1.genesisPrevHash)(run.id);
113
+ const base = {
114
+ schemaVersion: 1,
115
+ runId: run.id,
116
+ recordId: (0, telemetry_ledger_1.recordId)(ledger.records.length + 1),
117
+ recordedAt: now,
118
+ workerId: input.workerId,
119
+ taskId: input.taskId,
120
+ promptDigest: input.promptDigest,
121
+ reportedUsageDigest: (0, telemetry_ledger_1.reportedUsageDigest)(input.reportedUsage),
122
+ ...(input.reportedUsage ? { reportedUsage: input.reportedUsage } : {}),
123
+ usageSignature: input.usageSignature,
124
+ ...(input.resultDigest ? { resultDigest: input.resultDigest } : {}),
125
+ attestation: input.attestation,
126
+ attestationReason: input.attestationReason,
127
+ prevHash,
128
+ };
129
+ const record = { ...base, recordHash: (0, telemetry_ledger_1.computeRecordHash)(base) };
130
+ ledger.records.push(record);
131
+ (0, fs_atomic_1.writeJson)(telemetryLedgerPath(run), ledger, { durable: true });
132
+ return record;
133
+ });
122
134
  }
123
135
  /** Re-prove the whole telemetry chain for a run, reading from disk.
124
136
  * Absent -> present:false/verified:true (nothing to prove). Corrupt ->