cool-workflow 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/cli/dispatch.js +29 -91
  10. package/dist/cli/entry.js +41 -1
  11. package/dist/cli/io.js +6 -2
  12. package/dist/cli/parseargv.js +6 -2
  13. package/dist/core/capability-data.js +271 -0
  14. package/dist/core/capability-table.js +16 -3504
  15. package/dist/core/format/completion.js +68 -0
  16. package/dist/core/format/help.js +50 -8
  17. package/dist/core/format/safe-json.js +73 -0
  18. package/dist/core/format/state-explosion-text.js +1 -1
  19. package/dist/core/multi-agent/candidate-scoring.js +5 -1
  20. package/dist/core/multi-agent/collaboration.js +6 -5
  21. package/dist/core/multi-agent/coordinator.js +9 -8
  22. package/dist/core/multi-agent/runtime.js +4 -4
  23. package/dist/core/multi-agent/topology.js +3 -3
  24. package/dist/core/pipeline/commit-gate.js +2 -1
  25. package/dist/core/pipeline/dispatch.js +18 -4
  26. package/dist/core/pipeline/drive-decide.js +2 -1
  27. package/dist/core/state/migrations.js +16 -1
  28. package/dist/core/state/state-explosion/digest.js +16 -15
  29. package/dist/core/state/state-explosion/graph.js +26 -19
  30. package/dist/core/state/state-explosion/helpers.js +2 -1
  31. package/dist/core/state/state-explosion/report.js +6 -6
  32. package/dist/core/trust/ledger.js +2 -1
  33. package/dist/core/types/execution-backend.js +18 -0
  34. package/dist/core/types/observability.js +7 -0
  35. package/dist/core/util/collate.js +23 -0
  36. package/dist/core/util/numeric-flag.js +40 -0
  37. package/dist/core/version.js +1 -1
  38. package/dist/mcp/server.js +99 -11
  39. package/dist/shell/audit-cli.js +99 -23
  40. package/dist/shell/commit-summary.js +2 -1
  41. package/dist/shell/coordinator-io.js +73 -13
  42. package/dist/shell/dispatch.js +1 -1
  43. package/dist/shell/doctor.js +80 -1
  44. package/dist/shell/drive.js +269 -59
  45. package/dist/shell/eval-text.js +2 -2
  46. package/dist/shell/evidence-reasoning.js +8 -7
  47. package/dist/shell/execution-backend/agent.js +20 -1
  48. package/dist/shell/execution-backend/container.js +4 -1
  49. package/dist/shell/execution-backend/local.js +4 -1
  50. package/dist/shell/execution-backend/registry.js +2 -1
  51. package/dist/shell/execution-backend/types.js +0 -9
  52. package/dist/shell/feedback-cli.js +6 -6
  53. package/dist/shell/fs-atomic.js +232 -30
  54. package/dist/shell/man-cli.js +6 -0
  55. package/dist/shell/metrics-cli.js +2 -1
  56. package/dist/shell/multi-agent-cli.js +367 -323
  57. package/dist/shell/multi-agent-host.js +9 -9
  58. package/dist/shell/multi-agent-operator-ux.js +82 -39
  59. package/dist/shell/node-store.js +10 -4
  60. package/dist/shell/observability.js +13 -12
  61. package/dist/shell/onramp.js +17 -1
  62. package/dist/shell/operator-ux-text.js +24 -23
  63. package/dist/shell/operator-ux.js +22 -20
  64. package/dist/shell/orchestrator.js +49 -38
  65. package/dist/shell/pipeline-cli.js +168 -111
  66. package/dist/shell/reclamation-io.js +16 -5
  67. package/dist/shell/registry-cli.js +19 -17
  68. package/dist/shell/remote-source.js +13 -8
  69. package/dist/shell/report-cli.js +45 -0
  70. package/dist/shell/report.js +4 -2
  71. package/dist/shell/run-registry-io.js +77 -19
  72. package/dist/shell/run-store.js +91 -2
  73. package/dist/shell/scheduling-io.js +12 -13
  74. package/dist/shell/state-cli.js +2 -7
  75. package/dist/shell/state-explosion-cli.js +19 -10
  76. package/dist/shell/topology-io.js +38 -6
  77. package/dist/shell/trust-audit.js +264 -23
  78. package/dist/shell/trust-policy-io.js +1 -1
  79. package/dist/shell/worker-cli.js +41 -29
  80. package/dist/shell/worker-isolation.js +34 -9
  81. package/dist/shell/workflow-app-loader.js +3 -2
  82. package/dist/wiring/capability-table/basics.js +91 -0
  83. package/dist/wiring/capability-table/exec-backend.js +171 -0
  84. package/dist/wiring/capability-table/index.js +42 -0
  85. package/dist/wiring/capability-table/multi-agent.js +630 -0
  86. package/dist/wiring/capability-table/parity.js +467 -0
  87. package/dist/wiring/capability-table/pipeline.js +292 -0
  88. package/dist/wiring/capability-table/registry-core.js +208 -0
  89. package/dist/wiring/capability-table/reporting.js +435 -0
  90. package/dist/wiring/capability-table/scheduling-registry.js +592 -0
  91. package/dist/wiring/capability-table/state.js +181 -0
  92. package/dist/wiring/capability-table/trust-ledger.js +158 -0
  93. package/dist/wiring/capability-table/workflow-apps.js +381 -0
  94. package/docs/agent-delegation-drive.7.md +4 -0
  95. package/docs/candidate-scoring.7.md +1 -1
  96. package/docs/canonical-workflow-apps.7.md +7 -7
  97. package/docs/cli-mcp-parity.7.md +13 -3
  98. package/docs/contract-migration-tooling.7.md +4 -0
  99. package/docs/control-plane-scheduling.7.md +4 -0
  100. package/docs/coordinator-blackboard.7.md +17 -17
  101. package/docs/dogfood-one-real-repo.7.md +11 -11
  102. package/docs/durable-state-and-locking.7.md +24 -0
  103. package/docs/end-to-end-golden-path.7.md +14 -14
  104. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  105. package/docs/execution-backends.7.md +4 -0
  106. package/docs/getting-started.md +37 -37
  107. package/docs/index.md +1 -0
  108. package/docs/multi-agent-cli-mcp-surface.7.md +21 -17
  109. package/docs/multi-agent-eval-replay-harness.7.md +17 -13
  110. package/docs/multi-agent-operator-ux.7.md +23 -19
  111. package/docs/multi-agent-runtime-core.7.md +22 -22
  112. package/docs/multi-agent-topologies.7.md +6 -6
  113. package/docs/multi-agent-trust-policy-audit.7.md +11 -11
  114. package/docs/node-snapshot-diff-replay.7.md +4 -0
  115. package/docs/observability-cost-accounting.7.md +4 -0
  116. package/docs/operator-ux.7.md +34 -34
  117. package/docs/pipeline-runner.7.md +4 -4
  118. package/docs/project-index.md +48 -5
  119. package/docs/real-execution-backends.7.md +4 -0
  120. package/docs/release-and-migration.7.md +5 -1
  121. package/docs/release-tooling.7.md +4 -0
  122. package/docs/routines.md +4 -4
  123. package/docs/run-registry-control-plane.7.md +23 -19
  124. package/docs/run-retention-reclamation.7.md +4 -0
  125. package/docs/scheduled-tasks.md +14 -14
  126. package/docs/security-trust-hardening.7.md +43 -13
  127. package/docs/state-explosion-management.7.md +14 -10
  128. package/docs/team-collaboration.7.md +4 -0
  129. package/docs/trust-audit-anchor.7.md +71 -0
  130. package/docs/unix-principles.md +3 -1
  131. package/docs/verifier-gated-commit.7.md +1 -1
  132. package/docs/web-desktop-workbench.7.md +4 -0
  133. package/docs/workflow-app-framework.7.md +13 -13
  134. package/manifest/plugin.manifest.json +1 -1
  135. package/package.json +4 -2
  136. package/scripts/agents/claude-p-agent.js +2 -2
  137. package/scripts/block-unapproved-tag.sh +23 -2
  138. package/scripts/bump-version.js +24 -2
  139. package/scripts/canonical-apps.js +4 -4
  140. package/scripts/children/batch-delegate-child.js +52 -2
  141. package/scripts/dogfood-release.js +1 -1
  142. package/scripts/fake-date-for-reproduction.js +44 -0
  143. package/scripts/golden-path.js +4 -4
  144. package/scripts/purity-baseline.json +71 -0
  145. package/scripts/purity-gate.js +239 -0
  146. package/scripts/release-check.js +8 -1
  147. package/scripts/release-flow.js +57 -1
  148. package/scripts/verdict-keygen.js +83 -0
  149. package/scripts/verify-bump-reproduction.sh +148 -0
  150. package/scripts/verify-verdict-signature.js +61 -0
  151. package/scripts/version-sync-check.js +33 -12
  152. package/skills/cool-workflow/SKILL.md +9 -9
  153. package/skills/cool-workflow/references/commands.md +89 -88
  154. package/ui/workbench/app.css +37 -1
  155. package/ui/workbench/app.js +124 -6
  156. package/workflows/README.md +19 -0
