pi-extensible-workflows 3.0.0 → 3.1.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.
@@ -13,12 +13,18 @@ export declare function asWorkflowError(error: unknown): WorkflowError;
13
13
  export declare function fail(code: WorkflowErrorCode, message: string): never;
14
14
  export declare function parseThinking(value: unknown): ModelSpec["thinking"] | undefined;
15
15
  export declare function parseModelReference(value: string): ModelSpec;
16
+ export declare function annotateModelAliasError(error: unknown, name: string): unknown;
17
+ export declare function modelAliasErrorName(error: unknown): string | undefined;
16
18
  export declare function modelAliasName(value: string, aliases: Readonly<Record<string, string>>): string | undefined;
17
19
  export declare function validateModelAliases(value: unknown, settingsPath?: string): Readonly<Record<string, string>>;
18
20
  export declare function unknownModel(value: string, target: string | undefined, settingsPath?: string): never;
19
21
  export declare function resolveModelReference(value: string, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec;
20
22
  export declare function modelCapability(value: string | ModelSpec, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): string;
21
23
  export declare function aliasDrift(previous: Readonly<Record<string, string>>, current: Readonly<Record<string, string>>): string[];
24
+ export declare function validateResourcePattern(pattern: string): void;
25
+ export declare function resourcePatternMatches(resource: string, pattern: string): boolean;
26
+ export declare function disabledResources(patterns: readonly string[], resources: readonly string[]): string[];
27
+ export declare function unmatchedResourcePatterns(patterns: readonly string[], resources: readonly string[]): string[];
22
28
  export declare function mergeAgentResourceExclusions(...values: (AgentResourceExclusions | undefined)[]): AgentResourceExclusions;
23
29
  export declare function createLaunchSnapshot(input: Omit<import("./types.js").LaunchSnapshot, "identityVersion"> & {
24
30
  identityVersion?: number;
package/dist/src/utils.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WorkflowError } from "./types.js";
2
+ import { Minimatch } from "minimatch";
2
3
  export function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
3
4
  export { object as isObject };
4
5
  export function jsonValue(value, seen = new Set()) {
@@ -61,7 +62,21 @@ export function parseModelReference(value) {
61
62
  fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
62
63
  return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking } : {}) };
63
64
  }
64
- function aliasError(message, settingsPath) { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
65
+ const MODEL_ALIAS_ERROR_NAME = Symbol("modelAliasErrorName");
66
+ function aliasError(message, settingsPath, name) {
67
+ const error = new WorkflowError("CONFIG_ERROR", `${message} (settings: ${settingsPath})`);
68
+ if (name)
69
+ Object.defineProperty(error, MODEL_ALIAS_ERROR_NAME, { value: name, configurable: true });
70
+ throw error;
71
+ }
72
+ export function annotateModelAliasError(error, name) {
73
+ if (error instanceof WorkflowError)
74
+ Object.defineProperty(error, MODEL_ALIAS_ERROR_NAME, { value: name, configurable: true });
75
+ return error;
76
+ }
77
+ export function modelAliasErrorName(error) {
78
+ return error instanceof WorkflowError ? error[MODEL_ALIAS_ERROR_NAME] : undefined;
79
+ }
65
80
  export function modelAliasName(value, aliases) {
66
81
  const name = /^([^/:\s]+)(?::[^:\s]+)?$/.exec(value)?.[1];
67
82
  return name && Object.prototype.hasOwnProperty.call(aliases, name) ? name : undefined;
@@ -72,9 +87,9 @@ export function validateModelAliases(value, settingsPath = "workflow settings")
72
87
  const aliases = {};
73
88
  for (const [name, target] of Object.entries(value)) {
74
89
  if (!MODEL_ALIAS_NAME.test(name))
75
- aliasError(`Invalid model alias name: ${name}`, settingsPath);
90
+ aliasError(`Invalid model alias name: ${name}`, settingsPath, name);
76
91
  if (typeof target !== "string" || !target.trim())
77
- aliasError(`Invalid model alias target for ${name}`, settingsPath);
92
+ aliasError(`Invalid model alias target for ${name}`, settingsPath, name);
78
93
  aliases[name] = target;
79
94
  }
80
95
  for (const name of Object.keys(aliases)) {
@@ -82,7 +97,7 @@ export function validateModelAliases(value, settingsPath = "workflow settings")
82
97
  resolveModelReference(name, aliases);
83
98
  }
84
99
  catch (error) {
85
- aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath);
100
+ aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath, name);
86
101
  }
87
102
  }
88
103
  return Object.freeze(aliases);
