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.
@@ -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