pi-extensible-workflows 0.3.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 (47) hide show
  1. package/README.md +72 -0
  2. package/dist/src/agent-execution.d.ts +213 -0
  3. package/dist/src/agent-execution.js +537 -0
  4. package/dist/src/ambient-workflow-evals.d.ts +112 -0
  5. package/dist/src/ambient-workflow-evals.js +375 -0
  6. package/dist/src/cli.d.ts +6 -0
  7. package/dist/src/cli.js +26 -0
  8. package/dist/src/doctor.d.ts +59 -0
  9. package/dist/src/doctor.js +269 -0
  10. package/dist/src/eval-capture-extension.d.ts +5 -0
  11. package/dist/src/eval-capture-extension.js +48 -0
  12. package/dist/src/index.d.ts +362 -0
  13. package/dist/src/index.js +2839 -0
  14. package/dist/src/persistence.d.ts +90 -0
  15. package/dist/src/persistence.js +530 -0
  16. package/dist/src/session-inspector.d.ts +59 -0
  17. package/dist/src/session-inspector.js +396 -0
  18. package/dist/src/workflow-evals-child.d.ts +1 -0
  19. package/dist/src/workflow-evals-child.js +13 -0
  20. package/dist/src/workflow-evals.d.ts +290 -0
  21. package/dist/src/workflow-evals.js +1221 -0
  22. package/evals/cases/custom-model-read.yaml +15 -0
  23. package/evals/cases/direct-answer.yaml +6 -0
  24. package/evals/cases/mixed-parallel-pipeline.yaml +18 -0
  25. package/evals/cases/output-schema.yaml +22 -0
  26. package/evals/cases/parallel.yaml +15 -0
  27. package/evals/cases/pipeline.yaml +10 -0
  28. package/evals/cases/ready-for-agent-parallel-merge.yaml +32 -0
  29. package/evals/cases/required-role.yaml +14 -0
  30. package/evals/cases/role-model-mixed.yaml +18 -0
  31. package/evals/cases/two-agents.yaml +10 -0
  32. package/package.json +65 -0
  33. package/skills/pi-extensible-workflows/SKILL.md +109 -0
  34. package/src/agent-execution.ts +529 -0
  35. package/src/ambient-workflow-evals.ts +452 -0
  36. package/src/cli.ts +23 -0
  37. package/src/doctor.ts +285 -0
  38. package/src/eval-capture-extension.ts +54 -0
  39. package/src/index.ts +2519 -0
  40. package/src/persistence.ts +470 -0
  41. package/src/session-inspector.ts +370 -0
  42. package/src/workflow-evals-child.ts +15 -0
  43. package/src/workflow-evals.ts +876 -0
  44. package/test/fixtures/ready-for-agent-tasks.md +9 -0
  45. package/test/fixtures/workflow-eval-roles/developer.md +18 -0
  46. package/test/fixtures/workflow-eval-roles/reviewer.md +18 -0
  47. package/test/fixtures/workflow-eval-roles/scout.md +17 -0
