pi-extensible-workflows 2.0.0 → 3.0.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.
@@ -0,0 +1,740 @@
1
+ import { atomicWriteFile } from "./persistence.js";
2
+ import { mkdirSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, extname, join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import * as acorn from "acorn";
7
+ import { Script } from "node:vm";
8
+ import { Value } from "typebox/value";
9
+ import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
+ import { WorkflowError } from "./types.js";
11
+ import { deepFreeze, errorText, fail, jsonValue, mergeAgentResourceExclusions, modelAliasName, modelCapability, object, parseThinking, positiveInteger, resolveModelReference, unknownModel, validateModelAliases } from "./utils.js";
12
+ import { WORKFLOW_CALL_KINDS } from "./types.js";
13
+ export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8 });
14
+ export function validateCheckpoint(value) {
15
+ if (!object(value) || Object.keys(value).some((key) => !["name", "prompt", "context"].includes(key)) || typeof value.name !== "string" || value.name.trim() === "" || typeof value.prompt !== "string" || !jsonValue(value.context))
16
+ fail("INVALID_METADATA", "checkpoint requires only name, prompt, and JSON context");
17
+ if (Buffer.byteLength(value.prompt) > 1024)
18
+ fail("INVALID_METADATA", "checkpoint prompt exceeds 1024 UTF-8 bytes");
19
+ if (Buffer.byteLength(JSON.stringify(value.context)) > 4096)
20
+ fail("INVALID_METADATA", "checkpoint context exceeds 4096 UTF-8 bytes");
21
+ return { name: value.name, prompt: value.prompt, context: value.context };
22
+ }
23
+ export function workflowSettingsPath(agentDir = getAgentDir()) { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
24
+ export function workflowProjectSettingsPath(cwd) { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
25
+ const EMPTY_AGENT_RESOURCE_EXCLUSIONS = Object.freeze({ skills: [], extensions: [] });
26
+ function normalizedResourcePath(value, settingsPath) {
27
+ let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
28
+ if (expanded.startsWith("file://"))
29
+ expanded = fileURLToPath(expanded);
30
+ const resolved = resolve(dirname(settingsPath), expanded);
31
+ try {
32
+ return realpathSync(resolved);
33
+ }
34
+ catch {
35
+ return resolved;
36
+ }
37
+ }
38
+ function validateAgentResourceExclusions(value, settingsPath, errorCode = "INVALID_SETTINGS") {
39
+ if (value === undefined)
40
+ return undefined;
41
+ const base = `${settingsPath}.disabledAgentResources`;
42
+ if (!object(value))
43
+ fail(errorCode, `${base} must be an object`);
44
+ for (const key of Object.keys(value))
45
+ if (key !== "skills" && key !== "extensions")
46
+ fail(errorCode, `${base}.${key} is not supported`);
47
+ const normalized = { skills: [], extensions: [] };
48
+ for (const kind of ["skills", "extensions"]) {
49
+ const entries = value[kind];
50
+ if (entries === undefined)
51
+ continue;
52
+ if (!Array.isArray(entries))
53
+ fail(errorCode, `${base}.${kind} must be an array`);
54
+ const seen = new Set();
55
+ for (const [index, entry] of entries.entries()) {
56
+ if (typeof entry !== "string" || !entry.trim())
57
+ fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
58
+ let selector = entry.trim();
59
+ if (kind === "extensions") {
60
+ try {
61
+ selector = normalizedResourcePath(selector, settingsPath);
62
+ }
63
+ catch (error) {
64
+ fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`);
65
+ }
66
+ }
67
+ if (!seen.has(selector)) {
68
+ seen.add(selector);
69
+ normalized[kind].push(selector);
70
+ }
71
+ }
72
+ }
73
+ return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
74
+ }
75
+ export function loadSettings(path = workflowSettingsPath()) {
76
+ let parsed;
77
+ try {
78
+ parsed = JSON.parse(readFileSync(path, "utf8"));
79
+ }
80
+ catch (error) {
81
+ if (error.code === "ENOENT")
82
+ return DEFAULT_SETTINGS;
83
+ fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
84
+ }
85
+ if (!object(parsed))
86
+ fail("INVALID_SETTINGS", `Workflow settings at ${path} must be an object`);
87
+ const allowed = new Set(["concurrency", "modelAliases", "disabledAgentResources"]);
88
+ const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
89
+ if (unknown)
90
+ fail("INVALID_SETTINGS", `Unknown workflow setting at ${path}: ${unknown}`);
91
+ const concurrency = parsed.concurrency === undefined ? DEFAULT_SETTINGS.concurrency : parsed.concurrency;
92
+ if (!positiveInteger(concurrency) || concurrency > 16)
93
+ fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
94
+ const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
95
+ const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
96
+ return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
97
+ }
98
+ export function resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath = workflowSettingsPath()) {
99
+ const projectSettingsPath = workflowProjectSettingsPath(cwd);
100
+ const global = loadSettings(globalSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS;
101
+ const project = projectTrusted ? loadSettings(projectSettingsPath).disabledAgentResources ?? EMPTY_AGENT_RESOURCE_EXCLUSIONS : EMPTY_AGENT_RESOURCE_EXCLUSIONS;
102
+ const effective = mergeAgentResourceExclusions(global, project);
103
+ return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
104
+ }
105
+ export function saveModelAliases(path = workflowSettingsPath(), aliases = {}) {
106
+ const normalized = validateModelAliases(aliases, path);
107
+ let parsed = {};
108
+ try {
109
+ loadSettings(path);
110
+ parsed = JSON.parse(readFileSync(path, "utf8"));
111
+ }
112
+ catch (error) {
113
+ if (error.code !== "ENOENT")
114
+ throw error;
115
+ }
116
+ mkdirSync(dirname(path), { recursive: true });
117
+ atomicWriteFile(path, `${JSON.stringify({ ...parsed, modelAliases: normalized }, null, 2)}\n`, true);
118
+ }
119
+ export function parseRoleMarkdown(content, strict = false, rolePath) {
120
+ if (!strict) {
121
+ if (!content.startsWith("---\n"))
122
+ return { prompt: content };
123
+ const end = content.indexOf("\n---", 4);
124
+ if (end < 0)
125
+ return { prompt: content };
126
+ const meta = {};
127
+ for (const line of content.slice(4, end).split("\n")) {
128
+ const match = /^(model|thinking|tools|description)\s*:\s*(.+)$/.exec(line.trim());
129
+ if (match?.[1] && match[2])
130
+ meta[match[1]] = match[2].trim();
131
+ }
132
+ const tools = meta.tools ? meta.tools.replace(/^\[|\]$/g, "").split(",").map((tool) => tool.trim().replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "")).filter(Boolean) : undefined;
133
+ const thinking = meta.thinking?.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
134
+ if (thinking && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking))
135
+ fail("INVALID_METADATA", `Invalid role thinking level: ${thinking}`);
136
+ const definition = { prompt: content.slice(end + 4).replace(/^\n/, "") };
137
+ if (meta.model)
138
+ definition.model = meta.model.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
139
+ if (meta.description)
140
+ definition.description = meta.description.replace(/^[']|[']$/g, "").replace(/^["]|["]$/g, "");
141
+ if (thinking)
142
+ definition.thinking = thinking;
143
+ if (tools)
144
+ definition.tools = tools;
145
+ return definition;
146
+ }
147
+ const normalized = content.replace(/\r\n?/g, "\n");
148
+ if (normalized.startsWith("---\n") && normalized.indexOf("\n---", 3) < 0)
149
+ fail("INVALID_METADATA", "Role frontmatter is missing its closing delimiter");
150
+ let parsed;
151
+ try {
152
+ parsed = parseFrontmatter(content);
153
+ }
154
+ catch (error) {
155
+ fail("INVALID_METADATA", `Invalid role frontmatter: ${errorText(error)}`);
156
+ }
157
+ if (!object(parsed.frontmatter))
158
+ fail("INVALID_METADATA", "Role frontmatter must be an object");
159
+ const { model, thinking, tools, description, disabledAgentResources } = parsed.frontmatter;
160
+ if (model !== undefined && (typeof model !== "string" || model.trim() === ""))
161
+ fail("INVALID_METADATA", "Role model must be a non-empty string");
162
+ if (thinking !== undefined && (typeof thinking !== "string" || !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(thinking)))
163
+ fail("INVALID_METADATA", `Invalid role thinking level: ${typeof thinking === "string" ? thinking : typeof thinking}`);
164
+ if (description !== undefined && (typeof description !== "string" || description.trim() === "" || description.length > 1024 || /[\r\n]/.test(description)))
165
+ fail("INVALID_METADATA", "Role description must be a non-empty single-line string of at most 1024 characters");
166
+ if (tools !== undefined && (!Array.isArray(tools) || tools.some((tool) => typeof tool !== "string" || tool.trim() === "")))
167
+ fail("INVALID_METADATA", "Role tools must be an array of non-empty strings");
168
+ const normalizedResources = validateAgentResourceExclusions(disabledAgentResources, rolePath ?? "<role>", "INVALID_METADATA");
169
+ return { prompt: parsed.body, ...(typeof description === "string" ? { description: description.trim() } : {}), ...(typeof model === "string" ? { model: model.trim() } : {}), ...(typeof thinking === "string" ? { thinking: thinking } : {}), ...(Array.isArray(tools) ? { tools: tools.map((tool) => tool.trim()) } : {}), ...(normalizedResources ? { disabledAgentResources: normalizedResources } : {}) };
170
+ }
171
+ const ROLE_DIRECTORY = "pi-extensible-workflows";
172
+ export function workflowRoleDirectories(agentDir = getAgentDir()) {
173
+ return [join(agentDir, ROLE_DIRECTORY, "roles")];
174
+ }
175
+ function projectRoleDirectories(root) {
176
+ return [join(root, ROLE_DIRECTORY, "roles")];
177
+ }
178
+ function readAgentDefinitions(dir) {
179
+ try {
180
+ return Object.fromEntries(readdirSync(dir, { withFileTypes: true })
181
+ .filter((entry) => entry.isFile() && extname(entry.name) === ".md")
182
+ .map((entry) => { const path = join(dir, entry.name); return [basename(entry.name, ".md"), parseRoleMarkdown(readFileSync(path, "utf8"), true, path)]; }));
183
+ }
184
+ catch (error) {
185
+ if (error.code === "ENOENT")
186
+ return {};
187
+ throw error;
188
+ }
189
+ }
190
+ function readRoleDefinitions(dirs) {
191
+ return Object.fromEntries(dirs.flatMap((dir) => Object.entries(readAgentDefinitions(dir))));
192
+ }
193
+ export function loadAgentDefinitions(cwd, agentDir = getAgentDir(), projectTrusted = true) {
194
+ return deepFreeze({ ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
195
+ }
196
+ function validateRolePolicies(definitions, roles, availableModels, rootTools, aliases = {}, knownModels = availableModels, settingsPath) {
197
+ for (const role of roles) {
198
+ const definition = definitions[role];
199
+ if (!definition)
200
+ continue;
201
+ if (definition.model !== undefined) {
202
+ const resolved = modelCapability(definition.model, aliases, knownModels, settingsPath);
203
+ if (!availableModels.has(resolved)) {
204
+ if (modelAliasName(definition.model, aliases))
205
+ unknownModel(definition.model, resolved, settingsPath);
206
+ fail("UNKNOWN_MODEL", `Unknown model for role ${role}: ${resolved}`);
207
+ }
208
+ }
209
+ const missingTool = (definition.tools ?? [...rootTools]).find((tool) => !rootTools.has(tool));
210
+ if (missingTool)
211
+ fail("UNKNOWN_TOOL", `Unknown tool for role ${role}: ${missingTool}`);
212
+ }
213
+ }
214
+ function validateWorkflowMetadata(value) {
215
+ if (!object(value) || typeof value.name !== "string" || value.name.trim() === "")
216
+ fail("INVALID_METADATA", "Workflow metadata requires a non-empty name");
217
+ if (value.description !== undefined && (typeof value.description !== "string" || value.description.trim() === ""))
218
+ fail("INVALID_METADATA", "Workflow description must be a non-empty string when provided");
219
+ if (Object.keys(value).some((key) => !["name", "description"].includes(key)))
220
+ fail("INVALID_METADATA", "Unknown workflow metadata");
221
+ return Object.freeze({ name: value.name.trim(), ...(typeof value.description === "string" ? { description: value.description.trim() } : {}) });
222
+ }
223
+ function workflowBody(script) {
224
+ if (typeof script !== "string" || script.trim() === "")
225
+ fail("INVALID_SYNTAX", "Workflow script must be non-empty");
226
+ try {
227
+ const program = acorn.parse(script, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true });
228
+ const first = program.body[0];
229
+ if (first?.type === "ExportNamedDeclaration" && first.declaration?.type === "VariableDeclaration") {
230
+ const declarator = first.declaration.declarations[0];
231
+ if (declarator?.id.type === "Identifier" && declarator.id.name === "meta")
232
+ return script.slice(first.end).replace(/^\s*/, "");
233
+ }
234
+ return script;
235
+ }
236
+ catch (error) {
237
+ fail("INVALID_SYNTAX", `Invalid workflow syntax: ${errorText(error)}`);
238
+ }
239
+ }
240
+ function parseWorkflow(script) {
241
+ const body = workflowBody(script);
242
+ try {
243
+ new Script(`(async()=>{${body}\n})`);
244
+ return acorn.parse(body, { ecmaVersion: "latest", sourceType: "module", allowReturnOutsideFunction: true });
245
+ }
246
+ catch (error) {
247
+ fail("INVALID_SYNTAX", `Invalid workflow syntax: ${errorText(error)}`);
248
+ }
249
+ }
250
+ function astNode(value) {
251
+ return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
252
+ }
253
+ function astChildren(node) {
254
+ const children = [];
255
+ for (const value of Object.values(node)) {
256
+ if (Array.isArray(value)) {
257
+ for (const child of value)
258
+ if (astNode(child))
259
+ children.push(child);
260
+ }
261
+ else if (astNode(value))
262
+ children.push(value);
263
+ }
264
+ return children;
265
+ }
266
+ function workflowCallKind(node) {
267
+ if (node.type !== "CallExpression" || node.callee.type !== "Identifier")
268
+ return undefined;
269
+ const kind = node.callee.name;
270
+ return WORKFLOW_CALL_KINDS.includes(kind) ? kind : undefined;
271
+ }
272
+ function workflowCalls(program) {
273
+ const calls = [];
274
+ const visit = (node) => {
275
+ if (workflowCallKind(node))
276
+ calls.push(node);
277
+ for (const child of astChildren(node))
278
+ visit(child);
279
+ };
280
+ visit(program);
281
+ return calls.sort((left, right) => left.start - right.start);
282
+ }
283
+ function workflowCallsWithStructure(program) {
284
+ const calls = [];
285
+ const visit = (node, context) => {
286
+ let current = context;
287
+ if (node.type === "Property" && current.structure.length) {
288
+ const scope = current.structure.at(-1);
289
+ const key = node.key.type === "Identifier" ? node.key.name : node.key.type === "Literal" ? String(node.key.value) : undefined;
290
+ if (scope?.key === null && key)
291
+ current = { ...current, structure: [...current.structure.slice(0, -1), { ...scope, key }] };
292
+ }
293
+ const operation = workflowCallKind(node);
294
+ if (operation) {
295
+ const call = node;
296
+ const execution = operation === "parallel" ? "parallel" : operation === "pipeline" ? "sequential" : current.execution;
297
+ calls.push({ call, execution, structure: current.structure });
298
+ for (const [index, argument] of call.arguments.entries()) {
299
+ if (argument.type === "SpreadElement")
300
+ continue;
301
+ const scopeKind = operation === "parallel" && index === 1 ? "parallel" : operation === "pipeline" && index === 2 ? "pipeline" : undefined;
302
+ visit(argument, scopeKind ? { execution, structure: [...current.structure, { kind: scopeKind, name: staticString(callArgument(call, 0)), key: null }] } : current);
303
+ }
304
+ return;
305
+ }
306
+ for (const child of astChildren(node))
307
+ visit(child, current);
308
+ };
309
+ visit(program, { execution: "sequential", structure: [] });
310
+ return calls.sort((left, right) => left.call.start - right.call.start);
311
+ }
312
+ function validateDirectPrimitiveReferences(program, name) {
313
+ const visit = (node, parent) => {
314
+ if (node.type === "Identifier" && node.name === name) {
315
+ const directCall = parent?.type === "CallExpression" && parent.callee === node;
316
+ const propertyKey = parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
317
+ if (!directCall && !propertyKey)
318
+ fail("INVALID_METADATA", `${name} calls must use a direct ${name}(...) call; aliases and indirect calls are unsupported`);
319
+ }
320
+ for (const child of astChildren(node))
321
+ visit(child, node);
322
+ };
323
+ visit(program);
324
+ }
325
+ function validateRemovedWorkflowPrimitives(program, code) {
326
+ const visit = (node) => {
327
+ if (node.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === "conversation")
328
+ fail(code, "conversation() was removed; pass prior agent results explicitly");
329
+ for (const child of astChildren(node))
330
+ visit(child);
331
+ };
332
+ visit(program);
333
+ }
334
+ function hasIdentifier(node, name) {
335
+ if (node.type === "Identifier" && node.name === name)
336
+ return true;
337
+ return astChildren(node).some((child) => hasIdentifier(child, name));
338
+ }
339
+ const INTERNAL_AGENT_NAME = "__pi_extensible_workflows_agent";
340
+ const INTERNAL_WORKTREE_NAME = "__pi_extensible_workflows_withWorktree";
341
+ const INTERNAL_SHELL_NAME = "__pi_extensible_workflows_shell";
342
+ function callHasTrailingComma(source, call) {
343
+ let previous;
344
+ let current;
345
+ for (const token of acorn.tokenizer(source.slice(call.start, call.end), { ecmaVersion: "latest", sourceType: "module" })) {
346
+ previous = current;
347
+ current = token;
348
+ }
349
+ return current?.type.label === ")" && previous?.type.label === ",";
350
+ }
351
+ export function instrumentWorkflow(script) {
352
+ const body = workflowBody(script);
353
+ if (!body.trim())
354
+ return body;
355
+ const program = parseWorkflow(body);
356
+ if (hasIdentifier(program, INTERNAL_AGENT_NAME))
357
+ fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
358
+ if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
359
+ fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
360
+ if (hasIdentifier(program, INTERNAL_SHELL_NAME))
361
+ fail("INVALID_METADATA", `${INTERNAL_SHELL_NAME} is reserved for workflow shell instrumentation`);
362
+ validateRemovedWorkflowPrimitives(program, "INVALID_METADATA");
363
+ const calls = workflowCalls(program).filter((call) => ["agent", "withWorktree", "shell"].includes(call.callee.name));
364
+ const edits = calls.flatMap((call) => {
365
+ const replacement = { start: call.callee.start, end: call.callee.end, text: call.callee.name === "agent" ? INTERNAL_AGENT_NAME : call.callee.name === "withWorktree" ? INTERNAL_WORKTREE_NAME : INTERNAL_SHELL_NAME };
366
+ if (call.callee.name === "withWorktree")
367
+ return [replacement];
368
+ const callSite = `${String(call.start)}:${String(call.end)}`;
369
+ const hiddenArgument = call.arguments.length === 0 || callHasTrailingComma(body, call) ? "" : ", ";
370
+ return [replacement, { start: call.end - 1, end: call.end - 1, text: `${hiddenArgument}${JSON.stringify(callSite)}` }];
371
+ }).sort((left, right) => right.start - left.start);
372
+ let instrumented = body;
373
+ for (const edit of edits)
374
+ instrumented = instrumented.slice(0, edit.start) + edit.text + instrumented.slice(edit.end);
375
+ return instrumented;
376
+ }
377
+ function literalString(node) {
378
+ return node?.type === "Literal" && typeof node.value === "string" ? node.value : undefined;
379
+ }
380
+ function propertyNode(node, name) {
381
+ if (node?.type !== "ObjectExpression")
382
+ return undefined;
383
+ for (let index = node.properties.length - 1; index >= 0; index -= 1) {
384
+ const property = node.properties[index];
385
+ if (!property || property.type === "SpreadElement" || property.computed)
386
+ return undefined;
387
+ const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
388
+ if (key === name)
389
+ return property.value;
390
+ }
391
+ return undefined;
392
+ }
393
+ function stableName(node) {
394
+ if (!node)
395
+ return false;
396
+ if (node.type !== "ObjectExpression") {
397
+ if (["Literal", "ArrayExpression", "ArrowFunctionExpression", "FunctionExpression", "ClassExpression", "TemplateLiteral", "UnaryExpression", "UpdateExpression", "BinaryExpression"].includes(node.type))
398
+ return false;
399
+ return undefined;
400
+ }
401
+ let result = false;
402
+ for (const property of node.properties) {
403
+ if (property.type === "SpreadElement" || property.computed) {
404
+ result = undefined;
405
+ continue;
406
+ }
407
+ const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
408
+ if (key !== "name")
409
+ continue;
410
+ const value = literalString(property.value);
411
+ result = value === undefined ? property.value.type === "Literal" ? false : undefined : value.trim() !== "";
412
+ }
413
+ return result;
414
+ }
415
+ export function workflowPrompt(template, values) {
416
+ if (typeof template !== "string")
417
+ fail("INVALID_METADATA", "prompt() template must be a string");
418
+ if (!object(values) || Array.isArray(values) || !jsonValue(values))
419
+ fail("INVALID_METADATA", "prompt() values must be a plain JSON-compatible object");
420
+ const placeholders = [...template.matchAll(/{{|}}|{([A-Za-z_$][\w$]*)}/g)].flatMap((match) => match[1] === undefined ? [] : [match[1]]);
421
+ const used = new Set(placeholders);
422
+ const keys = Object.keys(values);
423
+ const missing = placeholders.find((key) => !Object.prototype.hasOwnProperty.call(values, key));
424
+ if (missing)
425
+ fail("INVALID_METADATA", `Missing prompt value "${missing}"`);
426
+ const unused = keys.find((key) => !used.has(key));
427
+ if (unused !== undefined)
428
+ fail("INVALID_METADATA", `Unused prompt value "${unused}"`);
429
+ return template.replace(/{{|}}|{([A-Za-z_$][\w$]*)}/g, (match, key) => match === "{{" ? "{" : match === "}}" ? "}" : typeof values[key] === "string" ? values[key] : JSON.stringify(values[key], null, 2));
430
+ }
431
+ export function validateSchema(schema, at = "schema") {
432
+ if (!object(schema) || Object.getPrototypeOf(schema) !== Object.prototype || !jsonValue(schema))
433
+ fail("INVALID_SCHEMA", `${at} must be a plain JSON-compatible Schema object`);
434
+ if (typeof schema.type !== "string" && !Array.isArray(schema.type) && schema.$ref === undefined && schema.anyOf === undefined && schema.oneOf === undefined && schema.allOf === undefined && schema.const === undefined && schema.enum === undefined)
435
+ fail("INVALID_SCHEMA", `${at} has no JSON Schema shape`);
436
+ if (schema.required !== undefined && (!Array.isArray(schema.required) || schema.required.some((key) => typeof key !== "string")))
437
+ fail("INVALID_SCHEMA", `${at}.required must be an array of strings`);
438
+ if (schema.properties !== undefined && !object(schema.properties))
439
+ fail("INVALID_SCHEMA", `${at}.properties must be an object`);
440
+ }
441
+ const AGENT_OPTION_KEYS = new Set(["label", "model", "thinking", "tools", "role", "outputSchema", "retries", "timeoutMs"]);
442
+ function validateAgentOption(key, value, aliases, knownModels, settingsPath) {
443
+ switch (key) {
444
+ case "label":
445
+ if (typeof value !== "string" || !value.trim())
446
+ fail("INVALID_METADATA", "agent label must be a non-empty string");
447
+ break;
448
+ case "model":
449
+ if (typeof value !== "string" || !value.trim())
450
+ fail("INVALID_METADATA", "agent model must be a non-empty string");
451
+ if (aliases !== undefined)
452
+ resolveModelReference(value, aliases, knownModels, settingsPath);
453
+ break;
454
+ case "thinking":
455
+ if (typeof value !== "string" || !parseThinking(value))
456
+ fail("INVALID_METADATA", "agent thinking must be off, minimal, low, medium, high, xhigh, or max");
457
+ break;
458
+ case "tools":
459
+ if (!Array.isArray(value) || value.some((tool) => typeof tool !== "string"))
460
+ fail("INVALID_METADATA", "agent tools must be an array of strings");
461
+ break;
462
+ case "role":
463
+ if (typeof value !== "string" || !value.trim())
464
+ fail("INVALID_METADATA", "agent role must be a non-empty string");
465
+ break;
466
+ case "outputSchema":
467
+ validateSchema(value, "agent outputSchema");
468
+ break;
469
+ case "retries":
470
+ if (!Number.isInteger(value) || value < 0)
471
+ fail("INVALID_METADATA", "agent retries must be a non-negative integer");
472
+ break;
473
+ case "timeoutMs":
474
+ if (value !== null && !positiveInteger(value))
475
+ fail("INVALID_METADATA", "agent timeoutMs must be null or a positive integer");
476
+ break;
477
+ }
478
+ }
479
+ export function validateAgentOptions(value) {
480
+ if (!object(value) || !jsonValue(value))
481
+ fail("INVALID_METADATA", "agent options must be a JSON object");
482
+ for (const [key, option] of Object.entries(value))
483
+ if (AGENT_OPTION_KEYS.has(key))
484
+ validateAgentOption(key, option);
485
+ if (typeof value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(value, key)))
486
+ fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
487
+ return value;
488
+ }
489
+ const SHELL_OPTION_KEYS = new Set(["timeoutMs", "env"]);
490
+ export function validateShellOptions(value) {
491
+ if (value === undefined)
492
+ return {};
493
+ if (!object(value) || !jsonValue(value) || Object.keys(value).some((key) => !SHELL_OPTION_KEYS.has(key)))
494
+ fail("INVALID_METADATA", "shell options must contain only timeoutMs and env");
495
+ if (value.timeoutMs !== undefined && !positiveInteger(value.timeoutMs))
496
+ fail("INVALID_METADATA", "shell timeoutMs must be a positive integer");
497
+ if (value.env !== undefined && (!object(value.env) || Object.values(value.env).some((entry) => typeof entry !== "string")))
498
+ fail("INVALID_METADATA", "shell env must be an object of strings");
499
+ return { ...(value.timeoutMs === undefined ? {} : { timeoutMs: value.timeoutMs }), ...(value.env === undefined ? {} : { env: value.env }) };
500
+ }
501
+ export function validateShellCommand(value) {
502
+ if (typeof value !== "string")
503
+ fail("INVALID_METADATA", "shell command must be a string");
504
+ return value;
505
+ }
506
+ function staticValue(node) {
507
+ if (!node)
508
+ return { known: false };
509
+ if (node.type === "Literal")
510
+ return { known: true, value: node.value };
511
+ if (node.type === "UnaryExpression" && (node.operator === "-" || node.operator === "+")) {
512
+ const argument = staticValue(node.argument);
513
+ return argument.known && typeof argument.value === "number" ? { known: true, value: node.operator === "-" ? -argument.value : argument.value } : { known: false };
514
+ }
515
+ if (node.type === "ArrayExpression") {
516
+ const values = [];
517
+ for (const element of node.elements) {
518
+ if (!element || element.type === "SpreadElement")
519
+ return { known: false };
520
+ const value = staticValue(element);
521
+ if (!value.known)
522
+ return { known: false };
523
+ values.push(value.value);
524
+ }
525
+ return { known: true, value: values };
526
+ }
527
+ if (node.type === "ObjectExpression") {
528
+ const value = {};
529
+ for (const property of node.properties) {
530
+ if (property.type === "SpreadElement" || property.computed)
531
+ return { known: false };
532
+ const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
533
+ const child = staticValue(property.value);
534
+ if (!key || !child.known)
535
+ return { known: false };
536
+ value[key] = child.value;
537
+ }
538
+ return { known: true, value };
539
+ }
540
+ return { known: false };
541
+ }
542
+ function callArgument(call, index) {
543
+ const argument = call.arguments[index];
544
+ return argument?.type === "SpreadElement" ? undefined : argument;
545
+ }
546
+ function staticString(node) {
547
+ const value = staticValue(node);
548
+ return value.known && typeof value.value === "string" ? value.value : null;
549
+ }
550
+ export function inspectWorkflowScript(script) {
551
+ return workflowCallsWithStructure(parseWorkflow(script)).map(({ call, execution, structure }) => {
552
+ const kind = call.callee.name;
553
+ const first = callArgument(call, 0);
554
+ const options = callArgument(call, 1);
555
+ const placement = { execution, structure };
556
+ if (kind === "agent") {
557
+ const retries = staticValue(propertyNode(options, "retries"));
558
+ const outputSchema = staticValue(propertyNode(options, "outputSchema"));
559
+ const optionKeys = options?.type === "ObjectExpression" ? options.properties.flatMap((property) => {
560
+ if (property.type === "SpreadElement" || property.computed)
561
+ return [];
562
+ const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
563
+ return key ? [key] : [];
564
+ }) : [];
565
+ const knownOptions = Object.fromEntries(optionKeys.flatMap((key) => { const value = staticValue(propertyNode(options, key)); return value.known && jsonValue(value.value) ? [[key, value.value]] : []; }));
566
+ const base = { ...placement, kind, start: call.start, end: call.end, name: null, prompt: staticString(first), model: staticString(propertyNode(options, "model")), label: staticString(propertyNode(options, "label")), role: staticString(propertyNode(options, "role")) };
567
+ return { ...base, ...(retries.known && typeof retries.value === "number" ? { retries: retries.value } : {}), ...(outputSchema.known && object(outputSchema.value) ? { outputSchema: outputSchema.value } : {}), ...(optionKeys.length ? { options: knownOptions, optionKeys } : {}) };
568
+ }
569
+ if (kind === "checkpoint")
570
+ return { ...placement, kind, start: call.start, end: call.end, name: staticString(propertyNode(first, "name")), prompt: staticString(propertyNode(first, "prompt")), model: null, role: null };
571
+ if (kind === "shell")
572
+ return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
573
+ return { ...placement, kind, start: call.start, end: call.end, name: staticString(first), prompt: null, model: null, role: null };
574
+ });
575
+ }
576
+ function validateStaticAgentOptions(node, aliases = {}, knownModels, settingsPath) {
577
+ if (node?.type !== "ObjectExpression")
578
+ return;
579
+ const options = staticValue(node);
580
+ if (options.known && object(options.value) && typeof options.value.role === "string" && ["model", "thinking", "tools"].some((key) => Object.prototype.hasOwnProperty.call(options.value, key)))
581
+ fail("INVALID_METADATA", "Role agents must not specify model, thinking, or tools");
582
+ for (const key of AGENT_OPTION_KEYS) {
583
+ const value = staticValue(propertyNode(node, key));
584
+ if (value.known)
585
+ validateAgentOption(key, value.value, aliases, knownModels, settingsPath);
586
+ }
587
+ }
588
+ function validateStaticShellOptions(call) {
589
+ if (call.arguments.some((argument) => argument.type === "SpreadElement"))
590
+ return;
591
+ if (call.arguments.length !== 1 && call.arguments.length !== 2)
592
+ fail("INVALID_METADATA", "shell requires a command string and optional options");
593
+ const command = staticValue(callArgument(call, 0));
594
+ if (command.known)
595
+ validateShellCommand(command.value);
596
+ const options = staticValue(callArgument(call, 1));
597
+ if (options.known)
598
+ validateShellOptions(options.value);
599
+ }
600
+ function validateStaticWithWorktree(call, compatibility) {
601
+ if (call.arguments.some((argument) => argument.type === "SpreadElement"))
602
+ return;
603
+ if (call.arguments.length !== 2)
604
+ fail(compatibility ? "RESUME_INCOMPATIBLE" : "INVALID_METADATA", "withWorktree requires a name and callback");
605
+ const callback = call.arguments[1];
606
+ if (staticValue(callback).known)
607
+ fail("INVALID_METADATA", "withWorktree callback must be a function");
608
+ const name = staticValue(callArgument(call, 0));
609
+ if (name.known && (typeof name.value !== "string" || !name.value.trim()))
610
+ fail("INVALID_METADATA", "withWorktree name must be a non-empty string");
611
+ }
612
+ function hasDynamicAgentRole(node) {
613
+ if (!node)
614
+ return false;
615
+ if (node.type !== "ObjectExpression")
616
+ return true;
617
+ for (let index = node.properties.length - 1; index >= 0; index -= 1) {
618
+ const property = node.properties[index];
619
+ if (!property || property.type === "SpreadElement" || property.computed)
620
+ return true;
621
+ const key = property.key.type === "Identifier" ? property.key.name : property.key.type === "Literal" ? String(property.key.value) : undefined;
622
+ if (key === "role")
623
+ return literalString(property.value) === undefined;
624
+ }
625
+ return false;
626
+ }
627
+ export function preflight(script, capabilities, schemas = [], metadata = { name: "workflow" }, compatibility = false) {
628
+ const checkedMetadata = validateWorkflowMetadata(metadata);
629
+ const program = parseWorkflow(script);
630
+ if (hasIdentifier(program, INTERNAL_AGENT_NAME))
631
+ fail("INVALID_METADATA", `${INTERNAL_AGENT_NAME} is reserved for workflow agent instrumentation`);
632
+ if (hasIdentifier(program, INTERNAL_WORKTREE_NAME))
633
+ fail("INVALID_METADATA", `${INTERNAL_WORKTREE_NAME} is reserved for workflow withWorktree instrumentation`);
634
+ if (hasIdentifier(program, INTERNAL_SHELL_NAME))
635
+ fail("INVALID_METADATA", `${INTERNAL_SHELL_NAME} is reserved for workflow shell instrumentation`);
636
+ validateDirectPrimitiveReferences(program, "withWorktree");
637
+ validateRemovedWorkflowPrimitives(program, compatibility ? "RESUME_INCOMPATIBLE" : "INVALID_METADATA");
638
+ validateDirectPrimitiveReferences(program, "shell");
639
+ for (const [index, schema] of schemas.entries())
640
+ validateSchema(schema, `schema[${String(index)}]`);
641
+ const calls = workflowCalls(program);
642
+ const phases = calls.filter((call) => call.callee.name === "phase").map((call) => literalString(call.arguments[0])).filter((phase) => phase !== undefined);
643
+ for (const call of calls) {
644
+ const operation = call.callee.name;
645
+ if (operation === "agent")
646
+ validateStaticAgentOptions(call.arguments[1], capabilities.modelAliases ?? {}, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath);
647
+ if (operation === "withWorktree")
648
+ validateStaticWithWorktree(call, compatibility);
649
+ if (operation === "shell")
650
+ validateStaticShellOptions(call);
651
+ if ((operation === "parallel" || operation === "pipeline") && call.arguments.some((argument) => argument.type === "SpreadElement"))
652
+ continue;
653
+ if (operation === "checkpoint" && stableName(call.arguments[0]) === false)
654
+ fail("INVALID_METADATA", `${operation} requires a stable explicit name`);
655
+ if (operation === "parallel" && (call.arguments.length !== 2 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression"))
656
+ fail("INVALID_METADATA", "parallel requires an operation name string and tasks record");
657
+ if (operation === "pipeline" && (call.arguments.length !== 3 || !literalString(call.arguments[0])?.trim() || call.arguments[1]?.type !== "ObjectExpression" || call.arguments[2]?.type !== "ObjectExpression"))
658
+ fail("INVALID_METADATA", "pipeline requires an operation name string, items record, and stages record");
659
+ }
660
+ const agentCalls = calls.filter((call) => call.callee.name === "agent");
661
+ const dynamicAgentRoles = agentCalls.some((call) => hasDynamicAgentRole(call.arguments[1]));
662
+ const staticSchemas = agentCalls.flatMap((call) => { const value = staticValue(propertyNode(call.arguments[1], "outputSchema")); return value.known ? [value.value] : []; });
663
+ for (const [index, schema] of staticSchemas.entries())
664
+ validateSchema(schema, `agent outputSchema[${String(index)}]`);
665
+ const checkedSchemas = [...schemas, ...staticSchemas];
666
+ const modelRefs = agentCalls.flatMap((call) => { const requested = literalString(propertyNode(call.arguments[1], "model")); return requested === undefined ? [] : [{ requested, resolved: modelCapability(requested, capabilities.modelAliases, capabilities.knownModels ?? capabilities.models, capabilities.settingsPath) }]; });
667
+ const models = modelRefs.map(({ resolved }) => resolved);
668
+ const tools = agentCalls.flatMap((call) => {
669
+ const value = propertyNode(call.arguments[1], "tools");
670
+ return value?.type === "ArrayExpression" ? value.elements.flatMap((element) => { const tool = element && element.type !== "SpreadElement" ? literalString(element) : undefined; return tool === undefined ? [] : [tool]; }) : [];
671
+ });
672
+ const agentTypes = agentCalls.flatMap((call) => { const value = literalString(propertyNode(call.arguments[1], "role")); return value === undefined ? [] : [value]; });
673
+ const missingModel = capabilities.skipModelAvailability ? undefined : modelRefs.find(({ resolved }) => !capabilities.models.has(resolved));
674
+ if (missingModel) {
675
+ if (modelAliasName(missingModel.requested, capabilities.modelAliases ?? {}))
676
+ unknownModel(missingModel.requested, missingModel.resolved, capabilities.settingsPath);
677
+ fail("UNKNOWN_MODEL", `Unknown model: ${missingModel.resolved}`);
678
+ }
679
+ const missingTool = tools.find((tool) => !capabilities.tools.has(tool));
680
+ if (missingTool)
681
+ fail("UNKNOWN_TOOL", `Unknown tool: ${missingTool}`);
682
+ const missingType = agentTypes.find((type) => !capabilities.agentTypes.has(type));
683
+ if (missingType)
684
+ fail("UNKNOWN_AGENT_TYPE", `Unknown agent type: ${missingType}`);
685
+ return Object.freeze({ metadata: deepFreeze(checkedMetadata), referenced: deepFreeze({ phases, models, tools, agentTypes }), schemas: deepFreeze(checkedSchemas), dynamicAgentRoles });
686
+ }
687
+ function functionLaunchScript(name) { return `return await ${name}(args);`; }
688
+ export function validateWorkflowLaunch(params, context, registry) {
689
+ return validateWorkflowLaunchWithRegistry(params, context, registry);
690
+ }
691
+ export function validateWorkflowLaunchWithRegistry(params, context, registry) {
692
+ if (Object.prototype.hasOwnProperty.call(params, "maxAgentLaunches"))
693
+ fail("INVALID_METADATA", "maxAgentLaunches has been removed; use budget.agentLaunches");
694
+ if (params.script !== undefined && params.workflow !== undefined)
695
+ fail("INVALID_METADATA", "Provide either script or workflow, not both");
696
+ const functionName = typeof params.workflow === "string" ? params.workflow : undefined;
697
+ if (functionName !== undefined && params.name !== undefined)
698
+ fail("INVALID_METADATA", "Registered function launches do not accept name; workflow is the run name");
699
+ const workflowName = functionName ?? (typeof params.name === "string" ? params.name.trim() : "");
700
+ if (functionName === undefined && !workflowName)
701
+ fail("INVALID_METADATA", "Inline workflow launches require a non-empty name");
702
+ const fn = functionName === undefined ? undefined : registry?.function(functionName);
703
+ if (functionName !== undefined && !registry)
704
+ fail("MISSING_WORKFLOW", `Registered function is unavailable: ${functionName}`);
705
+ const args = params.args === undefined ? null : params.args;
706
+ if (functionName !== undefined && fn && (!object(args) || !jsonValue(args) || !Value.Check(fn.input, args)))
707
+ fail("RESULT_INVALID", `Invalid input for ${functionName}`);
708
+ const script = functionName !== undefined && fn ? functionLaunchScript(functionName) : typeof params.script === "string" && params.script.trim() ? params.script : "";
709
+ if (!script)
710
+ fail("INVALID_SYNTAX", "Provide script or registered function");
711
+ const metadata = validateWorkflowMetadata({ name: workflowName, ...(typeof params.description === "string" ? { description: params.description } : fn?.description ? { description: fn.description } : {}) });
712
+ const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false);
713
+ const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
714
+ const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
715
+ const aliases = context.modelAliases ?? {};
716
+ const knownModels = context.knownModels ?? context.availableModels;
717
+ const checked = preflight(script, { models: context.availableModels, tools: context.rootTools, agentTypes: new Set(Object.keys(agentDefinitions)), modelAliases: aliases, knownModels, ...(context.settingsPath ? { settingsPath: context.settingsPath } : {}) }, [], metadata);
718
+ const roleNames = checked.dynamicAgentRoles ? Object.keys(agentDefinitions) : checked.referenced.agentTypes;
719
+ validateRolePolicies(agentDefinitions, roleNames, context.availableModels, context.rootTools, aliases, knownModels, context.settingsPath);
720
+ return { script, checked, agentDefinitions, projectAgentDefinitions, roleNames, ...(functionName ? { functionName } : {}) };
721
+ }
722
+ export function launchScriptForSnapshot(snapshot, registry) {
723
+ if (snapshot.launchKind === "function") {
724
+ if (!snapshot.functionName)
725
+ fail("RESUME_INCOMPATIBLE", "Persisted registered function launch is missing its function name");
726
+ try {
727
+ registry.function(snapshot.functionName);
728
+ }
729
+ catch (error) {
730
+ if (error instanceof WorkflowError && error.code === "MISSING_WORKFLOW")
731
+ throw new WorkflowError("RESUME_INCOMPATIBLE", `Persisted registered function is unavailable: ${snapshot.functionName}`);
732
+ throw error;
733
+ }
734
+ return functionLaunchScript(snapshot.functionName);
735
+ }
736
+ if (snapshot.launchKind === "inline")
737
+ return snapshot.script;
738
+ fail("RESUME_INCOMPATIBLE", "This persisted run uses the removed registered-workflow format; launch it again as a registered function or inline script");
739
+ }
740
+ export { createLaunchSnapshot, loadLaunchSnapshot } from "./utils.js";