cool-workflow 0.1.98 → 0.2.1

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 (315) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/README.md +18 -7
  4. package/apps/architecture-review/app.json +2 -2
  5. package/apps/architecture-review/workflow.js +12 -12
  6. package/apps/architecture-review-fast/app.json +1 -1
  7. package/apps/end-to-end-golden-path/app.json +1 -1
  8. package/apps/pr-review-fix-ci/app.json +1 -1
  9. package/apps/release-cut/app.json +1 -1
  10. package/apps/research-synthesis/app.json +1 -1
  11. package/dist/cli/dispatch.js +236 -0
  12. package/dist/cli/entry.js +120 -0
  13. package/dist/cli/io.js +21 -4
  14. package/dist/cli/parseargv.js +157 -0
  15. package/dist/cli.js +6 -33
  16. package/dist/core/capability-table.js +3518 -0
  17. package/dist/core/format/help.js +314 -0
  18. package/dist/{state-explosion/format.js → core/format/state-explosion-text.js} +10 -1
  19. package/dist/core/hash.js +137 -0
  20. package/dist/core/multi-agent/candidate-scoring.js +219 -0
  21. package/dist/core/multi-agent/collaboration.js +481 -0
  22. package/dist/core/multi-agent/coordinator.js +515 -0
  23. package/dist/core/multi-agent/eval-replay.js +306 -0
  24. package/dist/core/multi-agent/runtime.js +929 -0
  25. package/dist/core/multi-agent/topology.js +298 -0
  26. package/dist/core/multi-agent/trust-policy.js +197 -0
  27. package/dist/core/pipeline/commit-gate.js +320 -0
  28. package/dist/{pipeline-contract.js → core/pipeline/contract.js} +24 -37
  29. package/dist/core/pipeline/dispatch.js +103 -0
  30. package/dist/core/pipeline/drive-decide.js +227 -0
  31. package/dist/core/pipeline/error-feedback.js +161 -0
  32. package/dist/core/pipeline/loop-expansion.js +124 -0
  33. package/dist/{result-normalize.js → core/pipeline/result-normalize.js} +14 -37
  34. package/dist/core/pipeline/runner.js +230 -0
  35. package/dist/{contract-migration.js → core/state/contract-migration.js} +68 -92
  36. package/dist/{state-migrations.js → core/state/migrations.js} +103 -96
  37. package/dist/core/state/node-projection.js +95 -0
  38. package/dist/core/state/node-snapshot.js +230 -0
  39. package/dist/core/state/run-paths.js +93 -0
  40. package/dist/{schema-validate.js → core/state/schema-validate.js} +35 -28
  41. package/dist/core/state/schema.js +50 -0
  42. package/dist/core/state/state-explosion/digest.js +243 -0
  43. package/dist/core/state/state-explosion/graph.js +527 -0
  44. package/dist/{state-explosion → core/state/state-explosion}/helpers.js +34 -3
  45. package/dist/core/state/state-explosion/report.js +187 -0
  46. package/dist/{state-explosion → core/state/state-explosion}/size.js +25 -7
  47. package/dist/{state-node.js → core/state/state-node.js} +90 -113
  48. package/dist/core/state/types.js +19 -0
  49. package/dist/{validation.js → core/state/validation.js} +64 -131
  50. package/dist/core/trust/evidence-grounding.js +134 -0
  51. package/dist/core/trust/ledger.js +199 -0
  52. package/dist/{telemetry-attestation.js → core/trust/telemetry-attestation.js} +94 -68
  53. package/dist/core/trust/telemetry-ledger.js +127 -0
  54. package/dist/core/types.js +20 -0
  55. package/dist/core/version.js +16 -0
  56. package/dist/{workflow-app-framework.js → core/workflow-apps/app-schema.js} +337 -426
  57. package/dist/mcp/dispatch.js +104 -0
  58. package/dist/mcp/server.js +144 -0
  59. package/dist/mcp-server.js +6 -84
  60. package/dist/{agent-config.js → shell/agent-config.js} +118 -71
  61. package/dist/shell/app-run-cli.js +88 -0
  62. package/dist/shell/audit-cli.js +259 -0
  63. package/dist/shell/audit-provenance.js +83 -0
  64. package/dist/shell/candidate-scoring-io.js +459 -0
  65. package/dist/shell/collaboration-io.js +264 -0
  66. package/dist/shell/commit-summary.js +104 -0
  67. package/dist/shell/commit.js +290 -0
  68. package/dist/shell/coordinator-io.js +476 -0
  69. package/dist/shell/demo-cli.js +19 -0
  70. package/dist/shell/dispatch.js +162 -0
  71. package/dist/shell/doctor.js +292 -0
  72. package/dist/shell/drive.js +885 -0
  73. package/dist/shell/error-feedback-io.js +305 -0
  74. package/dist/shell/eval-io.js +473 -0
  75. package/dist/{multi-agent-eval/format.js → shell/eval-text.js} +78 -49
  76. package/dist/{evidence-reasoning.js → shell/evidence-reasoning.js} +124 -255
  77. package/dist/shell/exec-backend-cli.js +88 -0
  78. package/dist/shell/execution-backend/agent.js +507 -0
  79. package/dist/shell/execution-backend/ci.js +15 -0
  80. package/dist/shell/execution-backend/container.js +69 -0
  81. package/dist/shell/execution-backend/envelopes.js +55 -0
  82. package/dist/shell/execution-backend/local.js +113 -0
  83. package/dist/shell/execution-backend/probes.js +175 -0
  84. package/dist/shell/execution-backend/registry.js +402 -0
  85. package/dist/shell/execution-backend/remote.js +128 -0
  86. package/dist/shell/execution-backend/types.js +11 -0
  87. package/dist/shell/feedback-cli.js +81 -0
  88. package/dist/shell/feedback-operations.js +48 -0
  89. package/dist/shell/fs-atomic.js +276 -0
  90. package/dist/shell/harness.js +98 -0
  91. package/dist/shell/ledger-cli.js +212 -0
  92. package/dist/shell/ledger-io.js +169 -0
  93. package/dist/shell/man-cli.js +89 -0
  94. package/dist/shell/metrics-cli.js +100 -0
  95. package/dist/shell/multi-agent-cli.js +1002 -0
  96. package/dist/shell/multi-agent-host.js +563 -0
  97. package/dist/shell/multi-agent-io.js +387 -0
  98. package/dist/{multi-agent-operator-ux.js → shell/multi-agent-operator-ux.js} +234 -191
  99. package/dist/shell/node-store.js +124 -0
  100. package/dist/{observability/format.js → shell/observability-format.js} +7 -1
  101. package/dist/{observability/intake.js → shell/observability-intake.js} +79 -56
  102. package/dist/{observability.js → shell/observability.js} +159 -332
  103. package/dist/{onramp.js → shell/onramp.js} +11 -0
  104. package/dist/{operator-ux/format.js → shell/operator-ux-text.js} +250 -239
  105. package/dist/shell/operator-ux.js +431 -0
  106. package/dist/shell/orchestrator.js +231 -0
  107. package/dist/shell/pipeline-cli.js +667 -0
  108. package/dist/shell/pipeline.js +217 -0
  109. package/dist/shell/reclamation-io.js +1366 -0
  110. package/dist/shell/registry-cli.js +344 -0
  111. package/dist/{remote-source.js → shell/remote-source.js} +2 -2
  112. package/dist/shell/report-cli.js +101 -0
  113. package/dist/shell/report-view-cli.js +117 -0
  114. package/dist/{orchestrator → shell}/report.js +289 -282
  115. package/dist/shell/reporter.js +62 -0
  116. package/dist/shell/run-export-cli.js +106 -0
  117. package/dist/shell/run-export.js +680 -0
  118. package/dist/shell/run-registry-io.js +1014 -0
  119. package/dist/shell/run-store.js +164 -0
  120. package/dist/{sandbox-profile.js → shell/sandbox-profile.js} +134 -95
  121. package/dist/{scheduler.js → shell/scheduler-io.js} +257 -48
  122. package/dist/shell/scheduling-io.js +321 -0
  123. package/dist/shell/state-cli.js +181 -0
  124. package/dist/shell/state-explosion-cli.js +197 -0
  125. package/dist/shell/telemetry-cli.js +85 -0
  126. package/dist/{telemetry-demo.js → shell/telemetry-demo.js} +149 -119
  127. package/dist/shell/telemetry-ledger-io.js +132 -0
  128. package/dist/{term.js → shell/term.js} +36 -46
  129. package/dist/shell/topology-io.js +361 -0
  130. package/dist/shell/trust-audit.js +472 -0
  131. package/dist/{multi-agent-trust.js → shell/trust-policy-io.js} +67 -205
  132. package/dist/shell/verifier.js +48 -0
  133. package/dist/shell/workbench-host.js +258 -0
  134. package/dist/shell/workbench-text.js +18 -0
  135. package/dist/shell/workbench.js +170 -0
  136. package/dist/shell/worker-cli.js +124 -0
  137. package/dist/shell/worker-isolation.js +852 -0
  138. package/dist/shell/workflow-app-loader.js +650 -0
  139. package/docs/agent-delegation-drive.7.md +4 -0
  140. package/docs/cli-mcp-parity.7.md +282 -219
  141. package/docs/contract-migration-tooling.7.md +4 -0
  142. package/docs/control-plane-scheduling.7.md +4 -0
  143. package/docs/durable-state-and-locking.7.md +4 -0
  144. package/docs/evidence-adoption-reasoning-chain.7.md +4 -0
  145. package/docs/execution-backends.7.md +4 -0
  146. package/docs/multi-agent-cli-mcp-surface.7.md +4 -0
  147. package/docs/multi-agent-eval-replay-harness.7.md +4 -0
  148. package/docs/multi-agent-operator-ux.7.md +4 -0
  149. package/docs/node-snapshot-diff-replay.7.md +4 -0
  150. package/docs/observability-cost-accounting.7.md +6 -1
  151. package/docs/project-index.md +137 -71
  152. package/docs/real-execution-backends.7.md +4 -0
  153. package/docs/release-and-migration.7.md +4 -0
  154. package/docs/release-tooling.7.md +4 -0
  155. package/docs/remote-source-review.7.md +4 -4
  156. package/docs/report-verifiable-bundle.7.md +1 -1
  157. package/docs/run-registry-control-plane.7.md +4 -0
  158. package/docs/run-retention-reclamation.7.md +25 -0
  159. package/docs/security-trust-hardening.7.md +3 -1
  160. package/docs/state-explosion-management.7.md +4 -0
  161. package/docs/team-collaboration.7.md +4 -0
  162. package/docs/web-desktop-workbench.7.md +15 -1
  163. package/manifest/plugin.manifest.json +1 -1
  164. package/manifest/source-context-profiles.json +9 -13
  165. package/package.json +1 -1
  166. package/scripts/agents/agent-adapter-core.js +22 -2
  167. package/scripts/agents/claude-p-agent.js +8 -3
  168. package/scripts/agents/cw-attest-wrap.js +2 -2
  169. package/scripts/agents/gemini-agent.js +5 -1
  170. package/scripts/agents/opencode-agent.js +5 -1
  171. package/scripts/bump-version.js +4 -3
  172. package/scripts/canonical-apps.js +4 -4
  173. package/scripts/dogfood-architecture-review.js +2 -3
  174. package/scripts/dogfood-release.js +3 -3
  175. package/scripts/gen-parity-doc.js +15 -2
  176. package/scripts/golden-path.js +4 -4
  177. package/scripts/onramp-check.js +1 -1
  178. package/scripts/parity-check.js +38 -21
  179. package/scripts/release-flow.js +2 -2
  180. package/scripts/sync-project-index.js +51 -27
  181. package/scripts/validate-run-state-schema.js +2 -2
  182. package/scripts/version-sync-check.js +30 -30
  183. package/dist/candidate-scoring.js +0 -729
  184. package/dist/capability-core.js +0 -1189
  185. package/dist/capability-registry.js +0 -885
  186. package/dist/cli/command-surface.js +0 -494
  187. package/dist/cli/format.js +0 -56
  188. package/dist/cli/handlers/audit.js +0 -82
  189. package/dist/cli/handlers/blackboard.js +0 -81
  190. package/dist/cli/handlers/candidate.js +0 -40
  191. package/dist/cli/handlers/clones.js +0 -34
  192. package/dist/cli/handlers/collaboration.js +0 -61
  193. package/dist/cli/handlers/eval.js +0 -40
  194. package/dist/cli/handlers/ledger.js +0 -169
  195. package/dist/cli/handlers/maintenance.js +0 -107
  196. package/dist/cli/handlers/multi-agent.js +0 -165
  197. package/dist/cli/handlers/node.js +0 -41
  198. package/dist/cli/handlers/operational.js +0 -155
  199. package/dist/cli/handlers/operator.js +0 -146
  200. package/dist/cli/handlers/registry.js +0 -68
  201. package/dist/cli/handlers/run.js +0 -153
  202. package/dist/cli/handlers/scheduling.js +0 -132
  203. package/dist/cli/handlers/workbench.js +0 -41
  204. package/dist/cli/handlers/worker.js +0 -45
  205. package/dist/cli/run-summary.js +0 -45
  206. package/dist/clones.js +0 -162
  207. package/dist/collaboration.js +0 -726
  208. package/dist/commit.js +0 -592
  209. package/dist/compare.js +0 -18
  210. package/dist/coordinator/classify.js +0 -45
  211. package/dist/coordinator/paths.js +0 -42
  212. package/dist/coordinator/util.js +0 -126
  213. package/dist/coordinator.js +0 -990
  214. package/dist/daemon.js +0 -44
  215. package/dist/dispatch.js +0 -250
  216. package/dist/doctor.js +0 -212
  217. package/dist/drive.js +0 -864
  218. package/dist/error-feedback.js +0 -419
  219. package/dist/evidence-grounding.js +0 -184
  220. package/dist/execution-backend/agent.js +0 -354
  221. package/dist/execution-backend/probes.js +0 -112
  222. package/dist/execution-backend/util.js +0 -47
  223. package/dist/execution-backend.js +0 -961
  224. package/dist/gates.js +0 -48
  225. package/dist/harness.js +0 -61
  226. package/dist/ledger.js +0 -313
  227. package/dist/loop-expansion.js +0 -60
  228. package/dist/mcp/tool-call.js +0 -470
  229. package/dist/mcp/tool-definitions.js +0 -1066
  230. package/dist/mcp-surface.js +0 -30
  231. package/dist/multi-agent/graph.js +0 -84
  232. package/dist/multi-agent/helpers.js +0 -141
  233. package/dist/multi-agent/ids.js +0 -20
  234. package/dist/multi-agent/paths.js +0 -22
  235. package/dist/multi-agent-eval/normalize.js +0 -51
  236. package/dist/multi-agent-eval.js +0 -678
  237. package/dist/multi-agent-host.js +0 -777
  238. package/dist/multi-agent.js +0 -984
  239. package/dist/node-projection.js +0 -59
  240. package/dist/node-snapshot.js +0 -260
  241. package/dist/operator-ux.js +0 -631
  242. package/dist/orchestrator/app-operations.js +0 -211
  243. package/dist/orchestrator/audit-operations.js +0 -182
  244. package/dist/orchestrator/candidate-operations.js +0 -117
  245. package/dist/orchestrator/cli-options.js +0 -294
  246. package/dist/orchestrator/collaboration-operations.js +0 -86
  247. package/dist/orchestrator/feedback-operations.js +0 -81
  248. package/dist/orchestrator/host-operations.js +0 -78
  249. package/dist/orchestrator/lifecycle-operations.js +0 -650
  250. package/dist/orchestrator/migration-operations.js +0 -44
  251. package/dist/orchestrator/multi-agent-operations.js +0 -362
  252. package/dist/orchestrator/topology-operations.js +0 -84
  253. package/dist/orchestrator.js +0 -925
  254. package/dist/pipeline-runner.js +0 -285
  255. package/dist/reclamation/hash.js +0 -72
  256. package/dist/reclamation.js +0 -812
  257. package/dist/reporter.js +0 -67
  258. package/dist/run-export.js +0 -815
  259. package/dist/run-registry/derive.js +0 -175
  260. package/dist/run-registry/format.js +0 -124
  261. package/dist/run-registry/gc.js +0 -251
  262. package/dist/run-registry/policy.js +0 -16
  263. package/dist/run-registry/queue.js +0 -115
  264. package/dist/run-registry.js +0 -850
  265. package/dist/run-state-schema.js +0 -68
  266. package/dist/scheduling.js +0 -184
  267. package/dist/state-explosion.js +0 -1014
  268. package/dist/state.js +0 -367
  269. package/dist/telemetry-ledger.js +0 -196
  270. package/dist/topology.js +0 -565
  271. package/dist/triggers.js +0 -184
  272. package/dist/trust-audit.js +0 -644
  273. package/dist/types/blackboard.js +0 -2
  274. package/dist/types/candidate.js +0 -2
  275. package/dist/types/collaboration.js +0 -2
  276. package/dist/types/core.js +0 -2
  277. package/dist/types/drive.js +0 -10
  278. package/dist/types/error-feedback.js +0 -2
  279. package/dist/types/evidence-reasoning.js +0 -2
  280. package/dist/types/execution-backend.js +0 -2
  281. package/dist/types/multi-agent.js +0 -2
  282. package/dist/types/observability.js +0 -2
  283. package/dist/types/pipeline.js +0 -2
  284. package/dist/types/reclamation.js +0 -8
  285. package/dist/types/report-bundle.js +0 -6
  286. package/dist/types/result.js +0 -2
  287. package/dist/types/run-registry.js +0 -2
  288. package/dist/types/run.js +0 -2
  289. package/dist/types/sandbox.js +0 -2
  290. package/dist/types/schedule.js +0 -2
  291. package/dist/types/state-node.js +0 -2
  292. package/dist/types/topology.js +0 -2
  293. package/dist/types/trust.js +0 -2
  294. package/dist/types/workbench.js +0 -2
  295. package/dist/types/worker.js +0 -2
  296. package/dist/types/workflow-app.js +0 -2
  297. package/dist/types.js +0 -44
  298. package/dist/util/fingerprint.js +0 -19
  299. package/dist/util/fingerprint.test.js +0 -27
  300. package/dist/verifier.js +0 -78
  301. package/dist/version.js +0 -8
  302. package/dist/workbench-host.js +0 -192
  303. package/dist/workbench.js +0 -192
  304. package/dist/worker-accept/acceptance.js +0 -114
  305. package/dist/worker-accept/blackboard-fanout.js +0 -80
  306. package/dist/worker-accept/blackboard-linkage.js +0 -19
  307. package/dist/worker-accept/context.js +0 -2
  308. package/dist/worker-accept/telemetry-ledger.js +0 -126
  309. package/dist/worker-accept/validation.js +0 -77
  310. package/dist/worker-accept/verifier-completion.js +0 -73
  311. package/dist/worker-isolation/helpers.js +0 -51
  312. package/dist/worker-isolation/paths.js +0 -46
  313. package/dist/worker-isolation.js +0 -656
  314. package/dist/workflow-api.js +0 -131
  315. /package/dist/{types → core/types}/boundary.js +0 -0