@@ -41,6 +41,7 @@ var __importStar = (this && this.__importStar) || (function () {
41
41
  })();
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.writeJson = writeJson;
44
+ exports.writeTextDurable = writeTextDurable;
44
45
  exports.readJson = readJson;
45
46
  exports.safeFileName = safeFileName;
46
47
  exports.assertSafeRunId = assertSafeRunId;
@@ -55,19 +56,22 @@ const path = __importStar(require("node:path"));
55
56
  // + "\n", written via temp-file + optional fsync + rename (atomic).
56
57
  // ---------------------------------------------------------------------------
57
58
  let atomicWriteCounter = 0;
58
- /** Atomic, optionally-durable JSON write. ORDER IS THE SAFETY PROPERTY: write
59
- * the full bytes to a unique temp file, optionally fsync it, close, then
60
- * rename over the target. `rename(2)` is atomic on POSIX, so a reader always
61
- * sees either the old bytes or the new bytes, never a torn file. On a rename
62
- * failure the temp file is removed (best-effort) and the error is rethrown —
63
- * the old bytes at `file` are never touched. */
64
- function writeJson(file, value, options = {}) {
59
+ /** Shared core of `writeJson`/`writeTextDurable`: write `contents` to a
60
+ * unique temp file, optionally fsync it, close, then rename over `file`.
61
+ * ORDER IS THE SAFETY PROPERTY — `rename(2)` is atomic on POSIX, so a
62
+ * reader always sees either the old bytes or the new bytes, never a torn
63
+ * file. On a rename failure the temp file is removed (best-effort) and
64
+ * the error is rethrown — the old bytes at `file` are never touched. */
65
+ function writeBytesAtomic(file, contents, durable) {
65
66
  fs.mkdirSync(path.dirname(file), { recursive: true });
66
67
  const tmp = `${file}.tmp.${process.pid}.${atomicWriteCounter++}`;
67
- const fd = fs.openSync(tmp, "w");
68
+ // 0o600: this is run/ledger/registry state, not a file meant to be shared
69
+ // with other local users. rename(2) preserves the mode set at creation, so
70
+ // this covers the final file too.
71
+ const fd = fs.openSync(tmp, "w", 0o600);
68
72
  try {
69
- fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
70
- if (options.durable)
73
+ fs.writeFileSync(fd, contents, "utf8");
74
+ if (durable)
71
75
  fs.fsyncSync(fd);
72
76
  }
73
77
  finally {
@@ -85,7 +89,7 @@ function writeJson(file, value, options = {}) {
85
89
  }
86
90
  throw error;
87
91
  }
88
- if (options.durable) {
92
+ if (durable) {
89
93
  try {
90
94
  const dirFd = fs.openSync(path.dirname(file), "r");
91
95
  try {
@@ -100,6 +104,17 @@ function writeJson(file, value, options = {}) {
100
104
  }
101
105
  }
102
106
  }
107
+ /** Atomic, optionally-durable JSON write — see `writeBytesAtomic`. */
108
+ function writeJson(file, value, options = {}) {
109
+ writeBytesAtomic(file, `${JSON.stringify(value, null, 2)}\n`, Boolean(options.durable));
110
+ }
111
+ /** Same atomic-write contract as `writeJson` (temp file + optional fsync +
112
+ * rename), but for exact pre-built text instead of a value to serialize —
113
+ * used by callers replacing an NDJSON-style file (e.g. a repaired
114
+ * trust-audit event log) where the caller already has the final bytes. */
115
+ function writeTextDurable(file, text, options = {}) {
116
+ writeBytesAtomic(file, text, Boolean(options.durable));
117
+ }
103
118
  /** Read + JSON.parse a file. Throws `File not found: <file>` when absent,
104
119
  * `Invalid JSON in <file>: <message>` on a parse error. */
105
120
  function readJson(file) {
@@ -172,7 +187,7 @@ function isContainedPath(candidate, allowed) {
172
187
  // ---------------------------------------------------------------------------
173
188
  function durableAppendFileSync(file, data) {
174
189
  fs.mkdirSync(path.dirname(file), { recursive: true });
175
- const fd = fs.openSync(file, "a");
190
+ const fd = fs.openSync(file, "a", 0o600);
176
191
  try {
177
192
  fs.writeFileSync(fd, data, "utf8");
178
193
  fs.fsyncSync(fd);
@@ -184,11 +199,14 @@ function durableAppendFileSync(file, data) {
184
199
  // ---------------------------------------------------------------------------
185
200
  // withFileLock — portable advisory cross-process lock.
186
201
  //
187
- // Lock file: `<targetPath>.lock`, created with O_EXCL (`wx`), body
188
- // `"<pid>@<ISO>\n"`. Up to 240 tries; on EEXIST, a lock whose mtime is older
189
- // than FILE_LOCK_STALE_MS (30_000) is stolen (deleted) and retried AT ONCE;
190
- // else sleep 25ms (Atomics.wait busy-safe sleep) and retry. Any non-EEXIST
191
- // open error is rethrown. No lock after 240 tries throws
202
+ // Lock file: `<targetPath>.lock`, created by hard-linking a per-attempt temp
203
+ // 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
192
210
  // `could not acquire file lock for <targetPath>`.
193
211
  //
194
212
  // Right before fn() the lock mtime is refreshed (utimesSync, best-effort).
@@ -198,42 +216,225 @@ function durableAppendFileSync(file, data) {
198
216
  // only when still owned by this pid.
199
217
  // ---------------------------------------------------------------------------
200
218
  const FILE_LOCK_STALE_MS = 30_000;
219
+ // A lock stale for this long is stolen regardless of what `isLockOwnerAlive`
220
+ // says — the fallback for the one case a liveness check cannot resolve: the
221
+ // recorded pid has been recycled by an unrelated live process, which would
222
+ // otherwise make a dead owner's pid look "alive" forever and wedge the lock
223
+ // permanently. 10x the normal window keeps this astronomically unlikely to
224
+ // fire against a genuinely still-working holder.
225
+ const FILE_LOCK_FORCE_STALE_MS = FILE_LOCK_STALE_MS * 10;
226
+ // A steal guard older than this belongs to a stealer that crashed inside the
227
+ // guarded window (which is a handful of syscalls, microseconds for any live
228
+ // process) — remove it so stealing cannot wedge forever.
229
+ const FILE_LOCK_STEAL_GUARD_STALE_MS = FILE_LOCK_STALE_MS;
230
+ // Lock paths this process holds right now. A nested withFileLock on the
231
+ // 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
233
+ // load -> change -> save cycle hold one lock while the save path inside
234
+ // it keeps its own withFileLock call unchanged.
235
+ const HELD_LOCKS = new Set();
201
236
  function sleepSync(ms) {
202
237
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
203
238
  }
239
+ /** Whether the pid recorded in a lock body ("<pid>@<ISO>\n") is still a
240
+ * live process on this machine. Locks here are local-advisory only (no
241
+ * network-shared run directories in this tool's model), so
242
+ * `process.kill(pid, 0)` — signal 0 delivers nothing, it just probes
243
+ * existence/permission — gives a direct, same-instant answer, unlike
244
+ * mtime, which is a snapshot a caller can act on after it is already
245
+ * outdated. This is the property that actually closes the steal race: a
246
+ * lock belonging to a currently running process can never pass this
247
+ * check, no matter how a mtime-based timing window lines up, because its
248
+ * owner really is alive right now. Malformed content or a missing pid is
249
+ * treated as "not verifiably alive" so the mtime-only path still applies.
250
+ * `EPERM` (a process with that pid exists but this process cannot signal
251
+ * it) is treated as alive — the conservative, never-wrongly-steal
252
+ * direction. */
253
+ function isLockOwnerAlive(lockBody) {
254
+ const match = /^(\d+)@/.exec(lockBody);
255
+ if (!match)
256
+ return false;
257
+ const pid = Number(match[1]);
258
+ if (!Number.isInteger(pid) || pid <= 0)
259
+ return false;
260
+ try {
261
+ process.kill(pid, 0);
262
+ return true;
263
+ }
264
+ catch (error) {
265
+ return Boolean(error && typeof error === "object" && error.code === "EPERM");
266
+ }
267
+ }
268
+ /** Steal a stale lock, serialized through a single-winner guard file.
269
+ *
270
+ * Every earlier steal design judged the lock and then deleted it as two
271
+ * separate steps with no exclusion around them, and every narrowing of
272
+ * that gap (content-equality re-read, liveness gate, unlinkSync over
273
+ * rmSync) still lost a CI run eventually: under load, the judging
274
+ * process can be preempted between its last read and its unlink, a
275
+ * concurrent stealer completes the whole steal-and-reacquire in that
276
+ * gap, and the sleeper's unlink then removes the NEW owner's lock —
277
+ * two processes end up inside the critical section (reproduced in PR
278
+ * #420's CI on arm64 Node 22, trial 10, after the linkSync acquire fix
279
+ * landed). The gap cannot be closed from the judging side; POSIX has no
280
+ * compare-and-delete.
281
+ *
282
+ * So close it by exclusion instead: only the holder of `<lock>.steal`
283
+ * (acquired with the same single-winner linkSync used for the lock
284
+ * itself) may judge and delete. While the judged lock file still
285
+ * exists, no waiter can link-acquire the lock path, so nothing can
286
+ * replace the lock between a guard-held verdict and the unlink; and no
287
+ * second stealer exists to double-delete. Before the unlink, the
288
+ * verdict is additionally pinned to the exact file it judged (same
289
+ * inode, same mtime) — insurance for the force-stale path, where an
290
+ * owner release + fresh re-acquire between the guard's stat and read
291
+ * could otherwise slip through.
292
+ *
293
+ * A guard whose holder crashed inside the guarded window (microseconds
294
+ * of syscalls for any live process) is removed after
295
+ * FILE_LOCK_STEAL_GUARD_STALE_MS so stealing cannot wedge; that cleanup
296
+ * is itself a plain stat-and-unlink, but reintroducing the original
297
+ * race through it needs a crashed stealer AND two cleaners AND a fresh
298
+ * guard all colliding inside one syscall-wide window — compounded odds
299
+ * the hot path never sees.
300
+ *
301
+ * Returns true when this process held the guard and rendered a verdict
302
+ * (steal done or judged-not-stale) — the caller should retry the
303
+ * acquire at once. Returns false when the guard was busy — the caller
304
+ * should back off like ordinary contention. */
305
+ function stealStaleLockUnderGuard(lock, pid, attempt) {
306
+ const guard = `${lock}.steal`;
307
+ try {
308
+ const guardAge = Date.now() - fs.statSync(guard).mtimeMs;
309
+ if (guardAge > FILE_LOCK_STEAL_GUARD_STALE_MS)
310
+ fs.unlinkSync(guard);
311
+ }
312
+ catch {
313
+ /* no guard, or another cleaner beat us to it */
314
+ }
315
+ const tmp = `${guard}.${pid}.${attempt}.tmp`;
316
+ let haveGuard = false;
317
+ try {
318
+ fs.writeFileSync(tmp, `${pid}@${new Date().toISOString()}\n`, { mode: 0o600 });
319
+ fs.linkSync(tmp, guard);
320
+ haveGuard = true;
321
+ }
322
+ catch {
323
+ /* EEXIST — another stealer holds the guard */
324
+ }
325
+ finally {
326
+ try {
327
+ fs.unlinkSync(tmp);
328
+ }
329
+ catch {
330
+ /* already gone */
331
+ }
332
+ }
333
+ if (!haveGuard)
334
+ return false;
335
+ try {
336
+ // Re-judge INSIDE the guard: only what is verified here counts.
337
+ const judged = fs.statSync(lock);
338
+ const age = Date.now() - judged.mtimeMs;
339
+ if (age <= FILE_LOCK_STALE_MS)
340
+ return true;
341
+ const body = fs.readFileSync(lock, "utf8");
342
+ if (!(age > FILE_LOCK_FORCE_STALE_MS || !isLockOwnerAlive(body)))
343
+ return true;
344
+ // Pin the verdict to the exact file it judged: a lock re-acquired in
345
+ // the stat->read gap is a NEW inode (every acquire links a fresh tmp
346
+ // file) with a fresh mtime, so either check catches it.
347
+ const current = fs.statSync(lock);
348
+ if (current.ino === judged.ino && current.mtimeMs === judged.mtimeMs) {
349
+ fs.unlinkSync(lock);
350
+ }
351
+ }
352
+ catch {
353
+ /* lock vanished — nothing left to steal */
354
+ }
355
+ finally {
356
+ try {
357
+ fs.unlinkSync(guard);
358
+ }
359
+ catch {
360
+ /* guard already cleaned up */
361
+ }
362
+ }
363
+ return true;
364
+ }
204
365
  /** Run `fn` while holding an advisory lock for `targetPath`; always released
205
366
  * (unless the lock was stolen mid-operation, in which case releasing would
206
- * corrupt the thief's critical section, so it is deliberately NOT released). */
367
+ * corrupt the thief's critical section, so it is deliberately NOT released).
368
+ * Re-entrant inside one process: a nested call on the same target runs
369
+ * `fn` under the already-held lock. */
207
370
  function withFileLock(targetPath, fn) {
208
371
  const lock = `${targetPath}.lock`;
372
+ const heldKey = path.resolve(lock);
373
+ if (HELD_LOCKS.has(heldKey))
374
+ return fn();
209
375
  fs.mkdirSync(path.dirname(lock), { recursive: true });
210
376
  const pid = String(process.pid);
211
377
  let acquired = false;
212
378
  for (let attempt = 0; attempt < 240 && !acquired; attempt++) {
379
+ // Acquire via `linkSync`, not `open(lock, "wx")`: directly testing the
380
+ // retry loop under contention showed `open(O_CREAT|O_EXCL)` is NOT
381
+ // reliably single-winner on every Node version this runs on — under
382
+ // CI load (reproduced on Node 18, both x64 and arm64), two separate
383
+ // processes could each `open(wx)` + write + read back and each see
384
+ // ONLY ITS OWN content, meaning the OS was not actually serializing
385
+ // the two creates against each other (see
386
+ // test/fs-atomic-lock-steal-race-smoke.js for the reproduction). A
387
+ // readback-verification pass added on top of `open(wx)` still could
388
+ // not close this, because the underlying create itself was the
389
+ // unreliable step, not just the later unlink. `link(2)`'s exclusivity
390
+ // guarantee is the older, more battle-tested primitive precisely for
391
+ // this class of lockfile race (classic Unix "lock via link" idiom) —
392
+ // stress-tested directly (24-process contention, 15/15 trials) with
393
+ // zero double-winners where the same rig reproduced `open(wx)`
394
+ // double-winners within a handful of trials.
395
+ const tmp = `${lock}.${pid}.${attempt}.tmp`;
396
+ let linkError = null;
213
397
  try {
214
- const fd = fs.openSync(lock, "wx");
215
- fs.writeFileSync(fd, `${pid}@${new Date().toISOString()}\n`, "utf8");
216
- fs.closeSync(fd);
398
+ fs.writeFileSync(tmp, `${pid}@${new Date().toISOString()}\n`, { mode: 0o600 });
399
+ fs.linkSync(tmp, lock);
217
400
  acquired = true;
218
401
  }
219
402
  catch (error) {
220
- if (!(error && typeof error === "object" && error.code === "EEXIST")) {
221
- throw error;
222
- }
403
+ linkError = error;
404
+ }
405
+ finally {
223
406
  try {
224
- if (Date.now() - fs.statSync(lock).mtimeMs > FILE_LOCK_STALE_MS) {
225
- fs.rmSync(lock, { force: true });
226
- continue;
227
- }
407
+ fs.unlinkSync(tmp);
228
408
  }
229
409
  catch {
230
- continue;
410
+ /* already gone, or the link consumed it — either is fine */
231
411
  }
232
- sleepSync(25);
233
412
  }
413
+ if (acquired)
414
+ break;
415
+ if (!(linkError && typeof linkError === "object" && linkError.code === "EEXIST")) {
416
+ throw linkError;
417
+ }
418
+ try {
419
+ const age = Date.now() - fs.statSync(lock).mtimeMs;
420
+ if (age > FILE_LOCK_STALE_MS) {
421
+ // The steal itself runs under a single-winner guard lock — see
422
+ // stealStaleLockUnderGuard for why judging staleness and deleting
423
+ // the lock without one can never be made safe from here.
424
+ if (stealStaleLockUnderGuard(lock, pid, attempt))
425
+ continue;
426
+ // Guard was busy — another process is mid-steal. Back off like
427
+ // ordinary contention instead of hot-spinning the attempt budget.
428
+ }
429
+ }
430
+ catch {
431
+ continue;
432
+ }
433
+ sleepSync(25);
234
434
  }
235
435
  if (!acquired)
236
436
  throw new Error(`could not acquire file lock for ${targetPath}`);
437
+ HELD_LOCKS.add(heldKey);
237
438
  // Refresh mtime right before the critical section.
238
439
  try {
239
440
  fs.utimesSync(lock, new Date(), new Date());
@@ -263,6 +464,7 @@ function withFileLock(targetPath, fn) {
263
464
  return result;
264
465
  }
265
466
  finally {
467
+ HELD_LOCKS.delete(heldKey);
266
468
  try {
267
469
  // Only release if we still own the lock.
268
470
  const current = fs.readFileSync(lock, "utf8");
@@ -47,6 +47,7 @@ exports.ManPageNotFoundError = void 0;
47
47
  exports.readManPage = readManPage;
48
48
  const fs = __importStar(require("node:fs"));
49
49
  const path = __importStar(require("node:path"));
50
+ const fs_atomic_1 = require("./fs-atomic");
50
51
  class ManPageNotFoundError extends Error {
51
52
  topic;
52
53
  constructor(topic) {
@@ -77,6 +78,11 @@ function readManPage(topic) {
77
78
  const docsDir = path.join(pluginRoot(), "docs");
78
79
  const candidates = [path.join(docsDir, `${topic}.7.md`), path.join(docsDir, `${topic}.md`), path.join(docsDir, topic)];
79
80
  for (const candidate of candidates) {
81
+ // A topic containing `..` or an absolute-path escape must never resolve
82
+ // outside docsDir — this bare third candidate would otherwise read any
83
+ // file on disk (e.g. `cw man ../../../../etc/passwd`).
84
+ if (!(0, fs_atomic_1.isContainedPath)(candidate, docsDir))
85
+ continue;
80
86
  try {
81
87
  if (fs.statSync(candidate).isFile())
82
88
  return fs.readFileSync(candidate, "utf8");
@@ -44,6 +44,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
44
44
  exports.metricsShowCli = metricsShowCli;
45
45
  exports.metricsSummaryCli = metricsSummaryCli;
46
46
  const path = __importStar(require("node:path"));
47
+ const numeric_flag_1 = require("../core/util/numeric-flag");
47
48
  const run_store_1 = require("./run-store");
48
49
  const observability_1 = require("./observability");
49
50
  const run_registry_io_1 = require("./run-registry-io");
@@ -78,7 +79,7 @@ function metricsSummaryCli(args) {
78
79
  const cwd = invocationCwd(args);
79
80
  const scope = args.scope === "home" ? "home" : "repo";
80
81
  const registry = new run_registry_io_1.RunRegistry(cwd);
81
- const limit = args.limit === undefined ? undefined : Number(args.limit);
82
+ const limit = (0, numeric_flag_1.requiredNumberFlag)(args.limit, "--limit");
82
83
  const listing = registry.list({ scope, includeArchived: true, limit });
83
84
  const inputs = [];
84
85
  let unreadableRuns = 0;