cool-workflow 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.gemini-plugin/mcp.json +10 -0
  4. package/.gemini-plugin/plugin.json +40 -0
  5. package/.opencode-plugin/mcp.json +10 -0
  6. package/.opencode-plugin/plugin.json +40 -0
  7. package/README.md +202 -48
  8. package/apps/architecture-review/app.json +1 -1
  9. package/apps/architecture-review-fast/app.json +1 -1
  10. package/apps/end-to-end-golden-path/app.json +1 -1
  11. package/apps/pr-review-fix-ci/app.json +1 -1
  12. package/apps/release-cut/app.json +1 -1
  13. package/apps/research-synthesis/app.json +1 -1
  14. package/dist/cli/dispatch.js +33 -4
  15. package/dist/cli/entry.js +11 -19
  16. package/dist/cli/global-flags.js +113 -0
  17. package/dist/cli/io.js +6 -20
  18. package/dist/cli/parseargv.js +7 -1
  19. package/dist/core/capability-data.js +339 -10
  20. package/dist/core/format/help.js +80 -4
  21. package/dist/core/format/recovery-hint.js +32 -0
  22. package/dist/core/multi-agent/collaboration.js +35 -6
  23. package/dist/core/multi-agent/runtime.js +7 -0
  24. package/dist/core/multi-agent/trust-policy.js +7 -1
  25. package/dist/core/pipeline/contract.js +7 -0
  26. package/dist/core/pipeline/error-feedback.js +2 -2
  27. package/dist/core/trust/evidence-grounding.js +13 -1
  28. package/dist/core/util/cli-args.js +55 -0
  29. package/dist/core/version.js +1 -1
  30. package/dist/mcp/dispatch.js +22 -2
  31. package/dist/mcp/server.js +124 -13
  32. package/dist/mcp-server.js +20 -0
  33. package/dist/shell/commit.js +8 -2
  34. package/dist/shell/coordinator-io.js +73 -1
  35. package/dist/shell/drive.js +129 -64
  36. package/dist/shell/error-feedback-io.js +6 -0
  37. package/dist/shell/execution-backend/agent.js +205 -24
  38. package/dist/shell/execution-backend/container.js +44 -10
  39. package/dist/shell/execution-backend/local.js +47 -10
  40. package/dist/shell/fs-atomic.js +93 -12
  41. package/dist/shell/ledger-cli.js +9 -2
  42. package/dist/shell/multi-agent-cli.js +5 -1
  43. package/dist/shell/onramp.js +48 -5
  44. package/dist/shell/pipeline-cli.js +20 -2
  45. package/dist/shell/pipeline.js +2 -1
  46. package/dist/shell/reclamation-io.js +76 -7
  47. package/dist/shell/registry-cli.js +4 -0
  48. package/dist/shell/run-export.js +52 -4
  49. package/dist/shell/run-registry-io.js +1 -0
  50. package/dist/shell/run-store.js +156 -0
  51. package/dist/shell/scheduler-io.js +101 -10
  52. package/dist/shell/telemetry-ledger-io.js +36 -24
  53. package/dist/shell/trust-audit.js +104 -10
  54. package/dist/shell/workbench-host.js +121 -10
  55. package/dist/shell/workbench.js +79 -5
  56. package/dist/shell/worker-isolation.js +1 -1
  57. package/dist/shell/workflow-app-loader.js +67 -1
  58. package/dist/wiring/capability-table/basics.js +7 -2
  59. package/dist/wiring/capability-table/exec-backend.js +42 -24
  60. package/dist/wiring/capability-table/multi-agent.js +69 -69
  61. package/dist/wiring/capability-table/pipeline.js +60 -28
  62. package/dist/wiring/capability-table/registry-core.js +29 -6
  63. package/dist/wiring/capability-table/reporting.js +45 -39
  64. package/dist/wiring/capability-table/scheduling-registry.js +66 -60
  65. package/dist/wiring/capability-table/state.js +34 -34
  66. package/dist/wiring/capability-table/trust-ledger.js +66 -24
  67. package/dist/wiring/capability-table/workflow-apps.js +15 -15
  68. package/docs/agent-delegation-drive.7.md +17 -0
  69. package/docs/cli-mcp-parity.7.md +43 -13
  70. package/docs/contract-migration-tooling.7.md +4 -0
  71. package/docs/control-plane-scheduling.7.md +38 -0
  72. package/docs/cross-agent-ledger.7.md +20 -8
  73. package/docs/durable-state-and-locking.7.md +4 -0
  74. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  75. package/docs/execution-backends.7.md +4 -0
  76. package/docs/fix.7.md +4 -4
  77. package/docs/getting-started.md +40 -32
  78. package/docs/index.md +17 -0
  79. package/docs/launch/demo.tape +4 -3
  80. package/docs/mcp-app-surface.7.md +6 -0
  81. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  82. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  83. package/docs/multi-agent-operator-ux.7.md +4 -0
  84. package/docs/node-snapshot-diff-replay.7.md +4 -0
  85. package/docs/observability-cost-accounting.7.md +4 -0
  86. package/docs/project-index.md +43 -9
  87. package/docs/real-execution-backends.7.md +4 -0
  88. package/docs/release-and-migration.7.md +4 -0
  89. package/docs/release-tooling.7.md +22 -0
  90. package/docs/routine.7.md +22 -0
  91. package/docs/run-registry-control-plane.7.md +4 -0
  92. package/docs/run-retention-reclamation.7.md +28 -3
  93. package/docs/state-explosion-management.7.md +4 -0
  94. package/docs/team-collaboration.7.md +4 -0
  95. package/docs/trust-audit-anchor.7.md +4 -0
  96. package/docs/web-desktop-workbench.7.md +26 -2
  97. package/docs/workflow-app-framework.7.md +31 -0
  98. package/manifest/plugin.manifest.json +1 -1
  99. package/manifest/source-context-profiles.json +4 -3
  100. package/package.json +5 -2
  101. package/scripts/agents/agent-adapter-core.js +26 -0
  102. package/scripts/agents/claude-p-agent.js +4 -1
  103. package/scripts/agents/codex-agent.js +4 -0
  104. package/scripts/agents/cw-attest-wrap.js +1 -1
  105. package/scripts/agents/gemini-agent.js +4 -0
  106. package/scripts/agents/opencode-agent.js +5 -0
  107. package/scripts/block-unapproved-tag.js +160 -0
  108. package/scripts/bump-version.js +27 -10
  109. package/scripts/canonical-apps.js +4 -4
  110. package/scripts/children/batch-delegate-child.js +72 -4
  111. package/scripts/children/http-batch-delegate-child.js +132 -0
  112. package/scripts/children/http-delegate-child.js +8 -0
  113. package/scripts/dogfood-release.js +2 -2
  114. package/scripts/fake-date-for-reproduction.js +1 -1
  115. package/scripts/golden-path.js +4 -4
  116. package/scripts/lang-policy-check.js +82 -0
  117. package/scripts/purity-baseline.json +0 -30
  118. package/scripts/purity-gate.js +16 -3
  119. package/scripts/release-check.js +2 -1
  120. package/scripts/release-flow.js +264 -41
  121. package/scripts/release-gate.js +184 -0
  122. package/scripts/release-oneclick.js +438 -0
  123. package/scripts/release-tags.js +47 -0
  124. package/scripts/verdict-keygen.js +2 -2
  125. package/scripts/verify-bump-reproduction.js +193 -0
  126. package/scripts/verify-verdict-signature.js +1 -1
  127. package/scripts/version-sync-check.js +39 -22
  128. package/ui/workbench/app.css +41 -9
  129. package/ui/workbench/app.js +160 -27
  130. package/ui/workbench/index.html +2 -2
  131. package/docs/agent-framework.md +0 -177
  132. package/docs/designs/handoff-ledger.md +0 -145
  133. package/docs/dogfood/resume-drive-real-agent-2026-06-14.md +0 -40
  134. package/docs/launch/launch-kit.md +0 -195
  135. package/docs/launch/pre-launch-checklist.md +0 -53
  136. package/docs/readme-v0.1.87-full.md +0 -301
  137. package/docs/routines.md +0 -101
  138. package/docs/scheduled-tasks.md +0 -80
  139. package/scripts/agents/claude-p-agent.sh +0 -9
  140. package/scripts/block-unapproved-tag.sh +0 -60
  141. package/scripts/release-gate.sh +0 -94
  142. package/scripts/verify-bump-reproduction.sh +0 -148
  143. package/scripts/verify-container-selfref.js +0 -64
