pi-workflow-engine 0.4.0 → 0.5.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.
@@ -4,6 +4,7 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
4
4
  import { Text } from "@earendil-works/pi-tui";
5
5
  import type { WorkflowModule, WorkflowRunOptions } from "./src/types.ts";
6
6
  import type { PerfSink, PerfSnapshot } from "./src/perf.ts";
7
+ import { registerDynamax } from "./src/dynamax.ts";
7
8
  import { isWorkflowResult, renderWorkflowResult, type WorkflowPerfDetails, type WorkflowResultEnvelope } from "./src/ui/workflow-result-renderer.ts";
8
9
 
9
10
  /** Extension root (this file lives in <repo>/.pi/extensions/pi-workflow-engine/index.ts). */
@@ -38,6 +39,7 @@ function formatPerfLine(perf: WorkflowPerfDetails | undefined): string | undefin
38
39
 
39
40
  type DiscoveryModule = typeof import("./src/discovery.ts");
40
41
  type EngineModule = typeof import("./src/engine.ts");
42
+ type InlineWorkflowModule = typeof import("./src/inline-workflow.ts");
41
43
 
42
44
  async function loadDiscovery(): Promise<DiscoveryModule> {
43
45
  return await import("./src/discovery.ts");
@@ -47,6 +49,10 @@ async function loadEngine(): Promise<EngineModule> {
47
49
  return await import("./src/engine.ts");
48
50
  }
49
51
 
52
+ async function loadInlineWorkflow(): Promise<InlineWorkflowModule> {
53
+ return await import("./src/inline-workflow.ts");
54
+ }
55
+
50
56
  async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSink | undefined> {
51
57
  const enabled = options.perf ?? process.env.PI_WORKFLOW_PERF === "1";
52
58
  if (!enabled) return undefined;
@@ -54,11 +60,14 @@ async function createInvocationPerf(options: WorkflowRunOptions): Promise<PerfSi
54
60
  return createPerfRecorder(true);
55
61
  }
56
62
 
63
+ const AUTHOR_TEMP_WORKFLOW_CHOICE = "✍ Author temporary one-shot workflow…";
64
+
57
65
  export interface WorkflowInvocation {
58
66
  name: string;
59
67
  args: string;
60
68
  options: WorkflowRunOptions;
61
69
  refreshDiscovery?: boolean;
70
+ authorBrief?: string;
62
71
  }
63
72
 
64
73
  export function parseWorkflowInvocation(input: string): WorkflowInvocation {
@@ -120,14 +129,77 @@ function parseNumericOption(value: string | undefined): number | undefined {
120
129
  return Number.isFinite(parsed) ? parsed : undefined;
121
130
  }
122
131
 
132
+ function compactInlinePreview(script: string | undefined): string {
133
+ if (!script) return "";
134
+ const compact = script.replace(/\s+/g, " ").trim();
135
+ return compact.length > 60 ? `${compact.slice(0, 57)}…` : compact;
136
+ }
137
+
138
+ export function buildTemporaryWorkflowAuthorPrompt(brief: string): string {
139
+ return `dynamax author and run a temporary one-shot inline workflow.
140
+
141
+ User brief:
142
+ ${brief.trim()}
143
+
144
+ Use the workflow tool with a script argument, not a saved workflow name.
145
+ The script must start with export const meta = { ... } and default-export an async workflow function.
146
+ Use the injected Type object for schemas. Do not import anything or use dynamic import().
147
+ Set thinkingLevel explicitly on each agent() call.
148
+ Do not edit files unless the user explicitly requested edits.`;
149
+ }
150
+
151
+ export interface WorkflowToolRequestParams {
152
+ readonly name?: string;
153
+ readonly script?: string;
154
+ }
155
+
156
+ export type WorkflowToolRequest =
157
+ | { readonly kind: "named"; readonly name: string }
158
+ | { readonly kind: "inline"; readonly script: string }
159
+ | { readonly kind: "error"; readonly error: "invalid_workflow_invocation"; readonly message: string };
160
+
161
+ export interface WorkflowToolErrorResult {
162
+ readonly content: Array<{ readonly type: "text"; readonly text: string }>;
163
+ readonly details: { readonly error: "invalid_workflow_invocation" } | { readonly error: "inline_compile_error"; readonly message: string };
164
+ }
165
+
166
+ const INVALID_WORKFLOW_INVOCATION_MESSAGE = "Provide exactly one workflow name or inline workflow script.";
167
+
168
+ export function normalizeWorkflowToolRequest(params: WorkflowToolRequestParams): WorkflowToolRequest {
169
+ const name = params.name?.trim() ?? "";
170
+ const script = params.script?.trim() ?? "";
171
+ const hasName = name.length > 0;
172
+ const hasScript = script.length > 0;
173
+ if (hasName === hasScript) return { kind: "error", error: "invalid_workflow_invocation", message: INVALID_WORKFLOW_INVOCATION_MESSAGE };
174
+ return hasName ? { kind: "named", name } : { kind: "inline", script };
175
+ }
176
+
177
+ export function invalidWorkflowInvocationResult(): WorkflowToolErrorResult {
178
+ return { content: [{ type: "text", text: INVALID_WORKFLOW_INVOCATION_MESSAGE }], details: { error: "invalid_workflow_invocation" } };
179
+ }
180
+
181
+ export function inlineCompileErrorResult(message: string): WorkflowToolErrorResult {
182
+ return { content: [{ type: "text", text: `Inline workflow did not compile: ${message}` }], details: { error: "inline_compile_error", message } };
183
+ }
184
+
123
185
  async function pickWorkflow(
124
186
  workflows: ReadonlyMap<string, WorkflowModule>,
125
187
  ctx: ExtensionCommandContext,
126
188
  ): Promise<WorkflowInvocation | undefined> {
127
- const choices = [...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`);
189
+ const choices = [AUTHOR_TEMP_WORKFLOW_CHOICE, ...[...workflows.values()].map((mod) => `${mod.meta.name} — ${mod.meta.description}`)];
128
190
  const choice = await ctx.ui.select("Run workflow", choices);
129
191
  if (!choice) return undefined;
130
192
 
193
+ if (choice === AUTHOR_TEMP_WORKFLOW_CHOICE) {
194
+ const brief = await ctx.ui.editor(
195
+ "Describe temporary workflow",
196
+ "Goal:\n\nAgents to run:\n- \n\nFinal output should include:\n- summary\n- findings\n- next steps\n",
197
+ );
198
+ const trimmed = brief?.trim();
199
+ if (!trimmed) return undefined;
200
+ return { name: "", args: "", options: {}, authorBrief: trimmed };
201
+ }
202
+
131
203
  const separator = choice.indexOf(" — ");
132
204
  const name = separator === -1 ? choice : choice.slice(0, separator);
133
205
  const args = name === "code-review" ? (await ctx.ui.input("Code-review target/instructions", "Blank = auto-detect diff"))?.trim() ?? "" : "";
@@ -163,6 +235,8 @@ async function sendWorkflowResult(
163
235
  }
164
236
 
165
237
  export default function workflowEngine(pi: ExtensionAPI): void {
238
+ registerDynamax(pi);
239
+
166
240
  pi.registerMessageRenderer("workflow-result", (message, { expanded }, theme) => {
167
241
  const details = message.details;
168
242
  if (isWorkflowResult(details)) return renderWorkflowResult(details.name, details.result, expanded, theme);
@@ -185,6 +259,11 @@ export default function workflowEngine(pi: ExtensionAPI): void {
185
259
  return;
186
260
  }
187
261
 
262
+ if (invocation.authorBrief) {
263
+ pi.sendUserMessage(buildTemporaryWorkflowAuthorPrompt(invocation.authorBrief));
264
+ return;
265
+ }
266
+
188
267
  const mod = workflows.get(invocation.name);
189
268
  if (!mod) {
190
269
  ctx.ui.notify(`Unknown workflow "${invocation.name}". Available: ${available}`, "error");
@@ -200,9 +279,18 @@ export default function workflowEngine(pi: ExtensionAPI): void {
200
279
  name: "workflow",
201
280
  label: "Workflow",
202
281
  description:
203
- "Run a named multi-agent workflow (fan-out → verify → synthesize) and return its structured result. Use for thorough reviews or audits.",
282
+ "ONLY call workflow when the user opted into multi-agent orchestration via the literal token `dynamax`, sticky `/dynamax on`, an explicit request to run or author a workflow, or a command/skill instruction. Runs either a registered named workflow or an inline one-off workflow script (fan-out → verify → synthesize) and returns its structured result.",
283
+ promptSnippet: "Run an existing named workflow or an inline one-off workflow script",
284
+ promptGuidelines: [
285
+ "Use workflow only when the user opted into workflow orchestration via `dynamax`, `/dynamax on`, an explicit request to run/author a workflow, or a command/skill instruction.",
286
+ "Use workflow with `name` for existing registered workflows such as code-review, diagnose, refactor-scout, or perf-review.",
287
+ "Use workflow with `script` for a new one-off inline workflow; the script must start with `export const meta = { ... }` and default-export an async workflow function.",
288
+ "Inline workflow scripts must use the injected `Type` object for schemas and must not contain imports or dynamic import().",
289
+ "Every workflow tool call must provide exactly one of `name` or `script`, never both.",
290
+ ],
204
291
  parameters: Type.Object({
205
- name: Type.String({ description: "Workflow name, e.g. code-review" }),
292
+ name: Type.Optional(Type.String({ description: "Workflow name, e.g. code-review. Provide exactly one of name or script." })),
293
+ script: Type.Optional(Type.String({ description: "Inline workflow script. Provide exactly one of script or name." })),
206
294
  args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
207
295
  concurrency: Type.Optional(Type.Number({ description: "Optional per-run agent concurrency cap" })),
208
296
  parallelSubmissionLimit: Type.Optional(Type.Number({ description: "Optional limit for eagerly submitted parallel thunks" })),
@@ -210,7 +298,12 @@ export default function workflowEngine(pi: ExtensionAPI): void {
210
298
  }),
211
299
  renderCall(args, theme) {
212
300
  const suffix = args.args ? ` ${theme.fg("dim", args.args)}` : "";
213
- return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name)}${suffix}`, 0, 0);
301
+ if (args.name?.trim()) {
302
+ return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", args.name.trim())}${suffix}`, 0, 0);
303
+ }
304
+ const preview = compactInlinePreview(args.script);
305
+ const previewSuffix = preview ? ` ${theme.fg("dim", preview)}` : "";
306
+ return new Text(`▸ ${theme.fg("toolTitle", theme.bold("workflow"))} ${theme.fg("accent", "inline")}${suffix}${previewSuffix}`, 0, 0);
214
307
  },
215
308
  renderResult(result, { expanded, isPartial }, theme) {
216
309
  if (isPartial) return new Text(theme.fg("accent", "Running workflow…"), 0, 0);
@@ -221,6 +314,9 @@ export default function workflowEngine(pi: ExtensionAPI): void {
221
314
  return new Text(theme.fg("muted", text), 0, 0);
222
315
  },
223
316
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
317
+ const request = normalizeWorkflowToolRequest(params);
318
+ if (request.kind === "error") return invalidWorkflowInvocationResult();
319
+
224
320
  const runOptions: WorkflowRunOptions = {
225
321
  concurrency: params.concurrency,
226
322
  parallelSubmissionLimit: params.parallelSubmissionLimit,
@@ -228,16 +324,33 @@ export default function workflowEngine(pi: ExtensionAPI): void {
228
324
  signal,
229
325
  };
230
326
  const perfRecorder = await createInvocationPerf(runOptions);
231
- const { discoverWorkflows } = await loadDiscovery();
232
- const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
233
- const mod = workflows.get(params.name);
234
- if (!mod) {
235
- const available = [...workflows.keys()].join(", ") || "(none)";
236
- return {
237
- content: [{ type: "text", text: `Unknown workflow "${params.name}". Available: ${available}` }],
238
- details: { error: "unknown_workflow", available },
239
- };
327
+ let mod: WorkflowModule;
328
+ let resultName: string;
329
+
330
+ if (request.kind === "named") {
331
+ const { discoverWorkflows } = await loadDiscovery();
332
+ const workflows = await discoverWorkflows(EXTENSION_DIR, { perf: perfRecorder });
333
+ const named = workflows.get(request.name);
334
+ if (!named) {
335
+ const available = [...workflows.keys()].join(", ") || "(none)";
336
+ return {
337
+ content: [{ type: "text", text: `Unknown workflow "${request.name}". Available: ${available}` }],
338
+ details: { error: "unknown_workflow", available },
339
+ };
340
+ }
341
+ mod = named;
342
+ resultName = request.name;
343
+ } else {
344
+ const inline = await loadInlineWorkflow();
345
+ try {
346
+ mod = inline.compileInlineWorkflow(request.script);
347
+ } catch (error) {
348
+ if (error instanceof inline.InlineWorkflowCompileError) return inlineCompileErrorResult(error.message);
349
+ throw error;
350
+ }
351
+ resultName = mod.meta.name;
240
352
  }
353
+
241
354
  const { runWorkflow } = await loadEngine();
242
355
  let perfSnapshot: PerfSnapshot | undefined;
243
356
  const result = await runWorkflow(ctx, mod, params.args ?? "", {
@@ -250,7 +363,7 @@ export default function workflowEngine(pi: ExtensionAPI): void {
250
363
  });
251
364
  const perf = compactPerfSnapshot(perfSnapshot);
252
365
  const perfLine = formatPerfLine(perf);
253
- return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(params.name, result, perf) };
366
+ return { content: [{ type: "text", text: `${summarize(result)}${perfLine ? `\n\n${perfLine}` : ""}` }], details: workflowEnvelope(resultName, result, perf) };
254
367
  },
255
368
  });
256
369
  }
@@ -2,15 +2,11 @@ import { readdir } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { pathToFileURL } from "node:url";
5
- import type { WorkflowMeta, WorkflowModule } from "./types.ts";
5
+ import type { WorkflowModule } from "./types.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
+ import { parseWorkflowModule } from "./workflow-module.ts";
7
8
  import { BUILTIN_WORKFLOW_FILES, BUILTIN_WORKFLOWS } from "./workflows.ts";
8
9
 
9
- type WorkflowModuleCandidate = {
10
- meta?: { name?: unknown; description?: unknown; phases?: unknown };
11
- default?: unknown;
12
- };
13
-
14
10
  export interface DiscoverWorkflowsOptions {
15
11
  readonly refresh?: boolean;
16
12
  readonly perf?: PerfSink;
@@ -19,33 +15,6 @@ export interface DiscoverWorkflowsOptions {
19
15
 
20
16
  const discoveryCache = new Map<string, Map<string, WorkflowModule>>();
21
17
 
22
- function parseWorkflowModule(value: unknown): { module: WorkflowModule } | { reason: string } {
23
- if (!value || typeof value !== "object") return { reason: "module export is not an object" };
24
- const candidate = value as WorkflowModuleCandidate;
25
- const meta = candidate.meta;
26
- if (!meta || typeof meta !== "object") return { reason: "missing meta export" };
27
- if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
28
-
29
- const description = meta.description;
30
- if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
31
-
32
- const phases = meta.phases;
33
- if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
34
- if (!isWorkflowRun(candidate.default)) return { reason: "default export must be a function" };
35
-
36
- const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
37
- if (phases !== undefined) workflowMeta.phases = phases;
38
- return { module: { meta: workflowMeta, default: candidate.default } };
39
- }
40
-
41
- function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
42
- return typeof value === "function";
43
- }
44
-
45
- function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
46
- return Array.isArray(value) && value.every((phase) => typeof phase === "object" && phase !== null && typeof (phase as { title?: unknown }).title === "string");
47
- }
48
-
49
18
  /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
50
19
  async function loadDir(dir: string, excludeFiles: ReadonlySet<string> = new Set()): Promise<WorkflowModule[]> {
51
20
  let entries: string[];
@@ -0,0 +1,123 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ export interface DynamaxState {
5
+ sticky: boolean;
6
+ oneShotPending: boolean;
7
+ }
8
+
9
+ export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
10
+
11
+ export const DYNAMAX_REMINDER = `
12
+ ## dynamax workflow opt-in
13
+
14
+ The user has opted into dynamax multi-agent orchestration. The workflow tool is permitted for this task. You may either run an existing named workflow or author a new inline workflow script when that best serves the user's request.
15
+
16
+ Inline workflow rules:
17
+ - Use the injected Type object for schemas, for example Type.Object({ ok: Type.Boolean() }); do not import typebox.
18
+ - Do not use import statements or dynamic import() in inline workflow scripts.
19
+ - Set thinkingLevel explicitly on each agent() call so fan-out remains bounded.
20
+ - Provide exactly one of workflow.name or workflow.script.
21
+ `;
22
+
23
+ const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
24
+
25
+ export function createDynamaxState(): DynamaxState {
26
+ return { sticky: false, oneShotPending: false };
27
+ }
28
+
29
+ export function hasDynamaxToken(text: string): boolean {
30
+ return DYNAMAX_TOKEN_PATTERN.test(text);
31
+ }
32
+
33
+ export function markDynamaxOneShot(state: DynamaxState): void {
34
+ state.oneShotPending = true;
35
+ }
36
+
37
+ export function setDynamaxSticky(state: DynamaxState, sticky: boolean): void {
38
+ state.sticky = sticky;
39
+ if (!sticky) state.oneShotPending = false;
40
+ }
41
+
42
+ export function clearDynamax(state: DynamaxState): void {
43
+ state.sticky = false;
44
+ state.oneShotPending = false;
45
+ }
46
+
47
+ export function consumeDynamaxOneShot(state: DynamaxState): boolean {
48
+ const pending = state.oneShotPending;
49
+ state.oneShotPending = false;
50
+ return pending;
51
+ }
52
+
53
+ export function isDynamaxActive(state: DynamaxState): boolean {
54
+ return state.sticky || state.oneShotPending;
55
+ }
56
+
57
+ export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
58
+ if (!isDynamaxActive(state)) return systemPrompt;
59
+ state.oneShotPending = false;
60
+ return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
61
+ }
62
+
63
+ export function appendDynamaxContextReminder(messages: AgentMessage[], state: DynamaxState): AgentMessage[] {
64
+ if (!state.sticky) return messages;
65
+ return [...messages, createDynamaxContextMessage()];
66
+ }
67
+
68
+ export function registerDynamax(pi: ExtensionAPI): void {
69
+ const state = createDynamaxState();
70
+
71
+ pi.on("input", (event) => {
72
+ if (event.source === "extension") return { action: "continue" };
73
+ if (hasDynamaxToken(event.text)) markDynamaxOneShot(state);
74
+ return { action: "continue" };
75
+ });
76
+
77
+ pi.on("before_agent_start", (event) => {
78
+ const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, state);
79
+ if (systemPrompt === event.systemPrompt) return undefined;
80
+ return { systemPrompt };
81
+ });
82
+
83
+ pi.on("context", (event) => {
84
+ const messages = appendDynamaxContextReminder(event.messages, state);
85
+ if (messages === event.messages) return undefined;
86
+ return { messages };
87
+ });
88
+
89
+ pi.registerCommand("dynamax", {
90
+ description: "Toggle dynamax workflow orchestration opt-in: /dynamax on|off|status",
91
+ handler: async (args, ctx) => {
92
+ const action = args.trim().toLowerCase();
93
+ if (action === "on") {
94
+ setDynamaxSticky(state, true);
95
+ ctx.ui.notify("dynamax workflow orchestration is on for this session", "info");
96
+ return;
97
+ }
98
+ if (action === "off") {
99
+ clearDynamax(state);
100
+ ctx.ui.notify("dynamax workflow orchestration is off", "info");
101
+ return;
102
+ }
103
+ if (action === "status") {
104
+ const sticky = state.sticky ? "on" : "off";
105
+ const oneShot = state.oneShotPending ? "; one-shot opt-in pending" : "";
106
+ ctx.ui.notify(`dynamax sticky mode is ${sticky}${oneShot}`, "info");
107
+ return;
108
+ }
109
+ ctx.ui.notify("Usage: /dynamax on|off|status", "warning");
110
+ },
111
+ });
112
+ }
113
+
114
+ function createDynamaxContextMessage(): AgentMessage {
115
+ return {
116
+ role: "custom",
117
+ customType: DYNAMAX_CONTEXT_CUSTOM_TYPE,
118
+ content: DYNAMAX_REMINDER.trim(),
119
+ display: false,
120
+ details: { dynamax: true, sticky: true },
121
+ timestamp: Date.now(),
122
+ };
123
+ }
@@ -0,0 +1,396 @@
1
+ import { Type } from "typebox";
2
+ import type { WorkflowApi, WorkflowMeta, WorkflowModule } from "./types.ts";
3
+ import { parseWorkflowMeta } from "./workflow-module.ts";
4
+
5
+ /**
6
+ * v1 inline workflow source contract:
7
+ * - The script starts with `export const meta = { ... };` where the object is a pure literal.
8
+ * - The script contains exactly one `export default async ...` workflow function.
9
+ * - Inline scripts must not use `import` or any exports other than `meta` and the default function.
10
+ * - `Type` is injected lexically by the host; scripts must use that `Type`, not import typebox.
11
+ */
12
+
13
+ export class InlineWorkflowCompileError extends Error {
14
+ constructor(message: string) {
15
+ super(message);
16
+ this.name = "InlineWorkflowCompileError";
17
+ }
18
+ }
19
+
20
+ export interface InlineMetaLiteral {
21
+ readonly literal: string;
22
+ readonly value: unknown;
23
+ readonly endOffset: number;
24
+ }
25
+
26
+ type InlineWorkflowExecutor = (api: WorkflowApi, typebox: typeof Type) => Promise<unknown>;
27
+ type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecutor;
28
+
29
+ const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
30
+
31
+ export function compileInlineWorkflow(source: string): WorkflowModule {
32
+ rejectForbiddenModuleSyntax(source);
33
+ const metaLiteral = extractMetaLiteral(source);
34
+ const parsedMeta = parseWorkflowMeta(metaLiteral.value);
35
+ if ("reason" in parsedMeta) throw new InlineWorkflowCompileError(parsedMeta.reason);
36
+
37
+ const defaultExpression = extractDefaultWorkflowExpression(source, metaLiteral.endOffset);
38
+ const executor = compileExecutor(defaultExpression);
39
+ return { meta: parsedMeta.meta, default: (api) => executor(api, Type) };
40
+ }
41
+
42
+ export function extractMetaLiteral(source: string): InlineMetaLiteral {
43
+ const start = skipWhitespace(source, 0);
44
+ const prefix = /export\s+const\s+meta\s*=/y;
45
+ prefix.lastIndex = start;
46
+ const match = prefix.exec(source);
47
+ if (!match || match.index !== start) {
48
+ throw new InlineWorkflowCompileError("inline workflow must start with `export const meta = { ... }`;");
49
+ }
50
+
51
+ const objectStart = skipWhitespace(source, prefix.lastIndex);
52
+ if (source[objectStart] !== "{") {
53
+ throw new InlineWorkflowCompileError("inline workflow meta must be an object literal");
54
+ }
55
+
56
+ const parser = new LiteralParser(source, objectStart);
57
+ const value = parser.parseValue();
58
+ const literalEnd = parser.offset;
59
+ let endOffset = skipWhitespace(source, literalEnd);
60
+ if (source[endOffset] === ";") endOffset++;
61
+
62
+ return { literal: source.slice(objectStart, literalEnd), value, endOffset };
63
+ }
64
+
65
+ function rejectForbiddenModuleSyntax(source: string): void {
66
+ if (/^\s*import\s/m.test(source)) {
67
+ throw new InlineWorkflowCompileError("inline workflows must not contain import statements; use injected Type instead");
68
+ }
69
+ if (/\bimport\s*\(/.test(source)) {
70
+ throw new InlineWorkflowCompileError("inline workflows must not use dynamic import()");
71
+ }
72
+ }
73
+
74
+ function extractDefaultWorkflowExpression(source: string, startOffset: number): string {
75
+ const moduleSource = source.slice(startOffset).trim();
76
+ if (!moduleSource) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
77
+ if (!moduleSource.startsWith("export default")) {
78
+ throw new InlineWorkflowCompileError("inline workflow default export must directly follow the meta declaration");
79
+ }
80
+
81
+ const defaultPrefix = /^export\s+default\s+/;
82
+ const match = defaultPrefix.exec(moduleSource);
83
+ if (!match) throw new InlineWorkflowCompileError("inline workflow must export a default async function");
84
+
85
+ const expressionSource = moduleSource.slice(match[0].length).trimStart();
86
+ const bodyOpen = findDefaultBodyOpen(expressionSource);
87
+ const bodyClose = findMatchingDelimiter(expressionSource, bodyOpen, "{", "}");
88
+ const expression = expressionSource.slice(0, bodyClose + 1);
89
+ const remainder = expressionSource.slice(bodyClose + 1).trim();
90
+ if (remainder !== "" && remainder !== ";") {
91
+ throw new InlineWorkflowCompileError("inline workflow must not contain code after the default export");
92
+ }
93
+ return expression;
94
+ }
95
+
96
+ function findDefaultBodyOpen(expressionSource: string): number {
97
+ const functionMatch = /^async\s+function(?:\s+[A-Za-z_$][\w$]*)?\s*/.exec(expressionSource);
98
+ if (functionMatch) {
99
+ const paramsOpen = skipWhitespace(expressionSource, functionMatch[0].length);
100
+ if (expressionSource[paramsOpen] !== "(") {
101
+ throw new InlineWorkflowCompileError("default async function must declare parameters with parentheses");
102
+ }
103
+ const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
104
+ const bodyOpen = skipWhitespace(expressionSource, paramsClose + 1);
105
+ if (expressionSource[bodyOpen] !== "{") {
106
+ throw new InlineWorkflowCompileError("default async function must use a block body");
107
+ }
108
+ return bodyOpen;
109
+ }
110
+
111
+ const arrowMatch = /^async\s*/.exec(expressionSource);
112
+ if (arrowMatch) {
113
+ const paramsOpen = skipWhitespace(expressionSource, arrowMatch[0].length);
114
+ if (expressionSource[paramsOpen] !== "(") {
115
+ throw new InlineWorkflowCompileError("default async arrow workflow must declare parameters with parentheses");
116
+ }
117
+ const paramsClose = findMatchingDelimiter(expressionSource, paramsOpen, "(", ")");
118
+ let cursor = skipWhitespace(expressionSource, paramsClose + 1);
119
+ if (!expressionSource.startsWith("=>", cursor)) {
120
+ throw new InlineWorkflowCompileError("default async arrow workflow must use =>");
121
+ }
122
+ cursor = skipWhitespace(expressionSource, cursor + 2);
123
+ if (expressionSource[cursor] !== "{") {
124
+ throw new InlineWorkflowCompileError("default async arrow workflow must use a block body");
125
+ }
126
+ return cursor;
127
+ }
128
+
129
+ throw new InlineWorkflowCompileError("inline workflow default export must be an async function or async arrow function");
130
+ }
131
+
132
+ function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
133
+ try {
134
+ return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
135
+ } catch (error) {
136
+ const message = error instanceof Error ? error.message : String(error);
137
+ throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${message}`);
138
+ }
139
+ }
140
+
141
+ class LiteralParser {
142
+ private index: number;
143
+
144
+ constructor(private readonly source: string, offset: number) {
145
+ this.index = offset;
146
+ }
147
+
148
+ get offset(): number {
149
+ return this.index;
150
+ }
151
+
152
+ parseValue(): unknown {
153
+ this.skipWhitespace();
154
+ const char = this.source[this.index];
155
+ if (char === "{") return this.parseObject();
156
+ if (char === "[") return this.parseArray();
157
+ if (char === '"' || char === "'") return this.parseString(char);
158
+ if (char === "-" || isDigit(char)) return this.parseNumber();
159
+ if (this.source.startsWith("true", this.index)) return this.consumeKeyword("true", true);
160
+ if (this.source.startsWith("false", this.index)) return this.consumeKeyword("false", false);
161
+ if (this.source.startsWith("null", this.index)) return this.consumeKeyword("null", null);
162
+ if (char === "`") this.fail("template literals are not allowed in inline workflow meta");
163
+ this.fail(`unexpected token in inline workflow meta: ${char ?? "end of input"}`);
164
+ }
165
+
166
+ private parseObject(): Record<string, unknown> {
167
+ const object: Record<string, unknown> = {};
168
+ this.expect("{");
169
+ this.skipWhitespace();
170
+ if (this.peek() === "}") {
171
+ this.index++;
172
+ return object;
173
+ }
174
+
175
+ while (true) {
176
+ this.skipWhitespace();
177
+ if (this.source.startsWith("...", this.index)) this.fail("spread properties are not allowed in inline workflow meta");
178
+ if (this.peek() === "[") this.fail("computed properties are not allowed in inline workflow meta");
179
+ const key = this.parsePropertyKey();
180
+ this.skipWhitespace();
181
+ if (this.peek() !== ":") this.fail("inline workflow meta properties must use explicit key: value syntax");
182
+ this.index++;
183
+ object[key] = this.parseValue();
184
+ this.skipWhitespace();
185
+ const next = this.peek();
186
+ if (next === ",") {
187
+ this.index++;
188
+ this.skipWhitespace();
189
+ if (this.peek() === "}") {
190
+ this.index++;
191
+ return object;
192
+ }
193
+ continue;
194
+ }
195
+ if (next === "}") {
196
+ this.index++;
197
+ return object;
198
+ }
199
+ this.fail("expected `,` or `}` in inline workflow meta object");
200
+ }
201
+ }
202
+
203
+ private parseArray(): unknown[] {
204
+ const array: unknown[] = [];
205
+ this.expect("[");
206
+ this.skipWhitespace();
207
+ if (this.peek() === "]") {
208
+ this.index++;
209
+ return array;
210
+ }
211
+
212
+ while (true) {
213
+ array.push(this.parseValue());
214
+ this.skipWhitespace();
215
+ const next = this.peek();
216
+ if (next === ",") {
217
+ this.index++;
218
+ this.skipWhitespace();
219
+ if (this.peek() === "]") {
220
+ this.index++;
221
+ return array;
222
+ }
223
+ continue;
224
+ }
225
+ if (next === "]") {
226
+ this.index++;
227
+ return array;
228
+ }
229
+ this.fail("expected `,` or `]` in inline workflow meta array");
230
+ }
231
+ }
232
+
233
+ private parsePropertyKey(): string {
234
+ const char = this.peek();
235
+ if (char === '"' || char === "'") return this.parseString(char);
236
+ if (!isIdentifierStart(char)) this.fail("inline workflow meta property keys must be strings or identifiers");
237
+ const start = this.index;
238
+ this.index++;
239
+ while (isIdentifierPart(this.peek())) this.index++;
240
+ return this.source.slice(start, this.index);
241
+ }
242
+
243
+ private parseString(quote: string): string {
244
+ this.expect(quote);
245
+ let value = "";
246
+ while (this.index < this.source.length) {
247
+ const char = this.source[this.index];
248
+ if (char === quote) {
249
+ this.index++;
250
+ return value;
251
+ }
252
+ if (char === "\\") {
253
+ value += this.parseEscapeSequence();
254
+ continue;
255
+ }
256
+ if (char === "\n" || char === "\r") this.fail("inline workflow meta strings must not contain raw newlines");
257
+ value += char;
258
+ this.index++;
259
+ }
260
+ this.fail("unterminated string in inline workflow meta");
261
+ }
262
+
263
+ private parseEscapeSequence(): string {
264
+ this.expect("\\");
265
+ const escaped = this.source[this.index];
266
+ if (escaped === undefined) this.fail("unterminated escape sequence in inline workflow meta");
267
+ this.index++;
268
+ switch (escaped) {
269
+ case '"':
270
+ case "'":
271
+ case "\\":
272
+ case "/":
273
+ return escaped;
274
+ case "b":
275
+ return "\b";
276
+ case "f":
277
+ return "\f";
278
+ case "n":
279
+ return "\n";
280
+ case "r":
281
+ return "\r";
282
+ case "t":
283
+ return "\t";
284
+ case "u": {
285
+ const hex = this.source.slice(this.index, this.index + 4);
286
+ if (!/^[0-9A-Fa-f]{4}$/.test(hex)) this.fail("invalid unicode escape in inline workflow meta");
287
+ this.index += 4;
288
+ return String.fromCharCode(Number.parseInt(hex, 16));
289
+ }
290
+ default:
291
+ this.fail(`unsupported escape sequence \\${escaped} in inline workflow meta`);
292
+ }
293
+ }
294
+
295
+ private parseNumber(): number {
296
+ const match = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
297
+ match.lastIndex = this.index;
298
+ const parsed = match.exec(this.source);
299
+ if (!parsed) this.fail("invalid number in inline workflow meta");
300
+ this.index = match.lastIndex;
301
+ return Number(parsed[0]);
302
+ }
303
+
304
+ private consumeKeyword<T>(keyword: string, value: T): T {
305
+ this.index += keyword.length;
306
+ if (isIdentifierPart(this.peek())) this.fail(`unexpected identifier after ${keyword} in inline workflow meta`);
307
+ return value;
308
+ }
309
+
310
+ private skipWhitespace(): void {
311
+ this.index = skipWhitespace(this.source, this.index);
312
+ }
313
+
314
+ private peek(): string | undefined {
315
+ return this.source[this.index];
316
+ }
317
+
318
+ private expect(char: string): void {
319
+ if (this.source[this.index] !== char) this.fail(`expected ${char} in inline workflow meta`);
320
+ this.index++;
321
+ }
322
+
323
+ private fail(message: string): never {
324
+ throw new InlineWorkflowCompileError(`${message} at offset ${this.index}`);
325
+ }
326
+ }
327
+
328
+ function findMatchingDelimiter(source: string, openIndex: number, open: string, close: string): number {
329
+ let depth = 1;
330
+ let index = openIndex + 1;
331
+ while (index < source.length) {
332
+ const char = source[index];
333
+ if (char === '"' || char === "'" || char === "`") {
334
+ index = skipQuotedSource(source, index, char);
335
+ continue;
336
+ }
337
+ if (char === "/" && source[index + 1] === "/") {
338
+ index = skipLineComment(source, index);
339
+ continue;
340
+ }
341
+ if (char === "/" && source[index + 1] === "*") {
342
+ index = skipBlockComment(source, index);
343
+ continue;
344
+ }
345
+ if (char === open) depth++;
346
+ if (char === close) {
347
+ depth--;
348
+ if (depth === 0) return index;
349
+ }
350
+ index++;
351
+ }
352
+ throw new InlineWorkflowCompileError(`unterminated ${open}${close} block in inline workflow default export`);
353
+ }
354
+
355
+ function skipQuotedSource(source: string, start: number, quote: string): number {
356
+ let index = start + 1;
357
+ while (index < source.length) {
358
+ const char = source[index];
359
+ if (char === "\\") {
360
+ index += 2;
361
+ continue;
362
+ }
363
+ if (char === quote) return index + 1;
364
+ index++;
365
+ }
366
+ throw new InlineWorkflowCompileError("unterminated string in inline workflow default export");
367
+ }
368
+
369
+ function skipLineComment(source: string, start: number): number {
370
+ const newline = source.indexOf("\n", start + 2);
371
+ return newline === -1 ? source.length : newline + 1;
372
+ }
373
+
374
+ function skipBlockComment(source: string, start: number): number {
375
+ const end = source.indexOf("*/", start + 2);
376
+ if (end === -1) throw new InlineWorkflowCompileError("unterminated block comment in inline workflow default export");
377
+ return end + 2;
378
+ }
379
+
380
+ function skipWhitespace(source: string, start: number): number {
381
+ let index = start;
382
+ while (index < source.length && /\s/.test(source[index] ?? "")) index++;
383
+ return index;
384
+ }
385
+
386
+ function isDigit(value: string | undefined): boolean {
387
+ return value !== undefined && value >= "0" && value <= "9";
388
+ }
389
+
390
+ function isIdentifierStart(value: string | undefined): boolean {
391
+ return value !== undefined && /[A-Za-z_$]/.test(value);
392
+ }
393
+
394
+ function isIdentifierPart(value: string | undefined): boolean {
395
+ return value !== undefined && /[A-Za-z0-9_$]/.test(value);
396
+ }
@@ -0,0 +1,57 @@
1
+ import type { WorkflowMeta, WorkflowModule } from "./types.ts";
2
+
3
+ type WorkflowMetaCandidate = {
4
+ readonly name?: unknown;
5
+ readonly description?: unknown;
6
+ readonly phases?: unknown;
7
+ };
8
+
9
+ type WorkflowModuleCandidate = {
10
+ readonly meta?: unknown;
11
+ readonly default?: unknown;
12
+ };
13
+
14
+ /** Validate and normalize workflow metadata without executing workflow code. */
15
+ export function parseWorkflowMeta(value: unknown): { meta: WorkflowMeta } | { reason: string } {
16
+ if (!isRecord(value)) return { reason: "meta export must be an object" };
17
+ return parseWorkflowMetaObject(value);
18
+ }
19
+
20
+ export function parseWorkflowModule(value: unknown): { module: WorkflowModule } | { reason: string } {
21
+ if (!isRecord(value)) return { reason: "module export is not an object" };
22
+ const candidate = value as WorkflowModuleCandidate;
23
+ const meta = candidate.meta;
24
+ if (!isRecord(meta)) return { reason: "missing meta export" };
25
+
26
+ const parsedMeta = parseWorkflowMetaObject(meta);
27
+ if ("reason" in parsedMeta) return parsedMeta;
28
+ if (!isWorkflowRun(candidate.default)) return { reason: "default export must be a function" };
29
+
30
+ return { module: { meta: parsedMeta.meta, default: candidate.default } };
31
+ }
32
+
33
+ function parseWorkflowMetaObject(meta: WorkflowMetaCandidate): { meta: WorkflowMeta } | { reason: string } {
34
+ if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
35
+
36
+ const description = meta.description;
37
+ if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
38
+
39
+ const phases = meta.phases;
40
+ if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
41
+
42
+ const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
43
+ if (phases !== undefined) workflowMeta.phases = phases;
44
+ return { meta: workflowMeta };
45
+ }
46
+
47
+ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
48
+ return typeof value === "function";
49
+ }
50
+
51
+ function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
52
+ return Array.isArray(value) && value.every((phase) => isRecord(phase) && typeof phase.title === "string");
53
+ }
54
+
55
+ function isRecord(value: unknown): value is Record<string, unknown> {
56
+ return typeof value === "object" && value !== null;
57
+ }
@@ -1,6 +1,5 @@
1
1
  import type { WorkflowModule } from "./types.ts";
