@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,881 @@
1
+ /**
2
+ * Journaled tool-review lineage finalization.
3
+ * Zones: lineage projection persistence, ledger/index CAS, crash roll-forward
4
+ * Owns idempotent ledger mutation after portfolio filesystem commit; admission-state activation and quarantine cleanup remain separate.
5
+ */
6
+
7
+ import { createHash } from "node:crypto";
8
+ import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
9
+ import { dirname, extname, join, relative, resolve } from "node:path";
10
+
11
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
12
+ import * as RecipesReferences from "./recipes-references.ts";
13
+ import * as RecipesUsage from "./recipes-usage.ts";
14
+ import { projectToolReviewLineage } from "./tool-review-lineage.ts";
15
+ import type {
16
+ ToolReviewApprovedPlan,
17
+ ToolReviewApprovedSource,
18
+ ToolReviewApprovedTarget,
19
+ } from "./tool-review-transaction.ts";
20
+
21
+ interface LineageWrite {
22
+ expectedSha256: string | null;
23
+ path: string;
24
+ sha256: string;
25
+ value: Record<string, unknown>;
26
+ }
27
+
28
+ interface RecipeRevisionSnapshot extends Record<string, unknown> {
29
+ content_base64: string;
30
+ created_at: string;
31
+ lineage_name: string;
32
+ review_id: string;
33
+ rollback_recipe: Record<string, unknown>;
34
+ rollback_recipe_sha256: string;
35
+ source_extension: string;
36
+ source_name: string;
37
+ source_revision: number;
38
+ source_sha256: string;
39
+ target_path: string;
40
+ target_sha256: string;
41
+ transition_action: "evolve" | "replace";
42
+ type: "recipe_revision_snapshot";
43
+ }
44
+
45
+ interface LineageDelete {
46
+ expectedSha256: string;
47
+ path: string;
48
+ }
49
+
50
+ interface ToolReviewLineageJournal {
51
+ deletes: LineageDelete[];
52
+ operationsSha256: string;
53
+ phase: "committed" | "prepared";
54
+ planSha256: string;
55
+ reviewId: string;
56
+ updatedAt: string;
57
+ writes: LineageWrite[];
58
+ }
59
+
60
+ export interface ToolReviewLineageTransactionResult {
61
+ journalPath: string;
62
+ phase: "committed";
63
+ }
64
+
65
+ export interface ToolReviewLineageTransactionOptions {
66
+ checkpoint?(checkpoint: "ledger_written" | "prepared"): void;
67
+ now?(): Date;
68
+ recipeRoot: string;
69
+ }
70
+
71
+ function canonicalJson(value: unknown): string {
72
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
73
+ if (!value || typeof value !== "object") return JSON.stringify(value);
74
+ const record = value as Record<string, unknown>;
75
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
76
+ }
77
+
78
+ function sha256(value: Buffer | string): string {
79
+ return createHash("sha256").update(value).digest("hex");
80
+ }
81
+
82
+ function fileHash(path: string): string | null {
83
+ return existsSync(path) ? sha256(readFileSync(path)) : null;
84
+ }
85
+
86
+ function recipeFingerprint(recipe: Record<string, unknown>): string {
87
+ const { usage: _usage, ...content } = recipe;
88
+ return sha256(canonicalJson(content));
89
+ }
90
+
91
+ function pathKey(path: string, recipeRoot: string): string {
92
+ return relative(resolve(recipeRoot), resolve(path)).replaceAll("\\", "/");
93
+ }
94
+
95
+ function strings(value: unknown): string[] {
96
+ return Array.isArray(value)
97
+ ? value.filter((entry): entry is string => typeof entry === "string")
98
+ : [];
99
+ }
100
+
101
+ function count(value: unknown): number {
102
+ return typeof value === "number" && Number.isInteger(value) && value >= 0
103
+ ? value
104
+ : 0;
105
+ }
106
+
107
+ function unique(values: string[], limit = 128): string[] {
108
+ return [...new Set(values)].slice(-limit);
109
+ }
110
+
111
+ function sourceRecord(
112
+ source: ToolReviewApprovedSource,
113
+ recipeRoot: string,
114
+ ): Record<string, unknown> {
115
+ const record = RecipesUsage.readRecipeUsage(source.path, recipeRoot);
116
+ if (!record || typeof record.lineage_name !== "string") {
117
+ throw new Error(`Missing tool review lineage source: ${source.name}`);
118
+ }
119
+ return record;
120
+ }
121
+
122
+ function reviewedRecord(
123
+ source: Record<string, unknown>,
124
+ reviewId: string,
125
+ now: string,
126
+ ): Record<string, unknown> {
127
+ const fingerprint = String(source.fingerprint ?? "");
128
+ return {
129
+ ...source,
130
+ lineage_events: [
131
+ ...(Array.isArray(source.lineage_events) ? source.lineage_events : []),
132
+ { at: now, review_epoch: reviewId, type: "reviewed" },
133
+ ].slice(-128),
134
+ review_epochs: unique([...strings(source.review_epochs), reviewId]),
135
+ reviewed_fingerprints: unique([
136
+ ...strings(source.reviewed_fingerprints),
137
+ fingerprint,
138
+ ]),
139
+ };
140
+ }
141
+
142
+ function continuedRecord(
143
+ records: Record<string, unknown>[],
144
+ target: ToolReviewApprovedTarget,
145
+ action: "demote" | "evolve" | "merge" | "split",
146
+ reviewId: string,
147
+ now: string,
148
+ recipeRoot: string,
149
+ ): Record<string, unknown> {
150
+ const primary = records[0]!;
151
+ const fingerprint = recipeFingerprint(target.recipe);
152
+ const lifetimeCalls = records.reduce(
153
+ (total, record) => total + count(record.lifetime_calls ?? record.calls),
154
+ 0,
155
+ );
156
+ const changed = records.length > 1 || primary.fingerprint !== fingerprint;
157
+ const revision = Math.max(...records.map((record) => count(record.revision) || 1)) + (changed ? 1 : 0);
158
+ const formerNames = unique(
159
+ records.flatMap((record) => [
160
+ ...strings(record.former_names),
161
+ String(record.lineage_name ?? ""),
162
+ ]).filter((name) => name && name !== target.name),
163
+ );
164
+ const formerPaths = unique(
165
+ records.flatMap((record) => [
166
+ ...strings(record.former_paths),
167
+ String(record.current_path ?? ""),
168
+ ]).filter(Boolean),
169
+ );
170
+ const revisions = records.flatMap((record) =>
171
+ Array.isArray(record.revisions) ? record.revisions : [],
172
+ );
173
+ const {
174
+ demoted_at: _demotedAt,
175
+ demoted_fingerprint: _demotedFingerprint,
176
+ demoted_from_revision: _demotedFromRevision,
177
+ demoted_review_epoch: _demotedReviewEpoch,
178
+ rollback_of_revision: _rollbackOfRevision,
179
+ ...base
180
+ } = primary;
181
+ return {
182
+ ...base,
183
+ calls: lifetimeCalls,
184
+ current_path: pathKey(target.path, recipeRoot),
185
+ ...(action === "demote"
186
+ ? {
187
+ demoted_at: now,
188
+ demoted_fingerprint: fingerprint,
189
+ demoted_from_revision: count(primary.revision) || 1,
190
+ demoted_review_epoch: reviewId,
191
+ }
192
+ : {}),
193
+ fingerprint,
194
+ former_names: formerNames,
195
+ former_paths: formerPaths,
196
+ lifetime_calls: lifetimeCalls,
197
+ lineage_events: [
198
+ ...records.flatMap((record) =>
199
+ Array.isArray(record.lineage_events) ? record.lineage_events : [],
200
+ ),
201
+ {
202
+ at: now,
203
+ from: records.map((record) => String(record.lineage_name)).join(","),
204
+ review_epoch: reviewId,
205
+ to: target.name,
206
+ type: action,
207
+ },
208
+ ].slice(-128),
209
+ lineage_name: target.name,
210
+ review_epochs: unique([
211
+ ...records.flatMap((record) => strings(record.review_epochs)),
212
+ reviewId,
213
+ ]),
214
+ reviewed_fingerprints: unique([
215
+ ...records.flatMap((record) => strings(record.reviewed_fingerprints)),
216
+ fingerprint,
217
+ ]),
218
+ revision,
219
+ revision_calls: changed ? 0 : count(primary.revision_calls),
220
+ revision_direct_calls: changed ? 0 : count(primary.revision_direct_calls),
221
+ revision_spawn_calls: changed ? 0 : count(primary.revision_spawn_calls),
222
+ revision_tool_calls: changed ? 0 : count(primary.revision_tool_calls),
223
+ revisions: [
224
+ ...revisions,
225
+ ...(changed ? [{ fingerprint, first_seen: now, revision }] : []),
226
+ ].slice(-128),
227
+ };
228
+ }
229
+
230
+ function replacementRecord(
231
+ target: ToolReviewApprovedTarget,
232
+ reviewId: string,
233
+ now: string,
234
+ recipeRoot: string,
235
+ ): Record<string, unknown> {
236
+ const fingerprint = recipeFingerprint(target.recipe);
237
+ return {
238
+ calls: 0,
239
+ current_path: pathKey(target.path, recipeRoot),
240
+ fingerprint,
241
+ first_seen: now,
242
+ former_names: [],
243
+ former_paths: [],
244
+ lifetime_calls: 0,
245
+ lineage_events: [
246
+ { at: now, review_epoch: reviewId, to: target.name, type: "replaced" },
247
+ ],
248
+ lineage_name: target.name,
249
+ review_epochs: [reviewId],
250
+ reviewed_fingerprints: [fingerprint],
251
+ revision: 1,
252
+ revision_calls: 0,
253
+ revision_direct_calls: 0,
254
+ revision_spawn_calls: 0,
255
+ revision_tool_calls: 0,
256
+ revisions: [{ fingerprint, first_seen: now, revision: 1 }],
257
+ };
258
+ }
259
+
260
+ function readIndex(recipeRoot: string): Record<string, unknown> {
261
+ const path = RecipesUsage.getRecipeUsageIndexPath(recipeRoot);
262
+ if (!existsSync(path)) return { paths: {} };
263
+ const value = JSON.parse(readFileSync(path, "utf8"));
264
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
265
+ throw new Error("Invalid recipe usage index.");
266
+ }
267
+ return value as Record<string, unknown>;
268
+ }
269
+
270
+ function revisionSnapshot(
271
+ approvedPath: string,
272
+ plan: ToolReviewApprovedPlan,
273
+ source: ToolReviewApprovedSource,
274
+ sourceRecord: Record<string, unknown>,
275
+ target: ToolReviewApprovedTarget,
276
+ now: string,
277
+ ): RecipeRevisionSnapshot {
278
+ const transactionPath = join(dirname(approvedPath), "journal.json");
279
+ const transaction = JSON.parse(readFileSync(transactionPath, "utf8")) as {
280
+ operations?: {
281
+ sources?: Array<{ original?: string; quarantine?: string; sha256?: string }>;
282
+ };
283
+ phase?: unknown;
284
+ };
285
+ const operation = transaction.operations?.sources?.find(
286
+ (candidate) => resolve(String(candidate.original)) === resolve(source.path),
287
+ );
288
+ if (
289
+ transaction.phase !== "committed" ||
290
+ !operation ||
291
+ operation.sha256 !== source.sha256 ||
292
+ typeof operation.quarantine !== "string" ||
293
+ !existsSync(operation.quarantine)
294
+ ) {
295
+ throw new Error(`Missing committed rollback source: ${source.name}`);
296
+ }
297
+ const bytes = readFileSync(operation.quarantine);
298
+ if (sha256(bytes) !== source.sha256) {
299
+ throw new Error(`Changed committed rollback source: ${source.name}`);
300
+ }
301
+ const rollbackRecipe = RecipesReferences.readRawRecipeConfig(
302
+ operation.quarantine,
303
+ );
304
+ if (!rollbackRecipe || !Object.hasOwn(rollbackRecipe, "template")) {
305
+ throw new Error(`Invalid committed rollback source: ${source.name}`);
306
+ }
307
+ return {
308
+ content_base64: bytes.toString("base64"),
309
+ created_at: now,
310
+ lineage_name: target.name,
311
+ review_id: plan.reviewId,
312
+ rollback_recipe: rollbackRecipe,
313
+ rollback_recipe_sha256: sha256(canonicalJson(rollbackRecipe)),
314
+ source_extension: extname(source.path).toLowerCase(),
315
+ source_name: source.name,
316
+ source_revision: count(sourceRecord.revision) || 1,
317
+ source_sha256: source.sha256,
318
+ target_path: resolve(target.path),
319
+ target_sha256: sha256(`${JSON.stringify(target.recipe, null, 2)}\n`),
320
+ transition_action: source.action === "replace" ? "replace" : "evolve",
321
+ type: "recipe_revision_snapshot",
322
+ };
323
+ }
324
+
325
+ function deriveJournal(
326
+ approvedPath: string,
327
+ plan: ToolReviewApprovedPlan,
328
+ recipeRoot: string,
329
+ now: string,
330
+ ): ToolReviewLineageJournal {
331
+ const operations = projectToolReviewLineage(plan);
332
+ const index = readIndex(recipeRoot);
333
+ const indexPaths = {
334
+ ...((index.paths && typeof index.paths === "object" && !Array.isArray(index.paths))
335
+ ? index.paths as Record<string, string>
336
+ : {}),
337
+ };
338
+ const desiredLedgers = new Map<string, Record<string, unknown>>();
339
+ const revisionSnapshots = new Map<string, RecipeRevisionSnapshot>();
340
+ const sourceLedgerPaths = new Set<string>();
341
+ for (const operation of operations) {
342
+ const records = operation.sources.map((source) => sourceRecord(source, recipeRoot));
343
+ for (const record of records) {
344
+ const lineageName = String(record.lineage_name);
345
+ sourceLedgerPaths.add(
346
+ RecipesUsage.getRecipeUsageLedgerPath(lineageName, recipeRoot),
347
+ );
348
+ delete indexPaths[String(record.current_path)];
349
+ }
350
+ if (operation.action === "keep") {
351
+ const record = reviewedRecord(records[0]!, plan.reviewId, now);
352
+ desiredLedgers.set(
353
+ RecipesUsage.getRecipeUsageLedgerPath(String(record.lineage_name), recipeRoot),
354
+ record,
355
+ );
356
+ indexPaths[String(record.current_path)] = String(record.lineage_name);
357
+ continue;
358
+ }
359
+ if (operation.action === "replace") {
360
+ const target = operation.targets[0];
361
+ const snapshot = revisionSnapshot(
362
+ approvedPath,
363
+ plan,
364
+ operation.sources[0],
365
+ records[0]!,
366
+ target,
367
+ now,
368
+ );
369
+ revisionSnapshots.set(
370
+ RecipesUsage.getRecipeRevisionSnapshotPath(
371
+ target.name,
372
+ snapshot.source_revision,
373
+ recipeRoot,
374
+ ),
375
+ snapshot,
376
+ );
377
+ const record = replacementRecord(target, plan.reviewId, now, recipeRoot);
378
+ desiredLedgers.set(
379
+ RecipesUsage.getRecipeUsageLedgerPath(target.name, recipeRoot),
380
+ record,
381
+ );
382
+ indexPaths[pathKey(target.path, recipeRoot)] = target.name;
383
+ continue;
384
+ }
385
+ for (const target of operation.targets) {
386
+ if (operation.action === "evolve") {
387
+ const snapshot = revisionSnapshot(
388
+ approvedPath,
389
+ plan,
390
+ operation.sources[0],
391
+ records[0]!,
392
+ target,
393
+ now,
394
+ );
395
+ revisionSnapshots.set(
396
+ RecipesUsage.getRecipeRevisionSnapshotPath(
397
+ target.name,
398
+ snapshot.source_revision,
399
+ recipeRoot,
400
+ ),
401
+ snapshot,
402
+ );
403
+ }
404
+ const record = continuedRecord(
405
+ records,
406
+ target,
407
+ operation.action,
408
+ plan.reviewId,
409
+ now,
410
+ recipeRoot,
411
+ );
412
+ desiredLedgers.set(
413
+ RecipesUsage.getRecipeUsageLedgerPath(target.name, recipeRoot),
414
+ record,
415
+ );
416
+ indexPaths[pathKey(target.path, recipeRoot)] = target.name;
417
+ }
418
+ }
419
+ const writes: LineageWrite[] = [...desiredLedgers.entries()]
420
+ .sort(([left], [right]) => left.localeCompare(right))
421
+ .map(([path, value]) => ({
422
+ expectedSha256: fileHash(path),
423
+ path,
424
+ sha256: sha256(`${JSON.stringify(value, null, 2)}\n`),
425
+ value,
426
+ }));
427
+ for (const [path, value] of [...revisionSnapshots.entries()].sort(([left], [right]) =>
428
+ left.localeCompare(right),
429
+ )) {
430
+ const existing = existsSync(path)
431
+ ? JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>
432
+ : undefined;
433
+ if (
434
+ existing &&
435
+ canonicalJson(existing) !== canonicalJson(value) &&
436
+ !(
437
+ value.transition_action === "replace" ||
438
+ (
439
+ existing.type === "recipe_revision_snapshot" &&
440
+ count(existing.source_revision) <=
441
+ value.source_revision - RecipesUsage.RECIPE_REVISION_SNAPSHOT_LIMIT
442
+ )
443
+ )
444
+ ) {
445
+ throw new Error(`Recipe revision snapshot collision: ${path}`);
446
+ }
447
+ writes.push({
448
+ expectedSha256: fileHash(path),
449
+ path,
450
+ sha256: sha256(`${JSON.stringify(value, null, 2)}\n`),
451
+ value,
452
+ });
453
+ }
454
+ const deletes: LineageDelete[] = [...sourceLedgerPaths]
455
+ .filter((path) => !desiredLedgers.has(path) && existsSync(path))
456
+ .sort()
457
+ .map((path) => ({ expectedSha256: fileHash(path)!, path }));
458
+ const indexPath = RecipesUsage.getRecipeUsageIndexPath(recipeRoot);
459
+ const nextIndex = { ...index, paths: indexPaths };
460
+ writes.push({
461
+ expectedSha256: fileHash(indexPath),
462
+ path: indexPath,
463
+ sha256: sha256(`${JSON.stringify(nextIndex, null, 2)}\n`),
464
+ value: nextIndex,
465
+ });
466
+ const journal = {
467
+ deletes,
468
+ operationsSha256: "",
469
+ phase: "prepared" as const,
470
+ planSha256: sha256(canonicalJson(plan)),
471
+ reviewId: plan.reviewId,
472
+ updatedAt: now,
473
+ writes,
474
+ };
475
+ journal.operationsSha256 = sha256(canonicalJson({ deletes, writes }));
476
+ return journal;
477
+ }
478
+
479
+ function assertContainedUsagePath(path: string, usageRoot: string): void {
480
+ const relation = relative(resolve(usageRoot), resolve(path));
481
+ if (!relation || relation === ".." || relation.startsWith("../") || relation.startsWith("..\\")) {
482
+ throw new Error(`Tool review lineage path escapes usage root: ${path}`);
483
+ }
484
+ let current = resolve(usageRoot);
485
+ if (!existsSync(current) || lstatSync(current).isSymbolicLink()) {
486
+ throw new Error(`Invalid tool review lineage root: ${current}`);
487
+ }
488
+ for (const segment of relation.split(/[\\/]/u)) {
489
+ current = join(current, segment);
490
+ if (!existsSync(current)) break;
491
+ if (lstatSync(current).isSymbolicLink()) {
492
+ throw new Error(`Symlink is not allowed in tool review lineage: ${current}`);
493
+ }
494
+ }
495
+ }
496
+
497
+ function validateJournal(
498
+ journal: ToolReviewLineageJournal,
499
+ plan: ToolReviewApprovedPlan,
500
+ recipeRoot: string,
501
+ ): void {
502
+ if (
503
+ journal.reviewId !== plan.reviewId ||
504
+ journal.planSha256 !== sha256(canonicalJson(plan)) ||
505
+ journal.operationsSha256 !==
506
+ sha256(canonicalJson({ deletes: journal.deletes, writes: journal.writes }))
507
+ ) {
508
+ throw new Error("Invalid tool review lineage journal.");
509
+ }
510
+ const indexPath = RecipesUsage.getRecipeUsageIndexPath(recipeRoot);
511
+ const usageRoot = dirname(indexPath);
512
+ const allowedWrites = new Set<string>([
513
+ indexPath,
514
+ ...plan.sources
515
+ .filter((source) => source.action === "keep")
516
+ .map((source) => RecipesUsage.getRecipeUsageLedgerPath(source.name, recipeRoot)),
517
+ ...plan.targets.map((target) =>
518
+ RecipesUsage.getRecipeUsageLedgerPath(target.name, recipeRoot),
519
+ ),
520
+ ].map((path) => resolve(path)));
521
+ const allowedDeletes = new Set<string>(
522
+ plan.sources
523
+ .filter((source) => source.action !== "keep")
524
+ .map((source) => RecipesUsage.getRecipeUsageLedgerPath(source.name, recipeRoot))
525
+ .filter((path) => !allowedWrites.has(resolve(path)))
526
+ .map((path) => resolve(path)),
527
+ );
528
+ const writePaths = journal.writes.map((write) => resolve(write.path));
529
+ const deletePaths = journal.deletes.map((deletion) => resolve(deletion.path));
530
+ const invalidWrite = journal.writes.some((write) => {
531
+ const path = resolve(write.path);
532
+ if (write.sha256 !== sha256(`${JSON.stringify(write.value, null, 2)}\n`)) {
533
+ return true;
534
+ }
535
+ if (allowedWrites.has(path)) return false;
536
+ const snapshot = write.value as unknown as RecipeRevisionSnapshot;
537
+ const source = plan.sources.find(
538
+ (candidate) => candidate.name === snapshot.source_name,
539
+ );
540
+ const target = plan.targets.find(
541
+ (candidate) => candidate.name === snapshot.lineage_name,
542
+ );
543
+ return (
544
+ snapshot?.type !== "recipe_revision_snapshot" ||
545
+ !source ||
546
+ !target ||
547
+ !["evolve", "replace"].includes(source.action) ||
548
+ snapshot.transition_action !== source.action ||
549
+ !target.sources.includes(source.name) ||
550
+ snapshot.review_id !== plan.reviewId ||
551
+ snapshot.source_sha256 !== source.sha256 ||
552
+ typeof snapshot.content_base64 !== "string" ||
553
+ Buffer.from(snapshot.content_base64, "base64").toString("base64") !==
554
+ snapshot.content_base64 ||
555
+ sha256(Buffer.from(snapshot.content_base64, "base64")) !== source.sha256 ||
556
+ snapshot.rollback_recipe_sha256 !== sha256(canonicalJson(snapshot.rollback_recipe)) ||
557
+ snapshot.target_path !== resolve(target.path) ||
558
+ snapshot.target_sha256 !==
559
+ sha256(`${JSON.stringify(target.recipe, null, 2)}\n`) ||
560
+ path !== resolve(
561
+ RecipesUsage.getRecipeRevisionSnapshotPath(
562
+ target.name,
563
+ snapshot.source_revision,
564
+ recipeRoot,
565
+ ),
566
+ )
567
+ );
568
+ });
569
+ if (
570
+ !["prepared", "committed"].includes(journal.phase) ||
571
+ new Set(writePaths).size !== writePaths.length ||
572
+ new Set(deletePaths).size !== deletePaths.length ||
573
+ invalidWrite ||
574
+ deletePaths.some((path) => !allowedDeletes.has(path)) ||
575
+ writePaths.at(-1) !== resolve(indexPath)
576
+ ) {
577
+ throw new Error("Tool review lineage journal paths do not match the approved plan.");
578
+ }
579
+ for (const path of [...writePaths, ...deletePaths]) {
580
+ assertContainedUsagePath(path, usageRoot);
581
+ }
582
+ }
583
+
584
+ function assertCas(
585
+ current: string | null,
586
+ expected: string | null,
587
+ intended?: string | null,
588
+ ): void {
589
+ if (current !== expected && current !== intended) {
590
+ throw new Error("Tool review lineage CAS failed.");
591
+ }
592
+ }
593
+
594
+ function rollForward(
595
+ journalPath: string,
596
+ journal: ToolReviewLineageJournal,
597
+ checkpoint?: ToolReviewLineageTransactionOptions["checkpoint"],
598
+ ): ToolReviewLineageTransactionResult {
599
+ for (const write of journal.writes) {
600
+ assertCas(fileHash(write.path), write.expectedSha256, write.sha256);
601
+ writeJsonAtomic(write.path, write.value);
602
+ checkpoint?.("ledger_written");
603
+ }
604
+ for (const deletion of journal.deletes) {
605
+ assertCas(fileHash(deletion.path), deletion.expectedSha256, null);
606
+ rmSync(deletion.path, { force: true });
607
+ }
608
+ for (const write of journal.writes) {
609
+ if (fileHash(write.path) !== write.sha256) {
610
+ throw new Error(`Tool review lineage write changed: ${write.path}`);
611
+ }
612
+ }
613
+ for (const deletion of journal.deletes) {
614
+ if (existsSync(deletion.path)) {
615
+ throw new Error(`Tool review lineage delete failed: ${deletion.path}`);
616
+ }
617
+ }
618
+ writeJsonAtomic(journalPath, {
619
+ ...journal,
620
+ phase: "committed",
621
+ updatedAt: new Date().toISOString(),
622
+ });
623
+ return { journalPath, phase: "committed" };
624
+ }
625
+
626
+ export function finalizeToolReviewLineage(
627
+ approvedPath: string,
628
+ options: ToolReviewLineageTransactionOptions,
629
+ ): ToolReviewLineageTransactionResult {
630
+ const plan = JSON.parse(readFileSync(approvedPath, "utf8")) as ToolReviewApprovedPlan;
631
+ const journalPath = `${approvedPath}.lineage-journal.json`;
632
+ const indexPath = RecipesUsage.getRecipeUsageIndexPath(options.recipeRoot);
633
+ return withFileMutationLock(indexPath, () => {
634
+ let journal: ToolReviewLineageJournal;
635
+ if (existsSync(journalPath)) {
636
+ journal = JSON.parse(readFileSync(journalPath, "utf8")) as ToolReviewLineageJournal;
637
+ validateJournal(journal, plan, options.recipeRoot);
638
+ if (journal.phase === "committed") {
639
+ return { journalPath, phase: "committed" };
640
+ }
641
+ } else {
642
+ journal = deriveJournal(
643
+ approvedPath,
644
+ plan,
645
+ options.recipeRoot,
646
+ (options.now ?? (() => new Date()))().toISOString(),
647
+ );
648
+ validateJournal(journal, plan, options.recipeRoot);
649
+ writeJsonAtomic(journalPath, journal);
650
+ options.checkpoint?.("prepared");
651
+ }
652
+ return rollForward(journalPath, journal, options.checkpoint);
653
+ });
654
+ }
655
+
656
+ export interface ToolReviewRevisionRollbackResult {
657
+ journalPath: string;
658
+ lineageName: string;
659
+ restoredRevision: number;
660
+ targetPath: string;
661
+ }
662
+
663
+ interface RecipeRollbackWrite {
664
+ expectedSha256: string;
665
+ path: string;
666
+ sha256: string;
667
+ value: Record<string, unknown>;
668
+ }
669
+
670
+ interface RecipeRollbackJournal {
671
+ lineageName: string;
672
+ operationsSha256: string;
673
+ phase: "committed" | "prepared";
674
+ revision: number;
675
+ snapshotSha256: string;
676
+ updatedAt: string;
677
+ writes: [RecipeRollbackWrite, RecipeRollbackWrite];
678
+ }
679
+
680
+ export interface ToolReviewRevisionRollbackOptions {
681
+ checkpoint?(checkpoint: "ledger_written" | "prepared" | "recipe_written"): void;
682
+ now?(): Date;
683
+ recipeRoot: string;
684
+ }
685
+
686
+ function assertRecipeRollbackPath(path: string, recipeRoot: string): void {
687
+ const relation = relative(resolve(recipeRoot), resolve(path));
688
+ if (!relation || relation === ".." || relation.startsWith("../") || relation.startsWith("..\\")) {
689
+ throw new Error(`Recipe rollback path escapes recipe root: ${path}`);
690
+ }
691
+ let current = resolve(recipeRoot);
692
+ if (!existsSync(current) || lstatSync(current).isSymbolicLink()) {
693
+ throw new Error(`Invalid recipe rollback root: ${current}`);
694
+ }
695
+ for (const segment of relation.split(/[\\/]/u)) {
696
+ current = join(current, segment);
697
+ if (!existsSync(current)) break;
698
+ if (lstatSync(current).isSymbolicLink()) {
699
+ throw new Error(`Symlink is not allowed in recipe rollback: ${current}`);
700
+ }
701
+ }
702
+ }
703
+
704
+ export function rollbackToolRecipeRevision(
705
+ lineageName: string,
706
+ revision: number,
707
+ options: ToolReviewRevisionRollbackOptions,
708
+ ): ToolReviewRevisionRollbackResult {
709
+ const snapshotPath = RecipesUsage.getRecipeRevisionSnapshotPath(
710
+ lineageName,
711
+ revision,
712
+ options.recipeRoot,
713
+ );
714
+ const indexPath = RecipesUsage.getRecipeUsageIndexPath(options.recipeRoot);
715
+ const usageRoot = dirname(indexPath);
716
+ const journalPath = `${snapshotPath}.rollback-journal`;
717
+ assertContainedUsagePath(snapshotPath, usageRoot);
718
+ assertContainedUsagePath(journalPath, usageRoot);
719
+ const snapshotBytes = readFileSync(snapshotPath);
720
+ const snapshot = JSON.parse(snapshotBytes.toString("utf8")) as RecipeRevisionSnapshot;
721
+ const encodedSource = typeof snapshot.content_base64 === "string"
722
+ ? Buffer.from(snapshot.content_base64, "base64")
723
+ : undefined;
724
+ if (
725
+ snapshot?.type !== "recipe_revision_snapshot" ||
726
+ snapshot.lineage_name !== lineageName ||
727
+ snapshot.source_revision !== revision ||
728
+ !["evolve", "replace"].includes(snapshot.transition_action) ||
729
+ !snapshot.rollback_recipe ||
730
+ typeof snapshot.rollback_recipe !== "object" ||
731
+ !Object.hasOwn(snapshot.rollback_recipe, "template") ||
732
+ !encodedSource ||
733
+ encodedSource.toString("base64") !== snapshot.content_base64 ||
734
+ sha256(encodedSource) !== snapshot.source_sha256 ||
735
+ snapshot.rollback_recipe_sha256 !== sha256(canonicalJson(snapshot.rollback_recipe)) ||
736
+ RecipesReferences.getRecipeIdFromPath(snapshot.target_path) !== lineageName
737
+ ) {
738
+ throw new Error("Invalid or rotated recipe revision snapshot.");
739
+ }
740
+ assertRecipeRollbackPath(snapshot.target_path, options.recipeRoot);
741
+ const ledgerPath = RecipesUsage.getRecipeUsageLedgerPath(
742
+ lineageName,
743
+ options.recipeRoot,
744
+ );
745
+ assertContainedUsagePath(ledgerPath, usageRoot);
746
+ const snapshotSha256 = sha256(snapshotBytes);
747
+ const result = withFileMutationLock(indexPath, () =>
748
+ withFileMutationLock(snapshot.target_path, () => {
749
+ let journal: RecipeRollbackJournal;
750
+ if (existsSync(journalPath)) {
751
+ journal = JSON.parse(readFileSync(journalPath, "utf8")) as RecipeRollbackJournal;
752
+ } else {
753
+ const currentTargetSha256 = fileHash(snapshot.target_path);
754
+ const rollbackSha256 = sha256(
755
+ `${JSON.stringify(snapshot.rollback_recipe, null, 2)}\n`,
756
+ );
757
+ if (currentTargetSha256 !== snapshot.target_sha256) {
758
+ throw new Error("Recipe revision rollback target CAS failed.");
759
+ }
760
+ const ledger = RecipesUsage.readRecipeUsage(
761
+ snapshot.target_path,
762
+ options.recipeRoot,
763
+ );
764
+ if (
765
+ !ledger ||
766
+ ledger.lineage_name !== lineageName ||
767
+ fileHash(ledgerPath) === null
768
+ ) {
769
+ throw new Error("Recipe revision rollback lineage is unavailable.");
770
+ }
771
+ const now = (options.now ?? (() => new Date()))().toISOString();
772
+ const fingerprint = recipeFingerprint(snapshot.rollback_recipe);
773
+ const changed = ledger.fingerprint !== fingerprint;
774
+ const nextRevision = (count(ledger.revision) || 1) + (changed ? 1 : 0);
775
+ const events = Array.isArray(ledger.lineage_events)
776
+ ? ledger.lineage_events as Array<Record<string, unknown>>
777
+ : [];
778
+ const revisions = Array.isArray(ledger.revisions)
779
+ ? ledger.revisions as Array<Record<string, unknown>>
780
+ : [];
781
+ const nextLedger = {
782
+ ...ledger,
783
+ fingerprint,
784
+ lineage_events: [
785
+ ...events,
786
+ ...(changed ? [{ at: now, type: "revised" }] : []),
787
+ {
788
+ at: now,
789
+ revision: nextRevision,
790
+ rollback_revision: revision,
791
+ type: "rollback",
792
+ },
793
+ ].slice(-128),
794
+ rollback_of_revision: revision,
795
+ ...(changed
796
+ ? {
797
+ reset_at: now,
798
+ reset_reason: "recipe content fingerprint changed",
799
+ revision: nextRevision,
800
+ revision_calls: 0,
801
+ revision_direct_calls: 0,
802
+ revision_spawn_calls: 0,
803
+ revision_tool_calls: 0,
804
+ revisions: [
805
+ ...revisions,
806
+ { fingerprint, first_seen: now, revision: nextRevision },
807
+ ].slice(-128),
808
+ }
809
+ : {}),
810
+ };
811
+ const writes: [RecipeRollbackWrite, RecipeRollbackWrite] = [
812
+ {
813
+ expectedSha256: currentTargetSha256,
814
+ path: snapshot.target_path,
815
+ sha256: rollbackSha256,
816
+ value: snapshot.rollback_recipe,
817
+ },
818
+ {
819
+ expectedSha256: fileHash(ledgerPath)!,
820
+ path: ledgerPath,
821
+ sha256: sha256(`${JSON.stringify(nextLedger, null, 2)}\n`),
822
+ value: nextLedger,
823
+ },
824
+ ];
825
+ journal = {
826
+ lineageName,
827
+ operationsSha256: sha256(canonicalJson(writes)),
828
+ phase: "prepared",
829
+ revision,
830
+ snapshotSha256,
831
+ updatedAt: now,
832
+ writes,
833
+ };
834
+ writeJsonAtomic(journalPath, journal);
835
+ options.checkpoint?.("prepared");
836
+ }
837
+ if (
838
+ journal.lineageName !== lineageName ||
839
+ journal.revision !== revision ||
840
+ journal.snapshotSha256 !== snapshotSha256 ||
841
+ journal.operationsSha256 !== sha256(canonicalJson(journal.writes)) ||
842
+ !["prepared", "committed"].includes(journal.phase) ||
843
+ journal.writes.length !== 2 ||
844
+ resolve(journal.writes[0].path) !== resolve(snapshot.target_path) ||
845
+ resolve(journal.writes[1].path) !== resolve(ledgerPath) ||
846
+ journal.writes.some(
847
+ (write) =>
848
+ write.sha256 !== sha256(`${JSON.stringify(write.value, null, 2)}\n`),
849
+ )
850
+ ) {
851
+ throw new Error("Invalid recipe revision rollback journal.");
852
+ }
853
+ assertContainedUsagePath(journal.writes[1].path, usageRoot);
854
+ for (const write of journal.writes) {
855
+ assertCas(fileHash(write.path), write.expectedSha256, write.sha256);
856
+ writeJsonAtomic(write.path, write.value);
857
+ options.checkpoint?.(
858
+ write.path === snapshot.target_path ? "recipe_written" : "ledger_written",
859
+ );
860
+ }
861
+ if (journal.writes.some((write) => fileHash(write.path) !== write.sha256)) {
862
+ throw new Error("Recipe revision rollback write did not converge.");
863
+ }
864
+ if (journal.phase !== "committed") {
865
+ journal = {
866
+ ...journal,
867
+ phase: "committed",
868
+ updatedAt: new Date().toISOString(),
869
+ };
870
+ writeJsonAtomic(journalPath, journal);
871
+ }
872
+ return journal;
873
+ }),
874
+ );
875
+ return {
876
+ journalPath,
877
+ lineageName,
878
+ restoredRevision: revision,
879
+ targetPath: result.writes[0].path,
880
+ };
881
+ }