@@ -7,14 +7,49 @@
7
7
  //
8
8
  // Evidence: SPEC/execution-backend.md "container driver (runContainer)".
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.buildContainerEnvArgs = buildContainerEnvArgs;
10
11
  exports.containerHandle = containerHandle;
11
12
  exports.runContainer = runContainer;
12
13
  const node_child_process_1 = require("node:child_process");
13
14
  const probes_1 = require("./probes");
15
+ const local_1 = require("./local");
14
16
  const envelopes_1 = require("./envelopes");
15
17
  function messageOf(error) {
16
18
  return error instanceof Error ? error.message : String(error);
17
19
  }
20
+ /** buildContainerEnvArgs — build the `-e NAME=value` args passed into the
21
+ * container run command. Pulled out as its own pure function so it can be
22
+ * checked without a real docker/podman on the box (see local.ts's
23
+ * buildChildEnv for the same shape of test-only split).
24
+ *
25
+ * deny must win here too: an operator who sets inherit:true plus a deny
26
+ * list (a valid, normalized combination — see sandbox-profile.ts's
27
+ * normalizeEnv) means "everything EXCEPT these". Before this fix, deny was
28
+ * never read here, so a secret like AWS_SECRET_ACCESS_KEY that the operator
29
+ * named in deny still got copied into the container's `-e` args.
30
+ *
31
+ * This backend builds its `-e` args straight from baseEnv rather than through
32
+ * buildChildEnv, so it must apply CW_NEVER_FORWARD_ENV itself — otherwise
33
+ * inherit:true would still copy CW's own parent-only secrets (the release
34
+ * signing key, the workbench token) into the container. */
35
+ function buildContainerEnvArgs(policy, baseEnv = process.env) {
36
+ const args = [];
37
+ if (policy.env.inherit || (policy.env.expose && policy.env.expose.length)) {
38
+ const deny = new Set(policy.env.deny || []);
39
+ for (const name of policy.env.inherit ? Object.keys(baseEnv) : policy.env.expose || []) {
40
+ if (name === "PATH" || name === "HOME")
41
+ continue;
42
+ if (deny.has(name))
43
+ continue;
44
+ if (local_1.CW_NEVER_FORWARD_ENV.has(name))
45
+ continue; // parent-only secrets never enter the container
46
+ const value = baseEnv[name];
47
+ if (value !== undefined)
48
+ args.push("-e", `${name}=${value}`);
49
+ }
50
+ }
51
+ return args;
52
+ }
18
53
  function containerHandle(request, env = process.env) {
19
54
  const delegation = request.delegation || {};
20
55
  const image = delegation.image || (env.CW_CONTAINER_IMAGE || "").trim() || undefined;
@@ -46,20 +81,19 @@ function runContainer(descriptor, policy, request, label, handle, attestation) {
46
81
  if (policy.network.mode !== "any")
47
82
  runArgs.push("--network", "none");
48
83
  runArgs.push("-v", `${cwd}:${cwd}:ro`, "-w", cwd);
49
- if (policy.env.inherit || (policy.env.expose && policy.env.expose.length)) {
50
- for (const name of policy.env.inherit ? Object.keys(process.env) : policy.env.expose || []) {
51
- if (name === "PATH" || name === "HOME")
52
- continue;
53
- const value = process.env[name];
54
- if (value !== undefined)
55
- runArgs.push("-e", `${name}=${value}`);
56
- }
57
- }
84
+ runArgs.push(...buildContainerEnvArgs(policy));
58
85
  runArgs.push(handle.ref, command, ...args);
59
86
  // An unset timeoutMs must not mean "no timeout" — spawnSync would then
60
87
  // block forever on a hung container with no kill path. 600000 matches the
61
88
  // agent backend's own default fallback (execution-backend/agent.ts).
62
- const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024 });
89
+ // killSignal SIGKILL, not the default SIGTERM: a container runtime that
90
+ // ignores SIGTERM would leave this blocking spawnSync waiting forever (no
91
+ // second-stage escalation is possible from inside a sync call), so the
92
+ // timeout above would not actually bound anything. SIGKILL is uncatchable,
93
+ // so the runtime always dies and spawnSync returns; a timed-out run is
94
+ // classified the same either way (status null, ETIMEDOUT message does not
95
+ // name the signal, result.signal is never recorded). Mirrors agent.ts.
96
+ const result = (0, node_child_process_1.spawnSync)(runtime, runArgs, { cwd, encoding: "utf8", timeout: request.timeoutMs || 600000, maxBuffer: 32 * 1024 * 1024, killSignal: "SIGKILL" });
63
97
  if (result.error) {
64
98
  return (0, envelopes_1.refusedEnvelope)(descriptor, policy, label, "delegation-failed", `${runtime} run failed: ${messageOf(result.error)}`, attestation);
65
99
  }
