@llblab/pi-actors 0.41.0 → 0.42.0

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 (150) hide show
  1. package/AGENTS.md +13 -8
  2. package/BACKLOG.md +2 -81
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +22 -4
  5. package/dist/index.js +29 -82
  6. package/dist/lib/async-runs.d.ts +25 -5
  7. package/dist/lib/async-runs.js +136 -47
  8. package/dist/lib/automatic-review-runtime.d.ts +18 -0
  9. package/dist/lib/automatic-review-runtime.js +96 -0
  10. package/dist/lib/draft-consolidation-transaction.d.ts +65 -0
  11. package/dist/lib/draft-consolidation-transaction.js +610 -0
  12. package/dist/lib/draft-consolidation.d.ts +35 -0
  13. package/dist/lib/draft-consolidation.js +126 -0
  14. package/dist/lib/draft-review.d.ts +56 -0
  15. package/dist/lib/draft-review.js +254 -0
  16. package/dist/lib/draft-sleep.d.ts +65 -0
  17. package/dist/lib/draft-sleep.js +468 -0
  18. package/dist/lib/file-state.d.ts +8 -1
  19. package/dist/lib/file-state.js +115 -18
  20. package/dist/lib/inspector-actions.d.ts +16 -0
  21. package/dist/lib/inspector-actions.js +57 -0
  22. package/dist/lib/inspector-command.d.ts +7 -0
  23. package/dist/lib/inspector-command.js +37 -0
  24. package/dist/lib/inspector-overlay.d.ts +29 -1
  25. package/dist/lib/inspector-overlay.js +537 -90
  26. package/dist/lib/inspector.d.ts +9 -0
  27. package/dist/lib/inspector.js +52 -1
  28. package/dist/lib/observability.d.ts +39 -3
  29. package/dist/lib/observability.js +162 -34
  30. package/dist/lib/paths.d.ts +7 -0
  31. package/dist/lib/paths.js +28 -0
  32. package/dist/lib/recipes-discovery.js +32 -24
  33. package/dist/lib/recipes-usage.d.ts +18 -6
  34. package/dist/lib/recipes-usage.js +445 -34
  35. package/dist/lib/review-control.d.ts +14 -0
  36. package/dist/lib/review-control.js +111 -0
  37. package/dist/lib/review-diagnostics.d.ts +11 -0
  38. package/dist/lib/review-diagnostics.js +148 -0
  39. package/dist/lib/review-projection.d.ts +14 -0
  40. package/dist/lib/review-projection.js +170 -0
  41. package/dist/lib/run-ui-runtime.d.ts +18 -0
  42. package/dist/lib/run-ui-runtime.js +123 -0
  43. package/dist/lib/runs-artifacts.d.ts +1 -1
  44. package/dist/lib/runs-artifacts.js +1 -1
  45. package/dist/lib/runs-control.d.ts +8 -2
  46. package/dist/lib/runs-control.js +23 -6
  47. package/dist/lib/runs-identity.d.ts +1 -1
  48. package/dist/lib/runs-identity.js +1 -1
  49. package/dist/lib/runs-index.d.ts +11 -2
  50. package/dist/lib/runs-index.js +46 -23
  51. package/dist/lib/runs-mailbox.d.ts +1 -1
  52. package/dist/lib/runs-mailbox.js +1 -1
  53. package/dist/lib/runs-messages.d.ts +1 -1
  54. package/dist/lib/runs-messages.js +1 -1
  55. package/dist/lib/runs-outbox.d.ts +1 -1
  56. package/dist/lib/runs-outbox.js +1 -1
  57. package/dist/lib/runs-ownership.d.ts +1 -1
  58. package/dist/lib/runs-ownership.js +1 -1
  59. package/dist/lib/runs-parent-teardown.d.ts +51 -0
  60. package/dist/lib/runs-parent-teardown.js +172 -0
  61. package/dist/lib/runs-process.d.ts +1 -1
  62. package/dist/lib/runs-process.js +1 -1
  63. package/dist/lib/runs-retention.d.ts +1 -1
  64. package/dist/lib/runs-retention.js +1 -1
  65. package/dist/lib/runs-start.d.ts +5 -3
  66. package/dist/lib/runs-start.js +6 -48
  67. package/dist/lib/runs-status.d.ts +5 -3
  68. package/dist/lib/runs-status.js +4 -6
  69. package/dist/lib/runtime.d.ts +7 -1
  70. package/dist/lib/runtime.js +32 -18
  71. package/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
  72. package/dist/lib/tool-review-lineage-transaction.js +597 -0
  73. package/dist/lib/tool-review-lineage.d.ts +24 -0
  74. package/dist/lib/tool-review-lineage.js +98 -0
  75. package/dist/lib/tool-review-scheduler.d.ts +80 -0
  76. package/dist/lib/tool-review-scheduler.js +494 -0
  77. package/dist/lib/tool-review-transaction.d.ts +50 -0
  78. package/dist/lib/tool-review-transaction.js +362 -0
  79. package/dist/lib/tool-review.d.ts +56 -0
  80. package/dist/lib/tool-review.js +197 -0
  81. package/dist/lib/tools-inspect.js +26 -3
  82. package/dist/lib/tools-local.js +4 -2
  83. package/dist/lib/tools-message.d.ts +1 -0
  84. package/dist/lib/tools-message.js +29 -17
  85. package/dist/lib/tools-response.d.ts +1 -1
  86. package/dist/lib/tools-response.js +5 -7
  87. package/dist/lib/tools-spawn.js +4 -1
  88. package/dist/lib/tools.d.ts +1 -0
  89. package/dist/lib/tools.js +1 -0
  90. package/dist/recipes/draft-review.json +24 -0
  91. package/dist/recipes/tool-review.json +24 -0
  92. package/dist/scripts/release-gates.mjs +165 -0
  93. package/dist/skills/actors/SKILL.md +11 -14
  94. package/dist/skills/swarm/SKILL.md +1 -1
  95. package/docs/actor-inspector.md +32 -18
  96. package/docs/async-runs.md +10 -2
  97. package/docs/recipe-library.md +7 -3
  98. package/docs/template-recipes.md +4 -11
  99. package/docs/tool-registry.md +11 -4
  100. package/index.ts +29 -103
  101. package/lib/async-runs.ts +218 -62
  102. package/lib/automatic-review-runtime.ts +135 -0
  103. package/lib/draft-consolidation-transaction.ts +821 -0
  104. package/lib/draft-consolidation.ts +181 -0
  105. package/lib/draft-review.ts +325 -0
  106. package/lib/draft-sleep.ts +576 -0
  107. package/lib/file-state.ts +143 -19
  108. package/lib/inspector-actions.ts +79 -0
  109. package/lib/inspector-command.ts +54 -0
  110. package/lib/inspector-overlay.ts +675 -105
  111. package/lib/inspector.ts +78 -3
  112. package/lib/observability.ts +219 -40
  113. package/lib/paths.ts +43 -0
  114. package/lib/recipes-discovery.ts +34 -26
  115. package/lib/recipes-usage.ts +569 -40
  116. package/lib/review-control.ts +137 -0
  117. package/lib/review-diagnostics.ts +164 -0
  118. package/lib/review-projection.ts +200 -0
  119. package/lib/run-ui-runtime.ts +153 -0
  120. package/lib/runs-artifacts.ts +1 -1
  121. package/lib/runs-control.ts +49 -5
  122. package/lib/runs-identity.ts +1 -1
  123. package/lib/runs-index.ts +57 -21
  124. package/lib/runs-mailbox.ts +1 -1
  125. package/lib/runs-messages.ts +1 -1
  126. package/lib/runs-outbox.ts +1 -1
  127. package/lib/runs-ownership.ts +1 -1
  128. package/lib/runs-parent-teardown.ts +257 -0
  129. package/lib/runs-process.ts +1 -1
  130. package/lib/runs-retention.ts +1 -1
  131. package/lib/runs-start.ts +12 -68
  132. package/lib/runs-status.ts +12 -8
  133. package/lib/runtime.ts +34 -17
  134. package/lib/tool-review-lineage-transaction.ts +881 -0
  135. package/lib/tool-review-lineage.ts +145 -0
  136. package/lib/tool-review-scheduler.ts +635 -0
  137. package/lib/tool-review-transaction.ts +563 -0
  138. package/lib/tool-review.ts +270 -0
  139. package/lib/tools-inspect.ts +33 -3
  140. package/lib/tools-local.ts +8 -2
  141. package/lib/tools-message.ts +45 -30
  142. package/lib/tools-response.ts +5 -6
  143. package/lib/tools-spawn.ts +8 -1
  144. package/lib/tools.ts +5 -0
  145. package/package.json +3 -2
  146. package/recipes/draft-review.json +24 -0
  147. package/recipes/tool-review.json +24 -0
  148. package/scripts/release-gates.mjs +165 -0
  149. package/skills/actors/SKILL.md +11 -14
  150. package/skills/swarm/SKILL.md +1 -1
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Journaled active-tool portfolio mutation.
3
+ * Zones: approved-plan CAS, source quarantine, target commit, crash recovery
4
+ * Owns deterministic filesystem mutation; reviewer policy and runtime activation remain separate domains.
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import {
9
+ existsSync,
10
+ lstatSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ renameSync,
15
+ rmSync,
16
+ statSync,
17
+ } from "node:fs";
18
+ import {
19
+ basename,
20
+ dirname,
21
+ isAbsolute,
22
+ join,
23
+ relative,
24
+ resolve,
25
+ sep,
26
+ } from "node:path";
27
+
28
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
29
+ import * as RecipesReferences from "./recipes-references.ts";
30
+ import type { ToolReviewDecision } from "./tool-review.ts";
31
+
32
+ export interface ToolReviewApprovedSource {
33
+ action: ToolReviewDecision["action"];
34
+ name: string;
35
+ path: string;
36
+ sha256: string;
37
+ }
38
+
39
+ export interface ToolReviewApprovedTarget {
40
+ expectedSha256: string | null;
41
+ lineage: "demote" | "evolve" | "merge" | "replace" | "split";
42
+ name: string;
43
+ path: string;
44
+ recipe: Record<string, unknown>;
45
+ sources: string[];
46
+ }
47
+
48
+ export interface ToolReviewApprovedPlan {
49
+ createdAt: string;
50
+ decisions: ToolReviewDecision[];
51
+ reviewId: string;
52
+ sources: ToolReviewApprovedSource[];
53
+ targets: ToolReviewApprovedTarget[];
54
+ }
55
+
56
+ export type ToolReviewTransactionPhase =
57
+ | "committed"
58
+ | "prepared"
59
+ | "rollback_required"
60
+ | "rolled_back"
61
+ | "sources_quarantined"
62
+ | "targets_written";
63
+
64
+ interface ToolReviewRootIdentity {
65
+ dev: string;
66
+ ino: string;
67
+ path: string;
68
+ realpath: string;
69
+ }
70
+
71
+ interface ToolReviewTransactionOperations {
72
+ sources: Array<{ original: string; quarantine: string; sha256: string }>;
73
+ targets: Array<{ path: string; sha256: string }>;
74
+ }
75
+
76
+ interface ToolReviewTransactionJournal {
77
+ approvedSha256: string;
78
+ operations: ToolReviewTransactionOperations;
79
+ operationsSha256: string;
80
+ phase: ToolReviewTransactionPhase;
81
+ quarantined: Array<{ original: string; quarantine: string; sha256: string }>;
82
+ reviewId: string;
83
+ roots: {
84
+ cycleDir: ToolReviewRootIdentity;
85
+ quarantineDir: ToolReviewRootIdentity;
86
+ recipeRoot: ToolReviewRootIdentity;
87
+ };
88
+ updatedAt: string;
89
+ writtenTargets: Array<{ path: string; sha256: string }>;
90
+ }
91
+
92
+ export interface ToolReviewTransactionResult {
93
+ evidencePath?: string;
94
+ journalPath: string;
95
+ phase: ToolReviewTransactionPhase;
96
+ quarantineDir: string;
97
+ }
98
+
99
+ export type ToolReviewTransactionCheckpoint =
100
+ | "evidence_written"
101
+ | "prepared"
102
+ | "source_quarantined"
103
+ | "sources_quarantined"
104
+ | "target_written"
105
+ | "targets_written";
106
+
107
+ export interface ToolReviewTransactionOptions {
108
+ checkpoint?(checkpoint: ToolReviewTransactionCheckpoint): void;
109
+ now?(): Date;
110
+ recipeRoot: string;
111
+ }
112
+
113
+ function sha256Bytes(value: Buffer | string): string {
114
+ return createHash("sha256").update(value).digest("hex");
115
+ }
116
+
117
+ function sha256File(path: string): string {
118
+ return sha256Bytes(readFileSync(path));
119
+ }
120
+
121
+ function canonicalJson(value: unknown): string {
122
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
123
+ if (!value || typeof value !== "object") return JSON.stringify(value);
124
+ const record = value as Record<string, unknown>;
125
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
126
+ }
127
+
128
+ interface PathContainmentApi {
129
+ isAbsolute(path: string): boolean;
130
+ relative(from: string, to: string): string;
131
+ resolve(...paths: string[]): string;
132
+ sep: string;
133
+ }
134
+
135
+ export function isPathContained(
136
+ path: string,
137
+ root: string,
138
+ pathApi: PathContainmentApi = { isAbsolute, relative, resolve, sep },
139
+ ): boolean {
140
+ const relation = pathApi.relative(pathApi.resolve(root), pathApi.resolve(path));
141
+ return Boolean(
142
+ relation &&
143
+ relation !== ".." &&
144
+ !relation.startsWith(`..${pathApi.sep}`) &&
145
+ !pathApi.isAbsolute(relation),
146
+ );
147
+ }
148
+
149
+ function assertNoSymlinkComponents(path: string, root: string): void {
150
+ const absoluteRoot = resolve(root);
151
+ if (!existsSync(absoluteRoot) || lstatSync(absoluteRoot).isSymbolicLink()) {
152
+ throw new Error(`Invalid tool review transaction root: ${absoluteRoot}`);
153
+ }
154
+ if (!isPathContained(path, absoluteRoot)) {
155
+ throw new Error(`Tool review path escapes transaction root: ${path}`);
156
+ }
157
+ let current = absoluteRoot;
158
+ for (const segment of relative(absoluteRoot, resolve(path)).split(/[\\/]/u)) {
159
+ current = join(current, segment);
160
+ if (!existsSync(current)) break;
161
+ if (lstatSync(current).isSymbolicLink()) {
162
+ throw new Error(`Symlink is not allowed in tool review transaction: ${current}`);
163
+ }
164
+ }
165
+ }
166
+
167
+ function captureRootIdentity(path: string): ToolReviewRootIdentity {
168
+ const absolute = resolve(path);
169
+ const lexical = lstatSync(absolute);
170
+ if (!lexical.isDirectory() || lexical.isSymbolicLink()) {
171
+ throw new Error(`Invalid tool review transaction root: ${absolute}`);
172
+ }
173
+ const canonical = realpathSync.native(absolute);
174
+ const stat = statSync(canonical, { bigint: true });
175
+ return {
176
+ dev: String(stat.dev),
177
+ ino: String(stat.ino),
178
+ path: absolute,
179
+ realpath: canonical,
180
+ };
181
+ }
182
+
183
+ function captureRootIdentities(
184
+ cycleDir: string,
185
+ quarantineDir: string,
186
+ recipeRoot: string,
187
+ ): ToolReviewTransactionJournal["roots"] {
188
+ return {
189
+ cycleDir: captureRootIdentity(cycleDir),
190
+ quarantineDir: captureRootIdentity(quarantineDir),
191
+ recipeRoot: captureRootIdentity(recipeRoot),
192
+ };
193
+ }
194
+
195
+ function verifyRootIdentities(
196
+ roots: ToolReviewTransactionJournal["roots"],
197
+ cycleDir: string,
198
+ quarantineDir: string,
199
+ recipeRoot: string,
200
+ ): void {
201
+ const current = captureRootIdentities(cycleDir, quarantineDir, recipeRoot);
202
+ for (const key of ["cycleDir", "quarantineDir", "recipeRoot"] as const) {
203
+ const expected = roots?.[key];
204
+ if (
205
+ !expected ||
206
+ expected.path !== current[key].path ||
207
+ expected.realpath !== current[key].realpath ||
208
+ expected.dev !== current[key].dev ||
209
+ expected.ino !== current[key].ino
210
+ ) {
211
+ throw new Error(`Tool review transaction root changed: ${key}`);
212
+ }
213
+ }
214
+ }
215
+
216
+ function deriveOperations(
217
+ plan: ToolReviewApprovedPlan,
218
+ quarantineDir: string,
219
+ ): ToolReviewTransactionOperations {
220
+ const sources = changedSources(plan).map((source, index) => ({
221
+ original: resolve(source.path),
222
+ quarantine: join(quarantineDir, `${index}-${basename(source.path)}`),
223
+ sha256: source.sha256,
224
+ }));
225
+ const targets = plan.targets.map((target) => ({
226
+ path: resolve(target.path),
227
+ sha256: sha256Bytes(`${JSON.stringify(target.recipe, null, 2)}\n`),
228
+ }));
229
+ return { sources, targets };
230
+ }
231
+
232
+ function assertPlan(plan: ToolReviewApprovedPlan, recipeRoot: string): void {
233
+ if (
234
+ !/^[a-f0-9-]{36}$/u.test(plan.reviewId) ||
235
+ plan.sources.length !== 36 ||
236
+ plan.decisions.length !== 36
237
+ ) {
238
+ throw new Error("Invalid approved tool review plan identity.");
239
+ }
240
+ const sourcePaths = new Set<string>();
241
+ for (const source of plan.sources) {
242
+ if (!isPathContained(source.path, recipeRoot) || sourcePaths.has(resolve(source.path))) {
243
+ throw new Error(`Invalid or duplicate tool review source: ${source.path}`);
244
+ }
245
+ sourcePaths.add(resolve(source.path));
246
+ }
247
+ const targetPaths = new Set<string>();
248
+ for (const target of plan.targets) {
249
+ if (!isPathContained(target.path, recipeRoot) || targetPaths.has(resolve(target.path))) {
250
+ throw new Error(`Invalid or duplicate tool review target: ${target.path}`);
251
+ }
252
+ targetPaths.add(resolve(target.path));
253
+ if (!target.recipe || typeof target.recipe !== "object" || !("template" in target.recipe)) {
254
+ throw new Error(`Incomplete tool review target recipe: ${target.path}`);
255
+ }
256
+ }
257
+ }
258
+
259
+ function readPlan(path: string): ToolReviewApprovedPlan {
260
+ return JSON.parse(readFileSync(path, "utf8")) as ToolReviewApprovedPlan;
261
+ }
262
+
263
+ function readJournal(
264
+ path: string,
265
+ plan: ToolReviewApprovedPlan,
266
+ cycleDir: string,
267
+ quarantineDir: string,
268
+ recipeRoot: string,
269
+ ): ToolReviewTransactionJournal {
270
+ const journal = JSON.parse(readFileSync(path, "utf8")) as ToolReviewTransactionJournal;
271
+ const phases: ToolReviewTransactionPhase[] = [
272
+ "committed",
273
+ "prepared",
274
+ "rollback_required",
275
+ "rolled_back",
276
+ "sources_quarantined",
277
+ "targets_written",
278
+ ];
279
+ const operations = deriveOperations(plan, quarantineDir);
280
+ if (
281
+ journal.reviewId !== plan.reviewId ||
282
+ journal.approvedSha256 !== sha256Bytes(canonicalJson(plan)) ||
283
+ !phases.includes(journal.phase) ||
284
+ journal.operationsSha256 !== sha256Bytes(canonicalJson(journal.operations)) ||
285
+ canonicalJson(journal.operations) !== canonicalJson(operations) ||
286
+ journal.quarantined.length > operations.sources.length ||
287
+ journal.writtenTargets.length > operations.targets.length ||
288
+ canonicalJson(journal.quarantined) !==
289
+ canonicalJson(operations.sources.slice(0, journal.quarantined.length)) ||
290
+ canonicalJson(journal.writtenTargets) !==
291
+ canonicalJson(operations.targets.slice(0, journal.writtenTargets.length))
292
+ ) {
293
+ throw new Error("Invalid or mismatched tool review transaction journal.");
294
+ }
295
+ verifyRootIdentities(journal.roots, cycleDir, quarantineDir, recipeRoot);
296
+ for (const source of operations.sources) {
297
+ assertNoSymlinkComponents(source.original, recipeRoot);
298
+ assertNoSymlinkComponents(source.quarantine, cycleDir);
299
+ }
300
+ for (const target of operations.targets) {
301
+ assertNoSymlinkComponents(target.path, recipeRoot);
302
+ }
303
+ return journal;
304
+ }
305
+
306
+ function writeJournal(path: string, journal: ToolReviewTransactionJournal): void {
307
+ writeJsonAtomic(path, journal);
308
+ }
309
+
310
+ function changedSources(plan: ToolReviewApprovedPlan): ToolReviewApprovedSource[] {
311
+ return plan.sources.filter((source) => source.action !== "keep");
312
+ }
313
+
314
+ function assertCas(plan: ToolReviewApprovedPlan): void {
315
+ for (const source of plan.sources) {
316
+ if (!existsSync(source.path) || sha256File(source.path) !== source.sha256) {
317
+ throw new Error(`Tool review source CAS failed: ${source.path}`);
318
+ }
319
+ }
320
+ for (const target of plan.targets) {
321
+ if (target.expectedSha256 === null) {
322
+ if (existsSync(target.path)) throw new Error(`Tool review target appeared: ${target.path}`);
323
+ } else if (!existsSync(target.path) || sha256File(target.path) !== target.expectedSha256) {
324
+ throw new Error(`Tool review target CAS failed: ${target.path}`);
325
+ }
326
+ }
327
+ }
328
+
329
+ function rollback(
330
+ journalPath: string,
331
+ journal: ToolReviewTransactionJournal,
332
+ now: Date,
333
+ ): ToolReviewTransactionJournal {
334
+ for (const target of [...journal.writtenTargets].reverse()) {
335
+ if (!existsSync(target.path)) continue;
336
+ if (sha256File(target.path) !== target.sha256) {
337
+ const failed = { ...journal, phase: "rollback_required" as const, updatedAt: now.toISOString() };
338
+ writeJournal(journalPath, failed);
339
+ return failed;
340
+ }
341
+ rmSync(target.path);
342
+ }
343
+ for (const source of [...journal.quarantined].reverse()) {
344
+ const originalExists = existsSync(source.original);
345
+ const quarantineExists = existsSync(source.quarantine);
346
+ if (
347
+ originalExists &&
348
+ !quarantineExists &&
349
+ sha256File(source.original) === source.sha256
350
+ ) continue;
351
+ if (
352
+ originalExists ||
353
+ !quarantineExists ||
354
+ sha256File(source.quarantine) !== source.sha256
355
+ ) {
356
+ const failed = { ...journal, phase: "rollback_required" as const, updatedAt: now.toISOString() };
357
+ writeJournal(journalPath, failed);
358
+ return failed;
359
+ }
360
+ mkdirSync(dirname(source.original), { recursive: true });
361
+ renameSync(source.quarantine, source.original);
362
+ }
363
+ const rolledBack = {
364
+ ...journal,
365
+ phase: "rolled_back" as const,
366
+ updatedAt: now.toISOString(),
367
+ writtenTargets: [],
368
+ };
369
+ writeJournal(journalPath, rolledBack);
370
+ return rolledBack;
371
+ }
372
+
373
+ function commitEvidence(
374
+ cycleDir: string,
375
+ journalPath: string,
376
+ journal: ToolReviewTransactionJournal,
377
+ plan: ToolReviewApprovedPlan,
378
+ now: Date,
379
+ checkpoint?: ToolReviewTransactionOptions["checkpoint"],
380
+ ): ToolReviewTransactionResult {
381
+ verifyRootIdentities(
382
+ journal.roots,
383
+ cycleDir,
384
+ join(cycleDir, "quarantine"),
385
+ journal.roots.recipeRoot.path,
386
+ );
387
+ if (journal.writtenTargets.length !== journal.operations.targets.length) {
388
+ throw new Error("Committed tool review targets are incomplete.");
389
+ }
390
+ for (const target of journal.writtenTargets) {
391
+ if (!existsSync(target.path) || sha256File(target.path) !== target.sha256) {
392
+ throw new Error(`Committed tool review target changed: ${target.path}`);
393
+ }
394
+ }
395
+ if (journal.quarantined.length !== journal.operations.sources.length) {
396
+ throw new Error("Committed tool review quarantine is incomplete.");
397
+ }
398
+ for (const source of journal.quarantined) {
399
+ const replacement = journal.operations.targets.find(
400
+ (target) => resolve(target.path) === resolve(source.original),
401
+ );
402
+ if (
403
+ existsSync(source.original) &&
404
+ (!replacement || sha256File(source.original) !== replacement.sha256)
405
+ ) {
406
+ throw new Error(`Committed tool review source changed: ${source.original}`);
407
+ }
408
+ if (
409
+ !existsSync(source.quarantine) ||
410
+ sha256File(source.quarantine) !== source.sha256
411
+ ) {
412
+ throw new Error(`Committed tool review quarantine changed: ${source.quarantine}`);
413
+ }
414
+ }
415
+ const evidencePath = join(cycleDir, "evidence.json");
416
+ writeJsonAtomic(evidencePath, {
417
+ actions: Object.fromEntries(
418
+ [...new Set(plan.sources.map((source) => source.action))].map((action) => [
419
+ action,
420
+ plan.sources.filter((source) => source.action === action).length,
421
+ ])),
422
+ reviewId: plan.reviewId,
423
+ sources: plan.sources.length,
424
+ targets: plan.targets.length,
425
+ ts: now.toISOString(),
426
+ });
427
+ checkpoint?.("evidence_written");
428
+ const committed = { ...journal, phase: "committed" as const, updatedAt: now.toISOString() };
429
+ writeJournal(journalPath, committed);
430
+ return {
431
+ evidencePath,
432
+ journalPath,
433
+ phase: "committed",
434
+ quarantineDir: join(cycleDir, "quarantine"),
435
+ };
436
+ }
437
+
438
+ export function applyToolReviewPlan(
439
+ approvedPath: string,
440
+ options: ToolReviewTransactionOptions,
441
+ ): ToolReviewTransactionResult {
442
+ const plan = readPlan(approvedPath);
443
+ assertPlan(plan, options.recipeRoot);
444
+ const cycleDir = dirname(approvedPath);
445
+ const journalPath = join(cycleDir, "journal.json");
446
+ const quarantineDir = join(cycleDir, "quarantine");
447
+ const now = options.now ?? (() => new Date());
448
+ return withFileMutationLock(options.recipeRoot, () => {
449
+ if (existsSync(journalPath)) {
450
+ throw new Error(`Tool review transaction already exists: ${plan.reviewId}`);
451
+ }
452
+ mkdirSync(quarantineDir, { recursive: true });
453
+ const operations = deriveOperations(plan, quarantineDir);
454
+ const roots = captureRootIdentities(
455
+ cycleDir,
456
+ quarantineDir,
457
+ options.recipeRoot,
458
+ );
459
+ for (const source of operations.sources) {
460
+ assertNoSymlinkComponents(source.original, options.recipeRoot);
461
+ assertNoSymlinkComponents(source.quarantine, cycleDir);
462
+ }
463
+ for (const target of operations.targets) {
464
+ assertNoSymlinkComponents(target.path, options.recipeRoot);
465
+ }
466
+ assertCas(plan);
467
+ let journal: ToolReviewTransactionJournal = {
468
+ approvedSha256: sha256Bytes(canonicalJson(plan)),
469
+ operations,
470
+ operationsSha256: sha256Bytes(canonicalJson(operations)),
471
+ phase: "prepared",
472
+ quarantined: [],
473
+ reviewId: plan.reviewId,
474
+ roots,
475
+ updatedAt: now().toISOString(),
476
+ writtenTargets: [],
477
+ };
478
+ writeJournal(journalPath, journal);
479
+ options.checkpoint?.("prepared");
480
+ try {
481
+ for (const source of operations.sources) {
482
+ journal = {
483
+ ...journal,
484
+ quarantined: [...journal.quarantined, source],
485
+ updatedAt: now().toISOString(),
486
+ };
487
+ writeJournal(journalPath, journal);
488
+ renameSync(source.original, source.quarantine);
489
+ options.checkpoint?.("source_quarantined");
490
+ }
491
+ journal = { ...journal, phase: "sources_quarantined", updatedAt: now().toISOString() };
492
+ writeJournal(journalPath, journal);
493
+ options.checkpoint?.("sources_quarantined");
494
+ for (const [index, target] of plan.targets.entries()) {
495
+ const operation = operations.targets[index]!;
496
+ mkdirSync(dirname(operation.path), { recursive: true });
497
+ journal = {
498
+ ...journal,
499
+ writtenTargets: [...journal.writtenTargets, operation],
500
+ updatedAt: now().toISOString(),
501
+ };
502
+ writeJournal(journalPath, journal);
503
+ writeJsonAtomic(operation.path, target.recipe);
504
+ if (
505
+ sha256File(operation.path) !== operation.sha256 ||
506
+ !RecipesReferences.readResolvedRecipeConfig(operation.path)
507
+ ) {
508
+ throw new Error(`Written tool review target is invalid: ${operation.path}`);
509
+ }
510
+ options.checkpoint?.("target_written");
511
+ }
512
+ journal = { ...journal, phase: "targets_written", updatedAt: now().toISOString() };
513
+ writeJournal(journalPath, journal);
514
+ options.checkpoint?.("targets_written");
515
+ return commitEvidence(
516
+ cycleDir,
517
+ journalPath,
518
+ journal,
519
+ plan,
520
+ now(),
521
+ options.checkpoint,
522
+ );
523
+ } catch (error) {
524
+ const result = rollback(journalPath, journal, now());
525
+ if (result.phase === "rollback_required") {
526
+ throw new Error(`Tool review rollback requires operator attention: ${plan.reviewId}`);
527
+ }
528
+ throw error;
529
+ }
530
+ });
531
+ }
532
+
533
+ export function recoverToolReviewTransaction(
534
+ approvedPath: string,
535
+ options: ToolReviewTransactionOptions,
536
+ ): ToolReviewTransactionResult {
537
+ const plan = readPlan(approvedPath);
538
+ assertPlan(plan, options.recipeRoot);
539
+ const cycleDir = dirname(approvedPath);
540
+ const journalPath = join(cycleDir, "journal.json");
541
+ const quarantineDir = join(cycleDir, "quarantine");
542
+ const now = options.now ?? (() => new Date());
543
+ return withFileMutationLock(options.recipeRoot, () => {
544
+ const journal = readJournal(
545
+ journalPath,
546
+ plan,
547
+ cycleDir,
548
+ quarantineDir,
549
+ options.recipeRoot,
550
+ );
551
+ if (journal.phase === "committed") {
552
+ return commitEvidence(cycleDir, journalPath, journal, plan, now());
553
+ }
554
+ if (journal.phase === "targets_written") {
555
+ return commitEvidence(cycleDir, journalPath, journal, plan, now());
556
+ }
557
+ if (journal.phase === "rollback_required") {
558
+ return { journalPath, phase: journal.phase, quarantineDir };
559
+ }
560
+ const recovered = rollback(journalPath, journal, now());
561
+ return { journalPath, phase: recovered.phase, quarantineDir };
562
+ });
563
+ }