pi-workflow-engine 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 timbrinded
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,135 @@
1
+ <p align="center">
2
+ <img src="assets/preview.png" width="200" alt="pi-workflow-engine — a central agent orchestrating a ring of subagents" />
3
+ </p>
4
+
5
+ # pi-workflow-engine
6
+
7
+ A customisable multi-agent workflow-orchestration engine for the [pi](https://pi.dev) coding agent — your own version of Claude Code's built-in `/code-review`, built on pi's SDK. Workflows fan out to many subagents, pass validated structured data between stages, and synthesise a result.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pi install git:github.com/timbrinded/pi-workflow-engine
13
+ ```
14
+
15
+ Or from npm:
16
+
17
+ ```bash
18
+ pi install npm:pi-workflow-engine
19
+ ```
20
+
21
+ That's all — pi fetches the package and serves its dependencies from its own bundle, so there's no clone, install, or build step. Restart pi (or run `/reload` in an open session), then confirm it's registered:
22
+
23
+ ```bash
24
+ pi list
25
+ ```
26
+
27
+ Scope it to a single repo instead of globally (writes to that repo's `.pi/settings.json`):
28
+
29
+ ```bash
30
+ pi install git:github.com/timbrinded/pi-workflow-engine -l
31
+ ```
32
+
33
+ Uninstall with `pi remove git:github.com/timbrinded/pi-workflow-engine`.
34
+
35
+ ## Usage
36
+
37
+ In a pi session, from inside a git repo with changes:
38
+
39
+ ```
40
+ /workflow code-review # review the current branch
41
+ /workflow code-review HEAD~3 # review a ref range / target / focus area
42
+ /workflow ping # quick engine smoke test (one agent)
43
+ ```
44
+
45
+ The host agent can also invoke the `workflow` tool itself mid-conversation (e.g. "run the code-review workflow") and fold the structured result back into its reasoning.
46
+
47
+ `code-review` returns a verified, ranked report:
48
+
49
+ ```json
50
+ {
51
+ "summary": "1 confirmed bug: off-by-one loop boundary in sum.js ...",
52
+ "findings": [
53
+ {
54
+ "file": "sum.js",
55
+ "line": 3,
56
+ "severity": "bug",
57
+ "verdict": "CONFIRMED",
58
+ "summary": "Off-by-one: `i <= arr.length` should be `i < arr.length`; accesses arr[arr.length] (undefined) → NaN."
59
+ }
60
+ ]
61
+ }
62
+ ```
63
+
64
+ ## How it works
65
+
66
+ A **workflow** is a TypeScript module that exports `meta` plus a default `async (api) => result`. The injected `api` gives you five primitives:
67
+
68
+ | Primitive | Behaviour |
69
+ |-----------|-----------|
70
+ | `agent(prompt, { schema })` | Runs a subagent in an isolated session; with a typebox `schema` it returns validated structured data, otherwise the final text. |
71
+ | `parallel(thunks)` | Runs every thunk concurrently and waits for all (a barrier). |
72
+ | `pipeline(items, ...stages)` | Runs each item through all stages independently — no barrier between stages. |
73
+ | `phase(title)` / `log(msg)` | Drive the live progress tree shown in the TUI. |
74
+
75
+ ```ts
76
+ import { Type } from "typebox";
77
+ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
78
+
79
+ export const meta: WorkflowMeta = { name: "my-workflow", description: "..." };
80
+
81
+ export default async function run({ agent, parallel, pipeline, phase, log, args }: WorkflowApi) {
82
+ phase("Find");
83
+ const result = await agent("find issues ...", { schema: Type.Object({ /* ... */ }) });
84
+ return result; // a typed object, validated against the schema
85
+ }
86
+ ```
87
+
88
+ Under the hood:
89
+
90
+ - **Each `agent()` is an in-process pi `AgentSession`** (`createAgentSession` + `SessionManager.inMemory()`), inheriting the host's model unless you set one.
91
+ - **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 — no parsing.
92
+ - **A single global semaphore caps concurrent agents**, so `parallel`/`pipeline` nest freely while the cap holds inside every `agent()` call.
93
+ - **Surfaces**: a `/workflow <name> [args]` command, plus a `workflow` tool the host agent can call.
94
+
95
+ ## Local development
96
+
97
+ Only needed if you want to **add or customise workflows, or contribute** — using the bundled workflows requires none of this.
98
+
99
+ ```bash
100
+ git clone https://github.com/timbrinded/pi-workflow-engine
101
+ cd pi-workflow-engine
102
+ bun install # dev deps for typechecking only; pi serves bundled copies at runtime
103
+ bun run typecheck # tsc --noEmit
104
+ bun scripts/smoke.ts # no-LLM check: module graph loads + workflow discovery resolves
105
+ ```
106
+
107
+ Load your working copy into a session without installing it (ephemeral — overrides nothing):
108
+
109
+ ```bash
110
+ pi -e ./src/index.ts -p "/workflow ping"
111
+ ```
112
+
113
+ **Add a workflow:** drop a `.ts` in `workflows/`, import it in `src/workflows.ts`, and add it to `BUILTIN_WORKFLOWS`. Statically-imported workflows share pi's bundled `typebox`, which guarantees schema validation. (Files in `workflows/` and `~/.pi/agent/workflows/` are also discovered dynamically at runtime, best-effort.)
114
+
115
+ **Customise the review:** edit the `ANGLES` array in `workflows/code-review.ts` — the lenses are where your codebase's real failure modes and conventions belong. Tune `model` / `thinkingLevel` / `tools` per `agent()` call, and `DEFAULT_CONCURRENCY` in `src/engine.ts`.
116
+
117
+ ### Layout
118
+
119
+ ```
120
+ src/
121
+ index.ts extension entry (registers the command + tool)
122
+ types.ts WorkflowApi / WorkflowModule contracts
123
+ agent-runner.ts the createAgentSession + terminating-tool schema bridge
124
+ concurrency.ts Semaphore, parallel(), pipeline()
125
+ engine.ts runWorkflow() — binds the primitives to a run
126
+ progress.ts live phase/agent tree via ctx.ui.setWidget
127
+ discovery.ts static registry + best-effort drop-in loading
128
+ workflows/
129
+ code-review.ts the example: scope → find → verify → synthesize
130
+ ping.ts minimal smoke workflow
131
+ ```
132
+
133
+ ## License
134
+
135
+ MIT — see [LICENSE](LICENSE).
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "pi-workflow-engine",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "A customisable multi-agent workflow-orchestration engine for the pi coding agent.",
6
+ "keywords": ["pi-package"],
7
+ "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/timbrinded/pi-workflow-engine.git"
11
+ },
12
+ "files": ["src", "workflows"],
13
+ "pi": {
14
+ "extensions": ["./src/index.ts"],
15
+ "image": "https://raw.githubusercontent.com/timbrinded/pi-workflow-engine/master/assets/preview.png"
16
+ },
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit",
19
+ "prepublishOnly": "tsc --noEmit"
20
+ },
21
+ "peerDependencies": {
22
+ "@earendil-works/pi-agent-core": "*",
23
+ "@earendil-works/pi-ai": "*",
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@earendil-works/pi-tui": "*",
26
+ "typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22",
30
+ "typescript": "^5.6.0"
31
+ }
32
+ }
@@ -0,0 +1,118 @@
1
+ import { createAgentSession, defineTool, SessionManager } from "@earendil-works/pi-coding-agent";
2
+ import type { ModelRegistry, ToolDefinition } from "@earendil-works/pi-coding-agent";
3
+ import type { Model } from "@earendil-works/pi-ai";
4
+ import type { AgentState } from "@earendil-works/pi-agent-core";
5
+ import type { AgentOptions } from "./types.ts";
6
+ import type { Semaphore } from "./concurrency.ts";
7
+ import type { ProgressTracker } from "./progress.ts";
8
+
9
+ /** Name of the synthetic terminating tool that carries structured output. */
10
+ const FINAL_TOOL = "final_answer";
11
+
12
+ /** Shared per-run context threaded into every agent() call. */
13
+ export interface RunContext {
14
+ cwd: string;
15
+ hostModel: Model<any> | undefined;
16
+ modelRegistry: ModelRegistry;
17
+ semaphore: Semaphore;
18
+ progress: ProgressTracker;
19
+ signal: AbortSignal | undefined;
20
+ }
21
+
22
+ /** Pull the last assistant message's plain text out of a finished session. */
23
+ function lastAssistantText(state: AgentState): string {
24
+ const messages = state.messages;
25
+ for (let i = messages.length - 1; i >= 0; i--) {
26
+ const message = messages[i];
27
+ if ("role" in message && message.role === "assistant") {
28
+ const content = (message as { content?: Array<{ type: string; text?: string }> }).content ?? [];
29
+ return content
30
+ .filter((part) => part.type === "text")
31
+ .map((part) => part.text ?? "")
32
+ .join("")
33
+ .trim();
34
+ }
35
+ }
36
+ return "";
37
+ }
38
+
39
+ /**
40
+ * Run one subagent to completion in an isolated in-memory session.
41
+ *
42
+ * Structured output trick: when `opts.schema` is set we register a single
43
+ * terminating tool whose `parameters` IS the schema. The agent's last action is to
44
+ * call it; pi validates the args against the schema, hands them to `execute`, and
45
+ * `terminate: true` ends the turn with no extra LLM round-trip. We capture those
46
+ * validated args in a closure — that captured object is the structured result, so
47
+ * no event-stream parsing is needed.
48
+ */
49
+ export async function runAgent(rc: RunContext, prompt: string, opts: AgentOptions = {}): Promise<unknown> {
50
+ const label = opts.label ?? "agent";
51
+
52
+ // Acquire a global concurrency slot before spawning the session.
53
+ return rc.semaphore.run(async () => {
54
+ rc.progress.agentStart(opts.phase, label);
55
+
56
+ let captured: unknown = null;
57
+ const customTools: ToolDefinition[] = opts.schema
58
+ ? [
59
+ defineTool({
60
+ name: FINAL_TOOL,
61
+ label: "Final Answer",
62
+ description:
63
+ "Return your final structured answer. This MUST be your last action — do not write a normal reply after calling it.",
64
+ parameters: opts.schema,
65
+ async execute(_toolCallId, params) {
66
+ captured = params;
67
+ return { content: [{ type: "text", text: "Recorded." }], details: params, terminate: true };
68
+ },
69
+ }),
70
+ ]
71
+ : [];
72
+
73
+ // When the workflow restricts built-in tools, keep the terminating tool visible.
74
+ const allow = opts.tools
75
+ ? opts.schema
76
+ ? [...opts.tools, FINAL_TOOL]
77
+ : opts.tools
78
+ : undefined;
79
+
80
+ // Resolve the model by runtime id lookup (no compile-time model-id union); fall back to the host's model.
81
+ const model = opts.model ? rc.modelRegistry.find("anthropic", opts.model) ?? rc.hostModel : rc.hostModel;
82
+
83
+ const { session } = await createAgentSession({
84
+ cwd: rc.cwd,
85
+ model,
86
+ modelRegistry: rc.modelRegistry,
87
+ thinkingLevel: opts.thinkingLevel,
88
+ tools: allow,
89
+ customTools,
90
+ sessionManager: SessionManager.inMemory(rc.cwd),
91
+ });
92
+
93
+ const unsubscribe = session.subscribe((event) => {
94
+ if (event.type === "tool_execution_start" && event.toolName !== FINAL_TOOL) {
95
+ rc.progress.agentTool(label, event.toolName);
96
+ }
97
+ });
98
+
99
+ try {
100
+ const finalPrompt = opts.schema
101
+ ? `${prompt}\n\nWhen finished, return your result by calling the ${FINAL_TOOL} tool.`
102
+ : prompt;
103
+ await session.prompt(finalPrompt);
104
+ if (opts.schema) {
105
+ if (captured === null) rc.progress.log(`${label}: no structured answer returned`);
106
+ return captured;
107
+ }
108
+ return lastAssistantText(session.state);
109
+ } catch (error) {
110
+ rc.progress.log(`${label} failed: ${error instanceof Error ? error.message : String(error)}`);
111
+ throw error;
112
+ } finally {
113
+ unsubscribe();
114
+ session.dispose();
115
+ rc.progress.agentDone(label);
116
+ }
117
+ });
118
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * A counting semaphore: bounds how many async tasks run at once.
3
+ *
4
+ * This is the single global concurrency cap for a workflow run. Every `agent()`
5
+ * call acquires a slot before spawning a session and releases it when done, so the
6
+ * cap holds no matter how `parallel`/`pipeline` are nested — exactly like the
7
+ * built-in Workflow tool, where "concurrent agent() calls are capped and excess queue".
8
+ */
9
+ export class Semaphore {
10
+ private active = 0;
11
+ private readonly waiters: Array<() => void> = [];
12
+
13
+ constructor(private readonly max: number) {}
14
+
15
+ async run<T>(fn: () => Promise<T>): Promise<T> {
16
+ if (this.active >= this.max) {
17
+ await new Promise<void>((resolve) => this.waiters.push(resolve));
18
+ }
19
+ this.active++;
20
+ try {
21
+ return await fn();
22
+ } finally {
23
+ this.active--;
24
+ this.waiters.shift()?.();
25
+ }
26
+ }
27
+ }
28
+
29
+ /** Run every thunk concurrently and wait for all results (a barrier). */
30
+ export async function parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]> {
31
+ return Promise.all(thunks.map((thunk) => thunk()));
32
+ }
33
+
34
+ /**
35
+ * Run each item through all stages independently. There is NO barrier between
36
+ * stages: item A can be in stage 3 while item B is still in stage 1. Actual agent
37
+ * concurrency is bounded by the run's Semaphore (inside each `agent()`), not here —
38
+ * so launching every item at once is safe.
39
+ *
40
+ * Each stage receives (previousResult, originalItem, index).
41
+ */
42
+ export async function pipeline(
43
+ items: unknown[],
44
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
45
+ ): Promise<unknown[]> {
46
+ return Promise.all(
47
+ items.map(async (item, index) => {
48
+ let acc: unknown = item;
49
+ for (const stage of stages) {
50
+ acc = await stage(acc, item, index);
51
+ }
52
+ return acc;
53
+ }),
54
+ );
55
+ }
@@ -0,0 +1,54 @@
1
+ import { readdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import type { WorkflowModule } from "./types.ts";
6
+ import { BUILTIN_WORKFLOWS } from "./workflows.ts";
7
+
8
+ function isWorkflowModule(value: unknown): value is WorkflowModule {
9
+ if (!value || typeof value !== "object") return false;
10
+ const candidate = value as { meta?: { name?: unknown }; default?: unknown };
11
+ return typeof candidate.meta?.name === "string" && typeof candidate.default === "function";
12
+ }
13
+
14
+ /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
15
+ async function loadDir(dir: string): Promise<WorkflowModule[]> {
16
+ let entries: string[];
17
+ try {
18
+ entries = await readdir(dir);
19
+ } catch {
20
+ return [];
21
+ }
22
+
23
+ const modules: WorkflowModule[] = [];
24
+ for (const name of entries) {
25
+ if (!name.endsWith(".ts") || name.startsWith("_") || name.startsWith(".")) continue;
26
+ try {
27
+ const loaded: unknown = await import(pathToFileURL(join(dir, name)).href);
28
+ if (isWorkflowModule(loaded)) modules.push(loaded);
29
+ } catch (error) {
30
+ // Drop-in loading depends on the runtime resolving TS + the bundled typebox.
31
+ // Failures here are non-fatal: the static registry still works.
32
+ console.error(`[workflow-engine] skipped ${name}: ${error instanceof Error ? error.message : String(error)}`);
33
+ }
34
+ }
35
+ return modules;
36
+ }
37
+
38
+ /**
39
+ * All available workflows by name. Static registry wins on name collisions, so the
40
+ * bundled example is always the verified one even if a same-named file is dropped in.
41
+ */
42
+ export async function discoverWorkflows(repoDir: string): Promise<Map<string, WorkflowModule>> {
43
+ const byName = new Map<string, WorkflowModule>();
44
+ for (const mod of BUILTIN_WORKFLOWS) byName.set(mod.meta.name, mod);
45
+
46
+ const dynamic = [
47
+ ...(await loadDir(join(repoDir, "workflows"))),
48
+ ...(await loadDir(join(homedir(), ".pi", "agent", "workflows"))),
49
+ ];
50
+ for (const mod of dynamic) {
51
+ if (!byName.has(mod.meta.name)) byName.set(mod.meta.name, mod);
52
+ }
53
+ return byName;
54
+ }
package/src/engine.ts ADDED
@@ -0,0 +1,43 @@
1
+ import { cpus } from "node:os";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { parallel, pipeline, Semaphore } from "./concurrency.ts";
4
+ import { runAgent, type RunContext } from "./agent-runner.ts";
5
+ import { ProgressTracker } from "./progress.ts";
6
+ import type { AgentOptions, WorkflowApi, WorkflowModule } from "./types.ts";
7
+
8
+ /** Default global cap on concurrent agents per run. */
9
+ const DEFAULT_CONCURRENCY = Math.min(8, Math.max(2, cpus().length));
10
+
11
+ /**
12
+ * Run a workflow module: build the per-run primitives (binding agent/parallel/pipeline
13
+ * to a shared semaphore + progress tracker), invoke the workflow, return its result.
14
+ */
15
+ export async function runWorkflow(ctx: ExtensionContext, mod: WorkflowModule, args: string): Promise<unknown> {
16
+ const progress = new ProgressTracker(ctx, mod.meta.name);
17
+ const rc: RunContext = {
18
+ cwd: ctx.cwd,
19
+ hostModel: ctx.model,
20
+ modelRegistry: ctx.modelRegistry,
21
+ semaphore: new Semaphore(DEFAULT_CONCURRENCY),
22
+ progress,
23
+ signal: ctx.signal,
24
+ };
25
+
26
+ const agent = ((prompt: string, opts?: AgentOptions) => runAgent(rc, prompt, opts)) as WorkflowApi["agent"];
27
+
28
+ const api: WorkflowApi = {
29
+ agent,
30
+ parallel,
31
+ pipeline,
32
+ phase: (title) => progress.phase(title),
33
+ log: (message) => progress.log(message),
34
+ args,
35
+ signal: ctx.signal,
36
+ };
37
+
38
+ try {
39
+ return await mod.default(api);
40
+ } finally {
41
+ progress.done();
42
+ }
43
+ }
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { Type } from "typebox";
3
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
4
+ import { discoverWorkflows } from "./discovery.ts";
5
+ import { runWorkflow } from "./engine.ts";
6
+
7
+ /** Repo root (this file lives in <repo>/src/index.ts). */
8
+ const REPO_DIR = fileURLToPath(new URL("..", import.meta.url));
9
+
10
+ function summarize(result: unknown): string {
11
+ if (result && typeof result === "object" && typeof (result as { summary?: unknown }).summary === "string") {
12
+ return (result as { summary: string }).summary;
13
+ }
14
+ return typeof result === "string" ? result : "Workflow finished.";
15
+ }
16
+
17
+ function formatReport(name: string, result: unknown): string {
18
+ return [`## Workflow: ${name}`, "", "```json", JSON.stringify(result, null, 2), "```"].join("\n");
19
+ }
20
+
21
+ export default function workflowEngine(pi: ExtensionAPI): void {
22
+ // /workflow <name> [args] — user-invoked.
23
+ pi.registerCommand("workflow", {
24
+ description: "Run a multi-agent workflow: /workflow <name> [args]",
25
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
26
+ const trimmed = args.trim();
27
+ const space = trimmed.indexOf(" ");
28
+ const name = space === -1 ? trimmed : trimmed.slice(0, space);
29
+ const rest = space === -1 ? "" : trimmed.slice(space + 1).trim();
30
+
31
+ const workflows = await discoverWorkflows(REPO_DIR);
32
+ const available = [...workflows.keys()].join(", ") || "(none)";
33
+
34
+ if (!name) {
35
+ ctx.ui.notify(`Usage: /workflow <name> [args]. Available: ${available}`, "warning");
36
+ return;
37
+ }
38
+ const mod = workflows.get(name);
39
+ if (!mod) {
40
+ ctx.ui.notify(`Unknown workflow "${name}". Available: ${available}`, "error");
41
+ return;
42
+ }
43
+
44
+ const result = await runWorkflow(ctx, mod, rest);
45
+ pi.sendMessage(
46
+ { customType: "workflow-result", content: formatReport(name, result), display: true, details: result },
47
+ { triggerTurn: false },
48
+ );
49
+ },
50
+ });
51
+
52
+ // workflow tool — lets the host agent fan out mid-conversation.
53
+ pi.registerTool({
54
+ name: "workflow",
55
+ label: "Workflow",
56
+ description:
57
+ "Run a named multi-agent workflow (fan-out → verify → synthesize) and return its structured result. Use for thorough reviews or audits.",
58
+ parameters: Type.Object({
59
+ name: Type.String({ description: "Workflow name, e.g. code-review" }),
60
+ args: Type.Optional(Type.String({ description: "Arguments for the workflow (e.g. target or focus)" })),
61
+ }),
62
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
63
+ const workflows = await discoverWorkflows(REPO_DIR);
64
+ const mod = workflows.get(params.name);
65
+ if (!mod) {
66
+ const available = [...workflows.keys()].join(", ") || "(none)";
67
+ return {
68
+ content: [{ type: "text", text: `Unknown workflow "${params.name}". Available: ${available}` }],
69
+ details: { error: "unknown_workflow", available },
70
+ };
71
+ }
72
+ const result = await runWorkflow(ctx, mod, params.args ?? "");
73
+ return { content: [{ type: "text", text: summarize(result) }], details: result };
74
+ },
75
+ });
76
+ }
@@ -0,0 +1,108 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ interface AgentRow {
4
+ label: string;
5
+ status: "running" | "done";
6
+ lastTool?: string;
7
+ }
8
+
9
+ interface Phase {
10
+ title: string;
11
+ agents: AgentRow[];
12
+ }
13
+
14
+ /**
15
+ * Renders a live phase/agent tree into the pi TUI via `ctx.ui.setWidget`.
16
+ * In non-interactive modes (print/JSON/RPC) it falls back to terse stderr breadcrumbs
17
+ * so it never corrupts stdout.
18
+ */
19
+ export class ProgressTracker {
20
+ private readonly phases: Phase[] = [];
21
+ private readonly logs: string[] = [];
22
+ private current = "Workflow";
23
+
24
+ constructor(
25
+ private readonly ctx: ExtensionContext,
26
+ private readonly title: string,
27
+ ) {
28
+ this.ensurePhase(this.current);
29
+ }
30
+
31
+ private ensurePhase(title: string): Phase {
32
+ let phase = this.phases.find((candidate) => candidate.title === title);
33
+ if (!phase) {
34
+ phase = { title, agents: [] };
35
+ this.phases.push(phase);
36
+ }
37
+ return phase;
38
+ }
39
+
40
+ phase(title: string): void {
41
+ this.current = title;
42
+ this.ensurePhase(title);
43
+ if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${title}\n`);
44
+ this.render();
45
+ }
46
+
47
+ log(message: string): void {
48
+ this.logs.push(message);
49
+ if (this.logs.length > 6) this.logs.shift();
50
+ if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${message}\n`);
51
+ this.render();
52
+ }
53
+
54
+ agentStart(phase: string | undefined, label: string): void {
55
+ this.ensurePhase(phase ?? this.current).agents.push({ label, status: "running" });
56
+ this.render();
57
+ }
58
+
59
+ agentTool(label: string, tool: string): void {
60
+ const row = this.findRow(label);
61
+ if (row) row.lastTool = tool;
62
+ this.render();
63
+ }
64
+
65
+ agentDone(label: string): void {
66
+ const row = this.findRow(label);
67
+ if (row && row.status === "running") row.status = "done";
68
+ this.render();
69
+ }
70
+
71
+ private findRow(label: string): AgentRow | undefined {
72
+ for (let i = this.phases.length - 1; i >= 0; i--) {
73
+ const running = this.phases[i].agents.find((agent) => agent.label === label && agent.status === "running");
74
+ if (running) return running;
75
+ }
76
+ for (let i = this.phases.length - 1; i >= 0; i--) {
77
+ const any = this.phases[i].agents.find((agent) => agent.label === label);
78
+ if (any) return any;
79
+ }
80
+ return undefined;
81
+ }
82
+
83
+ private lines(): string[] {
84
+ const out: string[] = [`⚙ ${this.title}`];
85
+ for (const phase of this.phases) {
86
+ if (phase.agents.length === 0 && phase.title === "Workflow") continue;
87
+ out.push(` ${phase.title}`);
88
+ for (const agent of phase.agents) {
89
+ const icon = agent.status === "done" ? "✓" : "⏳";
90
+ const tool = agent.status === "running" && agent.lastTool ? ` · ${agent.lastTool}` : "";
91
+ out.push(` ${icon} ${agent.label}${tool}`);
92
+ }
93
+ }
94
+ for (const line of this.logs) out.push(` · ${line}`);
95
+ return out;
96
+ }
97
+
98
+ private render(): void {
99
+ if (this.ctx.hasUI) this.ctx.ui.setWidget("workflow", this.lines());
100
+ }
101
+
102
+ /** Clear the widget; optionally leave a one-line status in the footer. */
103
+ done(status?: string): void {
104
+ if (!this.ctx.hasUI) return;
105
+ this.ctx.ui.setWidget("workflow", undefined);
106
+ this.ctx.ui.setStatus("workflow", status);
107
+ }
108
+ }
package/src/types.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { Static, TSchema } from "typebox";
2
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
+
4
+ /** Metadata every workflow module must export. */
5
+ export interface WorkflowMeta {
6
+ name: string;
7
+ description: string;
8
+ phases?: Array<{ title: string }>;
9
+ }
10
+
11
+ /** Options for a single `agent()` call. */
12
+ export interface AgentOptions<S extends TSchema = TSchema> {
13
+ /** Label shown in the progress tree (e.g. "find:logic-bugs"). */
14
+ label?: string;
15
+ /** Phase to group this agent under in the progress tree. */
16
+ phase?: string;
17
+ /** Model id (e.g. "claude-opus-4-5"); omit to inherit the host's current model. */
18
+ model?: string;
19
+ /** Reasoning effort for this agent. */
20
+ thinkingLevel?: ThinkingLevel;
21
+ /** Allowlist of built-in tool names the agent may use (e.g. ["read", "bash"]). */
22
+ tools?: string[];
23
+ /** typebox schema. When set, the agent is forced to return matching structured data. */
24
+ schema?: S;
25
+ }
26
+
27
+ /**
28
+ * The primitives injected into every workflow run. A workflow is any module that
29
+ * exports `meta` plus a default `async (api: WorkflowApi) => result`.
30
+ */
31
+ export interface WorkflowApi {
32
+ /** Run a subagent and return its validated structured output (null if it never produced one). */
33
+ agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S> | null>;
34
+ /** Run a subagent and return its final assistant text. */
35
+ agent(prompt: string, opts?: AgentOptions): Promise<string>;
36
+ /** Run every thunk concurrently and wait for all (a barrier). */
37
+ parallel<T>(thunks: Array<() => Promise<T>>): Promise<T[]>;
38
+ /** Run each item through all stages independently — no barrier between stages. */
39
+ pipeline(
40
+ items: unknown[],
41
+ ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
42
+ ): Promise<unknown[]>;
43
+ /** Start a new phase; subsequent agents group under it in the progress tree. */
44
+ phase(title: string): void;
45
+ /** Emit a progress line. */
46
+ log(message: string): void;
47
+ /** Raw argument string passed after the workflow name. */
48
+ args: string;
49
+ /** Abort signal for the run, propagated from the host. */
50
+ signal: AbortSignal | undefined;
51
+ }
52
+
53
+ export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
54
+
55
+ export interface WorkflowModule {
56
+ meta: WorkflowMeta;
57
+ default: WorkflowRun;
58
+ }
@@ -0,0 +1,14 @@
1
+ import type { WorkflowModule } from "./types.ts";
2
+ import * as codeReview from "../workflows/code-review.ts";
3
+ import * as ping from "../workflows/ping.ts";
4
+
5
+ /**
6
+ * Workflows statically imported by the extension. These are loaded through pi's own
7
+ * jiti instance (because they're part of the extension's module graph), so they share
8
+ * pi's bundled `typebox` / SDK — guaranteeing schema validation works.
9
+ *
10
+ * To add a guaranteed workflow: drop a `.ts` in `workflows/`, import it here, and add
11
+ * it to this array. (Drop-in workflows discovered at runtime are also supported — see
12
+ * discovery.ts — but the static registry is the always-correct path.)
13
+ */
14
+ export const BUILTIN_WORKFLOWS: WorkflowModule[] = [codeReview, ping];
@@ -0,0 +1,185 @@
1
+ import { Type } from "typebox";
2
+ import type { WorkflowApi, WorkflowMeta } from "../src/types.ts";
3
+
4
+ export const meta: WorkflowMeta = {
5
+ name: "code-review",
6
+ description: "Fan-out review of the branch's open PR (or branch vs main): scope → per-angle find → independent verify → synthesize.",
7
+ phases: [{ title: "Scope" }, { title: "Find" }, { title: "Verify" }, { title: "Synthesize" }],
8
+ };
9
+
10
+ // ─── Schemas (the contracts that make orchestration plain code) ───
11
+ const ScopeSchema = Type.Object({
12
+ diffCommand: Type.String({ description: "Exact git command that produces the review diff" }),
13
+ files: Type.Array(Type.String(), { description: "Changed file paths" }),
14
+ summary: Type.String({ description: "One-paragraph summary of the change" }),
15
+ conventions: Type.Optional(Type.String({ description: "Relevant CLAUDE.md / project conventions" })),
16
+ });
17
+
18
+ const CandidatesSchema = Type.Object({
19
+ candidates: Type.Array(
20
+ Type.Object({
21
+ file: Type.String(),
22
+ line: Type.Optional(Type.Number()),
23
+ summary: Type.String({ description: "One-line description of the issue" }),
24
+ failure_scenario: Type.String({ description: "Concrete scenario where this causes a problem" }),
25
+ }),
26
+ ),
27
+ });
28
+
29
+ const VerdictSchema = Type.Object({
30
+ verdict: Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE"), Type.Literal("REFUTED")]),
31
+ evidence: Type.String({ description: "Quote or cite the relevant line(s)" }),
32
+ });
33
+
34
+ const ReportSchema = Type.Object({
35
+ summary: Type.String(),
36
+ findings: Type.Array(
37
+ Type.Object({
38
+ file: Type.String(),
39
+ line: Type.Optional(Type.Number()),
40
+ severity: Type.Union([Type.Literal("bug"), Type.Literal("cleanup")]),
41
+ verdict: Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE")]),
42
+ summary: Type.String(),
43
+ }),
44
+ ),
45
+ });
46
+
47
+ interface Candidate {
48
+ file: string;
49
+ line?: number;
50
+ summary: string;
51
+ failure_scenario: string;
52
+ }
53
+ interface Angle {
54
+ label: string;
55
+ kind: "bug" | "cleanup";
56
+ text: string;
57
+ }
58
+ interface Verified extends Candidate {
59
+ verdict: "CONFIRMED" | "PLAUSIBLE" | "REFUTED";
60
+ evidence: string;
61
+ kind: "bug" | "cleanup";
62
+ }
63
+
64
+ // The review lenses — this is the part you customise to your codebase's real failure modes.
65
+ const ANGLES: Angle[] = [
66
+ { label: "logic-bugs", kind: "bug", text: "Off-by-one errors, wrong conditionals, incorrect return values, broken control flow." },
67
+ { label: "error-paths", kind: "bug", text: "Unhandled errors, swallowed exceptions, missing awaits, partial failure leaving inconsistent state." },
68
+ { label: "edge-cases", kind: "bug", text: "Empty/null inputs, boundary values, concurrency races, resource leaks." },
69
+ { label: "simplification", kind: "cleanup", text: "Dead code, needless complexity, duplicated logic, clearer equivalents." },
70
+ { label: "conventions", kind: "cleanup", text: "Violations of the project conventions noted in scope (naming, idioms, banned patterns)." },
71
+ ];
72
+
73
+ const TOOLS = ["read", "bash"];
74
+ const PER_ANGLE = 6;
75
+
76
+ export default async function run(api: WorkflowApi): Promise<unknown> {
77
+ const { agent, parallel, pipeline, phase, log, args } = api;
78
+ const target = args.trim();
79
+
80
+ // ─── Phase 0: Scope ───
81
+ phase("Scope");
82
+ const scope = await agent(
83
+ "Establish the scope of a code review.\n" +
84
+ (target
85
+ ? `Target / instructions (verbatim): "${target}". If it names a PR number, branch, ref range, or files, build the matching diff command (use 'gh pr diff <number>' for a PR). Otherwise use the default selection below.\n`
86
+ : "No explicit target — select the diff to review using the default below.\n") +
87
+ "Default selection — run commands to decide, falling through until you get a NON-EMPTY diff:\n" +
88
+ "1. Get the current branch: `git branch --show-current`.\n" +
89
+ "2. Check for an OPEN GitHub PR for this branch: `gh pr list --head <branch> --state open --json number,title`. " +
90
+ "If one exists, the diff command is `gh pr diff <number>` — note the PR number and title in the summary.\n" +
91
+ "3. If there is no open PR (or `gh` is unavailable / there is no GitHub remote), diff the branch against its base: " +
92
+ "prefer `git diff main...HEAD`, then `git diff master...HEAD`, then `git diff HEAD~1`. " +
93
+ "If the branch itself is main/master, use `git diff HEAD~1`.\n" +
94
+ "4. Run the chosen command to confirm the diff is non-empty.\n\n" +
95
+ "Then: list the changed files, summarize the change in one paragraph (mention the PR if one was found), " +
96
+ "and read any relevant CLAUDE.md noting conventions a reviewer should know.\n" +
97
+ "Return diffCommand exactly as a reviewer should run it. Structured output only.",
98
+ { phase: "Scope", label: "scope", tools: TOOLS, thinkingLevel: "medium", schema: ScopeSchema },
99
+ );
100
+
101
+ if (!scope || scope.files.length === 0) {
102
+ return { summary: "No changes found to review.", findings: [], stats: { candidates: 0, verified: 0 } };
103
+ }
104
+ log(`${scope.files.length} changed files`);
105
+
106
+ const scopeBlock =
107
+ `## Diff command\n${scope.diffCommand}\n\n## Changed files\n${scope.files.map((file) => `- ${file}`).join("\n")}\n\n` +
108
+ `## Summary\n${scope.summary}\n\n## Conventions\n${scope.conventions ?? "(none noted)"}\n` +
109
+ (target ? `\n## User instructions (verbatim)\n${target}\n` : "");
110
+
111
+ // Dedup state accumulates as finders complete (the pipeline has no barrier).
112
+ const seen = new Set<string>();
113
+ const dedupKey = (candidate: Candidate): string =>
114
+ `${candidate.file}:${candidate.line != null ? Math.round(candidate.line / 5) * 5 : candidate.summary.slice(0, 40).toLowerCase()}`;
115
+
116
+ // ─── Find → dedup → Verify (no barrier between stages) ───
117
+ phase("Find");
118
+ const perAngle = await pipeline(
119
+ ANGLES,
120
+ // Stage 1: each angle finds candidates through its single lens.
121
+ async (_prev, item) => {
122
+ const angle = item as Angle;
123
+ const found = await agent(
124
+ `## Code-review finder — ${angle.label}\n\n${scopeBlock}\n` +
125
+ `Run the diff command and review ONLY through this lens:\n${angle.text}\n` +
126
+ `Surface up to ${PER_ANGLE} candidates, each with file, line, a one-line summary, and a concrete failure_scenario. ` +
127
+ "Pass through anything with a nameable failure scenario — a separate verifier judges them next. Structured output only.",
128
+ { phase: "Find", label: `find:${angle.label}`, tools: TOOLS, thinkingLevel: "low", schema: CandidatesSchema },
129
+ );
130
+ return { angle, candidates: (found?.candidates ?? []).slice(0, PER_ANGLE) };
131
+ },
132
+ // Stage 2: dedup against the shared set, then verify survivors concurrently.
133
+ async (prev) => {
134
+ const { angle, candidates } = prev as { angle: Angle; candidates: Candidate[] };
135
+ const novel = candidates.filter((candidate) => {
136
+ const key = dedupKey(candidate);
137
+ if (seen.has(key)) return false;
138
+ seen.add(key);
139
+ return true;
140
+ });
141
+ const verdicts = await parallel(
142
+ novel.map((candidate) => async (): Promise<Verified | null> => {
143
+ const judged = await agent(
144
+ `## Code-review verifier\n\n${scopeBlock}\n## Candidate\n` +
145
+ `File: ${candidate.file}${candidate.line != null ? `:${candidate.line}` : ""}\n` +
146
+ `Summary: ${candidate.summary}\nFailure scenario: ${candidate.failure_scenario}\n\n` +
147
+ "Run the diff command, read the relevant file(s), and return exactly one verdict (CONFIRMED / PLAUSIBLE / REFUTED) " +
148
+ "with evidence quoting the line(s). Default toward REFUTED if you cannot substantiate it. Structured output only.",
149
+ { phase: "Verify", label: `verify:${candidate.file.split("/").pop() ?? candidate.file}`, tools: TOOLS, thinkingLevel: "low", schema: VerdictSchema },
150
+ );
151
+ return judged ? { ...candidate, verdict: judged.verdict, evidence: judged.evidence, kind: angle.kind } : null;
152
+ }),
153
+ );
154
+ return verdicts.filter((value): value is Verified => value !== null);
155
+ },
156
+ );
157
+
158
+ const verified = (perAngle as Verified[][]).flat();
159
+ const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
160
+ log(`${verified.length} verified → ${surviving.length} kept`);
161
+
162
+ if (surviving.length === 0) {
163
+ return { summary: "No findings survived verification.", findings: [], stats: { candidates: seen.size, verified: verified.length } };
164
+ }
165
+
166
+ // ─── Synthesize: rank, merge, report ───
167
+ phase("Synthesize");
168
+ const rank = (finding: Verified): number => (finding.kind === "cleanup" ? 2 : 0) + (finding.verdict === "PLAUSIBLE" ? 1 : 0);
169
+ const ranked = [...surviving].sort((a, b) => rank(a) - rank(b));
170
+ const block = ranked
171
+ .map(
172
+ (finding, index) =>
173
+ `### [${index}] ${finding.file}${finding.line != null ? `:${finding.line}` : ""} (${finding.verdict}${finding.kind === "cleanup" ? ", cleanup" : ""})\n` +
174
+ `${finding.summary}\nFailure: ${finding.failure_scenario}\nEvidence: ${finding.evidence}`,
175
+ )
176
+ .join("\n\n");
177
+
178
+ const report = await agent(
179
+ `## Synthesis: final code-review report\n\n${ranked.length} findings survived independent verification.\n\n${block}\n\n` +
180
+ "Merge findings with the same root cause, rank most-severe first (correctness bugs above cleanups), and produce the final report. Structured output only.",
181
+ { phase: "Synthesize", label: "synthesize", thinkingLevel: "medium", schema: ReportSchema },
182
+ );
183
+
184
+ return report ?? { summary: "Synthesis produced no output.", findings: [] };
185
+ }
@@ -0,0 +1,28 @@
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
+ schema: Type.Object({
22
+ headline: Type.String({ description: "One word" }),
23
+ summary: Type.String({ description: "One sentence" }),
24
+ }),
25
+ },
26
+ );
27
+ return result ?? { headline: "none", summary: "no structured answer returned" };
28
+ }