@@ -123,6 +138,31 @@ export function modelCapability(value, aliases, knownModels, settingsPath) {
123
138
  export function aliasDrift(previous, current) {
124
139
  return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
125
140
  }
126
- export function mergeAgentResourceExclusions(...values) { return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] }; }
141
+ const RESOURCE_PATTERN_OPTIONS = { dot: true, nonegate: true, nocomment: true };
142
+ function resourcePatternBody(pattern) { return pattern.startsWith("!") ? pattern.slice(1) : pattern; }
143
+ function resourcePatternPath(value) { return value.replaceAll("\\", "/"); }
144
+ export function validateResourcePattern(pattern) {
145
+ const body = resourcePatternBody(pattern);
146
+ if (!body)
147
+ throw new Error(`Empty minimatch pattern ${JSON.stringify(pattern)}`);
148
+ const matcher = new Minimatch(resourcePatternPath(body), RESOURCE_PATTERN_OPTIONS);
149
+ if (matcher.makeRe() === false)
150
+ throw new Error(`Invalid minimatch pattern ${JSON.stringify(pattern)}`);
151
+ }
152
+ export function resourcePatternMatches(resource, pattern) { return new Minimatch(resourcePatternPath(resourcePatternBody(pattern)), RESOURCE_PATTERN_OPTIONS).match(resourcePatternPath(resource)); }
153
+ export function disabledResources(patterns, resources) {
154
+ const disabled = new Set();
155
+ for (const resource of resources) {
156
+ let excluded = false;
157
+ for (const pattern of patterns)
158
+ if (resourcePatternMatches(resource, pattern))
159
+ excluded = !pattern.startsWith("!");
160
+ if (excluded)
161
+ disabled.add(resource);
162
+ }
163
+ return [...disabled];
164
+ }
165
+ export function unmatchedResourcePatterns(patterns, resources) { return patterns.filter((pattern) => !resources.some((resource) => resourcePatternMatches(resource, pattern))); }
166
+ export function mergeAgentResourceExclusions(...values) { return { skills: values.flatMap((value) => value?.skills ?? []), extensions: values.flatMap((value) => value?.extensions ?? []) }; }
127
167
  export function createLaunchSnapshot(input) { return deepFreeze(structuredClone({ ...input, launchKind: input.launchKind ?? (input.functionName ? "function" : "inline"), identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION })); }
128
168
  export function loadLaunchSnapshot(input) { return deepFreeze(structuredClone(input)); }
@@ -1,15 +1,19 @@
1
- import type { AgentDefinition, AgentResourcePolicy, CheckpointInput, JsonSchema, JsonValue, LaunchSnapshot, PreflightCapabilities, PreflightResult, ShellOptions, StaticWorkflowCall, ValidatedWorkflowLaunch, WorkflowMetadata, WorkflowSettings, WorkflowValidationContext, WorkflowValidationParameters } from "./types.js";
1
+ import type { AgentDefinition, AgentResourcePolicy, CheckpointInput, JsonSchema, JsonValue, LaunchSnapshot, PreflightCapabilities, PreflightResult, ShellOptions, StaticWorkflowCall, ValidatedWorkflowLaunch, WorkflowMetadata, WorkflowRoleDirectoryRegistration, WorkflowSettings, WorkflowSettingsOverrides, WorkflowSettingsResolution, WorkflowValidationContext, WorkflowValidationParameters } from "./types.js";
2
2
  import type { WorkflowRegistryApi } from "./registry.js";
3
3
  export declare const DEFAULT_SETTINGS: Readonly<WorkflowSettings>;
4
4
  export declare function validateCheckpoint(value: unknown): CheckpointInput;
5
5
  export declare function workflowSettingsPath(agentDir?: string): string;
6
6
  export declare function workflowProjectSettingsPath(cwd: string): string;
7
7
  export declare function loadSettings(path?: string): Readonly<WorkflowSettings>;
8
+ export declare function loadSettingsOverrides(path: string): Readonly<WorkflowSettingsOverrides>;
9
+ export declare function resolveWorkflowSettings(cwd: string, projectTrusted: boolean, globalSettingsPath?: string): WorkflowSettingsResolution;
10
+ export declare function validateModelAliasAvailability(aliases: Readonly<Record<string, string>>, names: readonly string[], availableModels: ReadonlySet<string>, knownModels: ReadonlySet<string>, settingsPath?: string): void;
8
11
  export declare function resolveAgentResourcePolicy(cwd: string, projectTrusted: boolean, globalSettingsPath?: string): AgentResourcePolicy;
9
12
  export declare function saveModelAliases(path?: string, aliases?: Readonly<Record<string, string>>): void;
10
13
  export declare function parseRoleMarkdown(content: string, strict?: boolean, rolePath?: string): AgentDefinition;
11
14
  export declare function workflowRoleDirectories(agentDir?: string): readonly string[];
12
- export declare function loadAgentDefinitions(cwd: string, agentDir?: string, projectTrusted?: boolean): Readonly<Record<string, AgentDefinition>>;
15
+ export type WorkflowRoleDirectoryInput = string | WorkflowRoleDirectoryRegistration;
16
+ export declare function loadAgentDefinitions(cwd: string, agentDir?: string, projectTrusted?: boolean, extensionRoleDirectories?: readonly WorkflowRoleDirectoryInput[]): Readonly<Record<string, AgentDefinition>>;
13
17
  export declare function instrumentWorkflow(script: string): string;
14
18
  export declare function workflowPrompt(template: string, values: Readonly<Record<string, JsonValue>>): string;
15
19
  export declare function validateSchema(schema: unknown, at?: string): asserts schema is JsonSchema;
@@ -8,7 +8,8 @@ import { Script } from "node:vm";
8
8
  import { Value } from "typebox/value";
9
9
  import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
10
10
  import { WorkflowError } from "./types.js";
11
- import { deepFreeze, errorText, fail, jsonValue, mergeAgentResourceExclusions, modelAliasName, modelCapability, object, parseThinking, positiveInteger, resolveModelReference, unknownModel, validateModelAliases } from "./utils.js";
11
+ import { registeredWorkflowRoleDirectoryRegistrations } from "./registry.js";
12
+ import { annotateModelAliasError, deepFreeze, errorText, fail, jsonValue, modelAliasName, modelCapability, object, parseThinking, positiveInteger, resolveModelReference, unknownModel, validateModelAliases, validateResourcePattern } from "./utils.js";
12
13
  import { WORKFLOW_CALL_KINDS } from "./types.js";