2
2
  import * as codeReview from "../workflows/code-review.ts";
3
- import * as ping from "../workflows/ping.ts";
4
3
  import * as refactorScout from "../workflows/refactor-scout.ts";
5
4
  import * as diagnose from "../workflows/diagnose.ts";
6
5
  import * as perfReview from "../workflows/perf-review.ts";
@@ -19,6 +18,6 @@ import * as perfReview from "../workflows/perf-review.ts";
19
18
  * dynamically imported built-ins. Startup optimizations should happen at the extension
20
19
  * entrypoint/discovery boundary first.
21
20
  */
22
- export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, ping, refactorScout, diagnose, perfReview];
23
- export const BUILTIN_WORKFLOW_FILES = new Set(["code-review.ts", "ping.ts", "refactor-scout.ts", "diagnose.ts", "perf-review.ts"]);
21
+ export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, refactorScout, diagnose, perfReview];
22
+ export const BUILTIN_WORKFLOW_FILES = new Set(["code-review.ts", "refactor-scout.ts", "diagnose.ts", "perf-review.ts"]);
24
23
  export const BUILTIN_WORKFLOW_NAMES = BUILTIN_WORKFLOWS.map((mod) => mod.meta.name);
package/README.md CHANGED
@@ -55,6 +55,8 @@ pi remove git:github.com/timbrinded/pi-workflow-engine
55
55
 