@@ -0,0 +1,269 @@
1
+ import { readFileSync, readdirSync, realpathSync } from "node:fs";
2
+ import { basename, dirname, extname, join, resolve } from "node:path";
3
+ import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
4
+ import { ModelRuntime, createAgentSessionFromServices, createAgentSessionServices, getAgentDir, hasTrustRequiringProjectResources, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
5
+ import { DEFAULT_SETTINGS, loadSettings, parseModelReference, parseRoleMarkdown, preflight, registeredWorkflowDefinitions, workflowRoleDirectories, workflowSettingsPath, WorkflowError, } from "./index.js";
6
+ const THINKING_HINT = "Use off, minimal, low, medium, high, xhigh, or max.";
7
+ function canonical(path) {
8
+ const absolute = resolve(path);
9
+ try {
10
+ return realpathSync(absolute);
11
+ }
12
+ catch {
13
+ return absolute;
14
+ }
15
+ }
16
+ async function readCredentials(agentDir) {
17
+ const credentials = new InMemoryCredentialStore();
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
20
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
21
+ throw new Error("Pi auth.json must be an object");
22
+ await Promise.all(Object.entries(parsed).map(([provider, credential]) => credentials.modify(provider, async () => credential)));
23
+ }
24
+ catch (error) {
25
+ if (error.code !== "ENOENT")
26
+ throw error;
27
+ }
28
+ return credentials;
29
+ }
30
+ function savedTrust(cwd, agentDir) {
31
+ let parsed;
32
+ try {
33
+ parsed = JSON.parse(readFileSync(join(agentDir, "trust.json"), "utf8"));
34
+ }
35
+ catch (error) {
36
+ if (error.code === "ENOENT")
37
+ return undefined;
38
+ throw error;
39
+ }
40
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
41
+ throw new Error("Pi trust.json must be an object");
42
+ let current = canonical(cwd);
43
+ while (current !== dirname(current)) {
44
+ const value = parsed[current];
45
+ if (value === true || value === false)
46
+ return value;
47
+ current = dirname(current);
48
+ }
49
+ const value = parsed[current];
50
+ return value === true || value === false ? value : undefined;
51
+ }
52
+ async function discoverPi(cwd, agentDir) {
53
+ const required = hasTrustRequiringProjectResources(cwd);
54
+ const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: false });
55
+ const saved = required ? savedTrust(cwd, agentDir) : true;
56
+ const fallback = settingsManager.getDefaultProjectTrust();
57
+ const trusted = !required || saved !== undefined ? Boolean(saved) : fallback === "always";
58
+ const source = !required ? "no trust-gated project resources" : saved !== undefined ? "saved Pi trust decision" : `headless defaultProjectTrust=${fallback}`;
59
+ const previousOffline = process.env.PI_OFFLINE;
60
+ process.env.PI_OFFLINE = "1";
61
+ try {
62
+ const modelRuntime = await ModelRuntime.create({ credentials: await readCredentials(agentDir), modelsPath: join(agentDir, "models.json") });
63
+ const services = await createAgentSessionServices({
64
+ cwd,
65
+ agentDir,
66
+ settingsManager,
67
+ modelRuntime,
68
+ resourceLoaderOptions: { noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true },
69
+ resourceLoaderReloadOptions: { resolveProjectTrust: async () => trusted },
70
+ });
71
+ const allModels = services.modelRuntime.getModels();
72
+ const availableModels = await services.modelRuntime.getAvailable();
73
+ const model = availableModels[0] ?? allModels[0];
74
+ if (!model)
75
+ throw new Error("Pi has no models registered");
76
+ const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(), model });
77
+ const activeTools = session.agent.state.tools.map(({ name }) => name).filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_catalog");
78
+ const extensions = services.resourceLoader.getExtensions();
79
+ return {
80
+ trust: { required, trusted, source },
81
+ activeTools,
82
+ knownModels: allModels.map(({ provider, id }) => `${provider}/${id}`),
83
+ availableModels: availableModels.map(({ provider, id }) => `${provider}/${id}`),
84
+ extensionErrors: [
85
+ ...extensions.errors.map(({ path, error }) => ({ path, message: error })),
86
+ ...services.diagnostics.filter(({ type }) => type === "error").map(({ message }) => ({ message })),
87
+ ],
88
+ workflows: registeredWorkflowDefinitions(),
89
+ };
90
+ }
91
+ finally {
92
+ if (previousOffline === undefined)
93
+ delete process.env.PI_OFFLINE;
94
+ else
95
+ process.env.PI_OFFLINE = previousOffline;
96
+ }
97
+ }
98
+ function roleFiles(dir) {
99
+ try {
100
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && extname(entry.name) === ".md").map((entry) => join(dir, entry.name)).sort();
101
+ }
102
+ catch (error) {
103
+ if (error.code === "ENOENT")
104
+ return [];
105
+ throw error;
106
+ }
107
+ }
108
+ function roleFilesFrom(dirs) {
109
+ const paths = dirs.flatMap((dir) => roleFiles(dir));
110
+ return [...new Map(paths.map((path) => [basename(path, ".md"), path])).values()].sort();
111
+ }
112
+ function diagnostic(severity, code, message, source, hint) {
113
+ return { severity, code, message, ...(source ? { source } : {}), ...(hint ? { hint } : {}) };
114
+ }
115
+ function validateModel(value, known, available, source, diagnostics) {
116
+ try {
117
+ const parsed = parseModelReference(value);
118
+ const name = `${parsed.provider}/${parsed.model}`;
119
+ if (!known.has(name) || !available.has(name))
120
+ diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${name}`, source));
121
+ }
122
+ catch (error) {
123
+ diagnostics.push(diagnostic("error", "MODEL_INVALID", error.message, source, error.message.includes("thinking") ? THINKING_HINT : "Use provider/model or provider/model:thinking."));
124
+ }
125
+ }
126
+ function inspectRole(path, activeTools, knownModels, availableModels, diagnostics) {
127
+ let definition;
128
+ try {
129
+ definition = parseRoleMarkdown(readFileSync(path, "utf8"), true);
130
+ }
131
+ catch (error) {
132
+ diagnostics.push(diagnostic("error", "ROLE_FRONTMATTER", error.message, path, "Fix the role YAML frontmatter."));
133
+ return undefined;
134
+ }
135
+ const body = definition.prompt ?? "";
136
+ if (body.trim() === "")
137
+ diagnostics.push(diagnostic("warning", "ROLE_BODY_EMPTY", "Role body is empty", path));
138
+ if (Buffer.byteLength(body) > 50 * 1024)
139
+ diagnostics.push(diagnostic("warning", "ROLE_BODY_LARGE", "Role body exceeds 50KB", path));
140
+ if (/{{\s*[^{}]+\s*}}/.test(body))
141
+ diagnostics.push(diagnostic("warning", "ROLE_PLACEHOLDER", "Role body contains an unsupported placeholder-looking token", path));
142
+ if (definition.model)
143
+ validateModel(definition.model, knownModels, availableModels, path, diagnostics);
144
+ for (const tool of definition.tools ?? [])
145
+ if (!activeTools.has(tool))
146
+ diagnostics.push(diagnostic("error", "ROLE_TOOL_INACTIVE", `Tool is unknown or inactive: ${tool}`, path, "Use a tool listed under Active tools or enable its Pi extension."));
147
+ return definition;
148
+ }
149
+ class DoctorModelSet extends Set {
150
+ has(value) { parseModelReference(value); return true; }
151
+ }
152
+ export async function doctor(options = {}) {
153
+ const cwd = canonical(options.cwd ?? process.cwd());
154
+ const agentDir = canonical(options.agentDir ?? getAgentDir());
155
+ const settingsPath = canonical(options.settingsPath ?? workflowSettingsPath(agentDir));
156
+ const diagnostics = [];
157
+ let settings = DEFAULT_SETTINGS;
158
+ try {
159
+ settings = loadSettings(settingsPath);
160
+ }
161
+ catch (error) {
162
+ diagnostics.push(diagnostic("error", "SETTINGS_INVALID", error.message, settingsPath, "Fix or remove the invalid workflow settings file."));
163
+ }
164
+ let pi;
165
+ try {
166
+ pi = await (options.discoverPi ?? discoverPi)(cwd, agentDir);
167
+ }
168
+ catch (error) {
169
+ diagnostics.push(diagnostic("error", "PI_DISCOVERY", `Pi headless discovery failed: ${error.message}`, undefined, "Open and trust the project in Pi, fix extension errors, then rerun doctor."));
170
+ pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [], workflows: {} };
171
+ }
172
+ if (options.activeTools)
173
+ pi = { ...pi, activeTools: options.activeTools.filter((tool) => tool !== "workflow" && tool !== "workflow_respond" && tool !== "workflow_catalog") };
174
+ if (pi.trust.required && !pi.trust.trusted)
175
+ diagnostics.push(diagnostic("warning", "PROJECT_UNTRUSTED", "Pi project resources are inactive because the project is not trusted", cwd, "Open this project in Pi, choose Trust, then rerun doctor."));
176
+ for (const error of pi.extensionErrors)
177
+ diagnostics.push(diagnostic("error", "EXTENSION_LOAD", error.message, error.path, "Fix or disable the failing Pi extension."));
178
+ const activeTools = new Set(pi.activeTools);
179
+ const knownModels = new Set(pi.knownModels);
180
+ const availableModels = new Set(pi.availableModels);
181
+ const roles = [];
182
+ const definitions = new Map();
183
+ const globalPaths = new Map();
184
+ const globalRoleDirs = workflowRoleDirectories(agentDir);
185
+ for (const path of roleFilesFrom(globalRoleDirs)) {
186
+ const name = basename(path, ".md");
187
+ roles.push({ name, path, scope: "global", active: true });
188
+ globalPaths.set(name, path);
189
+ const definition = inspectRole(path, activeTools, knownModels, availableModels, diagnostics);
190
+ if (definition)
191
+ definitions.set(name, definition);
192
+ }
193
+ for (const path of roleFilesFrom([join(cwd, ".pi", "pi-extensible-workflows", "roles")])) {
194
+ const name = basename(path, ".md");
195
+ const globalPath = globalPaths.get(name);
196
+ const active = pi.trust.trusted;
197
+ roles.push({ name, path, scope: "project", active, ...(active && globalPath ? { overrides: globalPath } : {}) });
198
+ if (!active)
199
+ continue;
200
+ if (globalPath) {
201
+ const global = roles.find((role) => role.path === globalPath);
202
+ if (global) {
203
+ global.active = false;
204
+ global.overriddenBy = path;
205
+ }
206
+ }
207
+ const definition = inspectRole(path, activeTools, knownModels, availableModels, diagnostics);
208
+ if (definition)
209
+ definitions.set(name, definition);
210
+ else
211
+ definitions.delete(name);
212
+ }
213
+ const workflows = [];
214
+ for (const [name, workflow] of Object.entries(pi.workflows).sort(([left], [right]) => left.localeCompare(right))) {
215
+ let valid = true;
216
+ try {
217
+ const checked = preflight(workflow.script, {
218
+ models: new DoctorModelSet(pi.knownModels),
219
+ tools: activeTools,
220
+ agentTypes: new Set(definitions.keys()),
221
+ }, [], { name, description: workflow.description });
222
+ for (const model of checked.referenced.models)
223
+ if (!knownModels.has(model) || !availableModels.has(model))
224
+ diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${model}`, name));
225
+ }
226
+ catch (error) {
227
+ valid = false;
228
+ const typed = error instanceof WorkflowError ? error : new WorkflowError("INTERNAL_ERROR", String(error));
229
+ diagnostics.push(diagnostic("error", `WORKFLOW_${typed.code}`, typed.message, name, "Fix the registered workflow source or its referenced role/tool/model/extension."));
230
+ }
231
+ workflows.push({ name, description: workflow.description, valid });
232
+ }
233
+ const severityOrder = { error: 0, warning: 1 };
234
+ diagnostics.sort((left, right) => severityOrder[left.severity] - severityOrder[right.severity] || (left.source ?? "").localeCompare(right.source ?? "") || left.code.localeCompare(right.code) || left.message.localeCompare(right.message));
235
+ roles.sort((left, right) => left.name.localeCompare(right.name) || left.scope.localeCompare(right.scope));
236
+ return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles, workflows, diagnostics };
237
+ }
238
+ function count(report, severity) { return report.diagnostics.filter((item) => item.severity === severity).length; }
239
+ export function doctorExitCode(report) { return count(report, "error") > 0 ? 1 : 0; }
240
+ export function formatDoctorReport(report) {
241
+ const lines = [
242
+ "# pi-extensible-workflows doctor",
243
+ "",
244
+ "## Environment",
245
+ `- CWD: \`${report.cwd}\``,
246
+ `- Agent dir: \`${report.agentDir}\``,
247
+ `- Workflow settings: \`${report.settingsPath}\``,
248
+ `- Limits: concurrency=${String(report.settings.concurrency)}, maxAgentLaunches=${String(report.settings.maxAgentLaunches)}`,
249
+ "",
250
+ "## Trust/resources",
251
+ `- [${report.trust.trusted ? "ok" : "warning"}] ${report.trust.source}`,
252
+ "",
253
+ "## Active tools",
254
+ ...(report.activeTools.length ? report.activeTools.map((tool) => `- \`${tool}\``) : ["- None resolved"]),
255
+ "",
256
+ "## Roles",
257
+ ...(report.roles.length ? report.roles.map((role) => `- \`${role.name}\` (${role.scope}, ${role.active ? "active" : role.overriddenBy ? `overridden by ${role.overriddenBy}` : "inactive: project untrusted"}) - \`${role.path}\`${role.overrides ? `; overrides \`${role.overrides}\`` : ""}`) : ["- None found"]),
258
+ "",
259
+ "## Reusable workflows",
260
+ ...(report.workflows.length ? report.workflows.map((workflow) => `- [${workflow.valid ? "ok" : "error"}] \`${workflow.name}\` - ${workflow.description}`) : ["- None registered"]),
261
+ "",
262
+ "## Diagnostics",
263
+ ...(report.diagnostics.length ? report.diagnostics.map((item) => `- [${item.severity}] ${item.code}${item.source ? ` \`${item.source}\`` : ""}: ${item.message}${item.hint ? ` Fix: ${item.hint}` : ""}`) : ["- [ok] No diagnostics"]),
264
+ "",
265
+ "## Summary",
266
+ `- ${String(count(report, "error"))} error(s), ${String(count(report, "warning"))} warning(s)`,
267
+ ];
268
+ return `${lines.join("\n")}\n`;
269
+ }
@@ -0,0 +1,5 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ export declare const CAPTURE_IDENTITY = "pi-extensible-workflows-eval-capture-v1";
3
+ export declare const CAPTURE_ERROR_PREFIX = "pi-extensible-workflows-eval-capture-v1:";
4
+ export declare function resolveWorkflowSkillPath(): string;
5
+ export default function evalCaptureExtension(pi: ExtensionAPI): void;
@@ -0,0 +1,48 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { validateWorkflowLaunch, WorkflowError, WORKFLOW_TOOL_DESCRIPTION, WORKFLOW_TOOL_LABEL, WORKFLOW_TOOL_PARAMETERS, WORKFLOW_TOOL_PROMPT_SNIPPET } from "./index.js";
5
+ export const CAPTURE_IDENTITY = "pi-extensible-workflows-eval-capture-v1";
6
+ export const CAPTURE_ERROR_PREFIX = `${CAPTURE_IDENTITY}:`;
7
+ export function resolveWorkflowSkillPath() {
8
+ let directory = dirname(fileURLToPath(import.meta.url));
9
+ for (let depth = 0; depth < 6; depth += 1) {
10
+ const candidate = join(directory, "skills", "pi-extensible-workflows", "SKILL.md");
11
+ if (existsSync(candidate))
12
+ return candidate;
13
+ const parent = dirname(directory);
14
+ if (parent === directory)
15
+ break;
16
+ directory = parent;
17
+ }
18
+ throw new Error("Could not resolve skills/pi-extensible-workflows/SKILL.md from the eval extension");
19
+ }
20
+ export default function evalCaptureExtension(pi) {
21
+ pi.registerTool({
22
+ name: "workflow",
23
+ label: WORKFLOW_TOOL_LABEL,
24
+ description: WORKFLOW_TOOL_DESCRIPTION,
25
+ promptSnippet: WORKFLOW_TOOL_PROMPT_SNIPPET,
26
+ parameters: WORKFLOW_TOOL_PARAMETERS,
27
+ async execute(_id, params, _signal, _onUpdate, ctx) {
28
+ try {
29
+ if (!ctx.model)
30
+ throw new WorkflowError("UNKNOWN_MODEL", "A launching model is required");
31
+ const rootModel = `${ctx.model.provider}/${ctx.model.id}`;
32
+ const availableModels = new Set((ctx.modelRegistry?.getAvailable() ?? [ctx.model]).map((model) => `${model.provider}/${model.id}`));
33
+ availableModels.add(rootModel);
34
+ const rootTools = new Set(pi.getActiveTools().filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_catalog"));
35
+ const validated = validateWorkflowLaunch(params, { cwd: ctx.cwd, projectTrusted: ctx.isProjectTrusted?.() ?? true, availableModels, rootTools });
36
+ return {
37
+ content: [{ type: "text", text: JSON.stringify({ captured: true, validated: true, launchBudget: 0, name: validated.checked.metadata.name }) }],
38
+ details: { captured: true, captureIdentity: CAPTURE_IDENTITY, realWorkflowAgentsLaunched: 0, launchBudget: 0, validation: { valid: true, script: validated.script, metadata: validated.checked.metadata, roles: validated.roleNames } },
39
+ };
40
+ }
41
+ catch (error) {
42
+ if (error instanceof WorkflowError)
43
+ throw new WorkflowError(error.code, `${CAPTURE_ERROR_PREFIX}${error.code}: ${error.message}`);
44
+ throw error;
45
+ }
46
+ },
47
+ });
48
+ }