@llblab/pi-actors 0.12.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 (86) hide show
  1. package/AGENTS.md +72 -0
  2. package/BACKLOG.md +38 -0
  3. package/CHANGELOG.md +179 -0
  4. package/README.md +338 -0
  5. package/docs/README.md +21 -0
  6. package/docs/actor-messages.md +149 -0
  7. package/docs/async-runs.md +335 -0
  8. package/docs/command-templates.md +424 -0
  9. package/docs/component-recipes.md +148 -0
  10. package/docs/recipe-library.md +176 -0
  11. package/docs/task-first-recipes.md +233 -0
  12. package/docs/template-recipes.md +285 -0
  13. package/docs/tool-registry.md +142 -0
  14. package/index.ts +198 -0
  15. package/lib/actor-messages.ts +120 -0
  16. package/lib/async-runs.ts +688 -0
  17. package/lib/command-templates.ts +795 -0
  18. package/lib/config.ts +266 -0
  19. package/lib/execution.ts +720 -0
  20. package/lib/file-state.ts +24 -0
  21. package/lib/identity.ts +29 -0
  22. package/lib/observability.ts +525 -0
  23. package/lib/output.ts +123 -0
  24. package/lib/paths.ts +35 -0
  25. package/lib/prompts.ts +75 -0
  26. package/lib/recipe-references.ts +586 -0
  27. package/lib/registry.ts +302 -0
  28. package/lib/runtime.ts +101 -0
  29. package/lib/schema.ts +402 -0
  30. package/lib/temp.ts +44 -0
  31. package/lib/tools.ts +651 -0
  32. package/package.json +52 -0
  33. package/recipes/music-player.json +25 -0
  34. package/recipes/pipeline-architect-coordinator.json +88 -0
  35. package/recipes/pipeline-artifact-report.json +52 -0
  36. package/recipes/pipeline-artifact-write.json +66 -0
  37. package/recipes/pipeline-async-run-ops.json +67 -0
  38. package/recipes/pipeline-checkpoint-continuation.json +57 -0
  39. package/recipes/pipeline-development-tasking.json +73 -0
  40. package/recipes/pipeline-docs-maintenance.json +72 -0
  41. package/recipes/pipeline-media-library.json +51 -0
  42. package/recipes/pipeline-quorum-review.json +72 -0
  43. package/recipes/pipeline-release-readiness.json +83 -0
  44. package/recipes/pipeline-repo-health.json +81 -0
  45. package/recipes/pipeline-research-synthesis.json +87 -0
  46. package/recipes/pipeline-review-readiness.json +49 -0
  47. package/recipes/subagent-artifact.json +26 -0
  48. package/recipes/subagent-checkpoint.json +27 -0
  49. package/recipes/subagent-conflict-report.json +25 -0
  50. package/recipes/subagent-contradiction-map.json +26 -0
  51. package/recipes/subagent-critic.json +28 -0
  52. package/recipes/subagent-evidence-map.json +26 -0
  53. package/recipes/subagent-followup.json +27 -0
  54. package/recipes/subagent-judge.json +26 -0
  55. package/recipes/subagent-merge.json +26 -0
  56. package/recipes/subagent-message.json +29 -0
  57. package/recipes/subagent-normalize.json +24 -0
  58. package/recipes/subagent-plan.json +26 -0
  59. package/recipes/subagent-prompt.json +22 -0
  60. package/recipes/subagent-quorum.json +41 -0
  61. package/recipes/subagent-review-coordinator.json +107 -0
  62. package/recipes/subagent-review.json +30 -0
  63. package/recipes/subagent-task-card.json +28 -0
  64. package/recipes/subagent-tools.json +17 -0
  65. package/recipes/subagent-verify.json +27 -0
  66. package/recipes/subagents-prompts.json +32 -0
  67. package/recipes/utility-actor-message.json +24 -0
  68. package/recipes/utility-artifact-manifest.json +17 -0
  69. package/recipes/utility-artifact-write.json +17 -0
  70. package/recipes/utility-changelog-head.json +12 -0
  71. package/recipes/utility-changelog-section.json +14 -0
  72. package/recipes/utility-git-log.json +12 -0
  73. package/recipes/utility-git-status.json +10 -0
  74. package/recipes/utility-jsonl-tail.json +11 -0
  75. package/recipes/utility-markdown-index.json +15 -0
  76. package/recipes/utility-package-summary.json +12 -0
  77. package/recipes/utility-playlist-build.json +18 -0
  78. package/recipes/utility-playlist-scan.json +12 -0
  79. package/recipes/utility-run-state-files.json +14 -0
  80. package/recipes/utility-run-summary.json +12 -0
  81. package/recipes/utility-validate-recipe.json +14 -0
  82. package/recipes/utility-validation-wrapper.json +14 -0
  83. package/scripts/async-runner.mjs +170 -0
  84. package/scripts/music-player.mjs +637 -0
  85. package/scripts/recipe-utils.mjs +273 -0
  86. package/scripts/validate-recipe.mjs +89 -0