56
56
  ## Usage
57
57
 
58
+ For the complete installation, invocation, authoring, tuning, and troubleshooting guide, see [USAGE.md](USAGE.md).
59
+
58
60
  In a pi session, from inside a git repo with changes:
59
61
 
60
62
  ```text
@@ -64,15 +66,25 @@ In a pi session, from inside a git repo with changes:
64
66
  /workflow diagnose "typecheck fails after the schema change"
65
67
  /workflow perf-review "workflow startup latency"
66
68
  /workflow code-review --inspect # open the live workflow inspector
67
- /workflow ping # quick engine smoke test
68
69
  ```
69
70
 
70
- The host agent can also invoke the `workflow` tool mid-conversation:
71
+ The host agent can also invoke the `workflow` tool mid-conversation. It accepts either a registered workflow `name` or a one-off inline workflow `script`:
71
72
 
72
73
  ```text
73
74
  Run the code-review workflow on this PR and use the result before deciding what to fix.
74
75
  ```
75
76
 
77
+ Opt into dynamic multi-agent orchestration with the literal `dynamax` token, or keep it sticky for the session:
78
+
79
+ ```text
80
+ dynamax inspect this bug with multiple focused agents
81
+ /dynamax on
82
+ /dynamax status
83
+ /dynamax off
84
+ ```
85
+
86
+ `dynamax` is a permission signal for the host agent: once opted in, it may run an existing named workflow or author an inline workflow script through the `workflow` tool. In interactive mode, `/workflow` with no arguments also offers `✍ Author temporary one-shot workflow…`; choose it, type a brief, and pi will ask the host agent to author/run a temporary inline workflow.
87
+
76
88
  The advisory workflows inspect and report only; they do not edit files. They return the same shape: summary, ranked findings, evidence, impact, recommendations, and next steps.
