pi-workflow-engine 1.1.0 → 1.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.
Files changed (52) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -78
  2. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  4. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
  5. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  7. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  8. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  9. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  10. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  11. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +114 -31
  12. package/.pi/extensions/pi-workflow-engine/src/engine.ts +74 -18
  13. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  14. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  15. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  16. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  17. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  18. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  19. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  20. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
  21. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  22. package/.pi/extensions/pi-workflow-engine/src/types.ts +44 -9
  23. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  24. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  25. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  26. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  27. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  28. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  29. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +17 -6
  30. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  31. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  32. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  33. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  34. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  35. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  44. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  45. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  46. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  47. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  48. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  49. package/README.md +11 -3
  50. package/USAGE.md +121 -22
  51. package/package.json +13 -4
  52. package/skills/workflow-authoring/SKILL.md +160 -0
@@ -17,8 +17,10 @@ import {
17
17
  isPersistedWorkflowBackground,
18
18
  type PersistedWorkflowBackground,
19
19
  } from "./workflow-run-background.ts";
20
+ import { isWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
20
21
 
21
22
  export const WORKFLOW_RUN_RECORD_VERSION = 1;
23
+ export const WORKFLOW_RUN_SCRIPT_SUFFIX = ".script.js";
22
24
 
23
25
  const MAX_RESULT_BYTES = 1 << 20;
24
26
  const MAX_RESULT_DEPTH = 64;
@@ -97,6 +99,14 @@ interface WorkflowRunRecordBase {
97
99
  readonly runId: string;
98
100
  readonly workflow: PersistedWorkflowIdentity;
99
101
  readonly journalFile: string;
102
+ /** An inline run's script, persisted beside the record; prompts in it never enter the record itself. */
103
+ readonly scriptFile?: string;
104
+ /**
105
+ * This install's HMAC over the run id and source fingerprint, proving the engine recorded the script
106
+ * rather than a repository shipping it. A run of an inline or saved script relaunches from
107
+ * `/workflow:runs` only with one.
108
+ */
109
+ readonly scriptSeal?: string;
100
110
  readonly options: PersistedWorkflowRunOptions;
101
111
  readonly createdAt: number;
102
112
  readonly updatedAt: number;
@@ -125,6 +135,10 @@ export function createWorkflowRunRecord(input: {
125
135
  readonly options: ResolvedWorkflowRunOptions;
126
136
  readonly progress: WorkflowProgressSnapshot;
127
137
  readonly argumentsPresent?: boolean;
138
+ /** Whether the run's inline script was persisted beside the record. */
139
+ readonly scriptPersisted?: boolean;
140
+ /** The run's script seal; see {@link WorkflowRunRecordBase.scriptSeal}. */
141
+ readonly scriptSeal?: string;
128
142
  }): WorkflowRunRecord {
129
143
  const runId = validateWorkflowRunId(input.runId);
130
144
  assertMatchingRun(runId, input.progress.runId);
@@ -134,6 +148,8 @@ export function createWorkflowRunRecord(input: {
134
148
  state: "queued",
135
149
  workflow: persistedWorkflowIdentity(input.workflow),
136
150
  journalFile: `${runId}.jsonl`,
151
+ scriptFile: input.scriptPersisted ? workflowRunScriptFile(runId) : undefined,
152
+ scriptSeal: input.scriptSeal,
137
153
  options: persistedWorkflowRunOptions(input.options, input.argumentsPresent ?? false),
138
154
  createdAt: input.progress.startedAt,
139
155
  updatedAt: input.progress.startedAt,
@@ -142,6 +158,11 @@ export function createWorkflowRunRecord(input: {
142
158
  };
143
159
  }
144
160
 
161
+ /** The persisted script's file name in the runs directory. */
162
+ export function workflowRunScriptFile(runId: string): string {
163
+ return `${runId}${WORKFLOW_RUN_SCRIPT_SUFFIX}`;
164
+ }
165
+
145
166
  export function updateWorkflowRunProgress(
146
167
  record: WorkflowRunRecord,
147
168
  progress: WorkflowProgressSnapshot,
@@ -169,6 +190,8 @@ export function transitionWorkflowRun(record: WorkflowRunRecord, transition: Wor
169
190
  runId: record.runId,
170
191
  workflow: record.workflow,
171
192
  journalFile: record.journalFile,
193
+ scriptFile: record.scriptFile,
194
+ scriptSeal: record.scriptSeal,
172
195
  options: record.options,
173
196
  createdAt: record.createdAt,
174
197
  updatedAt: at,
@@ -475,6 +498,9 @@ export function isWorkflowRunRecord(value: unknown): value is WorkflowRunRecord
475
498
  if (!isValidRunId(value.runId) || !isWorkflowRunState(value.state)) return false;
476
499
  if (!isPersistedWorkflowIdentity(value.workflow) || !isPersistedWorkflowRunOptions(value.options)) return false;
477
500
  if (value.journalFile !== `${value.runId}.jsonl`) return false;
501
+ if (value.scriptFile !== undefined && value.scriptFile !== workflowRunScriptFile(value.runId)) return false;
502
+ // A seal covers an inline run's persisted script or a saved script, which resolves again by name.
503
+ if (value.scriptSeal !== undefined && (!isWorkflowRunScriptSeal(value.scriptSeal) || (value.scriptFile === undefined && value.workflow.sourceKind !== "file"))) return false;
478
504
  if (!isFiniteNumber(value.createdAt) || !isFiniteNumber(value.updatedAt)) return false;
479
505
  if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
480
506
  if (value.endedAt !== undefined && !isFiniteNumber(value.endedAt)) return false;
@@ -568,7 +594,7 @@ function isPhaseSnapshot(value: unknown): boolean {
568
594
 
569
595
  function isAgentSnapshot(value: unknown): boolean {
570
596
  if (!isRecord(value) || !isFiniteNumber(value.id) || typeof value.label !== "string") return false;
571
- if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed") return false;
597
+ if (value.status !== "queued" && value.status !== "running" && value.status !== "done" && value.status !== "failed" && value.status !== "skipped") return false;
572
598
  if (!isFiniteNumber(value.toolUses)) return false;
573
599
  if (value.startedAt !== undefined && !isFiniteNumber(value.startedAt)) return false;
574
600
  if (value.doneAt !== undefined && !isFiniteNumber(value.doneAt)) return false;
@@ -0,0 +1,83 @@
1
+ import { createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { link, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import { isExistingPathError, isMissingPathError } from "./filesystem-error.ts";
6
+
7
+ /**
8
+ * The per-install key that seals inline run scripts, in pi's agent directory. It lives outside every
9
+ * project, so a repository can ship run records and scripts but never a seal this install accepts.
10
+ */
11
+ export const WORKFLOW_RUN_SEAL_KEY_FILE = "workflow-run-seal.key";
12
+
13
+ const KEY_BYTES = 32;
14
+ const HEX_256 = /^[0-9a-f]{64}$/;
15
+
16
+ /** Whether `value` has a seal's shape; only {@link verifyWorkflowRunScriptSeal} says whether it is genuine. */
17
+ export function isWorkflowRunScriptSeal(value: unknown): value is string {
18
+ return typeof value === "string" && HEX_256.test(value);
19
+ }
20
+
21
+ /** The seal a run record carries for its persisted inline script, creating this install's key on first use. */
22
+ export async function sealWorkflowRunScript(
23
+ runId: string,
24
+ sourceFingerprint: string,
25
+ agentDir: string = getAgentDir(),
26
+ ): Promise<string> {
27
+ const path = join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE);
28
+ return runSeal((await readSealKey(path)) ?? (await createSealKey(agentDir, path)), runId, sourceFingerprint);
29
+ }
30
+
31
+ /** Whether this install sealed `sourceFingerprint` for `runId`. Never creates a key: without one, nothing was sealed here. */
32
+ export async function verifyWorkflowRunScriptSeal(
33
+ runId: string,
34
+ sourceFingerprint: string,
35
+ seal: string,
36
+ agentDir: string = getAgentDir(),
37
+ ): Promise<boolean> {
38
+ if (!isWorkflowRunScriptSeal(seal)) return false;
39
+ const key = await readSealKey(join(agentDir, WORKFLOW_RUN_SEAL_KEY_FILE));
40
+ if (key === undefined) return false;
41
+ return timingSafeEqual(Buffer.from(runSeal(key, runId, sourceFingerprint), "hex"), Buffer.from(seal, "hex"));
42
+ }
43
+
44
+ function runSeal(key: Buffer, runId: string, sourceFingerprint: string): string {
45
+ return createHmac("sha256", key).update(`${runId}\0${sourceFingerprint}`).digest("hex");
46
+ }
47
+
48
+ async function readSealKey(path: string): Promise<Buffer | undefined> {
49
+ let content: string;
50
+ try {
51
+ content = await readFile(path, "utf8");
52
+ } catch (error) {
53
+ if (isMissingPathError(error)) return undefined;
54
+ throw error;
55
+ }
56
+ const hex = content.trim();
57
+ if (!HEX_256.test(hex)) {
58
+ throw new Error(`workflow run seal key ${path} is malformed; delete it to create a new one, which retires every existing seal`);
59
+ }
60
+ return Buffer.from(hex, "hex");
61
+ }
62
+
63
+ /**
64
+ * Publish a new key with an exclusive hard link from a complete temporary file, so a concurrent reader
65
+ * never sees a partial key. When another process published one first, its key wins and is read back.
66
+ */
67
+ async function createSealKey(agentDir: string, path: string): Promise<Buffer> {
68
+ await mkdir(agentDir, { recursive: true });
69
+ const key = randomBytes(KEY_BYTES);
70
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
71
+ try {
72
+ await writeFile(temporaryPath, `${key.toString("hex")}\n`, { encoding: "utf8", flag: "wx", mode: 0o600 });
73
+ await link(temporaryPath, path);
74
+ return key;
75
+ } catch (error) {
76
+ if (!isExistingPathError(error)) throw error;
77
+ const existing = await readSealKey(path);
78
+ if (existing === undefined) throw error;
79
+ return existing;
80
+ } finally {
81
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
82
+ }
83
+ }
@@ -1,12 +1,14 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { basename, dirname, join } from "node:path";
4
- import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
4
+ import { ensureWorkflowRunsDir, validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
5
5
  import type { WorkflowProgressSnapshot } from "./progress-types.ts";
6
6
  import {
7
7
  isWorkflowRunRecord,
8
8
  transitionWorkflowRun,
9
9
  updateWorkflowRunProgress,
10
+ WORKFLOW_RUN_SCRIPT_SUFFIX,
11
+ workflowRunScriptFile,
10
12
  type WorkflowRunRecord,
11
13
  type WorkflowRunState,
12
14
  type WorkflowRunTransition,
@@ -34,7 +36,7 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
34
36
  if (Buffer.byteLength(content) > MAX_RECORD_BYTES) {
35
37
  throw new Error(`Workflow run record exceeded ${MAX_RECORD_BYTES} bytes.`);
36
38
  }
37
- await mkdir(dirname(path), { recursive: true });
39
+ await ensureWorkflowRunsDir(dirname(path));
38
40
  try {
39
41
  await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
40
42
  await rename(temporaryPath, path);
@@ -70,7 +72,12 @@ export class ProjectWorkflowRunStore implements WorkflowRunStore {
70
72
  .filter((entry): entry is { readonly path: string; readonly mtimeMs: number } => entry !== undefined)
71
73
  .sort((left, right) => right.mtimeMs - left.mtimeMs)
72
74
  .slice(Math.max(0, keep));
73
- await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
75
+ // A pruned record takes its persisted script with it: scripts are only ever reached through their record.
76
+ const paths = stale.flatMap((entry) => [
77
+ entry.path,
78
+ `${entry.path.slice(0, -WORKFLOW_RUN_RECORD_SUFFIX.length)}${WORKFLOW_RUN_SCRIPT_SUFFIX}`,
79
+ ]);
80
+ await Promise.all(paths.map((path) => rm(path, { force: true }).catch(() => undefined)));
74
81
  }
75
82
 
76
83
  /** Record file paths in the runs directory; empty when the directory cannot be read. */
@@ -153,6 +160,25 @@ export function workflowRunRecordPath(cwd: string, runId: string): string {
153
160
  return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}${WORKFLOW_RUN_RECORD_SUFFIX}`);
154
161
  }
155
162
 
163
+ export function workflowRunScriptPath(cwd: string, runId: string): string {
164
+ return join(cwd, WORKFLOW_RUNS_DIR, workflowRunScriptFile(validateWorkflowRunId(runId)));
165
+ }
166
+
167
+ /**
168
+ * Persist an inline run's script beside its record, which redacts prompts and so cannot hold it. This
169
+ * adds no new exposure: pi's session transcript already holds the script as the tool call's arguments.
170
+ */
171
+ export async function persistWorkflowRunScript(cwd: string, runId: string, source: string): Promise<string> {
172
+ const path = workflowRunScriptPath(cwd, runId);
173
+ await ensureWorkflowRunsDir(dirname(path));
174
+ await writeFile(path, source, { encoding: "utf8", mode: 0o600 });
175
+ return path;
176
+ }
177
+
178
+ export async function readWorkflowRunScript(cwd: string, runId: string): Promise<string> {
179
+ return await readFile(workflowRunScriptPath(cwd, runId), "utf8");
180
+ }
181
+
156
182
  async function loadWorkflowRunRecord(path: string): Promise<WorkflowRunRecord | undefined> {
157
183
  try {
158
184
  const content = await readFile(path, "utf8");
@@ -0,0 +1,50 @@
1
+ import type { Stats } from "node:fs";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { compileInlineWorkflow } from "./inline-workflow.ts";
5
+ import type { LoadedWorkflow } from "./types.ts";
6
+ import { unknownErrorMessage } from "./unknown-error.ts";
7
+
8
+ /** The largest script file a `scriptPath` may name; real workflow scripts are a few kilobytes. */
9
+ export const MAX_WORKFLOW_SCRIPT_FILE_BYTES = 1 << 20;
10
+
11
+ /** A `scriptPath` that cannot be read. */
12
+ export class WorkflowScriptPathError extends Error {
13
+ override readonly name = "WorkflowScriptPathError";
14
+ }
15
+
16
+ /**
17
+ * Read an inline-format script named relative to `cwd`. No containment: the script runs with the
18
+ * extension's access either way, as an inline `script` does.
19
+ */
20
+ export async function readWorkflowScriptFile(cwd: string, scriptPath: string): Promise<string> {
21
+ return await readBoundedScriptFile(resolve(cwd, scriptPath), `scriptPath ${scriptPath}`);
22
+ }
23
+
24
+ /**
25
+ * Read a workflow script only if it is a regular file within the size limit: a FIFO or device would
26
+ * block the read or never end. `label` names the file in errors.
27
+ */
28
+ export async function readBoundedScriptFile(file: string, label: string): Promise<string> {
29
+ const unreadable = (error: unknown) => new WorkflowScriptPathError(`${label} could not be read: ${unknownErrorMessage(error)}`);
30
+ let info: Stats;
31
+ try {
32
+ info = await stat(file);
33
+ } catch (error) {
34
+ throw unreadable(error);
35
+ }
36
+ if (!info.isFile()) throw new WorkflowScriptPathError(`${label} is not a regular file`);
37
+ if (info.size > MAX_WORKFLOW_SCRIPT_FILE_BYTES) {
38
+ throw new WorkflowScriptPathError(`${label} is ${info.size} bytes, over the ${MAX_WORKFLOW_SCRIPT_FILE_BYTES}-byte limit`);
39
+ }
40
+ try {
41
+ return await readFile(file, "utf8");
42
+ } catch (error) {
43
+ throw unreadable(error);
44
+ }
45
+ }
46
+
47
+ /** Compile a script file exactly like an inline `script`: same contract, injected `Type`, content fingerprint. */
48
+ export async function compileWorkflowScriptFile(cwd: string, scriptPath: string): Promise<LoadedWorkflow> {
49
+ return compileInlineWorkflow(await readWorkflowScriptFile(cwd, scriptPath));
50
+ }
@@ -0,0 +1,147 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
4
+ import { configFilePaths, readJsonConfigFile, writeJsonConfigFile, type ConfigFilePaths } from "./config-file.ts";
5
+ import { isRecord } from "./guards.ts";
6
+ import { DEFAULT_WORKFLOW_MAX_AGENTS } from "./options.ts";
7
+ import { unknownErrorMessage } from "./unknown-error.ts";
8
+
9
+ /**
10
+ * How large the host should make each workflow it authors. A prompt guideline only: the run's
11
+ * `maxAgents` limit stays the separate hard cap.
12
+ */
13
+ export const WORKFLOW_SIZE_GUIDELINES = ["small", "medium", "large", "off"] as const;
14
+ export type WorkflowSizeGuideline = (typeof WORKFLOW_SIZE_GUIDELINES)[number];
15
+ export const DEFAULT_WORKFLOW_SIZE_GUIDELINE: WorkflowSizeGuideline = "medium";
16
+
17
+ const SIZE_AGENT_LIMITS: Readonly<Record<Exclude<WorkflowSizeGuideline, "off">, number>> = { small: 5, medium: 10, large: 25 };
18
+ const SETTINGS_FILE_NAME = "workflow-settings.json";
19
+ const SETTINGS_LABEL = "workflow settings";
20
+
21
+ export interface WorkflowSettingsFile {
22
+ readonly size?: WorkflowSizeGuideline;
23
+ }
24
+
25
+ export type ResolvedWorkflowSize =
26
+ | { readonly size: WorkflowSizeGuideline; readonly source: "default" }
27
+ | { readonly size: WorkflowSizeGuideline; readonly source: "user" | "project"; readonly configPath: string };
28
+
29
+ export function workflowSettingsPaths(cwd: string, agentDir?: string): ConfigFilePaths {
30
+ return configFilePaths(SETTINGS_FILE_NAME, cwd, agentDir);
31
+ }
32
+
33
+ export function isWorkflowSizeGuideline(value: string): value is WorkflowSizeGuideline {
34
+ return WORKFLOW_SIZE_GUIDELINES.includes(value as WorkflowSizeGuideline);
35
+ }
36
+
37
+ export function readWorkflowSettingsFile(configPath: string): WorkflowSettingsFile {
38
+ const parsed = readJsonConfigFile(configPath, SETTINGS_LABEL);
39
+ if (parsed === undefined) return {};
40
+ if (!isRecord(parsed)) throw invalidSettings(configPath, "the root must be an object");
41
+ const unexpected = Object.keys(parsed).find((key) => key !== "size");
42
+ if (unexpected) throw invalidSettings(configPath, `unexpected key "${unexpected}"`);
43
+ const { size } = parsed;
44
+ if (size === undefined) return {};
45
+ if (typeof size !== "string" || !isWorkflowSizeGuideline(size)) {
46
+ throw invalidSettings(configPath, '"size" must be small, medium, large, or off');
47
+ }
48
+ return { size };
49
+ }
50
+
51
+ /** The project file wins over the user file, which wins over the default. */
52
+ export function resolveWorkflowSize(paths: ConfigFilePaths): ResolvedWorkflowSize {
53
+ for (const source of ["project", "user"] as const) {
54
+ const { size } = readWorkflowSettingsFile(paths[source]);
55
+ if (size !== undefined) return { size, source, configPath: paths[source] };
56
+ }
57
+ return { size: DEFAULT_WORKFLOW_SIZE_GUIDELINE, source: "default" };
58
+ }
59
+
60
+ export function setWorkflowSize(configPath: string, size: WorkflowSizeGuideline): void {
61
+ const current = readWorkflowSettingsFile(configPath);
62
+ writeJsonConfigFile(configPath, { ...current, size }, SETTINGS_LABEL);
63
+ }
64
+
65
+ /** The catalog line for a size, or undefined when the guideline is off. */
66
+ export function workflowSizeGuidelineLine(size: WorkflowSizeGuideline): string | undefined {
67
+ if (size === "off") return undefined;
68
+ return `Workflow size guideline: ${size} — keep each workflow under ${SIZE_AGENT_LIMITS[size]} agents unless the user asks for more; the user changes this with /workflow:size.`;
69
+ }
70
+
71
+ type WorkflowSizeCommand =
72
+ | { readonly kind: "status" }
73
+ | { readonly kind: "set"; readonly size: WorkflowSizeGuideline; readonly scope: keyof ConfigFilePaths }
74
+ | { readonly kind: "error"; readonly message: string };
75
+
76
+ const USAGE = "Usage: /workflow:size [small|medium|large|off] [--user|--project]";
77
+ const SIZE_COMPLETIONS = WORKFLOW_SIZE_GUIDELINES.map((value) => ({
78
+ value,
79
+ description: value === "off" ? "No size guideline in the prompt" : `Keep each workflow under ${SIZE_AGENT_LIMITS[value]} agents`,
80
+ }));
81
+ const SCOPE_COMPLETIONS = [
82
+ { value: "--user", description: "Write the user-level workflow settings" },
83
+ { value: "--project", description: "Write the project-level workflow settings" },
84
+ ] as const;
85
+
86
+ export function parseWorkflowSizeCommand(args: string): WorkflowSizeCommand {
87
+ const tokens = args.trim().toLowerCase().split(/\s+/).filter(Boolean);
88
+ const userScope = tokens.includes("--user");
89
+ const projectScope = tokens.includes("--project");
90
+ if (userScope && projectScope) return { kind: "error", message: "Choose either --user or --project, not both." };
91
+ const positional = tokens.filter((token) => token !== "--user" && token !== "--project");
92
+ if (positional.length === 0) {
93
+ return userScope || projectScope ? { kind: "error", message: "Name a size to write." } : { kind: "status" };
94
+ }
95
+ const [size, ...extra] = positional;
96
+ if (extra.length > 0 || !size || !isWorkflowSizeGuideline(size)) {
97
+ return { kind: "error", message: "Size must be small, medium, large, or off." };
98
+ }
99
+ return { kind: "set", size, scope: projectScope ? "project" : "user" };
100
+ }
101
+
102
+ export function workflowSizeArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
103
+ const { completed } = splitArgumentPrefix(argumentPrefix);
104
+ if (completed.length === 0) return completeCurrentArgument(argumentPrefix, SIZE_COMPLETIONS);
105
+ if (completed.length === 1 && isWorkflowSizeGuideline(completed[0] ?? "")) return completeCurrentArgument(argumentPrefix, SCOPE_COMPLETIONS);
106
+ return null;
107
+ }
108
+
109
+ export function formatWorkflowSize(resolved: ResolvedWorkflowSize): string {
110
+ const source = resolved.source === "default" ? "default" : `${resolved.source} config ${resolved.configPath}`;
111
+ const rule = resolved.size === "off"
112
+ ? "no size guideline is added to the prompt"
113
+ : `keep each workflow under ${SIZE_AGENT_LIMITS[resolved.size]} agents unless the user asks for more`;
114
+ return [
115
+ `Workflow size guideline: ${resolved.size} (${source}) — ${rule}.`,
116
+ `The maxAgents run limit (default ${DEFAULT_WORKFLOW_MAX_AGENTS}) stays the hard cap on live agents per run.`,
117
+ ].join("\n");
118
+ }
119
+
120
+ /** Register `/workflow:size`; `onChange` lets the session's prompt pick the new guideline up. */
121
+ export function registerWorkflowSizeCommand(pi: Pick<ExtensionAPI, "registerCommand">, onChange: (ctx: ExtensionCommandContext) => void): void {
122
+ pi.registerCommand("workflow:size", {
123
+ description: "Show or set the workflow size guideline: /workflow:size [small|medium|large|off] [--user|--project]",
124
+ getArgumentCompletions: workflowSizeArgumentCompletions,
125
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
126
+ const command = parseWorkflowSizeCommand(args);
127
+ if (command.kind === "error") {
128
+ ctx.ui.notify(`${command.message}\n${USAGE}`, "warning");
129
+ return;
130
+ }
131
+ try {
132
+ const paths = workflowSettingsPaths(ctx.cwd);
133
+ if (command.kind === "set") {
134
+ setWorkflowSize(paths[command.scope], command.size);
135
+ onChange(ctx);
136
+ }
137
+ ctx.ui.notify(formatWorkflowSize(resolveWorkflowSize(paths)), "info");
138
+ } catch (error) {
139
+ ctx.ui.notify(unknownErrorMessage(error), "error");
140
+ }
141
+ },
142
+ });
143
+ }
144
+
145
+ function invalidSettings(configPath: string, reason: string): Error {
146
+ return new Error(`Invalid workflow settings at ${configPath}: ${reason}. Fix or remove the file.`);
147
+ }
@@ -0,0 +1,61 @@
1
+ export interface WorkflowToolRequestParams {
2
+ readonly name?: string;
3
+ readonly script?: string;
4
+ readonly scriptPath?: string;
5
+ readonly resumeFromRunId?: string;
6
+ readonly background?: boolean;
7
+ }
8
+
9
+ export type WorkflowToolRequest =
10
+ | { readonly kind: "named"; readonly name: string }
11
+ | { readonly kind: "inline"; readonly script: string }
12
+ | { readonly kind: "script-path"; readonly scriptPath: string }
13
+ | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
14
+
15
+ export const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one of a workflow name, an inline workflow script, or a scriptPath.";
16
+
17
+ export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
18
+ const name = params.name?.trim() ?? "";
19
+ const script = params.script?.trim() ?? "";
20
+ const scriptPath = params.scriptPath?.trim() ?? "";
21
+ const provided = [name, script, scriptPath].filter((value) => value.length > 0).length;
22
+ if (provided !== 1) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
23
+ if (name) return { kind: "named", name };
24
+ return script ? { kind: "inline", script } : { kind: "script-path", scriptPath };
25
+ }
26
+
27
+ /** pi names MCP tools `mcp__<server>__<tool>`; such a word contains its server's `mcp__<server>` namespace. */
28
+ const MCP_TOOL_REFERENCE = /mcp__[A-Za-z0-9_-]+/g;
29
+ /** `api.workflow(...)` or a destructured `workflow(...)`; prose that happens to match only costs one wait. */
30
+ const SUB_WORKFLOW_CALL = /\bworkflow\s*\(/;
31
+
32
+ /**
33
+ * Which tools a `workflow` tool call may reach, for pi's `reachesTools`, read from the same request the
34
+ * call executes. Before the call runs, pi's MCP extension waits for every server whose namespace a
35
+ * returned word contains, or for all of them on `"all"`. Without that wait a synchronous run early in a
36
+ * session can start before MCP tools register, and agents that name them then fail at the host-tools bridge.
37
+ *
38
+ * - A background run never gets the bridge, so it reaches nothing.
39
+ * - A named workflow is opaque, so it reaches `"all"`. Each server's readiness settles once, so this
40
+ * costs at most one wait at session start.
41
+ * - An inline script reaches the MCP tools it names, or `"all"` when it asks for `external-search`
42
+ * tools or composes a sub-workflow, which is as opaque as a named one.
43
+ * - A script file reaches `"all"` unread. pi asks synchronously on its main thread, where reading a FIFO,
44
+ * a terminal or a device named as `scriptPath` would block or never end.
45
+ * - An invalid call fails before running anything, so it reaches nothing.
46
+ */
47
+ export function workflowToolReach(input: Record<string, unknown>): "all" | readonly string[] {
48
+ if (input.background === true) return [];
49
+ const request = normalizeWorkflowToolRequest({ name: text(input.name), script: text(input.script), scriptPath: text(input.scriptPath) });
50
+ if (request.kind === "inline") return scriptReach(request.script);
51
+ return request.kind === "error" ? [] : "all";
52
+ }
53
+
54
+ function scriptReach(script: string): "all" | readonly string[] {
55
+ if (script.includes("external-search") || SUB_WORKFLOW_CALL.test(script)) return "all";
56
+ return [...new Set(script.match(MCP_TOOL_REFERENCE) ?? [])];
57
+ }
58
+
59
+ function text(value: unknown): string | undefined {
60
+ return typeof value === "string" ? value : undefined;
61
+ }
@@ -23,6 +23,7 @@ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
23
23
  export const meta: WorkflowMeta = {
24
24
  name: "code-review",
25
25
  description: "Fan-out review of the branch's open PR (or branch vs main): scope → per-angle find → independent verify → synthesize.",
26
+ whenToUse: "Use to review a PR, branch diff, ref range, or focused target for correctness bugs and cleanups, with every finding independently verified; args are the target or review instructions, blank auto-detects the diff.",
26
27
  phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Challenge" }, { title: "Synthesize" }],
27
28
  };
28
29
 
@@ -16,6 +16,7 @@ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
16
16
  export const meta: WorkflowMeta = {
17
17
  name: "diagnose",
18
18
  description: "Advisory-only bug diagnosis: scope symptoms → competing hypotheses → independent verify → synthesize likely root causes.",
19
+ whenToUse: "Use when a bug, failing command, flaky test, or regression has an unclear cause and competing hypotheses should be checked independently before anyone edits code; args describe the symptom.",
19
20
  phases: [{ title: "Scope" }, { title: "Hypothesize" }, { title: "Verify" }, { title: "Challenge" }, { title: "Synthesize" }],
20
21
  };
21
22
 
@@ -17,6 +17,7 @@ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
17
17
  export const meta: WorkflowMeta = {
18
18
  name: "perf-review",
19
19
  description: "Advisory-only performance review: scope slow path → per-lens bottleneck hypotheses → verify evidence → synthesize measurements and safe optimizations.",
20
+ whenToUse: "Use for a slow path, startup cost, or performance concern where measured bottlenecks and safe optimizations matter more than guesses; args name the path, workload, or command.",
20
21
  phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Challenge" }, { title: "Synthesize" }],
21
22
  };
22
23
 
@@ -18,6 +18,7 @@ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
18
18
  export const meta: WorkflowMeta = {
19
19
  name: "refactor-scout",
20
20
  description: "Advisory-only refactor scout: scope → per-lens find → independent verify → synthesize safe refactor opportunities.",
21
+ whenToUse: "Use to find small, behaviour-preserving refactors (duplication, complexity, type safety, boundaries, dead code) in a path or module without changing it; args name the target.",
21
22
  phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Challenge" }, { title: "Synthesize" }],
22
23
  };
23
24
 
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  MAX_RESEARCH_LANES,
3
+ MAX_VERIFICATION_CLAIMS,
3
4
  ResearchLaneResultSchema,
4
5
  ResearchPlanSchema,
5
6
  ResearchReportSchema,
@@ -23,6 +24,7 @@ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
23
24
  export const meta: WorkflowMeta = {
24
25
  name: "research",
25
26
  description: "Source-grounded external research: decompose → gather direct-page evidence → independently verify claims → cited synthesis.",
27
+ whenToUse: "Use for questions that need current external sources and cited, independently verified claims; needs an installed web search or browsing tool; args are the question and any scope constraints.",
26
28
  phases: [{ title: "Plan" }, { title: "Gather" }, { title: "Verify" }, { title: "Synthesize" }],
27
29
  };
28
30
 
@@ -105,10 +107,11 @@ export default async function run(api: WorkflowApi): Promise<ResearchReport> {
105
107
  const gathered = gatherResults.flatMap((result) => (result.ok ? [result.value] : []));
106
108
  const failedLanes = lanes.filter((_, index) => !gatherResults[index]!.ok);
107
109
 
108
- const candidates = buildClaimCandidates(gathered);
110
+ const { candidates, overCap } = buildClaimCandidates(gathered);
109
111
  if (candidates.length === 0) return unavailableResearchReport(failedLanes.length > 0 ? "lanes-failed" : "no-evidence");
110
112
  progress({ type: "counter", key: "research.claims", label: "claims to verify", value: candidates.length });
111
113
  log(`${candidates.length} bounded claim(s) selected for independent verification`);
114
+ if (overCap > 0) log(`Dropped ${overCap} lower-priority claim(s) over the verification cap of ${MAX_VERIFICATION_CLAIMS}`);
112
115
 
113
116
  phase("Verify");
114
117
  const verificationResults = await parallel(
@@ -162,9 +165,14 @@ export default async function run(api: WorkflowApi): Promise<ResearchReport> {
162
165
  }
163
166
 
164
167
  const report = sanitizeResearchReport(synthesis, synthesisInputs);
165
- if (failedLanes.length === 0) return report;
166
- const laneGap = `${failedLanes.length} of ${lanes.length} research lane(s) failed (${failedLanes.map((lane) => lane.title).join(", ")}); coverage is incomplete.`;
167
- return { ...report, limitations: [...report.limitations, laneGap] };
168
+ const gaps: string[] = [];
169
+ if (failedLanes.length > 0) {
170
+ gaps.push(`${failedLanes.length} of ${lanes.length} research lane(s) failed (${failedLanes.map((lane) => lane.title).join(", ")}); coverage is incomplete.`);
171
+ }
172
+ if (overCap > 0) {
173
+ gaps.push(`${overCap} lower-priority claim(s) were not independently verified; verification is capped at ${MAX_VERIFICATION_CLAIMS} claims.`);
174
+ }
175
+ return gaps.length === 0 ? report : { ...report, limitations: [...report.limitations, ...gaps] };
168
176
  }
169
177
 
170
178
  function verificationPrompt(question: string, candidate: ResearchClaimCandidate): string {
package/README.md CHANGED
@@ -72,15 +72,21 @@ dynamax investigate this flaky test using separate timing, fixture, diff, and ex
72
72
  dynamax argue both implementation options, then have another agent challenge the recommendation
73
73
  ```
74
74
 
75
- The token enables Dynamax for one turn. Sticky mode remains active for the pi
76
- session:
75
+ The token enables Dynamax for that one request. Sticky mode instead makes
76
+ workflow orchestration the default for every substantive task in the pi session;
77
+ `/workflow:dynamax` with no argument toggles it:
77
78
 
78
79
  ```text
80
+ /workflow:dynamax
79
81
  /workflow:dynamax on
80
82
  /workflow:dynamax off
81
83
  /workflow:dynamax status
82
84
  ```
83
85
 
86
+ `/workflow:size small|medium|large|off` sets how many agents pi aims to keep
87
+ each authored workflow under (5, 10 by default, or 25); see the
88
+ [size guideline](USAGE.md#workflow-size-guideline).
89
+
84
90
  In the TUI, a standalone `dynamax` token receives a short moving-shine sweep and
85
91
  then stays highlighted. Use `PI_DYNAMAX_EFFECT=static` or
86
92
  `PI_DYNAMAX_EFFECT=off` to reduce or disable the cue; the
@@ -108,7 +114,9 @@ covers review actions and recent run history.
108
114
  ## Create your own workflows
109
115
 
110
116
  Saved workflows are TypeScript modules for repeatable orchestration; inline
111
- workflows are useful for one-off Dynamax tasks. The
117
+ workflows are useful for one-off Dynamax tasks. An inline script that proves
118
+ useful can be saved as a `.js` file in a project's `.pi/workflows/` or in
119
+ `~/.pi/agent/workflows/` and run by name. The
112
120
  [workflow authoring guide](USAGE.md#author-a-saved-workflow) covers the API,
113
121
  typed handoffs, model profiles, tool access, composition, and registration.
114
122