@piewf/cli 5.0.0 → 5.2.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.
@@ -2,13 +2,50 @@ import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, re
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
+ function isJsonObject(value) {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+ function readJsonObject(path) {
9
+ try {
10
+ const value = JSON.parse(readFileSync(path, "utf8"));
11
+ return isJsonObject(value) ? value : undefined;
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ }
17
+ function readPackageMetadata(path) {
18
+ const value = readJsonObject(path);
19
+ if (!value)
20
+ return undefined;
21
+ const metadata = {};
22
+ if (typeof value.name === "string")
23
+ metadata.name = value.name;
24
+ if (typeof value.version === "string")
25
+ metadata.version = value.version;
26
+ if (typeof value.bin === "string")
27
+ metadata.bin = value.bin;
28
+ else if (isJsonObject(value.bin)) {
29
+ const bin = {};
30
+ let valid = true;
31
+ for (const [name, entry] of Object.entries(value.bin)) {
32
+ if (typeof entry !== "string") {
33
+ valid = false;
34
+ break;
35
+ }
36
+ bin[name] = entry;
37
+ }
38
+ if (valid)
39
+ metadata.bin = bin;
40
+ }
41
+ return metadata;
42
+ }
5
43
  function packageJson() {
6
44
  const directory = dirname(fileURLToPath(import.meta.url));
7
45
  for (const path of [join(directory, "../package.json"), join(directory, "../../package.json")]) {
8
- try {
9
- return JSON.parse(readFileSync(path, "utf8"));
10
- }
11
- catch { /* Try the source and built layouts. */ }
46
+ const metadata = readPackageMetadata(path);
47
+ if (metadata)
48
+ return metadata;
12
49
  }
13
50
  return {};
14
51
  }
@@ -300,7 +337,6 @@ function workflowModule(workflow, functionSource, withRoles, aliasTargets, exten
300
337
  " registerWorkflowExtension({",
301
338
  ` version: ${JSON.stringify("1.0.0")},`,
302
339
  ` headline: ${JSON.stringify("Portable workflow bundle")},`,
303
- ` description: ${JSON.stringify(workflow.description)},`,
304
340
  ...(withRoles ? [` roleDirectories: [new URL("./roles", import.meta.url)],`] : []),
305
341
  ...(aliases.length ? [` modelAliases: { ${aliases.map(([name, target]) => `${JSON.stringify(name)}: { resolve: () => ${JSON.stringify(target)} }`).join(", ")} },`] : []),
306
342
  " functions: {",
@@ -328,6 +364,8 @@ function roleMarkdown(role) {
328
364
  metadata.push(`tools: ${JSON.stringify(role.tools)}`);
329
365
  if (role.overrideSystemPrompt !== undefined)
330
366
  metadata.push(`overrideSystemPrompt: ${String(role.overrideSystemPrompt)}`);
367
+ if (role.contextFiles !== undefined)
368
+ metadata.push(`contextFiles: ${JSON.stringify(role.contextFiles)}`);
331
369
  if (role.disabledAgentResources !== undefined)
332
370
  metadata.push(`disabledAgentResources: ${JSON.stringify(role.disabledAgentResources)}`);
333
371
  metadata.push("---");
@@ -352,12 +390,9 @@ function copyResources(root, resources) {
352
390
  throw new Error(`Bundle resource may contain credentials and cannot be selected: ${source}`);
353
391
  let name = basename(source);
354
392
  if (kind === "dependencies") {
355
- try {
356
- const packageName = JSON.parse(readFileSync(join(source, "package.json"), "utf8")).name;
357
- if (typeof packageName === "string" && packageName.trim())
358
- name = packageName;
359
- }
360
- catch { /* Files can be dependency entry points. */ }
393
+ const packageName = readPackageMetadata(join(source, "package.json"))?.name;
394
+ if (typeof packageName === "string" && packageName.trim())
395
+ name = packageName;
361
396
  }
362
397
  if (!name || name === "." || name === ".." || name.includes("\\") || name.startsWith("/"))
363
398
  throw new Error(`Invalid bundle resource name: ${name}`);
package/dist/src/cli.d.ts CHANGED
@@ -15,5 +15,9 @@ export interface CliOptions extends DoctorOptions {
15
15
  }
16
16
  export declare function formatWorkflowCliHelp(fn: WorkflowCatalogFunction, command?: string): string;
17
17
  export declare function parseWorkflowCliArgs(schema: JsonSchema, rawArgs: readonly string[]): Record<string, JsonValue>;
18
+ export declare function parseDoctorArgs(rawArgs: readonly string[]): {
19
+ role?: string;
20
+ prompt?: string;
21
+ };
18
22
  export declare function parseDoctorCleanupArgs(rawArgs: readonly string[]): Required<Pick<DoctorCleanupOptions, "olderThanDays" | "yes">>;
19
23
  export declare function runCli(args: readonly string[], options?: CliOptions, write?: (text: string) => void): Promise<number>;
package/dist/src/cli.js CHANGED
@@ -8,17 +8,27 @@ import { ProjectTrustStore, SessionManager, SettingsManager, createAgentSessionF
8
8
  import { Value } from "typebox/value";
9
9
  import { doctor, doctorExitCode, formatDoctorReport } from "./doctor.js";
10
10
  import { doctorCleanup, doctorCleanupExitCode, formatDoctorCleanupReport } from "./doctor-cleanup.js";
11
- import workflowExtension, { formatWorkflowProgress, loadAgentDefinitions, registeredWorkflowFunctions, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath } from "pi-extensible-workflows";
11
+ import workflowExtension, { formatWorkflowProgress, isNodeError, jsonValue, loadAgentDefinitions, registeredWorkflowFunctions, truncateWorkflowProgress, workflowCatalog, workflowSettingsPath } from "pi-extensible-workflows";
12
12
  import { portableEngineVersion, portablePiVersion, writePortableWorkflowBundle } from "./bundles.js";
13
13
  import { runSessionInspector, transcriptFileLines } from "./session-inspector.js";
14
+ import { isPersistedRun } from "pi-extensible-workflows/persistence";
14
15
  function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
15
16
  function has(value, key) { return Object.prototype.hasOwnProperty.call(value, key); }
16
- function clone(value) { return structuredClone(value); }
17
+ function requiredArg(args, index) {
18
+ const value = args[index];
19
+ if (value === undefined)
20
+ throw new Error("Missing argument");
21
+ return value;
22
+ }
23
+ function typedOptionKey(options, key) { return Object.prototype.hasOwnProperty.call(options, key); }
24
+ function clone(value) { const cloned = structuredClone(value); if (!jsonValue(cloned))
25
+ throw new Error("Invalid JSON passed to --input"); return cloned; }
17
26
  function kebabCase(value) { return value.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase(); }
27
+ function isCliScalar(value) { return value === "string" || value === "integer" || value === "number" || value === "boolean"; }
18
28
  function scalarType(schema) {
19
29
  if (!object(schema) || typeof schema.type !== "string")
20
30
  return undefined;
21
- return ["string", "integer", "number", "boolean"].includes(schema.type) ? schema.type : undefined;
31
+ return isCliScalar(schema.type) ? schema.type : undefined;
22
32
  }
23
33
  function schemaPlan(schema) {
24
34
  if (!object(schema) || schema.type !== "object")
@@ -40,8 +50,14 @@ function schemaPlan(schema) {
40
50
  return { fields, ...(requiredScalars.length === 1 ? { positional: requiredScalars[0] } : {}) };
41
51
  }
42
52
  function scalarLabel(type) { return type === "integer" ? "integer" : type; }
43
- function scalarFieldType(field) { return field.type === "array" ? field.itemType : field.type; }
44
- function fieldLabel(field) { return field.type === "array" ? `${field.option} <${scalarLabel(field.itemType)}>` : `${field.option}${field.type === "boolean" ? "" : ` <${scalarLabel(field.type)}>`}`; }
53
+ function scalarFieldType(field) {
54
+ if (field.type !== "array")
55
+ return field.type;
56
+ if (!isCliScalar(field.itemType))
57
+ throw new Error("Invalid array field");
58
+ return field.itemType;
59
+ }
60
+ function fieldLabel(field) { return field.type === "array" ? `${field.option} <${scalarLabel(scalarFieldType(field))}>` : `${field.option}${field.type === "boolean" ? "" : ` <${scalarLabel(field.type)}>`}`; }
45
61
  function fieldDescription(field) {
46
62
  const description = typeof field.schema.description === "string" ? field.schema.description.trim() : "";
47
63
  const required = field.required ? "required" : "optional";
@@ -109,15 +125,19 @@ export function parseWorkflowCliArgs(schema, rawArgs) {
109
125
  let endOptions = false;
110
126
  const assign = (field, raw) => {
111
127
  if (field.type === "array") {
112
- const values = Array.isArray(result[field.name]) ? result[field.name] : [];
113
- values.push(coerce(raw, field.itemType, field.schema.items));
128
+ const current = result[field.name];
129
+ const values = Array.isArray(current) ? current : [];
130
+ const itemSchema = field.schema.items;
131
+ if (!object(itemSchema))
132
+ throw new Error("Invalid array field");
133
+ values.push(coerce(raw, scalarFieldType(field), itemSchema));
114
134
  result[field.name] = values;
115
135
  }
116
136
  else
117
137
  result[field.name] = coerce(raw, field.type, field.schema);
118
138
  };
119
139
  for (let index = 0; index < rawArgs.length; index += 1) {
120
- const token = rawArgs[index];
140
+ const token = requiredArg(rawArgs, index);
121
141
  if (token === "--") {
122
142
  endOptions = true;
123
143
  continue;
@@ -147,7 +167,7 @@ export function parseWorkflowCliArgs(schema, rawArgs) {
147
167
  if (equals >= 0)
148
168
  assign(field, token.slice(equals + 1));
149
169
  else if (rawArgs[index + 1] === "true" || rawArgs[index + 1] === "false")
150
- assign(field, rawArgs[++index]);
170
+ assign(field, requiredArg(rawArgs, ++index));
151
171
  else
152
172
  result[field.name] = true;
153
173
  }
@@ -218,12 +238,47 @@ function parseInspectArgs(rawArgs) {
218
238
  }
219
239
  return { ...(sessionId ? { sessionId } : {}), mode: failedOnly && mode === "tui" ? "summary" : mode, failedOnly };
220
240
  }
241
+ export function parseDoctorArgs(rawArgs) {
242
+ let role;
243
+ let prompt;
244
+ for (let index = 0; index < rawArgs.length; index += 1) {
245
+ const token = requiredArg(rawArgs, index);
246
+ const equals = token.indexOf("=");
247
+ const option = equals >= 0 ? token.slice(0, equals) : token;
248
+ if (option === "--role" || option === "--prompt") {
249
+ const value = equals >= 0 ? token.slice(equals + 1) : rawArgs[++index];
250
+ if (!value)
251
+ throw new Error(`Missing value for ${option}`);
252
+ if (option === "--role") {
253
+ if (role !== undefined)
254
+ throw new Error("--role may only be provided once");
255
+ role = value;
256
+ }
257
+ else {
258
+ if (prompt !== undefined)
259
+ throw new Error("--prompt may only be provided once");
260
+ prompt = value;
261
+ }
262
+ continue;
263
+ }
264
+ if (token === "--help" || token === "-h")
265
+ throw new Error("help");
266
+ if (token.startsWith("--"))
267
+ throw new Error(`Unknown doctor option: ${token}`);
268
+ if (role !== undefined)
269
+ throw new Error(`Unexpected argument: ${token}`);
270
+ role = token;
271
+ }
272
+ if (prompt !== undefined && role === undefined)
273
+ throw new Error("--prompt requires --role");
274
+ return { ...(role === undefined ? {} : { role }), ...(prompt === undefined ? {} : { prompt }) };
275
+ }
221
276
  export function parseDoctorCleanupArgs(rawArgs) {
222
277
  let olderThanDays = 90;
223
278
  let yes = false;
224
279
  let seenDays = false;
225
280
  for (let index = 0; index < rawArgs.length; index += 1) {
226
- const token = rawArgs[index];
281
+ const token = requiredArg(rawArgs, index);
227
282
  if (token === "--yes") {
228
283
  yes = true;
229
284
  continue;
@@ -267,6 +322,8 @@ function stripTrustOptions(rawArgs) {
267
322
  }
268
323
  return { args, ...(trustOverride !== undefined ? { trustOverride } : {}) };
269
324
  }
325
+ function isHeadlessWorkflowResult(value) { return object(value) && Array.isArray(value.content) && value.content.every((entry) => object(entry) && typeof entry.type === "string" && typeof entry.text === "string"); }
326
+ function isHeadlessWorkflowTool(value) { return object(value) && value.name === "workflow" && typeof value.execute === "function"; }
270
327
  async function createWorkflowRuntime(options, shutdownHandlers = []) {
271
328
  const cwd = options.cwd ?? process.cwd();
272
329
  const agentDir = options.agentDir ?? getAgentDir();
@@ -289,6 +346,8 @@ async function createWorkflowRuntime(options, shutdownHandlers = []) {
289
346
  for (const handler of extension.handlers.get("project_trust") ?? []) {
290
347
  try {
291
348
  const result = await handler({ type: "project_trust", cwd }, projectTrustContext);
349
+ if (!object(result))
350
+ continue;
292
351
  if (result.trusted === "undecided")
293
352
  continue;
294
353
  if (result.trusted !== "yes" && result.trusted !== "no")
@@ -328,7 +387,7 @@ async function createWorkflowRuntime(options, shutdownHandlers = []) {
328
387
  events: { emit() { } },
329
388
  };
330
389
  workflowExtension(headlessPi, homedir(), undefined, undefined, agentDir, options.skillPaths);
331
- const workflowTool = tools.find((tool) => object(tool) && tool.name === "workflow");
390
+ const workflowTool = tools.find(isHeadlessWorkflowTool);
332
391
  if (!workflowTool)
333
392
  throw new Error("The workflow runtime could not be initialized");
334
393
  return { catalog: workflowCatalog({ cwd, projectTrusted: settingsManager.isProjectTrusted(), globalSettingsPath: workflowSettingsPath(agentDir) }), services, workflowTool, shutdownHandlers };
@@ -360,8 +419,8 @@ import { homedir } from "node:os";
360
419
  import { join } from "node:path";
361
420
  import { pathToFileURL } from "node:url";
362
421
  let cli;
363
- try { cli = await import(import.meta.resolve("@piewf/cli")); } catch {}
364
- if (!cli) try { cli = await import(pathToFileURL(join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "npm", "node_modules", "@piewf/cli", "dist", "src", "cli.js")).href); } catch {}
422
+ try { cli = await import(pathToFileURL(join(process.env.PI_CODING_AGENT_DIR ?? join(homedir(), ".pi", "agent"), "npm", "node_modules", "@piewf/cli", "dist", "src", "cli.js")).href); } catch {}
423
+ if (!cli) try { cli = await import(import.meta.resolve("@piewf/cli")); } catch {}
365
424
  if (cli) process.exitCode = await cli.runCli(["run", ${JSON.stringify(workflowName)}, ...process.argv.slice(2)]);
366
425
  else { const result = spawnSync("piewf", ["run", ${JSON.stringify(workflowName)}, ...process.argv.slice(2)], { stdio: "inherit" }); if (result.error) { console.error("Could not resolve @piewf/cli; install it or put piewf on PATH."); process.exitCode = 1; } else process.exitCode = result.status ?? 1; }
367
426
  `;
@@ -374,7 +433,7 @@ else { const result = spawnSync("piewf", ["run", ${JSON.stringify(workflowName)}
374
433
  linkSync(tempPath, destination);
375
434
  }
376
435
  catch (error) {
377
- if (error.code === "EEXIST")
436
+ if (isNodeError(error, "EEXIST"))
378
437
  throw new Error(`Destination already exists: ${destination}; use --force to replace it`, { cause: error });
379
438
  throw error;
380
439
  }
@@ -467,13 +526,15 @@ async function invokeWorkflow(fn, args, runtime, options, context) {
467
526
  return; announcedRunId = runId; options.stderr(`Run ID: ${runId}\n`); };
468
527
  const progress = new CliProgress(options.stderr, options.isTTY ?? process.stderr.isTTY, announceRunId);
469
528
  try {
470
- const result = await runtime.workflowTool.execute(randomUUID(), { name: fn.name, script: `return await ${fn.name}(args);`, args, foreground: true }, options.signal, (update) => { if (object(update) && object(update.details) && object(update.details.run))
529
+ const result = await runtime.workflowTool.execute(randomUUID(), { name: fn.name, script: `return await ${fn.name}(args);`, args, foreground: true }, options.signal, (update) => { if (object(update) && object(update.details) && isPersistedRun(update.details.run))
471
530
  progress.update(update.details.run); }, context);
531
+ if (!isHeadlessWorkflowResult(result))
532
+ throw new Error("Workflow returned an invalid result");
472
533
  const details = object(result.details) ? result.details : {};
473
534
  const runId = typeof details.runId === "string" ? details.runId : undefined;
474
535
  if (runId)
475
536
  announceRunId(runId);
476
- if (has(details, "value"))
537
+ if (has(details, "value") && jsonValue(details.value))
477
538
  return { value: details.value, ...(runId ? { runId } : {}) };
478
539
  const first = result.content[0];
479
540
  if (!first || first.type !== "text")
@@ -522,7 +583,7 @@ async function runWorkflowCli(rawArgs, options) {
522
583
  options.write(workflowUsage());
523
584
  return args.length ? 0 : 1;
524
585
  }
525
- const name = args[0];
586
+ const name = requiredArg(args, 0);
526
587
  return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime, context) => {
527
588
  const help = args.slice(1).some((arg) => arg === "--help" || arg === "-h");
528
589
  const fn = runtime.catalog.functions.find((candidate) => candidate.name === name);
@@ -547,13 +608,13 @@ async function exportWorkflowCli(rawArgs, options) {
547
608
  options.write(exportUsage());
548
609
  return args.length ? 0 : 1;
549
610
  }
550
- const workflowName = args[0];
611
+ const workflowName = requiredArg(args, 0);
551
612
  return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
552
613
  let name;
553
614
  let output;
554
615
  let force = false;
555
616
  for (let index = 1; index < args.length; index += 1) {
556
- const arg = args[index];
617
+ const arg = requiredArg(args, index);
557
618
  if (arg === "--force") {
558
619
  force = true;
559
620
  continue;
@@ -605,7 +666,7 @@ async function bundleWorkflowCli(rawArgs, options) {
605
666
  options.write(bundleUsage());
606
667
  return args.length ? 0 : 1;
607
668
  }
608
- const workflowName = args[0];
669
+ const workflowName = requiredArg(args, 0);
609
670
  return withWorkflowRuntime({ ...options, ...(parsed.trustOverride !== undefined ? { trustOverride: parsed.trustOverride } : {}) }, async (runtime) => {
610
671
  let name;
611
672
  let output;
@@ -613,7 +674,7 @@ async function bundleWorkflowCli(rawArgs, options) {
613
674
  const requirements = { roles: [], aliases: [], tools: [], commands: [], environment: [] };
614
675
  const resources = { extensions: [], skills: [], static: [], dependencies: [] };
615
676
  for (let index = 1; index < args.length; index += 1) {
616
- const arg = args[index];
677
+ const arg = requiredArg(args, index);
617
678
  if (arg === "--force") {
618
679
  force = true;
619
680
  continue;
@@ -622,7 +683,7 @@ async function bundleWorkflowCli(rawArgs, options) {
622
683
  const option = equals >= 0 ? arg.slice(0, equals) : arg;
623
684
  const requirementOptions = { "--role": "roles", "--alias": "aliases", "--tool": "tools", "--command": "commands", "--environment": "environment" };
624
685
  const resourceOptions = { "--extension": "extensions", "--skill": "skills", "--resource": "static", "--dependency": "dependencies" };
625
- if (option === "--name" || option === "--output" || Object.prototype.hasOwnProperty.call(requirementOptions, option) || Object.prototype.hasOwnProperty.call(resourceOptions, option)) {
686
+ if (option === "--name" || option === "--output" || typedOptionKey(requirementOptions, option) || typedOptionKey(resourceOptions, option)) {
626
687
  const value = equals >= 0 ? arg.slice(equals + 1) : args[++index];
627
688
  if (!value)
628
689
  throw new Error(`Missing value for ${option}`);
@@ -630,9 +691,9 @@ async function bundleWorkflowCli(rawArgs, options) {
630
691
  name = value;
631
692
  else if (option === "--output")
632
693
  output = value;
633
- else if (Object.prototype.hasOwnProperty.call(requirementOptions, option))
694
+ else if (typedOptionKey(requirementOptions, option))
634
695
  requirements[requirementOptions[option]].push(value);
635
- else
696
+ else if (typedOptionKey(resourceOptions, option))
636
697
  resources[resourceOptions[option]].push(value);
637
698
  continue;
638
699
  }
@@ -674,10 +735,21 @@ async function bundleWorkflowCli(rawArgs, options) {
674
735
  }
675
736
  export async function runCli(args, options = {}, write = (text) => { process.stdout.write(text); }) {
676
737
  const stderr = options.stderr ?? ((text) => { process.stderr.write(text); });
677
- if (args[0] === "doctor" && args.length === 1) {
678
- const report = await doctor(options);
679
- write(formatDoctorReport(report));
680
- return doctorExitCode(report);
738
+ if (args[0] === "doctor" && args[1] !== "cleanup") {
739
+ if (args.slice(1).some((arg) => arg === "--help" || arg === "-h")) {
740
+ write("Usage: piewf doctor [role] [--role <role>] [--prompt <text>]\n");
741
+ return 0;
742
+ }
743
+ try {
744
+ const parsed = parseDoctorArgs(args.slice(1));
745
+ const report = await doctor({ ...options, ...parsed });
746
+ write(formatDoctorReport(report));
747
+ return doctorExitCode(report);
748
+ }
749
+ catch (error) {
750
+ stderr(`Error: ${error instanceof Error ? error.message : String(error)}\n`);
751
+ return 1;
752
+ }
681
753
  }
682
754
  if (args[0] === "doctor" && args[1] === "cleanup") {
683
755
  if (args.slice(2).some((arg) => arg === "--help" || arg === "-h")) {
@@ -712,10 +784,11 @@ export async function runCli(args, options = {}, write = (text) => { process.std
712
784
  }
713
785
  if (args[0] === "transcript" && args.length === 2) {
714
786
  try {
787
+ const transcript = requiredArg(args, 1);
715
788
  if (options.transcript)
716
- await options.transcript(args[1]);
789
+ await options.transcript(transcript);
717
790
  else
718
- write(`${transcriptFileLines(args[1]).join("\n")}\n`);
791
+ write(`${transcriptFileLines(transcript).join("\n")}\n`);
719
792
  return 0;
720
793
  }
721
794
  catch (error) {
@@ -735,7 +808,7 @@ export async function runCli(args, options = {}, write = (text) => { process.std
735
808
  return 1;
736
809
  }
737
810
  }
738
- write("Usage: piewf doctor | inspect [session-id] [--json|--summary] [--failed] | transcript <session-file> | bundle <workflow-name> [--name <command>] [--output <path>] [--force] | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force] [--bundle]\n");
811
+ write("Usage: piewf doctor [role] [--role <role>] [--prompt <text>] | inspect [session-id] [--json|--summary] [--failed] | transcript <session-file> | bundle <workflow-name> [--name <command>] [--output <path>] [--force] | run <workflow-name> [workflow arguments] | export <workflow-name> [--name <command>] [--output <path>] [--force] [--bundle]\n");
739
812
  return 1;
740
813
  }
741
814
  if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
@@ -4,7 +4,7 @@ import { homedir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import { validateBudget } from "pi-extensible-workflows";
6
6
  import { RUN_STATES } from "pi-extensible-workflows";
7
- import { jsonValue, validateModelAliases } from "pi-extensible-workflows";
7
+ import { isNodeError, jsonValue, validateModelAliases } from "pi-extensible-workflows";
8
8
  import { validateSchema } from "pi-extensible-workflows";
9
9
  import { acquireSessionLease, hasLiveSessionLease, projectSessionsDirectory, RunStore } from "pi-extensible-workflows/persistence";
10
10
  const TERMINAL_STATES = new Set(["completed", "failed", "stopped"]);
@@ -18,6 +18,13 @@ const SCHEDULER_STATES = new Set(["queued", "running", "waiting_for_child", "pau
18
18
  const BUDGET_DIMENSIONS = new Set(["tokens", "costUsd", "durationMs", "agentLaunches"]);
19
19
  const BUDGET_EVENT_TYPES = new Set(["soft_crossed", "hard_overrun", "hard_exhausted", "adjustment_requested", "adjustment_approved", "adjustment_rejected"]);
20
20
  function object(value) { return typeof value === "object" && value !== null && !Array.isArray(value); }
21
+ function isList(value) { return Array.isArray(value); }
22
+ function isThinking(value) { return ["off", "minimal", "low", "medium", "high", "xhigh", "max"].some((candidate) => candidate === value); }
23
+ function isRunState(value) { return RUN_STATES.some((candidate) => candidate === value); }
24
+ function isAgentState(value) { return typeof value === "string" && AGENT_STATES.has(value); }
25
+ function isSchedulerState(value) { return typeof value === "string" && SCHEDULER_STATES.has(value); }
26
+ function isToolCallState(value) { return ["running", "completed", "failed"].some((candidate) => candidate === value); }
27
+ function isActivityKind(value) { return ["reasoning", "tool", "text"].some((candidate) => candidate === value); }
21
28
  function textError(error) { return error instanceof Error ? error.message : String(error); }
22
29
  function positiveDays(value) { if (!Number.isSafeInteger(value) || value < 1 || !Number.isFinite(value * DAY_MS))
23
30
  throw new Error("older-than-days must be a positive integer"); return value; }
@@ -26,17 +33,17 @@ function sameNames(left, right) { return left.length === right.length && left.ev
26
33
  async function jsonFile(path) { return JSON.parse(await readFile(path, "utf8")); }
27
34
  async function requiredFile(path) { const info = await lstat(path); if (!info.isFile())
28
35
  throw new Error(`Required artifact is not a regular file: ${path}`); }
29
- function stringList(value, label, nonEmpty = false) { if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || (nonEmpty && !item)))
36
+ function stringList(value, label, nonEmpty = false) { if (!isList(value) || value.some((item) => typeof item !== "string" || (nonEmpty && !item)))
30
37
  throw new Error(`${label} is invalid`); }
31
38
  function optionalString(value, label) { if (value !== undefined && typeof value !== "string")
32
39
  throw new Error(`${label} is invalid`); }
33
- function nonNegativeInteger(value, label) { if (!Number.isSafeInteger(value) || value < 0)
40
+ function nonNegativeInteger(value, label) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)
34
41
  throw new Error(`${label} is invalid`); }
35
- function positiveInteger(value, label) { if (!Number.isSafeInteger(value) || value < 1)
42
+ function positiveInteger(value, label) { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1)
36
43
  throw new Error(`${label} is invalid`); }
37
44
  function finiteNumber(value, label) { if (typeof value !== "number" || !Number.isFinite(value) || value < 0)
38
45
  throw new Error(`${label} is invalid`); }
39
- function model(value, label) { if (!object(value) || typeof value.provider !== "string" || !value.provider || typeof value.model !== "string" || !value.model || (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking)))
46
+ function model(value, label) { if (!object(value) || typeof value.provider !== "string" || !value.provider || typeof value.model !== "string" || !value.model || (value.thinking !== undefined && !isThinking(value.thinking)))
40
47
  throw new Error(`${label} is invalid`); }
41
48
  function accounting(value, label) { if (!object(value))
42
49
  throw new Error(`${label} is invalid`); for (const key of ["input", "output", "cacheRead", "cacheWrite", "cost"])
@@ -47,26 +54,48 @@ function resourceExclusions(value, label) { if (value === undefined)
47
54
  stringList(value.skills, `${label}.skills`); if (value.extensions !== undefined)
48
55
  stringList(value.extensions, `${label}.extensions`); }
49
56
  function agentDefinition(value, label) { if (!object(value))
50
- throw new Error(`${label} is invalid`); optionalString(value.prompt, `${label}.prompt`); optionalString(value.description, `${label}.description`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking))
57
+ throw new Error(`${label} is invalid`); optionalString(value.prompt, `${label}.prompt`); optionalString(value.description, `${label}.description`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !isThinking(value.thinking))
51
58
  throw new Error(`${label}.thinking is invalid`); if (value.tools !== undefined)
52
59
  stringList(value.tools, `${label}.tools`); resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`); }
60
+ function optionalRole(value, label) {
61
+ if (value === undefined)
62
+ return;
63
+ if (typeof value === "string") {
64
+ if (!value.trim())
65
+ throw new Error(`${label} is invalid`);
66
+ return;
67
+ }
68
+ if (!object(value) || typeof value.name !== "string" || !value.name.trim())
69
+ throw new Error(`${label} is invalid`);
70
+ for (const key of ["model", "description"])
71
+ if (value[key] !== undefined && value[key] !== null && typeof value[key] !== "string")
72
+ throw new Error(`${label}.${key} is invalid`);
73
+ if (value.thinking !== undefined && value.thinking !== null && !isThinking(value.thinking))
74
+ throw new Error(`${label}.thinking is invalid`);
75
+ if (value.tools !== undefined && value.tools !== null)
76
+ stringList(value.tools, `${label}.tools`);
77
+ if (value.overrideSystemPrompt !== undefined && value.overrideSystemPrompt !== null && typeof value.overrideSystemPrompt !== "boolean")
78
+ throw new Error(`${label}.overrideSystemPrompt is invalid`);
79
+ if (value.contextFiles !== undefined && value.contextFiles !== null)
80
+ stringList(value.contextFiles, `${label}.contextFiles`);
81
+ if (value.disabledAgentResources !== undefined && value.disabledAgentResources !== null)
82
+ resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`);
83
+ }
53
84
  function validateScheduledOptions(value, label) { if (!object(value) || typeof value.label !== "string" || !value.label || typeof value.cwd !== "string" || !value.cwd)
54
- throw new Error(`${label} is invalid`); optionalString(value.requestedLabel, `${label}.requestedLabel`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); stringList(value.tools, `${label}.tools`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !["off", "minimal", "low", "medium", "high", "xhigh", "max"].includes(value.thinking))
55
- throw new Error(`${label}.thinking is invalid`); optionalString(value.role, `${label}.role`); if (value.schema !== undefined)
85
+ throw new Error(`${label} is invalid`); optionalString(value.requestedLabel, `${label}.requestedLabel`); optionalString(value.parentBreadcrumb, `${label}.parentBreadcrumb`); stringList(value.tools, `${label}.tools`); optionalString(value.worktreeOwner, `${label}.worktreeOwner`); optionalString(value.model, `${label}.model`); if (value.thinking !== undefined && !isThinking(value.thinking))
86
+ throw new Error(`${label}.thinking is invalid`); optionalRole(value.role, `${label}.role`); if (value.schema !== undefined)
56
87
  validateSchema(value.schema, `${label}.schema`); if (value.retries !== undefined)
57
88
  nonNegativeInteger(value.retries, `${label}.retries`); if (value.timeoutMs !== undefined && value.timeoutMs !== null)
58
89
  positiveInteger(value.timeoutMs, `${label}.timeoutMs`); if (value.agentOptions !== undefined && (!object(value.agentOptions) || !jsonValue(value.agentOptions)))
59
90
  throw new Error(`${label}.agentOptions is invalid`); if (value.agentIdentity !== undefined) {
60
- if (!object(value.agentIdentity) || !Array.isArray(value.agentIdentity.structuralPath) || value.agentIdentity.structuralPath.some((part) => typeof part !== "string") || typeof value.agentIdentity.callSite !== "string")
91
+ if (!object(value.agentIdentity) || !isList(value.agentIdentity.structuralPath) || value.agentIdentity.structuralPath.some((part) => typeof part !== "string") || typeof value.agentIdentity.callSite !== "string")
61
92
  throw new Error(`${label}.agentIdentity is invalid`);
62
93
  positiveInteger(value.agentIdentity.occurrence, `${label}.agentIdentity.occurrence`);
63
94
  optionalString(value.agentIdentity.parentBreadcrumb, `${label}.agentIdentity.parentBreadcrumb`);
64
95
  optionalString(value.agentIdentity.worktreeOwner, `${label}.agentIdentity.worktreeOwner`);
65
96
  } }
66
- function validateSessionReference(value, label) {
67
- if (!object(value) || typeof value.transport !== "string" || !value.transport || typeof value.sessionId !== "string" || !value.sessionId || (value.locator !== undefined && !jsonValue(value.locator)))
68
- throw new Error(`${label} is invalid`);
69
- }
97
+ function validateSessionReference(value, label) { if (!object(value) || typeof value.transport !== "string" || !value.transport || typeof value.sessionId !== "string" || !value.sessionId || (value.locator !== undefined && !jsonValue(value.locator)))
98
+ throw new Error(`${label} is invalid`); }
70
99
  function validateAgentSetup(value, label) {
71
100
  if (!object(value))
72
101
  throw new Error(`${label} is invalid`);
@@ -78,7 +107,7 @@ function validateAgentSetup(value, label) {
78
107
  resourceExclusions(value.disabledAgentResources, `${label}.disabledAgentResources`);
79
108
  }
80
109
  function validateAgent(value, label) {
81
- if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name || typeof value.path !== "string" || !value.path || typeof value.state !== "string" || !AGENT_STATES.has(value.state))
110
+ if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.name !== "string" || !value.name || typeof value.path !== "string" || !value.path || !isAgentState(value.state))
82
111
  throw new Error(`${label} is invalid`);
83
112
  optionalString(value.systemPrompt, `${label}.systemPrompt`);
84
113
  optionalString(value.prompt, `${label}.prompt`);
@@ -94,7 +123,7 @@ function validateAgent(value, label) {
94
123
  stringList(value.tools, `${label}.tools`);
95
124
  nonNegativeInteger(value.attempts, `${label}.attempts`);
96
125
  if (value.attemptDetails !== undefined) {
97
- if (!Array.isArray(value.attemptDetails))
126
+ if (!isList(value.attemptDetails))
98
127
  throw new Error(`${label}.attemptDetails is invalid`);
99
128
  for (const [index, attempt] of value.attemptDetails.entries()) {
100
129
  const at = `${label}.attemptDetails[${String(index)}]`;
@@ -102,8 +131,9 @@ function validateAgent(value, label) {
102
131
  throw new Error(`${at} is invalid`);
103
132
  validateAgentSetup(attempt.setup, `${at}.setup`);
104
133
  if (attempt.session !== undefined) {
105
- validateSessionReference(attempt.session, `${at}.session`);
106
- if (attempt.session.transport !== attempt.transport)
134
+ const session = attempt.session;
135
+ validateSessionReference(session, `${at}.session`);
136
+ if (session.transport !== attempt.transport)
107
137
  throw new Error(`${at}.session transport does not match attempt transport`);
108
138
  }
109
139
  accounting(attempt.accounting, `${at}.accounting`);
@@ -114,14 +144,14 @@ function validateAgent(value, label) {
114
144
  if (value.accounting !== undefined)
115
145
  accounting(value.accounting, `${label}.accounting`);
116
146
  if (value.toolCalls !== undefined) {
117
- if (!Array.isArray(value.toolCalls))
147
+ if (!isList(value.toolCalls))
118
148
  throw new Error(`${label}.toolCalls is invalid`);
119
149
  for (const [index, call] of value.toolCalls.entries())
120
- if (!object(call) || typeof call.id !== "string" || !call.id || typeof call.name !== "string" || !call.name || !["running", "completed", "failed"].includes(call.state))
150
+ if (!object(call) || typeof call.id !== "string" || !call.id || typeof call.name !== "string" || !call.name || !isToolCallState(call.state))
121
151
  throw new Error(`${label}.toolCalls[${String(index)}] is invalid`);
122
152
  }
123
153
  if (value.activity !== undefined) {
124
- if (!object(value.activity) || !["reasoning", "tool", "text"].includes(value.activity.kind) || typeof value.activity.text !== "string")
154
+ if (!object(value.activity) || !isActivityKind(value.activity.kind) || typeof value.activity.text !== "string")
125
155
  throw new Error(`${label}.activity is invalid`);
126
156
  }
127
157
  if (value.lastEventAt !== undefined)
@@ -131,20 +161,18 @@ function validateUsage(value, label) { if (!object(value))
131
161
  throw new Error(`${label} is invalid`); for (const key of ["tokens", "costUsd", "durationMs", "agentLaunches"])
132
162
  finiteNumber(value[key], `${label}.${key}`); }
133
163
  function validateBudgetEvents(value) { if (value === undefined)
134
- return; if (!Array.isArray(value))
164
+ return; if (!isList(value))
135
165
  throw new Error("Persisted budget events are invalid"); for (const [index, event] of value.entries()) {
136
166
  const label = `budgetEvents[${String(index)}]`;
137
- if (!object(event) || typeof event.type !== "string" || !BUDGET_EVENT_TYPES.has(event.type) || !Number.isSafeInteger(event.budgetVersion) || Number(event.budgetVersion) < 1 || !Array.isArray(event.dimensions) || event.dimensions.some((dimension) => typeof dimension !== "string" || !BUDGET_DIMENSIONS.has(dimension)) || typeof event.at !== "number" || !Number.isFinite(event.at) || event.limits === undefined)
167
+ if (!object(event) || typeof event.type !== "string" || !BUDGET_EVENT_TYPES.has(event.type) || !Number.isSafeInteger(event.budgetVersion) || Number(event.budgetVersion) < 1 || !isList(event.dimensions) || event.dimensions.some((dimension) => typeof dimension !== "string" || !BUDGET_DIMENSIONS.has(dimension)) || typeof event.at !== "number" || !Number.isFinite(event.at) || event.limits === undefined)
138
168
  throw new Error(`${label} is invalid`);
139
169
  validateUsage(event.usage, `${label}.usage`);
140
170
  validateBudget(event.limits);
141
171
  } }
142
- function validateRunRecord(run) {
143
- const value = run;
144
- if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.workflowName !== "string" || !value.workflowName || typeof value.cwd !== "string" || !value.cwd || typeof value.sessionId !== "string" || !value.sessionId || !RUN_STATES.includes(value.state) || !Array.isArray(value.agents) || !Array.isArray(value.agentSessions) || Object.hasOwn(value, "nativeSessions"))
172
+ function validateRunRecord(value) {
173
+ if (!object(value) || typeof value.id !== "string" || !value.id || typeof value.workflowName !== "string" || !value.workflowName || typeof value.cwd !== "string" || !value.cwd || typeof value.sessionId !== "string" || !value.sessionId || !isRunState(value.state) || !isList(value.agents) || !isList(value.agentSessions) || Object.hasOwn(value, "nativeSessions"))
145
174
  throw new Error("Persisted run state is invalid");
146
- const agents = value.agents;
147
- agents.forEach((agent, index) => { validateAgent(agent, `agents[${String(index)}]`); });
175
+ const agents = value.agents.map((agent, index) => { validateAgent(agent, `agents[${String(index)}]`); return agent; });
148
176
  const agentIds = new Set();
149
177
  for (const agent of agents) {
150
178
  const id = agent.id;
@@ -154,36 +182,36 @@ function validateRunRecord(run) {
154
182
  }
155
183
  for (const agent of agents) {
156
184
  const parentId = agent.parentId;
157
- if (parentId !== undefined && (typeof parentId !== "string" || !agentIds.has(parentId)))
185
+ if (parentId !== undefined && !agentIds.has(parentId))
158
186
  throw new Error("Persisted agent has a missing parent");
159
187
  const seen = new Set();
160
- let parent = typeof parentId === "string" ? parentId : undefined;
188
+ let parent = parentId;
161
189
  while (parent) {
162
190
  if (seen.has(parent))
163
191
  throw new Error("Persisted agent parent cycle");
164
192
  seen.add(parent);
165
193
  const parentAgent = agents.find((candidate) => candidate.id === parent);
166
- parent = typeof parentAgent?.parentId === "string" ? parentAgent.parentId : undefined;
194
+ parent = parentAgent?.parentId;
167
195
  }
168
196
  }
169
- const sessions = value.agentSessions;
170
- for (const [index, session] of sessions.entries())
197
+ for (const [index, session] of value.agentSessions.entries())
171
198
  validateSessionReference(session, `agentSessions[${String(index)}]`);
172
199
  optionalString(value.parentRunId, "Persisted parent run");
173
200
  optionalString(value.failedAt, "Persisted failed path");
174
- if (value.retry !== undefined) {
175
- if (!object(value.retry) || typeof value.retry.sourceRunId !== "string" || !value.retry.sourceRunId || typeof value.retry.lineageRootRunId !== "string" || !value.retry.lineageRootRunId)
201
+ const retry = value.retry;
202
+ if (retry !== undefined) {
203
+ if (!object(retry) || typeof retry.sourceRunId !== "string" || !retry.sourceRunId || typeof retry.lineageRootRunId !== "string" || !retry.lineageRootRunId)
176
204
  throw new Error("Persisted retry provenance is invalid");
177
- const sourceRunId = value.retry.sourceRunId;
178
- stringList(value.retry.completedPaths, "Persisted retry completed paths");
179
- stringList(value.retry.incompletePaths, "Persisted retry incomplete paths");
180
- stringList(value.retry.namedWorktrees, "Persisted retry named worktrees");
205
+ const sourceRunId = retry.sourceRunId;
206
+ stringList(retry.completedPaths, "Persisted retry completed paths");
207
+ stringList(retry.incompletePaths, "Persisted retry incomplete paths");
208
+ stringList(retry.namedWorktrees, "Persisted retry named worktrees");
181
209
  if (value.parentRunId !== sourceRunId)
182
210
  throw new Error("Persisted retry parent does not match its source");
183
211
  }
184
212
  optionalString(value.phase, "Persisted phase");
185
213
  if (value.phaseHistory !== undefined) {
186
- if (!Array.isArray(value.phaseHistory))
214
+ if (!isList(value.phaseHistory))
187
215
  throw new Error("Persisted phase history is invalid");
188
216
  for (const phase of value.phaseHistory) {
189
217
  if (!object(phase) || typeof phase.phase !== "string" || !phase.phase)
@@ -191,6 +219,18 @@ function validateRunRecord(run) {
191
219
  nonNegativeInteger(phase.afterAgent, "Persisted phase history afterAgent");
192
220
  }
193
221
  }
222
+ if (value.phaseHistoryIndex !== undefined)
223
+ nonNegativeInteger(value.phaseHistoryIndex, "Persisted phase history index");
224
+ if (value.activeShellsByPhase !== undefined) {
225
+ if (!isList(value.activeShellsByPhase))
226
+ throw new Error("Persisted phase shell activity is invalid");
227
+ for (const activity of value.activeShellsByPhase) {
228
+ if (!object(activity) || !Number.isSafeInteger(activity.phaseIndex) || Number(activity.phaseIndex) < -1)
229
+ throw new Error("Persisted phase shell activity is invalid");
230
+ positiveInteger(activity.active, "Persisted phase shell activity count");
231
+ finiteNumber(activity.startedAt, "Persisted phase shell activity start");
232
+ }
233
+ }
194
234
  if (value.error !== undefined && (!object(value.error) || typeof value.error.code !== "string" || typeof value.error.message !== "string"))
195
235
  throw new Error("Persisted run error is invalid");
196
236
  validateBudget(value.budget);
@@ -200,7 +240,7 @@ function validateRunRecord(run) {
200
240
  validateUsage(value.usage, "Persisted usage");
201
241
  validateBudgetEvents(value.budgetEvents);
202
242
  if (value.events !== undefined) {
203
- if (!Array.isArray(value.events))
243
+ if (!isList(value.events))
204
244
  throw new Error("Persisted run events are invalid");
205
245
  for (const event of value.events)
206
246
  if (!object(event) || typeof event.type !== "string" || typeof event.message !== "string")
@@ -208,7 +248,7 @@ function validateRunRecord(run) {
208
248
  }
209
249
  }
210
250
  function validateSnapshot(snapshot) {
211
- if (!object(snapshot) || typeof snapshot.script !== "string" || !snapshot.script || !jsonValue(snapshot.args) || !object(snapshot.metadata) || typeof snapshot.metadata.name !== "string" || !snapshot.metadata.name || (snapshot.metadata.description !== undefined && typeof snapshot.metadata.description !== "string") || !object(snapshot.settings) || !Number.isSafeInteger(snapshot.settings.concurrency) || Number(snapshot.settings.concurrency) < 1 || Number(snapshot.settings.concurrency) > 16 || !Array.isArray(snapshot.models) || snapshot.models.some((modelName) => typeof modelName !== "string") || !Array.isArray(snapshot.tools) || snapshot.tools.some((tool) => typeof tool !== "string") || !Array.isArray(snapshot.agentTypes) || snapshot.agentTypes.some((agentType) => typeof agentType !== "string") || !Array.isArray(snapshot.schemas))
251
+ if (!object(snapshot) || typeof snapshot.script !== "string" || !snapshot.script || !jsonValue(snapshot.args) || !object(snapshot.metadata) || typeof snapshot.metadata.name !== "string" || !snapshot.metadata.name || (snapshot.metadata.description !== undefined && typeof snapshot.metadata.description !== "string") || !object(snapshot.settings) || !Number.isSafeInteger(snapshot.settings.concurrency) || Number(snapshot.settings.concurrency) < 1 || Number(snapshot.settings.concurrency) > 16 || !isList(snapshot.models) || snapshot.models.some((modelName) => typeof modelName !== "string") || !isList(snapshot.tools) || snapshot.tools.some((tool) => typeof tool !== "string") || !isList(snapshot.agentTypes) || snapshot.agentTypes.some((agentType) => typeof agentType !== "string") || !isList(snapshot.schemas))
212
252
  throw new Error("Persisted launch snapshot is invalid");
213
253
  if (snapshot.identityVersion !== undefined)
214
254
  positiveInteger(snapshot.identityVersion, "Persisted snapshot identity version");
@@ -254,7 +294,7 @@ async function validateSystemPrompts(store) {
254
294
  await store.systemPrompts();
255
295
  return;
256
296
  }
257
- if (!object(value) || value.version !== 1 || !Array.isArray(value.entries))
297
+ if (!object(value) || value.version !== 1 || !isList(value.entries))
258
298
  throw new Error("Persisted system prompts are invalid");
259
299
  for (const [index, entry] of value.entries.entries()) {
260
300
  const label = `system-prompts.entries[${String(index)}]`;
@@ -276,6 +316,13 @@ async function validateRunDirectory(store) {
276
316
  throw new Error(`Run artifact is not a regular file: ${join(store.directory, entry.name)}`);
277
317
  }
278
318
  }
319
+ function validateOwnershipRecord(value, label, ownershipIds) {
320
+ if (!object(value) || typeof value.id !== "string" || !value.id || ownershipIds.has(value.id) || typeof value.label !== "string" || !value.label || !isSchedulerState(value.state))
321
+ throw new Error(`${label} is invalid`);
322
+ optionalString(value.parentId, `${label}.parentId`);
323
+ optionalString(value.prompt, `${label}.prompt`);
324
+ validateScheduledOptions(value.options, `${label}.options`);
325
+ }
279
326
  async function validateRunArtifacts(store, workflowScript, state) {
280
327
  await validateRunDirectory(store);
281
328
  for (const name of REQUIRED_RUN_FILES)
@@ -283,7 +330,7 @@ async function validateRunArtifacts(store, workflowScript, state) {
283
330
  if (await readFile(join(store.directory, "workflow.js"), "utf8") !== workflowScript)
284
331
  throw new Error("Persisted workflow source does not match its launch snapshot");
285
332
  await validateSystemPrompts(store);
286
- const result = await jsonFile(join(store.directory, "result.json")).catch((error) => { if (error.code === "ENOENT")
333
+ const result = await jsonFile(join(store.directory, "result.json")).catch((error) => { if (isNodeError(error, "ENOENT"))
287
334
  return undefined; throw error; });
288
335
  if (result === undefined && state === "completed")
289
336
  throw new Error("Completed run result is missing");
@@ -291,21 +338,17 @@ async function validateRunArtifacts(store, workflowScript, state) {
291
338
  throw new Error("Persisted workflow result is invalid");
292
339
  validateJournal(await jsonFile(join(store.directory, "journal.json")));
293
340
  const rawOwnership = await jsonFile(join(store.directory, "ownership.json"));
294
- if (!Array.isArray(rawOwnership))
341
+ if (!isList(rawOwnership))
295
342
  throw new Error("Persisted ownership records are invalid");
296
- const ownership = rawOwnership;
297
343
  const ownershipIds = new Set();
298
- for (const [index, record] of ownership.entries()) {
344
+ const ownership = rawOwnership.map((record, index) => {
299
345
  const label = `ownership[${String(index)}]`;
300
- if (!object(record) || typeof record.id !== "string" || !record.id || ownershipIds.has(record.id) || typeof record.label !== "string" || !record.label || typeof record.state !== "string" || !SCHEDULER_STATES.has(record.state))
301
- throw new Error(`${label} is invalid`);
346
+ validateOwnershipRecord(record, label, ownershipIds);
302
347
  ownershipIds.add(record.id);
303
- optionalString(record.parentId, `${label}.parentId`);
304
- optionalString(record.prompt, `${label}.prompt`);
305
- validateScheduledOptions(record.options, `${label}.options`);
306
- }
348
+ return record;
349
+ });
307
350
  for (const record of ownership)
308
- if (object(record) && record.parentId !== undefined && (typeof record.parentId !== "string" || !ownershipIds.has(record.parentId)))
351
+ if (record.parentId !== undefined && !ownershipIds.has(record.parentId))
309
352
  throw new Error("Persisted ownership parent is missing");
310
353
  await store.validateDeletionWorktrees();
311
354
  const borrowed = await store.borrowedWorktrees();
@@ -443,7 +486,7 @@ function planSession(scan, cutoffMs) {
443
486
  }
444
487
  return { candidates: scan.runs.filter(({ runId }) => oldTerminal.has(runId) && !protectedRuns.has(runId)), skipped };
445
488
  }
446
- function deletionOrder(scan, candidates) {
489
+ function deletionOrder(_scan, candidates) {
447
490
  const candidateIds = new Set(candidates.map(({ runId }) => runId));
448
491
  const remaining = new Set(candidateIds);
449
492
  const ordered = [];
@@ -463,7 +506,7 @@ async function storedSessionIds(cwd, home) {
463
506
  entries = await sessionEntries(path);
464
507
  }
465
508
  catch (error) {
466
- if (error.code === "ENOENT")
509
+ if (isNodeError(error, "ENOENT"))
467
510
  return [];
468
511
  throw error;
469
512
  }
@@ -27,8 +27,42 @@ export interface DoctorTrust {
27
27
  trusted: boolean;
28
28
  source: string;
29
29
  }
30
+ export interface DoctorRoleInspection {
31
+ role: string;
32
+ path: string;
33
+ model: {
34
+ provider: string;
35
+ model: string;
36
+ thinking?: string;
37
+ inherited?: boolean;
38
+ };
39
+ tools: readonly string[];
40
+ resources: {
41
+ skills: readonly string[];
42
+ extensions: readonly string[];
43
+ excludedSkills: readonly string[];
44
+ excludedExtensions: readonly string[];
45
+ unmatchedSkills: readonly string[];
46
+ unmatchedExtensions: readonly string[];
47
+ };
48
+ systemPrompt: {
49
+ probe: string;
50
+ expandedProbe: string;
51
+ text: string;
52
+ source?: string;
53
+ };
54
+ setup: {
55
+ hooks: readonly string[];
56
+ diagnostics: readonly DoctorDiagnostic[];
57
+ };
58
+ }
30
59
  export interface DoctorPiState {
31
60
  trust: DoctorTrust;
61
+ model?: {
62
+ provider: string;
63
+ model: string;
64
+ thinking?: string;
65
+ };
32
66
  activeTools: readonly string[];
33
67
  knownModels: readonly string[];
34
68
  availableModels: readonly string[];
@@ -48,16 +82,22 @@ export interface DoctorReport {
48
82
  settingsSources: WorkflowSettingsSources;
49
83
  trust: DoctorTrust;
50
84
  activeTools: readonly string[];
85
+ piExtensions: readonly string[];
86
+ piSkills: readonly string[];
51
87
  roles: readonly DoctorRole[];
52
88
  functions: readonly DoctorFunction[];
53
89
  resourcePolicy: AgentResourcePolicy;
54
90
  modelAliases: readonly WorkflowCatalogModelAlias[];
91
+ roleTarget?: string;
92
+ roleInspection?: DoctorRoleInspection;
55
93
  diagnostics: readonly DoctorDiagnostic[];
56
94
  }
57
95
  export interface DoctorOptions {
58
96
  cwd?: string;
59
97
  agentDir?: string;
60
98
  settingsPath?: string;
99
+ role?: string;
100
+ prompt?: string;
61
101
  discoverPi?: (cwd: string, agentDir: string) => Promise<DoctorPiState>;
62
102
  activeTools?: readonly string[];
63
103
  registry?: WorkflowRegistryApi;
@@ -1,8 +1,8 @@
1
- import { readFileSync, readdirSync, realpathSync } from "node:fs";
1
+ import { readFileSync, readdirSync, realpathSync, statSync } 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, resolveAgentResourcePolicy, resolveWorkflowSettings, resolveModelReference, parseThinking, parseRoleMarkdown, registeredWorkflowFunctions, registeredWorkflowRoleDirectoryRegistrations, workflowRoleDirectories, workflowProjectSettingsPath, workflowSettingsPath, } from "pi-extensible-workflows";
5
+ import { DEFAULT_SETTINGS, createLocalPiSession, errorText, isNodeError, isObject, loadSettings, prepareAgentSetupForInspection, resolveAgentResourcePolicy, resolveWorkflowSettings, resolveModelReference, parseThinking, parseRoleMarkdown, registeredWorkflowFunctions, registeredWorkflowRoleDirectoryRegistrations, workflowRoleDirectories, workflowProjectSettingsPath, workflowSettingsPath, } from "pi-extensible-workflows";
6
6
  import { loadingRegistry } from "pi-extensible-workflows";
7
7
  import { disabledResources, unmatchedResourcePatterns } from "pi-extensible-workflows";
8
8
  const THINKING_HINT = "Use off, minimal, low, medium, high, xhigh, or max.";
@@ -20,16 +20,23 @@ function isDynamicModelAlias(value, aliases) {
20
20
  const name = match?.[1];
21
21
  return Boolean(name && (match[2] === undefined || parseThinking(match[2]) !== undefined) && aliases.has(name));
22
22
  }
23
+ function isCredential(value) {
24
+ if (!isObject(value))
25
+ return false;
26
+ if (value.type === "api_key")
27
+ return (value.key === undefined || typeof value.key === "string") && (value.env === undefined || isObject(value.env) && Object.values(value.env).every((entry) => typeof entry === "string"));
28
+ return value.type === "oauth" && typeof value.refresh === "string" && typeof value.access === "string" && typeof value.expires === "number";
29
+ }
23
30
  async function readCredentials(agentDir) {
24
31
  const credentials = new InMemoryCredentialStore();
25
32
  try {
26
33
  const parsed = JSON.parse(readFileSync(join(agentDir, "auth.json"), "utf8"));
27
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
34
+ if (!isObject(parsed))
28
35
  throw new Error("Pi auth.json must be an object");
29
- await Promise.all(Object.entries(parsed).map(([provider, credential]) => credentials.modify(provider, async () => credential)));
36
+ await Promise.all(Object.entries(parsed).flatMap(([provider, credential]) => isCredential(credential) ? [credentials.modify(provider, async () => credential)] : []));
30
37
  }
31
38
  catch (error) {
32
- if (error.code !== "ENOENT")
39
+ if (!isNodeError(error, "ENOENT"))
33
40
  throw error;
34
41
  }
35
42
  return credentials;
@@ -40,11 +47,11 @@ function savedTrust(cwd, agentDir) {
40
47
  parsed = JSON.parse(readFileSync(join(agentDir, "trust.json"), "utf8"));
41
48
  }
42
49
  catch (error) {
43
- if (error.code === "ENOENT")
50
+ if (isNodeError(error, "ENOENT"))
44
51
  return undefined;
45
52
  throw error;
46
53
  }
47
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
54
+ if (!isObject(parsed))
48
55
  throw new Error("Pi trust.json must be an object");
49
56
  let current = canonical(cwd);
50
57
  while (current !== dirname(current)) {
@@ -86,6 +93,7 @@ async function discoverPi(cwd, agentDir) {
86
93
  const skills = services.resourceLoader.getSkills().skills;
87
94
  return {
88
95
  trust: { required, trusted, source },
96
+ model: { provider: model.provider, model: model.id, thinking: session.thinkingLevel },
89
97
  activeTools,
90
98
  knownModels: allModels.map(({ provider, id }) => `${provider}/${id}`),
91
99
  availableModels: availableModels.map(({ provider, id }) => `${provider}/${id}`),
@@ -105,12 +113,28 @@ async function discoverPi(cwd, agentDir) {
105
113
  process.env.PI_OFFLINE = previousOffline;
106
114
  }
107
115
  }
116
+ function isRoleFile(dir, entry) {
117
+ if (extname(entry.name) !== ".md")
118
+ return false;
119
+ if (entry.isFile())
120
+ return true;
121
+ if (!entry.isSymbolicLink())
122
+ return false;
123
+ try {
124
+ return statSync(join(dir, entry.name)).isFile();
125
+ }
126
+ catch (error) {
127
+ if (isNodeError(error, "ENOENT"))
128
+ return false;
129
+ throw error;
130
+ }
131
+ }
108
132
  function roleFiles(dir) {
109
133
  try {
110
- return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isFile() && extname(entry.name) === ".md").map((entry) => join(dir, entry.name)).sort();
134
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => isRoleFile(dir, entry)).map((entry) => join(dir, entry.name)).sort();
111
135
  }
112
136
  catch (error) {
113
- if (error.code === "ENOENT")
137
+ if (isNodeError(error, "ENOENT"))
114
138
  return [];
115
139
  throw error;
116
140
  }
@@ -127,7 +151,7 @@ function scanExtensionRoleFiles(registrations) {
127
151
  for (const registration of registrations) {
128
152
  try {
129
153
  const entries = readdirSync(registration.path, { withFileTypes: true });
130
- const roleFiles = entries.filter((entry) => entry.isFile() && extname(entry.name) === ".md");
154
+ const roleFiles = entries.filter((entry) => isRoleFile(registration.path, entry));
131
155
  if (!roleFiles.length)
132
156
  empty.push(registration);
133
157
  for (const entry of roleFiles)
@@ -160,7 +184,8 @@ function validateModel(value, known, available, source, diagnostics, aliases, dy
160
184
  diagnostics.push(diagnostic("warning", "MODEL_UNAVAILABLE", `Model is valid-shaped but unavailable: ${name}`, source));
161
185
  }
162
186
  catch (error) {
163
- diagnostics.push(diagnostic("error", "MODEL_INVALID", error.message, source, error.message.includes("thinking") ? THINKING_HINT : "Use provider/model or provider/model:thinking."));
187
+ const message = errorText(error);
188
+ diagnostics.push(diagnostic("error", "MODEL_INVALID", message, source, message.includes("thinking") ? THINKING_HINT : "Use provider/model or provider/model:thinking."));
164
189
  }
165
190
  }
166
191
  function inspectRole(path, activeTools, knownModels, availableModels, diagnostics, aliases, dynamicAliases, settingsPath, source) {
@@ -184,7 +209,7 @@ function inspectRole(path, activeTools, knownModels, availableModels, diagnostic
184
209
  validateModel(definition.model, knownModels, availableModels, path, diagnostics, aliases, dynamicAliases, settingsPath);
185
210
  for (const tool of definition.tools ?? [])
186
211
  if (!activeTools.has(tool))
187
- 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."));
212
+ diagnostics.push(diagnostic("error", "ROLE_TOOL_INACTIVE", `Tool is unknown or inactive: ${tool}`, path, "Use a tool listed under Pi active tools or enable its Pi extension."));
188
213
  return definition;
189
214
  }
190
215
  function matchResourcePolicy(policy, pi) {
@@ -192,6 +217,60 @@ function matchResourcePolicy(policy, pi) {
192
217
  const skills = [...new Set(pi.skills ?? [])];
193
218
  return { ...policy, excludedSkills: disabledResources(policy.effective.skills, skills), excludedExtensions: disabledResources(policy.effective.extensions, extensions), unmatchedSkills: unmatchedResourcePatterns(policy.effective.skills, skills), unmatchedExtensions: unmatchedResourcePatterns(policy.effective.extensions, extensions) };
194
219
  }
220
+ async function inspectRoleSession(cwd, agentDir, roleName, definition, rolePath, basePolicy, rootModel, activeTools, aliases, knownModels, availableModels, settingsPath, prompt, hooks, diagnostics) {
221
+ const setupDiagnostics = [];
222
+ const signal = new AbortController().signal;
223
+ const transport = { id: "doctor-local", createSession: async () => { throw new Error("Doctor inspection does not create transport sessions"); } };
224
+ const run = { cwd, sessionId: "doctor", runId: "doctor", workflow: { name: "doctor" }, args: null, signal };
225
+ const root = { cwd, model: { ...rootModel }, tools: new Set(activeTools), agentDefinitions: { [roleName]: definition }, agentDir, modelAliases: aliases, knownModels, availableModels, settingsPath, agentSetupHooks: hooks, agentResourcePolicy: () => structuredClone(basePolicy), runContext: run };
226
+ const options = { label: roleName, workflowName: "doctor", role: roleName };
227
+ let prepared;
228
+ try {
229
+ prepared = await prepareAgentSetupForInspection(root, prompt, options, transport);
230
+ }
231
+ catch (error) {
232
+ setupDiagnostics.push(diagnostic("error", "ROLE_INSPECTION", error instanceof Error ? error.message : String(error), rolePath));
233
+ diagnostics.push(...setupDiagnostics);
234
+ return undefined;
235
+ }
236
+ if (prepared.failure) {
237
+ const error = prepared.failure.error;
238
+ const code = prepared.failure.hook ? "ROLE_SETUP_HOOK" : "ROLE_INSPECTION";
239
+ setupDiagnostics.push(diagnostic("error", code, `${prepared.failure.hook ? `Role setup hook ${prepared.failure.hook} failed: ` : ""}${error instanceof Error ? error.message : String(error)}`, prepared.failure.hook ?? rolePath));
240
+ diagnostics.push(...setupDiagnostics);
241
+ return undefined;
242
+ }
243
+ const session = await (async () => { try {
244
+ return await createLocalPiSession({ ...prepared.setup.sessionInput, sessionManager: SessionManager.inMemory() });
245
+ }
246
+ catch (error) {
247
+ setupDiagnostics.push(diagnostic("error", "ROLE_INSPECTION", error instanceof Error ? error.message : String(error), rolePath));
248
+ return undefined;
249
+ } })();
250
+ if (!session) {
251
+ diagnostics.push(...setupDiagnostics);
252
+ return undefined;
253
+ }
254
+ try {
255
+ const promptResult = await session.preparePrompt(prompt);
256
+ const resources = session.getResourceInspection();
257
+ const state = session.agent?.state;
258
+ const inherited = prepared.setup.sessionInput.model.provider === rootModel.provider && prepared.setup.sessionInput.model.model === rootModel.model && prepared.setup.sessionInput.model.thinking === rootModel.thinking;
259
+ const actualModel = session.model?.provider && (session.model.model ?? session.model.id) ? { provider: session.model.provider, model: session.model.model ?? session.model.id ?? prepared.setup.sessionInput.model.model, ...(session.thinkingLevel ? { thinking: session.thinkingLevel } : {}), ...(inherited ? { inherited: true } : {}) } : { ...prepared.setup.sessionInput.model, ...(inherited ? { inherited: true } : {}) };
260
+ const policy = prepared.setup.sessionInput.resourcePolicy ?? basePolicy;
261
+ for (const item of [...resources.diagnostics, ...promptResult.diagnostics])
262
+ setupDiagnostics.push(diagnostic(item.type === "error" ? "error" : "warning", "ROLE_INSPECTION", item.message, item.source));
263
+ return { role: roleName, path: rolePath, model: actualModel, tools: state?.tools.map(({ name }) => name) ?? [...prepared.setup.sessionInput.tools], resources: { skills: resources.skills, extensions: resources.extensions, excludedSkills: policy.excludedSkills ?? [], excludedExtensions: policy.excludedExtensions ?? [], unmatchedSkills: policy.unmatchedSkills, unmatchedExtensions: policy.unmatchedExtensions }, systemPrompt: { probe: prompt, expandedProbe: promptResult.expandedPrompt, text: promptResult.systemPrompt, ...(resources.systemPromptSource ? { source: resources.systemPromptSource } : {}) }, setup: { hooks: prepared.summary.hookNames, diagnostics: setupDiagnostics } };
264
+ }
265
+ catch (error) {
266
+ setupDiagnostics.push(diagnostic("error", "ROLE_INSPECTION", error instanceof Error ? error.message : String(error), rolePath));
267
+ diagnostics.push(...setupDiagnostics);
268
+ return undefined;
269
+ }
270
+ finally {
271
+ await session.dispose();
272
+ }
273
+ }
195
274
  function resourcePolicySource(settingsSource) { return settingsSource; }
196
275
  export async function doctor(options = {}) {
197
276
  const cwd = canonical(options.cwd ?? process.cwd());
@@ -203,7 +282,7 @@ export async function doctor(options = {}) {
203
282
  settings = loadSettings(settingsPath);
204
283
  }
205
284
  catch (error) {
206
- diagnostics.push(diagnostic("error", "SETTINGS_INVALID", error.message, settingsPath, "Fix or remove the invalid workflow settings file."));
285
+ diagnostics.push(diagnostic("error", "SETTINGS_INVALID", errorText(error), settingsPath, "Fix or remove the invalid workflow settings file."));
207
286
  }
208
287
  let settingsSources = { concurrency: settingsPath, modelAliases: settingsPath, disabledAgentResources: settingsPath };
209
288
  const projectSettingsPath = workflowProjectSettingsPath(cwd);
@@ -212,7 +291,7 @@ export async function doctor(options = {}) {
212
291
  pi = await (options.discoverPi ?? discoverPi)(cwd, agentDir);
213
292
  }
214
293
  catch (error) {
215
- 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."));
294
+ diagnostics.push(diagnostic("error", "PI_DISCOVERY", `Pi headless discovery failed: ${errorText(error)}`, undefined, "Open and trust the project in Pi, fix extension errors, then rerun doctor."));
216
295
  pi = { trust: { required: false, trusted: false, source: "discovery failed" }, activeTools: [], knownModels: [], availableModels: [], extensionErrors: [], functions: {} };
217
296
  }
218
297
  if (options.activeTools)
@@ -256,7 +335,7 @@ export async function doctor(options = {}) {
256
335
  const dynamicAliases = new Set(registeredModelAliases.map(({ name }) => name).filter((name) => !Object.prototype.hasOwnProperty.call(aliases, name)));
257
336
  const modelAliases = [
258
337
  ...Object.keys(aliases).map((name) => ({ name, kind: "static", provenance: settingsSources.modelAliases })),
259
- ...registeredModelAliases.map(({ name, version, headline, extensionDescription }) => ({ name, kind: "dynamic", provenance: `extension: ${headline}`, version, headline, extensionDescription })),
338
+ ...registeredModelAliases.map(({ name, version, headline }) => ({ name, kind: "dynamic", provenance: `extension: ${headline}`, version, headline })),
260
339
  ].sort((left, right) => left.name.localeCompare(right.name) || left.kind.localeCompare(right.kind));
261
340
  const roles = [];
262
341
  const definitions = new Map();
@@ -335,6 +414,40 @@ export async function doctor(options = {}) {
335
414
  else
336
415
  definitions.delete(name);
337
416
  }
417
+ let roleInspection;
418
+ if (options.role !== undefined) {
419
+ const activeRole = roles.find(({ name, active }) => name === options.role && active);
420
+ const definition = activeRole ? definitions.get(options.role) : undefined;
421
+ if (!activeRole || !definition)
422
+ diagnostics.push(diagnostic("error", "ROLE_NOT_FOUND", `Active role not found: ${options.role}`, options.role));
423
+ else {
424
+ const rootReference = pi.model ? `${pi.model.provider}/${pi.model.model}` : pi.availableModels[0] ?? pi.knownModels[0];
425
+ if (!rootReference)
426
+ diagnostics.push(diagnostic("error", "ROLE_INSPECTION_MODEL", "Cannot inspect a role because Pi has no registered model"));
427
+ else {
428
+ let rootModel;
429
+ try {
430
+ if (pi.model) {
431
+ const thinking = parseThinking(pi.model.thinking);
432
+ rootModel = { provider: pi.model.provider, model: pi.model.model, ...(thinking ? { thinking } : {}) };
433
+ }
434
+ else
435
+ rootModel = resolveModelReference(rootReference, aliases, knownModels, settingsPath);
436
+ let roleAliases = aliases;
437
+ if (definition.model && isDynamicModelAlias(definition.model, dynamicAliases)) {
438
+ const dynamic = await registry.resolveModelAliases({ cwd, projectTrusted: pi.trust.trusted, rootModel, knownModels, availableModels, signal: new AbortController().signal });
439
+ roleAliases = { ...aliases, ...dynamic };
440
+ }
441
+ roleInspection = await inspectRoleSession(cwd, agentDir, options.role, definition, activeRole.path, resourcePolicy, rootModel, [...activeTools], roleAliases, knownModels, availableModels, settingsPath, options.prompt ?? "", registry.agentSetupHooks(), diagnostics);
442
+ if (roleInspection)
443
+ diagnostics.push(...roleInspection.setup.diagnostics);
444
+ }
445
+ catch (error) {
446
+ diagnostics.push(diagnostic("error", "ROLE_INSPECTION_MODEL", error instanceof Error ? error.message : String(error), activeRole.path));
447
+ }
448
+ }
449
+ }
450
+ }
338
451
  const functions = [];
339
452
  for (const [name, fn] of Object.entries(pi.functions).sort(([left], [right]) => left.localeCompare(right))) {
340
453
  functions.push({ name, description: fn.description, valid: true });
@@ -342,11 +455,55 @@ export async function doctor(options = {}) {
342
455
  const severityOrder = { error: 0, warning: 1 };
343
456
  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));
344
457
  roles.sort((left, right) => left.name.localeCompare(right.name) || left.scope.localeCompare(right.scope));
345
- return { cwd, agentDir, settingsPath, settings, settingsSources, trust: pi.trust, activeTools: [...activeTools].sort(), roles, functions, modelAliases, resourcePolicy, diagnostics };
458
+ return { cwd, agentDir, settingsPath, settings, settingsSources, trust: pi.trust, activeTools: [...activeTools].sort(), piExtensions: [...new Set((pi.extensions ?? []).map(canonical))].sort(), piSkills: [...new Set(pi.skills ?? [])].sort(), roles, functions, modelAliases, resourcePolicy, ...(options.role !== undefined ? { roleTarget: options.role } : {}), ...(roleInspection ? { roleInspection } : {}), diagnostics };
346
459
  }
347
460
  function count(report, severity) { return report.diagnostics.filter((item) => item.severity === severity).length; }
348
461
  export function doctorExitCode(report) { return count(report, "error") > 0 ? 1 : 0; }
462
+ function exposedValues(values, excluded) {
463
+ const disabled = new Set(excluded);
464
+ return values.filter((value) => !disabled.has(value));
465
+ }
466
+ function nestedValues(label, values) {
467
+ return [`- ${label}:`, ...(values.length ? values.map((value) => ` - \`${value}\``) : [" - (none)"])];
468
+ }
469
+ function roleInspectionLines(inspection) {
470
+ return [
471
+ `- Role: \`${inspection.role}\` - \`${inspection.path}\``,
472
+ `- Model: \`${inspection.model.provider}/${inspection.model.model}\` (${inspection.model.inherited ? "inherited, " : ""}${inspection.model.thinking ?? "off"})`,
473
+ ...nestedValues("Tools", inspection.tools),
474
+ ...nestedValues("Effective skills", inspection.resources.skills),
475
+ ...nestedValues("Effective extensions", inspection.resources.extensions),
476
+ ...nestedValues("Excluded skills", inspection.resources.excludedSkills),
477
+ ...nestedValues("Excluded extensions", inspection.resources.excludedExtensions),
478
+ ...nestedValues("Unmatched skills", inspection.resources.unmatchedSkills),
479
+ ...nestedValues("Unmatched extensions", inspection.resources.unmatchedExtensions),
480
+ `- Prompt probe: ${inspection.systemPrompt.probe ? JSON.stringify(inspection.systemPrompt.probe) : "empty"}`,
481
+ `- Expanded probe: ${JSON.stringify(inspection.systemPrompt.expandedProbe)}`,
482
+ `- System prompt source: ${inspection.systemPrompt.source ?? "(none)"}`,
483
+ "### Final system prompt",
484
+ "```",
485
+ inspection.systemPrompt.text,
486
+ "```",
487
+ ...nestedValues("Applied setup hooks", inspection.setup.hooks),
488
+ `- Setup diagnostics: ${String(inspection.setup.diagnostics.length)}`,
489
+ ];
490
+ }
349
491
  export function formatDoctorReport(report) {
492
+ if (report.roleInspection || report.roleTarget !== undefined) {
493
+ const lines = [
494
+ "# pi-extensible-workflows doctor",
495
+ "",
496
+ "## Role inspection",
497
+ ...(report.roleInspection ? roleInspectionLines(report.roleInspection) : [`- Role: \`${report.roleTarget ?? "(unknown)"}\``, "- Inspection unavailable"]),
498
+ "",
499
+ "## Diagnostics",
500
+ ...(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"]),
501
+ "",
502
+ "## Summary",
503
+ `- ${String(count(report, "error"))} error(s), ${String(count(report, "warning"))} warning(s)`,
504
+ ];
505
+ return `${lines.join("\n")}\n`;
506
+ }
350
507
  const lines = [
351
508
  "# pi-extensible-workflows doctor",
352
509
  "",
@@ -361,7 +518,16 @@ export function formatDoctorReport(report) {
361
518
  "## Trust/resources",
362
519
  `- [${report.trust.trusted ? "ok" : "warning"}] ${report.trust.source}`,
363
520
  "",
364
- "## Agent resource exclusions",
521
+ "## Pi active tools",
522
+ ...(report.activeTools.length ? report.activeTools.map((tool) => `- \`${tool}\``) : ["- None resolved"]),
523
+ "",
524
+ "## Pi active extensions",
525
+ ...(report.piExtensions.length ? report.piExtensions.map((extension) => `- \`${extension}\``) : ["- None resolved"]),
526
+ "",
527
+ "## Pi active skills",
528
+ ...(report.piSkills.length ? report.piSkills.map((skill) => `- \`${skill}\``) : ["- None resolved"]),
529
+ "",
530
+ "## Workflow agent resources (after disabledAgentResources)",
365
531
  `- Global settings: \`${report.resourcePolicy.globalSettingsPath}\``,
366
532
  `- Global skills: ${report.resourcePolicy.global.skills.join(", ") || "(none)"}`,
367
533
  `- Global extensions: ${report.resourcePolicy.global.extensions.join(", ") || "(none)"}`,
@@ -370,14 +536,13 @@ export function formatDoctorReport(report) {
370
536
  `- Project extensions: ${report.resourcePolicy.project.extensions.join(", ") || "(none)"}`,
371
537
  `- Effective skills: ${report.resourcePolicy.effective.skills.join(", ") || "(none)"}`,
372
538
  `- Effective extensions: ${report.resourcePolicy.effective.extensions.join(", ") || "(none)"}`,
373
- `- Excluded skills: ${report.resourcePolicy.excludedSkills?.join(", ") || "(none)"}`,
374
- `- Excluded extensions: ${report.resourcePolicy.excludedExtensions?.join(", ") || "(none)"}`,
539
+ ...nestedValues("Exposed skills", exposedValues(report.piSkills, report.resourcePolicy.excludedSkills ?? [])),
540
+ ...nestedValues("Exposed extensions", exposedValues(report.piExtensions, report.resourcePolicy.excludedExtensions ?? [])),
541
+ ...nestedValues("Disabled skills", report.resourcePolicy.excludedSkills ?? []),
542
+ ...nestedValues("Disabled extensions", report.resourcePolicy.excludedExtensions ?? []),
375
543
  `- Unmatched skills: ${report.resourcePolicy.unmatchedSkills.join(", ") || "(none)"}`,
376
544
  `- Unmatched extensions: ${report.resourcePolicy.unmatchedExtensions.join(", ") || "(none)"}`,
377
545
  "",
378
- "## Active tools",
379
- ...(report.activeTools.length ? report.activeTools.map((tool) => `- \`${tool}\``) : ["- None resolved"]),
380
- "",
381
546
  "## Roles",
382
547
  ...(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.extension ? `; ${extensionLabel(role.extension)} role directory "${dirname(role.path)}"` : ""}${role.overrides ? `; overrides \`${role.overrides}\`` : ""}`) : ["- None found"]),
383
548
  "",
@@ -14,27 +14,39 @@ function text(content) {
14
14
  const parts = content;
15
15
  return parts.flatMap((part) => typeof part === "object" && part !== null && "type" in part && part.type === "text" && "text" in part && typeof part.text === "string" ? [part.text] : []).join("");
16
16
  }
17
+ function isTranscriptPart(value) {
18
+ if (typeof value !== "object" || value === null || !("type" in value))
19
+ return false;
20
+ if (value.type === "text")
21
+ return "text" in value && typeof value.text === "string";
22
+ if (value.type === "thinking")
23
+ return "thinking" in value && typeof value.thinking === "string";
24
+ if (value.type === "toolCall")
25
+ return "name" in value && typeof value.name === "string";
26
+ return value.type === "image";
27
+ }
28
+ function isTranscriptMessage(value) {
29
+ if (typeof value !== "object" || value === null)
30
+ return false;
31
+ return (!(("role" in value) && value.role !== undefined) || typeof value.role === "string") && (!(("toolName" in value) && value.toolName !== undefined) || typeof value.toolName === "string") && (!(("customType" in value) && value.customType !== undefined) || typeof value.customType === "string");
32
+ }
17
33
  function transcriptPartLines(part) {
18
- if (typeof part !== "object" || part === null || !("type" in part))
34
+ if (!isTranscriptPart(part))
19
35
  return [];
20
- const value = part;
21
- if (value.type === "text" && typeof value.text === "string")
22
- return value.text.split("\n");
23
- if (value.type === "thinking" && typeof value.thinking === "string")
24
- return ["Thinking:", ...value.thinking.split("\n")];
25
- if (value.type === "toolCall" && typeof value.name === "string")
26
- return [`Tool call: ${value.name}`, JSON.stringify(value.arguments, null, 2)];
27
- if (value.type === "image")
28
- return ["[image]"];
29
- return [];
36
+ if (part.type === "text")
37
+ return part.text.split("\n");
38
+ if (part.type === "thinking")
39
+ return ["Thinking:", ...part.thinking.split("\n")];
40
+ if (part.type === "toolCall")
41
+ return [`Tool call: ${part.name}`, JSON.stringify(part.arguments, null, 2)];
42
+ return ["[image]"];
30
43
  }
31
44
  function transcriptMessageLines(message) {
32
- if (typeof message !== "object" || message === null)
45
+ if (!isTranscriptMessage(message))
33
46
  return ["(invalid message)"];
34
- const value = message;
35
- const role = typeof value.role === "string" ? value.role : "message";
36
- const label = role === "toolResult" && typeof value.toolName === "string" ? `${role}: ${value.toolName}` : role === "custom" && typeof value.customType === "string" ? `${role}: ${value.customType}` : role;
37
- const content = Array.isArray(value.content) ? value.content.flatMap(transcriptPartLines) : typeof value.content === "string" ? value.content.split("\n") : [];
47
+ const role = message.role ?? "message";
48
+ const label = role === "toolResult" && message.toolName !== undefined ? `${role}: ${message.toolName}` : role === "custom" && message.customType !== undefined ? `${role}: ${message.customType}` : role;
49
+ const content = Array.isArray(message.content) ? message.content.flatMap(transcriptPartLines) : typeof message.content === "string" ? message.content.split("\n") : [];
38
50
  return [`[${label}]`, ...(content.length ? content : ["(empty)"])];
39
51
  }
40
52
  export function transcriptLines(entries) {
@@ -58,8 +70,11 @@ function mergedModels(groups) {
58
70
  return [...totals].map(([model, cost]) => ({ model, cost })).sort((a, b) => b.cost - a.cost || a.model.localeCompare(b.model));
59
71
  }
60
72
  const thinkingLevels = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
73
+ function isThinkingLevel(value) {
74
+ return typeof value === "string" && thinkingLevels.some((level) => level === value);
75
+ }
61
76
  function parsedThinking(value) {
62
- return typeof value === "string" && thinkingLevels.includes(value) ? value : undefined;
77
+ return isThinkingLevel(value) ? value : undefined;
63
78
  }
64
79
  function modelName(provider, model) {
65
80
  return typeof provider === "string" && provider && typeof model === "string" && model ? `${provider}/${model}` : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piewf/cli",
3
- "version": "5.0.0",
3
+ "version": "5.2.0",
4
4
  "description": "CLI for pi-extensible-workflows",
5
5
  "homepage": "https://vekexasia.github.io/pi-extensible-workflows/",
6
6
  "repository": {
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "license": "MIT",
47
47
  "dependencies": {
48
- "pi-extensible-workflows": "5.0.0"
48
+ "pi-extensible-workflows": "5.2.0"
49
49
  },
50
50
  "publishConfig": {
51
51
  "access": "public"