77
89
 
78
90
  ## Built-in workflows
@@ -81,7 +93,6 @@ The advisory workflows inspect and report only; they do not edit files. They ret
81
93
  - `refactor-scout`: Looks for small, safe refactor opportunities: duplication, complexity, weak types, boundary leaks, dead code, and convention drift.
82
94
  - `diagnose`: Investigates a symptom, failing command, or regression. It generates competing root-cause hypotheses, verifies them, and returns the most likely causes with next validation steps.
83
95
  - `perf-review`: Reviews a slow path or workload for bottleneck hypotheses, measurement gaps, and safe optimization directions. It avoids claiming certainty when measurement evidence is missing.
84
- - `ping`: Minimal smoke workflow for checking that the engine can run one structured agent call.
85
96
 
86
97
  ## The code-review workflow
87
98
 
@@ -135,12 +146,33 @@ export default async function run({ agent, parallel, phase }: WorkflowApi) {
135
146
  }
136
147
  ```
137
148
 
149
+ Inline workflow scripts use the same primitives, but are passed as a string to the `workflow` tool instead of being saved as files. A minimal inline workflow starts with `export const meta` and uses the injected Type object for schemas:
150
+
151
+ ```ts
152
+ export const meta = {
153
+ name: "inline-review",
154
+ description: "One-off focused review workflow.",
155
+ };
156
+
157
+ export default async function run({ agent, parallel, phase, args }) {
158
+ phase("Find");
159
+ const Finding = Type.Object({ summary: Type.String() });
160
+ const findings = await parallel([
161
+ () => agent(`Find correctness issues: ${args}`, { schema: Finding, thinkingLevel: "low" }),
162
+ () => agent(`Find edge cases: ${args}`, { schema: Finding, thinkingLevel: "low" }),
163
+ ]);
164
+ return { summary: JSON.stringify(findings) };
165
+ }
166
+ ```
167
+
168
+ Inline v1 rules: no imports, no dynamic `import()`, pure-literal `meta`, schemas must use the injected Type value, and scripts run in-process with the extension's permissions. This v1 path is permissive rather than sandboxed.
169
+
138
170
  Under the hood:
139
171
 
140
172
  - **Each `agent()` is an in-process pi `AgentSession`** using `createAgentSession` and `SessionManager.inMemory()`.
141
173
  - **Structured output is a terminating tool**. The engine registers one tool whose `parameters` is your schema; pi validates the call and the engine captures the args. There is no JSON scraping.
142
174
  - **A single global semaphore caps concurrent agents**, so `parallel` and `pipeline` can nest freely while every `agent()` call still respects the run cap.
143
- - **Two surfaces are registered**: `/workflow <name> [args]` for direct use, and a `workflow` tool for host-agent delegation.
175
+ - **Three surfaces are registered**: `/workflow <name> [args]` for direct use, `/dynamax on|off|status` for sticky orchestration opt-in, and a `workflow` tool for host-agent delegation by `name` or inline `script`.
144
176
 
145
177
  ## Local development
146
178
 
@@ -177,11 +209,7 @@ bun run bench:startup -- --json
177
209
  bun run bench:ui -- --agents 1000 --lane-items 1000 --json
178
210
  ```