@@ -0,0 +1,1366 @@
1
+ "use strict";
2
+ // shell/reclamation-io.ts — gc plan/run/verify's write-ahead reclamation
3
+ // transaction, plus the orphan-run sweep and the clone cache gc.
4
+ //
5
+ // MILESTONE 10 (docs/rebuild/PLAN.md build order, step 10). Byte-exact port of the
6
+ // old build's src/reclamation.ts + src/reclamation/hash.ts +
7
+ // src/run-registry/{gc,orphans}.ts + src/clones.ts. Reuses
8
+ // shell/fs-atomic.ts's `withFileLock` directly (no reimplementation, per
9
+ // the task's instruction) and core/state/node-projection.ts's
10
+ // `replayStableStringify`/`nodeProjectionDigestInput` so the tombstone
11
+ // hash-chain input shares the exact same canonical bytes as node-
12
+ // snapshot.ts (never re-derived).
13
+ //
14
+ // WRITE-AHEAD ORDER (the safety property; SPEC/scheduling-registry.md
15
+ // section E, "Rebuild risks" #2): extractSkeleton -> validateSkeleton +
16
+ // validateSkeletonAgainstRun (refuse skeleton-incomplete) -> under the
17
+ // per-run lock: planReclamation + buildTombstone + commitTombstone
18
+ // (durable fsync) -> prepareFree (re-point node artifacts off scratch,
19
+ // persist state.json durably, prove no dangling reference; refuse
20
+ // repoint-incomplete) -> freeBulk. A crash between any two steps leaves
21
+ // EITHER the full run OR a complete tombstone, never half-deleted.
22
+ //
23
+ // tombstoneHash reproducibility: `freeable` MUST be sorted by path bytes
24
+ // BEFORE it is hashed (this is exactly what the tombstonesort-*.case.js
25
+ // conformance cases pin) — see `planReclamation`'s explicit sort below.
26
+ //
27
+ // Evidence: SPEC/scheduling-registry.md sections E, F, G;
28
+ // plugins/cool-workflow/src/reclamation.ts, src/reclamation/hash.ts,
29
+ // src/run-registry/{gc,orphans}.ts, src/clones.ts (byte-exact source).
30
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
31
+ if (k2 === undefined) k2 = k;
32
+ var desc = Object.getOwnPropertyDescriptor(m, k);
33
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
34
+ desc = { enumerable: true, get: function() { return m[k]; } };
35
+ }
36
+ Object.defineProperty(o, k2, desc);
37
+ }) : (function(o, m, k, k2) {
38
+ if (k2 === undefined) k2 = k;
39
+ o[k2] = m[k];
40
+ }));
41
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
42
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
43
+ }) : function(o, v) {
44
+ o["default"] = v;
45
+ });
46
+ var __importStar = (this && this.__importStar) || (function () {
47
+ var ownKeys = function(o) {
48
+ ownKeys = Object.getOwnPropertyNames || function (o) {
49
+ var ar = [];
50
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
51
+ return ar;
52
+ };
53
+ return ownKeys(o);
54
+ };
55
+ return function (mod) {
56
+ if (mod && mod.__esModule) return mod;
57
+ var result = {};
58
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
59
+ __setModuleDefault(result, mod);
60
+ return result;
61
+ };
62
+ })();
63
+ Object.defineProperty(exports, "__esModule", { value: true });
64
+ exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES = exports.SKELETON_REQUIRED_KEYS = exports.ReclamationAbort = exports.ReclamationError = void 0;
65
+ exports.sha256OfString = sha256OfString;
66
+ exports.sha256OfFile = sha256OfFile;
67
+ exports.dirBytes = dirBytes;
68
+ exports.contentDigest = contentDigest;
69
+ exports.reclaimedLogPath = reclaimedLogPath;
70
+ exports.loadReclamationLog = loadReclamationLog;
71
+ exports.extractSkeleton = extractSkeleton;
72
+ exports.validateSkeleton = validateSkeleton;
73
+ exports.validateSkeletonAgainstRun = validateSkeletonAgainstRun;
74
+ exports.planReclamation = planReclamation;
75
+ exports.genesisPrevHash = genesisPrevHash;
76
+ exports.computeTombstoneHash = computeTombstoneHash;
77
+ exports.buildTombstone = buildTombstone;
78
+ exports.commitTombstone = commitTombstone;
79
+ exports.prepareFree = prepareFree;
80
+ exports.freeBulk = freeBulk;
81
+ exports.runReclamation = runReclamation;
82
+ exports.reconstructArtifact = reconstructArtifact;
83
+ exports.verifyReclamation = verifyReclamation;
84
+ exports.reclamationPolicy = reclamationPolicy;
85
+ exports.reclaimEligibility = reclaimEligibility;
86
+ exports.gcPlan = gcPlan;
87
+ exports.gcRun = gcRun;
88
+ exports.gcVerify = gcVerify;
89
+ exports.formatGcPlan = formatGcPlan;
90
+ exports.formatGcRun = formatGcRun;
91
+ exports.formatGcVerify = formatGcVerify;
92
+ exports.listOrphanRuns = listOrphanRuns;
93
+ exports.gcOrphanRuns = gcOrphanRuns;
94
+ exports.formatOrphanRunsList = formatOrphanRunsList;
95
+ exports.formatOrphanRunsGc = formatOrphanRunsGc;
96
+ exports.listClones = listClones;
97
+ exports.gcClones = gcClones;
98
+ exports.formatClonesList = formatClonesList;
99
+ exports.formatClonesGc = formatClonesGc;
100
+ const fs = __importStar(require("node:fs"));
101
+ const os = __importStar(require("node:os"));
102
+ const path = __importStar(require("node:path"));
103
+ const fs_atomic_1 = require("./fs-atomic");
104
+ const trust_audit_1 = require("./trust-audit");
105
+ const node_store_1 = require("./node-store");
106
+ const node_snapshot_1 = require("../core/state/node-snapshot");
107
+ const node_projection_1 = require("../core/state/node-projection");
108
+ const hash_1 = require("../core/hash");
109
+ const run_registry_io_1 = require("./run-registry-io");
110
+ // ---------------------------------------------------------------------------
111
+ // Content addressing + byte measurement (in-process, no `du`) — carried
112
+ // forward from src/reclamation/hash.ts.
113
+ // ---------------------------------------------------------------------------
114
+ function sha256OfString(value) {
115
+ return (0, hash_1.sha256)(value);
116
+ }
117
+ function sha256OfFile(file) {
118
+ return `sha256:${(0, hash_1.sha256Bytes)(fs.readFileSync(file))}`;
119
+ }
120
+ /** Walk a path and sum file sizes IN-PROCESS. Returns 0 if absent. */
121
+ function dirBytes(p) {
122
+ let stat;
123
+ try {
124
+ stat = fs.statSync(p);
125
+ }
126
+ catch {
127
+ return 0;
128
+ }
129
+ if (stat.isFile())
130
+ return stat.size;
131
+ if (!stat.isDirectory())
132
+ return 0;
133
+ let total = 0;
134
+ for (const entry of fs.readdirSync(p, { withFileTypes: true })) {
135
+ total += dirBytes(path.join(p, entry.name));
136
+ }
137
+ return total;
138
+ }
139
+ /** Stable content digest of a path (file = its bytes; dir = digest over
140
+ * each member's relative path + bytes, sorted). */
141
+ function contentDigest(p) {
142
+ const stat = fs.statSync(p);
143
+ if (stat.isFile())
144
+ return sha256OfFile(p);
145
+ const parts = [];
146
+ const walk = (dir, rel) => {
147
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (0, run_registry_io_1.compareBytes)(a.name, b.name))) {
148
+ const abs = path.join(dir, entry.name);
149
+ const r = path.join(rel, entry.name);
150
+ if (entry.isDirectory())
151
+ walk(abs, r);
152
+ else
153
+ parts.push(`${r}:${sha256OfFile(abs)}`);
154
+ }
155
+ };
156
+ walk(p, "");
157
+ return sha256OfString(parts.join("\n"));
158
+ }
159
+ /** Fail-closed refusal: a real reason reclamation froze nothing. */
160
+ class ReclamationError extends Error {
161
+ code;
162
+ details;
163
+ constructor(code, message, details) {
164
+ super(message);
165
+ this.name = "ReclamationError";
166
+ this.code = code;
167
+ this.details = details;
168
+ }
169
+ }
170
+ exports.ReclamationError = ReclamationError;
171
+ /** Synthetic abort thrown by runReclamation({ faultAfter }) — a TESTABLE
172
+ * crash injection that never kills the process. */
173
+ class ReclamationAbort extends Error {
174
+ step;
175
+ constructor(step) {
176
+ super(`ReclamationAbort after step: ${step}`);
177
+ this.name = "ReclamationAbort";
178
+ this.step = step;
179
+ }
180
+ }
181
+ exports.ReclamationAbort = ReclamationAbort;
182
+ exports.SKELETON_REQUIRED_KEYS = [
183
+ "runId",
184
+ "finalVerdict",
185
+ "commits",
186
+ "evidenceDigests",
187
+ "attestationChain",
188
+ "costRecord",
189
+ "auditLog",
190
+ "collaborationLog",
191
+ "stateDigest",
192
+ ];
193
+ function persistRunDurable(run) {
194
+ run.updatedAt = new Date().toISOString();
195
+ (0, fs_atomic_1.writeJson)(run.paths.state, run, { durable: true });
196
+ }
197
+ function withRunLock(run, fn) {
198
+ return (0, fs_atomic_1.withFileLock)(reclaimedLogPath(run), fn);
199
+ }
200
+ function reclaimedLogPath(run) {
201
+ return path.join(run.paths.runDir, "reclaimed.json");
202
+ }
203
+ /** Fail-OPEN on absence/corruption: a malformed overlay must never brick
204
+ * the run (SPEC/scheduling-registry.md "Rebuild risks" #1). */
205
+ function loadReclamationLog(run) {
206
+ const file = reclaimedLogPath(run);
207
+ if (!fs.existsSync(file))
208
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
209
+ try {
210
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
211
+ if (!parsed || typeof parsed !== "object" || parsed.schemaVersion !== 1 || !Array.isArray(parsed.tombstones)) {
212
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
213
+ }
214
+ return { schemaVersion: 1, runId: run.id, tombstones: parsed.tombstones };
215
+ }
216
+ catch {
217
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
218
+ }
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // Skeleton extraction
222
+ // ---------------------------------------------------------------------------
223
+ function deriveTerminalLifecycle(run) {
224
+ const tasks = run.tasks || [];
225
+ const running = tasks.filter((t) => t.status === "running").length;
226
+ const failed = tasks.filter((t) => t.status === "failed").length;
227
+ const completed = tasks.filter((t) => t.status === "completed").length;
228
+ const total = tasks.length;
229
+ const pending = tasks.filter((t) => t.status === "pending").length;
230
+ const openFeedback = (run.feedback || []).filter((f) => f.status === "open" || f.status === "tasked").length;
231
+ const verifierGated = (run.commits || []).filter((c) => c.verifierGated).length;
232
+ if (running > 0)
233
+ return "running";
234
+ if (openFeedback > 0)
235
+ return "blocked";
236
+ if (failed > 0)
237
+ return "failed";
238
+ if (total > 0 && completed === total)
239
+ return "completed";
240
+ if (verifierGated > 0 && pending === 0)
241
+ return "completed";
242
+ if (completed > 0)
243
+ return "running";
244
+ return "queued";
245
+ }
246
+ function auditEventLogPath(run) {
247
+ return run.audit?.eventLogPath || path.join(run.paths.auditDir || path.join(run.paths.runDir, "audit"), "events.jsonl");
248
+ }
249
+ function digestEvidenceEntry(entry) {
250
+ const ref = entry.locator || entry.path || entry.summary || entry.id;
251
+ if (!ref)
252
+ return undefined;
253
+ const candidatePath = entry.path || entry.locator;
254
+ if (candidatePath && typeof candidatePath === "string" && !candidatePath.includes(":") && fs.existsSync(candidatePath)) {
255
+ try {
256
+ const stat = fs.statSync(candidatePath);
257
+ if (stat.isFile())
258
+ return { ref, digest: sha256OfFile(candidatePath) };
259
+ }
260
+ catch {
261
+ /* fall through to locator digest */
262
+ }
263
+ }
264
+ return { ref, digest: sha256OfString(ref) };
265
+ }
266
+ /** STEP 1: extract + seal the skeleton. Pure read over the run; never
267
+ * mutates. */
268
+ function extractSkeleton(run) {
269
+ const lifecycle = deriveTerminalLifecycle(run);
270
+ const commits = (run.commits || []).map((commit) => ({
271
+ id: commit.id,
272
+ verifierGated: Boolean(commit.verifierGated),
273
+ checkpoint: Boolean(commit.checkpoint),
274
+ candidateId: commit.candidateId,
275
+ selectionId: commit.selectionId,
276
+ verifierNodeId: commit.verifierNodeId,
277
+ evidenceCount: (commit.evidence || []).length,
278
+ acceptanceRationale: commit.acceptanceRationale,
279
+ }));
280
+ const evidenceSources = [];
281
+ for (const node of run.nodes || [])
282
+ for (const e of node.evidence || [])
283
+ evidenceSources.push(e);
284
+ for (const candidate of run.candidates || [])
285
+ for (const e of candidate.evidence || [])
286
+ evidenceSources.push(e);
287
+ for (const selection of run.candidateSelections || [])
288
+ for (const e of selection.evidence || [])
289
+ evidenceSources.push(e);
290
+ for (const commit of run.commits || [])
291
+ for (const e of commit.evidence || [])
292
+ evidenceSources.push(e);
293
+ const evidenceMap = new Map();
294
+ for (const e of evidenceSources) {
295
+ const digested = digestEvidenceEntry(e);
296
+ if (digested)
297
+ evidenceMap.set(digested.ref, digested.digest);
298
+ }
299
+ const evidenceDigests = [...evidenceMap.entries()].map(([ref, digest]) => ({ ref, digest })).sort((a, b) => (0, run_registry_io_1.compareBytes)(a.ref, b.ref));
300
+ const eventLog = auditEventLogPath(run);
301
+ const auditLogDigest = fs.existsSync(eventLog) ? sha256OfFile(eventLog) : sha256OfString("");
302
+ const events = fs.existsSync(eventLog)
303
+ ? fs
304
+ .readFileSync(eventLog, "utf8")
305
+ .split(/\n/g)
306
+ .map((line) => line.trim())
307
+ .filter(Boolean)
308
+ .map((line) => {
309
+ try {
310
+ const e = JSON.parse(line);
311
+ return { id: e.id || "", kind: e.kind || "", decision: e.decision || "", createdAt: e.createdAt || "" };
312
+ }
313
+ catch {
314
+ return { id: "", kind: "malformed", decision: "", createdAt: "" };
315
+ }
316
+ })
317
+ : [];
318
+ const metricsReport = path.join(run.paths.runDir, "metrics", "metrics-report.json");
319
+ const costRecord = {
320
+ tasks: (run.tasks || []).map((task) => ({ taskId: task.id, model: task.model, source: task.agentType })),
321
+ metricsDigest: fs.existsSync(metricsReport) ? sha256OfFile(metricsReport) : undefined,
322
+ };
323
+ const collaboration = run.collaboration;
324
+ const collaborationLog = {
325
+ digest: sha256OfString((0, node_projection_1.replayStableStringify)(collaboration || {})),
326
+ approvals: collaboration?.approvals?.length || 0,
327
+ comments: collaboration?.comments?.length || 0,
328
+ handoffs: collaboration?.handoffs?.length || 0,
329
+ };
330
+ const stateDigest = fs.existsSync(run.paths.state) ? sha256OfFile(run.paths.state) : "";
331
+ return {
332
+ schemaVersion: 1,
333
+ runId: run.id,
334
+ finalVerdict: {
335
+ lifecycle,
336
+ loopStage: run.loopStage,
337
+ terminal: lifecycle === "completed" || lifecycle === "failed",
338
+ commitGated: (run.commits || []).some((c) => c.verifierGated),
339
+ },
340
+ commits,
341
+ evidenceDigests,
342
+ attestationChain: { auditLogDigest, eventCount: events.length, events },
343
+ costRecord,
344
+ auditLog: { path: path.relative(run.paths.runDir, eventLog), digest: auditLogDigest },
345
+ collaborationLog,
346
+ stateDigest,
347
+ };
348
+ }
349
+ function validateSkeleton(skeleton) {
350
+ const missing = [];
351
+ if (!skeleton)
352
+ return [...exports.SKELETON_REQUIRED_KEYS];
353
+ for (const key of exports.SKELETON_REQUIRED_KEYS) {
354
+ const value = skeleton[key];
355
+ if (value === undefined || value === null) {
356
+ missing.push(key);
357
+ continue;
358
+ }
359
+ if (key === "runId" && !String(value).trim())
360
+ missing.push(key);
361
+ if (key === "stateDigest" && !String(value).trim())
362
+ missing.push(key);
363
+ if (key === "finalVerdict" && (typeof value !== "object" || !value.lifecycle))
364
+ missing.push(key);
365
+ if (key === "auditLog" && (typeof value !== "object" || !value.digest))
366
+ missing.push(key);
367
+ if (key === "attestationChain" && (typeof value !== "object" || typeof value.auditLogDigest !== "string"))
368
+ missing.push(key);
369
+ if (key === "commits" && !Array.isArray(value))
370
+ missing.push(key);
371
+ if (key === "evidenceDigests" && !Array.isArray(value))
372
+ missing.push(key);
373
+ }
374
+ return missing;
375
+ }
376
+ /** Refuse if extraction dropped audit content the run actually has. */
377
+ function validateSkeletonAgainstRun(run, skeleton) {
378
+ const failures = [];
379
+ const runCommits = (run.commits || []).length;
380
+ if (runCommits > 0 && skeleton.commits.length !== runCommits) {
381
+ failures.push(`commits-dropped(run=${runCommits},sealed=${skeleton.commits.length})`);
382
+ }
383
+ const runHasEvidence = (run.nodes || []).some((n) => (n.evidence || []).length) ||
384
+ (run.candidates || []).some((c) => (c.evidence || []).length) ||
385
+ (run.candidateSelections || []).some((s) => (s.evidence || []).length) ||
386
+ (run.commits || []).some((c) => (c.evidence || []).length);
387
+ if (runHasEvidence && skeleton.evidenceDigests.length === 0) {
388
+ failures.push("evidence-dropped");
389
+ }
390
+ if (!skeleton.finalVerdict || !skeleton.finalVerdict.lifecycle)
391
+ failures.push("verdict-missing");
392
+ return failures;
393
+ }
394
+ function snapshotProjectionDigest(node) {
395
+ return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
396
+ }
397
+ function nodeBodyDigest(node) {
398
+ return sha256OfString((0, node_projection_1.nodeProjectionDigestInput)(node));
399
+ }
400
+ /** Build the retention plan: which paths are freeable under `policy`, of
401
+ * what kind, how many bytes, and the resulting capability downgrade. */
402
+ function planReclamation(run, policy = {}) {
403
+ const runDir = run.paths.runDir;
404
+ const freeable = [];
405
+ const rel = (abs) => path.relative(runDir, abs);
406
+ // (1) Worker scratch dirs — pure scratch with zero audit value.
407
+ let reclaimedScratch = false;
408
+ if (!policy.keepScratch) {
409
+ for (const scope of run.workers || []) {
410
+ const workerDir = scope.workerDir;
411
+ if (!workerDir || !fs.existsSync(workerDir))
412
+ continue;
413
+ const task = (run.tasks || []).find((t) => t.id === scope.taskId);
414
+ const resultNodeId = scope.resultNodeId || task?.resultNodeId;
415
+ const resultsCopy = task?.resultPath;
416
+ if (!resultNodeId || !resultsCopy || !fs.existsSync(resultsCopy))
417
+ continue;
418
+ const bytes = dirBytes(workerDir);
419
+ if (bytes <= 0)
420
+ continue;
421
+ freeable.push({ path: rel(workerDir), absPath: workerDir, kind: "scratch", bytes, repointResultNodeId: resultNodeId });
422
+ reclaimedScratch = true;
423
+ }
424
+ }
425
+ const repointNodeIds = new Set(freeable.filter((f) => f.repointResultNodeId).map((f) => f.repointResultNodeId));
426
+ // (2) Reconstructable node snapshots.
427
+ let reclaimedSnapshot = false;
428
+ let reconstructableSnapshot = false;
429
+ if (!policy.keepSnapshots) {
430
+ const nodesDir = run.paths.stateNodesDir || path.join(runDir, "nodes");
431
+ const snapshotsRoot = path.join(nodesDir, "snapshots");
432
+ if (fs.existsSync(snapshotsRoot)) {
433
+ for (const nodeDirName of fs.readdirSync(snapshotsRoot, { withFileTypes: true })) {
434
+ if (!nodeDirName.isDirectory())
435
+ continue;
436
+ const nodeDir = path.join(snapshotsRoot, nodeDirName.name);
437
+ for (const file of fs.readdirSync(nodeDir, { withFileTypes: true })) {
438
+ if (!file.isFile() || !file.name.endsWith(".json"))
439
+ continue;
440
+ const snapFile = path.join(nodeDir, file.name);
441
+ let snap;
442
+ try {
443
+ snap = JSON.parse(fs.readFileSync(snapFile, "utf8"));
444
+ }
445
+ catch {
446
+ continue;
447
+ }
448
+ if (!snap || typeof snap !== "object" || typeof snap.nodeId !== "string")
449
+ continue;
450
+ const nodeId = snap.nodeId;
451
+ const node = (run.nodes || []).find((n) => n.id === nodeId);
452
+ if (!node)
453
+ continue;
454
+ if (repointNodeIds.has(node.id))
455
+ continue;
456
+ const bytes = dirBytes(snapFile);
457
+ if (bytes <= 0)
458
+ continue;
459
+ const inputDigest = nodeBodyDigest(node);
460
+ const recipe = {
461
+ recipeKind: "node-snapshot-projection",
462
+ inputDigests: [inputDigest],
463
+ inputsDigest: sha256OfString((0, node_projection_1.replayStableStringify)([inputDigest])),
464
+ expectDigest: snapshotProjectionDigest(node),
465
+ sourceRef: node.id,
466
+ };
467
+ freeable.push({ path: rel(snapFile), absPath: snapFile, kind: "reconstructable-snapshot", bytes, recipe });
468
+ reclaimedSnapshot = true;
469
+ reconstructableSnapshot = true;
470
+ }
471
+ }
472
+ }
473
+ }
474
+ // Determinism (HARD constraint): sort by path BEFORE anything hashes it,
475
+ // so tombstoneHash is reproducible across hosts regardless of
476
+ // fs.readdirSync's filesystem-dependent order.
477
+ freeable.sort((a, b) => (0, run_registry_io_1.compareBytes)(a.path, b.path));
478
+ const byKind = {};
479
+ let bytesToFree = 0;
480
+ for (const entry of freeable) {
481
+ byKind[entry.kind] = (byKind[entry.kind] || 0) + entry.bytes;
482
+ bytesToFree += entry.bytes;
483
+ }
484
+ let capability = "re-runnable";
485
+ let capabilityReason = "scratch-only-reclaimed";
486
+ if (reclaimedSnapshot && reconstructableSnapshot) {
487
+ capability = "re-runnable-by-reconstruction";
488
+ capabilityReason = "inputs-and-expectdigest-retained";
489
+ }
490
+ else if (reclaimedSnapshot) {
491
+ capability = "verify-only";
492
+ capabilityReason = "snapshot-reclaimed-no-reconstruction";
493
+ }
494
+ else if (reclaimedScratch) {
495
+ capability = "re-runnable";
496
+ capabilityReason = "scratch-only-reclaimed";
497
+ }
498
+ return { freeable, bytesToFree, byKind, capability, capabilityReason };
499
+ }
500
+ // ---------------------------------------------------------------------------
501
+ // Tombstone construction + hash chain
502
+ // ---------------------------------------------------------------------------
503
+ function policyDigestOf(policy) {
504
+ return sha256OfString((0, node_projection_1.replayStableStringify)(policy));
505
+ }
506
+ /** genesis prevTombstoneHash = sha256 of the sealed skeleton. */
507
+ function genesisPrevHash(skeleton) {
508
+ return sha256OfString((0, node_projection_1.replayStableStringify)(skeleton));
509
+ }
510
+ function tombstoneHashInput(t) {
511
+ return (0, node_projection_1.replayStableStringify)({
512
+ runId: t.runId,
513
+ tombstoneId: t.tombstoneId,
514
+ reclaimedAt: t.reclaimedAt,
515
+ actor: t.actor || null,
516
+ policyDigest: t.policyDigest,
517
+ freed: t.freed.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes, sha256: f.sha256, recipe: f.recipe || null })),
518
+ bytesFreed: t.bytesFreed,
519
+ skeletonDigest: sha256OfString((0, node_projection_1.replayStableStringify)(t.skeleton)),
520
+ capability: t.capability,
521
+ capabilityReason: t.capabilityReason,
522
+ prevTombstoneHash: t.prevTombstoneHash,
523
+ });
524
+ }
525
+ function computeTombstoneHash(t) {
526
+ return sha256OfString(tombstoneHashInput(t));
527
+ }
528
+ function tombstoneId(seq) {
529
+ return `tomb-${String(seq).padStart(3, "0")}`;
530
+ }
531
+ /** STEP 2: build the FULL tombstone (pre-deletion sha256 per freed path +
532
+ * the hash chain). Reads the freed files (still present); mutates
533
+ * nothing on disk. */
534
+ function buildTombstone(run, skeleton, plan, options = {}) {
535
+ const now = options.now || new Date().toISOString();
536
+ const prior = loadReclamationLog(run).tombstones;
537
+ const prevTombstoneHash = prior.length ? prior[prior.length - 1].tombstoneHash : genesisPrevHash(skeleton);
538
+ const freed = plan.freeable.map((entry) => ({
539
+ path: entry.path,
540
+ kind: entry.kind,
541
+ bytes: entry.bytes,
542
+ sha256: contentDigest(entry.absPath),
543
+ recipe: entry.recipe,
544
+ }));
545
+ const base = {
546
+ schemaVersion: 1,
547
+ runId: run.id,
548
+ tombstoneId: tombstoneId(prior.length + 1),
549
+ reclaimedAt: now,
550
+ actor: options.actor,
551
+ policyDigest: policyDigestOf(options.policy || {}),
552
+ freed,
553
+ bytesFreed: freed.reduce((sum, f) => sum + f.bytes, 0),
554
+ skeleton,
555
+ capability: plan.capability,
556
+ capabilityReason: plan.capabilityReason,
557
+ prevTombstoneHash,
558
+ };
559
+ return { ...base, tombstoneHash: computeTombstoneHash(base) };
560
+ }
561
+ /** STEP 3: commit the tombstone DURABLY into the append-only overlay
562
+ * (temp -> fsync -> rename) and record the attestation. No byte is freed
563
+ * here — write-ahead order is the safety property. */
564
+ function commitTombstone(run, tombstone) {
565
+ const log = loadReclamationLog(run);
566
+ log.tombstones.push(tombstone);
567
+ (0, fs_atomic_1.writeJson)(reclaimedLogPath(run), log, { durable: true });
568
+ try {
569
+ (0, trust_audit_1.recordTrustAuditEvent)(run, {
570
+ kind: "run.reclamation",
571
+ decision: "recorded",
572
+ source: "cw-validated",
573
+ metadata: {
574
+ tombstoneId: tombstone.tombstoneId,
575
+ tombstoneHash: tombstone.tombstoneHash,
576
+ prevTombstoneHash: tombstone.prevTombstoneHash,
577
+ bytesFreed: tombstone.bytesFreed,
578
+ freedPaths: tombstone.freed.length,
579
+ capability: tombstone.capability,
580
+ capabilityReason: tombstone.capabilityReason,
581
+ actor: tombstone.actor,
582
+ },
583
+ });
584
+ }
585
+ catch {
586
+ // The tombstone is already durable; an audit-append hiccup must not unwind it.
587
+ }
588
+ }
589
+ /** STEP 4: re-point every surviving node's artifacts off the scratch
590
+ * paths about to vanish, DURABLY persist that state.json change, and
591
+ * PROVE no surviving node still references a freed path — BEFORE a
592
+ * single byte is freed. Fail closed (`repoint-incomplete`) if the proof
593
+ * does not hold. */
594
+ function prepareFree(run, tombstone) {
595
+ const runDir = run.paths.runDir;
596
+ const scratchDirs = tombstone.freed.filter((f) => f.kind === "scratch").map((f) => (0, fs_atomic_1.realResolve)(path.join(runDir, f.path)));
597
+ if (!scratchDirs.length)
598
+ return;
599
+ const repointed = new Set();
600
+ for (const scratchDir of scratchDirs) {
601
+ for (const id of repointResultNodeArtifacts(run, scratchDir))
602
+ repointed.add(id);
603
+ }
604
+ persistRunDurable(run);
605
+ for (const node of run.nodes || []) {
606
+ for (const artifact of node.artifacts || []) {
607
+ if (!artifact.path)
608
+ continue;
609
+ const resolved = (0, fs_atomic_1.realResolve)(artifact.path);
610
+ for (const scratchDir of scratchDirs) {
611
+ if (resolved === scratchDir || resolved.startsWith(scratchDir + path.sep)) {
612
+ throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed scratch path ${artifact.path}`, { nodeId: node.id, artifactId: artifact.id, path: artifact.path });
613
+ }
614
+ }
615
+ }
616
+ }
617
+ for (const nodeId of repointed) {
618
+ try {
619
+ const fresh = (0, node_store_1.snapshotNode)(run, nodeId, { persist: false });
620
+ const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
621
+ if (freshness === "absent") {
622
+ throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
623
+ }
624
+ }
625
+ catch (error) {
626
+ if (error instanceof ReclamationError)
627
+ throw error;
628
+ throw new ReclamationError("repoint-incomplete", `could not prove re-pointed node ${nodeId} stays valid: ${error.message}`, {
629
+ nodeId,
630
+ });
631
+ }
632
+ }
633
+ }
634
+ /** STEP 5: free the bulk DATA bytes. Pure deletion — every re-point is
635
+ * already done and DURABLY persisted by prepareFree(). */
636
+ function freeBulk(run, tombstone) {
637
+ const runDir = run.paths.runDir;
638
+ let freedBytes = 0;
639
+ for (const entry of tombstone.freed) {
640
+ const abs = path.join(runDir, entry.path);
641
+ const before = dirBytes(abs);
642
+ fs.rmSync(abs, { recursive: true, force: true });
643
+ freedBytes += before;
644
+ }
645
+ return freedBytes;
646
+ }
647
+ function repointResultNodeArtifacts(run, freedScratchDir) {
648
+ const freedReal = (0, fs_atomic_1.realResolve)(freedScratchDir);
649
+ const freedPrefix = freedReal + path.sep;
650
+ const changedIds = [];
651
+ for (const node of run.nodes || []) {
652
+ if (!node.artifacts)
653
+ continue;
654
+ let changed = false;
655
+ for (const artifact of node.artifacts) {
656
+ if (!artifact.path)
657
+ continue;
658
+ const resolved = (0, fs_atomic_1.realResolve)(artifact.path);
659
+ if (resolved === freedReal || resolved.startsWith(freedPrefix)) {
660
+ const retained = node.artifacts.find((a) => a.id === "result" && a.path && fs.existsSync(a.path));
661
+ if (retained && retained.path) {
662
+ artifact.path = retained.path;
663
+ changed = true;
664
+ }
665
+ }
666
+ }
667
+ if (changed) {
668
+ node.updatedAt = new Date().toISOString();
669
+ changedIds.push(node.id);
670
+ }
671
+ }
672
+ return changedIds;
673
+ }
674
+ /** Execute the write-ahead, fail-closed reclamation transaction. */
675
+ function runReclamation(run, options = {}) {
676
+ const skeleton = extractSkeleton(run);
677
+ const missing = validateSkeleton(skeleton);
678
+ if (missing.length) {
679
+ throw new ReclamationError("skeleton-incomplete", `Skeleton missing required keys: ${missing.join(", ")}`, { missing });
680
+ }
681
+ const contentLoss = validateSkeletonAgainstRun(run, skeleton);
682
+ if (contentLoss.length) {
683
+ throw new ReclamationError("skeleton-incomplete", `Skeleton dropped audit content: ${contentLoss.join(", ")}`, { contentLoss });
684
+ }
685
+ if (options.faultAfter === "skeleton")
686
+ throw new ReclamationAbort("skeleton");
687
+ const { plan, tombstone } = withRunLock(run, () => {
688
+ const builtPlan = planReclamation(run, options.reclaimPolicy || {});
689
+ const builtTombstone = buildTombstone(run, skeleton, builtPlan, { now: options.now, actor: options.actor, policy: options.policy });
690
+ if (options.faultAfter === "tombstone-write")
691
+ throw new ReclamationAbort("tombstone-write");
692
+ commitTombstone(run, builtTombstone);
693
+ return { plan: builtPlan, tombstone: builtTombstone };
694
+ });
695
+ if (options.faultAfter === "tombstone-commit")
696
+ throw new ReclamationAbort("tombstone-commit");
697
+ prepareFree(run, tombstone);
698
+ const bytesFreed = freeBulk(run, tombstone);
699
+ return { tombstone, bytesFreed, plan };
700
+ }
701
+ // ---------------------------------------------------------------------------
702
+ // Reconstruction + verification
703
+ // ---------------------------------------------------------------------------
704
+ function reconstructArtifact(run, recipe) {
705
+ if (recipe.recipeKind === "node-snapshot-projection") {
706
+ const node = (run.nodes || []).find((n) => n.id === recipe.sourceRef);
707
+ if (!node) {
708
+ return { inputsDigest: sha256OfString("absent"), expectDigest: sha256OfString("absent") };
709
+ }
710
+ const inputDigest = nodeBodyDigest(node);
711
+ const inputsDigest = sha256OfString((0, node_projection_1.replayStableStringify)([inputDigest]));
712
+ const expectDigest = snapshotProjectionDigest(node);
713
+ return { inputsDigest, expectDigest };
714
+ }
715
+ return { inputsDigest: sha256OfString("unknown-recipe"), expectDigest: sha256OfString("unknown-recipe") };
716
+ }
717
+ /** Re-prove the whole reclamation chain for a run. Recomputes every hash
718
+ * independently — never trusts the stored value. */
719
+ function verifyReclamation(run) {
720
+ const log = loadReclamationLog(run);
721
+ const tombstones = log.tombstones;
722
+ const checks = [];
723
+ if (!tombstones.length) {
724
+ return { reclaimed: false, verified: false, checks: [{ name: "reclaimed", pass: false, code: "not-reclaimed" }], tombstones };
725
+ }
726
+ let chainOk = true;
727
+ for (let i = 0; i < tombstones.length; i++) {
728
+ const expectedPrev = i === 0 ? genesisPrevHash(tombstones[0].skeleton) : tombstones[i - 1].tombstoneHash;
729
+ const pass = tombstones[i].prevTombstoneHash === expectedPrev;
730
+ if (!pass)
731
+ chainOk = false;
732
+ checks.push({ name: `chain-link[${i}]`, pass, code: pass ? undefined : "tombstone-chain-broken" });
733
+ }
734
+ let digestsOk = true;
735
+ for (let i = 0; i < tombstones.length; i++) {
736
+ const { tombstoneHash, ...rest } = tombstones[i];
737
+ const recomputed = computeTombstoneHash(rest);
738
+ const pass = recomputed === tombstoneHash;
739
+ if (!pass)
740
+ digestsOk = false;
741
+ checks.push({ name: `tombstone-hash[${i}]`, pass, code: pass ? undefined : "tombstone-digest-mismatch" });
742
+ }
743
+ let skeletonOk = true;
744
+ for (let i = 0; i < tombstones.length; i++) {
745
+ const missing = validateSkeleton(tombstones[i].skeleton);
746
+ const pass = missing.length === 0;
747
+ if (!pass)
748
+ skeletonOk = false;
749
+ checks.push({ name: `skeleton[${i}]`, pass, code: pass ? undefined : "skeleton-incomplete", detail: missing.join(",") || undefined });
750
+ }
751
+ let reconstructionOk = true;
752
+ for (let i = 0; i < tombstones.length; i++) {
753
+ for (const entry of tombstones[i].freed) {
754
+ if (!entry.recipe)
755
+ continue;
756
+ const recomputed = reconstructArtifact(run, entry.recipe);
757
+ const inputsMatch = recomputed.inputsDigest === entry.recipe.inputsDigest;
758
+ const expectMatch = recomputed.expectDigest === entry.recipe.expectDigest;
759
+ const pass = inputsMatch && expectMatch;
760
+ if (!pass)
761
+ reconstructionOk = false;
762
+ checks.push({
763
+ name: `reconstruct[${i}]:${entry.path}`,
764
+ pass,
765
+ code: pass ? undefined : "reconstruction-digest-mismatch",
766
+ detail: pass ? undefined : `inputs=${inputsMatch} expect=${expectMatch}`,
767
+ });
768
+ }
769
+ }
770
+ const verified = chainOk && digestsOk && skeletonOk && reconstructionOk;
771
+ return { reclaimed: true, verified, checks, tombstones };
772
+ }
773
+ function reclamationPolicy(overrides = {}) {
774
+ return { ...run_registry_io_1.DEFAULT_RUN_REGISTRY_POLICY, ...overrides };
775
+ }
776
+ /** Fail-closed eligibility, checked IN ORDER (SPEC "Rebuild risks" #6):
777
+ * already-reclaimed -> non-terminal -> open-feedback -> not-archived ->
778
+ * within-retention. `null` means eligible. */
779
+ function reclaimEligibility(record, policy, nowMs) {
780
+ if (record.tier === "reclaimed")
781
+ return "already-reclaimed";
782
+ const terminalStates = policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"];
783
+ if (record.derivedLifecycle !== "completed" && record.derivedLifecycle !== "failed")
784
+ return "non-terminal";
785
+ if (!terminalStates.includes(record.derivedLifecycle))
786
+ return "non-terminal";
787
+ if (record.openFeedbackCount > 0)
788
+ return "open-feedback";
789
+ if (!record.archived)
790
+ return "not-archived";
791
+ const days = policy.reclaimAfterArchiveDays ?? 0;
792
+ if (days > 0) {
793
+ const archivedAtMs = record.archivedAt ? Date.parse(record.archivedAt) : NaN;
794
+ if (!Number.isFinite(archivedAtMs))
795
+ return "within-retention";
796
+ if (archivedAtMs > nowMs - days * 24 * 60 * 60 * 1000)
797
+ return "within-retention";
798
+ }
799
+ return null;
800
+ }
801
+ function recordsForRunId(host, runId, scope) {
802
+ const located = host.locate(runId, scope);
803
+ return located ? [located.record] : [];
804
+ }
805
+ function gcPlan(host, options = {}) {
806
+ const scope = options.scope || "home";
807
+ const policy = reclamationPolicy(options.policy);
808
+ const nowIso = options.now || new Date().toISOString();
809
+ const nowMs = Date.parse(nowIso);
810
+ const records = options.runId ? recordsForRunId(host, options.runId, scope) : host.buildIndex(scope).records;
811
+ const entries = [];
812
+ let bytesToFree = 0;
813
+ let eligibleCount = 0;
814
+ for (const record of records) {
815
+ const refusal = reclaimEligibility(record, policy, nowMs);
816
+ let plan;
817
+ try {
818
+ const run = host.loadRun(record.repo, record.runId);
819
+ plan = planReclamation(run, { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots });
820
+ }
821
+ catch {
822
+ entries.push({
823
+ runId: record.runId,
824
+ repo: record.repo,
825
+ eligible: false,
826
+ reason: "unreadable",
827
+ tier: record.tier || "live",
828
+ capability: record.capability || "re-runnable",
829
+ capabilityReason: record.capabilityReason || "live-full",
830
+ bytesToFree: 0,
831
+ byKind: {},
832
+ freeable: [],
833
+ });
834
+ continue;
835
+ }
836
+ const eligible = refusal === null;
837
+ const entry = {
838
+ runId: record.runId,
839
+ repo: record.repo,
840
+ eligible,
841
+ reason: eligible ? "eligible" : refusal,
842
+ tier: record.tier || "live",
843
+ capability: plan.capability,
844
+ capabilityReason: plan.capabilityReason,
845
+ bytesToFree: eligible ? plan.bytesToFree : 0,
846
+ byKind: eligible ? plan.byKind : {},
847
+ freeable: eligible ? plan.freeable.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes })) : [],
848
+ };
849
+ entries.push(entry);
850
+ if (eligible) {
851
+ eligibleCount += 1;
852
+ bytesToFree += plan.bytesToFree;
853
+ }
854
+ }
855
+ return {
856
+ schemaVersion: 1,
857
+ scope,
858
+ generatedAt: nowIso,
859
+ policy: {
860
+ reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays ?? 0,
861
+ keepSnapshots: Boolean(policy.keepSnapshots),
862
+ keepScratch: Boolean(policy.keepScratch),
863
+ reclaimStates: policy.reclaimStates && policy.reclaimStates.length ? policy.reclaimStates : ["completed", "failed"],
864
+ },
865
+ total: entries.length,
866
+ eligibleCount,
867
+ bytesToFree,
868
+ entries,
869
+ nextAction: eligibleCount ? "node scripts/cw.js gc run" : "node scripts/cw.js run search",
870
+ };
871
+ }
872
+ function gcRun(host, options = {}) {
873
+ const scope = options.scope || "home";
874
+ const policy = reclamationPolicy(options.policy);
875
+ const nowIso = options.now || new Date().toISOString();
876
+ const nowMs = Date.parse(nowIso);
877
+ const records = options.runId ? recordsForRunId(host, options.runId, scope) : host.buildIndex(scope).records;
878
+ const maxRuns = options.limit ?? (policy.maxReclaimRuns || 0);
879
+ const maxBytes = policy.maxReclaimBytes || 0;
880
+ const reclaimed = [];
881
+ const refused = [];
882
+ let totalBytesFreed = 0;
883
+ for (const record of records) {
884
+ const refusal = reclaimEligibility(record, policy, nowMs);
885
+ if (refusal) {
886
+ refused.push({ runId: record.runId, code: refusal });
887
+ continue;
888
+ }
889
+ if (maxRuns > 0 && reclaimed.length >= maxRuns)
890
+ break;
891
+ let run;
892
+ try {
893
+ run = host.loadRun(record.repo, record.runId);
894
+ }
895
+ catch {
896
+ refused.push({ runId: record.runId, code: "unreadable" });
897
+ continue;
898
+ }
899
+ try {
900
+ const result = runReclamation(run, {
901
+ now: nowIso,
902
+ actor: options.actor,
903
+ policy: { reclaimAfterArchiveDays: policy.reclaimAfterArchiveDays, keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
904
+ reclaimPolicy: { keepScratch: policy.keepScratch, keepSnapshots: policy.keepSnapshots },
905
+ });
906
+ reclaimed.push({
907
+ runId: record.runId,
908
+ bytesFreed: result.bytesFreed,
909
+ tombstoneHash: result.tombstone.tombstoneHash,
910
+ capability: result.tombstone.capability,
911
+ capabilityReason: result.tombstone.capabilityReason,
912
+ });
913
+ (0, trust_audit_1.recordTrustAuditEvent)(run, {
914
+ kind: "run.reclaimed",
915
+ decision: "recorded",
916
+ source: "cw-validated",
917
+ metadata: { tombstoneHash: result.tombstone.tombstoneHash, bytesFreed: result.bytesFreed, capability: result.tombstone.capability },
918
+ });
919
+ totalBytesFreed += result.bytesFreed;
920
+ if (maxBytes > 0 && totalBytesFreed >= maxBytes)
921
+ break;
922
+ }
923
+ catch (error) {
924
+ if (error instanceof ReclamationError)
925
+ refused.push({ runId: record.runId, code: error.code });
926
+ else
927
+ throw error;
928
+ }
929
+ }
930
+ return {
931
+ schemaVersion: 1,
932
+ scope,
933
+ generatedAt: nowIso,
934
+ dryRun: false,
935
+ reclaimed,
936
+ refused,
937
+ totalBytesFreed,
938
+ nextAction: reclaimed.length ? "node scripts/cw.js gc verify <run-id>" : "node scripts/cw.js gc plan",
939
+ };
940
+ }
941
+ function gcVerify(host, runId, options = {}) {
942
+ const scope = options.scope || "home";
943
+ const located = host.locate(runId, scope);
944
+ if (!located) {
945
+ return {
946
+ schemaVersion: 1,
947
+ runId,
948
+ reclaimed: false,
949
+ verified: false,
950
+ tier: "live",
951
+ capability: "re-runnable",
952
+ chainLength: 0,
953
+ checks: [{ name: "located", pass: false, code: "not-reclaimed", detail: "run source not found" }],
954
+ nextAction: "node scripts/cw.js registry refresh" + (scope === "home" ? " --scope home" : ""),
955
+ };
956
+ }
957
+ const run = host.loadRun(located.record.repo, runId);
958
+ const result = verifyReclamation(run);
959
+ const checks = result.checks.map((c) => ({ name: c.name, pass: c.pass, code: c.code, detail: c.detail }));
960
+ let eligibleWhenReclaimed = result.reclaimed;
961
+ for (const tombstone of result.tombstones) {
962
+ const terminal = tombstone.skeleton.finalVerdict?.terminal === true;
963
+ if (!terminal) {
964
+ eligibleWhenReclaimed = false;
965
+ checks.push({ name: `eligible-when-reclaimed:${tombstone.tombstoneId}`, pass: false, code: "ineligible-when-reclaimed", detail: "non-terminal verdict sealed" });
966
+ }
967
+ }
968
+ const last = result.tombstones[result.tombstones.length - 1];
969
+ const witnessed = (0, trust_audit_1.listTrustAuditEvents)(run).some((event) => event.kind === "run.reclaimed");
970
+ const proofDeleted = witnessed && !result.reclaimed;
971
+ if (proofDeleted) {
972
+ checks.push({ name: "reclaim-witness", pass: false, code: "reclaim-proof-deleted", detail: "trust-audit attests reclamation but reclaimed.json is missing/empty" });
973
+ }
974
+ const reclaimed = result.reclaimed || proofDeleted;
975
+ const verified = result.verified && eligibleWhenReclaimed && !proofDeleted;
976
+ return {
977
+ schemaVersion: 1,
978
+ runId,
979
+ reclaimed,
980
+ verified,
981
+ tier: located.record.tier || (reclaimed ? "reclaimed" : "live"),
982
+ capability: located.record.capability || "re-runnable",
983
+ capabilityReason: located.record.capabilityReason,
984
+ tombstoneHash: last?.tombstoneHash,
985
+ chainLength: result.tombstones.length,
986
+ checks,
987
+ nextAction: verified ? "node scripts/cw.js run show " + runId : "node scripts/cw.js gc plan",
988
+ };
989
+ }
990
+ // ---------------------------------------------------------------------------
991
+ // Human formatting for gc (CLI-only)
992
+ // ---------------------------------------------------------------------------
993
+ function formatGcPlan(result) {
994
+ const lines = [
995
+ `GC Plan (${result.scope}): ${result.eligibleCount}/${result.total} eligible, ${result.bytesToFree} byte(s) would be freed [DRY-RUN, frees nothing]`,
996
+ ` policy: reclaimAfterArchiveDays=${result.policy.reclaimAfterArchiveDays} keepScratch=${result.policy.keepScratch} keepSnapshots=${result.policy.keepSnapshots}`,
997
+ ];
998
+ for (const entry of result.entries) {
999
+ if (entry.eligible) {
1000
+ const kinds = Object.entries(entry.byKind)
1001
+ .map(([k, v]) => `${k}=${v}`)
1002
+ .join(" ");
1003
+ lines.push(` [eligible] ${entry.runId} -> ${entry.capability} (${entry.capabilityReason}) ${entry.bytesToFree}B {${kinds}}`);
1004
+ }
1005
+ else {
1006
+ lines.push(` [skip:${entry.reason}] ${entry.runId} (tier=${entry.tier})`);
1007
+ }
1008
+ }
1009
+ if (!result.entries.length)
1010
+ lines.push(" (no runs in scope)");
1011
+ return lines.join("\n");
1012
+ }
1013
+ function formatGcRun(result) {
1014
+ const lines = [`GC Run (${result.scope}): reclaimed ${result.reclaimed.length} run(s), freed ${result.totalBytesFreed} byte(s)`];
1015
+ for (const r of result.reclaimed)
1016
+ lines.push(` [reclaimed] ${r.runId} -> ${r.capability} (${r.capabilityReason}) ${r.bytesFreed}B tombstone=${r.tombstoneHash.slice(0, 19)}`);
1017
+ for (const r of result.refused)
1018
+ lines.push(` [refused:${r.code}] ${r.runId}`);
1019
+ if (!result.reclaimed.length && !result.refused.length)
1020
+ lines.push(" (nothing eligible)");
1021
+ return lines.join("\n");
1022
+ }
1023
+ function formatGcVerify(result) {
1024
+ const lines = [
1025
+ `GC Verify ${result.runId}: reclaimed=${result.reclaimed} verified=${result.verified} tier=${result.tier} capability=${result.capability}${result.tombstoneHash ? ` tombstone=${result.tombstoneHash.slice(0, 19)}` : ""}`,
1026
+ ];
1027
+ for (const check of result.checks)
1028
+ lines.push(` ${check.pass ? "PASS" : "FAIL"} ${check.name}${check.code ? ` [${check.code}]` : ""}${check.detail ? ` (${check.detail})` : ""}`);
1029
+ return lines.join("\n");
1030
+ }
1031
+ exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES = 60;
1032
+ function resolveNowMs(now) {
1033
+ if (now === undefined)
1034
+ return Date.now();
1035
+ const ms = new Date(now).getTime();
1036
+ if (!Number.isFinite(ms))
1037
+ throw new Error(`--now must be a valid ISO date (got ${now})`);
1038
+ return ms;
1039
+ }
1040
+ /** Walk a directory tree; return total bytes + the newest mtime found
1041
+ * anywhere in it (including the directory itself). Best-effort. */
1042
+ function walk(dir) {
1043
+ let bytes = 0;
1044
+ let newestMs = 0;
1045
+ const bump = (p) => {
1046
+ let st;
1047
+ try {
1048
+ st = fs.lstatSync(p);
1049
+ }
1050
+ catch {
1051
+ return;
1052
+ }
1053
+ if (st.mtimeMs > newestMs)
1054
+ newestMs = st.mtimeMs;
1055
+ if (st.isDirectory()) {
1056
+ let names;
1057
+ try {
1058
+ names = fs.readdirSync(p);
1059
+ }
1060
+ catch {
1061
+ return;
1062
+ }
1063
+ for (const name of names)
1064
+ bump(path.join(p, name));
1065
+ }
1066
+ else {
1067
+ bytes += st.size;
1068
+ }
1069
+ };
1070
+ bump(dir);
1071
+ return { bytes, newestMs };
1072
+ }
1073
+ function runsDirFor(repo) {
1074
+ return path.join(repo, ".cw", "runs");
1075
+ }
1076
+ function candidatesFor(repo, knownDirs, nowMs) {
1077
+ const runsDir = runsDirFor(repo);
1078
+ let dirents;
1079
+ try {
1080
+ dirents = fs.readdirSync(runsDir, { withFileTypes: true });
1081
+ }
1082
+ catch {
1083
+ return [];
1084
+ }
1085
+ const out = [];
1086
+ for (const entry of dirents) {
1087
+ if (!entry.isDirectory())
1088
+ continue;
1089
+ const dir = path.join(runsDir, entry.name);
1090
+ if (knownDirs.has(path.resolve(dir)))
1091
+ continue;
1092
+ if (fs.existsSync(path.join(dir, "state.json")))
1093
+ continue; // gc.ts's territory
1094
+ const { bytes, newestMs } = walk(dir);
1095
+ const ageMinutes = Math.max(0, Math.round((nowMs - newestMs) / 60000));
1096
+ out.push({ repo, runId: entry.name, path: dir, ageMinutes, bytes });
1097
+ }
1098
+ return out;
1099
+ }
1100
+ function scan(host, scope, nowMs) {
1101
+ const index = host.buildIndex(scope);
1102
+ const known = new Set(index.records.map((r) => path.resolve(r.runDir)));
1103
+ const entries = [];
1104
+ for (const repo of index.repos)
1105
+ entries.push(...candidatesFor(repo, known, nowMs));
1106
+ return { repos: index.repos, entries };
1107
+ }
1108
+ /** `cw orphans list` (read-only). */
1109
+ function listOrphanRuns(host, options = {}) {
1110
+ const scope = options.scope || "home";
1111
+ const { repos, entries } = scan(host, scope, resolveNowMs(options.now));
1112
+ return {
1113
+ schemaVersion: 1,
1114
+ scope,
1115
+ repos,
1116
+ count: entries.length,
1117
+ totalBytes: entries.reduce((sum, e) => sum + e.bytes, 0),
1118
+ entries,
1119
+ };
1120
+ }
1121
+ /** `cw orphans gc [--min-age-minutes N] [--all]` — reclaim orphan run
1122
+ * directories. The re-check (state.json still absent) and the delete
1123
+ * run inside the SAME `state.json.lock` held by `saveCheckpoint`, via
1124
+ * `withFileLock` reused directly from shell/fs-atomic.ts. */
1125
+ function gcOrphanRuns(host, options = {}) {
1126
+ const scope = options.scope || "home";
1127
+ const all = Boolean(options.all);
1128
+ let minAgeMinutes = null;
1129
+ if (!all) {
1130
+ minAgeMinutes = options.minAgeMinutes ?? exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES;
1131
+ if (!Number.isFinite(minAgeMinutes) || minAgeMinutes < 0) {
1132
+ throw new Error(`--min-age-minutes must be a non-negative number (got ${String(options.minAgeMinutes)})`);
1133
+ }
1134
+ }
1135
+ const nowMs = resolveNowMs(options.now);
1136
+ const { entries } = scan(host, scope, nowMs);
1137
+ const removed = [];
1138
+ let freedBytes = 0;
1139
+ for (const entry of entries) {
1140
+ if (!all && entry.ageMinutes < minAgeMinutes)
1141
+ continue;
1142
+ const runsDirResolved = path.resolve(runsDirFor(entry.repo));
1143
+ const resolved = path.resolve(entry.path);
1144
+ if (!resolved.startsWith(runsDirResolved + path.sep))
1145
+ continue; // containment, fail closed
1146
+ const statePath = path.join(resolved, "state.json");
1147
+ const deleted = (0, fs_atomic_1.withFileLock)(statePath, () => {
1148
+ if (fs.existsSync(statePath))
1149
+ return false; // a checkpoint landed between scan and here
1150
+ fs.rmSync(resolved, { recursive: true, force: true });
1151
+ return true;
1152
+ });
1153
+ if (!deleted)
1154
+ continue;
1155
+ removed.push({ repo: entry.repo, runId: entry.runId, path: entry.path, bytes: entry.bytes });
1156
+ freedBytes += entry.bytes;
1157
+ }
1158
+ return {
1159
+ schemaVersion: 1,
1160
+ scope,
1161
+ removed,
1162
+ freedBytes,
1163
+ keptCount: entries.length - removed.length,
1164
+ minAgeMinutes,
1165
+ all,
1166
+ };
1167
+ }
1168
+ function humanBytesLocal(n) {
1169
+ if (n < 1024)
1170
+ return `${n}B`;
1171
+ const units = ["KiB", "MiB", "GiB"];
1172
+ let v = n / 1024;
1173
+ let i = 0;
1174
+ while (v >= 1024 && i < units.length - 1) {
1175
+ v /= 1024;
1176
+ i += 1;
1177
+ }
1178
+ return `${v.toFixed(1)}${units[i]}`;
1179
+ }
1180
+ function formatOrphanRunsList(result) {
1181
+ if (!result.count)
1182
+ return `No orphan run(s) (${result.scope}): every ".cw/runs/" entry across ${result.repos.length} repo(s) is known to the registry.`;
1183
+ const lines = [`Orphan Runs (${result.scope}): ${result.count} in ${result.repos.length} repo(s), ${humanBytesLocal(result.totalBytes)} total`];
1184
+ for (const e of result.entries)
1185
+ lines.push(` ${e.runId} (${e.repo}) age=${e.ageMinutes}m ${humanBytesLocal(e.bytes)}`);
1186
+ lines.push(`\nReclaim with: cw orphans gc --min-age-minutes ${exports.DEFAULT_ORPHAN_MIN_AGE_MINUTES} (or --all)`);
1187
+ return lines.join("\n");
1188
+ }
1189
+ function formatOrphanRunsGc(result) {
1190
+ const scope = result.all ? "all orphan candidates" : `orphans older than ${result.minAgeMinutes} minute(s)`;
1191
+ if (!result.removed.length)
1192
+ return `Nothing to reclaim (${scope}); ${result.keptCount} kept (${result.scope}).`;
1193
+ const lines = [`Reclaimed ${result.removed.length} orphan run(s) (${scope}) — freed ${humanBytesLocal(result.freedBytes)}; ${result.keptCount} kept`];
1194
+ for (const r of result.removed)
1195
+ lines.push(` ${r.runId} (${r.repo}) ${humanBytesLocal(r.bytes)}`);
1196
+ return lines.join("\n");
1197
+ }
1198
+ function isTrue(value) {
1199
+ return value === true || value === "true" || value === "1" || value === 1;
1200
+ }
1201
+ function optionalNumber(value) {
1202
+ if (value === undefined || value === null || value === "")
1203
+ return undefined;
1204
+ const n = Number(value);
1205
+ return Number.isFinite(n) ? n : undefined;
1206
+ }
1207
+ function clonesRoot(env = process.env) {
1208
+ return path.join(resolveCwHomeForClones(env), "clones");
1209
+ }
1210
+ // Local, tiny re-implementation of run-registry-io.ts's resolveCwHome so
1211
+ // this module has no import-time dependency on that file beyond the type
1212
+ // re-exports already pulled in above (keeps the module boundary the same
1213
+ // shape as the old build's clones.ts -> run-registry.ts single-function
1214
+ // import).
1215
+ function resolveCwHomeForClones(env) {
1216
+ if (env.CW_HOME && String(env.CW_HOME).trim())
1217
+ return path.resolve(String(env.CW_HOME));
1218
+ if (env.XDG_STATE_HOME && String(env.XDG_STATE_HOME).trim()) {
1219
+ return path.join(path.resolve(String(env.XDG_STATE_HOME)), "cool-workflow");
1220
+ }
1221
+ return path.join(os.homedir(), ".local", "state", "cool-workflow");
1222
+ }
1223
+ function dirSize(dir) {
1224
+ let total = 0;
1225
+ const walkDir = (d) => {
1226
+ let names;
1227
+ try {
1228
+ names = fs.readdirSync(d);
1229
+ }
1230
+ catch {
1231
+ return;
1232
+ }
1233
+ for (const name of names) {
1234
+ const p = path.join(d, name);
1235
+ let st;
1236
+ try {
1237
+ st = fs.lstatSync(p);
1238
+ }
1239
+ catch {
1240
+ continue;
1241
+ }
1242
+ if (st.isDirectory())
1243
+ walkDir(p);
1244
+ else
1245
+ total += st.size;
1246
+ }
1247
+ };
1248
+ walkDir(dir);
1249
+ return total;
1250
+ }
1251
+ function readCloneEntries(root) {
1252
+ let names = [];
1253
+ try {
1254
+ names = fs.readdirSync(root);
1255
+ }
1256
+ catch {
1257
+ return [];
1258
+ }
1259
+ const entries = [];
1260
+ for (const hash of names) {
1261
+ if (hash.startsWith("."))
1262
+ continue;
1263
+ const dir = path.join(root, hash);
1264
+ let st;
1265
+ try {
1266
+ st = fs.statSync(dir);
1267
+ }
1268
+ catch {
1269
+ continue;
1270
+ }
1271
+ if (!st.isDirectory())
1272
+ continue;
1273
+ let meta = {};
1274
+ try {
1275
+ meta = JSON.parse(fs.readFileSync(path.join(dir, ".cw-clone-meta.json"), "utf8"));
1276
+ }
1277
+ catch {
1278
+ /* legacy/partial entry without meta — still listable/reclaimable */
1279
+ }
1280
+ entries.push({
1281
+ hash,
1282
+ url: typeof meta.url === "string" ? meta.url : "(unknown)",
1283
+ kind: typeof meta.kind === "string" ? meta.kind : "git",
1284
+ ref: typeof meta.ref === "string" ? meta.ref : null,
1285
+ fetchedAt: typeof meta.fetchedAt === "string" ? meta.fetchedAt : null,
1286
+ commit: typeof meta.commit === "string" ? meta.commit : null,
1287
+ bytes: dirSize(dir),
1288
+ });
1289
+ }
1290
+ entries.sort((a, b) => (a.fetchedAt || "").localeCompare(b.fetchedAt || ""));
1291
+ return entries;
1292
+ }
1293
+ /** `cw clones list` — every cached remote checkout with its origin,
1294
+ * commit, age, and size. */
1295
+ function listClones(env = process.env) {
1296
+ const root = clonesRoot(env);
1297
+ const entries = readCloneEntries(root);
1298
+ return { schemaVersion: 1, clonesDir: root, count: entries.length, totalBytes: entries.reduce((sum, e) => sum + e.bytes, 0), entries };
1299
+ }
1300
+ /** `cw clones gc [--older-than-days N] [--all]` — reclaim cached
1301
+ * checkouts. Default keeps entries fetched within the last 30 days;
1302
+ * `--all` removes every entry. Deletes ONLY paths proven inside the
1303
+ * clones root (fail closed). */
1304
+ function gcClones(options = {}, env = process.env) {
1305
+ const root = clonesRoot(env);
1306
+ const all = isTrue(options.all);
1307
+ let olderThanDays = null;
1308
+ if (!all) {
1309
+ const raw = options.olderThanDays;
1310
+ olderThanDays = optionalNumber(raw) ?? 30;
1311
+ if (!Number.isFinite(olderThanDays) || olderThanDays < 0) {
1312
+ throw new Error(`--older-than-days must be a non-negative number (got ${String(raw)})`);
1313
+ }
1314
+ }
1315
+ let now = Date.now();
1316
+ if (options.now !== undefined) {
1317
+ now = new Date(String(options.now)).getTime();
1318
+ if (!Number.isFinite(now))
1319
+ throw new Error(`--now must be a valid ISO date (got ${String(options.now)})`);
1320
+ }
1321
+ const cutoff = olderThanDays != null ? now - olderThanDays * 24 * 60 * 60 * 1000 : Infinity;
1322
+ const rootResolved = path.resolve(root);
1323
+ const removed = [];
1324
+ let freedBytes = 0;
1325
+ const entries = readCloneEntries(root);
1326
+ for (const entry of entries) {
1327
+ if (!all) {
1328
+ if (!entry.fetchedAt)
1329
+ continue;
1330
+ const age = new Date(entry.fetchedAt).getTime();
1331
+ if (!Number.isFinite(age) || age > cutoff)
1332
+ continue;
1333
+ }
1334
+ const dir = path.join(root, entry.hash);
1335
+ if (!path.resolve(dir).startsWith(rootResolved + path.sep))
1336
+ continue; // containment, fail closed
1337
+ fs.rmSync(dir, { recursive: true, force: true });
1338
+ removed.push({ hash: entry.hash, url: entry.url, bytes: entry.bytes });
1339
+ freedBytes += entry.bytes;
1340
+ }
1341
+ return { schemaVersion: 1, clonesDir: root, removed, freedBytes, keptCount: entries.length - removed.length, olderThanDays, all };
1342
+ }
1343
+ function formatClonesList(result) {
1344
+ if (result.count === 0)
1345
+ return `No cached remote checkouts in ${result.clonesDir}.`;
1346
+ const rows = result.entries.map((e) => {
1347
+ const when = e.fetchedAt ? e.fetchedAt.replace("T", " ").replace(/\..*$/, "Z") : "unknown";
1348
+ return ` ${e.kind.padEnd(7)} ${humanBytesLocal(e.bytes).padStart(8)} ${when} ${e.url}${e.ref ? `@${e.ref}` : ""}`;
1349
+ });
1350
+ return [
1351
+ `${result.count} cached checkout${result.count === 1 ? "" : "s"} — ${humanBytesLocal(result.totalBytes)} in ${result.clonesDir}`,
1352
+ " KIND SIZE FETCHED SOURCE",
1353
+ ...rows,
1354
+ `\nReclaim with: cw clones gc --older-than-days 30 (or --all)`,
1355
+ ].join("\n");
1356
+ }
1357
+ function formatClonesGc(result) {
1358
+ const scope = result.all ? "all entries" : `entries older than ${result.olderThanDays} day(s)`;
1359
+ if (result.removed.length === 0)
1360
+ return `Nothing to reclaim (${scope}); ${result.keptCount} kept in ${result.clonesDir}.`;
1361
+ const rows = result.removed.map((r) => ` ${humanBytesLocal(r.bytes).padStart(8)} ${r.url}`);
1362
+ return [
1363
+ `Reclaimed ${result.removed.length} checkout${result.removed.length === 1 ? "" : "s"} (${scope}) — freed ${humanBytesLocal(result.freedBytes)}; ${result.keptCount} kept`,
1364
+ ...rows,
1365
+ ].join("\n");
1366
+ }