cool-workflow 0.1.78

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 (193) hide show
  1. package/.claude-plugin/plugin.json +20 -0
  2. package/.codex-plugin/mcp.json +10 -0
  3. package/.codex-plugin/plugin.json +38 -0
  4. package/.mcp.json +10 -0
  5. package/LICENSE +24 -0
  6. package/README.md +638 -0
  7. package/apps/architecture-review/app.json +51 -0
  8. package/apps/architecture-review/workflow.js +116 -0
  9. package/apps/end-to-end-golden-path/app.json +30 -0
  10. package/apps/end-to-end-golden-path/workflow.js +33 -0
  11. package/apps/pr-review-fix-ci/app.json +59 -0
  12. package/apps/pr-review-fix-ci/workflow.js +90 -0
  13. package/apps/release-cut/app.json +54 -0
  14. package/apps/release-cut/workflow.js +82 -0
  15. package/apps/research-synthesis/app.json +50 -0
  16. package/apps/research-synthesis/workflow.js +76 -0
  17. package/apps/workflow-app-framework-demo/app.json +29 -0
  18. package/apps/workflow-app-framework-demo/workflow.js +44 -0
  19. package/dist/agent-config.js +223 -0
  20. package/dist/candidate-scoring.js +715 -0
  21. package/dist/capability-core.js +630 -0
  22. package/dist/capability-dispatcher.js +86 -0
  23. package/dist/capability-registry.js +523 -0
  24. package/dist/cli.js +1276 -0
  25. package/dist/collaboration.js +727 -0
  26. package/dist/commit.js +570 -0
  27. package/dist/contract-migration.js +234 -0
  28. package/dist/coordinator.js +1163 -0
  29. package/dist/daemon.js +44 -0
  30. package/dist/dispatch.js +201 -0
  31. package/dist/drive.js +503 -0
  32. package/dist/error-feedback.js +415 -0
  33. package/dist/evidence-grounding.js +179 -0
  34. package/dist/evidence-reasoning.js +733 -0
  35. package/dist/execution-backend.js +1279 -0
  36. package/dist/harness.js +61 -0
  37. package/dist/mcp-server.js +1615 -0
  38. package/dist/multi-agent-eval.js +857 -0
  39. package/dist/multi-agent-host.js +764 -0
  40. package/dist/multi-agent-operator-ux.js +537 -0
  41. package/dist/multi-agent-trust.js +366 -0
  42. package/dist/multi-agent.js +1173 -0
  43. package/dist/node-snapshot.js +270 -0
  44. package/dist/observability.js +922 -0
  45. package/dist/operator-ux.js +971 -0
  46. package/dist/orchestrator/audit-operations.js +182 -0
  47. package/dist/orchestrator/candidate-operations.js +117 -0
  48. package/dist/orchestrator/cli-options.js +288 -0
  49. package/dist/orchestrator/collaboration-operations.js +86 -0
  50. package/dist/orchestrator/feedback-operations.js +81 -0
  51. package/dist/orchestrator/host-operations.js +78 -0
  52. package/dist/orchestrator/lifecycle-operations.js +462 -0
  53. package/dist/orchestrator/migration-operations.js +44 -0
  54. package/dist/orchestrator/multi-agent-operations.js +362 -0
  55. package/dist/orchestrator/report.js +369 -0
  56. package/dist/orchestrator/topology-operations.js +84 -0
  57. package/dist/orchestrator.js +874 -0
  58. package/dist/pipeline-contract.js +92 -0
  59. package/dist/pipeline-runner.js +285 -0
  60. package/dist/reclamation.js +882 -0
  61. package/dist/result-normalize.js +194 -0
  62. package/dist/run-export.js +64 -0
  63. package/dist/run-registry.js +1347 -0
  64. package/dist/run-state-schema.js +67 -0
  65. package/dist/sandbox-profile.js +471 -0
  66. package/dist/scheduler.js +266 -0
  67. package/dist/scheduling.js +184 -0
  68. package/dist/schema-validate.js +98 -0
  69. package/dist/state-explosion.js +1213 -0
  70. package/dist/state-migrations.js +463 -0
  71. package/dist/state-node.js +301 -0
  72. package/dist/state.js +308 -0
  73. package/dist/telemetry-attestation.js +156 -0
  74. package/dist/telemetry-ledger.js +145 -0
  75. package/dist/topology.js +527 -0
  76. package/dist/triggers.js +159 -0
  77. package/dist/trust-audit.js +475 -0
  78. package/dist/types/blackboard.js +2 -0
  79. package/dist/types/boundary.js +29 -0
  80. package/dist/types/candidate.js +2 -0
  81. package/dist/types/collaboration.js +2 -0
  82. package/dist/types/core.js +2 -0
  83. package/dist/types/drive.js +10 -0
  84. package/dist/types/error-feedback.js +2 -0
  85. package/dist/types/evidence-reasoning.js +2 -0
  86. package/dist/types/execution-backend.js +2 -0
  87. package/dist/types/multi-agent.js +2 -0
  88. package/dist/types/observability.js +2 -0
  89. package/dist/types/pipeline.js +2 -0
  90. package/dist/types/reclamation.js +8 -0
  91. package/dist/types/result.js +2 -0
  92. package/dist/types/run-registry.js +2 -0
  93. package/dist/types/run.js +2 -0
  94. package/dist/types/sandbox.js +2 -0
  95. package/dist/types/schedule.js +2 -0
  96. package/dist/types/state-node.js +2 -0
  97. package/dist/types/topology.js +2 -0
  98. package/dist/types/trust.js +2 -0
  99. package/dist/types/workbench.js +2 -0
  100. package/dist/types/worker.js +2 -0
  101. package/dist/types/workflow-app.js +2 -0
  102. package/dist/types.js +43 -0
  103. package/dist/verifier-registry.js +46 -0
  104. package/dist/verifier.js +78 -0
  105. package/dist/version.js +8 -0
  106. package/dist/workbench-host.js +172 -0
  107. package/dist/workbench.js +190 -0
  108. package/dist/worker-isolation.js +1028 -0
  109. package/dist/workflow-api.js +98 -0
  110. package/dist/workflow-app-framework.js +626 -0
  111. package/docs/agent-delegation-drive.7.md +190 -0
  112. package/docs/agent-framework.md +176 -0
  113. package/docs/candidate-scoring.7.md +106 -0
  114. package/docs/canonical-workflow-apps.7.md +137 -0
  115. package/docs/capability-topology-registry.7.md +168 -0
  116. package/docs/cli-mcp-parity.7.md +373 -0
  117. package/docs/contract-migration-tooling.7.md +123 -0
  118. package/docs/control-plane-scheduling.7.md +110 -0
  119. package/docs/coordinator-blackboard.7.md +183 -0
  120. package/docs/dogfood/architecture-review-cool-workflow.md +16 -0
  121. package/docs/dogfood-one-real-repo.7.md +168 -0
  122. package/docs/durable-state-and-locking.7.md +107 -0
  123. package/docs/end-to-end-golden-path.7.md +117 -0
  124. package/docs/error-feedback.7.md +153 -0
  125. package/docs/evidence-adoption-reasoning-chain.7.md +270 -0
  126. package/docs/execution-backends.7.md +300 -0
  127. package/docs/getting-started.md +99 -0
  128. package/docs/index.md +41 -0
  129. package/docs/mcp-app-surface.7.md +235 -0
  130. package/docs/multi-agent-cli-mcp-surface.7.md +265 -0
  131. package/docs/multi-agent-eval-replay-harness.7.md +302 -0
  132. package/docs/multi-agent-operator-ux.7.md +314 -0
  133. package/docs/multi-agent-runtime-core.7.md +231 -0
  134. package/docs/multi-agent-topologies.7.md +103 -0
  135. package/docs/multi-agent-trust-policy-audit.7.md +154 -0
  136. package/docs/node-snapshot-diff-replay.7.md +135 -0
  137. package/docs/observability-cost-accounting.7.md +194 -0
  138. package/docs/operator-ux.7.md +180 -0
  139. package/docs/pipeline-runner.7.md +136 -0
  140. package/docs/project-index.md +261 -0
  141. package/docs/real-execution-backends.7.md +142 -0
  142. package/docs/release-and-migration.7.md +280 -0
  143. package/docs/release-tooling.7.md +159 -0
  144. package/docs/routines.md +48 -0
  145. package/docs/run-registry-control-plane.7.md +312 -0
  146. package/docs/run-retention-reclamation.7.md +191 -0
  147. package/docs/sandbox-profiles.7.md +137 -0
  148. package/docs/scheduled-tasks.md +80 -0
  149. package/docs/security-trust-hardening.7.md +117 -0
  150. package/docs/state-explosion-management.7.md +264 -0
  151. package/docs/state-node.7.md +96 -0
  152. package/docs/team-collaboration.7.md +207 -0
  153. package/docs/unix-principles.md +192 -0
  154. package/docs/verifier-gated-commit.7.md +140 -0
  155. package/docs/web-desktop-workbench.7.md +215 -0
  156. package/docs/worker-isolation.7.md +167 -0
  157. package/docs/workflow-app-framework.7.md +274 -0
  158. package/manifest/README.md +43 -0
  159. package/manifest/plugin.manifest.json +316 -0
  160. package/manifest/pricing.policy.json +14 -0
  161. package/package.json +79 -0
  162. package/scripts/agents/claude-p-agent.js +104 -0
  163. package/scripts/agents/claude-p-agent.sh +9 -0
  164. package/scripts/agents/cw-attest-keygen.js +55 -0
  165. package/scripts/agents/cw-attest-wrap.js +143 -0
  166. package/scripts/block-unapproved-tag.sh +39 -0
  167. package/scripts/bump-version.js +249 -0
  168. package/scripts/canonical-apps.js +171 -0
  169. package/scripts/cw.js +4 -0
  170. package/scripts/dist-drift-check.js +79 -0
  171. package/scripts/dogfood-architecture-review.js +237 -0
  172. package/scripts/dogfood-release.js +624 -0
  173. package/scripts/forward-ref-docs.js +73 -0
  174. package/scripts/gen-manifests.js +232 -0
  175. package/scripts/golden-path.js +300 -0
  176. package/scripts/mcp-server.js +4 -0
  177. package/scripts/new-feature.js +121 -0
  178. package/scripts/parity-check.js +213 -0
  179. package/scripts/release-check.js +118 -0
  180. package/scripts/release-flow.js +272 -0
  181. package/scripts/release-gate.sh +85 -0
  182. package/scripts/sync-project-index.js +387 -0
  183. package/scripts/validate-run-state-schema.js +126 -0
  184. package/scripts/verify-container-selfref.js +64 -0
  185. package/scripts/version-sync-check.js +237 -0
  186. package/skills/cool-workflow/SKILL.md +162 -0
  187. package/skills/cool-workflow/references/commands.md +282 -0
  188. package/tsconfig.json +16 -0
  189. package/ui/workbench/app.css +76 -0
  190. package/ui/workbench/app.js +159 -0
  191. package/ui/workbench/index.html +32 -0
  192. package/workflows/architecture-review.workflow.js +84 -0
  193. package/workflows/research-synthesis.workflow.js +47 -0