179
211
 
180
- Add `--out` to write machine-local JSON under `.artifacts/benchmarks/`. These timings are advisory, not portable thresholds. Optional LLM smoke timing is separate from the required gates:
181
-
182
- ```bash
183
- pi -e . -p "/workflow ping"
184
- ```
212
+ Add `--out` to write machine-local JSON under `.artifacts/benchmarks/`. These timings are advisory, not portable thresholds.
185
213
 
186
214
  Guardrails:
187
215
 
@@ -192,7 +220,7 @@ Guardrails:
192
220
  Load your working copy through the package manifest without installing it. This is ephemeral and exercises the same `.pi/extensions/pi-workflow-engine/index.ts` entrypoint that installed packages use:
193
221
 
194
222
  ```bash
195
- pi -e . -p "/workflow ping"
223
+ pi -e .
196
224
  ```
197
225
 
198
226
  This repo also includes `.pi/settings.json` for project-local auto-discovery. If you also have the global package installed, pi may report duplicate `/workflow` or `workflow` tool diagnostics; remove one source or force pi to ignore discovered extensions and load this working copy instead:
@@ -227,7 +255,6 @@ Tune the built-in review workflow by editing the `ANGLES` array in `.pi/extensio
227
255
  refactor-scout.ts advisory refactor opportunities
228
256
  diagnose.ts advisory bug diagnosis
229
257
  perf-review.ts advisory performance investigation
230
- ping.ts minimal smoke workflow
231
258
  ```
