@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
@@ -1,16 +1,16 @@
1
1
  /**
2
- * Recipe usage metadata helpers
3
- * Zones: recipe telemetry, muscle-memory cleanup evidence
4
- * Owns lightweight launch counters for user-owned recipe files
2
+ * Recipe usage and name-based lineage metadata.
3
+ * Zones: recipe lineage, launch telemetry, revision history, migration
4
+ * Owns priority-compatible recipe names and lifetime/revision counters for user recipes.
5
5
  */
6
6
 
7
7
  import { createHash } from "node:crypto";
8
- import { existsSync, readFileSync } from "node:fs";
9
- import { basename, dirname, join } from "node:path";
8
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
9
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
10
10
 
11
11
  import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
12
12
 
13
- interface RecipesUsageRecord {
13
+ interface RecipeUsageCounters {
14
14
  calls?: number;
15
15
  direct_calls?: number;
16
16
  fingerprint?: string;
@@ -22,6 +22,47 @@ interface RecipesUsageRecord {
22
22
  tool_calls?: number;
23
23
  }
24
24
 
25
+ interface RecipeUsageIndex {
26
+ paths: Record<string, string>;
27
+ }
28
+
29
+ interface RecipeLineageEvent {
30
+ at: string;
31
+ from?: string;
32
+ review_epoch?: string;
33
+ to?: string;
34
+ rollback_revision?: number;
35
+ revision?: number;
36
+ type: "created" | "demoted" | "merged" | "promoted" | "renamed" | "replaced" | "reviewed" | "revised" | "rollback" | "split";
37
+ }
38
+
39
+ interface RecipeRevisionRecord {
40
+ fingerprint: string;
41
+ first_seen: string;
42
+ revision: number;
43
+ }
44
+
45
+ interface RecipeUsageRecord extends RecipeUsageCounters {
46
+ calls: number;
47
+ current_path: string;
48
+ fingerprint: string;
49
+ first_seen: string;
50
+ former_names: string[];
51
+ former_paths: string[];
52
+ lifetime_calls: number;
53
+ lineage_events: RecipeLineageEvent[];
54
+ lineage_name: string;
55
+ review_epochs: string[];
56
+ reviewed_fingerprints: string[];
57
+ rollback_of_revision?: number;
58
+ revision: number;
59
+ revision_calls: number;
60
+ revision_direct_calls: number;
61
+ revision_spawn_calls: number;
62
+ revision_tool_calls: number;
63
+ revisions: RecipeRevisionRecord[];
64
+ }
65
+
25
66
  function isRecord(value: unknown): value is Record<string, unknown> {
26
67
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
27
68
  }
@@ -41,69 +82,557 @@ function stableStringify(value: unknown): string {
41
82
  .join(",")}}`;
42
83
  }
43
84
 
44
- function getRecipeFingerprint(raw: Record<string, unknown>): string {
45
- const { usage: _usage, ...content } = raw;
46
- return createHash("sha256").update(stableStringify(content)).digest("hex");
85
+ function readRecipeSource(path: string): { fingerprint: string } | undefined {
86
+ const bytes = readFileSync(path);
87
+ if (extname(path).toLowerCase() !== ".json") {
88
+ return {
89
+ fingerprint: createHash("sha256").update(bytes).digest("hex"),
90
+ };
91
+ }
92
+ const parsed = JSON.parse(bytes.toString("utf8"));
93
+ if (!isRecord(parsed)) return undefined;
94
+ const { usage: _usage, ...content } = parsed;
95
+ return {
96
+ fingerprint: createHash("sha256")
97
+ .update(stableStringify(content))
98
+ .digest("hex"),
99
+ };
100
+ }
101
+
102
+ function inferRecipeRoot(path: string): string {
103
+ const parent = dirname(resolve(path));
104
+ return basename(parent) === "drafts" ? dirname(parent) : parent;
105
+ }
106
+
107
+ function pathKey(path: string, recipeRoot: string): string {
108
+ const relation = relative(resolve(recipeRoot), resolve(path));
109
+ if (!relation || relation.startsWith("..") || resolve(relation) === relation) {
110
+ throw new Error(`Recipe path escapes recipe root: ${path}`);
111
+ }
112
+ return relation.replaceAll("\\", "/");
113
+ }
114
+
115
+ function recipeName(path: string): string {
116
+ return basename(path).replace(/\.(?:json|md)$/u, "");
117
+ }
118
+
119
+ function lineageFilename(lineageName: string): string {
120
+ if (!lineageName || lineageName === "." || lineageName === "..") {
121
+ throw new Error("Invalid recipe lineage name.");
122
+ }
123
+ return `${encodeURIComponent(lineageName)}.json`;
124
+ }
125
+
126
+ function appendBounded(values: string[], value: string, limit = 64): string[] {
127
+ const next = [...values.filter((item) => item !== value), value];
128
+ return next.slice(Math.max(0, next.length - limit));
129
+ }
130
+
131
+ function appendEvent(
132
+ events: RecipeLineageEvent[],
133
+ event: RecipeLineageEvent,
134
+ ): RecipeLineageEvent[] {
135
+ return [...events, event].slice(-128);
136
+ }
137
+
138
+ function appendRevision(
139
+ revisions: RecipeRevisionRecord[],
140
+ revision: RecipeRevisionRecord,
141
+ ): RecipeRevisionRecord[] {
142
+ return [...revisions, revision].slice(-128);
47
143
  }
48
144
 
49
145
  export type RecipeLaunchKind = "direct" | "spawn" | "tool";
50
146
 
51
- export function getRecipeUsagePath(path: string): string {
52
- return join(dirname(path), ".usage", `${basename(path)}.json`);
147
+ export function getRecipeUsageIndexPath(recipeRoot: string): string {
148
+ return join(resolve(recipeRoot), ".usage", "index.json");
53
149
  }
54
150
 
55
- export function readRecipeUsage(path: string): Record<string, unknown> | undefined {
151
+ export function getRecipeUsageLedgerPath(
152
+ lineageName: string,
153
+ recipeRoot: string,
154
+ ): string {
155
+ return join(
156
+ resolve(recipeRoot),
157
+ ".usage",
158
+ "recipes",
159
+ lineageFilename(lineageName),
160
+ );
161
+ }
162
+
163
+ export const RECIPE_REVISION_SNAPSHOT_LIMIT = 32;
164
+
165
+ export function getRecipeRevisionSnapshotPath(
166
+ lineageName: string,
167
+ revision: number,
168
+ recipeRoot: string,
169
+ ): string {
170
+ if (!Number.isInteger(revision) || revision <= 0) {
171
+ throw new Error("Invalid recipe revision snapshot number.");
172
+ }
173
+ const slot = ((revision - 1) % RECIPE_REVISION_SNAPSHOT_LIMIT) + 1;
174
+ return join(
175
+ resolve(recipeRoot),
176
+ ".usage",
177
+ "revisions",
178
+ encodeURIComponent(lineageName),
179
+ `${slot}.json`,
180
+ );
181
+ }
182
+
183
+ function readJsonRecord(path: string): Record<string, unknown> | undefined {
56
184
  try {
57
- const value = JSON.parse(readFileSync(getRecipeUsagePath(path), "utf8"));
185
+ const value = JSON.parse(readFileSync(path, "utf8"));
58
186
  return isRecord(value) ? value : undefined;
59
187
  } catch {
60
188
  return undefined;
61
189
  }
62
190
  }
63
191
 
192
+ function readIndex(recipeRoot: string): RecipeUsageIndex {
193
+ const value = readJsonRecord(getRecipeUsageIndexPath(recipeRoot));
194
+ const paths = value?.paths;
195
+ return {
196
+ paths: isRecord(paths)
197
+ ? Object.fromEntries(
198
+ Object.entries(paths).filter(
199
+ (entry): entry is [string, string] => typeof entry[1] === "string",
200
+ ),
201
+ )
202
+ : {},
203
+ };
204
+ }
205
+
206
+ function normalizeStoredLedger(
207
+ value: Record<string, unknown>,
208
+ lineageName: string,
209
+ ): RecipeUsageRecord | undefined {
210
+ return value.lineage_name === lineageName
211
+ ? value as unknown as RecipeUsageRecord
212
+ : undefined;
213
+ }
214
+
215
+ function readLedger(
216
+ lineageName: string,
217
+ recipeRoot: string,
218
+ ): RecipeUsageRecord | undefined {
219
+ const value = readJsonRecord(getRecipeUsageLedgerPath(lineageName, recipeRoot));
220
+ return value ? normalizeStoredLedger(value, lineageName) : undefined;
221
+ }
222
+
223
+ function readIndexedLedger(
224
+ indexedName: string,
225
+ recipeRoot: string,
226
+ ): { record: RecipeUsageRecord } | undefined {
227
+ const current = readLedger(indexedName, recipeRoot);
228
+ return current ? { record: current } : undefined;
229
+ }
230
+
231
+ function findRelocatedRecipe(
232
+ index: RecipeUsageIndex,
233
+ fingerprint: string,
234
+ recipeRoot: string,
235
+ ): { indexedName: string; path: string; record: RecipeUsageRecord } | undefined {
236
+ const candidates = Object.entries(index.paths).flatMap(([oldPath, indexedName]) => {
237
+ if (existsSync(join(recipeRoot, oldPath))) return [];
238
+ const found = readIndexedLedger(indexedName, recipeRoot);
239
+ return found?.record.fingerprint === fingerprint
240
+ ? [{ indexedName, path: oldPath, record: found.record }]
241
+ : [];
242
+ });
243
+ return candidates.length === 1 ? candidates[0] : undefined;
244
+ }
245
+
64
246
  function launchCounterKey(
65
247
  kind: RecipeLaunchKind,
66
248
  ): "direct_calls" | "spawn_calls" | "tool_calls" {
67
249
  return `${kind}_calls` as "direct_calls" | "spawn_calls" | "tool_calls";
68
250
  }
69
251
 
70
- export function recordRecipeLaunch(
252
+ function revisionCounterKey(
253
+ kind: RecipeLaunchKind,
254
+ ): "revision_direct_calls" | "revision_spawn_calls" | "revision_tool_calls" {
255
+ return `revision_${kind}_calls` as
256
+ | "revision_direct_calls"
257
+ | "revision_spawn_calls"
258
+ | "revision_tool_calls";
259
+ }
260
+
261
+ export function readRecipeUsage(
262
+ path: string,
263
+ recipeRoot = inferRecipeRoot(path),
264
+ ): Record<string, unknown> | undefined {
265
+ try {
266
+ const key = pathKey(path, recipeRoot);
267
+ const indexedName = readIndex(recipeRoot).paths[key];
268
+ if (indexedName) {
269
+ return readIndexedLedger(indexedName, recipeRoot)?.record as unknown as
270
+ | Record<string, unknown>
271
+ | undefined;
272
+ }
273
+ return undefined;
274
+ } catch {
275
+ return undefined;
276
+ }
277
+ }
278
+
279
+ export function ensureRecipeLineage(
71
280
  path: string,
72
281
  now = new Date(),
73
- kind: RecipeLaunchKind = "direct",
282
+ recipeRoot = inferRecipeRoot(path),
74
283
  ): boolean {
75
284
  if (!existsSync(path)) return false;
76
- const usagePath = getRecipeUsagePath(path);
77
- return withFileMutationLock(usagePath, () => {
285
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
286
+ return withFileMutationLock(indexPath, () => {
78
287
  try {
79
- const raw = JSON.parse(readFileSync(path, "utf8"));
80
- if (!isRecord(raw)) return false;
81
- const storedUsage = readRecipeUsage(path);
82
- const usage: RecipesUsageRecord =
83
- storedUsage ?? (isRecord(raw.usage) ? raw.usage : {});
84
- const fingerprint = getRecipeFingerprint(raw);
85
- const changed =
86
- typeof usage.fingerprint === "string" &&
87
- usage.fingerprint !== fingerprint;
288
+ const source = readRecipeSource(path);
289
+ if (!source) return false;
290
+ const key = pathKey(path, recipeRoot);
291
+ const name = recipeName(path);
292
+ const index = readIndex(recipeRoot);
293
+ const indexedName = index.paths[key];
294
+ if (indexedName) {
295
+ const found = readIndexedLedger(indexedName, recipeRoot);
296
+ if (!found || indexedName !== name) return false;
297
+ if (found.record.fingerprint === source.fingerprint) return true;
298
+ const ledgerPath = getRecipeUsageLedgerPath(name, recipeRoot);
299
+ return withFileMutationLock(ledgerPath, () => {
300
+ const current = readLedger(name, recipeRoot);
301
+ if (!current) return false;
302
+ if (current.fingerprint === source.fingerprint) return true;
303
+ const nowIso = now.toISOString();
304
+ const revision = current.revision + 1;
305
+ writeJsonAtomic(ledgerPath, {
306
+ ...current,
307
+ fingerprint: source.fingerprint,
308
+ lineage_events: appendEvent(current.lineage_events, {
309
+ at: nowIso,
310
+ type: "revised",
311
+ }),
312
+ reset_at: nowIso,
313
+ reset_reason: "recipe content fingerprint changed",
314
+ revision,
315
+ revision_calls: 0,
316
+ revision_direct_calls: 0,
317
+ revision_spawn_calls: 0,
318
+ revision_tool_calls: 0,
319
+ revisions: appendRevision(current.revisions, {
320
+ fingerprint: source.fingerprint,
321
+ first_seen: nowIso,
322
+ revision,
323
+ }),
324
+ });
325
+ return true;
326
+ });
327
+ }
328
+ if (readLedger(name, recipeRoot)) return false;
88
329
  const nowIso = now.toISOString();
89
- const counterKey = launchCounterKey(kind);
90
- writeJsonAtomic(usagePath, {
91
- ...usage,
92
- calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
93
- [counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
94
- last_called: nowIso,
95
- launch_kind: kind,
96
- fingerprint,
97
- ...(changed
98
- ? {
99
- reset_at: nowIso,
100
- reset_reason: "recipe content fingerprint changed",
101
- }
102
- : {}),
330
+ const ledgerPath = getRecipeUsageLedgerPath(name, recipeRoot);
331
+ return withFileMutationLock(ledgerPath, () => {
332
+ if (readLedger(name, recipeRoot)) return false;
333
+ writeJsonAtomic(ledgerPath, {
334
+ calls: 0,
335
+ current_path: key,
336
+ fingerprint: source.fingerprint,
337
+ first_seen: nowIso,
338
+ former_names: [],
339
+ former_paths: [],
340
+ lifetime_calls: 0,
341
+ lineage_events: [{ at: nowIso, type: "created" }],
342
+ lineage_name: name,
343
+ review_epochs: [],
344
+ reviewed_fingerprints: [],
345
+ revision: 1,
346
+ revision_calls: 0,
347
+ revision_direct_calls: 0,
348
+ revision_spawn_calls: 0,
349
+ revision_tool_calls: 0,
350
+ revisions: [{ fingerprint: source.fingerprint, first_seen: nowIso, revision: 1 }],
351
+ } satisfies RecipeUsageRecord);
352
+ index.paths[key] = name;
353
+ writeJsonAtomic(indexPath, index);
354
+ return true;
355
+ });
356
+ } catch {
357
+ return false;
358
+ }
359
+ });
360
+ }
361
+
362
+ export function isCurrentRecipeRevisionReviewed(
363
+ path: string,
364
+ recipeRoot = inferRecipeRoot(path),
365
+ ): boolean {
366
+ const usage = readRecipeUsage(path, recipeRoot);
367
+ return Boolean(
368
+ usage &&
369
+ typeof usage.fingerprint === "string" &&
370
+ Array.isArray(usage.reviewed_fingerprints) &&
371
+ usage.reviewed_fingerprints.includes(usage.fingerprint),
372
+ );
373
+ }
374
+
375
+ export interface RecipeLaunchRecordOptions {
376
+ onMutationLockContention?(): void;
377
+ }
378
+
379
+ export function recordRecipeLaunch(
380
+ path: string,
381
+ now = new Date(),
382
+ kind: RecipeLaunchKind = "direct",
383
+ recipeRoot = inferRecipeRoot(path),
384
+ options: RecipeLaunchRecordOptions = {},
385
+ ): boolean {
386
+ return withFileMutationLock(recipeRoot, () => {
387
+ if (!existsSync(path)) return false;
388
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
389
+ return withFileMutationLock(indexPath, () => {
390
+ try {
391
+ const source = readRecipeSource(path);
392
+ if (!source) return false;
393
+ const { fingerprint } = source;
394
+ const key = pathKey(path, recipeRoot);
395
+ const desiredName = recipeName(path);
396
+ const index = readIndex(recipeRoot);
397
+ const relocated = index.paths[key]
398
+ ? undefined
399
+ : findRelocatedRecipe(index, fingerprint, recipeRoot);
400
+ const indexedName = index.paths[key] ?? relocated?.indexedName;
401
+ const found = indexedName
402
+ ? readIndexedLedger(indexedName, recipeRoot)
403
+ : undefined;
404
+ const existingAtDesired = readLedger(desiredName, recipeRoot);
405
+ if (!found && existingAtDesired) return false;
406
+ const stored = found?.record;
407
+ const ledgerPath = getRecipeUsageLedgerPath(desiredName, recipeRoot);
408
+ return withFileMutationLock(ledgerPath, () => {
409
+ const nowIso = now.toISOString();
410
+ const changed = Boolean(stored && stored.fingerprint !== fingerprint);
411
+ const renamed = Boolean(stored && stored.lineage_name !== desiredName);
412
+ const lifetimeCalls = normalizeCalls(
413
+ stored?.lifetime_calls ?? stored?.calls,
414
+ ) + 1;
415
+ const counterKey = launchCounterKey(kind);
416
+ const revisionKey = revisionCounterKey(kind);
417
+ const revision = (stored?.revision ?? 1) + (changed ? 1 : 0);
418
+ let events = stored?.lineage_events ?? [];
419
+ if (renamed) {
420
+ events = appendEvent(events, {
421
+ at: nowIso,
422
+ from: stored!.lineage_name,
423
+ to: desiredName,
424
+ type: "renamed",
425
+ });
426
+ }
427
+ if (changed) events = appendEvent(events, { at: nowIso, type: "revised" });
428
+ if (events.length === 0) events = [{ at: nowIso, type: "created" }];
429
+ const existingRevisions = stored?.revisions ?? [];
430
+ const record: RecipeUsageRecord = {
431
+ ...stored,
432
+ calls: lifetimeCalls,
433
+ current_path: key,
434
+ fingerprint,
435
+ first_seen: stored?.first_seen ?? nowIso,
436
+ former_names: renamed
437
+ ? appendBounded(stored?.former_names ?? [], stored!.lineage_name)
438
+ : relocated
439
+ ? appendBounded(stored?.former_names ?? [], recipeName(relocated.path))
440
+ : stored?.former_names ?? [],
441
+ former_paths: relocated
442
+ ? appendBounded(stored?.former_paths ?? [], relocated.path)
443
+ : stored?.former_paths ?? [],
444
+ lifetime_calls: lifetimeCalls,
445
+ lineage_events: events,
446
+ lineage_name: desiredName,
447
+ review_epochs: stored?.review_epochs ?? [],
448
+ reviewed_fingerprints: stored?.reviewed_fingerprints ?? [],
449
+ revision,
450
+ revision_calls: (changed ? 0 : normalizeCalls(stored?.revision_calls)) + 1,
451
+ revision_direct_calls: changed ? 0 : normalizeCalls(stored?.revision_direct_calls),
452
+ revision_spawn_calls: changed ? 0 : normalizeCalls(stored?.revision_spawn_calls),
453
+ revision_tool_calls: changed ? 0 : normalizeCalls(stored?.revision_tool_calls),
454
+ revisions: changed
455
+ ? appendRevision(existingRevisions, { fingerprint, first_seen: nowIso, revision })
456
+ : existingRevisions.length > 0
457
+ ? existingRevisions
458
+ : [{ fingerprint, first_seen: nowIso, revision }],
459
+ [counterKey]: normalizeCalls(stored?.[counterKey]) + 1,
460
+ [revisionKey]: (changed ? 0 : normalizeCalls(stored?.[revisionKey])) + 1,
461
+ last_called: nowIso,
462
+ launch_kind: kind,
463
+ ...(changed
464
+ ? { reset_at: nowIso, reset_reason: "recipe content fingerprint changed" }
465
+ : {}),
466
+ };
467
+ writeJsonAtomic(ledgerPath, record);
468
+ if (indexedName && indexedName !== desiredName) {
469
+ const oldPath = getRecipeUsageLedgerPath(indexedName, recipeRoot);
470
+ if (existsSync(oldPath)) unlinkSync(oldPath);
471
+ }
472
+ if (relocated) delete index.paths[relocated.path];
473
+ index.paths[key] = desiredName;
474
+ writeJsonAtomic(indexPath, index);
475
+ return true;
476
+ });
477
+ } catch {
478
+ return false;
479
+ }
480
+ });
481
+ }, { onContention: options.onMutationLockContention });
482
+ }
483
+
484
+ export function recordRecipeReview(
485
+ path: string,
486
+ reviewEpoch: string,
487
+ now = new Date(),
488
+ recipeRoot = inferRecipeRoot(path),
489
+ ): boolean {
490
+ if (!reviewEpoch.trim()) return false;
491
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
492
+ return withFileMutationLock(indexPath, () => {
493
+ try {
494
+ const key = pathKey(path, recipeRoot);
495
+ const lineageName = readIndex(recipeRoot).paths[key];
496
+ if (!lineageName) return false;
497
+ const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
498
+ return withFileMutationLock(ledgerPath, () => {
499
+ const ledger = readLedger(lineageName, recipeRoot);
500
+ if (!ledger) return false;
501
+ if (ledger.review_epochs.includes(reviewEpoch)) return true;
502
+ writeJsonAtomic(ledgerPath, {
503
+ ...ledger,
504
+ lineage_events: appendEvent(ledger.lineage_events ?? [], {
505
+ at: now.toISOString(),
506
+ review_epoch: reviewEpoch,
507
+ type: "reviewed",
508
+ }),
509
+ review_epochs: appendBounded(ledger.review_epochs, reviewEpoch, 128),
510
+ reviewed_fingerprints: appendBounded(
511
+ ledger.reviewed_fingerprints ?? [],
512
+ ledger.fingerprint,
513
+ 128,
514
+ ),
515
+ });
516
+ return true;
103
517
  });
518
+ } catch {
519
+ return false;
520
+ }
521
+ });
522
+ }
523
+
524
+ export function recordRecipeRollback(
525
+ path: string,
526
+ rollbackRevision: number,
527
+ now = new Date(),
528
+ recipeRoot = inferRecipeRoot(path),
529
+ ): boolean {
530
+ if (!Number.isInteger(rollbackRevision) || rollbackRevision <= 0) return false;
531
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
532
+ return withFileMutationLock(indexPath, () => {
533
+ try {
534
+ const key = pathKey(path, recipeRoot);
535
+ const lineageName = readIndex(recipeRoot).paths[key];
536
+ if (!lineageName) return false;
537
+ const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
538
+ return withFileMutationLock(ledgerPath, () => {
539
+ const ledger = readLedger(lineageName, recipeRoot);
540
+ if (!ledger) return false;
541
+ if (ledger.rollback_of_revision === rollbackRevision) return true;
542
+ writeJsonAtomic(ledgerPath, {
543
+ ...ledger,
544
+ lineage_events: appendEvent(ledger.lineage_events ?? [], {
545
+ at: now.toISOString(),
546
+ revision: ledger.revision,
547
+ rollback_revision: rollbackRevision,
548
+ type: "rollback",
549
+ }),
550
+ rollback_of_revision: rollbackRevision,
551
+ });
552
+ return true;
553
+ });
554
+ } catch {
555
+ return false;
556
+ }
557
+ });
558
+ }
559
+
560
+ export function retireRecipeUsage(
561
+ path: string,
562
+ recipeRoot = inferRecipeRoot(path),
563
+ ): boolean {
564
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
565
+ return withFileMutationLock(indexPath, () => {
566
+ try {
567
+ const key = pathKey(path, recipeRoot);
568
+ const index = readIndex(recipeRoot);
569
+ const lineageName = index.paths[key];
570
+ if (!lineageName) return true;
571
+ delete index.paths[key];
572
+ writeJsonAtomic(indexPath, index);
573
+ const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
574
+ if (existsSync(ledgerPath)) unlinkSync(ledgerPath);
104
575
  return true;
105
576
  } catch {
106
577
  return false;
107
578
  }
108
579
  });
109
580
  }
581
+
582
+ export function moveRecipeUsage(
583
+ fromPath: string,
584
+ toPath: string,
585
+ recipeRoot = inferRecipeRoot(fromPath),
586
+ ): boolean {
587
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
588
+ return withFileMutationLock(indexPath, () => {
589
+ try {
590
+ const fromKey = pathKey(fromPath, recipeRoot);
591
+ const toKey = pathKey(toPath, recipeRoot);
592
+ const index = readIndex(recipeRoot);
593
+ const oldName = index.paths[fromKey];
594
+ const newName = recipeName(toPath);
595
+ if (!oldName) {
596
+ return index.paths[toKey] === newName && Boolean(readLedger(newName, recipeRoot));
597
+ }
598
+ if (index.paths[toKey]) return false;
599
+ const found = readIndexedLedger(oldName, recipeRoot);
600
+ if (!found || (oldName !== newName && readLedger(newName, recipeRoot))) return false;
601
+ const oldLedgerPath = getRecipeUsageLedgerPath(oldName, recipeRoot);
602
+ const newLedgerPath = getRecipeUsageLedgerPath(newName, recipeRoot);
603
+ return withFileMutationLock(newLedgerPath, () => {
604
+ const fromDraft = fromKey.startsWith("drafts/");
605
+ const toDraft = toKey.startsWith("drafts/");
606
+ const transition: RecipeLineageEvent["type"] = fromDraft && !toDraft
607
+ ? "promoted"
608
+ : !fromDraft && toDraft
609
+ ? "demoted"
610
+ : "renamed";
611
+ writeJsonAtomic(newLedgerPath, {
612
+ ...found.record,
613
+ current_path: toKey,
614
+ former_names: oldName !== newName
615
+ ? appendBounded(found.record.former_names, oldName)
616
+ : found.record.former_names,
617
+ former_paths: appendBounded(found.record.former_paths, fromKey),
618
+ lineage_events: appendEvent(found.record.lineage_events ?? [], {
619
+ at: new Date().toISOString(),
620
+ from: oldName,
621
+ to: newName,
622
+ type: transition,
623
+ }),
624
+ lineage_name: newName,
625
+ });
626
+ index.paths[toKey] = newName;
627
+ delete index.paths[fromKey];
628
+ writeJsonAtomic(indexPath, index);
629
+ if (oldLedgerPath !== newLedgerPath && existsSync(oldLedgerPath)) {
630
+ unlinkSync(oldLedgerPath);
631
+ }
632
+ return true;
633
+ });
634
+ } catch {
635
+ return false;
636
+ }
637
+ });
638
+ }