pi-taskflow 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +324 -0
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/examples/summarize-files.json +37 -0
- package/extensions/agents.ts +152 -0
- package/extensions/index.ts +383 -0
- package/extensions/interpolate.ts +151 -0
- package/extensions/render.ts +81 -0
- package/extensions/runner.ts +301 -0
- package/extensions/runtime.ts +298 -0
- package/extensions/schema.ts +261 -0
- package/extensions/store.ts +170 -0
- package/package.json +65 -0
- package/skills/taskflow/SKILL.md +87 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Taskflow DSL — schema, types, and validation.
|
|
3
|
+
*
|
|
4
|
+
* A taskflow is a declarative, multi-phase workflow. Each phase delegates work
|
|
5
|
+
* to a subagent (an isolated `pi` process). Phases form a DAG via `dependsOn`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
9
|
+
import { Type, type Static } from "typebox";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Phase types
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce"] as const;
|
|
16
|
+
export type PhaseType = (typeof PHASE_TYPES)[number];
|
|
17
|
+
|
|
18
|
+
export const OUTPUT_FORMATS = ["text", "json"] as const;
|
|
19
|
+
|
|
20
|
+
const ParallelTaskSchema = Type.Object(
|
|
21
|
+
{
|
|
22
|
+
task: Type.String({ description: "Task for this parallel branch (supports interpolation)" }),
|
|
23
|
+
agent: Type.Optional(Type.String({ description: "Override the phase agent for this branch" })),
|
|
24
|
+
},
|
|
25
|
+
{ additionalProperties: false },
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
const PhaseSchema = Type.Object(
|
|
29
|
+
{
|
|
30
|
+
id: Type.String({ description: "Unique phase identifier (referenced via {steps.<id>.output})" }),
|
|
31
|
+
type: Type.Optional(StringEnum(PHASE_TYPES, { description: "Phase kind", default: "agent" })),
|
|
32
|
+
agent: Type.Optional(Type.String({ description: "Agent name to run this phase" })),
|
|
33
|
+
task: Type.Optional(Type.String({ description: "Task prompt (supports interpolation placeholders)" })),
|
|
34
|
+
|
|
35
|
+
// map fan-out
|
|
36
|
+
over: Type.Optional(
|
|
37
|
+
Type.String({ description: "[map] Interpolation ref resolving to an array to fan out over" }),
|
|
38
|
+
),
|
|
39
|
+
as: Type.Optional(Type.String({ description: "[map] Loop variable name (default: item)", default: "item" })),
|
|
40
|
+
|
|
41
|
+
// parallel static branches
|
|
42
|
+
branches: Type.Optional(Type.Array(ParallelTaskSchema, { description: "[parallel] Static task branches" })),
|
|
43
|
+
|
|
44
|
+
// reduce
|
|
45
|
+
from: Type.Optional(
|
|
46
|
+
Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" }),
|
|
47
|
+
),
|
|
48
|
+
|
|
49
|
+
dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Phase ids this phase depends on" })),
|
|
50
|
+
output: Type.Optional(StringEnum(OUTPUT_FORMATS, { description: "Parse output as text or json", default: "text" })),
|
|
51
|
+
model: Type.Optional(Type.String({ description: "Model override for this phase" })),
|
|
52
|
+
thinking: Type.Optional(Type.String({ description: "Thinking level override for this phase" })),
|
|
53
|
+
tools: Type.Optional(Type.Array(Type.String(), { description: "Restrict tools for this phase's agent" })),
|
|
54
|
+
cwd: Type.Optional(Type.String({ description: "Working directory for this phase's subagent" })),
|
|
55
|
+
final: Type.Optional(Type.Boolean({ description: "Mark this phase's output as the workflow result" })),
|
|
56
|
+
optional: Type.Optional(
|
|
57
|
+
Type.Boolean({ description: "If true, a failure does not abort the run", default: false }),
|
|
58
|
+
),
|
|
59
|
+
concurrency: Type.Optional(Type.Number({ description: "Override max concurrency for map/parallel" })),
|
|
60
|
+
},
|
|
61
|
+
{ additionalProperties: false },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const ArgSpecSchema = Type.Object(
|
|
65
|
+
{
|
|
66
|
+
default: Type.Optional(Type.Unknown()),
|
|
67
|
+
description: Type.Optional(Type.String()),
|
|
68
|
+
required: Type.Optional(Type.Boolean()),
|
|
69
|
+
},
|
|
70
|
+
{ additionalProperties: false },
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
export const TaskflowSchema = Type.Object(
|
|
74
|
+
{
|
|
75
|
+
name: Type.String({ description: "Workflow name (becomes /tf:<name> command when saved)" }),
|
|
76
|
+
description: Type.Optional(Type.String()),
|
|
77
|
+
version: Type.Optional(Type.Number({ default: 1 })),
|
|
78
|
+
args: Type.Optional(Type.Record(Type.String(), ArgSpecSchema, { description: "Declared invocation arguments" })),
|
|
79
|
+
concurrency: Type.Optional(Type.Number({ description: "Default max concurrent subagents", default: 8 })),
|
|
80
|
+
agentScope: Type.Optional(
|
|
81
|
+
StringEnum(["user", "project", "both"] as const, { description: "Agent discovery scope", default: "user" }),
|
|
82
|
+
),
|
|
83
|
+
phases: Type.Array(PhaseSchema, { minItems: 1, description: "Ordered phase definitions (DAG via dependsOn)" }),
|
|
84
|
+
},
|
|
85
|
+
{ additionalProperties: false },
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
export type ParallelTask = Static<typeof ParallelTaskSchema>;
|
|
89
|
+
export type Phase = Static<typeof PhaseSchema>;
|
|
90
|
+
export type Taskflow = Static<typeof TaskflowSchema>;
|
|
91
|
+
export type ArgSpec = Static<typeof ArgSpecSchema>;
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Validation (beyond schema: DAG integrity, phase-type requirements)
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
export interface ValidationResult {
|
|
98
|
+
ok: boolean;
|
|
99
|
+
errors: string[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function validateTaskflow(def: unknown): ValidationResult {
|
|
103
|
+
const errors: string[] = [];
|
|
104
|
+
|
|
105
|
+
if (typeof def !== "object" || def === null) {
|
|
106
|
+
return { ok: false, errors: ["Taskflow must be an object"] };
|
|
107
|
+
}
|
|
108
|
+
const flow = def as Partial<Taskflow>;
|
|
109
|
+
|
|
110
|
+
if (!flow.name || typeof flow.name !== "string") errors.push("Missing or invalid 'name'");
|
|
111
|
+
if (!Array.isArray(flow.phases) || flow.phases.length === 0) {
|
|
112
|
+
errors.push("Taskflow must have at least one phase");
|
|
113
|
+
return { ok: false, errors };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const ids = new Set<string>();
|
|
117
|
+
for (const p of flow.phases) {
|
|
118
|
+
if (!p || typeof p !== "object") {
|
|
119
|
+
errors.push("Each phase must be an object");
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (!p.id) {
|
|
123
|
+
errors.push("Each phase requires an 'id'");
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (ids.has(p.id)) errors.push(`Duplicate phase id: ${p.id}`);
|
|
127
|
+
ids.add(p.id);
|
|
128
|
+
|
|
129
|
+
const type = (p.type ?? "agent") as PhaseType;
|
|
130
|
+
if (!PHASE_TYPES.includes(type)) errors.push(`Phase '${p.id}': unknown type '${type}'`);
|
|
131
|
+
|
|
132
|
+
// Per-type requirements
|
|
133
|
+
if (type === "agent" || type === "gate") {
|
|
134
|
+
if (!p.task) errors.push(`Phase '${p.id}' (${type}) requires 'task'`);
|
|
135
|
+
}
|
|
136
|
+
if (type === "map") {
|
|
137
|
+
if (!p.over) errors.push(`Phase '${p.id}' (map) requires 'over'`);
|
|
138
|
+
if (!p.task) errors.push(`Phase '${p.id}' (map) requires 'task'`);
|
|
139
|
+
}
|
|
140
|
+
if (type === "parallel") {
|
|
141
|
+
if (!p.branches || p.branches.length === 0)
|
|
142
|
+
errors.push(`Phase '${p.id}' (parallel) requires non-empty 'branches'`);
|
|
143
|
+
}
|
|
144
|
+
if (type === "reduce") {
|
|
145
|
+
if (!p.from || p.from.length === 0) errors.push(`Phase '${p.id}' (reduce) requires 'from'`);
|
|
146
|
+
if (!p.task) errors.push(`Phase '${p.id}' (reduce) requires 'task'`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// dependsOn / from references must exist
|
|
151
|
+
for (const p of flow.phases) {
|
|
152
|
+
if (!p?.id) continue;
|
|
153
|
+
for (const dep of p.dependsOn ?? []) {
|
|
154
|
+
if (!ids.has(dep)) errors.push(`Phase '${p.id}': dependsOn unknown phase '${dep}'`);
|
|
155
|
+
}
|
|
156
|
+
for (const f of p.from ?? []) {
|
|
157
|
+
if (!ids.has(f)) errors.push(`Phase '${p.id}': from unknown phase '${f}'`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Cycle detection (Kahn)
|
|
162
|
+
if (errors.length === 0) {
|
|
163
|
+
const cycle = detectCycle(flow.phases as Phase[]);
|
|
164
|
+
if (cycle) errors.push(`Dependency cycle detected: ${cycle.join(" -> ")}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Exactly handle final-phase resolution lazily (0 finals => last phase is final)
|
|
168
|
+
const finals = (flow.phases as Phase[]).filter((p) => p.final);
|
|
169
|
+
if (finals.length > 1) errors.push(`Only one phase may be marked 'final' (found ${finals.length})`);
|
|
170
|
+
|
|
171
|
+
return { ok: errors.length === 0, errors };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Returns a cycle path if the DAG has one, else null. */
|
|
175
|
+
function detectCycle(phases: Phase[]): string[] | null {
|
|
176
|
+
const deps = new Map<string, string[]>();
|
|
177
|
+
for (const p of phases) deps.set(p.id, dependenciesOf(p));
|
|
178
|
+
|
|
179
|
+
const WHITE = 0;
|
|
180
|
+
const GRAY = 1;
|
|
181
|
+
const BLACK = 2;
|
|
182
|
+
const color = new Map<string, number>();
|
|
183
|
+
for (const p of phases) color.set(p.id, WHITE);
|
|
184
|
+
const stack: string[] = [];
|
|
185
|
+
|
|
186
|
+
const visit = (id: string): string[] | null => {
|
|
187
|
+
color.set(id, GRAY);
|
|
188
|
+
stack.push(id);
|
|
189
|
+
for (const d of deps.get(id) ?? []) {
|
|
190
|
+
if (!deps.has(d)) continue;
|
|
191
|
+
const c = color.get(d);
|
|
192
|
+
if (c === GRAY) {
|
|
193
|
+
const start = stack.indexOf(d);
|
|
194
|
+
return [...stack.slice(start), d];
|
|
195
|
+
}
|
|
196
|
+
if (c === WHITE) {
|
|
197
|
+
const found = visit(d);
|
|
198
|
+
if (found) return found;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
color.set(id, BLACK);
|
|
202
|
+
stack.pop();
|
|
203
|
+
return null;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
for (const p of phases) {
|
|
207
|
+
if (color.get(p.id) === WHITE) {
|
|
208
|
+
const found = visit(p.id);
|
|
209
|
+
if (found) return found;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Effective dependency ids of a phase (dependsOn ∪ from). */
|
|
216
|
+
export function dependenciesOf(phase: Phase): string[] {
|
|
217
|
+
const set = new Set<string>([...(phase.dependsOn ?? []), ...(phase.from ?? [])]);
|
|
218
|
+
return Array.from(set);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Topologically ordered layers; phases in the same layer can run concurrently. */
|
|
222
|
+
export function topoLayers(phases: Phase[]): Phase[][] {
|
|
223
|
+
const byId = new Map(phases.map((p) => [p.id, p]));
|
|
224
|
+
const indeg = new Map<string, number>();
|
|
225
|
+
const dependents = new Map<string, string[]>();
|
|
226
|
+
|
|
227
|
+
for (const p of phases) {
|
|
228
|
+
indeg.set(p.id, 0);
|
|
229
|
+
dependents.set(p.id, []);
|
|
230
|
+
}
|
|
231
|
+
for (const p of phases) {
|
|
232
|
+
for (const d of dependenciesOf(p)) {
|
|
233
|
+
if (!byId.has(d)) continue;
|
|
234
|
+
indeg.set(p.id, (indeg.get(p.id) ?? 0) + 1);
|
|
235
|
+
dependents.get(d)!.push(p.id);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const layers: Phase[][] = [];
|
|
240
|
+
let frontier = phases.filter((p) => (indeg.get(p.id) ?? 0) === 0);
|
|
241
|
+
const seen = new Set<string>();
|
|
242
|
+
|
|
243
|
+
while (frontier.length > 0) {
|
|
244
|
+
layers.push(frontier);
|
|
245
|
+
const next: Phase[] = [];
|
|
246
|
+
for (const p of frontier) {
|
|
247
|
+
seen.add(p.id);
|
|
248
|
+
for (const dep of dependents.get(p.id) ?? []) {
|
|
249
|
+
indeg.set(dep, (indeg.get(dep) ?? 0) - 1);
|
|
250
|
+
if ((indeg.get(dep) ?? 0) === 0 && !seen.has(dep)) next.push(byId.get(dep)!);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
frontier = next;
|
|
254
|
+
}
|
|
255
|
+
return layers;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** Resolve which phase is the result-bearing phase. */
|
|
259
|
+
export function finalPhase(phases: Phase[]): Phase {
|
|
260
|
+
return phases.find((p) => p.final) ?? phases[phases.length - 1];
|
|
261
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence for taskflow definitions and run state.
|
|
3
|
+
*
|
|
4
|
+
* Definitions: .pi/taskflows/<name>.json (project)
|
|
5
|
+
* ~/.pi/agent/taskflows/<name>.json (user)
|
|
6
|
+
* Run state: .pi/taskflows/runs/<runId>.json (resume support)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as crypto from "node:crypto";
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { Taskflow } from "./schema.ts";
|
|
14
|
+
import type { UsageStats } from "./runner.ts";
|
|
15
|
+
|
|
16
|
+
export interface SavedFlow {
|
|
17
|
+
name: string;
|
|
18
|
+
scope: "user" | "project";
|
|
19
|
+
filePath: string;
|
|
20
|
+
def: Taskflow;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type PhaseStatus = "pending" | "running" | "done" | "failed" | "skipped";
|
|
24
|
+
|
|
25
|
+
export interface PhaseState {
|
|
26
|
+
id: string;
|
|
27
|
+
status: PhaseStatus;
|
|
28
|
+
output?: string;
|
|
29
|
+
json?: unknown;
|
|
30
|
+
usage?: UsageStats;
|
|
31
|
+
model?: string;
|
|
32
|
+
error?: string;
|
|
33
|
+
inputHash?: string;
|
|
34
|
+
startedAt?: number;
|
|
35
|
+
endedAt?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RunState {
|
|
39
|
+
runId: string;
|
|
40
|
+
flowName: string;
|
|
41
|
+
def: Taskflow;
|
|
42
|
+
args: Record<string, unknown>;
|
|
43
|
+
status: "running" | "completed" | "failed" | "paused";
|
|
44
|
+
phases: Record<string, PhaseState>;
|
|
45
|
+
createdAt: number;
|
|
46
|
+
updatedAt: number;
|
|
47
|
+
cwd: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function userFlowsDir(): string {
|
|
51
|
+
return path.join(getAgentDir(), "taskflows");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function findProjectFlowsDir(cwd: string, create = false): string | null {
|
|
55
|
+
// Prefer an existing .pi dir up the tree; else use cwd/.pi when creating.
|
|
56
|
+
let dir = cwd;
|
|
57
|
+
while (true) {
|
|
58
|
+
const candidate = path.join(dir, ".pi");
|
|
59
|
+
if (fs.existsSync(candidate)) return path.join(candidate, "taskflows");
|
|
60
|
+
const parent = path.dirname(dir);
|
|
61
|
+
if (parent === dir) break;
|
|
62
|
+
dir = parent;
|
|
63
|
+
}
|
|
64
|
+
return create ? path.join(cwd, ".pi", "taskflows") : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function readFlowFile(filePath: string, scope: "user" | "project"): SavedFlow | null {
|
|
68
|
+
try {
|
|
69
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
70
|
+
const def = JSON.parse(raw) as Taskflow;
|
|
71
|
+
if (!def?.name) return null;
|
|
72
|
+
return { name: def.name, scope, filePath, def };
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** List all saved flows (project overrides user on name collision). */
|
|
79
|
+
export function listFlows(cwd: string): SavedFlow[] {
|
|
80
|
+
const map = new Map<string, SavedFlow>();
|
|
81
|
+
const dirs: Array<{ dir: string; scope: "user" | "project" }> = [{ dir: userFlowsDir(), scope: "user" }];
|
|
82
|
+
const projDir = findProjectFlowsDir(cwd);
|
|
83
|
+
if (projDir) dirs.push({ dir: projDir, scope: "project" });
|
|
84
|
+
|
|
85
|
+
for (const { dir, scope } of dirs) {
|
|
86
|
+
if (!fs.existsSync(dir)) continue;
|
|
87
|
+
let entries: string[];
|
|
88
|
+
try {
|
|
89
|
+
entries = fs.readdirSync(dir);
|
|
90
|
+
} catch {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const name of entries) {
|
|
94
|
+
if (!name.endsWith(".json")) continue;
|
|
95
|
+
const flow = readFlowFile(path.join(dir, name), scope);
|
|
96
|
+
if (flow) map.set(flow.name, flow); // project after user → overrides
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getFlow(cwd: string, name: string): SavedFlow | null {
|
|
103
|
+
return listFlows(cwd).find((f) => f.name === name) ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function saveFlow(
|
|
107
|
+
cwd: string,
|
|
108
|
+
def: Taskflow,
|
|
109
|
+
scope: "user" | "project" = "project",
|
|
110
|
+
): { filePath: string } {
|
|
111
|
+
const dir = scope === "user" ? userFlowsDir() : findProjectFlowsDir(cwd, true)!;
|
|
112
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
113
|
+
const safe = def.name.replace(/[^\w.-]+/g, "_");
|
|
114
|
+
const filePath = path.join(dir, `${safe}.json`);
|
|
115
|
+
fs.writeFileSync(filePath, `${JSON.stringify(def, null, 2)}\n`, "utf-8");
|
|
116
|
+
return { filePath };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Run state ---
|
|
120
|
+
|
|
121
|
+
function runsDir(cwd: string): string {
|
|
122
|
+
const projDir = findProjectFlowsDir(cwd, true)!;
|
|
123
|
+
return path.join(projDir, "runs");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function newRunId(flowName: string): string {
|
|
127
|
+
const safe = flowName.replace(/[^\w.-]+/g, "_").slice(0, 24);
|
|
128
|
+
return `${safe}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function saveRun(state: RunState): void {
|
|
132
|
+
const dir = runsDir(state.cwd);
|
|
133
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
134
|
+
state.updatedAt = Date.now();
|
|
135
|
+
fs.writeFileSync(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2), "utf-8");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function loadRun(cwd: string, runId: string): RunState | null {
|
|
139
|
+
try {
|
|
140
|
+
const raw = fs.readFileSync(path.join(runsDir(cwd), `${runId}.json`), "utf-8");
|
|
141
|
+
return JSON.parse(raw) as RunState;
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function listRuns(cwd: string, limit = 20): RunState[] {
|
|
148
|
+
const dir = runsDir(cwd);
|
|
149
|
+
if (!fs.existsSync(dir)) return [];
|
|
150
|
+
let files: string[];
|
|
151
|
+
try {
|
|
152
|
+
files = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
|
|
153
|
+
} catch {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
const runs: RunState[] = [];
|
|
157
|
+
for (const f of files) {
|
|
158
|
+
try {
|
|
159
|
+
runs.push(JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8")));
|
|
160
|
+
} catch {
|
|
161
|
+
/* ignore */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return runs.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, limit);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Stable hash of a phase's resolved task + inputs, for resume caching. */
|
|
168
|
+
export function hashInput(...parts: string[]): string {
|
|
169
|
+
return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
|
|
170
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-taskflow",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Lightweight workflow orchestration for the Pi coding agent — declarative multi-phase taskflows with dynamic fan-out, isolated subagent context, resumable runs, and saveable commands.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi",
|
|
8
|
+
"pi-coding-agent",
|
|
9
|
+
"pi-extension",
|
|
10
|
+
"workflow",
|
|
11
|
+
"orchestration",
|
|
12
|
+
"subagents",
|
|
13
|
+
"agents",
|
|
14
|
+
"taskflow",
|
|
15
|
+
"ai",
|
|
16
|
+
"automation"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "heggria <bshengtao@gmail.com>",
|
|
20
|
+
"homepage": "https://github.com/heggria/pi-taskflow#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/heggria/pi-taskflow/issues"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/heggria/pi-taskflow.git"
|
|
27
|
+
},
|
|
28
|
+
"type": "module",
|
|
29
|
+
"files": [
|
|
30
|
+
"extensions",
|
|
31
|
+
"skills",
|
|
32
|
+
"examples",
|
|
33
|
+
"README.md",
|
|
34
|
+
"DESIGN.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"test": "node --experimental-strip-types --test test/interpolate.test.ts test/schema.test.ts test/runtime.test.ts",
|
|
40
|
+
"test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
|
|
41
|
+
},
|
|
42
|
+
"pi": {
|
|
43
|
+
"extensions": [
|
|
44
|
+
"./extensions/index.ts"
|
|
45
|
+
],
|
|
46
|
+
"skills": [
|
|
47
|
+
"./skills"
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-agent-core": "*",
|
|
52
|
+
"@earendil-works/pi-ai": "*",
|
|
53
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
54
|
+
"@earendil-works/pi-tui": "*",
|
|
55
|
+
"typebox": "*"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@earendil-works/pi-agent-core": "^0.78.0",
|
|
59
|
+
"@earendil-works/pi-ai": "^0.78.0",
|
|
60
|
+
"@earendil-works/pi-coding-agent": "^0.78.0",
|
|
61
|
+
"@earendil-works/pi-tui": "^0.78.0",
|
|
62
|
+
"typebox": "^1.1.38",
|
|
63
|
+
"typescript": "^5.6.0"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: taskflow
|
|
3
|
+
description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use when a task needs several coordinated subagent steps, fan-out over many items (files, endpoints, modules), cross-checked/adversarial review, or a repeatable orchestration you want to save and rerun. Not for a single delegated task — use the subagent tool for that.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Taskflow
|
|
7
|
+
|
|
8
|
+
Build and run **declarative, multi-phase workflows** of subagents. The runtime
|
|
9
|
+
holds intermediate results and the phase DAG, so your main context only receives
|
|
10
|
+
the final answer — not every step's transcript.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- A task needs **several coordinated steps** (discover → work → review → report).
|
|
15
|
+
- You need to **fan out over many items** (audit every endpoint, summarize every file).
|
|
16
|
+
- You want **cross-checked / adversarial review** before reporting.
|
|
17
|
+
- You want a **repeatable** orchestration saved as a `/tf:<name>` command.
|
|
18
|
+
|
|
19
|
+
For a single delegated task, use the `subagent` tool instead.
|
|
20
|
+
|
|
21
|
+
## How to author a taskflow
|
|
22
|
+
|
|
23
|
+
Call the `taskflow` tool. To run a brand-new flow you write inline, pass
|
|
24
|
+
`action: "run"` with a `define` object. To run a saved flow, pass `name`.
|
|
25
|
+
|
|
26
|
+
### DSL shape
|
|
27
|
+
|
|
28
|
+
```jsonc
|
|
29
|
+
{
|
|
30
|
+
"name": "audit-endpoints",
|
|
31
|
+
"description": "Audit API endpoints for missing auth",
|
|
32
|
+
"args": { "dir": { "default": "src/routes" } },
|
|
33
|
+
"concurrency": 8,
|
|
34
|
+
"agentScope": "user", // user | project | both
|
|
35
|
+
"phases": [
|
|
36
|
+
{ "id": "discover", "type": "agent", "agent": "scout",
|
|
37
|
+
"task": "List endpoints under {args.dir}. Output ONLY a JSON array [{\"route\":\"\",\"file\":\"\"}].",
|
|
38
|
+
"output": "json" },
|
|
39
|
+
{ "id": "audit", "type": "map", "over": "{steps.discover.json}", "as": "item",
|
|
40
|
+
"agent": "analyst", "task": "Audit {item.route} ({item.file}) for missing auth.",
|
|
41
|
+
"dependsOn": ["discover"] },
|
|
42
|
+
{ "id": "review", "type": "gate", "agent": "reviewer",
|
|
43
|
+
"task": "Remove false positives from:\n{steps.audit.output}", "dependsOn": ["audit"] },
|
|
44
|
+
{ "id": "report", "type": "reduce", "from": ["review"], "agent": "writer",
|
|
45
|
+
"task": "Write a final report:\n{steps.review.output}", "dependsOn": ["review"],
|
|
46
|
+
"final": true }
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Phase types
|
|
52
|
+
|
|
53
|
+
| type | meaning |
|
|
54
|
+
|------|---------|
|
|
55
|
+
| `agent` | one subagent runs `task` |
|
|
56
|
+
| `parallel` | run `branches[]` concurrently |
|
|
57
|
+
| `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound |
|
|
58
|
+
| `gate` | quality/review step (a focused agent pass) |
|
|
59
|
+
| `reduce` | aggregate `from[]` phases into one output |
|
|
60
|
+
|
|
61
|
+
### Interpolation
|
|
62
|
+
|
|
63
|
+
- `{args.X}` — invocation argument
|
|
64
|
+
- `{steps.ID.output}` — a prior phase's text output
|
|
65
|
+
- `{steps.ID.json}` / `{steps.ID.json.field}` — prior output parsed as JSON
|
|
66
|
+
- `{item}` / `{item.field}` — current item inside a `map` phase
|
|
67
|
+
- `{previous.output}` — the immediately-upstream phase output
|
|
68
|
+
|
|
69
|
+
## Rules that make flows work
|
|
70
|
+
|
|
71
|
+
1. For a `map` phase, make the upstream phase **emit a JSON array** and set
|
|
72
|
+
`output: "json"` on it. Tell that agent to output **only** JSON.
|
|
73
|
+
2. Give each phase a clear, single responsibility.
|
|
74
|
+
3. Reference upstream results explicitly with `{steps.ID...}` and set `dependsOn`.
|
|
75
|
+
4. Mark the result-bearing phase with `"final": true` (else the last phase wins).
|
|
76
|
+
|
|
77
|
+
## Actions
|
|
78
|
+
|
|
79
|
+
- `action: "run"` — run inline `define` or a saved `name` (with optional `args`).
|
|
80
|
+
- `action: "save"` — persist `define` (scope `project` or `user`); becomes `/tf:<name>`.
|
|
81
|
+
- `action: "resume"` — continue a paused/failed run by `runId` (completed phases are cached).
|
|
82
|
+
- `action: "list"` — list saved flows.
|
|
83
|
+
|
|
84
|
+
## User commands
|
|
85
|
+
|
|
86
|
+
- `/tf list` · `/tf run <name> [args]` · `/tf show <name>` · `/tf runs` · `/tf resume <runId>`
|
|
87
|
+
- `/tf:<name> [args]` — shortcut for each saved flow
|