@@ -8,6 +8,7 @@
8
8
  //
9
9
  // Evidence: SPEC/execution-backend.md "Local execution (executeLocal)".
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
+ exports.CW_NEVER_FORWARD_ENV = void 0;
11
12
  exports.buildChildEnv = buildChildEnv;
12
13
  exports.checkShellGuard = checkShellGuard;
13
14
  exports.runtimeNoteFor = runtimeNoteFor;
@@ -18,21 +19,43 @@ const probes_1 = require("./probes");
18
19
  function messageOf(error) {
19
20
  return error instanceof Error ? error.message : String(error);
20
21
  }
22
+ /** CW's OWN secrets that must NEVER reach ANY child process — on any backend
23
+ * (local/container/remote/agent), regardless of policy.env.inherit / expose /
24
+ * deny. Each is read only by parent-side tooling, so no spawned child ever
25
+ * needs it; stripping them here, unconditionally, is a fail-closed backstop
26
+ * that does not depend on an operator remembering to list them in
27
+ * policy.env.deny. Do NOT add the agent-attest private key
28
+ * (CW_AGENT_ATTEST_PRIVKEY) here: the attest wrapper
29
+ * (scripts/agents/cw-attest-wrap.js) is spawned AS the agent and must receive
30
+ * it to sign telemetry. */
31
+ exports.CW_NEVER_FORWARD_ENV = new Set([
32
+ "CW_RELEASE_VERDICT_PRIVKEY", // release verdict signing key — release-flow.js only
33
+ "CW_WORKBENCH_TOKEN", // workbench HTTP bearer token — workbench-host.ts only
34
+ ]);
21
35
  function buildChildEnv(policy, baseEnv = process.env) {
22
- if (policy.env.inherit)
23
- return { ...baseEnv };
24
- const env = {};
25
- if (baseEnv.PATH !== undefined)
26
- env.PATH = baseEnv.PATH;
27
- if (baseEnv.HOME !== undefined)
28
- env.HOME = baseEnv.HOME;
29
- for (const name of policy.env.expose || []) {
30
- if (baseEnv[name] !== undefined)
31
- env[name] = baseEnv[name];
36
+ // deny must win regardless of inherit: a custom profile combining
37
+ // inherit:true with deny:[...] (a valid, normalized combination — see
38
+ // sandbox-profile.ts's normalizeEnv) means "everything EXCEPT these".
39
+ // An early return on inherit before this loop ran meant deny was
40
+ // silently skipped whenever inherit was true.
41
+ const env = policy.env.inherit ? { ...baseEnv } : {};
42
+ if (!policy.env.inherit) {
43
+ if (baseEnv.PATH !== undefined)
44
+ env.PATH = baseEnv.PATH;
45
+ if (baseEnv.HOME !== undefined)
46
+ env.HOME = baseEnv.HOME;
47
+ for (const name of policy.env.expose || []) {
48
+ if (baseEnv[name] !== undefined)
49
+ env[name] = baseEnv[name];
50
+ }
32
51
  }
33
52
  for (const name of policy.env.deny || []) {
34
53
  delete env[name];
35
54
  }
55
+ // Fail-closed backstop: parent-only secrets never cross into a child, even
56
+ // when inherit:true forwarded them above or an operator exposed one by name.
57
+ for (const name of exports.CW_NEVER_FORWARD_ENV)
58
+ delete env[name];
36
59
  return env;
37
60
  }
38
61
  /** Shell injection guard (SPEC/execution-backend.md "Local execution"): for
@@ -70,6 +93,20 @@ function executeLocal(descriptor, request, label, attestation, spawnStyle) {
70
93
  // agent backend's own default fallback (execution-backend/agent.ts).
71
94
  timeout: request.timeoutMs || 600000,
72
95
  maxBuffer: 32 * 1024 * 1024,
96
+ // SIGKILL, not the default SIGTERM: a child that ignores SIGTERM would
97
+ // leave this blocking spawnSync waiting forever (no second-stage
98
+ // escalation is possible from inside a sync call), so the `timeout` above
99
+ // would not actually bound anything. SIGKILL is uncatchable, so the child
100
+ // always dies and spawnSync returns. A timed-out run is classified as
101
+ // failed regardless of the signal (status stays null, the ETIMEDOUT
102
+ // message does not name the signal, and this backend never records
103
+ // result.signal). The exact captured stdout/stderr on a timeout is not
104
+ // guaranteed identical to the old SIGTERM path: a child that catches
105
+ // SIGTERM could flush a last partial write or exit with a code before it
106
+ // dies, while under SIGKILL it cannot — but that output belongs to a run
107
+ // we already treat as failed, so the hard kill loses nothing that counts.
108
+ // Mirrors execution-backend/agent.ts's fix.
109
+ killSignal: "SIGKILL",
73
110
  };
74
111
  if (spawnStyle === "shell") {
75
112
  checkShellGuard(command, args);
@@ -48,6 +48,8 @@ exports.assertSafeRunId = assertSafeRunId;
48
48
  exports.realResolve = realResolve;
49
49
  exports.isContainedPath = isContainedPath;
50
50
  exports.durableAppendFileSync = durableAppendFileSync;
51
+ exports.logEndsWithNewline = logEndsWithNewline;
52
+ exports.nextBackoffMs = nextBackoffMs;
51
53
  exports.withFileLock = withFileLock;
52
54
  const fs = __importStar(require("node:fs"));
53
55
  const path = __importStar(require("node:path"));
@@ -196,18 +198,55 @@ function durableAppendFileSync(file, data) {
196
198
  fs.closeSync(fd);
197
199
  }
198
200
  }
201
+ /** True when `file`'s final byte is "\n", given its already-known `size`.
202
+ * A COMPLETED `durableAppendFileSync` always leaves the file ending in
203
+ * "\n" (every append is `<line>\n`), so a non-newline last byte means the
204
+ * previous append was torn by a crash — its bytes were never a confirmed
205
+ * record. Reads ONLY the last byte at `size-1`, so callers on an append
206
+ * hot path stay O(1) and never re-read the whole file. A read failure
207
+ * returns false (treat as "not newline-terminated") — the safe side: an
208
+ * extra leading newline is harmless for an NDJSON reader that skips blank
209
+ * lines, while a MISSED torn boundary would merge two records into one
210
+ * unparseable line. Shared by trust-audit's events.jsonl and the
211
+ * blackboard's messages.jsonl append paths — same file shape, same
212
+ * torn-tail risk, one implementation. */
213
+ function logEndsWithNewline(file, size) {
214
+ if (size <= 0)
215
+ return false;
216
+ let fd;
217
+ try {
218
+ fd = fs.openSync(file, "r");
219
+ const buf = Buffer.alloc(1);
220
+ fs.readSync(fd, buf, 0, 1, size - 1);
221
+ return buf[0] === 0x0a; // "\n"
222
+ }
223
+ catch {
224
+ return false;
225
+ }
226
+ finally {
227
+ if (fd !== undefined)
228
+ fs.closeSync(fd);
229
+ }
230
+ }
199
231
  // ---------------------------------------------------------------------------
200
232
  // withFileLock — portable advisory cross-process lock.
201
233
  //
202
234
  // Lock file: `<targetPath>.lock`, created by hard-linking a per-attempt temp
203
235
  // file onto the lock path (single-winner `link(2)`), body `"<pid>@<ISO>\n"`.
204
- // Up to 240 tries; on EEXIST, a lock whose mtime is older than
205
- // FILE_LOCK_STALE_MS (30_000) is stolen — judged and deleted only while
206
- // holding the single-winner `<lock>.steal` guard (see
207
- // stealStaleLockUnderGuard) and retried AT ONCE; else sleep 25ms
208
- // (Atomics.wait busy-safe sleep) and retry. Any non-EEXIST link error is
209
- // rethrown. No lock after 240 tries throws
210
- // `could not acquire file lock for <targetPath>`.
236
+ // On EEXIST, a lock whose mtime is older than FILE_LOCK_STALE_MS (30_000) is
237
+ // stolen — judged and deleted only while holding the single-winner
238
+ // `<lock>.steal` guard (see stealStaleLockUnderGuard) — and retried AT ONCE
239
+ // (so is a lock that vanished between the EEXIST and the stat). Otherwise the
240
+ // thread sleeps a short, growing backoff (Atomics.wait busy-safe sleep:
241
+ // FILE_LOCK_BACKOFF_BASE_MS doubling toward FILE_LOCK_BACKOFF_MAX_MS, with
242
+ // jitter so many processes contending on ONE lock do not retry in lock-step)
243
+ // and tries again. The WHOLE acquire is bounded by wall-clock, not a try
244
+ // count: after FILE_LOCK_ACQUIRE_BUDGET_MS (~6s, kept well under the 30s steal
245
+ // window so a fresh orphan lock fails fast rather than being waited out) with
246
+ // no lock, it throws `could not acquire file lock for <targetPath>`. The tiny
247
+ // early sleeps re-grab a briefly-held lock in a few ms; the wall-clock bound
248
+ // (not the try count) is what keeps the worst case at ~6s. Any non-EEXIST
249
+ // link error is rethrown.
211
250
  //
212
251
  // Right before fn() the lock mtime is refreshed (utimesSync, best-effort).
213
252
  // After fn() returns, the lock body is re-read: if it no longer starts with
@@ -227,15 +266,37 @@ const FILE_LOCK_FORCE_STALE_MS = FILE_LOCK_STALE_MS * 10;
227
266
  // guarded window (which is a handful of syscalls, microseconds for any live
228
267
  // process) — remove it so stealing cannot wedge forever.
229
268
  const FILE_LOCK_STEAL_GUARD_STALE_MS = FILE_LOCK_STALE_MS;
269
+ // Acquire-retry pacing (all internal to withFileLock — no wire/protocol
270
+ // meaning). The wall-clock BUDGET, not a fixed try count, bounds how long a
271
+ // contended acquire blocks the calling thread; kept at ~6s so it stays well
272
+ // under the 30s steal window (a fresh orphan lock is failed fast, not waited
273
+ // out — a later command past 30s steals it). Between misses the thread sleeps
274
+ // a backoff that starts at BASE and doubles to MAX, so a briefly-held lock is
275
+ // re-grabbed in a few ms while a genuinely busy one is not hot-spun. Jitter on
276
+ // each sleep (applied at the call site) de-syncs many contenders on one lock.
277
+ // MAX_ATTEMPTS is only a loop-termination backstop for the (never-observed)
278
+ // case of a clock that fails to advance; the budget is the real bound.
279
+ const FILE_LOCK_ACQUIRE_BUDGET_MS = 6_000;
280
+ const FILE_LOCK_BACKOFF_BASE_MS = 1;
281
+ const FILE_LOCK_BACKOFF_MAX_MS = 50;
282
+ const FILE_LOCK_MAX_ATTEMPTS = 10_000;
230
283
  // Lock paths this process holds right now. A nested withFileLock on the
231
284
  // SAME target runs its fn directly (re-entrant) instead of waiting on its
232
- // own lock file until the 240 tries run out — that lets a whole
285
+ // own lock file until the acquire budget runs out — that lets a whole
233
286
  // load -> change -> save cycle hold one lock while the save path inside
234
287
  // it keeps its own withFileLock call unchanged.
235
288
  const HELD_LOCKS = new Set();
236
289
  function sleepSync(ms) {
237
290
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
238
291
  }
292
+ /** Next backoff ceiling (ms) given the previous one: double it, capped at
293
+ * FILE_LOCK_BACKOFF_MAX_MS. Pure and deterministic (the per-sleep jitter is
294
+ * applied separately at the call site), so the retry schedule is
295
+ * unit-testable without any timing. Exported for that test only — fs-atomic
296
+ * is not part of the public index.ts surface. */
297
+ function nextBackoffMs(previous) {
298
+ return Math.min(previous * 2, FILE_LOCK_BACKOFF_MAX_MS);
299
+ }
239
300
  /** Whether the pid recorded in a lock body ("<pid>@<ISO>\n") is still a
240
301
  * live process on this machine. Locks here are local-advisory only (no
241
302
  * network-shared run directories in this tool's model), so
@@ -375,7 +436,9 @@ function withFileLock(targetPath, fn) {
375
436
  fs.mkdirSync(path.dirname(lock), { recursive: true });
376
437
  const pid = String(process.pid);
377
438
  let acquired = false;
378
- for (let attempt = 0; attempt < 240 && !acquired; attempt++) {
439
+ const deadline = Date.now() + FILE_LOCK_ACQUIRE_BUDGET_MS;
440
+ let backoff = FILE_LOCK_BACKOFF_BASE_MS;
441
+ for (let attempt = 0; attempt < FILE_LOCK_MAX_ATTEMPTS && !acquired; attempt++) {
379
442
  // Acquire via `linkSync`, not `open(lock, "wx")`: directly testing the
380
443
  // retry loop under contention showed `open(O_CREAT|O_EXCL)` is NOT
381
444
  // reliably single-winner on every Node version this runs on — under
@@ -421,16 +484,34 @@ function withFileLock(targetPath, fn) {
421
484
  // The steal itself runs under a single-winner guard lock — see
422
485
  // stealStaleLockUnderGuard for why judging staleness and deleting
423
486
  // the lock without one can never be made safe from here.
424
- if (stealStaleLockUnderGuard(lock, pid, attempt))
487
+ if (stealStaleLockUnderGuard(lock, pid, attempt)) {
488
+ // Verdict rendered and the lock likely just freed — retry AT ONCE
489
+ // and reset the backoff so we grab it before another waiter does.
490
+ backoff = FILE_LOCK_BACKOFF_BASE_MS;
425
491
  continue;
492
+ }
426
493
  // Guard was busy — another process is mid-steal. Back off like
427
- // ordinary contention instead of hot-spinning the attempt budget.
494
+ // ordinary contention instead of hot-spinning.
428
495
  }
429
496
  }
430
497
  catch {
498
+ // The lock vanished between the EEXIST and the stat — it is likely free
499
+ // now, so retry AT ONCE (no sleep) with a reset backoff.
500
+ backoff = FILE_LOCK_BACKOFF_BASE_MS;
431
501
  continue;
432
502
  }
433
- sleepSync(25);
503
+ // Give up on wall-clock, never on a raw try count: this keeps the worst-
504
+ // case block at ~6s even though the per-sleep backoff grew. Bounding by a
505
+ // fixed try count instead (as the old flat-25ms loop's 240 tries did)
506
+ // would have let the growing backoff roughly double that worst case.
507
+ if (Date.now() >= deadline)
508
+ break;
509
+ // Sleep a jittered span in [BASE, backoff] — never 0, so never a busy
510
+ // spin — then grow the backoff toward the cap. The jitter de-syncs many
511
+ // processes contending on the SAME lock so they do not retry in lock-step.
512
+ const span = backoff - FILE_LOCK_BACKOFF_BASE_MS;
513
+ sleepSync(FILE_LOCK_BACKOFF_BASE_MS + Math.floor(Math.random() * (span + 1)));
514
+ backoff = nextBackoffMs(backoff);
434
515
  }
435
516
  if (!acquired)
436
517
  throw new Error(`could not acquire file lock for ${targetPath}`);
@@ -188,10 +188,17 @@ function ledgerApplyCli(options) {
188
188
  return (0, ledger_1.applyLedgerProposal)(parsed);
189
189
  }
190
190
  function ledgerListCli(options) {
191
- const dirs = Array.isArray(options.dir) ? options.dir.map(String).filter(Boolean) : [];
191
+ // `--ledger-dir` is the preferred flag: the global CLI front door
192
+ // (cli/entry.ts) treats `--dir` as an alias of `--repo` for EVERY
193
+ // command, so `cw ledger list --dir X` made one flag mean two things.
194
+ // `--dir` keeps working unchanged as the legacy spelling; when both are
195
+ // given, `--ledger-dir` wins. Repeated flags become an array via
196
+ // parseArgv's append behavior, same as `--dir` always has.
197
+ const input = options["ledger-dir"] ?? options.dir;
198
+ const dirs = Array.isArray(input) ? input.map(String).filter(Boolean) : [];
192
199
  if (dirs.length > 1)
193
200
  return (0, ledger_io_1.unionLedgerEntries)(dirs);
194
- const dir = required(dirs[0] || stringOption(options.dir), "--dir <ledger-directory>");
201
+ const dir = required(dirs[0] || stringOption(input), "--ledger-dir <ledger-directory>");
195
202
  return (0, ledger_io_1.listLedgerEntries)(dir);
196
203
  }
197
204
  /** MCP-facing verify/apply take the entry OBJECT directly (not a file/
@@ -113,6 +113,7 @@ exports.evalScoreCli = evalScoreCli;
113
113
  exports.evalGateCli = evalGateCli;
114
114
  exports.evalReportCli = evalReportCli;
115
115
  const path = __importStar(require("node:path"));
116
+ const cli_args_1 = require("../core/util/cli-args");
116
117
  const numeric_flag_1 = require("../core/util/numeric-flag");
117
118
  const run_store_1 = require("./run-store");
118
119
  const report_1 = require("./report");
@@ -188,7 +189,10 @@ function numberArg(value) {
188
189
  return Number.isFinite(parsed) ? parsed : undefined;
189
190
  }
190
191
  function boolArg(value) {
191
- return Boolean(value);
192
+ // parseBoolFlag reads "false"/"0"/"no"/"off" as false and throws on an
193
+ // unrecognized string — Boolean("false") is true, which silently
194
+ // ENABLED flags like `--allow-self-approval false` (fail-open).
195
+ return (0, cli_args_1.parseBoolFlag)(value, "flag") ?? false;
192
196
  }
193
197
  /** `--multi-agent-run <id>` — parseArgv keeps kebab-case option keys
194
198
  * verbatim (no camelCase folding), so this must check the literal
@@ -278,9 +278,9 @@ function resolveChangedFiles(options = {}) {
278
278
  const root = gitRoot(cwd);
279
279
  const baseRef = resolveBaseRef(root, options.changedFrom, options.env || process.env);
280
280
  const files = new Set();
281
- for (const file of gitLines(root, ["diff", "--name-only", baseRef, "--"]))
281
+ for (const file of gitLinesOrThrow(root, ["diff", "--name-only", baseRef, "--"]))
282
282
  files.add(normalizeChangedPath(file));
283
- for (const file of gitLines(root, ["ls-files", "--others", "--exclude-standard"]))
283
+ for (const file of gitLinesOrThrow(root, ["ls-files", "--others", "--exclude-standard"]))
284
284
  files.add(normalizeChangedPath(file));
285
285
  return { baseRef, files: [...files].filter(Boolean).sort() };
286
286
  }
@@ -406,8 +406,26 @@ function resolveBaseRef(root, changedFrom, env) {
406
406
  return verifyRef(root, changedFrom);
407
407
  if (env.CW_ONRAMP_BASE)
408
408
  return verifyRef(root, env.CW_ONRAMP_BASE);
409
- const baseBranch = env.GITHUB_BASE_REF ? `origin/${env.GITHUB_BASE_REF}` : "origin/main";
410
- const mergeBase = gitOne(root, ["merge-base", "HEAD", baseBranch]);
409
+ // A pull_request CI context sets GITHUB_BASE_REF, so a base ref is EXPECTED:
410
+ // the diff must run against the PR's own base branch. If merge-base cannot
411
+ // resolve there (a shallow clone that never fetched the base, a missing
412
+ // origin ref), we must NOT quietly degrade to HEAD -- a HEAD..HEAD diff on a
413
+ // clean, already-committed tree is empty, and an empty change set has no
414
+ // issues, so onramp:check would report ok:true on a real, unseen change (the
415
+ // same vacuous-pass class PR #446 closed one layer below). Fail closed.
416
+ if (env.GITHUB_BASE_REF) {
417
+ const baseBranch = `origin/${env.GITHUB_BASE_REF}`;
418
+ const mergeBase = gitOne(root, ["merge-base", "HEAD", baseBranch]);
419
+ if (mergeBase)
420
+ return mergeBase;
421
+ throw new Error(`onramp: cannot resolve a base ref -- git merge-base HEAD ${baseBranch} found no common commit (fail closed, not treated as zero changes). Fetch the base branch with a full (non-shallow) clone, or pass --changed-from / CW_ONRAMP_BASE.`);
422
+ }
423
+ // No base ref was requested (--changed-from / CW_ONRAMP_BASE) or expected
424
+ // (GITHUB_BASE_REF): this is the local "show my own changes" use. merge-base
425
+ // against origin/main narrows the diff to this branch's commits when the
426
+ // remote is present; with no remote we fall back to HEAD so `git diff HEAD`
427
+ // still surfaces the working-tree (uncommitted) changes.
428
+ const mergeBase = gitOne(root, ["merge-base", "HEAD", "origin/main"]);
411
429
  if (mergeBase)
412
430
  return mergeBase;
413
431
  return verifyRef(root, "HEAD");
@@ -423,12 +441,37 @@ function verifyRef(root, ref) {
423
441
  function gitRoot(cwd) {
424
442
  return gitOne(node_path_1.default.resolve(cwd), ["rev-parse", "--show-toplevel"]) || node_path_1.default.resolve(cwd);
425
443
  }
444
+ // Every onramp git call is a quick metadata read (rev-parse, merge-base, diff
445
+ // --name-only, ls-files). A finite timeout keeps a HUNG git -- a cold fsmonitor
446
+ // daemon, a credential prompt on a misconfigured remote -- from blocking the
447
+ // gate forever; 5s matches the git-metadata timeout already used in
448
+ // shell/commit.ts and shell/doctor.ts. On a timeout spawnSync sets
449
+ // `result.error` (and a null status), so gitLinesOrThrow fails closed while
450
+ // gitLines/gitOne fall back the same way they do for any other git failure.
451
+ const GIT_TIMEOUT_MS = 5000;
426
452
  function gitLines(cwd, args) {
427
- const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
453
+ const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
428
454
  if (result.status !== 0)
429
455
  return [];
430
456
  return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
431
457
  }
458
+ /** Same as gitLines, but a failed git invocation THROWS instead of silently
459
+ * becoming []. resolveChangedFiles must never mistake "git could not run
460
+ * this diff" for "there is nothing to diff" -- that turns a transient git
461
+ * failure into a vacuous onramp-contract pass (a 2026-07-12 security audit
462
+ * finding: a broken base ref or a git error made the changed-file set
463
+ * empty, and an empty set has no issues, so the gate reported ok:true on a
464
+ * real, unseen change). Callers that WANT a soft fallback (merge-base
465
+ * probing, optional discovery) should keep using gitLines/gitOne. */
466
+ function gitLinesOrThrow(cwd, args) {
467
+ const result = (0, node_child_process_1.spawnSync)("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: GIT_TIMEOUT_MS });
468
+ if (result.error)
469
+ throw new Error(`onramp: git ${args.join(" ")} failed to run: ${result.error.message}`);
470
+ if (result.status !== 0) {
471
+ throw new Error(`onramp: git ${args.join(" ")} exited ${result.status} -- cannot resolve changed files (fail closed, not treated as zero changes): ${String(result.stderr || "").trim()}`);
472
+ }
473
+ return String(result.stdout || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
474
+ }
432
475
  function gitOne(cwd, args) {
433
476
  return gitLines(cwd, args)[0] || "";
434
477
  }
@@ -159,6 +159,24 @@ function planInputsFor(args) {
159
159
  function invocationCwd(args) {
160
160
  return typeof args.cwd === "string" && args.cwd.trim() ? path.resolve(args.cwd) : process.cwd();
161
161
  }
162
+ /** Same as invocationCwd, but ALSO falls back to `--repo` before
163
+ * process.cwd() -- for resolving an EXISTING run's directory specifically
164
+ * (loadRunFromCwd's target), never for a fresh plan()'s repo input (see
165
+ * planRun's own comment on why THAT path deliberately never auto-fills
166
+ * repo from cwd). A caller resuming a run they didn't originally start
167
+ * from this exact directory (the common case: `cw run resume <id>
168
+ * --drive` or `cw quickstart <app> --resume --run <id>` typed from
169
+ * wherever the terminal happens to be, not necessarily the run's own
170
+ * --repo) must not have that already-known fact silently discarded in
171
+ * favor of process.cwd() -- this mirrors the --preview branch's own
172
+ * repoCwd fallback a few lines below, which already gets this right. */
173
+ function existingRunCwd(args) {
174
+ if (typeof args.cwd === "string" && args.cwd.trim())
175
+ return path.resolve(args.cwd);
176
+ if (typeof args.repo === "string" && args.repo.trim())
177
+ return path.resolve(args.repo);
178
+ return process.cwd();
179
+ }
162
180
  /** True for a value that counts as "not supplied" — byte-exact to the old
163
181
  * build's cli-options.isMissing (undefined / null / empty string). */
164
182
  function isMissingInput(value) {
@@ -239,7 +257,7 @@ async function runDriveStep(args) {
239
257
  incremental: Boolean(args.incremental),
240
258
  };
241
259
  if (existingRunId) {
242
- const cwd = invocationCwd(args);
260
+ const cwd = existingRunCwd(args);
243
261
  const run = (0, run_store_1.loadRunFromCwd)(existingRunId, cwd);
244
262
  return (0, drive_1.driveAsync)(existingRunId, run.cwd, options);
245
263
  }
@@ -502,7 +520,7 @@ async function quickstartRun(args) {
502
520
  };
503
521
  let run;
504
522
  if (existingRunId) {
505
- run = (0, run_store_1.loadRunFromCwd)(existingRunId, invocationCwd(args));
523
+ run = (0, run_store_1.loadRunFromCwd)(existingRunId, existingRunCwd(args));
506
524
  }
507
525
  else {
508
526
  run = (0, pipeline_1.plan)(resolveWorkflowAppForPlan(appId), planInputsFor(args));
@@ -48,6 +48,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.pureAppendRunNode = exports.writeJson = void 0;
49
49
  exports.plan = plan;
50
50
  const crypto = __importStar(require("node:crypto"));
51
+ const fs = __importStar(require("node:fs"));
51
52
  const path = __importStar(require("node:path"));
52
53
  const run_paths_1 = require("../core/state/run-paths");
53
54
  const migrations_1 = require("../core/state/migrations");
@@ -207,7 +208,7 @@ function plan(app, options) {
207
208
  loopStage: "interpret",
208
209
  artifacts: [{ id: "task", kind: "markdown", path: task.taskPath }],
209
210
  metadata: { workflowId: app.workflow.id, appId: app.id, appVersion: app.version, taskId: task.id, phase: task.phase, taskKind: task.kind, requiresEvidence: task.requiresEvidence, sandboxProfileId: task.sandboxProfileId },
210
- }, { persist: false, persistNode: node_store_1.writeRunNode });
211
+ }, { persist: false, persistNode: node_store_1.writeRunNode, pathExists: fs.existsSync });
211
212
  task.stateNodeId = taskResult.outputNodeId;
212
213
  }
213
214
  (0, report_1.writeReport)(run);