@llblab/pi-actors 0.41.1 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (157) hide show
  1. package/AGENTS.md +11 -6
  2. package/BACKLOG.md +1 -65
  3. package/CHANGELOG.md +25 -0
  4. package/README.md +24 -4
  5. package/dist/index.js +25 -121
  6. package/dist/lib/async-runs.d.ts +37 -5
  7. package/dist/lib/async-runs.js +181 -51
  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/command-templates.js +1 -1
  11. package/dist/lib/draft-consolidation-transaction.d.ts +65 -0
  12. package/dist/lib/draft-consolidation-transaction.js +610 -0
  13. package/dist/lib/draft-consolidation.d.ts +35 -0
  14. package/dist/lib/draft-consolidation.js +126 -0
  15. package/dist/lib/draft-review.d.ts +56 -0
  16. package/dist/lib/draft-review.js +254 -0
  17. package/dist/lib/draft-sleep.d.ts +65 -0
  18. package/dist/lib/draft-sleep.js +468 -0
  19. package/dist/lib/file-state.d.ts +8 -1
  20. package/dist/lib/file-state.js +115 -18
  21. package/dist/lib/inspector-actions.d.ts +16 -0
  22. package/dist/lib/inspector-actions.js +57 -0
  23. package/dist/lib/inspector-command.d.ts +7 -0
  24. package/dist/lib/inspector-command.js +37 -0
  25. package/dist/lib/inspector-overlay.d.ts +23 -3
  26. package/dist/lib/inspector-overlay.js +327 -70
  27. package/dist/lib/inspector.d.ts +9 -0
  28. package/dist/lib/inspector.js +49 -1
  29. package/dist/lib/observability.d.ts +13 -0
  30. package/dist/lib/observability.js +160 -6
  31. package/dist/lib/paths.d.ts +7 -0
  32. package/dist/lib/paths.js +28 -0
  33. package/dist/lib/recipes-discovery.js +32 -24
  34. package/dist/lib/recipes-usage.d.ts +18 -6
  35. package/dist/lib/recipes-usage.js +445 -34
  36. package/dist/lib/review-control.d.ts +14 -0
  37. package/dist/lib/review-control.js +111 -0
  38. package/dist/lib/review-diagnostics.d.ts +11 -0
  39. package/dist/lib/review-diagnostics.js +148 -0
  40. package/dist/lib/review-projection.d.ts +14 -0
  41. package/dist/lib/review-projection.js +170 -0
  42. package/dist/lib/run-ui-runtime.d.ts +18 -0
  43. package/dist/lib/run-ui-runtime.js +123 -0
  44. package/dist/lib/runs-artifacts.d.ts +1 -1
  45. package/dist/lib/runs-artifacts.js +1 -1
  46. package/dist/lib/runs-control.d.ts +10 -2
  47. package/dist/lib/runs-control.js +37 -7
  48. package/dist/lib/runs-identity.d.ts +1 -1
  49. package/dist/lib/runs-identity.js +1 -1
  50. package/dist/lib/runs-index.d.ts +11 -2
  51. package/dist/lib/runs-index.js +46 -23
  52. package/dist/lib/runs-mailbox.d.ts +1 -1
  53. package/dist/lib/runs-mailbox.js +1 -1
  54. package/dist/lib/runs-messages.d.ts +1 -1
  55. package/dist/lib/runs-messages.js +1 -1
  56. package/dist/lib/runs-outbox.d.ts +1 -1
  57. package/dist/lib/runs-outbox.js +1 -1
  58. package/dist/lib/runs-ownership.d.ts +1 -1
  59. package/dist/lib/runs-ownership.js +18 -4
  60. package/dist/lib/runs-parent-teardown.d.ts +51 -0
  61. package/dist/lib/runs-parent-teardown.js +172 -0
  62. package/dist/lib/runs-process.d.ts +1 -1
  63. package/dist/lib/runs-process.js +5 -4
  64. package/dist/lib/runs-retention.d.ts +1 -1
  65. package/dist/lib/runs-retention.js +1 -1
  66. package/dist/lib/runs-start.d.ts +5 -3
  67. package/dist/lib/runs-start.js +7 -48
  68. package/dist/lib/runs-status.d.ts +5 -3
  69. package/dist/lib/runs-status.js +7 -6
  70. package/dist/lib/runtime.d.ts +7 -1
  71. package/dist/lib/runtime.js +32 -18
  72. package/dist/lib/tool-review-lineage-transaction.d.ts +27 -0
  73. package/dist/lib/tool-review-lineage-transaction.js +597 -0
  74. package/dist/lib/tool-review-lineage.d.ts +24 -0
  75. package/dist/lib/tool-review-lineage.js +98 -0
  76. package/dist/lib/tool-review-scheduler.d.ts +80 -0
  77. package/dist/lib/tool-review-scheduler.js +494 -0
  78. package/dist/lib/tool-review-transaction.d.ts +50 -0
  79. package/dist/lib/tool-review-transaction.js +362 -0
  80. package/dist/lib/tool-review.d.ts +56 -0
  81. package/dist/lib/tool-review.js +197 -0
  82. package/dist/lib/tools-inspect.js +28 -4
  83. package/dist/lib/tools-local.js +21 -4
  84. package/dist/lib/tools-message.d.ts +1 -0
  85. package/dist/lib/tools-message.js +29 -17
  86. package/dist/lib/tools-spawn.js +14 -2
  87. package/dist/lib/tools.d.ts +1 -0
  88. package/dist/lib/tools.js +1 -0
  89. package/dist/recipes/draft-review.json +24 -0
  90. package/dist/recipes/tool-review.json +24 -0
  91. package/dist/scripts/async-runner.mjs +9 -9
  92. package/dist/scripts/build-dist.mjs +6 -1
  93. package/dist/scripts/conformance.mjs +6 -1
  94. package/dist/scripts/recipe-utils.mjs +3 -3
  95. package/dist/scripts/release-gates.mjs +165 -0
  96. package/dist/skills/actors/SKILL.md +9 -8
  97. package/dist/skills/swarm/SKILL.md +1 -1
  98. package/docs/actor-inspector.md +20 -11
  99. package/docs/async-runs.md +16 -2
  100. package/docs/recipe-library.md +7 -3
  101. package/docs/template-recipes.md +4 -11
  102. package/docs/tool-registry.md +13 -4
  103. package/index.ts +27 -142
  104. package/lib/async-runs.ts +283 -67
  105. package/lib/automatic-review-runtime.ts +135 -0
  106. package/lib/command-templates.ts +1 -1
  107. package/lib/draft-consolidation-transaction.ts +821 -0
  108. package/lib/draft-consolidation.ts +181 -0
  109. package/lib/draft-review.ts +325 -0
  110. package/lib/draft-sleep.ts +576 -0
  111. package/lib/file-state.ts +143 -19
  112. package/lib/inspector-actions.ts +79 -0
  113. package/lib/inspector-command.ts +54 -0
  114. package/lib/inspector-overlay.ts +377 -91
  115. package/lib/inspector.ts +73 -1
  116. package/lib/observability.ts +194 -5
  117. package/lib/paths.ts +43 -0
  118. package/lib/recipes-discovery.ts +34 -26
  119. package/lib/recipes-usage.ts +569 -40
  120. package/lib/review-control.ts +137 -0
  121. package/lib/review-diagnostics.ts +164 -0
  122. package/lib/review-projection.ts +200 -0
  123. package/lib/run-ui-runtime.ts +153 -0
  124. package/lib/runs-artifacts.ts +1 -1
  125. package/lib/runs-control.ts +69 -6
  126. package/lib/runs-identity.ts +1 -1
  127. package/lib/runs-index.ts +57 -21
  128. package/lib/runs-mailbox.ts +1 -1
  129. package/lib/runs-messages.ts +1 -1
  130. package/lib/runs-outbox.ts +1 -1
  131. package/lib/runs-ownership.ts +23 -4
  132. package/lib/runs-parent-teardown.ts +257 -0
  133. package/lib/runs-process.ts +5 -4
  134. package/lib/runs-retention.ts +1 -1
  135. package/lib/runs-start.ts +13 -68
  136. package/lib/runs-status.ts +17 -8
  137. package/lib/runtime.ts +34 -17
  138. package/lib/tool-review-lineage-transaction.ts +881 -0
  139. package/lib/tool-review-lineage.ts +145 -0
  140. package/lib/tool-review-scheduler.ts +635 -0
  141. package/lib/tool-review-transaction.ts +563 -0
  142. package/lib/tool-review.ts +270 -0
  143. package/lib/tools-inspect.ts +35 -4
  144. package/lib/tools-local.ts +29 -4
  145. package/lib/tools-message.ts +45 -30
  146. package/lib/tools-spawn.ts +22 -2
  147. package/lib/tools.ts +5 -0
  148. package/package.json +5 -3
  149. package/recipes/draft-review.json +24 -0
  150. package/recipes/tool-review.json +24 -0
  151. package/scripts/async-runner.mjs +9 -9
  152. package/scripts/build-dist.mjs +6 -1
  153. package/scripts/conformance.mjs +6 -1
  154. package/scripts/recipe-utils.mjs +3 -3
  155. package/scripts/release-gates.mjs +165 -0
  156. package/skills/actors/SKILL.md +9 -8
  157. package/skills/swarm/SKILL.md +1 -1
