pi-extensible-workflows 0.3.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { execFileSync } from "node:child_process";
3
3
  import { randomUUID } from "node:crypto";
4
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
4
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir, tmpdir } from "node:os";
6
6
  import { join, relative } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { CAPTURE_IDENTITY } from "./eval-capture-extension.js";
9
- import { extractCapturedWorkflows, extractParentOracleFile } from "./workflow-evals.js";
9
+ import { isObject } from "./index.js";
10
+ import { extractCapturedWorkflows, extractParentOracleFile, findSessionFile } from "./workflow-evals.js";
10
11
  export const AMBIENT_OPT_IN = "PI_WORKFLOW_EVAL_AMBIENT";
11
12
  export const AMBIENT_CAPTURE_NOTE = "Ambient Tier D uses the explicit capture extension. Pi 0.80.6 orders CLI extensions before discovered extensions and the first tool registration wins, so ambient tools and skills remain available while workflow execution stays capture-only.";
12
13
  export const AMBIENT_INVOCATION_MODE = "ambient-capture-only";
@@ -234,34 +235,12 @@ function emptyAccounting() {
234
235
  function ambientAgentDir(environment) {
235
236
  return environment.PI_CODING_AGENT_DIR ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent");
236
237
  }
237
- function isObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
238
- function findSessionFile(directory, sessionId) {
239
- if (!existsSync(directory))
240
- return undefined;
241
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
242
- const path = join(directory, entry.name);
243
- if (entry.isDirectory()) {
244
- const found = findSessionFile(path, sessionId);
245
- if (found)
246
- return found;
247
- }
248
- else if (entry.name.endsWith(".jsonl")) {
249
- try {
250
- const header = JSON.parse(readFileSync(path, "utf8").split("\n")[0] ?? "{}");
251
- if (isObject(header) && header.id === sessionId)
252
- return path;
253
- }
254
- catch { /* Ignore incomplete sessions. */ }
255
- }
256
- }
257
- return undefined;
258
- }
259
238
  function emptyResult(candidate, repository, worktree, environment, error) {
260
239
  const accounting = emptyAccounting();
261
240
  return {
262
241
  id: candidate.id, status: "failed", workflows: [], accounting, accountingTrustworthy: false, diagnostics: [], errors: [error],
263
242
  manifest: {
264
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
243
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: [], skillReads: [], workflowCalls: [], workflowCallCount: 0, tokenCost: accounting,
265
244
  cleanup: { processExited: false, processGroupTerminated: false, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified: false, realWorkflowAgentsLaunched: 0 },
266
245
  },
267
246
  };
@@ -271,12 +250,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
271
250
  const sessionId = randomUUID();
272
251
  const sessionDir = join(repository.root, "sessions", candidate.id);
273
252
  let result;
274
- let gitStatusAfter = worktree.gitStatusBefore;
253
+ let failure;
254
+ let gitStatusAfter;
275
255
  try {
276
256
  const pi = await runAmbientPiProcess({ worktree: worktree.path, sessionDir, sessionId, prompt: candidate.prompt, provider, model, ...(thinking ? { thinking } : {}), ...(piCommand ? { piCommand } : {}), timeoutMs: candidate.timeoutMs, maxCost: candidate.maxCost, environment });
277
257
  const sessionFile = findSessionFile(sessionDir, sessionId);
278
258
  if (!sessionFile)
279
- result = emptyResult(candidate, repository, worktree, environment, "Ambient parent session was not written.");
259
+ failure = "Ambient parent session was not written.";
280
260
  else {
281
261
  const oracle = extractParentOracleFile(sessionFile);
282
262
  const workflows = extractCapturedWorkflows(oracle);
@@ -286,14 +266,14 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
286
266
  result = {
287
267
  id: candidate.id, status, workflows, accounting: oracle.usage, accountingTrustworthy: !pi.timedOut && !pi.budgetExceeded && pi.exitCode === 0, diagnostics: [pi.stderr].filter(Boolean), errors,
288
268
  manifest: {
289
- invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, gitStatusAfter, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
269
+ invocationMode: AMBIENT_INVOCATION_MODE, fixtureRoot: repository.fixtureRoot, worktreePath: worktree.path, ambientAgentDir: ambientAgentDir(environment), fixtureFileList: worktree.fixtureFiles, gitStatusBefore: worktree.gitStatusBefore, parentToolSequence: oracle.parentToolSequence, skillReads: oracle.skillReads, workflowCalls: workflows, workflowCallCount: oracle.workflowCallCount, tokenCost: oracle.usage,
290
270
  cleanup: { processExited: pi.exitCode !== null, processGroupTerminated: pi.processGroupTerminated, worktreeRemoved: false, fixtureRepoRemoved: false, tempRootRemoved: false, captureIdentityVerified, realWorkflowAgentsLaunched: 0 },
291
271
  },
292
272
  };
293
273
  }
294
274
  }
295
275
  catch (error) {
296
- result = emptyResult(candidate, repository, worktree, environment, error instanceof Error ? error.message : String(error));
276
+ failure = error instanceof Error ? error.message : String(error);
297
277
  }
298
278
  finally {
299
279
  try {
@@ -304,12 +284,13 @@ async function runAmbientCase(repository, candidate, artifactsDir, environment,
304
284
  }
305
285
  const worktreeRemoved = removeAmbientCaseWorktree(repository, worktree);
306
286
  if (!result)
307
- result = emptyResult(candidate, repository, worktree, environment, "Ambient case produced no result.");
287
+ result = emptyResult(candidate, repository, worktree, environment, failure ?? "Ambient case produced no result.");
308
288
  const cleanup = { ...result.manifest.cleanup, worktreeRemoved };
309
- result = { ...result, manifest: { ...result.manifest, gitStatusAfter, cleanup } };
310
- writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
289
+ result = { ...result, manifest: { ...result.manifest, cleanup } };
311
290
  }
312
- return result;
291
+ const finalized = { ...result, manifest: { ...result.manifest, gitStatusAfter } };
292
+ writeFileSync(join(artifactsDir, `${candidate.id}.json`), `${JSON.stringify(finalized, null, 2)}\n`, { mode: 0o600 });
293
+ return finalized;
313
294
  }
314
295
  export function assertAmbientOptIn(environment = process.env) {
315
296
  if (environment[AMBIENT_OPT_IN] !== "1")
@@ -1,4 +1,4 @@
1
- import { type WorkflowScriptDefinition, type WorkflowSettings } from "./index.js";
1
+ import { type AgentResourcePolicy, type WorkflowScriptDefinition, type WorkflowSettings } from "./index.js";
2
2
  export type DoctorSeverity = "error" | "warning";
3
3
  export interface DoctorDiagnostic {
4
4
  severity: DoctorSeverity;
@@ -34,6 +34,8 @@ export interface DoctorPiState {
34
34
  path?: string;
35
35
  message: string;
36
36
  }[];
37
+ extensions?: readonly string[];
38
+ skills?: readonly string[];
37
39
  workflows: Readonly<Record<string, WorkflowScriptDefinition>>;
38
40
  }
39
41
  export interface DoctorReport {
@@ -45,6 +47,7 @@ export interface DoctorReport {
45
47
  activeTools: readonly string[];
46
48
  roles: readonly DoctorRole[];
47
49
  workflows: readonly DoctorWorkflow[];
50
+ resourcePolicy: AgentResourcePolicy;
48
51
  diagnostics: readonly DoctorDiagnostic[];
49
52
  }
50
53
  export interface DoctorOptions {
@@ -2,7 +2,7 @@ import { readFileSync, readdirSync, realpathSync } from "node:fs";
2
2
  import { basename, dirname, extname, join, resolve } from "node:path";
3
3
  import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
4
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";
5
+ import { DEFAULT_SETTINGS, loadSettings, resolveAgentResourcePolicy, resolveModelReference, parseRoleMarkdown, preflight, registeredWorkflowDefinitions, workflowRoleDirectories, workflowProjectSettingsPath, workflowSettingsPath, WorkflowError, } from "./index.js";
6
6
  const THINKING_HINT = "Use off, minimal, low, medium, high, xhigh, or max.";
7
7
  function canonical(path) {
8
8
  const absolute = resolve(path);
@@ -65,7 +65,7 @@ async function discoverPi(cwd, agentDir) {
65
65
  agentDir,
66
66
  settingsManager,
67
67
  modelRuntime,
68
- resourceLoaderOptions: { noSkills: true, noPromptTemplates: true, noThemes: true, noContextFiles: true },
68
+ resourceLoaderOptions: { noPromptTemplates: true, noThemes: true, noContextFiles: true },
69
69
  resourceLoaderReloadOptions: { resolveProjectTrust: async () => trusted },
70
70
  });
71
71
  const allModels = services.modelRuntime.getModels();
@@ -76,11 +76,14 @@ async function discoverPi(cwd, agentDir) {
76
76
  const { session } = await createAgentSessionFromServices({ services, sessionManager: SessionManager.inMemory(), model });
77
77
  const activeTools = session.agent.state.tools.map(({ name }) => name).filter((name) => name !== "workflow" && name !== "workflow_respond" && name !== "workflow_catalog");
78
78
  const extensions = services.resourceLoader.getExtensions();
79
+ const skills = services.resourceLoader.getSkills().skills;
79
80
  return {
80
81
  trust: { required, trusted, source },
81
82
  activeTools,
82
83
  knownModels: allModels.map(({ provider, id }) => `${provider}/${id}`),
83
84
  availableModels: availableModels.map(({ provider, id }) => `${provider}/${id}`),
85
+ extensions: extensions.extensions.map(({ resolvedPath }) => resolvedPath),
86
+ skills: skills.map(({ name }) => name),
84
87
  extensionErrors: [
85
88
  ...extensions.errors.map(({ path, error }) => ({ path, message: error })),
86
89
  ...services.diagnostics.filter(({ type }) => type === "error").map(({ message }) => ({ message })),
@@ -112,9 +115,13 @@ function roleFilesFrom(dirs) {
112
115
  function diagnostic(severity, code, message, source, hint) {
113
116
  return { severity, code, message, ...(source ? { source } : {}), ...(hint ? { hint } : {}) };
114
117
  }
115
- function validateModel(value, known, available, source, diagnostics) {
118
+ function emptyResourcePolicy(globalSettingsPath, cwd, projectTrusted) {
119
+ const empty = { skills: [], extensions: [] };
120
+ return { globalSettingsPath, projectSettingsPath: workflowProjectSettingsPath(cwd), projectTrusted, global: empty, project: empty, effective: empty, unmatchedSkills: [], unmatchedExtensions: [] };
121
+ }
122
+ function validateModel(value, known, available, source, diagnostics, aliases, settingsPath) {
116
123
  try {
117
- const parsed = parseModelReference(value);
124
+ const parsed = resolveModelReference(value, aliases, known, settingsPath);
118
125
  const name = `${parsed.provider}/${parsed.model}`;
119
126
  if (!known.has(name) || !available.has(name))
120
127
  diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${name}`, source));
@@ -123,10 +130,10 @@ function validateModel(value, known, available, source, diagnostics) {
123
130
  diagnostics.push(diagnostic("error", "MODEL_INVALID", error.message, source, error.message.includes("thinking") ? THINKING_HINT : "Use provider/model or provider/model:thinking."));
124
131
  }
125
132
  }
126
- function inspectRole(path, activeTools, knownModels, availableModels, diagnostics) {
133
+ function inspectRole(path, activeTools, knownModels, availableModels, diagnostics, aliases, settingsPath) {
127
134
  let definition;
128
135
  try {
129
- definition = parseRoleMarkdown(readFileSync(path, "utf8"), true);
136
+ definition = parseRoleMarkdown(readFileSync(path, "utf8"), true, path);
130
137
  }
131
138
  catch (error) {
132
139
  diagnostics.push(diagnostic("error", "ROLE_FRONTMATTER", error.message, path, "Fix the role YAML frontmatter."));
@@ -140,14 +147,19 @@ function inspectRole(path, activeTools, knownModels, availableModels, diagnostic
140
147
  if (/{{\s*[^{}]+\s*}}/.test(body))
141
148
  diagnostics.push(diagnostic("warning", "ROLE_PLACEHOLDER", "Role body contains an unsupported placeholder-looking token", path));
142
149
  if (definition.model)
143
- validateModel(definition.model, knownModels, availableModels, path, diagnostics);
150
+ validateModel(definition.model, knownModels, availableModels, path, diagnostics, aliases, settingsPath);
144
151
  for (const tool of definition.tools ?? [])
145
152
  if (!activeTools.has(tool))
146
153
  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
154
  return definition;
148
155
  }
149
- class DoctorModelSet extends Set {
150
- has(value) { parseModelReference(value); return true; }
156
+ function matchResourcePolicy(policy, pi) {
157
+ const extensions = new Set((pi.extensions ?? []).map(canonical));
158
+ const skills = new Set(pi.skills ?? []);
159
+ return { ...policy, unmatchedSkills: policy.effective.skills.filter((name) => !skills.has(name)), unmatchedExtensions: policy.effective.extensions.filter((path) => !extensions.has(canonical(path))) };
160
+ }
161
+ function resourcePolicySource(policy, kind, selector) {
162
+ return policy.project[kind].includes(selector) && !policy.global[kind].includes(selector) ? policy.projectSettingsPath : policy.globalSettingsPath;
151
163
  }
152
164
  export async function doctor(options = {}) {
153
165
  const cwd = canonical(options.cwd ?? process.cwd());
@@ -175,9 +187,24 @@ export async function doctor(options = {}) {
175
187
  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
188
  for (const error of pi.extensionErrors)
177
189
  diagnostics.push(diagnostic("error", "EXTENSION_LOAD", error.message, error.path, "Fix or disable the failing Pi extension."));
190
+ let resourcePolicy;
191
+ try {
192
+ resourcePolicy = matchResourcePolicy(resolveAgentResourcePolicy(cwd, pi.trust.trusted, settingsPath), pi);
193
+ }
194
+ catch (error) {
195
+ const message = error instanceof Error ? error.message : String(error);
196
+ if (!diagnostics.some(({ code, source }) => code === "SETTINGS_INVALID" && source === settingsPath))
197
+ diagnostics.push(diagnostic("error", "SETTINGS_INVALID", message, message.includes(".json") ? message.match(/(?:settings: )?([^ )]+\.json)/)?.[1] : undefined, "Fix or remove the invalid workflow settings file."));
198
+ resourcePolicy = emptyResourcePolicy(settingsPath, cwd, pi.trust.trusted);
199
+ }
200
+ for (const skill of resourcePolicy.unmatchedSkills)
201
+ diagnostics.push(diagnostic("warning", "AGENT_RESOURCE_UNMATCHED", `Disabled skill selector currently matches no discovered skill: ${skill}`, `${resourcePolicySource(resourcePolicy, "skills", skill)}.disabledAgentResources.skills`));
202
+ for (const extension of resourcePolicy.unmatchedExtensions)
203
+ diagnostics.push(diagnostic("warning", "AGENT_RESOURCE_UNMATCHED", `Disabled extension selector currently matches no discovered extension source: ${extension}`, `${resourcePolicySource(resourcePolicy, "extensions", extension)}.disabledAgentResources.extensions`));
178
204
  const activeTools = new Set(pi.activeTools);
179
205
  const knownModels = new Set(pi.knownModels);
180
206
  const availableModels = new Set(pi.availableModels);
207
+ const aliases = settings.modelAliases ?? {};
181
208
  const roles = [];
182
209
  const definitions = new Map();
183
210
  const globalPaths = new Map();
@@ -186,7 +213,7 @@ export async function doctor(options = {}) {
186
213
  const name = basename(path, ".md");
187
214
  roles.push({ name, path, scope: "global", active: true });
188
215
  globalPaths.set(name, path);
189
- const definition = inspectRole(path, activeTools, knownModels, availableModels, diagnostics);
216
+ const definition = inspectRole(path, activeTools, knownModels, availableModels, diagnostics, aliases, settingsPath);
190
217
  if (definition)
191
218
  definitions.set(name, definition);
192
219
  }
@@ -204,7 +231,7 @@ export async function doctor(options = {}) {
204
231
  global.overriddenBy = path;
205
232
  }
206
233
  }
207
- const definition = inspectRole(path, activeTools, knownModels, availableModels, diagnostics);
234
+ const definition = inspectRole(path, activeTools, knownModels, availableModels, diagnostics, aliases, settingsPath);
208
235
  if (definition)
209
236
  definitions.set(name, definition);
210
237
  else
@@ -215,9 +242,13 @@ export async function doctor(options = {}) {
215
242
  let valid = true;
216
243
  try {
217
244
  const checked = preflight(workflow.script, {
218
- models: new DoctorModelSet(pi.knownModels),
245
+ models: new Set(pi.knownModels),
219
246
  tools: activeTools,
220
247
  agentTypes: new Set(definitions.keys()),
248
+ modelAliases: aliases,
249
+ knownModels,
250
+ settingsPath,
251
+ skipModelAvailability: true,
221
252
  }, [], { name, description: workflow.description });
222
253
  for (const model of checked.referenced.models)
223
254
  if (!knownModels.has(model) || !availableModels.has(model))
@@ -233,7 +264,7 @@ export async function doctor(options = {}) {
233
264
  const severityOrder = { error: 0, warning: 1 };
234
265
  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
266
  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 };
267
+ return { cwd, agentDir, settingsPath, settings, trust: pi.trust, activeTools: [...activeTools].sort(), roles, workflows, resourcePolicy, diagnostics };
237
268
  }
238
269
  function count(report, severity) { return report.diagnostics.filter((item) => item.severity === severity).length; }
239
270
  export function doctorExitCode(report) { return count(report, "error") > 0 ? 1 : 0; }
@@ -245,11 +276,23 @@ export function formatDoctorReport(report) {
245
276
  `- CWD: \`${report.cwd}\``,
246
277
  `- Agent dir: \`${report.agentDir}\``,
247
278
  `- Workflow settings: \`${report.settingsPath}\``,
248
- `- Limits: concurrency=${String(report.settings.concurrency)}, maxAgentLaunches=${String(report.settings.maxAgentLaunches)}`,
279
+ `- Limits: concurrency=${String(report.settings.concurrency)}`,
249
280
  "",
250
281
  "## Trust/resources",
251
282
  `- [${report.trust.trusted ? "ok" : "warning"}] ${report.trust.source}`,
252
283
  "",
284
+ "## Agent resource exclusions",
285
+ `- Global settings: \`${report.resourcePolicy.globalSettingsPath}\``,
286
+ `- Global skills: ${report.resourcePolicy.global.skills.join(", ") || "(none)"}`,
287
+ `- Global extensions: ${report.resourcePolicy.global.extensions.join(", ") || "(none)"}`,
288
+ `- Project settings: \`${report.resourcePolicy.projectSettingsPath}\` (${report.resourcePolicy.projectTrusted ? "trusted" : "ignored: project untrusted"})`,
289
+ `- Project skills: ${report.resourcePolicy.project.skills.join(", ") || "(none)"}`,
290
+ `- Project extensions: ${report.resourcePolicy.project.extensions.join(", ") || "(none)"}`,
291
+ `- Effective skills: ${report.resourcePolicy.effective.skills.join(", ") || "(none)"}`,
292
+ `- Effective extensions: ${report.resourcePolicy.effective.extensions.join(", ") || "(none)"}`,
293
+ `- Unmatched skills: ${report.resourcePolicy.unmatchedSkills.join(", ") || "(none)"}`,
294
+ `- Unmatched extensions: ${report.resourcePolicy.unmatchedExtensions.join(", ") || "(none)"}`,
295
+ "",
253
296
  "## Active tools",
254
297
  ...(report.activeTools.length ? report.activeTools.map((tool) => `- \`${tool}\``) : ["- None resolved"]),
255
298
  "",