13
14
  export const DEFAULT_SETTINGS = Object.freeze({ concurrency: 8 });
14
15
  export function validateCheckpoint(value) {
@@ -23,11 +24,27 @@ export function validateCheckpoint(value) {
23
24
  export function workflowSettingsPath(agentDir = getAgentDir()) { return join(agentDir, ROLE_DIRECTORY, "settings.json"); }
24
25
  export function workflowProjectSettingsPath(cwd) { return join(cwd, ".pi", ROLE_DIRECTORY, "settings.json"); }
25
26
  const EMPTY_AGENT_RESOURCE_EXCLUSIONS = Object.freeze({ skills: [], extensions: [] });
27
+ function resourcePatternHasMagic(value) { return /[*?\x5b\x5d{}()]/.test(value); }
26
28
  function normalizedResourcePath(value, settingsPath) {
27
29
  let expanded = value === "~" ? homedir() : value.startsWith("~/") || value.startsWith("~\\") ? join(homedir(), value.slice(2)) : value;
28
30
  if (expanded.startsWith("file://"))
29
31
  expanded = fileURLToPath(expanded);
30
32
  const resolved = resolve(dirname(settingsPath), expanded);
33
+ if (expanded === "**" || expanded.startsWith("**/") || expanded.startsWith("**\\"))
34
+ return expanded;
35
+ if (resourcePatternHasMagic(expanded)) {
36
+ const magicIndex = resolved.search(/[*?\x5b\x5d{}()]/);
37
+ const separatorIndex = Math.max(resolved.lastIndexOf("/", magicIndex), resolved.lastIndexOf("\\", magicIndex));
38
+ const rootBoundary = separatorIndex === 0 || (separatorIndex === 2 && /^[A-Za-z]:[\\/]/.test(resolved));
39
+ const prefix = rootBoundary ? resolved.slice(0, separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(0, separatorIndex) : resolved;
40
+ const suffix = rootBoundary ? resolved.slice(separatorIndex + 1) : separatorIndex >= 0 ? resolved.slice(separatorIndex) : "";
41
+ try {
42
+ return `${realpathSync(prefix)}${suffix}`;
43
+ }
44
+ catch {
45
+ return resolved;
46
+ }
47
+ }
31
48
  try {
32
49
  return realpathSync(resolved);
33
50
  }
@@ -51,35 +68,41 @@ function validateAgentResourceExclusions(value, settingsPath, errorCode = "INVAL
51
68
  continue;
52
69
  if (!Array.isArray(entries))
53
70
  fail(errorCode, `${base}.${kind} must be an array`);
54
- const seen = new Set();
55
71
  for (const [index, entry] of entries.entries()) {
56
72
  if (typeof entry !== "string" || !entry.trim())
57
73
  fail(errorCode, `${base}.${kind}[${String(index)}] must be a non-empty string`);
58
74
  let selector = entry.trim();
59
75
  if (kind === "extensions") {
76
+ const negated = selector.startsWith("!");
77
+ const body = negated ? selector.slice(1) : selector;
78
+ if (!body)
79
+ fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid minimatch pattern: Empty minimatch pattern ${JSON.stringify(selector)}`);
60
80
  try {
61
- selector = normalizedResourcePath(selector, settingsPath);
81
+ selector = `${negated ? "!" : ""}${normalizedResourcePath(body, settingsPath)}`;
62
82
  }
63
83
  catch (error) {
64
84
  fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid path: ${errorText(error)}`);
65
85
  }
66
86
  }
67
- if (!seen.has(selector)) {
68
- seen.add(selector);
69
- normalized[kind].push(selector);
87
+ try {
88
+ validateResourcePattern(selector);
70
89
  }
90
+ catch (error) {
91
+ fail(errorCode, `${base}.${kind}[${String(index)}] must be a valid minimatch pattern: ${errorText(error)}`);
92
+ }
93
+ normalized[kind].push(selector);
71
94
  }
72
95
  }
73
96
  return Object.freeze({ skills: Object.freeze(normalized.skills), extensions: Object.freeze(normalized.extensions) });
74
97
  }
75
- export function loadSettings(path = workflowSettingsPath()) {
98
+ function parseSettings(path, partial) {
76
99
  let parsed;
77
100
  try {
78
101
  parsed = JSON.parse(readFileSync(path, "utf8"));
79
102
  }
80
103
  catch (error) {
81
104
  if (error.code === "ENOENT")
82
- return DEFAULT_SETTINGS;
105
+ return partial ? Object.freeze({}) : DEFAULT_SETTINGS;
83
106
  fail("CONFIG_ERROR", `Invalid workflow settings JSON at ${path}: ${errorText(error)}`);
84
107
  }
85
108
  if (!object(parsed))
@@ -88,19 +111,51 @@ export function loadSettings(path = workflowSettingsPath()) {
88
111
  const unknown = Object.keys(parsed).find((key) => !allowed.has(key));
89
112
  if (unknown)
90
113
  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)
114
+ const concurrency = parsed.concurrency === undefined ? (partial ? undefined : DEFAULT_SETTINGS.concurrency) : parsed.concurrency;
115
+ if (concurrency !== undefined && (!positiveInteger(concurrency) || concurrency > 16))
93
116
  fail("INVALID_SETTINGS", `${path}.concurrency must be an integer from 1 to 16`);
94
117
  const modelAliases = parsed.modelAliases === undefined ? undefined : validateModelAliases(parsed.modelAliases, path);
95
118
  const disabledAgentResources = validateAgentResourceExclusions(parsed.disabledAgentResources, path);
96
- return Object.freeze({ concurrency, ...(modelAliases ? { modelAliases } : {}), ...(disabledAgentResources ? { disabledAgentResources } : {}) });
119
+ return Object.freeze({ ...(concurrency === undefined ? {} : { concurrency }), ...(modelAliases === undefined ? {} : { modelAliases }), ...(disabledAgentResources === undefined ? {} : { disabledAgentResources }) });
97
120
  }
98
- export function resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath = workflowSettingsPath()) {
121
+ export function loadSettings(path = workflowSettingsPath()) { return parseSettings(path, false); }
122
+ export function loadSettingsOverrides(path) { return parseSettings(path, true); }
123
+ export function resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath = workflowSettingsPath()) {
99
124
  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: [] };
125
+ const global = loadSettings(globalSettingsPath);
126
+ const project = projectTrusted ? loadSettingsOverrides(projectSettingsPath) : Object.freeze({});
127
+ const projectHas = (key) => Object.prototype.hasOwnProperty.call(project, key);
128
+ const sources = {
129
+ concurrency: projectHas("concurrency") ? projectSettingsPath : globalSettingsPath,
130
+ modelAliases: projectHas("modelAliases") ? projectSettingsPath : globalSettingsPath,
131
+ disabledAgentResources: projectHas("disabledAgentResources") ? projectSettingsPath : globalSettingsPath,
132
+ };
133
+ const effective = Object.freeze({
134
+ concurrency: project.concurrency ?? global.concurrency,
135
+ ...(projectHas("modelAliases") ? { modelAliases: project.modelAliases } : global.modelAliases === undefined ? {} : { modelAliases: global.modelAliases }),
136
+ ...(projectHas("disabledAgentResources") ? { disabledAgentResources: project.disabledAgentResources } : global.disabledAgentResources === undefined ? {} : { disabledAgentResources: global.disabledAgentResources }),
137
+ });
138
+ return { globalSettingsPath, projectSettingsPath, projectTrusted, global, project, effective, sources };
139
+ }
140
+ export function validateModelAliasAvailability(aliases, names, availableModels, knownModels, settingsPath) {
141
+ for (const name of names) {
142
+ try {
143
+ const target = modelCapability(name, aliases, knownModels, settingsPath);
144
+ if (!availableModels.has(target))
145
+ unknownModel(name, target, settingsPath);
146
+ }
147
+ catch (error) {
148
+ throw annotateModelAliasError(error, name);
149
+ }
150
+ }
151
+ }
152
+ export function resolveAgentResourcePolicy(cwd, projectTrusted, globalSettingsPath = workflowSettingsPath()) {
153
+ const resolved = resolveWorkflowSettings(cwd, projectTrusted, globalSettingsPath);
154
+ const empty = EMPTY_AGENT_RESOURCE_EXCLUSIONS;
155
+ const global = resolved.global.disabledAgentResources ?? empty;
156
+ const project = resolved.project.disabledAgentResources ?? empty;
157
+ const effective = resolved.effective.disabledAgentResources ?? empty;
158
+ return { globalSettingsPath: resolved.globalSettingsPath, projectSettingsPath: resolved.projectSettingsPath, projectTrusted, global, project, effective, unmatchedSkills: [], unmatchedExtensions: [] };
104
159
  }
105
160
  export function saveModelAliases(path = workflowSettingsPath(), aliases = {}) {
106
161
  const normalized = validateModelAliases(aliases, path);
@@ -175,23 +230,60 @@ export function workflowRoleDirectories(agentDir = getAgentDir()) {
175
230
  function projectRoleDirectories(root) {
176
231
  return [join(root, ROLE_DIRECTORY, "roles")];
177
232
  }
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)]; }));
233
+ function roleDirectorySources(dirs) {
234
+ const seen = new Set();
235
+ return dirs.flatMap((value) => {
236
+ const source = typeof value === "string" ? { path: value } : { path: value.path, extension: value.extension };
237
+ if (seen.has(source.path))
238
+ return [];
239
+ seen.add(source.path);
240
+ return [source];
241
+ });
242
+ }
243
+ function roleDirectoryLabel(source, extension = true) {
244
+ return source.extension ? `Extension "${source.extension.headline}" (${source.extension.version})` : extension ? "Registered workflow extension" : "Standard workflow";
245
+ }
246
+ function scanRoleFiles(dirs, extension) {
247
+ const files = [];
248
+ for (const source of roleDirectorySources(dirs)) {
249
+ let entries;
250
+ try {
251
+ entries = readdirSync(source.path, { withFileTypes: true });
252
+ }
253
+ catch (error) {
254
+ if (!extension && error.code === "ENOENT")
255
+ continue;
256
+ fail("INVALID_METADATA", `${roleDirectoryLabel(source, extension)} role directory "${source.path}" could not be scanned: ${errorText(error)}`);
257
+ }
258
+ for (const entry of entries)
259
+ if (entry.isFile() && extname(entry.name) === ".md")
260
+ files.push({ name: basename(entry.name, ".md"), path: join(source.path, entry.name), source });
183
261
  }
184
- catch (error) {
185
- if (error.code === "ENOENT")
186
- return {};
187
- throw error;
262
+ return files.sort((left, right) => left.name.localeCompare(right.name) || left.path.localeCompare(right.path));
263
+ }
264
+ function readRoleDefinitions(dirs, extension = false) {
265
+ const files = scanRoleFiles(dirs, extension);
266
+ if (extension) {
267
+ const byName = new Map();
268
+ for (const file of files)
269
+ byName.set(file.name, [...(byName.get(file.name) ?? []), file]);
270
+ for (const [name, matches] of byName)
271
+ if (matches.length > 1)
272
+ fail("INVALID_METADATA", `Duplicate extension role "${name}": ${matches.map(({ path, source }) => `${roleDirectoryLabel(source)} role directory "${source.path}" (${path})`).join("; ")}`);
188
273
  }
274
+ return Object.fromEntries(files.map(({ name, path, source }) => {
275
+ try {
276
+ return [name, parseRoleMarkdown(readFileSync(path, "utf8"), true, path)];
277
+ }
278
+ catch (error) {
279
+ if (extension)
280
+ fail("INVALID_METADATA", `${roleDirectoryLabel(source)} role directory "${source.path}" contains invalid role "${name}" at "${path}": ${errorText(error)}`);
281
+ throw error;
282
+ }
283
+ }));
189
284
  }
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"))) : {}) });
285
+ export function loadAgentDefinitions(cwd, agentDir = getAgentDir(), projectTrusted = true, extensionRoleDirectories = registeredWorkflowRoleDirectoryRegistrations()) {
286
+ return deepFreeze({ ...readRoleDefinitions(extensionRoleDirectories, true), ...readRoleDefinitions(workflowRoleDirectories(agentDir)), ...(projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(cwd, ".pi"))) : {}) });
195
287
  }