@@ -0,0 +1,882 @@
1
+ "use strict";
2
+ // Run Retention & Provable Reclamation (v0.1.39) — the core write-ahead, fail-closed
3
+ // reclamation transaction. Frees disk WITHOUT violating the audit/replay moat:
4
+ // freeing bytes leaves behind a hash-chained tombstone proving what was freed is
5
+ // reconstructable-or-worthless and that the audit-essential subset is sealed.
6
+ //
7
+ // BSD / Unix discipline (each tenet is a hard constraint; load-bearing ones flagged):
8
+ //
9
+ // - THE INVARIANT [LOAD-BEARING]: never delete what is audit-essential AND
10
+ // irreproducible. A byte is freeable ONLY if it is (1) reconstructable from
11
+ // retained inputs + a recorded recipe + an `expectDigest`, or (2) pure scratch
12
+ // with zero audit value — AND referenced by no surviving evidence locator or
13
+ // audit event. Any UNCLASSIFIED path defaults to RETAINED.
14
+ // - WRITE-AHEAD, FAIL-CLOSED SEQUENCING [LOAD-BEARING]: extract+seal skeleton →
15
+ // write full tombstone (pre-deletion sha256 per path) → fsync/commit into the
16
+ // append-only overlay → ONLY THEN free the bulk. A crash between any steps
17
+ // leaves EITHER the full run OR a complete tombstone — never half-deleted.
18
+ // - APPEND-ONLY [LOAD-BEARING]: the tombstone is a NEW `reclaimed.json` overlay;
19
+ // only bulk DATA bytes are freed — no existing audit/state/commit record is ever
20
+ // rewritten. Hash-chained: tombstoneHash recomputed from freed-manifest + sealed
21
+ // skeleton + prevTombstoneHash (genesis = sha256 of the sealed skeleton).
22
+ // - CAPABILITY DOWNGRADE IS EXPLICIT [LOAD-BEARING for replay]: reclaiming a
23
+ // snapshot downgrades re-runnable → verify-only (or re-runnable-by-reconstruction
24
+ // when inputs + expectDigest are retained), surfaced as a closed-enum reason.
25
+ //
26
+ // This module is LOW-LEVEL (no import of run-registry); the registry composes
27
+ // these primitives. See docs/run-retention-reclamation.7.md.
28
+ var __importDefault = (this && this.__importDefault) || function (mod) {
29
+ return (mod && mod.__esModule) ? mod : { "default": mod };
30
+ };
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.ReclamationError = exports.ReclamationAbort = exports.SKELETON_REQUIRED_KEYS = exports.RECLAMATION_SCHEMA_VERSION = void 0;
33
+ exports.sha256OfString = sha256OfString;
34
+ exports.sha256OfFile = sha256OfFile;
35
+ exports.dirBytes = dirBytes;
36
+ exports.reclaimedLogPath = reclaimedLogPath;
37
+ exports.loadReclamationLog = loadReclamationLog;
38
+ exports.extractSkeleton = extractSkeleton;
39
+ exports.validateSkeleton = validateSkeleton;
40
+ exports.validateSkeletonAgainstRun = validateSkeletonAgainstRun;
41
+ exports.planReclamation = planReclamation;
42
+ exports.genesisPrevHash = genesisPrevHash;
43
+ exports.computeTombstoneHash = computeTombstoneHash;
44
+ exports.buildTombstone = buildTombstone;
45
+ exports.commitTombstone = commitTombstone;
46
+ exports.prepareFree = prepareFree;
47
+ exports.freeBulk = freeBulk;
48
+ exports.runReclamation = runReclamation;
49
+ exports.reconstructArtifact = reconstructArtifact;
50
+ exports.verifyReclamation = verifyReclamation;
51
+ exports.dominantFailureCode = dominantFailureCode;
52
+ const node_crypto_1 = __importDefault(require("node:crypto"));
53
+ const node_fs_1 = __importDefault(require("node:fs"));
54
+ const node_path_1 = __importDefault(require("node:path"));
55
+ const multi_agent_eval_1 = require("./multi-agent-eval");
56
+ const node_snapshot_1 = require("./node-snapshot");
57
+ const state_1 = require("./state");
58
+ const trust_audit_1 = require("./trust-audit");
59
+ exports.RECLAMATION_SCHEMA_VERSION = 1;
60
+ /** The skeleton schema is the contract for what MUST survive every reclamation.
61
+ * Machine-checkable via validateSkeleton(). If extraction can't produce all of
62
+ * these, reclamation fails closed and frees nothing. */
63
+ exports.SKELETON_REQUIRED_KEYS = [
64
+ "runId",
65
+ "finalVerdict",
66
+ "commits",
67
+ "evidenceDigests",
68
+ "attestationChain",
69
+ "costRecord",
70
+ "auditLog",
71
+ "collaborationLog",
72
+ "stateDigest"
73
+ ];
74
+ /** Synthetic abort thrown by runReclamation({ faultAfter }) — a TESTABLE crash
75
+ * injection that never kills the process. */
76
+ class ReclamationAbort extends Error {
77
+ step;
78
+ constructor(step) {
79
+ super(`ReclamationAbort after step: ${step}`);
80
+ this.name = "ReclamationAbort";
81
+ this.step = step;
82
+ }
83
+ }
84
+ exports.ReclamationAbort = ReclamationAbort;
85
+ /** Fail-closed refusal: a real reason reclamation freed nothing (distinct code). */
86
+ class ReclamationError extends Error {
87
+ code;
88
+ details;
89
+ constructor(code, message, details) {
90
+ super(message);
91
+ this.name = "ReclamationError";
92
+ this.code = code;
93
+ this.details = details;
94
+ }
95
+ }
96
+ exports.ReclamationError = ReclamationError;
97
+ // ---------------------------------------------------------------------------
98
+ // Content addressing + byte measurement (NO `du` — in-process only).
99
+ // ---------------------------------------------------------------------------
100
+ function sha256Hex(value) {
101
+ return node_crypto_1.default.createHash("sha256").update(value).digest("hex");
102
+ }
103
+ function sha256OfString(value) {
104
+ return `sha256:${sha256Hex(value)}`;
105
+ }
106
+ function sha256OfFile(file) {
107
+ return `sha256:${sha256Hex(node_fs_1.default.readFileSync(file))}`;
108
+ }
109
+ /** Walk a path and sum file sizes IN-PROCESS (no `du`). Returns 0 if absent. A
110
+ * file returns its own size; a dir returns the recursive sum. */
111
+ function dirBytes(p) {
112
+ let total = 0;
113
+ let stat;
114
+ try {
115
+ stat = node_fs_1.default.statSync(p);
116
+ }
117
+ catch {
118
+ return 0;
119
+ }
120
+ if (stat.isFile())
121
+ return stat.size;
122
+ if (!stat.isDirectory())
123
+ return 0;
124
+ for (const entry of node_fs_1.default.readdirSync(p, { withFileTypes: true })) {
125
+ total += dirBytes(node_path_1.default.join(p, entry.name));
126
+ }
127
+ return total;
128
+ }
129
+ /** Stable content digest of a path (file = its bytes; dir = digest over each
130
+ * member's relative path + bytes, sorted). Lets the freed-manifest record a
131
+ * single sha per freed dir. */
132
+ function contentDigest(p) {
133
+ const stat = node_fs_1.default.statSync(p);
134
+ if (stat.isFile())
135
+ return sha256OfFile(p);
136
+ const parts = [];
137
+ const walk = (dir, rel) => {
138
+ for (const entry of node_fs_1.default.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
139
+ const abs = node_path_1.default.join(dir, entry.name);
140
+ const r = node_path_1.default.join(rel, entry.name);
141
+ if (entry.isDirectory())
142
+ walk(abs, r);
143
+ else
144
+ parts.push(`${r}:${sha256OfFile(abs)}`);
145
+ }
146
+ };
147
+ walk(p, "");
148
+ return sha256OfString(parts.join("\n"));
149
+ }
150
+ /** Persist a run's authoritative state.json DURABLY (atomic temp → fsync →
151
+ * rename). The re-point that scratch reclamation depends on MUST be persisted
152
+ * this way BEFORE any byte is freed — see prepareFree(). */
153
+ function persistRunDurable(run) {
154
+ run.updatedAt = new Date().toISOString();
155
+ (0, state_1.writeJson)(run.paths.state, run, { durable: true });
156
+ }
157
+ /** Run `fn` while holding the per-run reclamation lock (serializes the
158
+ * reclaimed.json read-modify-write so a concurrent reclaimer can never lose a
159
+ * tombstone). Generalized into state.ts's portable withFileLock (P1-C/P1-D). */
160
+ function withRunLock(run, fn) {
161
+ return (0, state_1.withFileLock)(reclaimedLogPath(run), fn);
162
+ }
163
+ // ---------------------------------------------------------------------------
164
+ // The per-run reclamation log (`reclaimed.json`) — an append-only chain of
165
+ // tombstones, a PEER of archive.json, in the ALLOW-LIST (never freed).
166
+ // ---------------------------------------------------------------------------
167
+ function reclaimedLogPath(run) {
168
+ return node_path_1.default.join(run.paths.runDir, "reclaimed.json");
169
+ }
170
+ function loadReclamationLog(run) {
171
+ const file = reclaimedLogPath(run);
172
+ if (!node_fs_1.default.existsSync(file))
173
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
174
+ try {
175
+ const parsed = JSON.parse(node_fs_1.default.readFileSync(file, "utf8"));
176
+ return { schemaVersion: 1, runId: run.id, tombstones: Array.isArray(parsed.tombstones) ? parsed.tombstones : [] };
177
+ }
178
+ catch {
179
+ // A malformed overlay must NOT brick the run — fail closed to an empty chain.
180
+ return { schemaVersion: 1, runId: run.id, tombstones: [] };
181
+ }
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Skeleton extraction — the audit-essential subset that must survive.
185
+ // ---------------------------------------------------------------------------
186
+ function deriveTerminalLifecycle(run) {
187
+ const tasks = run.tasks || [];
188
+ const running = tasks.filter((t) => t.status === "running").length;
189
+ const failed = tasks.filter((t) => t.status === "failed").length;
190
+ const completed = tasks.filter((t) => t.status === "completed").length;
191
+ const total = tasks.length;
192
+ const pending = tasks.filter((t) => t.status === "pending").length;
193
+ const openFeedback = (run.feedback || []).filter((f) => f.status === "open" || f.status === "tasked").length;
194
+ const verifierGated = (run.commits || []).filter((c) => c.verifierGated).length;
195
+ if (running > 0)
196
+ return "running";
197
+ if (openFeedback > 0)
198
+ return "blocked";
199
+ if (failed > 0)
200
+ return "failed";
201
+ if (total > 0 && completed === total)
202
+ return "completed";
203
+ if (verifierGated > 0 && pending === 0)
204
+ return "completed";
205
+ if (completed > 0)
206
+ return "running";
207
+ return "queued";
208
+ }
209
+ function auditEventLogPath(run) {
210
+ return run.audit?.eventLogPath || node_path_1.default.join(run.paths.auditDir || node_path_1.default.join(run.paths.runDir, "audit"), "events.jsonl");
211
+ }
212
+ function digestEvidenceEntry(entry) {
213
+ const ref = entry.locator || entry.path || entry.summary || entry.id;
214
+ if (!ref)
215
+ return undefined;
216
+ // Prefer the file's content digest when the locator resolves to a real path.
217
+ const candidatePath = entry.path || entry.locator;
218
+ if (candidatePath && typeof candidatePath === "string" && !candidatePath.includes(":") && node_fs_1.default.existsSync(candidatePath)) {
219
+ try {
220
+ const stat = node_fs_1.default.statSync(candidatePath);
221
+ if (stat.isFile())
222
+ return { ref, digest: sha256OfFile(candidatePath) };
223
+ }
224
+ catch {
225
+ /* fall through to locator digest */
226
+ }
227
+ }
228
+ return { ref, digest: sha256OfString(ref) };
229
+ }
230
+ /** STEP 1: extract + seal the skeleton. Pure read over the run; never mutates. */
231
+ function extractSkeleton(run) {
232
+ const lifecycle = deriveTerminalLifecycle(run);
233
+ const commits = (run.commits || []).map((commit) => ({
234
+ id: commit.id,
235
+ verifierGated: Boolean(commit.verifierGated),
236
+ checkpoint: Boolean(commit.checkpoint),
237
+ candidateId: commit.candidateId,
238
+ selectionId: commit.selectionId,
239
+ verifierNodeId: commit.verifierNodeId,
240
+ evidenceCount: (commit.evidence || []).length,
241
+ acceptanceRationale: commit.acceptanceRationale
242
+ }));
243
+ const evidenceSources = [];
244
+ for (const node of run.nodes || [])
245
+ for (const e of node.evidence || [])
246
+ evidenceSources.push(e);
247
+ for (const candidate of run.candidates || [])
248
+ for (const e of candidate.evidence || [])
249
+ evidenceSources.push(e);
250
+ for (const selection of run.candidateSelections || [])
251
+ for (const e of selection.evidence || [])
252
+ evidenceSources.push(e);
253
+ for (const commit of run.commits || [])
254
+ for (const e of commit.evidence || [])
255
+ evidenceSources.push(e);
256
+ const evidenceMap = new Map();
257
+ for (const e of evidenceSources) {
258
+ const digested = digestEvidenceEntry(e);
259
+ if (digested)
260
+ evidenceMap.set(digested.ref, digested.digest);
261
+ }
262
+ const evidenceDigests = [...evidenceMap.entries()]
263
+ .map(([ref, digest]) => ({ ref, digest }))
264
+ .sort((a, b) => a.ref.localeCompare(b.ref));
265
+ const eventLog = auditEventLogPath(run);
266
+ const auditLogDigest = node_fs_1.default.existsSync(eventLog) ? sha256OfFile(eventLog) : sha256OfString("");
267
+ const events = node_fs_1.default.existsSync(eventLog)
268
+ ? node_fs_1.default
269
+ .readFileSync(eventLog, "utf8")
270
+ .split(/\n/g)
271
+ .map((line) => line.trim())
272
+ .filter(Boolean)
273
+ .map((line) => {
274
+ try {
275
+ const e = JSON.parse(line);
276
+ return { id: e.id || "", kind: e.kind || "", decision: e.decision || "", createdAt: e.createdAt || "" };
277
+ }
278
+ catch {
279
+ return { id: "", kind: "malformed", decision: "", createdAt: "" };
280
+ }
281
+ })
282
+ : [];
283
+ const metricsReport = node_path_1.default.join(run.paths.runDir, "metrics", "metrics-report.json");
284
+ const costRecord = {
285
+ tasks: (run.tasks || []).map((task) => ({ taskId: task.id, model: task.usage?.model, source: task.usage?.source })),
286
+ metricsDigest: node_fs_1.default.existsSync(metricsReport) ? sha256OfFile(metricsReport) : undefined
287
+ };
288
+ const collaboration = run.collaboration;
289
+ const collaborationLog = {
290
+ digest: sha256OfString((0, multi_agent_eval_1.stableStringify)(collaboration || {})),
291
+ approvals: collaboration?.approvals?.length || 0,
292
+ comments: collaboration?.comments?.length || 0,
293
+ handoffs: collaboration?.handoffs?.length || 0
294
+ };
295
+ // Empty (not a hash-of-empty) when state.json is absent, so the skeleton fails
296
+ // closed — you cannot seal a run whose authoritative state is gone.
297
+ const stateDigest = node_fs_1.default.existsSync(run.paths.state) ? sha256OfFile(run.paths.state) : "";
298
+ return {
299
+ schemaVersion: 1,
300
+ runId: run.id,
301
+ finalVerdict: {
302
+ lifecycle,
303
+ loopStage: run.loopStage,
304
+ terminal: lifecycle === "completed" || lifecycle === "failed",
305
+ commitGated: (run.commits || []).some((c) => c.verifierGated)
306
+ },
307
+ commits,
308
+ evidenceDigests,
309
+ attestationChain: { auditLogDigest, eventCount: events.length, events },
310
+ costRecord,
311
+ auditLog: { path: node_path_1.default.relative(run.paths.runDir, eventLog), digest: auditLogDigest },
312
+ collaborationLog,
313
+ stateDigest
314
+ };
315
+ }
316
+ /** Return the list of SKELETON_REQUIRED_KEYS that are missing/empty. Empty array
317
+ * ⇒ schema-complete. The runId + a populated finalVerdict are load-bearing. */
318
+ function validateSkeleton(skeleton) {
319
+ const missing = [];
320
+ if (!skeleton)
321
+ return [...exports.SKELETON_REQUIRED_KEYS];
322
+ for (const key of exports.SKELETON_REQUIRED_KEYS) {
323
+ const value = skeleton[key];
324
+ if (value === undefined || value === null) {
325
+ missing.push(key);
326
+ continue;
327
+ }
328
+ if (key === "runId" && !String(value).trim())
329
+ missing.push(key);
330
+ if (key === "stateDigest" && !String(value).trim())
331
+ missing.push(key);
332
+ if (key === "finalVerdict" && (typeof value !== "object" || !value.lifecycle))
333
+ missing.push(key);
334
+ if (key === "auditLog" && (typeof value !== "object" || !value.digest))
335
+ missing.push(key);
336
+ if (key === "attestationChain" && (typeof value !== "object" || typeof value.auditLogDigest !== "string"))
337
+ missing.push(key);
338
+ if (key === "commits" && !Array.isArray(value))
339
+ missing.push(key);
340
+ if (key === "evidenceDigests" && !Array.isArray(value))
341
+ missing.push(key);
342
+ }
343
+ return missing;
344
+ }
345
+ /** P2-A content fidelity (v0.1.40): a complete-SHAPED skeleton is not enough —
346
+ * reclamation must REFUSE if extraction dropped audit content the run actually
347
+ * has. When the run carries commits/evidence, the sealed skeleton MUST carry
348
+ * them too (extraction maps 1:1). Returns the content-loss reasons, empty when
349
+ * faithful. This is the run-aware counterpart to validateSkeleton's shape check. */
350
+ function validateSkeletonAgainstRun(run, skeleton) {
351
+ const failures = [];
352
+ const runCommits = (run.commits || []).length;
353
+ if (runCommits > 0 && skeleton.commits.length !== runCommits) {
354
+ failures.push(`commits-dropped(run=${runCommits},sealed=${skeleton.commits.length})`);
355
+ }
356
+ const runHasEvidence = (run.nodes || []).some((n) => (n.evidence || []).length) ||
357
+ (run.candidates || []).some((c) => (c.evidence || []).length) ||
358
+ (run.candidateSelections || []).some((s) => (s.evidence || []).length) ||
359
+ (run.commits || []).some((c) => (c.evidence || []).length);
360
+ if (runHasEvidence && skeleton.evidenceDigests.length === 0) {
361
+ failures.push("evidence-dropped");
362
+ }
363
+ if (!skeleton.finalVerdict || !skeleton.finalVerdict.lifecycle)
364
+ failures.push("verdict-missing");
365
+ return failures;
366
+ }
367
+ // ---------------------------------------------------------------------------
368
+ // Reference graph — the load-bearing classifier guard. A candidate/blackboard
369
+ // path referenced by ANY surviving evidence locator / audit event forces
370
+ // retention (fail closed). Scratch is the carved exception: its raw result.md is
371
+ // referenced by the result node, but that reference is REPOINTED (not retained).
372
+ // ---------------------------------------------------------------------------
373
+ function buildReferenceGraph(run) {
374
+ const refs = new Set();
375
+ const add = (value) => {
376
+ if (typeof value === "string" && value.trim())
377
+ refs.add(value.trim());
378
+ };
379
+ for (const node of run.nodes || []) {
380
+ for (const e of node.evidence || []) {
381
+ add(e.locator);
382
+ add(e.path);
383
+ add(e.id);
384
+ }
385
+ }
386
+ for (const candidate of run.candidates || [])
387
+ for (const e of candidate.evidence || [])
388
+ add(e.locator);
389
+ for (const commit of run.commits || [])
390
+ for (const e of commit.evidence || [])
391
+ add(e.locator);
392
+ for (const artifact of run.blackboard?.artifacts || []) {
393
+ add(artifact.id);
394
+ add(artifact.path);
395
+ }
396
+ for (const message of run.blackboard?.messages || [])
397
+ add(message.id);
398
+ return refs;
399
+ }
400
+ function snapshotProjectionDigest(node) {
401
+ // Mirror node-snapshot.ts's deterministic projection so reconstruction matches.
402
+ const body = (0, multi_agent_eval_1.normalizeValue)({
403
+ id: node.id,
404
+ kind: node.kind,
405
+ status: node.status,
406
+ loopStage: node.loopStage,
407
+ inputs: node.inputs,
408
+ outputs: node.outputs,
409
+ artifacts: node.artifacts,
410
+ evidence: node.evidence,
411
+ errors: node.errors,
412
+ parents: node.parents,
413
+ children: node.children,
414
+ contractId: node.contractId,
415
+ metadata: node.metadata
416
+ });
417
+ return sha256OfString((0, multi_agent_eval_1.stableStringify)(body));
418
+ }
419
+ /** Body digest of the RETAINED node (lives in state.json). The reconstruction
420
+ * verifier re-derives the projection from this retained input. */
421
+ function nodeBodyDigest(node) {
422
+ return sha256OfString((0, multi_agent_eval_1.stableStringify)(rawNodeBody(node)));
423
+ }
424
+ function rawNodeBody(node) {
425
+ return {
426
+ id: node.id,
427
+ kind: node.kind,
428
+ status: node.status,
429
+ loopStage: node.loopStage,
430
+ inputs: node.inputs,
431
+ outputs: node.outputs,
432
+ artifacts: node.artifacts,
433
+ evidence: node.evidence,
434
+ errors: node.errors,
435
+ parents: node.parents,
436
+ children: node.children,
437
+ contractId: node.contractId,
438
+ metadata: node.metadata
439
+ };
440
+ }
441
+ /** Build the retention plan: which paths are freeable under `policy`, of what
442
+ * kind, how many bytes, and the resulting capability downgrade. */
443
+ function planReclamation(run, policy = {}) {
444
+ const runDir = run.paths.runDir;
445
+ const freeable = [];
446
+ const rel = (abs) => node_path_1.default.relative(runDir, abs);
447
+ // (1) Worker scratch dirs — pure scratch with zero audit value. result.md is
448
+ // already copied to results/<task>.md (evidence-gated). The whole workerDir is
449
+ // freeable once the result node's worker-result artifact is re-pointed.
450
+ let reclaimedScratch = false;
451
+ if (!policy.keepScratch) {
452
+ const workersDir = run.paths.workersDir || node_path_1.default.join(runDir, "workers");
453
+ for (const scope of run.workers || []) {
454
+ const workerDir = scope.workerDir;
455
+ if (!workerDir || !node_fs_1.default.existsSync(workerDir))
456
+ continue;
457
+ // Only reclaim a worker whose output was accepted (result retained under results/).
458
+ const task = (run.tasks || []).find((t) => t.id === scope.taskId);
459
+ const resultNodeId = scope.resultNodeId || task?.resultNodeId;
460
+ const resultsCopy = task?.resultPath;
461
+ if (!resultNodeId || !resultsCopy || !node_fs_1.default.existsSync(resultsCopy))
462
+ continue;
463
+ const bytes = dirBytes(workerDir);
464
+ if (bytes <= 0)
465
+ continue;
466
+ freeable.push({
467
+ path: rel(workerDir),
468
+ absPath: workerDir,
469
+ kind: "scratch",
470
+ bytes,
471
+ repointResultNodeId: resultNodeId
472
+ });
473
+ reclaimedScratch = true;
474
+ }
475
+ void workersDir;
476
+ }
477
+ // A node whose scratch is being re-pointed THIS pass must NOT also have its
478
+ // snapshot freed in the same pass — re-pointing mutates the node body, which
479
+ // would make the snapshot's reconstruction recipe mismatch. Fail closed: retain
480
+ // such snapshots (a later pass, after the body settles, can reclaim them).
481
+ const repointNodeIds = new Set(freeable.filter((f) => f.repointResultNodeId).map((f) => f.repointResultNodeId));
482
+ // (2) Reconstructable node snapshots — deterministic projection of a RETAINED
483
+ // node (state.json). Reclaim the persisted snapshot file; retain the recipe +
484
+ // expectDigest so the projection re-derives without the freed bytes.
485
+ let reclaimedSnapshot = false;
486
+ let reconstructableSnapshot = false;
487
+ if (!policy.keepSnapshots) {
488
+ const nodesDir = run.paths.stateNodesDir || node_path_1.default.join(runDir, "nodes");
489
+ const snapshotsRoot = node_path_1.default.join(nodesDir, "snapshots");
490
+ if (node_fs_1.default.existsSync(snapshotsRoot)) {
491
+ for (const nodeDirName of node_fs_1.default.readdirSync(snapshotsRoot, { withFileTypes: true })) {
492
+ if (!nodeDirName.isDirectory())
493
+ continue;
494
+ const nodeDir = node_path_1.default.join(snapshotsRoot, nodeDirName.name);
495
+ for (const file of node_fs_1.default.readdirSync(nodeDir, { withFileTypes: true })) {
496
+ if (!file.isFile() || !file.name.endsWith(".json"))
497
+ continue;
498
+ const snapFile = node_path_1.default.join(nodeDir, file.name);
499
+ let snap;
500
+ try {
501
+ snap = JSON.parse(node_fs_1.default.readFileSync(snapFile, "utf8"));
502
+ }
503
+ catch {
504
+ continue; // unreadable snapshot → retain (fail closed)
505
+ }
506
+ const node = (run.nodes || []).find((n) => n.id === snap.nodeId);
507
+ if (!node)
508
+ continue; // source node gone → cannot reconstruct → retain
509
+ if (repointNodeIds.has(node.id))
510
+ continue; // body will be re-pointed → retain
511
+ const bytes = dirBytes(snapFile);
512
+ if (bytes <= 0)
513
+ continue;
514
+ const inputDigest = nodeBodyDigest(node);
515
+ const recipe = {
516
+ recipeKind: "node-snapshot-projection",
517
+ inputDigests: [inputDigest],
518
+ inputsDigest: sha256OfString((0, multi_agent_eval_1.stableStringify)([inputDigest])),
519
+ expectDigest: snapshotProjectionDigest(node),
520
+ sourceRef: node.id
521
+ };
522
+ freeable.push({ path: rel(snapFile), absPath: snapFile, kind: "reconstructable-snapshot", bytes, recipe });
523
+ reclaimedSnapshot = true;
524
+ reconstructableSnapshot = true;
525
+ }
526
+ }
527
+ }
528
+ }
529
+ // (3 / 4) candidate + reference-free blackboard artifacts are RETAINED by
530
+ // default in v0.1.39 (fail closed): a referenced blackboard digest forces
531
+ // retention, and we do not yet auto-capture reconstruction recipes for them.
532
+ // The reference graph is consulted so the door is closed, not merely unbuilt.
533
+ void buildReferenceGraph;
534
+ const byKind = {};
535
+ let bytesToFree = 0;
536
+ for (const entry of freeable) {
537
+ byKind[entry.kind] = (byKind[entry.kind] || 0) + entry.bytes;
538
+ bytesToFree += entry.bytes;
539
+ }
540
+ // Capability projection (closed enum). Reclaiming a reconstructable snapshot →
541
+ // re-runnable-by-reconstruction; a non-reconstructable snapshot → verify-only;
542
+ // scratch/none → re-runnable (scratch is pure waste, replay is unaffected).
543
+ let capability = "re-runnable";
544
+ let capabilityReason = "scratch-only-reclaimed";
545
+ if (reclaimedSnapshot && reconstructableSnapshot) {
546
+ capability = "re-runnable-by-reconstruction";
547
+ capabilityReason = "inputs-and-expectdigest-retained";
548
+ }
549
+ else if (reclaimedSnapshot) {
550
+ capability = "verify-only";
551
+ capabilityReason = "snapshot-reclaimed-no-reconstruction";
552
+ }
553
+ else if (reclaimedScratch) {
554
+ capability = "re-runnable";
555
+ capabilityReason = "scratch-only-reclaimed";
556
+ }
557
+ return { freeable, bytesToFree, byKind, capability, capabilityReason };
558
+ }
559
+ function policyDigestOf(policy) {
560
+ return sha256OfString((0, multi_agent_eval_1.stableStringify)(policy));
561
+ }
562
+ /** genesis prevTombstoneHash = sha256 of the sealed skeleton. */
563
+ function genesisPrevHash(skeleton) {
564
+ return sha256OfString((0, multi_agent_eval_1.stableStringify)(skeleton));
565
+ }
566
+ /** The canonical bytes a tombstoneHash binds: freed-manifest + sealed skeleton +
567
+ * prevTombstoneHash + capability. Recomputed independently by `gc verify`. */
568
+ function tombstoneHashInput(t) {
569
+ return (0, multi_agent_eval_1.stableStringify)({
570
+ runId: t.runId,
571
+ tombstoneId: t.tombstoneId,
572
+ reclaimedAt: t.reclaimedAt,
573
+ actor: t.actor || null,
574
+ policyDigest: t.policyDigest,
575
+ freed: t.freed.map((f) => ({ path: f.path, kind: f.kind, bytes: f.bytes, sha256: f.sha256, recipe: f.recipe || null })),
576
+ bytesFreed: t.bytesFreed,
577
+ skeletonDigest: sha256OfString((0, multi_agent_eval_1.stableStringify)(t.skeleton)),
578
+ capability: t.capability,
579
+ capabilityReason: t.capabilityReason,
580
+ prevTombstoneHash: t.prevTombstoneHash
581
+ });
582
+ }
583
+ function computeTombstoneHash(t) {
584
+ return sha256OfString(tombstoneHashInput(t));
585
+ }
586
+ let tombstoneCounter = 0;
587
+ function tombstoneId(run, now) {
588
+ tombstoneCounter += 1;
589
+ const stamp = now.replace(/[-:.TZ]/g, "").slice(0, 14);
590
+ return `tomb-${stamp}-${String(tombstoneCounter).padStart(3, "0")}`;
591
+ }
592
+ /** STEP 2: build the FULL tombstone (pre-deletion sha256 per freed path + the
593
+ * hash chain). Reads the freed files (still present); mutates nothing on disk. */
594
+ function buildTombstone(run, skeleton, plan, options = {}) {
595
+ const now = options.now || new Date().toISOString();
596
+ const prior = loadReclamationLog(run).tombstones;
597
+ const prevTombstoneHash = prior.length ? prior[prior.length - 1].tombstoneHash : genesisPrevHash(skeleton);
598
+ const freed = plan.freeable.map((entry) => ({
599
+ path: entry.path,
600
+ kind: entry.kind,
601
+ bytes: entry.bytes,
602
+ sha256: contentDigest(entry.absPath),
603
+ recipe: entry.recipe
604
+ }));
605
+ const base = {
606
+ schemaVersion: 1,
607
+ runId: run.id,
608
+ tombstoneId: tombstoneId(run, now),
609
+ reclaimedAt: now,
610
+ actor: options.actor,
611
+ policyDigest: policyDigestOf(options.policy || {}),
612
+ freed,
613
+ bytesFreed: freed.reduce((sum, f) => sum + f.bytes, 0),
614
+ skeleton,
615
+ capability: plan.capability,
616
+ capabilityReason: plan.capabilityReason,
617
+ prevTombstoneHash
618
+ };
619
+ return { ...base, tombstoneHash: computeTombstoneHash(base) };
620
+ }
621
+ /** STEP 3: commit the tombstone DURABLY into the append-only overlay (temp →
622
+ * fsync → rename) and record the attestation through the append-only audit log.
623
+ * No byte is freed here — write-ahead order is the safety property. */
624
+ function commitTombstone(run, tombstone) {
625
+ const log = loadReclamationLog(run);
626
+ log.tombstones.push(tombstone);
627
+ (0, state_1.writeJson)(reclaimedLogPath(run), log, { durable: true });
628
+ try {
629
+ (0, trust_audit_1.recordTrustAuditEvent)(run, {
630
+ kind: "run.reclamation",
631
+ decision: "recorded",
632
+ source: "cw-validated",
633
+ metadata: {
634
+ tombstoneId: tombstone.tombstoneId,
635
+ tombstoneHash: tombstone.tombstoneHash,
636
+ prevTombstoneHash: tombstone.prevTombstoneHash,
637
+ bytesFreed: tombstone.bytesFreed,
638
+ freedPaths: tombstone.freed.length,
639
+ capability: tombstone.capability,
640
+ capabilityReason: tombstone.capabilityReason,
641
+ actor: tombstone.actor
642
+ }
643
+ });
644
+ }
645
+ catch {
646
+ // The tombstone is already durable; an audit-append hiccup must not unwind it.
647
+ }
648
+ }
649
+ /** STEP 4 (preparation, P1-A + P1-B): re-point every surviving node's artifacts
650
+ * off the scratch paths about to vanish, DURABLY persist that state.json change,
651
+ * and PROVE no surviving node still references a freed path (and that each
652
+ * re-pointed result node's snapshot stays `valid`) — BEFORE a single byte is
653
+ * freed. Fail closed (`repoint-incomplete`) if the proof does not hold, so a
654
+ * crash can never leave state.json pointing at a freed path. */
655
+ function prepareFree(run, tombstone) {
656
+ const runDir = run.paths.runDir;
657
+ // Symlink-hardened (v0.1.40 self-audit P1): realResolve follows symlinks so the
658
+ // containment proofs below cannot be bypassed by an artifact symlinked across
659
+ // the freed/retained boundary.
660
+ const scratchDirs = tombstone.freed.filter((f) => f.kind === "scratch").map((f) => (0, state_1.realResolve)(node_path_1.default.join(runDir, f.path)));
661
+ if (!scratchDirs.length)
662
+ return; // nothing references a freed path; no state change needed.
663
+ const repointed = new Set();
664
+ for (const scratchDir of scratchDirs) {
665
+ for (const id of repointResultNodeArtifacts(run, scratchDir))
666
+ repointed.add(id);
667
+ }
668
+ // Durably persist the re-point so it survives a crash BEFORE the free runs.
669
+ persistRunDurable(run);
670
+ // PROOF 1: no surviving node artifact may resolve inside any freed scratch dir.
671
+ for (const node of run.nodes || []) {
672
+ for (const artifact of node.artifacts || []) {
673
+ if (!artifact.path)
674
+ continue;
675
+ const resolved = (0, state_1.realResolve)(artifact.path);
676
+ for (const scratchDir of scratchDirs) {
677
+ if (resolved === scratchDir || resolved.startsWith(scratchDir + node_path_1.default.sep)) {
678
+ throw new ReclamationError("repoint-incomplete", `node ${node.id} artifact ${artifact.id} still references freed scratch path ${artifact.path}`, {
679
+ nodeId: node.id,
680
+ artifactId: artifact.id,
681
+ path: artifact.path
682
+ });
683
+ }
684
+ }
685
+ }
686
+ }
687
+ // PROOF 2: each re-pointed result node's snapshot stays `valid` (not `absent`).
688
+ for (const nodeId of repointed) {
689
+ try {
690
+ const fresh = (0, node_snapshot_1.snapshotNode)(run, nodeId, { persist: false });
691
+ const { freshness } = (0, node_snapshot_1.loadNodeSnapshot)(run, fresh);
692
+ if (freshness === "absent") {
693
+ throw new ReclamationError("repoint-incomplete", `re-pointed node ${nodeId} snapshot is absent (dangling artifact)`, { nodeId });
694
+ }
695
+ }
696
+ catch (error) {
697
+ if (error instanceof ReclamationError)
698
+ throw error;
699
+ throw new ReclamationError("repoint-incomplete", `could not prove re-pointed node ${nodeId} stays valid: ${error.message}`, { nodeId });
700
+ }
701
+ }
702
+ }
703
+ /** STEP 5: free the bulk DATA bytes. Pure deletion — every re-point is already
704
+ * done and DURABLY persisted by prepareFree(), so a crash here can never leave a
705
+ * surviving node referencing a freed path. */
706
+ function freeBulk(run, tombstone) {
707
+ const runDir = run.paths.runDir;
708
+ let freedBytes = 0;
709
+ for (const entry of tombstone.freed) {
710
+ const abs = node_path_1.default.join(runDir, entry.path);
711
+ const before = dirBytes(abs);
712
+ node_fs_1.default.rmSync(abs, { recursive: true, force: true });
713
+ freedBytes += before;
714
+ }
715
+ return freedBytes;
716
+ }
717
+ /** Re-point a node's artifacts off `freedScratchDir` to the retained `result`
718
+ * copy. Returns the ids of nodes actually changed (for the validity proof). */
719
+ function repointResultNodeArtifacts(run, freedScratchDir) {
720
+ const freedReal = (0, state_1.realResolve)(freedScratchDir);
721
+ const freedPrefix = freedReal + node_path_1.default.sep;
722
+ const changedIds = [];
723
+ for (const node of run.nodes || []) {
724
+ if (!node.artifacts)
725
+ continue;
726
+ let changed = false;
727
+ for (const artifact of node.artifacts) {
728
+ if (!artifact.path)
729
+ continue;
730
+ const resolved = (0, state_1.realResolve)(artifact.path);
731
+ if (resolved === freedReal || resolved.startsWith(freedPrefix)) {
732
+ // Re-point to the retained results/<task>.md copy (the `result` artifact).
733
+ const retained = node.artifacts.find((a) => a.id === "result" && a.path && node_fs_1.default.existsSync(a.path));
734
+ if (retained && retained.path) {
735
+ artifact.path = retained.path;
736
+ changed = true;
737
+ }
738
+ }
739
+ }
740
+ if (changed) {
741
+ node.updatedAt = new Date().toISOString();
742
+ changedIds.push(node.id);
743
+ }
744
+ }
745
+ return changedIds;
746
+ }
747
+ /** Execute the write-ahead, fail-closed reclamation transaction. Ordering is the
748
+ * safety property: extract+seal skeleton → [under the per-run lock: build
749
+ * tombstone → commit (fsync)] → re-point + DURABLY persist state + prove no
750
+ * dangling reference → free bulk. The lock (P1-C) makes the chain read-modify-
751
+ * write atomic so a concurrent reclaimer can never lose a tombstone. The durable
752
+ * re-point BEFORE free (P1-A) means a crash can never leave state.json pointing
753
+ * at a freed path. `faultAfter` aborts after the named step so crash-safety is
754
+ * testable by design — a crash leaves EITHER the full run OR a complete
755
+ * tombstone, never a half-deleted run with no proof. */
756
+ function runReclamation(run, options = {}) {
757
+ // STEP 1 — extract + seal skeleton. Fail closed if incomplete (free nothing).
758
+ const skeleton = extractSkeleton(run);
759
+ const missing = validateSkeleton(skeleton);
760
+ if (missing.length) {
761
+ throw new ReclamationError("skeleton-incomplete", `Skeleton missing required keys: ${missing.join(", ")}`, { missing });
762
+ }
763
+ // P2-A: also refuse if extraction dropped audit content the run actually has.
764
+ const contentLoss = validateSkeletonAgainstRun(run, skeleton);
765
+ if (contentLoss.length) {
766
+ throw new ReclamationError("skeleton-incomplete", `Skeleton dropped audit content: ${contentLoss.join(", ")}`, { contentLoss });
767
+ }
768
+ if (options.faultAfter === "skeleton")
769
+ throw new ReclamationAbort("skeleton");
770
+ // STEPS 2-3 — under the per-run lock so the chain's read (prevTombstoneHash) and
771
+ // append are atomic: build the full tombstone (pre-deletion sha256 + chain) and
772
+ // commit it durably (fsync) into the append-only overlay.
773
+ const { plan, tombstone } = withRunLock(run, () => {
774
+ const builtPlan = planReclamation(run, options.reclaimPolicy || {});
775
+ const builtTombstone = buildTombstone(run, skeleton, builtPlan, { now: options.now, actor: options.actor, policy: options.policy });
776
+ if (options.faultAfter === "tombstone-write")
777
+ throw new ReclamationAbort("tombstone-write");
778
+ commitTombstone(run, builtTombstone);
779
+ return { plan: builtPlan, tombstone: builtTombstone };
780
+ });
781
+ if (options.faultAfter === "tombstone-commit")
782
+ throw new ReclamationAbort("tombstone-commit");
783
+ // STEP 4 — re-point surviving nodes off the scratch, DURABLY persist that
784
+ // state change, and PROVE no node references a freed path — all before freeing.
785
+ prepareFree(run, tombstone);
786
+ // STEP 5 — ONLY NOW free the bulk bytes.
787
+ const bytesFreed = freeBulk(run, tombstone);
788
+ return { tombstone, bytesFreed, plan };
789
+ }
790
+ // ---------------------------------------------------------------------------
791
+ // Reconstruction — re-derive a freed artifact from its RETAINED inputs, NEVER
792
+ // the freed source bytes. Distinct code path from live verifyNodeReplay.
793
+ // ---------------------------------------------------------------------------
794
+ /** Re-derive a reconstructable artifact's expectDigest from the retained run.
795
+ * Returns the recomputed digest (to compare to recipe.expectDigest). */
796
+ function reconstructArtifact(run, recipe) {
797
+ if (recipe.recipeKind === "node-snapshot-projection") {
798
+ const node = (run.nodes || []).find((n) => n.id === recipe.sourceRef);
799
+ if (!node) {
800
+ return { inputsDigest: sha256OfString("absent"), expectDigest: sha256OfString("absent") };
801
+ }
802
+ const inputDigest = nodeBodyDigest(node);
803
+ const inputsDigest = sha256OfString((0, multi_agent_eval_1.stableStringify)([inputDigest]));
804
+ const expectDigest = snapshotProjectionDigest(node);
805
+ return { inputsDigest, expectDigest };
806
+ }
807
+ // Unknown recipe kind → fail closed (digest can't match expectDigest).
808
+ return { inputsDigest: sha256OfString("unknown-recipe"), expectDigest: sha256OfString("unknown-recipe") };
809
+ }
810
+ /** Re-prove the whole reclamation chain for a run: skeleton schema-complete,
811
+ * tombstoneHash/prevTombstoneHash chain recomputed-and-untampered, and each
812
+ * reconstructable artifact re-derived from RETAINED inputs to its expectDigest.
813
+ * Recomputes every hash independently — never trusts the stored value. */
814
+ function verifyReclamation(run) {
815
+ const log = loadReclamationLog(run);
816
+ const tombstones = log.tombstones;
817
+ const checks = [];
818
+ if (!tombstones.length) {
819
+ return { reclaimed: false, verified: false, checks: [{ name: "reclaimed", pass: false, code: "not-reclaimed" }], tombstones };
820
+ }
821
+ // (a) chain linkage FIRST (priority): genesis = sha256 of the (first) skeleton.
822
+ let chainOk = true;
823
+ for (let i = 0; i < tombstones.length; i++) {
824
+ const expectedPrev = i === 0 ? genesisPrevHash(tombstones[0].skeleton) : tombstones[i - 1].tombstoneHash;
825
+ const pass = tombstones[i].prevTombstoneHash === expectedPrev;
826
+ if (!pass)
827
+ chainOk = false;
828
+ checks.push({ name: `chain-link[${i}]`, pass, code: pass ? undefined : "tombstone-chain-broken" });
829
+ }
830
+ // (b) per-tombstone independent hash recompute (digest integrity).
831
+ let digestsOk = true;
832
+ for (let i = 0; i < tombstones.length; i++) {
833
+ const { tombstoneHash, ...rest } = tombstones[i];
834
+ const recomputed = computeTombstoneHash(rest);
835
+ const pass = recomputed === tombstoneHash;
836
+ if (!pass)
837
+ digestsOk = false;
838
+ checks.push({ name: `tombstone-hash[${i}]`, pass, code: pass ? undefined : "tombstone-digest-mismatch" });
839
+ }
840
+ // (c) skeleton schema completeness (each tombstone seals a complete skeleton).
841
+ let skeletonOk = true;
842
+ for (let i = 0; i < tombstones.length; i++) {
843
+ const missing = validateSkeleton(tombstones[i].skeleton);
844
+ const pass = missing.length === 0;
845
+ if (!pass)
846
+ skeletonOk = false;
847
+ checks.push({ name: `skeleton[${i}]`, pass, code: pass ? undefined : "skeleton-incomplete", detail: missing.join(",") || undefined });
848
+ }
849
+ // (d) reconstruction — re-derive each reconstructable artifact from RETAINED
850
+ // inputs (NOT the freed source) to its expectDigest.
851
+ let reconstructionOk = true;
852
+ for (let i = 0; i < tombstones.length; i++) {
853
+ for (const entry of tombstones[i].freed) {
854
+ if (!entry.recipe)
855
+ continue;
856
+ const recomputed = reconstructArtifact(run, entry.recipe);
857
+ const inputsMatch = recomputed.inputsDigest === entry.recipe.inputsDigest;
858
+ const expectMatch = recomputed.expectDigest === entry.recipe.expectDigest;
859
+ const pass = inputsMatch && expectMatch;
860
+ if (!pass)
861
+ reconstructionOk = false;
862
+ checks.push({
863
+ name: `reconstruct[${i}]:${entry.path}`,
864
+ pass,
865
+ code: pass ? undefined : "reconstruction-digest-mismatch",
866
+ detail: pass ? undefined : `inputs=${inputsMatch} expect=${expectMatch}`
867
+ });
868
+ }
869
+ }
870
+ const verified = chainOk && digestsOk && skeletonOk && reconstructionOk;
871
+ return { reclaimed: true, verified, checks, tombstones };
872
+ }
873
+ /** Pick the priority failure code from a check list (chain > digest >
874
+ * reconstruction > skeleton). Used to surface the single dominant code. */
875
+ function dominantFailureCode(checks) {
876
+ const order = ["tombstone-chain-broken", "tombstone-digest-mismatch", "reconstruction-digest-mismatch", "skeleton-incomplete", "not-reclaimed"];
877
+ for (const code of order) {
878
+ if (checks.some((c) => !c.pass && c.code === code))
879
+ return code;
880
+ }
881
+ return undefined;
882
+ }