package/lib/paths.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Registry path helpers
3
+ * Zones: paths, registry config, temp directory
4
+ * Owns agent directory, tools config, recipe root, and actor run state root resolution
5
+ */
6
+
7
+ import { homedir } from "node:os";
8
+ import { join, resolve } from "node:path";
9
+
10
+ export function getAgentDir(
11
+ env: Record<string, string | undefined> = process.env,
12
+ ): string {
13
+ return env.PI_CODING_AGENT_DIR
14
+ ? resolve(env.PI_CODING_AGENT_DIR)
15
+ : join(homedir(), ".pi", "agent");
16
+ }
17
+
18
+ export function getConfigPath(agentDir = getAgentDir()): string {
19
+ return join(agentDir, "tools.json");
20
+ }
21
+
22
+ export function getExtensionTmpDir(
23
+ agentDir = getAgentDir(),
24
+ extensionName = "pi-actors",
25
+ ): string {
26
+ return join(agentDir, "tmp", extensionName);
27
+ }
28
+
29
+ export function getRunStateRoot(agentDir = getAgentDir()): string {
30
+ return join(getExtensionTmpDir(agentDir), "runs");
31
+ }
32
+
33
+ export function getRecipeRoot(agentDir = getAgentDir()): string {
34
+ return join(agentDir, "recipes");
35
+ }
package/lib/prompts.ts ADDED
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Prompt and schema copy helpers
3
+ * Zones: prompts, onboarding, tool schema copy
4
+ * Owns LLM-facing descriptions, prompt snippets, guidelines, and parameter descriptions
5
+ */
6
+
7
+ export const REGISTER_TOOL_DESCRIPTION =
8
+ "Register a persistent custom tool from a command template, template recipe path, or co-located template recipe. " +
9
+ "Definitions are stored in tools.json across reloads. " +
10
+ "Use update=true to overwrite an existing tool, template=null/empty to delete.";
11
+
12
+ export const REGISTER_TOOL_PROMPT_SNIPPET =
13
+ "Register persistent command templates as agent-callable tools";
14
+
15
+ export const REGISTER_TOOL_GUIDELINES = [
16
+ "Use register_tool to wrap trusted local commands, scripts, programs, libraries, or template recipes as persistent pi tools.",
17
+ "After register_tool succeeds, the new tool is immediately callable and remains available after reload.",
18
+ 'Set template=null or template="" in register_tool to delete a persisted tool.',
19
+ "Set update=true in register_tool to overwrite an existing tool registration.",
20
+ ];
21
+
22
+ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
23
+ - Local-first cybernetic tool memory: agents persist trusted local capabilities instead of repeating shell recipes.
24
+ - Task = user work; template = execution graph; recipe = saved JSON; run = execution instance.
25
+ - Command templates stay sync: string leaf, array sequence, object flags, parallel: true fanout.
26
+ - Template flags: args/defaults, parallel, when, timeout, delay, retry, failure, recover, repeat, output; placeholders support {value??fallback} and {flag?yes:no}.
27
+ - Recipes live in ~/.pi/agent/recipes/*.json and wrap templates with metadata/defaults/imports/artifacts.
28
+ - Recipe imports are local variables: imports.alias -> {"name":"alias"} nodes and {alias.defaults.key} refs.
29
+ - Imported recipes are definitions, not nested async runs; parent async:true creates one run.
30
+ - async:true = detached lifecycle; spawn creates run actors from recipes/templates.
31
+ - Actor run state lives under ~/.pi/agent/tmp/pi-actors/runs.
32
+ - Use spawn/message/inspect for actor-level start/send/observe; runtime action internals are absorbed into the actor API.
33
+ - Run lifecycle = state files, logs, actor messages, mailbox send, cancel/kill, compact status; do not busy-poll runs, rely on message/follow-up notifications and use message for explicit run-local commands.
34
+ - Tool template may be a command template, recipe path/name, or co-located recipe.
35
+ - register_tool makes compact persistent buttons; args may be typed or derived from placeholders.
36
+ - For single calls or short pipelines, use foreground templates/tools.
37
+ - For subagents, swarms, background music, or long fanout, prefer async recipes/runs.
38
+ - Long async fanout = parent async recipe wrapping template(parallel: true) and imports; packaged fanout recipes bubble branch completion follow-ups by default.
39
+ - If asked to explore pi-actors, read README.md, docs/README.md, docs/template-recipes.md, docs/async-runs.md, and recipes/.
40
+ - Ambient triangles show active async commands/subagents for the launching coordinator.
41
+ - After async run finish, inspect status/tail/events before final artifacts.`;
42
+
43
+ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
44
+ name: "Tool name in snake_case (e.g., 'transcribe')",
45
+ description:
46
+ "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
47
+ async:
48
+ "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
49
+ state_dir:
50
+ "Optional async run state directory for a co-located template recipe.",
51
+ template:
52
+ "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.",
53
+ templateArray:
54
+ "Sequential command-template composition array. Leaves may be strings or objects with template/defaults/timeout/retry/failure/recover.",
55
+ templateNull: "Delete the tool when template is null.",
56
+ args: "Optional comma-separated placeholder declarations. Usually omit because args are derived from template placeholders. Interactive shorthand defaults are accepted and normalized. Example: file,lang,model=openai-codex/gpt-5.5",
57
+ update: "Set to true to overwrite an existing tool registration.",
58
+ values:
59
+ "Optional default runtime placeholder values for a co-located template recipe.",
60
+ } as const;
61
+
62
+ export function formatRegisteredToolPromptSnippet(template: unknown): string {
63
+ const rendered =
64
+ typeof template === "string" ? template : JSON.stringify(template);
65
+ return `Execute command template: ${rendered}`;
66
+ }
67
+
68
+ export function formatRecipeToolPromptSnippet(
69
+ recipe: string,
70
+ asyncRecipe: boolean,
71
+ ): string {
72
+ return asyncRecipe
73
+ ? `Start async template recipe: ${recipe}`
74
+ : `Execute template recipe: ${recipe}`;
75
+ }
@@ -0,0 +1,586 @@
1
+ /**
2
+ * Template recipe reference helpers
3
+ * Zones: registry config, async runs, path resolution
4
+ * Owns detection, loading, and recipe-layer expansion for template recipe files
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, resolve } from "node:path";
10
+
11
+ import type {
12
+ CommandTemplateConfig,
13
+ CommandTemplateValue,
14
+ } from "./command-templates.ts";
15
+ import * as CommandTemplates from "./command-templates.ts";
16
+ import * as Paths from "./paths.ts";
17
+
18
+ export interface TemplateRecipeImportBinding {
19
+ from?: string;
20
+ defaults?: Record<string, unknown>;
21
+ values?: Record<string, unknown>;
22
+ }
23
+
24
+ export type TemplateRecipeImport = string | TemplateRecipeImportBinding;
25
+
26
+ export interface TemplateRecipeMailbox {
27
+ accepts?: string[];
28
+ emits?: string[];
29
+ }
30
+
31
+ export interface TemplateRecipeDefinition {
32
+ name?: string;
33
+ imports?: Record<string, TemplateRecipeImport>;
34
+ template: CommandTemplateValue;
35
+ args?: string[];
36
+ defaults?: Record<string, unknown>;
37
+ parallel?: boolean;
38
+ label?: string;
39
+ when?: boolean | string;
40
+ timeout?: number | string;
41
+ delay?: number | string;
42
+ output?: string;
43
+ artifacts?: Record<string, string>;
44
+ mailbox?: TemplateRecipeMailbox;
45
+ retry?: number | string;
46
+ failure?: CommandTemplates.CommandTemplateFailureScope;
47
+ recover?: CommandTemplateValue;
48
+ repeat?: number;
49
+ values?: Record<string, unknown>;
50
+ }
51
+
52
+ export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
53
+ async?: boolean;
54
+ state_dir?: string;
55
+ }
56
+
57
+ interface ImportedRecipe {
58
+ alias: string;
59
+ file: string;
60
+ name: string;
61
+ config: TemplateRecipeDefinition;
62
+ defaults: Record<string, unknown>;
63
+ values: Record<string, unknown>;
64
+ }
65
+
66
+ function hasWhitespace(value: string): boolean {
67
+ return /\s/.test(value);
68
+ }
69
+
70
+ export function resolveRecipePath(
71
+ value: string,
72
+ recipeRoot = Paths.getRecipeRoot(),
73
+ ): string {
74
+ const trimmed = value.trim();
75
+ if (trimmed.startsWith("~/")) return resolve(homedir(), trimmed.slice(2));
76
+ if (trimmed.includes("/")) return resolve(trimmed);
77
+ return resolve(
78
+ recipeRoot,
79
+ trimmed.endsWith(".json") ? trimmed : `${trimmed}.json`,
80
+ );
81
+ }
82
+
83
+ export function getRecipePath(
84
+ value: unknown,
85
+ recipeRoot = Paths.getRecipeRoot(),
86
+ ): string | undefined {
87
+ if (typeof value !== "string") return undefined;
88
+ const trimmed = value.trim();
89
+ if (!trimmed || hasWhitespace(trimmed)) return undefined;
90
+ if (trimmed.endsWith(".json")) return resolveRecipePath(trimmed, recipeRoot);
91
+ const path = resolveRecipePath(trimmed, recipeRoot);
92
+ if (!existsSync(path)) return undefined;
93
+ try {
94
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
95
+ string,
96
+ unknown
97
+ >;
98
+ return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
99
+ ? path
100
+ : undefined;
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ }
105
+
106
+ function isImportNode(value: unknown): boolean {
107
+ if (!isRecord(value) || Object.hasOwn(value, "template")) return false;
108
+ return typeof value.name === "string";
109
+ }
110
+
111
+ function isValidRecipeTemplateNode(value: unknown): boolean {
112
+ if (isImportNode(value)) return true;
113
+ if (isRecord(value)) {
114
+ if (isImportNode(value.template)) return true;
115
+ if (Array.isArray(value.template))
116
+ return isValidRecipeTemplateArray(value.template);
117
+ }
118
+ return (
119
+ CommandTemplates.expandCommandTemplateConfigs(
120
+ value as CommandTemplateConfig,
121
+ ).length > 0
122
+ );
123
+ }
124
+
125
+ function isValidRecipeTemplateArray(value: unknown[]): boolean {
126
+ return (
127
+ value.length > 0 && value.every((item) => isValidRecipeTemplateNode(item))
128
+ );
129
+ }
130
+
131
+ function normalizeRecipeTemplate(
132
+ value: unknown,
133
+ ): CommandTemplateValue | undefined {
134
+ if (typeof value === "string") return value.trim() || undefined;
135
+ if (Array.isArray(value)) {
136
+ const template = value as CommandTemplateConfig[];
137
+ return isValidRecipeTemplateArray(template) ? template : undefined;
138
+ }
139
+ if (isImportNode(value))
140
+ return value as CommandTemplates.CommandTemplateObjectConfig;
141
+ if (value && typeof value === "object") {
142
+ const template = value as CommandTemplates.CommandTemplateObjectConfig;
143
+ if (
144
+ Array.isArray(template.template) &&
145
+ isValidRecipeTemplateArray(template.template)
146
+ )
147
+ return template;
148
+ if (isImportNode(template.template)) return template;
149
+ return CommandTemplates.expandCommandTemplateConfigs(template).length > 0
150
+ ? template
151
+ : undefined;
152
+ }
153
+ return undefined;
154
+ }
155
+
156
+ function getRecipeCommandTemplate(
157
+ raw: Record<string, unknown>,
158
+ ): CommandTemplateValue | undefined {
159
+ const template = raw.template;
160
+ const envelope: Record<string, unknown> = {};
161
+ for (const key of [
162
+ "args",
163
+ "defaults",
164
+ "parallel",
165
+ "label",
166
+ "when",
167
+ "timeout",
168
+ "delay",
169
+ "output",
170
+ "retry",
171
+ "failure",
172
+ "recover",
173
+ "repeat",
174
+ ] as const) {
175
+ if (raw[key] !== undefined) envelope[key] = raw[key];
176
+ }
177
+ if (Object.keys(envelope).length === 0)
178
+ return normalizeRecipeTemplate(template);
179
+ if (template && typeof template === "object" && !Array.isArray(template)) {
180
+ return normalizeRecipeTemplate({
181
+ ...envelope,
182
+ ...(template as Record<string, unknown>),
183
+ });
184
+ }
185
+ return normalizeRecipeTemplate({ ...envelope, template });
186
+ }
187
+
188
+ function readRawRecipeConfig(
189
+ path: string,
190
+ ): Record<string, unknown> | undefined {
191
+ if (!existsSync(path)) return undefined;
192
+ try {
193
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Record<
194
+ string,
195
+ unknown
196
+ >;
197
+ return raw && typeof raw === "object" && !Object.hasOwn(raw, "tool")
198
+ ? raw
199
+ : undefined;
200
+ } catch {
201
+ return undefined;
202
+ }
203
+ }
204
+
205
+ function readRecipeConfig(value: unknown): TemplateRecipeConfig | undefined {
206
+ const path = getRecipePath(value);
207
+ return path ? readResolvedRecipeConfig(path) : undefined;
208
+ }
209
+
210
+ function isRecord(value: unknown): value is Record<string, unknown> {
211
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
212
+ }
213
+
214
+ function getRecipeImports(
215
+ raw: Record<string, unknown>,
216
+ ): Record<string, TemplateRecipeImport> {
217
+ if (!isRecord(raw.imports)) return {};
218
+ const result: Record<string, TemplateRecipeImport> = {};
219
+ for (const [alias, value] of Object.entries(raw.imports)) {
220
+ if (!/^[A-Za-z0-9_.-]+$/.test(alias))
221
+ throw new Error(`Invalid recipe import alias: ${alias}`);
222
+ if (typeof value === "string") {
223
+ result[alias] = value;
224
+ continue;
225
+ }
226
+ if (!isRecord(value))
227
+ throw new Error(`Recipe import must be a string or object: ${alias}`);
228
+ const from = typeof value.from === "string" ? value.from.trim() : "";
229
+ if (!from) throw new Error(`Recipe import must define from: ${alias}`);
230
+ result[alias] = {
231
+ from,
232
+ ...(isRecord(value.defaults) ? { defaults: value.defaults } : {}),
233
+ ...(isRecord(value.values) ? { values: value.values } : {}),
234
+ };
235
+ }
236
+ return result;
237
+ }
238
+
239
+ function getImportFrom(value: TemplateRecipeImport): string {
240
+ return typeof value === "string" ? value : (value.from ?? "");
241
+ }
242
+
243
+ function getPathValue(source: unknown, path: string | undefined): unknown {
244
+ if (!path) return source;
245
+ let current = source;
246
+ for (const key of path.split(".")) {
247
+ if (!key) continue;
248
+ if (!isRecord(current) || !Object.hasOwn(current, key)) return undefined;
249
+ current = current[key];
250
+ }
251
+ return current;
252
+ }
253
+
254
+ function resolveImportRef(
255
+ ref: string,
256
+ imports: Record<string, ImportedRecipe>,
257
+ allowMissing = false,
258
+ ): { matched: boolean; value: unknown } {
259
+ for (const [alias, imported] of Object.entries(imports)) {
260
+ const prefix = `${alias}.`;
261
+ if (!ref.startsWith(prefix)) continue;
262
+ const rest = ref.slice(prefix.length);
263
+ const match = /^(name|file|defaults|values)(?:\.(.+))?$/.exec(rest);
264
+ if (!match) return { matched: false, value: undefined };
265
+ const section = match[1];
266
+ if (section === "name") return { matched: true, value: imported.name };
267
+ if (section === "file") return { matched: true, value: imported.file };
268
+ const value = section === "defaults" ? imported.defaults : imported.values;
269
+ const resolved = getPathValue(value, match[2]);
270
+ if (resolved === undefined && !allowMissing)
271
+ throw new Error(`Unknown recipe import reference: ${ref}`);
272
+ return { matched: true, value: resolved };
273
+ }
274
+ return { matched: false, value: undefined };
275
+ }
276
+
277
+ function isFalsyImportValue(value: unknown): boolean {
278
+ return (
279
+ value === undefined ||
280
+ value === null ||
281
+ value === false ||
282
+ value === 0 ||
283
+ value === ""
284
+ );
285
+ }
286
+
287
+ function parseImportLiteral(
288
+ value: string,
289
+ imports: Record<string, ImportedRecipe>,
290
+ ): unknown {
291
+ const trimmed = value.trim();
292
+ const quoted = trimmed.match(/^(["'])(.*)\1$/);
293
+ if (quoted) return quoted[2];
294
+ const resolved = resolveImportRef(trimmed, imports, true);
295
+ return resolved.matched ? resolved.value : trimmed;
296
+ }
297
+
298
+ function evaluateImportExpression(
299
+ content: string,
300
+ imports: Record<string, ImportedRecipe>,
301
+ ): { matched: boolean; value: unknown } {
302
+ const trimmed = content.trim();
303
+ const ternary = trimmed.match(/^(.+?)\?([^:]*):(.*)$/);
304
+ if (ternary) {
305
+ const condition = resolveImportRef(ternary[1].trim(), imports, true);
306
+ if (!condition.matched) return { matched: false, value: undefined };
307
+ return {
308
+ matched: true,
309
+ value: parseImportLiteral(
310
+ isFalsyImportValue(condition.value) ? ternary[3] : ternary[2],
311
+ imports,
312
+ ),
313
+ };
314
+ }
315
+ const fallback = trimmed.match(/^([^=]+)=(.*)$/);
316
+ if (fallback) {
317
+ const resolved = resolveImportRef(fallback[1].trim(), imports, true);
318
+ if (!resolved.matched) return { matched: false, value: undefined };
319
+ return {
320
+ matched: true,
321
+ value:
322
+ resolved.value === undefined || resolved.value === null
323
+ ? parseImportLiteral(fallback[2], imports)
324
+ : resolved.value,
325
+ };
326
+ }
327
+ return resolveImportRef(trimmed, imports);
328
+ }
329
+
330
+ function substituteImportRefs(
331
+ value: unknown,
332
+ imports: Record<string, ImportedRecipe>,
333
+ ): unknown {
334
+ if (typeof value === "string") {
335
+ const exact = /^\{([^{}]+)\}$/.exec(value);
336
+ if (exact) {
337
+ const resolved = evaluateImportExpression(exact[1], imports);
338
+ if (resolved.matched) return resolved.value;
339
+ }
340
+ return value.replaceAll(/\{([^{}]+)\}/g, (token, ref) => {
341
+ const resolved = evaluateImportExpression(String(ref), imports);
342
+ return resolved.matched ? String(resolved.value ?? "") : token;
343
+ });
344
+ }
345
+ if (Array.isArray(value))
346
+ return value.map((item) => substituteImportRefs(item, imports));
347
+ if (isRecord(value)) {
348
+ const result: Record<string, unknown> = {};
349
+ for (const [key, child] of Object.entries(value))
350
+ result[key] = substituteImportRefs(child, imports);
351
+ return result;
352
+ }
353
+ return value;
354
+ }
355
+
356
+ function mergeDefaults(
357
+ ...items: Array<Record<string, unknown> | undefined>
358
+ ): Record<string, unknown> | undefined {
359
+ const merged = Object.assign({}, ...items.filter(Boolean));
360
+ return Object.keys(merged).length > 0 ? merged : undefined;
361
+ }
362
+
363
+ function applyDefaultsToTemplate(
364
+ template: CommandTemplateValue,
365
+ defaults: Record<string, unknown> | undefined,
366
+ overrides: Record<string, unknown>,
367
+ ): CommandTemplateValue {
368
+ const cleanOverrides = { ...overrides };
369
+ delete cleanOverrides.name;
370
+ delete cleanOverrides.values;
371
+ if (typeof template === "object" && !Array.isArray(template)) {
372
+ return {
373
+ ...template,
374
+ ...cleanOverrides,
375
+ ...(mergeDefaults(
376
+ template.defaults,
377
+ defaults,
378
+ isRecord(cleanOverrides.defaults) ? cleanOverrides.defaults : undefined,
379
+ )
380
+ ? {
381
+ defaults: mergeDefaults(
382
+ template.defaults,
383
+ defaults,
384
+ isRecord(cleanOverrides.defaults)
385
+ ? cleanOverrides.defaults
386
+ : undefined,
387
+ ),
388
+ }
389
+ : {}),
390
+ } as CommandTemplates.CommandTemplateObjectConfig;
391
+ }
392
+ return {
393
+ ...cleanOverrides,
394
+ ...(defaults ? { defaults } : {}),
395
+ template,
396
+ } as CommandTemplates.CommandTemplateObjectConfig;
397
+ }
398
+
399
+ function expandImportNodes(
400
+ value: CommandTemplateValue,
401
+ imports: Record<string, ImportedRecipe>,
402
+ ): CommandTemplateValue {
403
+ if (typeof value === "string") return value;
404
+ if (Array.isArray(value)) {
405
+ return value.map(
406
+ (item) => expandImportNodes(item, imports) as CommandTemplateConfig,
407
+ );
408
+ }
409
+ const record = value as Record<string, unknown>;
410
+ const importAlias =
411
+ !Object.hasOwn(record, "template") && typeof record.name === "string"
412
+ ? record.name
413
+ : undefined;
414
+ if (importAlias) {
415
+ const imported = imports[importAlias];
416
+ if (!imported) throw new Error(`Unknown recipe import: ${importAlias}`);
417
+ const nodeDefaults = isRecord(record.defaults)
418
+ ? record.defaults
419
+ : undefined;
420
+ const nodeValues = isRecord(record.values) ? record.values : undefined;
421
+ const defaults = mergeDefaults(
422
+ imported.defaults,
423
+ imported.values,
424
+ nodeDefaults,
425
+ nodeValues,
426
+ );
427
+ return applyDefaultsToTemplate(imported.config.template, defaults, record);
428
+ }
429
+ if (Array.isArray(record.template)) {
430
+ return {
431
+ ...record,
432
+ template: record.template.map(
433
+ (item) =>
434
+ expandImportNodes(
435
+ item as CommandTemplateValue,
436
+ imports,
437
+ ) as CommandTemplateConfig,
438
+ ),
439
+ } as CommandTemplates.CommandTemplateObjectConfig;
440
+ }
441
+ if (record.template && typeof record.template === "object") {
442
+ return {
443
+ ...record,
444
+ template: expandImportNodes(
445
+ record.template as CommandTemplateValue,
446
+ imports,
447
+ ),
448
+ } as CommandTemplates.CommandTemplateObjectConfig;
449
+ }
450
+ return value;
451
+ }
452
+
453
+ export function readResolvedRecipeConfig(
454
+ file: string,
455
+ stack: string[] = [],
456
+ ): TemplateRecipeConfig | undefined {
457
+ const path = resolveRecipePath(
458
+ file,
459
+ stack.length > 0 ? dirname(stack.at(-1)!) : Paths.getRecipeRoot(),
460
+ );
461
+ if (stack.includes(path)) {
462
+ throw new Error(`Cyclic recipe import: ${[...stack, path].join(" -> ")}`);
463
+ }
464
+ const raw = readRawRecipeConfig(path);
465
+ if (!raw || !Object.hasOwn(raw, "template")) return undefined;
466
+ const imports: Record<string, ImportedRecipe> = {};
467
+ for (const [alias, binding] of Object.entries(getRecipeImports(raw))) {
468
+ const importPath = resolveRecipePath(getImportFrom(binding), dirname(path));
469
+ const config = readResolvedRecipeConfig(importPath, [...stack, path]);
470
+ if (!config) throw new Error(`Recipe import not found: ${alias}`);
471
+ const bindingDefaults =
472
+ typeof binding === "string" ? undefined : binding.defaults;
473
+ const bindingValues =
474
+ typeof binding === "string" ? undefined : binding.values;
475
+ imports[alias] = {
476
+ alias,
477
+ file: importPath,
478
+ name: config.name ?? alias,
479
+ config,
480
+ defaults: { ...(config.defaults ?? {}), ...(bindingDefaults ?? {}) },
481
+ values: { ...(bindingValues ?? {}) },
482
+ };
483
+ }
484
+ const substituted = substituteImportRefs(raw, imports) as Record<
485
+ string,
486
+ unknown
487
+ >;
488
+ const template = getRecipeCommandTemplate(substituted);
489
+ if (!template) return undefined;
490
+ const expandedTemplate = expandImportNodes(template, imports);
491
+ return {
492
+ ...(typeof substituted.name === "string" ? { name: substituted.name } : {}),
493
+ ...(substituted.async === true
494
+ ? { async: true }
495
+ : substituted.async === false
496
+ ? { async: false }
497
+ : {}),
498
+ ...(typeof substituted.state_dir === "string"
499
+ ? { state_dir: substituted.state_dir }
500
+ : {}),
501
+ ...(Object.keys(imports).length > 0
502
+ ? { imports: getRecipeImports(raw) }
503
+ : {}),
504
+ template: expandedTemplate,
505
+ ...(Array.isArray(substituted.args)
506
+ ? { args: substituted.args as string[] }
507
+ : {}),
508
+ ...(isRecord(substituted.defaults)
509
+ ? { defaults: substituted.defaults }
510
+ : {}),
511
+ ...(typeof substituted.parallel === "boolean"
512
+ ? { parallel: substituted.parallel }
513
+ : {}),
514
+ ...(typeof substituted.label === "string"
515
+ ? { label: substituted.label }
516
+ : {}),
517
+ ...(typeof substituted.when === "string" || typeof substituted.when === "boolean"
518
+ ? { when: substituted.when }
519
+ : {}),
520
+ ...(typeof substituted.timeout === "number" || typeof substituted.timeout === "string"
521
+ ? { timeout: substituted.timeout }
522
+ : {}),
523
+ ...(typeof substituted.delay === "number" || typeof substituted.delay === "string"
524
+ ? { delay: substituted.delay }
525
+ : {}),
526
+ ...(typeof substituted.output === "string"
527
+ ? { output: substituted.output }
528
+ : {}),
529
+ ...(isRecord(substituted.artifacts)
530
+ ? {
531
+ artifacts: Object.fromEntries(
532
+ Object.entries(substituted.artifacts)
533
+ .filter((entry): entry is [string, string] => typeof entry[1] === "string"),
534
+ ),
535
+ }
536
+ : {}),
537
+ ...(isRecord(substituted.mailbox)
538
+ ? {
539
+ mailbox: {
540
+ ...(Array.isArray(substituted.mailbox.accepts)
541
+ ? { accepts: substituted.mailbox.accepts.filter((value): value is string => typeof value === "string") }
542
+ : {}),
543
+ ...(Array.isArray(substituted.mailbox.emits)
544
+ ? { emits: substituted.mailbox.emits.filter((value): value is string => typeof value === "string") }
545
+ : {}),
546
+ },
547
+ }
548
+ : {}),
549
+ ...(typeof substituted.retry === "number" || typeof substituted.retry === "string"
550
+ ? { retry: substituted.retry }
551
+ : {}),
552
+ ...(substituted.failure === "continue" ||
553
+ substituted.failure === "branch" ||
554
+ substituted.failure === "root"
555
+ ? { failure: substituted.failure }
556
+ : {}),
557
+ ...(substituted.recover !== undefined
558
+ ? { recover: substituted.recover as CommandTemplateValue }
559
+ : {}),
560
+ ...(typeof substituted.repeat === "number"
561
+ ? { repeat: substituted.repeat }
562
+ : {}),
563
+ ...(isRecord(substituted.values) ? { values: substituted.values } : {}),
564
+ };
565
+ }
566
+
567
+ export function getRecipeTemplate(
568
+ value: unknown,
569
+ ): CommandTemplateValue | undefined {
570
+ return readRecipeConfig(value)?.template;
571
+ }
572
+
573
+ export function isRecipeReference(value: unknown): boolean {
574
+ return getRecipePath(value) !== undefined;
575
+ }
576
+
577
+ export function isAsyncRecipeReference(value: unknown): boolean {
578
+ return readRecipeConfig(value)?.async === true;
579
+ }
580
+
581
+ export function isRecipeTool(
582
+ template: unknown,
583
+ recipe: TemplateRecipeConfig | undefined,
584
+ ): boolean {
585
+ return recipe !== undefined || isRecipeReference(template);
586
+ }