196
288
  function validateRolePolicies(definitions, roles, availableModels, rootTools, aliases = {}, knownModels = availableModels, settingsPath) {
197
289
  for (const role of roles) {
@@ -709,7 +801,7 @@ export function validateWorkflowLaunchWithRegistry(params, context, registry) {
709
801
  if (!script)
710
802
  fail("INVALID_SYNTAX", "Provide script or registered function");
711
803
  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);
804
+ const globalAgentDefinitions = loadAgentDefinitions(context.cwd, context.agentDir, false, registry && typeof registry.roleDirectoryRegistrations === "function" ? registry.roleDirectoryRegistrations() : registry && typeof registry.roleDirectories === "function" ? registry.roleDirectories() : undefined);
713
805
  const projectAgentDefinitions = context.projectTrusted ? readRoleDefinitions(projectRoleDirectories(join(context.cwd, ".pi"))) : {};
714
806
  const agentDefinitions = deepFreeze({ ...globalAgentDefinitions, ...projectAgentDefinitions });
715
807
  const aliases = context.modelAliases ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensible-workflows",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Deterministic multi-agent workflow orchestration for Pi",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
6
  "repository": {
@@ -64,6 +64,7 @@
64
64
  },
65
65
  "license": "MIT",
66
66
  "dependencies": {
67
- "acorn": "^8.17.0"
67
+ "acorn": "^8.17.0",
68
+ "minimatch": "^10.2.5"
68
69
  }
69
70
  }
@@ -4,30 +4,49 @@ description: Use when the task is complex enough to require multiple subagents o
4
4
  ---
5
5
 
6
6
  # pi-extensible-workflows
7
+
7
8
  Use `workflow` only for genuinely multi-agent orchestration; one agent uses ordinary tools or `Agent` directly. Give phases distinct responsibilities and keep result flow explicit.
8
9
 
9
10
  ## Example
10
- ```js
11
- const reportSchema = { type: "object", properties: { summary: { type: "string" }, findings: { type: "array", items: { type: "string" } } }, required: ["summary", "findings"], additionalProperties: false };
12
11
 
12
+ ```js
13
+ const reportSchema = {
14
+ type: "object",
15
+ properties: {
16
+ summary: { type: "string" },
17
+ findings: { type: "array", items: { type: "string" } },
18
+ },
19
+ required: ["summary", "findings"],
20
+ additionalProperties: false,
21
+ };
13
22
 
14
23
  const reports = await parallel("research", {
15
- first: () => agent("Research the first target.", { role: "scout", outputSchema: reportSchema }),
16
- second: () => agent("Research the second target.", { role: "scout", outputSchema: reportSchema }),
24
+ first: () =>
25
+ agent("Research the first target.", {
26
+ role: "scout",
27
+ outputSchema: reportSchema,
28
+ }),
29
+ second: () =>
30
+ agent("Research the second target.", {
31
+ role: "scout",
32
+ outputSchema: reportSchema,
33
+ }),
17
34
  });
18
35
 
19
- return agent(
20
- prompt("Review these reports:\n\n{reports}", { reports }),
21
- { role: "reviewer", outputSchema: reportSchema },
22
- );
36
+ return agent(prompt("Review these reports:\n\n{reports}", { reports }), {
37
+ role: "reviewer",
38
+ outputSchema: reportSchema,
39
+ });
23
40
  ```
24
41
 
25
42
  Inline launches require a non-empty `name`. Registered function launches must omit `name`; they use `workflow` as the run name:
43
+
26
44
  ```json
27
45
  { "workflow": "workflowName", "args": { "issue": 42 } }
28
46
  ```
47
+
29
48
  Inside the workflow, read `args.issue` (`args` is `null` when omitted). `workflow_stop` requires the exact run ID; foreground results retain their value and completed `runId`, while background launches return `runId` immediately. A terminal `parentRunId` reuses matching named `withWorktree` scopes but does not replay journal results. For an explicitly failed run, use the exact `runId` from failure diagnostics with `workflow_retry({ runId })`: diagnostics list replayable and incomplete paths, artifacts, and valid named worktrees; the tool creates a linked child, replays completed agent, shell, function, and checkpoint operations, and executes incomplete work. Retry versus per-agent `retries` and `workflow_resume` is always explicit; external side effects before failure are not guaranteed exactly once.
30
- Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
49
+ Inspect tool `workflow_catalog` result at least once before creating the first workflow for a task.
31
50
 
32
51
  Workflow JavaScript has no imports, filesystem, network, process, or timers. Delegate that work to agents. `shell(command, options)` is the trusted host RPC for deterministic gates: it inherits the workflow or active-worktree cwd, merges string `env` overrides, and returns `{ exitCode, stdout, stderr }`; nonzero exits are results, but launch failures and timeouts fail with `SHELL_FAILED`.
33
52
 
@@ -37,20 +56,24 @@ Example use of `shell`:
37
56
  // ... other code
38
57
  const testRes = await shell("yarn test", { env: { CI: "1" } });
39
58
  if (testRes.exitCode === 0) {
40
- // success path
41
- return {...}
59
+ // success path
60
+ return { ok: true };
42
61
  }
43
62
  ```
44
63
 
45
64
  Most of the times using `shell()` to perform mutations is an antipattern. Use it mainly for verifications or idempotent actions.
46
65
 
47
66
  ## `agent()` options
67
+
48
68
  ```typescript
49
69
  export interface AgentOptions {
50
- label?: string; model?: string; role?: string; tools?: string[];
51
- thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
52
- outputSchema?: JsonSchema;
53
- retries?: number;
70
+ label?: string;
71
+ model?: string;
72
+ role?: string;
73
+ tools?: string[];
74
+ thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
75
+ outputSchema?: JsonSchema;
76
+ retries?: number;
54
77
  timeoutMs?: number | null;
55
78
  [key: string]: JsonValue;
56
79
  }
@@ -61,24 +84,34 @@ Extensions may add JSON-compatible agent options such as `advisor: true`; core k
61
84
  Agent calls are unnamed. Direct calls receive hidden source call-site identity; aliases are unsupported, and calls from one source site must not race outside `parallel` or `pipeline`, whose structural keys make replay deterministic.
62
85
 
63
86
  ## Passing agent results
87
+
64
88
  Use independent `agent(prompt, options)` calls and pass each completed result explicitly to the next prompt. This keeps workflow execution deterministic and makes replay state local to each call:
89
+
65
90
  ```js
66
91
  const findings = await agent("Inspect the implementation.");
67
- const fix = await agent(prompt("Propose the smallest fix from these findings:\n\n{findings}", { findings }));
92
+ const fix = await agent(
93
+ prompt("Propose the smallest fix from these findings:\n\n{findings}", {
94
+ findings,
95
+ }),
96
+ );
68
97
  return { findings, fix };
69
98
  ```
70
99
 
71
100
  ## Worktrees
101
+
72
102
  Use `withWorktree(name, callback)` for top-level agents that collaborate in one explicitly named worktree scope:
103
+
73
104
  ```js
74
105
  const result = await withWorktree("issue", async ({ path, branch }) => {
75
106
  const report = await agent("Implement the issue");
76
107
  return { path, branch, report };
77
108
  });
78
109
  ```
110
+
79
111
  Entering the scope materializes its worktree before the callback. The callback receives a frozen reference containing only the real string `path` and `branch`; callbacks may ignore the argument, and their bare return value is preserved. Concurrent agents share mutable files, so assign non-conflicting work or coordinate explicitly.
80
112
 
81
113
  Branches may call any workflow function, not only `agent()`. Use separate named scopes when parallel branches need isolated worktrees:
114
+
82
115
  ```js
83
116
  const results = await parallel("implementation", {
84
117
  api: () => withWorktree("api", () => agent("Implement the API")),
@@ -89,6 +122,7 @@ const results = await parallel("implementation", {
89
122
  Registered extension functions receive `withWorktree` in context and can compose other registered functions with `context.invoke("reviewRepository", { focus: "security" })`. Their public inputs and outputs remain JSON; callbacks cannot cross the extension boundary.
90
123
 
91
124
  ## Rules
125
+
92
126
  - Use `log(messageString)` for brief operator status.
93
127
  - A role owns execution policy: with `role`, do not set `model`, `thinking`, or `tools`; only task options such as `outputSchema`, retries, timeout, or a `withWorktree` scope may accompany it.
94
128
  - Use `parallel()` for independent tasks with different flows and `pipeline()` when every keyed item follows the same ordered stages; do not duplicate identical chains in `parallel()`. Signatures are `parallel(operationName, tasksRecord)` and `pipeline(operationName, itemsRecord, stagesRecord)`; keys are stable task, item, and stage names.
@@ -2,13 +2,14 @@ import { realpathSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { Type } from "@earendil-works/pi-ai";
4
4
  import { Value } from "typebox/value";
5
- import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type AgentSessionEvent, type InlineExtension, type SessionStats, type ToolDefinition } from "@earendil-works/pi-coding-agent";
5
+ import { createAgentSession, DefaultPackageManager, DefaultResourceLoader, getAgentDir, ModelRuntime, SessionManager, SettingsManager, type ToolDefinition } from "@earendil-works/pi-coding-agent";
6
6
  type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
7
7
  type AgentMessage = { role: string; content?: unknown; stopReason?: string; errorMessage?: string; usage?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: { total: number } } };
8
- import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, WorkflowRunContext } from "./types.js";
9
- import { jsonObject, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference } from "./utils.js";
8
+ import type { AgentIdentity, AgentResourceExclusions, AgentResourcePolicy, AgentSetup, AgentSetupSummary, JsonSchema, JsonValue, ModelSpec, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput, WorkflowRunContext } from "./types.js";
9
+ import { jsonObject, disabledResources, mergeAgentResourceExclusions, modelAliasName, modelCapability, resolveModelReference, unmatchedResourcePatterns } from "./utils.js";
10
10
  import { WorkflowError } from "./types.js";
11
11
  import type { RunStore } from "./persistence.js";
12
+ export type { AgentSetup, AgentSetupContext, AgentSetupHook, NativeSession, RegisteredAgentSetupHook, SessionFactory, SessionInput } from "./types.js";
12
13
  export interface AgentBudgetHooks {
13
14
  beforeAttempt(): void;
14
15
  beforeTurn(): void;
@@ -66,29 +67,6 @@ export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: stri
66
67
  export interface AgentProgress { accounting: AgentAccounting; toolCalls: readonly AgentToolCallProgress[]; activity?: AgentActivity; persist: boolean }
67
68
  export interface AgentAttempt { attempt: number; sessionId: string; sessionFile: string; result?: JsonValue; error?: { code: string; message: string }; accounting: AgentAccounting; setup?: AgentSetupSummary }
68
69
  export interface AgentExecutionResult { value: JsonValue; attempts: readonly AgentAttempt[]; cwd: string }
69
- export interface AgentSetup { prompt: string; options: Record<string, JsonValue>; sessionInput: SessionInput; createSession: SessionFactory }
70
- export interface AgentSetupContext { readonly run: Readonly<WorkflowRunContext>; readonly identity: Readonly<AgentIdentity>; readonly attempt: number; readonly signal: AbortSignal }
71
- export interface AgentSetupHook { priority?: number; setup: (agent: AgentSetup, context: Readonly<AgentSetupContext>) => void | Promise<void> }
72
- export interface RegisteredAgentSetupHook { name: string; priority: number; setup: AgentSetupHook["setup"] }
73
- type NativeSessionStats = Pick<SessionStats, "tokens" | "cost">;
74
- export interface NativeSession {
75
- readonly sessionId: string;
76
- readonly sessionFile: string | undefined;
77
- readonly messages: readonly AgentMessage[];
78
- getSessionStats(): NativeSessionStats;
79
- readonly systemPrompt?: string;
80
- readonly model?: { provider: string; model?: string; id?: string };
81
- readonly agent?: { state: { tools: readonly { name: string }[] } };
82
- getLeafId?: () => string | null;
83
- getToolDefinitions?: () => unknown;
84
- subscribe?(listener: (event: AgentSessionEvent) => void): () => void;
85
- prompt(text: string): Promise<void>;
86
- steer?(text: string): Promise<void>;
87
- abort?(): Promise<void>;
88
- dispose(): void;
89
- }
90
- export interface SessionInput { cwd: string; model: ModelSpec; tools: string[]; sessionLabel: string; agentDir?: string; customTools?: ToolDefinition[]; resultTool?: ToolDefinition; systemPromptAppend?: string; extensionFactories?: InlineExtension[]; resourcePolicy?: AgentResourcePolicy; options?: Record<string, JsonValue> }
91
- export type SessionFactory = (input: SessionInput) => Promise<NativeSession>;
92
70
 
93
71
  function parseModel(value: string | undefined, fallback: ModelSpec, thinking?: ThinkingLevel, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
94
72
  if (!value) return { ...fallback, ...(thinking ? { thinking } : {}) };
@@ -129,7 +107,7 @@ function terminalProviderError(error: WorkflowError): TerminalProviderError | un
129
107
  return typeof candidate.provider === "string" && typeof candidate.model === "string" && typeof candidate.error === "string" ? { provider: candidate.provider, model: candidate.model, error: candidate.error } : undefined;
130
108
  }
131
109
 
132
- function accounting(stats: NativeSessionStats): AgentAccounting {
110
+ function accounting(stats: ReturnType<NativeSession["getSessionStats"]>): AgentAccounting {
133
111
  return { input: stats.tokens.input, output: stats.tokens.output, cacheRead: stats.tokens.cacheRead, cacheWrite: stats.tokens.cacheWrite, cost: stats.cost };
134
112
  }
135
113
  function canonicalSourcePath(path: string): string { try { return realpathSync(path); } catch { return resolve(path); } }
@@ -151,14 +129,17 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
151
129
  settingsManager.setProjectTrusted(policy.projectTrusted);
152
130
  const packageManager = new DefaultPackageManager({ cwd: input.cwd, agentDir, settingsManager });
153
131
  const resolved = await packageManager.resolve();
154
- const disabledExtensions = new Set(policy.effective.extensions);
155
- const extensionPaths = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)).filter((path) => !disabledExtensions.has(canonicalSourcePath(path))))];
132
+ const discoveredExtensions = [...new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)))];
133
+ const excludedExtensions = new Set(disabledResources(policy.effective.extensions, discoveredExtensions));
134
+ const extensionPaths = discoveredExtensions.filter((path) => !excludedExtensions.has(path));
135
+ Object.assign(policy, { excludedExtensions: [...excludedExtensions], unmatchedExtensions: unmatchedResourcePatterns(policy.effective.extensions, discoveredExtensions) });
156
136
  const skillPaths = [...new Set(resolved.skills.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => path))];