232
259
 
233
260
  ## License
package/USAGE.md ADDED
@@ -0,0 +1,183 @@
1
+ # Usage
2
+
3
+ `pi-workflow-engine` adds multi-agent workflows to pi. Most users only need two surfaces:
4
+
5
+ - `/workflow <name> [args]` to run a saved workflow directly.
6
+ - `dynamax` when you want the host agent to author a one-off inline workflow for investigation/review.
7
+
8
+ ## Run a workflow
9
+
10
+ From inside a repo:
11
+
12
+ ```text
13
+ /workflow code-review
14
+ /workflow code-review HEAD~3
15
+ /workflow refactor-scout src/
16
+ /workflow diagnose "typecheck fails after the schema change"
17
+ /workflow perf-review "workflow startup latency"
18
+ ```
19
+
20
+ The built-in workflows are advisory. They inspect, verify, and report; they do **not** edit files.
21
+
22
+ Useful flags:
23
+
24
+ ```text
25
+ /workflow code-review --inspect # show live inspector
26
+ /workflow code-review --concurrency=4 # cap concurrent subagents
27
+ /workflow code-review --refresh # rediscover newly added workflow files
28
+ ```
29
+
30
+ ## Built-in workflows
31
+
32
+ | Name | Purpose |
33
+ | --- | --- |
34
+ | `code-review` | Reviews a PR, branch diff, ref range, or focus area. |
35
+ | `refactor-scout` | Finds small, safe refactor opportunities. |
36
+ | `diagnose` | Investigates a bug, failing command, or regression. |
37
+ | `perf-review` | Reviews a slow path or performance concern. |
38
+
39
+ `code-review` auto-detects a diff when you do not pass args. If there is an open GitHub PR for the current branch, it uses that PR diff; otherwise it falls back to branch-vs-main/master or `HEAD~1`.
40
+
41
+ ## Use `dynamax` for inline workflows
42
+
43
+ Use `dynamax` when the built-in workflows are not specific enough and you want the main pi agent to author a temporary workflow for the current question.
44
+
45
+ Interactive shortcut: run `/workflow` with no arguments, choose `✍ Author temporary one-shot workflow…`, and type a brief. pi will send that brief back to the host agent as a `dynamax` request so it can author and run an inline workflow with the `workflow` tool's `script` argument.
46
+
47
+ That inline workflow should investigate, fan out, verify, and summarize. It is **not** where code edits happen. If you want changes made, use the workflow result as evidence and then ask pi to edit separately.
48
+
49
+ One-off examples:
50
+
51
+ ```text
52
+ dynamax author an inline workflow to investigate why typecheck is failing
53
+ dynamax create a one-off workflow to compare the parser and tests before we change code
54
+ dynamax use parallel agents to review this design and report the risks
55
+ ```
56
+
57
+ Sticky for the session:
58
+
59
+ ```text
60
+ /dynamax on
61
+ /dynamax off
62
+ /dynamax status
63
+ ```
64
+
65
+ With dynamax enabled, the host agent usually calls the `workflow` tool with `script`: a one-off inline workflow. It can still call a saved workflow by `name` when one already fits.
66
+
67
+ ## Workflow results
68
+
69
+ Workflow results are rendered in pi with:
70
+
71
+ - summary;
72
+ - ranked findings;
73
+ - locations and evidence;
74
+ - recommendations;
75
+ - next steps;
76
+ - run stats when available.
77
+
78
+ During a run, pi shows live phases and subagent status. Use `--inspect` if you want a larger live view.
79
+
80
+ ## Author a saved workflow
81
+
82
+ Saved workflows are TypeScript modules with `meta` plus a default async function:
83
+
84
+ ```ts
85
+ import { Type } from "typebox";
86
+ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
87
+
88
+ export const meta: WorkflowMeta = {
89
+ name: "my-workflow",
90
+ description: "Find and summarize something important.",
91
+ };
92
+
93
+ const Finding = Type.Object({
94
+ summary: Type.String(),
95
+ });
96
+
97
+ export default async function run({ agent, parallel, phase, args }: WorkflowApi) {
98
+ phase("Find");
99
+ const findings = await parallel([
100
+ () => agent(`Find correctness issues: ${args}`, { schema: Finding, tools: ["read", "bash"], thinkingLevel: "low" }),
101
+ () => agent(`Find edge cases: ${args}`, { schema: Finding, tools: ["read", "bash"], thinkingLevel: "low" }),
102
+ ]);
103
+
104
+ phase("Synthesize");
105
+ return agent(`Summarize: ${JSON.stringify(findings)}`, { thinkingLevel: "medium" });
106
+ }
107
+ ```
108
+
109
+ Core primitives:
110
+
111
+ | Primitive | What it does |
112
+ | --- | --- |
113
+ | `agent(prompt, opts)` | Runs one isolated subagent. With `schema`, returns validated structured data. |
114
+ | `parallel(thunks)` | Runs thunks concurrently and waits for all. |
115
+ | `pipeline(items, ...stages)` | Runs each item through stages independently. |
116
+ | `phase(title)` / `log(message)` | Updates workflow progress UI. |
117
+ | `progress(event)` | Emits counters, summaries, and lane items. |
118
+
119
+ Set `thinkingLevel` on fan-out agents. Otherwise many subagents can inherit an expensive global reasoning level.
120
+
121
+ ### Where workflows live
122
+
123
+ Guaranteed built-ins are statically registered:
124
+
125
+ 1. Add `.pi/extensions/pi-workflow-engine/workflows/<name>.ts`.
126
+ 2. Import it in `.pi/extensions/pi-workflow-engine/src/workflows.ts`.
127
+ 3. Add it to `BUILTIN_WORKFLOWS`.
128
+
129
+ Drop-in workflows are also discovered best-effort from:
130
+
131
+ - `.pi/extensions/pi-workflow-engine/workflows/*.ts`
132
+ - `~/.pi/agent/workflows/*.ts`
133
+
134
+ Use `/workflow <name> --refresh` after adding a drop-in file.
135
+
136
+ ## Author an inline workflow
137
+
138
+ Inline workflows are passed to the `workflow` tool as a script string. They are useful for one-off dynamax orchestration.
139
+
140
+ ```ts
141
+ export const meta = {
142
+ name: "inline-review",
143
+ description: "One-off focused review.",
144
+ };
145
+
146
+ export default async function run({ agent, parallel, phase, args }) {
147
+ phase("Find");
148
+ const Finding = Type.Object({ summary: Type.String() });
149
+ const findings = await parallel([
150
+ () => agent(`Find correctness issues: ${args}`, { schema: Finding, thinkingLevel: "low" }),
151
+ () => agent(`Find edge cases: ${args}`, { schema: Finding, thinkingLevel: "low" }),
152
+ ]);
153
+ return { summary: JSON.stringify(findings) };
154
+ }
155
+ ```
156
+
157
+ Inline rules:
158
+
159
+ - start with `export const meta = { ... }`;
160
+ - default-export an async workflow function;
161
+ - no imports or dynamic `import()`;
162
+ - use the injected `Type` object for schemas;
163
+ - no extra exports or code after the default export.
164
+
165
+ Inline workflows run in-process with extension permissions, so treat them as trusted task automation.
166
+
167
+ ## Runtime knobs
168
+
169
+ Only tune these when a workflow is too slow, too expensive, or too noisy:
170
+
171
+ | Knob | Meaning |
172
+ | --- | --- |
173
+ | `--concurrency=N` / `PI_WORKFLOW_CONCURRENCY=N` | Cap concurrent subagents. Default is `min(8, max(2, CPU count))`. |
174
+ | `--parallel-limit=N` / `PI_WORKFLOW_PARALLEL_SUBMISSION_LIMIT=N` | Limit eager `parallel()` submission. |
175
+ | `--perf` / `PI_WORKFLOW_PERF=1` | Include internal timing aggregates. |
176
+ | `PI_WORKFLOW_LANE_ITEM_LIMIT=N` | Cap retained progress lane items. |
177
+
178
+ ## Common fixes
179
+
180
+ - **Unknown workflow**: confirm the extension is loaded with `pi list`; use `--refresh` for new drop-ins.
181
+ - **Inline compile error**: check the inline rules above.
182
+ - **Slow run**: lower fan-out, set `thinkingLevel`, or reduce `--concurrency`.
183
+ - **Duplicate command/tool warnings while developing**: avoid loading both the global package and the working copy.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-workflow-engine",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "A customisable multi-agent workflow-orchestration engine for the pi coding agent.",
6
6
  "keywords": [
@@ -25,6 +25,7 @@
25
25
  ".pi/settings.json",
26
26
  "assets",
27
27
  "README.md",
28
+ "USAGE.md",
28
29
  "LICENSE"
29
30
  ],
30
31
  "pi": {
@@ -1,29 +0,0 @@
1
- import { Type } from "typebox";
2
- import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
3
-
4
- /** Minimal engine smoke test: a single structured-output agent call. Fast + cheap. */
5
- export const meta: WorkflowMeta = {
6
- name: "ping",
7
- description: "Engine smoke test — one structured agent call, no tools.",
8
- phases: [{ title: "Ping" }],
9
- };
10
-
11
- export default async function run(api: WorkflowApi): Promise<unknown> {
12
- const { agent, phase, log } = api;
13
- phase("Ping");
14
- log("calling one structured agent");
15
- const result = await agent(
16
- "You are a code-review workflow engine. Return a one-word headline and a one-sentence summary of what you do.",
17
- {
18
- phase: "Ping",
19
- label: "ping",
20
- tools: [],
21
- thinkingLevel: "low",
22
- schema: Type.Object({
23
- headline: Type.String({ description: "One word" }),
24
- summary: Type.String({ description: "One sentence" }),
25
- }),
26
- },
27
- );
28
- return result ?? { headline: "none", summary: "no structured answer returned" };
29
- }