pi-taskflow 0.0.4 → 0.0.6

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.
@@ -12,10 +12,11 @@ import { Type, type Static } from "typebox";
12
12
  // Phase types
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce"] as const;
15
+ export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow"] as const;
16
16
  export type PhaseType = (typeof PHASE_TYPES)[number];
17
17
 
18
18
  export const OUTPUT_FORMATS = ["text", "json"] as const;
19
+ export const JOIN_MODES = ["all", "any"] as const;
19
20
 
20
21
  const ParallelTaskSchema = Type.Object(
21
22
  {
@@ -25,6 +26,29 @@ const ParallelTaskSchema = Type.Object(
25
26
  { additionalProperties: false },
26
27
  );
27
28
 
29
+ /** Declarative retry policy for a phase's subagent call(s). */
30
+ const RetrySchema = Type.Object(
31
+ {
32
+ max: Type.Number({ description: "Max retry attempts after the first try (>= 0)" }),
33
+ backoffMs: Type.Optional(Type.Number({ description: "Base delay between attempts, in ms", default: 0 })),
34
+ factor: Type.Optional(
35
+ Type.Number({ description: "Backoff multiplier per attempt (1 = fixed, 2 = exponential)", default: 1 }),
36
+ ),
37
+ },
38
+ { additionalProperties: false },
39
+ );
40
+
41
+ /** Run-wide cost / token ceiling. Exceeding it halts the run (remaining phases skipped). */
42
+ const BudgetSchema = Type.Object(
43
+ {
44
+ maxUSD: Type.Optional(Type.Number({ description: "Halt the run once accumulated cost exceeds this many USD" })),
45
+ maxTokens: Type.Optional(
46
+ Type.Number({ description: "Halt the run once accumulated input+output tokens exceed this" }),
47
+ ),
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
28
52
  const PhaseSchema = Type.Object(
29
53
  {
30
54
  id: Type.String({ description: "Unique phase identifier (referenced via {steps.<id>.output})" }),
@@ -46,7 +70,28 @@ const PhaseSchema = Type.Object(
46
70
  Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" }),
47
71
  ),
48
72
 
73
+ // sub-workflow (flow)
74
+ use: Type.Optional(Type.String({ description: "[flow] Name of a saved taskflow to run as this phase" })),
75
+ with: Type.Optional(
76
+ Type.Record(Type.String(), Type.Unknown(), {
77
+ description: "[flow] Args passed to the sub-flow (string values support interpolation)",
78
+ }),
79
+ ),
80
+
49
81
  dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Phase ids this phase depends on" })),
82
+ join: Type.Optional(
83
+ StringEnum(JOIN_MODES, {
84
+ description: "Dependency join: 'all' (default) waits for every dep; 'any' runs as soon as one dep completes",
85
+ default: "all",
86
+ }),
87
+ ),
88
+ when: Type.Optional(
89
+ Type.String({
90
+ description:
91
+ "Conditional guard: skip this phase unless the expression is truthy. Supports {refs} and == != < > <= >= && || ! ()",
92
+ }),
93
+ ),
94
+ retry: Type.Optional(RetrySchema),
50
95
  output: Type.Optional(StringEnum(OUTPUT_FORMATS, { description: "Parse output as text or json", default: "text" })),
51
96
  model: Type.Optional(Type.String({ description: "Model override for this phase" })),
52
97
  thinking: Type.Optional(Type.String({ description: "Thinking level override for this phase" })),
@@ -77,6 +122,7 @@ export const TaskflowSchema = Type.Object(
77
122
  version: Type.Optional(Type.Number({ default: 1 })),
78
123
  args: Type.Optional(Type.Record(Type.String(), ArgSpecSchema, { description: "Declared invocation arguments" })),
79
124
  concurrency: Type.Optional(Type.Number({ description: "Default max concurrent subagents", default: 8 })),
125
+ budget: Type.Optional(BudgetSchema),
80
126
  agentScope: Type.Optional(
81
127
  StringEnum(["user", "project", "both"] as const, { description: "Agent discovery scope", default: "user" }),
82
128
  ),
@@ -89,6 +135,9 @@ export type ParallelTask = Static<typeof ParallelTaskSchema>;
89
135
  export type Phase = Static<typeof PhaseSchema>;
90
136
  export type Taskflow = Static<typeof TaskflowSchema>;
91
137
  export type ArgSpec = Static<typeof ArgSpecSchema>;
138
+ export type RetryPolicy = Static<typeof RetrySchema>;
139
+ export type Budget = Static<typeof BudgetSchema>;
140
+ export type JoinMode = (typeof JOIN_MODES)[number];
92
141
 
93
142
  // ---------------------------------------------------------------------------
94
143
  // Shorthand (non-DAG) specs — subagent-style ergonomics
@@ -227,6 +276,25 @@ export function validateTaskflow(def: unknown): ValidationResult {
227
276
  if (!p.from || p.from.length === 0) errors.push(`Phase '${p.id}' (reduce) requires 'from'`);
228
277
  if (!p.task) errors.push(`Phase '${p.id}' (reduce) requires 'task'`);
229
278
  }
279
+ if (type === "flow") {
280
+ if (!p.use) errors.push(`Phase '${p.id}' (flow) requires 'use' (a saved flow name)`);
281
+ }
282
+ if (p.retry) {
283
+ if (typeof p.retry.max !== "number" || p.retry.max < 0) {
284
+ errors.push(`Phase '${p.id}': retry.max must be a number >= 0`);
285
+ } else if (p.retry.max > 20) {
286
+ errors.push(`Phase '${p.id}': retry.max must be <= 20`);
287
+ }
288
+ if (p.retry.backoffMs !== undefined && (p.retry.backoffMs < 0 || p.retry.backoffMs > 60000)) {
289
+ errors.push(`Phase '${p.id}': retry.backoffMs must be between 0 and 60000`);
290
+ }
291
+ if (p.retry.factor !== undefined && (p.retry.factor < 1 || p.retry.factor > 10)) {
292
+ errors.push(`Phase '${p.id}': retry.factor must be between 1 and 10`);
293
+ }
294
+ }
295
+ if (p.join && !JOIN_MODES.includes(p.join as JoinMode)) {
296
+ errors.push(`Phase '${p.id}': unknown join mode '${p.join}'`);
297
+ }
230
298
  }
231
299
 
232
300
  // dependsOn / from references must exist
@@ -247,7 +315,7 @@ export function validateTaskflow(def: unknown): ValidationResult {
247
315
  }
248
316
 
249
317
  // Exactly handle final-phase resolution lazily (0 finals => last phase is final)
250
- const finals = (flow.phases as Phase[]).filter((p) => p.final);
318
+ const finals = (flow.phases as Phase[]).filter((p) => p?.final);
251
319
  if (finals.length > 1) errors.push(`Only one phase may be marked 'final' (found ${finals.length})`);
252
320
 
253
321
  return { ok: errors.length === 0, errors };
@@ -341,3 +409,18 @@ export function topoLayers(phases: Phase[]): Phase[][] {
341
409
  export function finalPhase(phases: Phase[]): Phase {
342
410
  return phases.find((p) => p.final) ?? phases[phases.length - 1];
343
411
  }
412
+
413
+ /**
414
+ * Apply a flow's declared arg defaults over the provided values, then pass
415
+ * through any extra provided keys. Shared by the tool entrypoint (index) and the
416
+ * sub-flow (`flow`) phase (runtime).
417
+ */
418
+ export function resolveArgs(def: Taskflow, provided?: Record<string, unknown>): Record<string, unknown> {
419
+ const args: Record<string, unknown> = {};
420
+ for (const [key, spec] of Object.entries(def.args ?? {})) {
421
+ if (provided && key in provided) args[key] = provided[key];
422
+ else if (spec.default !== undefined) args[key] = spec.default;
423
+ }
424
+ if (provided) for (const [k, v] of Object.entries(provided)) if (!(k in args)) args[k] = v;
425
+ return args;
426
+ }
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
11
11
  import * as path from "node:path";
12
12
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
13
  import type { Taskflow } from "./schema.ts";
14
- import type { UsageStats } from "./runner.ts";
14
+ import type { UsageStats } from "./usage.ts";
15
15
 
16
16
  export interface SavedFlow {
17
17
  name: string;
@@ -39,6 +39,12 @@ export interface PhaseState {
39
39
  liveText?: string;
40
40
  /** Gate verdict (gate phases only). */
41
41
  gate?: { verdict: "pass" | "block"; reason?: string };
42
+ /** Total subagent attempts incl. retries (when > calls, a retry happened). */
43
+ attempts?: number;
44
+ /** True when a map/parallel fan-out was cut short by the budget cap. */
45
+ budgetTruncated?: boolean;
46
+ /** Human-in-the-loop outcome (approval phases only). */
47
+ approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean };
42
48
  }
43
49
 
44
50
  export interface RunState {
@@ -118,7 +124,7 @@ export function saveFlow(
118
124
  fs.mkdirSync(dir, { recursive: true });
119
125
  const safe = def.name.replace(/[^\w.-]+/g, "_");
120
126
  const filePath = path.join(dir, `${safe}.json`);
121
- fs.writeFileSync(filePath, `${JSON.stringify(def, null, 2)}\n`, "utf-8");
127
+ writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`);
122
128
  return { filePath };
123
129
  }
124
130
 
@@ -138,7 +144,7 @@ export function saveRun(state: RunState): void {
138
144
  const dir = runsDir(state.cwd);
139
145
  fs.mkdirSync(dir, { recursive: true });
140
146
  state.updatedAt = Date.now();
141
- fs.writeFileSync(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2), "utf-8");
147
+ writeFileAtomic(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2));
142
148
  }
143
149
 
144
150
  export function loadRun(cwd: string, runId: string): RunState | null {
@@ -174,3 +180,23 @@ export function listRuns(cwd: string, limit = 20): RunState[] {
174
180
  export function hashInput(...parts: string[]): string {
175
181
  return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
176
182
  }
183
+
184
+ /**
185
+ * Write a file atomically: write to a unique temp file in the same directory,
186
+ * then rename over the target (rename is atomic on the same filesystem). Prevents
187
+ * a crash or concurrent write from leaving a half-written, corrupt JSON file.
188
+ */
189
+ function writeFileAtomic(filePath: string, data: string): void {
190
+ const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
191
+ try {
192
+ fs.writeFileSync(tmp, data, "utf-8");
193
+ fs.renameSync(tmp, filePath);
194
+ } catch (e) {
195
+ try {
196
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
197
+ } catch {
198
+ /* ignore cleanup failure */
199
+ }
200
+ throw e;
201
+ }
202
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Usage accounting — token/cost stats shared across the runner (producer),
3
+ * runtime (aggregation), store (persistence), and render (display).
4
+ *
5
+ * Kept in its own leaf module so persistence and TUI don't have to depend on
6
+ * the process-spawning layer (`runner.ts`) just for these types/helpers.
7
+ */
8
+
9
+ export interface UsageStats {
10
+ input: number;
11
+ output: number;
12
+ cacheRead: number;
13
+ cacheWrite: number;
14
+ cost: number;
15
+ contextTokens: number;
16
+ turns: number;
17
+ }
18
+
19
+ export function emptyUsage(): UsageStats {
20
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
21
+ }
22
+
23
+ /** Sum numeric usage fields across runs. `contextTokens` is intentionally excluded (it is a point-in-time gauge, not additive). */
24
+ export function aggregateUsage(usages: UsageStats[]): UsageStats {
25
+ const total = emptyUsage();
26
+ for (const u of usages) {
27
+ total.input += u.input;
28
+ total.output += u.output;
29
+ total.cacheRead += u.cacheRead;
30
+ total.cacheWrite += u.cacheWrite;
31
+ total.cost += u.cost;
32
+ total.turns += u.turns;
33
+ }
34
+ return total;
35
+ }
36
+
37
+ export function formatTokens(count: number): string {
38
+ if (count < 1000) return count.toString();
39
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
40
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
41
+ return `${(count / 1000000).toFixed(1)}M`;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
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
5
  "keywords": [
6
6
  "pi-package",
@@ -36,7 +36,7 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "typecheck": "tsc --noEmit",
39
- "test": "node --experimental-strip-types --test test/interpolate.test.ts test/schema.test.ts test/runtime.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
39
+ "test": "node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
40
40
  "test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
41
41
  },
42
42
  "pi": {
@@ -86,6 +86,71 @@ Call the `taskflow` tool. To run a brand-new flow you write inline, pass
86
86
  | `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound |
87
87
  | `gate` | quality/review step that can **halt the flow** (see below) |
88
88
  | `reduce` | aggregate `from[]` phases into one output |
89
+ | `approval` | **human-in-the-loop** pause: ask a person to approve / reject / edit before continuing |
90
+ | `flow` | run a **saved sub-flow** (by `use`) as a single phase — composition/reuse |
91
+
92
+ ### Control-flow fields (any phase)
93
+
94
+ | field | meaning |
95
+ |-------|---------|
96
+ | `when` | conditional guard — skip the phase unless the expression is truthy. Supports `{refs}`, `== != < > <= >=`, `&& \|\| !`, parentheses, quoted strings/numbers. Parse errors fail **open** (phase runs). |
97
+ | `join` | dependency join: `"all"` (default — wait for every dep) or `"any"` (OR-join — run as soon as one dep completes). |
98
+ | `retry` | `{ "max": N, "backoffMs": ms, "factor": k }` — retry a failing subagent up to N times; delay is `backoffMs * factor^attempt` (`factor:1`=fixed, `2`=exponential). |
99
+
100
+ ### Conditional routing (when + gate/branches)
101
+
102
+ Pair `when` with an upstream phase that emits a decision to build real if/else
103
+ routing. Use `join: "any"` on the merge phase so it runs whichever branch fired:
104
+
105
+ ```jsonc
106
+ { "id": "triage", "type": "agent", "agent": "analyst", "output": "json",
107
+ "task": "Classify the task. Output ONLY {\"route\":\"deep\"} or {\"route\":\"quick\"}." },
108
+ { "id": "deep", "when": "{steps.triage.json.route} == deep", "dependsOn": ["triage"], "agent": "analyst", "task": "..." },
109
+ { "id": "quick", "when": "{steps.triage.json.route} == quick", "dependsOn": ["triage"], "agent": "executor_fast", "task": "..." },
110
+ { "id": "report", "type": "reduce", "from": ["deep","quick"], "join": "any",
111
+ "dependsOn": ["deep","quick"], "agent": "writer", "task": "...", "final": true }
112
+ ```
113
+
114
+ > `when` should reference **upstream** (`dependsOn`) phases — a ref to a phase
115
+ > that hasn't completed resolves empty and the guard is treated as false.
116
+
117
+ ### Approval phases (human-in-the-loop)
118
+
119
+ An `approval` phase pauses the run and asks the operator to **Approve / Reject /
120
+ Edit**. Distinct from `gate` (which is an *agent* reviewing): this is a *human*
121
+ deciding. The (interpolated) `task` is the prompt shown.
122
+
123
+ - **Approve** → continue; the phase output is `(approve)`.
124
+ - **Reject** → halt the flow (same mechanism as a blocking gate).
125
+ - **Edit** → the typed note becomes this phase's `output`, so you can inject
126
+ guidance mid-run: reference it downstream with `{steps.<id>.output}`.
127
+ - **Non-interactive** runs (headless/CI/print mode) **auto-approve** and record it.
128
+
129
+ ```jsonc
130
+ { "id": "checkpoint", "type": "approval", "dependsOn": ["plan"],
131
+ "task": "Review the plan above before the expensive fan-out. Approve, reject, or add guidance." }
132
+ ```
133
+
134
+ ### Sub-flows (composition)
135
+
136
+ A `flow` phase runs another **saved** taskflow by name and bubbles up its final
137
+ output. Pass args via `with` (string values interpolate). Recursion is detected
138
+ and rejected.
139
+
140
+ ```jsonc
141
+ { "id": "research", "type": "flow", "use": "deep-research",
142
+ "with": { "topic": "{item}" }, "dependsOn": ["plan"] }
143
+ ```
144
+
145
+ ### Budget (cost / token caps)
146
+
147
+ Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it,
148
+ remaining phases are skipped (and an in-flight `map`/`parallel` stops spawning
149
+ new items); the run ends as `blocked`.
150
+
151
+ ```jsonc
152
+ { "name": "...", "budget": { "maxUSD": 1.50, "maxTokens": 2000000 }, "phases": [ ... ] }
153
+ ```
89
154
 
90
155
  ### Gate phases (quality control)
91
156
 
@@ -123,6 +188,20 @@ Review the audit results below. If any endpoint is missing auth, end with
123
188
  3. Reference upstream results explicitly with `{steps.ID...}` and set `dependsOn`.
124
189
  4. Mark the result-bearing phase with `"final": true` (else the last phase wins).
125
190
 
191
+ ## Configuration
192
+
193
+ For the full set of knobs — per-phase `model`/`thinking`/`tools`/`cwd`, the
194
+ two-level concurrency model, model/thinking/tools resolution precedence,
195
+ `agentScope` & agent discovery, `settings.json` overrides, environment
196
+ variables, and storage paths — read `configuration.md` (next to this file).
197
+
198
+ Quick reference:
199
+
200
+ - **Flow:** `name`, `description`, `concurrency` (default 8), `budget` (`maxUSD`/`maxTokens`), `agentScope` (user|project|both), `args`.
201
+ - **Phase:** `model`, `thinking`, `tools` (whitelist), `cwd`, `output:"json"`, `concurrency` (map/parallel fan-out), `when`, `join` (all|any), `retry`, `use`/`with` (flow), `final`.
202
+ - **Precedence (model/thinking/tools):** phase value → `settings.subagents.agentOverrides[agent]` → agent frontmatter → global/default.
203
+ - **Concurrency:** same-layer phases use `flow.concurrency`; a `map`/`parallel` phase uses `phase.concurrency ?? flow.concurrency ?? 8`.
204
+
126
205
  ## Actions
127
206
 
128
207
  - `action: "run"` — run inline `define` or a saved `name` (with optional `args`).
@@ -0,0 +1,275 @@
1
+ # Taskflow Configuration Reference
2
+
3
+ Every knob you can set on a taskflow, where it lives, and how the values are
4
+ resolved. Read this when you need fine control over models, concurrency, agent
5
+ discovery, working directories, tool restrictions, or storage.
6
+
7
+ Configuration lives in **five layers**, from most local to most global:
8
+
9
+ | Layer | Where | Sets |
10
+ |-------|-------|------|
11
+ | Phase | a phase object in the DSL | per-step model/thinking/tools/cwd/output/concurrency |
12
+ | Flow | the top-level DSL object | name, args, default concurrency, agent scope |
13
+ | Agent | `~/.pi/agent/agents/*.md`, `.pi/agents/*.md` frontmatter | per-agent default model/thinking/tools + system prompt |
14
+ | Settings | `~/.pi/agent/settings.json` | `subagents.agentOverrides`, global thinking |
15
+ | Environment | shell env | `PI_TASKFLOW_PI_BIN` |
16
+
17
+ ---
18
+
19
+ ## 1. Flow-level options
20
+
21
+ Top-level keys of the taskflow definition object.
22
+
23
+ ```jsonc
24
+ {
25
+ "name": "audit-endpoints", // required — also becomes /tf:<name> when saved
26
+ "description": "Audit API auth", // shown in /tf list and the command palette
27
+ "concurrency": 8, // default max concurrent subagents (default: 8)
28
+ "agentScope": "user", // user | project | both (default: user)
29
+ "args": { /* see §3 */ },
30
+ "phases": [ /* see §2 */ ] // required, at least one phase
31
+ }
32
+ ```
33
+
34
+ | Key | Type | Default | Notes |
35
+ |-----|------|---------|-------|
36
+ | `name` | string | — | **Required.** Saved as `/tf:<name>`. |
37
+ | `description` | string | — | Surfaced in `/tf list` and the slash-command. |
38
+ | `concurrency` | number | `8` | Default fan-out / same-layer parallelism cap. See §4. |
39
+ | `agentScope` | `user`\|`project`\|`both` | `user` | Which agent dirs to load. See §6. |
40
+ | `args` | record | `{}` | Declared invocation arguments. See §3. |
41
+ | `phases` | array | — | **Required.** The phase DAG. See §2. |
42
+ | `version` | number | `1` | ⚠️ Declared in schema but **not yet used** by the runtime. |
43
+
44
+ ---
45
+
46
+ ## 2. Phase-level options
47
+
48
+ Keys of each object in `phases[]`. Some only apply to specific `type`s.
49
+
50
+ ```jsonc
51
+ {
52
+ "id": "audit", // required, unique — referenced via {steps.audit.output}
53
+ "type": "map", // agent | parallel | map | gate | reduce (default: agent)
54
+ "agent": "analyst", // agent name to run this phase
55
+ "task": "Audit {item.route}…",
56
+ "dependsOn": ["discover"],// DAG edges
57
+ "over": "{steps.discover.json}", // [map] array to fan out over
58
+ "as": "item", // [map] loop var name (default: item)
59
+ "branches": [ /* … */ ], // [parallel] static task list
60
+ "from": ["audit"], // [reduce] phase ids to aggregate
61
+ "output": "json", // text | json (default: text)
62
+ "model": "claude-sonnet-4-5", // per-phase model override
63
+ "thinking": "high", // per-phase thinking override
64
+ "tools": ["read","bash"], // restrict tools for this phase's subagent
65
+ "cwd": "packages/api", // working directory for this phase's subagent
66
+ "concurrency": 4, // [map/parallel] fan-out cap for THIS phase
67
+ "final": true // mark this phase's output as the workflow result
68
+ }
69
+ ```
70
+
71
+ | Key | Applies to | Default | Notes |
72
+ |-----|-----------|---------|-------|
73
+ | `id` | all | — | **Required, unique.** Used in `{steps.<id>…}`. |
74
+ | `type` | all | `agent` | One of the 5 phase types. |
75
+ | `agent` | all | first available | Agent name; resolved from the scoped pool. |
76
+ | `task` | agent, gate, map, reduce | — | Prompt; supports interpolation. Required for these types. |
77
+ | `over` | map | — | **Required for map.** Must resolve to an array. |
78
+ | `as` | map | `item` | Loop variable bound per item. |
79
+ | `branches` | parallel | — | **Required for parallel.** `[{task, agent?}]`. |
80
+ | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. |
81
+ | `dependsOn` | all | `[]` | DAG edges. `from` also implies a dependency. |
82
+ | `output` | all | `text` | `json` parses output so `{steps.id.json}` / map `over` work. |
83
+ | `model` | all | agent/global | Per-phase model override. See §5. |
84
+ | `thinking` | all | agent/global | Per-phase thinking level. See §5. |
85
+ | `tools` | all | agent default | Whitelist of tools for the subagent. See §5. |
86
+ | `cwd` | all | flow cwd | Run this phase's subagent in a different directory. |
87
+ | `concurrency` | map, parallel | flow concurrency | Fan-out cap for this phase only. See §4. |
88
+ | `final` | all | last phase | Exactly one phase may be `final`; its output is returned. |
89
+ | `optional` | all | `false` | ⚠️ Declared in schema but **not yet enforced** — a failed phase still skips downstream. |
90
+
91
+ ---
92
+
93
+ ## 3. Declaring & passing arguments
94
+
95
+ Declare arguments on the flow, then reference them with `{args.X}`.
96
+
97
+ ```jsonc
98
+ "args": {
99
+ "dir": { "default": "src", "description": "Directory to scan" },
100
+ "depth": { "default": 2 },
101
+ "token": { "required": true, "description": "API token" }
102
+ }
103
+ ```
104
+
105
+ | Field | Notes |
106
+ |-------|-------|
107
+ | `default` | Used when the caller omits the arg. |
108
+ | `description` | Documentation only. |
109
+ | `required` | ⚠️ Declared but **not enforced** at runtime — treat as documentation for now. |
110
+
111
+ **Resolution:** for each declared arg, the provided value wins, else its
112
+ `default`. Any extra provided keys are also passed through (so undeclared args
113
+ still reach `{args.X}`).
114
+
115
+ **Passing args:**
116
+
117
+ ```
118
+ /tf run audit-endpoints {"dir":"packages/api"} # JSON
119
+ /tf run audit-endpoints dir=packages/api depth=3 # key=value pairs
120
+ /tf run audit-endpoints packages/api # single positional → first declared arg
121
+ ```
122
+
123
+ Via the tool: `{ "action": "run", "name": "audit-endpoints", "args": { "dir": "packages/api" } }`.
124
+
125
+ ---
126
+
127
+ ## 4. Concurrency model
128
+
129
+ There are **two independent concurrency limits**:
130
+
131
+ 1. **Same-layer parallelism** — phases with no dependency between them sit in the
132
+ same topological layer and run concurrently, bounded by **`flow.concurrency`**
133
+ (default `8`).
134
+ 2. **Fan-out within a `map`/`parallel` phase** — bounded by
135
+ **`phase.concurrency ?? flow.concurrency ?? 8`**.
136
+
137
+ ```jsonc
138
+ {
139
+ "concurrency": 6, // ≤6 sibling phases run at once
140
+ "phases": [
141
+ { "id": "scan", "type": "map", "over": "{steps.list.json}",
142
+ "concurrency": 3, // …but this map only fans out 3 at a time
143
+ "task": "…", "dependsOn": ["list"] }
144
+ ]
145
+ }
146
+ ```
147
+
148
+ Set a low `phase.concurrency` to protect rate-limited models or heavy bash work;
149
+ keep `flow.concurrency` higher to let independent phases overlap.
150
+
151
+ ---
152
+
153
+ ## 5. Model, thinking & tools resolution
154
+
155
+ For any phase, the effective value is resolved in this **precedence order**
156
+ (first defined wins):
157
+
158
+ | Setting | Precedence (high → low) |
159
+ |---------|-------------------------|
160
+ | **model** | `phase.model` → `settings.agentOverrides[agent].model` → agent frontmatter `model` → pi default |
161
+ | **thinking** | `phase.thinking` → `settings.agentOverrides[agent].thinking` → agent frontmatter `thinking` → `settings` global thinking → pi default |
162
+ | **tools** | `phase.tools` → `settings.agentOverrides[agent].tools` → agent frontmatter `tools` → all tools |
163
+
164
+ Notes:
165
+ - `tools` is a **whitelist** passed as `--tools a,b,c`. Omit it to allow all.
166
+ - Each phase runs as an isolated process:
167
+ `pi --mode json -p --no-session [--model …] [--thinking …] [--tools …] [--append-system-prompt <agent>] "Task: …"`.
168
+ - The agent's markdown body becomes the subagent's appended system prompt.
169
+
170
+ ---
171
+
172
+ ## 6. Agent discovery & scope
173
+
174
+ `flow.agentScope` controls which agent directories are loaded:
175
+
176
+ | Scope | Loads from |
177
+ |-------|-----------|
178
+ | `user` (default) | `~/.pi/agent/agents/*.md` |
179
+ | `project` | nearest `.pi/agents/*.md` found walking up from cwd |
180
+ | `both` | user **then** project (project overrides on name collision) |
181
+
182
+ - Agents are `.md` files with frontmatter `name` + `description` (required), plus
183
+ optional `model`, `thinking`, `tools`. The body is the system prompt.
184
+ - Reference agents in phases by their `name`. An unknown name fails that phase
185
+ with the list of available agents.
186
+ - If a phase omits `agent`, the **first discovered agent** is used.
187
+
188
+ ---
189
+
190
+ ## 7. settings.json
191
+
192
+ Taskflow shares the subagent settings file at `~/.pi/agent/settings.json`:
193
+
194
+ ```jsonc
195
+ {
196
+ "subagents": {
197
+ "globalThinking": "medium", // fallback thinking for all subagents
198
+ "agentOverrides": {
199
+ "analyst": { "model": "claude-sonnet-4-5", "thinking": "high" },
200
+ "scout": { "tools": ["read", "bash", "grep"] }
201
+ }
202
+ },
203
+ "defaultThinkingLevel": "low" // used if subagents.globalThinking is absent
204
+ }
205
+ ```
206
+
207
+ - `subagents.agentOverrides` — per-agent overrides applied at discovery; they beat
208
+ agent frontmatter but lose to a phase-level value (see §5).
209
+ - `subagents.globalThinking` (or top-level `defaultThinkingLevel`) — global
210
+ thinking fallback.
211
+
212
+ ---
213
+
214
+ ## 8. Environment variables
215
+
216
+ | Variable | Effect |
217
+ |----------|--------|
218
+ | `PI_TASKFLOW_PI_BIN` | Override the `pi` binary used to spawn subagents. Used by tests and unusual launch setups (e.g. `PI_TASKFLOW_PI_BIN=pi`). Normally auto-detected. |
219
+
220
+ ---
221
+
222
+ ## 9. Storage & file locations
223
+
224
+ | What | Path | Commit? |
225
+ |------|------|---------|
226
+ | User-scoped flow | `~/.pi/agent/taskflows/<name>.json` | personal |
227
+ | Project-scoped flow | `<nearest .pi>/taskflows/<name>.json` | ✅ commit to share |
228
+ | Run state (resume) | `<project .pi>/taskflows/runs/<runId>.json` | ❌ gitignore |
229
+
230
+ - `action: "save"` takes `scope: "project"` (default) or `"user"`.
231
+ - Saved flows auto-register as `/tf:<name>` (immediately for the current session,
232
+ and on future `session_start`).
233
+ - Project flows override user flows on a name collision.
234
+ - Add `.pi/taskflows/runs/` to `.gitignore`.
235
+
236
+ ---
237
+
238
+ ## 10. Quick recipes
239
+
240
+ **Pin a strong model only for the review gate:**
241
+ ```jsonc
242
+ { "id": "review", "type": "gate", "agent": "reviewer",
243
+ "model": "claude-opus-4", "thinking": "high",
244
+ "task": "…\nVERDICT:", "dependsOn": ["audit"] }
245
+ ```
246
+
247
+ **Sandbox a phase to read-only in a subdirectory:**
248
+ ```jsonc
249
+ { "id": "scan", "type": "agent", "agent": "scout",
250
+ "cwd": "packages/api", "tools": ["read", "grep", "ls"],
251
+ "task": "List route files. Output ONLY a JSON array.", "output": "json" }
252
+ ```
253
+
254
+ **Throttle a rate-limited fan-out:**
255
+ ```jsonc
256
+ { "id": "summarize", "type": "map", "over": "{steps.scan.json}",
257
+ "concurrency": 2, "agent": "writer",
258
+ "task": "Summarize {item.file}.", "dependsOn": ["scan"] }
259
+ ```
260
+
261
+ **Project-only agents:**
262
+ ```jsonc
263
+ { "name": "ci-audit", "agentScope": "project", "phases": [ /* … */ ] }
264
+ ```
265
+
266
+ ---
267
+
268
+ ## Caveats (declared but not yet enforced)
269
+
270
+ These keys validate but the runtime does **not** act on them yet — don't rely on
271
+ them for behavior:
272
+
273
+ - `phase.optional` — a failed phase still marks downstream phases as skipped.
274
+ - `arg.required` — missing required args are not rejected.
275
+ - `flow.version` — informational only.