157
- const updateSkillMatches = (skills: readonly { name: string }[]) => {
158
- const names = new Set(skills.map(({ name }) => name));
159
- Object.assign(policy, { unmatchedSkills: policy.effective.skills.filter((name) => !names.has(name)) });
137
+ const updateSkillMatches = (skills: readonly { name: string }[]): Set<string> => {
138
+ const names = [...new Set(skills.map(({ name }) => name))];
139
+ const excludedSkills = disabledResources(policy.effective.skills, names);
140
+ Object.assign(policy, { excludedSkills, unmatchedSkills: unmatchedResourcePatterns(policy.effective.skills, names) });
141
+ return new Set(excludedSkills);
160
142
  };
161
- const disabledSkills = new Set(policy.effective.skills);
162
143
  resourceLoader = new DefaultResourceLoader({
163
144
  cwd: input.cwd,
164
145
  agentDir,
@@ -169,14 +150,12 @@ export async function createNativeAgentSession(input: SessionInput): Promise<Nat
169
150
  additionalSkillPaths: skillPaths,
170
151
  ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}),
171
152
  skillsOverride: (base) => {
172
- updateSkillMatches(base.skills);
153
+ const disabledSkills = updateSkillMatches(base.skills);
173
154
  return { ...base, skills: base.skills.filter(({ name }) => !disabledSkills.has(name)) };
174
155
  },
175
156
  ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}),