@@ -1,11 +1,11 @@
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
  import { createHash } from "node:crypto";
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { basename, dirname, join } from "node:path";
7
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
8
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
9
9
  import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
10
10
  function isRecord(value) {
11
11
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -25,55 +25,410 @@ function stableStringify(value) {
25
25
  .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`)
26
26
  .join(",")}}`;
27
27
  }
28
- function getRecipeFingerprint(raw) {
29
- const { usage: _usage, ...content } = raw;
30
- return createHash("sha256").update(stableStringify(content)).digest("hex");
28
+ function readRecipeSource(path) {
29
+ const bytes = readFileSync(path);
30
+ if (extname(path).toLowerCase() !== ".json") {
31
+ return {
32
+ fingerprint: createHash("sha256").update(bytes).digest("hex"),
33
+ };
34
+ }
35
+ const parsed = JSON.parse(bytes.toString("utf8"));
36
+ if (!isRecord(parsed))
37
+ return undefined;
38
+ const { usage: _usage, ...content } = parsed;
39
+ return {
40
+ fingerprint: createHash("sha256")
41
+ .update(stableStringify(content))
42
+ .digest("hex"),
43
+ };
44
+ }
45
+ function inferRecipeRoot(path) {
46
+ const parent = dirname(resolve(path));
47
+ return basename(parent) === "drafts" ? dirname(parent) : parent;
48
+ }
49
+ function pathKey(path, recipeRoot) {
50
+ const relation = relative(resolve(recipeRoot), resolve(path));
51
+ if (!relation || relation.startsWith("..") || resolve(relation) === relation) {
52
+ throw new Error(`Recipe path escapes recipe root: ${path}`);
53
+ }
54
+ return relation.replaceAll("\\", "/");
55
+ }
56
+ function recipeName(path) {
57
+ return basename(path).replace(/\.(?:json|md)$/u, "");
58
+ }
59
+ function lineageFilename(lineageName) {
60
+ if (!lineageName || lineageName === "." || lineageName === "..") {
61
+ throw new Error("Invalid recipe lineage name.");
62
+ }
63
+ return `${encodeURIComponent(lineageName)}.json`;
64
+ }
65
+ function appendBounded(values, value, limit = 64) {
66
+ const next = [...values.filter((item) => item !== value), value];
67
+ return next.slice(Math.max(0, next.length - limit));
68
+ }
69
+ function appendEvent(events, event) {
70
+ return [...events, event].slice(-128);
31
71
  }
32
- export function getRecipeUsagePath(path) {
33
- return join(dirname(path), ".usage", `${basename(path)}.json`);
72
+ function appendRevision(revisions, revision) {
73
+ return [...revisions, revision].slice(-128);
34
74
  }
35
- export function readRecipeUsage(path) {
75
+ export function getRecipeUsageIndexPath(recipeRoot) {
76
+ return join(resolve(recipeRoot), ".usage", "index.json");
77
+ }
78
+ export function getRecipeUsageLedgerPath(lineageName, recipeRoot) {
79
+ return join(resolve(recipeRoot), ".usage", "recipes", lineageFilename(lineageName));
80
+ }
81
+ export const RECIPE_REVISION_SNAPSHOT_LIMIT = 32;
82
+ export function getRecipeRevisionSnapshotPath(lineageName, revision, recipeRoot) {
83
+ if (!Number.isInteger(revision) || revision <= 0) {
84
+ throw new Error("Invalid recipe revision snapshot number.");
85
+ }
86
+ const slot = ((revision - 1) % RECIPE_REVISION_SNAPSHOT_LIMIT) + 1;
87
+ return join(resolve(recipeRoot), ".usage", "revisions", encodeURIComponent(lineageName), `${slot}.json`);
88
+ }
89
+ function readJsonRecord(path) {
36
90
  try {
37
- const value = JSON.parse(readFileSync(getRecipeUsagePath(path), "utf8"));
91
+ const value = JSON.parse(readFileSync(path, "utf8"));
38
92
  return isRecord(value) ? value : undefined;
39
93
  }
40
94
  catch {
41
95
  return undefined;
42
96
  }
43
97
  }
98
+ function readIndex(recipeRoot) {
99
+ const value = readJsonRecord(getRecipeUsageIndexPath(recipeRoot));
100
+ const paths = value?.paths;
101
+ return {
102
+ paths: isRecord(paths)
103
+ ? Object.fromEntries(Object.entries(paths).filter((entry) => typeof entry[1] === "string"))
104
+ : {},
105
+ };
106
+ }
107
+ function normalizeStoredLedger(value, lineageName) {
108
+ return value.lineage_name === lineageName
109
+ ? value
110
+ : undefined;
111
+ }
112
+ function readLedger(lineageName, recipeRoot) {
113
+ const value = readJsonRecord(getRecipeUsageLedgerPath(lineageName, recipeRoot));
114
+ return value ? normalizeStoredLedger(value, lineageName) : undefined;
115
+ }
116
+ function readIndexedLedger(indexedName, recipeRoot) {
117
+ const current = readLedger(indexedName, recipeRoot);
118
+ return current ? { record: current } : undefined;
119
+ }
120
+ function findRelocatedRecipe(index, fingerprint, recipeRoot) {
121
+ const candidates = Object.entries(index.paths).flatMap(([oldPath, indexedName]) => {
122
+ if (existsSync(join(recipeRoot, oldPath)))
123
+ return [];
124
+ const found = readIndexedLedger(indexedName, recipeRoot);
125
+ return found?.record.fingerprint === fingerprint
126
+ ? [{ indexedName, path: oldPath, record: found.record }]
127
+ : [];
128
+ });
129
+ return candidates.length === 1 ? candidates[0] : undefined;
130
+ }
44
131
  function launchCounterKey(kind) {
45
132
  return `${kind}_calls`;
46
133
  }
47
- export function recordRecipeLaunch(path, now = new Date(), kind = "direct") {
134
+ function revisionCounterKey(kind) {
135
+ return `revision_${kind}_calls`;
136
+ }
137
+ export function readRecipeUsage(path, recipeRoot = inferRecipeRoot(path)) {
138
+ try {
139
+ const key = pathKey(path, recipeRoot);
140
+ const indexedName = readIndex(recipeRoot).paths[key];
141
+ if (indexedName) {
142
+ return readIndexedLedger(indexedName, recipeRoot)?.record;
143
+ }
144
+ return undefined;
145
+ }
146
+ catch {
147
+ return undefined;
148
+ }
149
+ }
150
+ export function ensureRecipeLineage(path, now = new Date(), recipeRoot = inferRecipeRoot(path)) {
48
151
  if (!existsSync(path))
49
152
  return false;
50
- const usagePath = getRecipeUsagePath(path);
51
- return withFileMutationLock(usagePath, () => {
153
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
154
+ return withFileMutationLock(indexPath, () => {
52
155
  try {
53
- const raw = JSON.parse(readFileSync(path, "utf8"));
54
- if (!isRecord(raw))
156
+ const source = readRecipeSource(path);
157
+ if (!source)
55
158
  return false;
56
- const storedUsage = readRecipeUsage(path);
57
- const usage = storedUsage ?? (isRecord(raw.usage) ? raw.usage : {});
58
- const fingerprint = getRecipeFingerprint(raw);
59
- const changed = typeof usage.fingerprint === "string" &&
60
- usage.fingerprint !== fingerprint;
61
- const nowIso = now.toISOString();
62
- const counterKey = launchCounterKey(kind);
63
- writeJsonAtomic(usagePath, {
64
- ...usage,
65
- calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
66
- [counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
67
- last_called: nowIso,
68
- launch_kind: kind,
69
- fingerprint,
70
- ...(changed
71
- ? {
159
+ const key = pathKey(path, recipeRoot);
160
+ const name = recipeName(path);
161
+ const index = readIndex(recipeRoot);
162
+ const indexedName = index.paths[key];
163
+ if (indexedName) {
164
+ const found = readIndexedLedger(indexedName, recipeRoot);
165
+ if (!found || indexedName !== name)
166
+ return false;
167
+ if (found.record.fingerprint === source.fingerprint)
168
+ return true;
169
+ const ledgerPath = getRecipeUsageLedgerPath(name, recipeRoot);
170
+ return withFileMutationLock(ledgerPath, () => {
171
+ const current = readLedger(name, recipeRoot);
172
+ if (!current)
173
+ return false;
174
+ if (current.fingerprint === source.fingerprint)
175
+ return true;
176
+ const nowIso = now.toISOString();
177
+ const revision = current.revision + 1;
178
+ writeJsonAtomic(ledgerPath, {
179
+ ...current,
180
+ fingerprint: source.fingerprint,
181
+ lineage_events: appendEvent(current.lineage_events, {
182
+ at: nowIso,
183
+ type: "revised",
184
+ }),
72
185
  reset_at: nowIso,
73
186
  reset_reason: "recipe content fingerprint changed",
187
+ revision,
188
+ revision_calls: 0,
189
+ revision_direct_calls: 0,
190
+ revision_spawn_calls: 0,
191
+ revision_tool_calls: 0,
192
+ revisions: appendRevision(current.revisions, {
193
+ fingerprint: source.fingerprint,
194
+ first_seen: nowIso,
195
+ revision,
196
+ }),
197
+ });
198
+ return true;
199
+ });
200
+ }
201
+ if (readLedger(name, recipeRoot))
202
+ return false;
203
+ const nowIso = now.toISOString();
204
+ const ledgerPath = getRecipeUsageLedgerPath(name, recipeRoot);
205
+ return withFileMutationLock(ledgerPath, () => {
206
+ if (readLedger(name, recipeRoot))
207
+ return false;
208
+ writeJsonAtomic(ledgerPath, {
209
+ calls: 0,
210
+ current_path: key,
211
+ fingerprint: source.fingerprint,
212
+ first_seen: nowIso,
213
+ former_names: [],
214
+ former_paths: [],
215
+ lifetime_calls: 0,
216
+ lineage_events: [{ at: nowIso, type: "created" }],
217
+ lineage_name: name,
218
+ review_epochs: [],
219
+ reviewed_fingerprints: [],
220
+ revision: 1,
221
+ revision_calls: 0,
222
+ revision_direct_calls: 0,
223
+ revision_spawn_calls: 0,
224
+ revision_tool_calls: 0,
225
+ revisions: [{ fingerprint: source.fingerprint, first_seen: nowIso, revision: 1 }],
226
+ });
227
+ index.paths[key] = name;
228
+ writeJsonAtomic(indexPath, index);
229
+ return true;
230
+ });
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ });
236
+ }
237
+ export function isCurrentRecipeRevisionReviewed(path, recipeRoot = inferRecipeRoot(path)) {
238
+ const usage = readRecipeUsage(path, recipeRoot);
239
+ return Boolean(usage &&
240
+ typeof usage.fingerprint === "string" &&
241
+ Array.isArray(usage.reviewed_fingerprints) &&
242
+ usage.reviewed_fingerprints.includes(usage.fingerprint));
243
+ }
244
+ export function recordRecipeLaunch(path, now = new Date(), kind = "direct", recipeRoot = inferRecipeRoot(path), options = {}) {
245
+ return withFileMutationLock(recipeRoot, () => {
246
+ if (!existsSync(path))
247
+ return false;
248
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
249
+ return withFileMutationLock(indexPath, () => {
250
+ try {
251
+ const source = readRecipeSource(path);
252
+ if (!source)
253
+ return false;
254
+ const { fingerprint } = source;
255
+ const key = pathKey(path, recipeRoot);
256
+ const desiredName = recipeName(path);
257
+ const index = readIndex(recipeRoot);
258
+ const relocated = index.paths[key]
259
+ ? undefined
260
+ : findRelocatedRecipe(index, fingerprint, recipeRoot);
261
+ const indexedName = index.paths[key] ?? relocated?.indexedName;
262
+ const found = indexedName
263
+ ? readIndexedLedger(indexedName, recipeRoot)
264
+ : undefined;
265
+ const existingAtDesired = readLedger(desiredName, recipeRoot);
266
+ if (!found && existingAtDesired)
267
+ return false;
268
+ const stored = found?.record;
269
+ const ledgerPath = getRecipeUsageLedgerPath(desiredName, recipeRoot);
270
+ return withFileMutationLock(ledgerPath, () => {
271
+ const nowIso = now.toISOString();
272
+ const changed = Boolean(stored && stored.fingerprint !== fingerprint);
273
+ const renamed = Boolean(stored && stored.lineage_name !== desiredName);
274
+ const lifetimeCalls = normalizeCalls(stored?.lifetime_calls ?? stored?.calls) + 1;
275
+ const counterKey = launchCounterKey(kind);
276
+ const revisionKey = revisionCounterKey(kind);
277
+ const revision = (stored?.revision ?? 1) + (changed ? 1 : 0);
278
+ let events = stored?.lineage_events ?? [];
279
+ if (renamed) {
280
+ events = appendEvent(events, {
281
+ at: nowIso,
282
+ from: stored.lineage_name,
283
+ to: desiredName,
284
+ type: "renamed",
285
+ });
286
+ }
287
+ if (changed)
288
+ events = appendEvent(events, { at: nowIso, type: "revised" });
289
+ if (events.length === 0)
290
+ events = [{ at: nowIso, type: "created" }];
291
+ const existingRevisions = stored?.revisions ?? [];
292
+ const record = {
293
+ ...stored,
294
+ calls: lifetimeCalls,
295
+ current_path: key,
296
+ fingerprint,
297
+ first_seen: stored?.first_seen ?? nowIso,
298
+ former_names: renamed
299
+ ? appendBounded(stored?.former_names ?? [], stored.lineage_name)
300
+ : relocated
301
+ ? appendBounded(stored?.former_names ?? [], recipeName(relocated.path))
302
+ : stored?.former_names ?? [],
303
+ former_paths: relocated
304
+ ? appendBounded(stored?.former_paths ?? [], relocated.path)
305
+ : stored?.former_paths ?? [],
306
+ lifetime_calls: lifetimeCalls,
307
+ lineage_events: events,
308
+ lineage_name: desiredName,
309
+ review_epochs: stored?.review_epochs ?? [],
310
+ reviewed_fingerprints: stored?.reviewed_fingerprints ?? [],
311
+ revision,
312
+ revision_calls: (changed ? 0 : normalizeCalls(stored?.revision_calls)) + 1,
313
+ revision_direct_calls: changed ? 0 : normalizeCalls(stored?.revision_direct_calls),
314
+ revision_spawn_calls: changed ? 0 : normalizeCalls(stored?.revision_spawn_calls),
315
+ revision_tool_calls: changed ? 0 : normalizeCalls(stored?.revision_tool_calls),
316
+ revisions: changed
317
+ ? appendRevision(existingRevisions, { fingerprint, first_seen: nowIso, revision })
318
+ : existingRevisions.length > 0
319
+ ? existingRevisions
320
+ : [{ fingerprint, first_seen: nowIso, revision }],
321
+ [counterKey]: normalizeCalls(stored?.[counterKey]) + 1,
322
+ [revisionKey]: (changed ? 0 : normalizeCalls(stored?.[revisionKey])) + 1,
323
+ last_called: nowIso,
324
+ launch_kind: kind,
325
+ ...(changed
326
+ ? { reset_at: nowIso, reset_reason: "recipe content fingerprint changed" }
327
+ : {}),
328
+ };
329
+ writeJsonAtomic(ledgerPath, record);
330
+ if (indexedName && indexedName !== desiredName) {
331
+ const oldPath = getRecipeUsageLedgerPath(indexedName, recipeRoot);
332
+ if (existsSync(oldPath))
333
+ unlinkSync(oldPath);
74
334
  }
75
- : {}),
335
+ if (relocated)
336
+ delete index.paths[relocated.path];
337
+ index.paths[key] = desiredName;
338
+ writeJsonAtomic(indexPath, index);
339
+ return true;
340
+ });
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ });
346
+ }, { onContention: options.onMutationLockContention });
347
+ }
348
+ export function recordRecipeReview(path, reviewEpoch, now = new Date(), recipeRoot = inferRecipeRoot(path)) {
349
+ if (!reviewEpoch.trim())
350
+ return false;
351
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
352
+ return withFileMutationLock(indexPath, () => {
353
+ try {
354
+ const key = pathKey(path, recipeRoot);
355
+ const lineageName = readIndex(recipeRoot).paths[key];
356
+ if (!lineageName)
357
+ return false;
358
+ const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
359
+ return withFileMutationLock(ledgerPath, () => {
360
+ const ledger = readLedger(lineageName, recipeRoot);
361
+ if (!ledger)
362
+ return false;
363
+ if (ledger.review_epochs.includes(reviewEpoch))
364
+ return true;
365
+ writeJsonAtomic(ledgerPath, {
366
+ ...ledger,
367
+ lineage_events: appendEvent(ledger.lineage_events ?? [], {
368
+ at: now.toISOString(),
369
+ review_epoch: reviewEpoch,
370
+ type: "reviewed",
371
+ }),
372
+ review_epochs: appendBounded(ledger.review_epochs, reviewEpoch, 128),
373
+ reviewed_fingerprints: appendBounded(ledger.reviewed_fingerprints ?? [], ledger.fingerprint, 128),
374
+ });
375
+ return true;
76
376
  });
377
+ }
378
+ catch {
379
+ return false;
380
+ }
381
+ });
382
+ }
383
+ export function recordRecipeRollback(path, rollbackRevision, now = new Date(), recipeRoot = inferRecipeRoot(path)) {
384
+ if (!Number.isInteger(rollbackRevision) || rollbackRevision <= 0)
385
+ return false;
386
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
387
+ return withFileMutationLock(indexPath, () => {
388
+ try {
389
+ const key = pathKey(path, recipeRoot);
390
+ const lineageName = readIndex(recipeRoot).paths[key];
391
+ if (!lineageName)
392
+ return false;
393
+ const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
394
+ return withFileMutationLock(ledgerPath, () => {
395
+ const ledger = readLedger(lineageName, recipeRoot);
396
+ if (!ledger)
397
+ return false;
398
+ if (ledger.rollback_of_revision === rollbackRevision)
399
+ return true;
400
+ writeJsonAtomic(ledgerPath, {
401
+ ...ledger,
402
+ lineage_events: appendEvent(ledger.lineage_events ?? [], {
403
+ at: now.toISOString(),
404
+ revision: ledger.revision,
405
+ rollback_revision: rollbackRevision,
406
+ type: "rollback",
407
+ }),
408
+ rollback_of_revision: rollbackRevision,
409
+ });
410
+ return true;
411
+ });
412
+ }
413
+ catch {
414
+ return false;
415
+ }
416
+ });
417
+ }
418
+ export function retireRecipeUsage(path, recipeRoot = inferRecipeRoot(path)) {
419
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
420
+ return withFileMutationLock(indexPath, () => {
421
+ try {
422
+ const key = pathKey(path, recipeRoot);
423
+ const index = readIndex(recipeRoot);
424
+ const lineageName = index.paths[key];
425
+ if (!lineageName)
426
+ return true;
427
+ delete index.paths[key];
428
+ writeJsonAtomic(indexPath, index);
429
+ const ledgerPath = getRecipeUsageLedgerPath(lineageName, recipeRoot);
430
+ if (existsSync(ledgerPath))
431
+ unlinkSync(ledgerPath);
77
432
  return true;
78
433
  }
79
434
  catch {
@@ -81,3 +436,59 @@ export function recordRecipeLaunch(path, now = new Date(), kind = "direct") {
81
436
  }
82
437
  });
83
438
  }
439
+ export function moveRecipeUsage(fromPath, toPath, recipeRoot = inferRecipeRoot(fromPath)) {
440
+ const indexPath = getRecipeUsageIndexPath(recipeRoot);
441
+ return withFileMutationLock(indexPath, () => {
442
+ try {
443
+ const fromKey = pathKey(fromPath, recipeRoot);
444
+ const toKey = pathKey(toPath, recipeRoot);
445
+ const index = readIndex(recipeRoot);
446
+ const oldName = index.paths[fromKey];
447
+ const newName = recipeName(toPath);
448
+ if (!oldName) {
449
+ return index.paths[toKey] === newName && Boolean(readLedger(newName, recipeRoot));
450
+ }
451
+ if (index.paths[toKey])
452
+ return false;
453
+ const found = readIndexedLedger(oldName, recipeRoot);
454
+ if (!found || (oldName !== newName && readLedger(newName, recipeRoot)))
455
+ return false;
456
+ const oldLedgerPath = getRecipeUsageLedgerPath(oldName, recipeRoot);
457
+ const newLedgerPath = getRecipeUsageLedgerPath(newName, recipeRoot);
458
+ return withFileMutationLock(newLedgerPath, () => {
459
+ const fromDraft = fromKey.startsWith("drafts/");
460
+ const toDraft = toKey.startsWith("drafts/");
461
+ const transition = fromDraft && !toDraft
462
+ ? "promoted"
463
+ : !fromDraft && toDraft
464
+ ? "demoted"
465
+ : "renamed";
466
+ writeJsonAtomic(newLedgerPath, {
467
+ ...found.record,
468
+ current_path: toKey,
469
+ former_names: oldName !== newName
470
+ ? appendBounded(found.record.former_names, oldName)
471
+ : found.record.former_names,
472
+ former_paths: appendBounded(found.record.former_paths, fromKey),
473
+ lineage_events: appendEvent(found.record.lineage_events ?? [], {
474
+ at: new Date().toISOString(),
475
+ from: oldName,
476
+ to: newName,
477
+ type: transition,
478
+ }),
479
+ lineage_name: newName,
480
+ });
481
+ index.paths[toKey] = newName;
482
+ delete index.paths[fromKey];
483
+ writeJsonAtomic(indexPath, index);
484
+ if (oldLedgerPath !== newLedgerPath && existsSync(oldLedgerPath)) {
485
+ unlinkSync(oldLedgerPath);
486
+ }
487
+ return true;
488
+ });
489
+ }
490
+ catch {
491
+ return false;
492
+ }
493
+ });
494
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Automatic recipe-review operator control.
3
+ * Owns: explicit retry/reset transitions over durable draft/tool admission state.
4
+ */
5
+ export type AutomaticReviewScope = "draft" | "tool";
6
+ export type AutomaticReviewControlAction = "review.reset" | "review.retry";
7
+ export interface AutomaticReviewControlOptions {
8
+ draftStatePath?: string;
9
+ scheduleDraft?(): void;
10
+ scheduleTool?(): void;
11
+ toolStatePath?: string;
12
+ }
13
+ export declare function controlAutomaticReview(action: AutomaticReviewControlAction, scope: AutomaticReviewScope, options?: AutomaticReviewControlOptions): Record<string, unknown>;
14
+ export declare function parseAutomaticReviewScope(body: unknown): AutomaticReviewScope;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Automatic recipe-review operator control.
3
+ * Owns: explicit retry/reset transitions over durable draft/tool admission state.
4
+ */
5
+ import { existsSync, readFileSync, rmSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
8
+ import * as Paths from "./paths.js";
9
+ function readState(path) {
10
+ try {
11
+ const value = JSON.parse(readFileSync(path, "utf8"));
12
+ return value && typeof value === "object" && !Array.isArray(value)
13
+ ? value
14
+ : undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ function statePath(scope, options) {
21
+ return scope === "draft"
22
+ ? options.draftStatePath ?? Paths.getDraftSleepStatePath()
23
+ : options.toolStatePath ?? Paths.getToolReviewStatePath();
24
+ }
25
+ function schedule(scope, options) {
26
+ if (scope === "draft")
27
+ options.scheduleDraft?.();
28
+ else
29
+ options.scheduleTool?.();
30
+ }
31
+ export function controlAutomaticReview(action, scope, options = {}) {
32
+ const path = statePath(scope, options);
33
+ const result = withFileMutationLock(path, () => {
34
+ const state = readState(path);
35
+ if (!state) {
36
+ return { action, changed: false, phase: "idle", reason: "review_state_unavailable", scope };
37
+ }
38
+ const phase = String(state.phase ?? "unknown");
39
+ if (action === "review.reset") {
40
+ if (!["completed", "failed", "processing_failed"].includes(phase)) {
41
+ return { action, changed: false, phase, reason: "review_state_not_terminal", scope };
42
+ }
43
+ if (scope === "tool" &&
44
+ (state.approvedPath || state.transactionEvidencePath || state.lineageJournalPath)) {
45
+ return {
46
+ action,
47
+ changed: false,
48
+ phase,
49
+ reason: "review_recovery_evidence_requires_retry",
50
+ scope,
51
+ };
52
+ }
53
+ rmSync(path, { force: true });
54
+ return { action, changed: true, phase: "idle", previous_phase: phase, scope };
55
+ }
56
+ if (!["failed", "processing_failed"].includes(phase)) {
57
+ return { action, changed: false, phase, reason: "review_state_not_retryable", scope };
58
+ }
59
+ const resumeDraftTransaction = scope === "draft" &&
60
+ phase === "processing_failed" &&
61
+ typeof state.inputPath === "string" &&
62
+ typeof state.runId === "string" &&
63
+ existsSync(join(dirname(state.inputPath), "journal.json"));
64
+ const nextPhase = resumeDraftTransaction
65
+ ? "launched"
66
+ : scope === "tool" && state.approvedPath
67
+ ? state.transactionEvidencePath
68
+ ? "lineage_pending"
69
+ : "approved"
70
+ : "captured";
71
+ const next = {
72
+ ...state,
73
+ attempts: 0,
74
+ lastError: undefined,
75
+ nextAction: undefined,
76
+ failedStage: undefined,
77
+ phase: nextPhase,
78
+ processingAttempts: 0,
79
+ runId: resumeDraftTransaction ? state.runId : undefined,
80
+ updatedAt: new Date().toISOString(),
81
+ };
82
+ writeJsonAtomic(path, next);
83
+ return {
84
+ action,
85
+ changed: true,
86
+ phase: nextPhase,
87
+ previous_phase: phase,
88
+ scope,
89
+ };
90
+ });
91
+ if (result.changed === true)
92
+ schedule(scope, options);
93
+ const nextActions = ["inspect target=recipes view=reviews"];
94
+ if (action === "review.retry" &&
95
+ scope === "tool" &&
96
+ (result.phase === "approved" || result.phase === "lineage_pending"))
97
+ nextActions.push("/reload");
98
+ return {
99
+ ...result,
100
+ next_actions: nextActions,
101
+ sent: result.changed === true,
102
+ };
103
+ }
104
+ export function parseAutomaticReviewScope(body) {
105
+ const record = body && typeof body === "object" && !Array.isArray(body)
106
+ ? body
107
+ : {};
108
+ if (record.scope === "draft" || record.scope === "tool")
109
+ return record.scope;
110
+ throw new Error("review control requires body.scope=draft or body.scope=tool.");
111
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Automatic recipe-review diagnostics.
3
+ * Zones: bounded draft/tool cycle evidence, lineage summaries, revision snapshot inventory
4
+ * Owns read-only diagnostic projection; schedulers and transactions own mutation.
5
+ */
6
+ export interface AutomaticReviewDiagnosticOptions {
7
+ draftStatePath?: string;
8
+ recipeRoot?: string;
9
+ toolStatePath?: string;
10
+ }
11
+ export declare function readAutomaticReviewDiagnostics(options?: AutomaticReviewDiagnosticOptions): Record<string, unknown>;