176
157
  });
177
158
  await resourceLoader.reload();
178
- const discoveredExtensions = new Set(resolved.extensions.filter(({ enabled, metadata }) => enabled && (policy.projectTrusted || metadata.scope !== "project")).map(({ path }) => canonicalSourcePath(path)));
179
- Object.assign(policy, { unmatchedExtensions: policy.effective.extensions.filter((path) => !discoveredExtensions.has(canonicalSourcePath(path))) });
180
159
  } else if (input.systemPromptAppend || input.extensionFactories?.length) {
181
160
  resourceLoader = new DefaultResourceLoader({ cwd: input.cwd, agentDir, ...(input.extensionFactories?.length ? { extensionFactories: input.extensionFactories } : {}), ...(input.systemPromptAppend ? { appendSystemPromptOverride: (base) => [...base, input.systemPromptAppend ?? ""] } : {}) });
182
161
  await resourceLoader.reload();
@@ -218,7 +197,7 @@ function fallbackSetupContext(root: AgentExecutionRoot, options: AgentExecutionO
218
197
  return { run, identity: Object.freeze({ ...identity, structuralPath: Object.freeze([...identity.structuralPath]) }) };
219
198
  }
220
199
  function resourcePolicySummary(policy: AgentResourcePolicy): NonNullable<AgentSetupSummary["disabledAgentResources"]> {
221
- return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
200
+ return { skills: [...policy.effective.skills], extensions: [...policy.effective.extensions], excludedSkills: [...(policy.excludedSkills ?? [])], excludedExtensions: [...(policy.excludedExtensions ?? [])], unmatchedSkills: [...policy.unmatchedSkills], unmatchedExtensions: [...policy.unmatchedExtensions] };
222
201
  }
223
202
  async function prepareAgentSetup(root: AgentExecutionRoot, createSession: SessionFactory, task: string, options: AgentExecutionOptions, resolved: { model: ModelSpec; tools: readonly string[]; systemPromptAppend: string }, cwd: string, attempt: number, signal: AbortSignal | undefined, customTools: readonly ToolDefinition[], resultTool: ToolDefinition | undefined): Promise<{ setup: AgentSetup; summary: AgentSetupSummary }> {
224
203
  const setupSignal = signal ?? root.runContext?.signal ?? new AbortController().signal;
@@ -518,6 +497,13 @@ export class FairAgentScheduler {
518
497
  this.#runs.set(runId, { limit, ...(beforeLaunch ? { beforeLaunch } : {}), logical: 0, active: 0, queue: [] });
519
498
  this.#runOrder.push(runId);
520
499
  }
500
+ updateRunLimit(runId: string, limit: number): void {
501
+ const run = this.#runs.get(runId);
502
+ if (!run) throw new WorkflowError("INTERNAL_ERROR", `Unknown scheduler run: ${runId}`);
503
+ if (!Number.isInteger(limit) || limit < 1 || limit > this.sessionLimit) throw new WorkflowError("INVALID_SETTINGS", "Invalid run concurrency");
504
+ run.limit = limit;
505
+ this.#dispatch();
506
+ }
521
507
 
522
508
  spawn(runId: string, prompt: string, options: ScheduledAgentOptions, parentId?: string): { id: string; result: Promise<ScheduledAgentResult> } {
523
509
  const run = this.#runs.get(runId);