pi-taskflow 0.0.3 → 0.0.5

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/README.md CHANGED
@@ -1,94 +1,238 @@
1
1
  # pi-taskflow
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/pi-taskflow.svg)](https://www.npmjs.com/package/pi-taskflow)
4
+ [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
5
+ [![Pi coding agent](https://img.shields.io/badge/for-Pi%20coding%20agent-7c3aed.svg)](https://pi.dev)
6
+
3
7
  > Lightweight workflow orchestration for the [Pi coding agent](https://pi.dev).
4
8
 
5
- Describe a multi-phase workflow once; let a deterministic runtime orchestrate
6
- isolated subagents to execute it. Intermediate results stay **out of your main
7
- context** only the final answer comes back. Inspired by Claude Code's Dynamic
8
- Workflows, rebuilt as a lightweight, declarative pi extension.
9
+ **Orchestrate your Pi subagents. Not by prompting by declaring.**
10
+
11
+ If you've used the built-in subagent tool's `task` / `tasks` / `chain`, you
12
+ already know the shorthand — your runs just get tracked, resumable, and
13
+ saveable as a one-word `/tf:<name>` command.
9
14
 
10
15
  ```bash
11
16
  pi install npm:pi-taskflow
12
17
  ```
13
18
 
19
+ Fan out one subagent per item, gate the results with an adversarial review, and
20
+ get back only the final report — none of the intermediate transcripts ever touch
21
+ your conversation.
22
+
14
23
  ## Why
15
24
 
16
- `subagent` is great for a single delegated task. But when a job needs **many
17
- coordinated steps**, **fan-out over dozens of items**, **cross-checked review**,
18
- or a **repeatable** pipeline, you want orchestration — without bloating your
19
- conversation with every step's transcript.
25
+ The built-in subagent tool is great for a single delegated task. But when a job
26
+ needs many coordinated steps, fan-out over dozens of items, cross-checked review,
27
+ or a repeatable pipeline, you want orchestration — without the intermediate
28
+ transcripts eating your context window.
20
29
 
21
30
  `pi-taskflow` moves the plan into a small declarative definition. The runtime
22
31
  holds the DAG, the loops, and the intermediate results; your context receives
23
32
  only the final phase's output.
24
33
 
25
- | | `subagent` | `pi-taskflow` |
34
+ | | `subagent` tool | `pi-taskflow` |
26
35
  |---|---|---|
27
36
  | Who drives | the model, turn by turn | the runtime, from a definition |
28
37
  | Intermediate results | in your context window | in the runtime (not your context) |
29
38
  | Reusable | re-described each time | saved as `/tf:<name>` |
30
39
  | Scale | a few tasks | dynamic `map` fan-out |
31
- | Resumable | no | yes (cross-session) |
40
+ | Resumable | no | yes (cross-session, cached phases skip) |
41
+ | Quality gates | no | `gate` phases with `VERDICT: BLOCK / PASS` |
42
+ | Progress visibility | opaque while running | live DAG render with timing + cost |
43
+ | Ergonomics | inline JSON each time | shorthand (`task`/`tasks`/`chain`) or DSL |
44
+
45
+ ## Show me
46
+
47
+ Describe a pipeline once, then run it from a pi session by name:
48
+
49
+ > `/tf:summarize-files dir=src`
50
+
51
+ The runtime fans out one subagent per file, merges the summaries in a `reduce`
52
+ phase, and returns only the final overview. Every intermediate transcript stays
53
+ in the runtime — never in your context window. (Full definition in
54
+ [Quickstart](#then-go-declarative) below.)
55
+
56
+ ## Quickstart
57
+
58
+ ### Shorthand: same effort as `subagent`, but tracked & resumable
59
+
60
+ **Single task** — one agent, one job:
61
+
62
+ ```jsonc
63
+ { "task": "Summarize the architecture of src/", "agent": "explorer" }
64
+ ```
65
+
66
+ **Parallel tasks** — fire several at once, outputs merge:
67
+
68
+ ```jsonc
69
+ { "tasks": [
70
+ { "task": "Audit auth in src/api", "agent": "analyst" },
71
+ { "task": "Audit input validation in src/api", "agent": "analyst" }
72
+ ] }
73
+ ```
32
74
 
33
- ## Concepts
75
+ **Chain** — sequential, each step sees the previous one's output:
76
+
77
+ ```jsonc
78
+ { "chain": [
79
+ { "task": "List the public API of src/lib", "agent": "scout" },
80
+ { "task": "Write docs for:\n{previous.output}", "agent": "writer" }
81
+ ] }
82
+ ```
34
83
 
35
- A **taskflow** is a set of **phases** forming a DAG via `dependsOn`. Each phase
36
- delegates to a subagent (an isolated `pi` process). Phases in the same DAG layer
37
- run concurrently (bounded by `concurrency`).
84
+ `agent` is optional (defaults to the first available agent). Add `name` to label
85
+ the run and enable saving it as a reusable command.
38
86
 
39
- ### Phase types
87
+ Try it inline — tell the model something like:
40
88
 
41
- | type | meaning |
42
- |------|---------|
43
- | `agent` | one subagent runs `task` |
44
- | `parallel` | run `branches[]` concurrently |
45
- | `map` | fan out over an array — one subagent per item, `{item}` bound |
46
- | `gate` | quality/adversarial-review step |
47
- | `reduce` | aggregate several phases' outputs into one |
89
+ > Run a chain: first explore the auth flow, then summarize findings.
48
90
 
49
- ### Interpolation
91
+ The model calls the `taskflow` tool; you get live progress, per-step timing,
92
+ token cost, and a run record. Ask to `save` it and you get `/tf:<name>`.
50
93
 
51
- - `{args.X}` invocation argument
52
- - `{steps.ID.output}` — a prior phase's text output
53
- - `{steps.ID.json}` / `{steps.ID.json.field}` — prior output parsed as JSON
54
- - `{item}` / `{item.field}` — current item inside a `map` phase
55
- - `{previous.output}` — the immediately-upstream phase output
94
+ ### Then go declarative
56
95
 
57
- ## Example
96
+ When your pipeline outgrows the shorthand — when you need dynamic fan-out,
97
+ intermediate JSON routing, or quality gates — graduate to the full DSL:
58
98
 
59
99
  ```jsonc
60
100
  {
61
101
  "name": "summarize-files",
102
+ "description": "Discover files, summarize each, produce a report",
62
103
  "args": { "dir": { "default": "." } },
63
- "concurrency": 4,
104
+ "concurrency": 8,
64
105
  "phases": [
65
106
  { "id": "discover", "type": "agent", "agent": "scout",
66
- "task": "List source files under {args.dir}. Output ONLY a JSON array [{\"file\":\"\"}].",
107
+ "task": "List source files under {args.dir} (non-recursive).\nOutput ONLY a JSON array [{\"file\":\"\"}]. No prose.",
67
108
  "output": "json" },
68
- { "id": "summarize", "type": "map", "over": "{steps.discover.json}", "as": "item",
69
- "agent": "scout", "task": "Read {item.file} and summarize it in one sentence.",
109
+ { "id": "summarize", "type": "map",
110
+ "over": "{steps.discover.json}", "as": "item",
111
+ "agent": "scout",
112
+ "task": "Read {item.file} and give a one-sentence summary.",
70
113
  "dependsOn": ["discover"] },
71
- { "id": "report", "type": "reduce", "from": ["summarize"], "agent": "writer",
114
+ { "id": "report", "type": "reduce", "from": ["summarize"],
115
+ "agent": "writer",
72
116
  "task": "Combine into a short overview:\n{steps.summarize.output}",
73
117
  "dependsOn": ["summarize"], "final": true }
74
118
  ]
75
119
  }
76
120
  ```
77
121
 
78
- ## Usage
122
+ What this does:
123
+
124
+ 1. **`discover`** — an agent lists every file in the directory and outputs a JSON array.
125
+ 2. **`summarize`** — a `map` fans out, spawning one subagent per file in parallel
126
+ (throttled to 8 concurrent). Each gets `{item.file}` bound to its file path.
127
+ 3. **`report`** — a `reduce` merges all summaries into one clean overview.
128
+
129
+ Intermediate outputs never enter your context. The runtime owns them. You get
130
+ only the final report back.
131
+
132
+ Save it once → `/tf:summarize-files` forever.
79
133
 
80
- The model calls the `taskflow` tool; you can also drive it directly:
134
+ ## Watch it run
135
+
136
+ This is the live progress render for a real run — the `self-improve` flow that
137
+ writes and verifies its own test suites, caught here mid-block by a quality gate:
81
138
 
82
139
  ```
83
- /tf list # saved flows
84
- /tf run <name> [args] # run a saved flow
85
- /tf show <name> # print a definition
86
- /tf runs # recent run history
87
- /tf resume <runId> # continue a paused/failed run (cached phases skipped)
88
- /tf:<name> [args] # shortcut per saved flow
140
+ ⊗ taskflow self-improve 6/7 · blocked · $0.095
141
+ discover agent deepseek-v4-flash 10t ↑38k ↓6.7k $0.011
142
+ write-runner-tests agent claude-sonnet-4-6 10t ↑13 ↓6.6k $0.020
143
+ write-store-tests agent claude-sonnet-4-6 10t ↑11 ↓10k $0.018
144
+ write-agents-tests agent claude-sonnet-4-6 10t ↑28 ↓13k $0.030
145
+ fix-stability agent claude-sonnet-4-6 10t ↑13 ↓3.9k $0.012
146
+ ✓ verify gate BLOCK 3 type errors in test files deepseek-v4-flash
147
+ ⊘ report reduce skipped · Gate blocked ↳ fix-stability
89
148
  ```
90
149
 
91
- Tool actions: `run` (inline `define` or saved `name`), `save`, `resume`, `list`.
150
+ **How to read it the layout *is* the DAG:**
151
+
152
+ - **Header** — `⊗` means the flow is blocked (a gate halted it); `6/7` phases
153
+ processed, aggregate cost `$0.095`.
154
+ - **Status icons** — `✓` done, `◐` running, `✗` failed, `⊘` skipped, `○` pending.
155
+ - **Rail `┌ ├ └`** — phases in the same DAG layer, running concurrently. The four
156
+ `write-*`/`fix-stability` tasks all fan out from `discover`. A blank gutter is
157
+ a single-phase layer.
158
+ - **`↳`** — a long (layer-skipping) dependency. `report` depends on `verify` (the
159
+ adjacent layer, implied by position) *and* `fix-stability` two layers back, so
160
+ only that skip edge is annotated.
161
+ - **Gate** — `verify` emitted `VERDICT: BLOCK`, so the runtime skipped `report`
162
+ and ended the run as `blocked`, surfacing the reason.
163
+ - **Detail** — per phase: model, token counts (`↑`in `↓`out), cost, and timing.
164
+ Fan-out phases also show sub-task progress.
165
+
166
+ ## Phase types
167
+
168
+ | type | meaning | required fields |
169
+ |------|---------|-----------------|
170
+ | `agent` | one subagent runs a single task | `task` |
171
+ | `parallel` | run `branches[]` concurrently | `branches` (array of `{task, agent?}`) |
172
+ | `map` | fan out over an array — one subagent per item, `{item}` bound | `over`, `task` |
173
+ | `gate` | quality/review step that can **halt the flow** | `task` |
174
+ | `reduce` | aggregate `from[]` phase outputs into one | `from`, `task` |
175
+
176
+ Every phase needs `id`. Optional fields: `agent`, `dependsOn`, `output`,
177
+ `model`, `thinking`, `tools`, `cwd`, `concurrency`, `final`, `optional`.
178
+
179
+ ### `output` format
180
+
181
+ - `output: "text"` (default) — the raw subagent output.
182
+ - `output: "json"` — the subagent output is parsed as JSON and exposed via
183
+ `{steps.ID.json}` / `{steps.ID.json.field}`. Set this on phases whose output
184
+ a downstream `map` or `reduce` needs to consume as structured data.
185
+
186
+ There is no `output: "file"`. For file-based output, have the agent write to
187
+ disk with a `write` tool call.
188
+
189
+ ### Gate phases (quality control)
190
+
191
+ A `gate` runs an agent to review upstream output and can **block the rest
192
+ of the workflow**. End the gate task's instructions by asking the agent to
193
+ emit a verdict the runtime can read:
194
+
195
+ - a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts `OK`, `FAIL`,
196
+ `STOP`, `REJECT`, `HALT` — last occurrence wins), or
197
+ - JSON like `{"continue": false, "reason": "missing auth checks"}` /
198
+ `{"verdict": "block", "reason": "..."}`.
199
+
200
+ On **BLOCK**, downstream phases are skipped and the run ends as `blocked` with
201
+ the reason surfaced. **Ambiguous output fails open** (treated as PASS) — a gate
202
+ never halts the flow by accident.
203
+
204
+ ```
205
+ Review the audit results below. If any endpoint is missing auth, end with
206
+ "VERDICT: BLOCK" and a one-line reason; otherwise end with "VERDICT: PASS".
207
+
208
+ {steps.audit.output}
209
+ ```
210
+
211
+ ## Interpolation
212
+
213
+ | placeholder | resolves to |
214
+ |---|---|
215
+ | `{args.X}` | invocation argument |
216
+ | `{steps.ID.output}` | a prior phase's text output |
217
+ | `{steps.ID.json}` | prior output parsed as JSON (or `{steps.ID.json.field}`) |
218
+ | `{item}` / `{item.field}` | current item inside a `map` phase |
219
+ | `{previous.output}` | the immediately-upstream phase output |
220
+
221
+ ## Commands
222
+
223
+ Saved flows become CLI shortcuts. All commands work in the pi session:
224
+
225
+ | Command | What it does |
226
+ |---|---|
227
+ | `/tf list` | List all saved flows |
228
+ | `/tf run <name> [args]` | Run a saved flow (e.g. `/tf run summarize-files dir=src`) |
229
+ | `/tf show <name>` | Print a flow's definition |
230
+ | `/tf runs` | Browse recent run history (interactive TUI) |
231
+ | `/tf resume <runId>` | Continue a paused/failed run — cached phases skip automatically |
232
+ | `/tf:<name> [args]` | Shortcut — runs the flow in one tap |
233
+
234
+ Tool actions (used by the model): `run` (inline `define` or saved `name`),
235
+ `save`, `resume`, `list`.
92
236
 
93
237
  ## Storage
94
238
 
@@ -98,30 +242,70 @@ Tool actions: `run` (inline `define` or saved `name`), `save`, `resume`, `list`.
98
242
  .pi/taskflows/runs/<runId>.json # run state (resume); gitignore this
99
243
  ```
100
244
 
245
+ Agent discovery scope (set via `agentScope` in the flow definition):
246
+
247
+ | value | discovers agents from |
248
+ |---|---|
249
+ | `"user"` (default) | `~/.pi/agent/agents/*.md` |
250
+ | `"project"` | `.pi/agents/*.md` (walks up the tree) |
251
+ | `"both"` | user + project; project wins on name collision |
252
+
101
253
  ## Agents
102
254
 
103
- Taskflow reuses your existing pi agents (`~/.pi/agent/agents/*.md`,
104
- `.pi/agents/*.md`) and honors `subagents.agentOverrides` in settings. Reference
105
- agents by `name`.
255
+ Taskflow reuses your existing pi agent files (`~/.pi/agent/agents/*.md`,
256
+ `.pi/agents/*.md`). Reference agents by `name` in a phase or shorthand.
257
+
258
+ When running a phase, the runtime extracts the agent's `systemPrompt` from its
259
+ `.md` frontmatter and passes it via `--append-system-prompt` (written to a temp
260
+ file). Phase-level overrides for `model`, `thinking`, and `tools` are passed as
261
+ `--model` / `--thinking` / `--tools` flags to the subagent invocation.
262
+
263
+ Settings from `~/.pi/agent/settings.json` (the `subagents.agentOverrides` map)
264
+ are honored, letting you tweak model, thinking, or tools per agent across all flows.
265
+
266
+ ## Status & limits
267
+
268
+ - **v0.0.4** — DSL + DAG runtime (`agent`/`parallel`/`map`/`gate`/`reduce`),
269
+ inline + saved flows, cross-session resume, live progress, isolated context.
270
+ Default `concurrency` is 8 (set on the flow; per-phase `concurrency` overrides
271
+ for that phase).
272
+ - A run executes as one streaming tool call (live progress while it runs).
273
+ - `map` requires the upstream phase to emit a JSON array (`output: "json"`).
274
+ - Gate verdicts are **fail-open**: if the agent output contains no recognizable
275
+ verdict marker (`VERDICT: BLOCK/PASS/OK/FAIL/STOP/REJECT/HALT` or
276
+ `{continue: false}` / `{verdict: "block"}`), the gate passes. This prevents
277
+ an accidental missing verdict from blocking your workflow.
278
+
279
+ ### What it doesn't do (yet)
280
+
281
+ - **No detached background execution.** A run needs the pi session to stay open.
282
+ True background execution is on the roadmap.
283
+ - **No `output: "file"`.** Outputs are text/JSON only. Write files via agent
284
+ tool calls if needed.
285
+ - **`map` requires a JSON array.** The `over` field must resolve to
286
+ `{steps.ID.json}` where the upstream phase emitted `output: "json"`. If the
287
+ source is a plain text list, wrap it in a single-agent phase that outputs JSON.
288
+ - **Cycles are rejected at validation.** The DAG must be acyclic.
106
289
 
107
290
  ## Development
108
291
 
109
292
  ```bash
110
293
  npm install
111
294
  npm run typecheck
112
- node --experimental-strip-types --test test/interpolate.test.ts test/schema.test.ts test/runtime.test.ts
295
+ node --experimental-strip-types --test test/interpolate.test.ts \
296
+ test/schema.test.ts test/runtime.test.ts test/runner.test.ts \
297
+ test/store.test.ts test/agents.test.ts test/render.test.ts \
298
+ test/desugar.test.ts
113
299
 
114
300
  # real end-to-end (spawns live subagents; needs model access)
115
301
  PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts
116
302
  ```
117
303
 
118
- ## Status & limits
304
+ ## Contributing
119
305
 
120
- - **v0.1**DSL + DAG runtime (`agent`/`parallel`/`map`/`gate`/`reduce`),
121
- inline + saved flows, cross-session resume, live progress, isolated context.
122
- - A run executes as one streaming tool call (live progress while it runs). True
123
- detached background execution is on the roadmap.
124
- - `map` requires the upstream phase to emit a JSON array (`output: "json"`).
306
+ Contributions welcome! This is a young project open an issue or PR on
307
+ [GitHub](https://github.com/heggria/pi-taskflow). Tests live in `test/`, the
308
+ runtime in `extensions/`.
125
309
 
126
310
  ## License
127
311
 
@@ -19,7 +19,7 @@ import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.
19
19
  import { renderRunResult, summarizeRun } from "./render.ts";
20
20
  import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
21
21
  import { executeTaskflow, type RuntimeResult } from "./runtime.ts";
22
- import { finalPhase, type Taskflow, validateTaskflow } from "./schema.ts";
22
+ import { finalPhase, type Taskflow, validateTaskflow, desugar, isShorthand } from "./schema.ts";
23
23
  import {
24
24
  getFlow,
25
25
  listFlows,
@@ -41,6 +41,14 @@ interface TaskflowDetails {
41
41
  /** pi reads `isError` at runtime to mark tool failures; it is not in the public type. */
42
42
  type ToolResult = AgentToolResult<TaskflowDetails> & { isError?: boolean };
43
43
 
44
+ const ShorthandStep = Type.Object(
45
+ {
46
+ agent: Type.Optional(Type.String({ description: "Agent for this step (defaults to the first available agent)" })),
47
+ task: Type.String({ description: "Task prompt for this step (supports {previous.output} in chains)" }),
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
44
52
  const TaskflowParams = Type.Object({
45
53
  action: StringEnum(["run", "save", "resume", "list"] as const, {
46
54
  description: "What to do: run a flow, save a definition, resume a paused run, or list saved flows",
@@ -53,6 +61,24 @@ const TaskflowParams = Type.Object({
53
61
  "Inline taskflow definition (JSON object matching the taskflow DSL). Use to run or save a new flow.",
54
62
  }),
55
63
  ),
64
+ // --- Shorthand (non-DAG) modes, like the subagent tool. No DSL required. ---
65
+ agent: Type.Optional(
66
+ Type.String({ description: "Shorthand single mode: agent to run with `task` (like subagent single mode)" }),
67
+ ),
68
+ task: Type.Optional(
69
+ Type.String({ description: "Shorthand single mode: the task prompt (like subagent single mode)" }),
70
+ ),
71
+ tasks: Type.Optional(
72
+ Type.Array(ShorthandStep, {
73
+ description: "Shorthand parallel mode: run these tasks concurrently and merge results (like subagent parallel)",
74
+ }),
75
+ ),
76
+ chain: Type.Optional(
77
+ Type.Array(ShorthandStep, {
78
+ description:
79
+ "Shorthand chain mode: run sequentially; reference the prior step with {previous.output} (like subagent chain)",
80
+ }),
81
+ ),
56
82
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Invocation arguments for the flow" })),
57
83
  runId: Type.Optional(Type.String({ description: "Run id to resume (for action=resume)" })),
58
84
  scope: Type.Optional(
@@ -175,6 +201,7 @@ export default function (pi: ExtensionAPI) {
175
201
  "Orchestrate a multi-phase workflow of subagents from a declarative definition.",
176
202
  "Phases (agent, parallel, map, gate, reduce) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.",
177
203
  "Use action=run with an inline `define` (you write the DSL) or a saved `name`.",
204
+ "For simple non-DAG delegations (like the subagent tool) skip the DSL: pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` to run in parallel, or `chain:[{task,agent?}]` to run sequentially (reference the prior step with {previous.output}).",
178
205
  "Use action=save to persist a definition as a reusable /tf:<name> command. action=resume continues a paused run. action=list shows saved flows.",
179
206
  "DSL: {name, args?, concurrency?, phases:[{id, type, agent, task, dependsOn?, over?(map), as?(map), branches?(parallel), from?(reduce), output?:'json', final?}]}.",
180
207
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
@@ -183,7 +210,7 @@ export default function (pi: ExtensionAPI) {
183
210
  promptSnippet: "Orchestrate many subagents over a whole codebase/many items (declarative DAG with map fan-out)",
184
211
  promptGuidelines: [
185
212
  "Prefer taskflow whenever a request spans a whole project/codebase or many items — e.g. 'explore / 探索 / 审计 / analyze the project', auditing endpoints, reviewing or migrating many files/modules, or cross-checked research. It fans out to many subagents across phases and aggregates the result, keeping intermediate work out of your context.",
186
- "Choose taskflow over ad-hoc parallel subagents when the work has multiple phases (discover → work → review → report), needs dynamic fan-out over a discovered list, or should be saved and rerun. Use the plain subagent tool only for a single delegated task.",
213
+ "Choose taskflow over ad-hoc parallel subagents when the work has multiple phases (discover → work → review → report), needs dynamic fan-out over a discovered list, or should be saved and rerun. For simple single/parallel/chain delegations use the shorthand `task`/`tasks`/`chain` (no DSL) when you want the run tracked, resumable, or saveable; otherwise the plain subagent tool is fine.",
187
214
  "For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
188
215
  ],
189
216
 
@@ -209,18 +236,39 @@ export default function (pi: ExtensionAPI) {
209
236
  return finalResult(action, result);
210
237
  }
211
238
 
212
- // resolve the definition (inline define wins, else saved name)
239
+ // resolve the definition: inline `define` / shorthand (single|parallel|chain), else saved `name`.
213
240
  let def: Taskflow | undefined;
214
- if (params.define) {
215
- const v = validateTaskflow(params.define);
241
+
242
+ // A shorthand spec can come from `define` (no phases) or top-level params.
243
+ const shorthandSpec: unknown =
244
+ params.define ??
245
+ (params.chain
246
+ ? { chain: params.chain, name: params.name }
247
+ : params.tasks
248
+ ? { tasks: params.tasks, name: params.name }
249
+ : params.task
250
+ ? { task: params.task, agent: params.agent, name: params.name }
251
+ : undefined);
252
+
253
+ if (shorthandSpec !== undefined) {
254
+ let candidate: unknown = shorthandSpec;
255
+ if (isShorthand(candidate)) {
256
+ try {
257
+ candidate = desugar(candidate);
258
+ } catch (e) {
259
+ return errorResult(action, `Invalid shorthand: ${e instanceof Error ? e.message : String(e)}`);
260
+ }
261
+ }
262
+ const v = validateTaskflow(candidate);
216
263
  if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
217
- def = params.define as Taskflow;
264
+ def = candidate as Taskflow;
218
265
  } else if (params.name) {
219
266
  const saved = getFlow(ctx.cwd, params.name);
220
267
  if (!saved) return errorResult(action, `Saved flow not found: ${params.name}`);
221
268
  def = saved.def;
222
269
  }
223
- if (!def) return errorResult(action, "Provide 'define' (inline) or 'name' (saved).");
270
+ if (!def)
271
+ return errorResult(action, "Provide 'define' (DSL), shorthand 'task'/'tasks'/'chain', or 'name' (saved).");
224
272
 
225
273
  // save
226
274
  if (action === "save") {
@@ -256,10 +304,23 @@ export default function (pi: ExtensionAPI) {
256
304
 
257
305
  renderCall(args, theme) {
258
306
  const action = args.action ?? "run";
259
- const name = args.name || (args.define as any)?.name || "(inline)";
260
- let text = theme.fg("toolTitle", theme.bold("taskflow ")) + theme.fg("accent", `${action} `) + theme.fg("muted", name);
307
+ let label = args.name || (args.define as { name?: string } | undefined)?.name;
308
+ let suffix = "";
261
309
  const phases = (args.define as Taskflow | undefined)?.phases;
262
- if (phases) text += theme.fg("dim", ` (${phases.length} phases)`);
310
+ if (phases) suffix = ` (${phases.length} phases)`;
311
+ else if (args.chain) {
312
+ label ||= "chain";
313
+ suffix = ` (${(args.chain as unknown[]).length} steps)`;
314
+ } else if (args.tasks) {
315
+ label ||= "parallel";
316
+ suffix = ` (${(args.tasks as unknown[]).length} tasks)`;
317
+ } else if (args.task) {
318
+ label ||= "task";
319
+ }
320
+ label ||= "(inline)";
321
+ let text =
322
+ theme.fg("toolTitle", theme.bold("taskflow ")) + theme.fg("accent", `${action} `) + theme.fg("muted", label);
323
+ if (suffix) text += theme.fg("dim", suffix);
263
324
  return new Text(text, 0, 0);
264
325
  },
265
326
 
@@ -9,7 +9,7 @@ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
10
10
  import { formatTokens, type UsageStats } from "./runner.ts";
11
11
  import type { PhaseState, RunState } from "./store.ts";
12
- import type { Phase } from "./schema.ts";
12
+ import { dependenciesOf, type Phase, topoLayers } from "./schema.ts";
13
13
 
14
14
  // Single-width glyphs (Geometric Shapes / check marks) — keep columns aligned.
15
15
  const ICON: Record<PhaseState["status"], { ch: string; color: string }> = {
@@ -223,35 +223,81 @@ function headerLine(state: RunState, theme: Theme): string {
223
223
  return line;
224
224
  }
225
225
 
226
- /** The full dense progress block (header + aligned phase rows). */
226
+ /**
227
+ * Left-gutter rail glyph for a phase at `i` within a parallel group of `size`.
228
+ * A group is a topological layer with >1 phase (they run concurrently); the
229
+ * bracket (┌ ├ └) visually fans them out from the preceding layer. Single-phase
230
+ * layers get a blank gutter so the column stays quiet.
231
+ */
232
+ function railGlyph(i: number, size: number): string {
233
+ if (size <= 1) return " ";
234
+ if (i === 0) return "┌";
235
+ if (i === size - 1) return "└";
236
+ return "├";
237
+ }
238
+
239
+ /** The full dense progress block (header + DAG-ordered phase rows). */
227
240
  export function renderProgress(state: RunState, theme: Theme): string {
228
241
  const phases = state.def.phases;
229
242
  const idW = Math.max(...phases.map((p) => p.id.length), 2);
230
243
  const typeW = Math.max(...phases.map((p) => (p.type ?? "agent").length), 4);
244
+ const defIndex = new Map(phases.map((p, i) => [p.id, i]));
245
+
246
+ // Render in topological order: each layer is a set of phases that can run
247
+ // concurrently; later layers depend on earlier ones. This makes the DAG's
248
+ // flow legible top-to-bottom without drawing a full graph.
249
+ const layers = topoLayers(phases);
250
+ const rendered = new Set<string>();
231
251
 
232
252
  let text = headerLine(state, theme);
233
- for (const phase of phases) {
253
+
254
+ const renderRow = (phase: Phase, rail: string, prevLayerIds: Set<string>) => {
234
255
  const ps = state.phases[phase.id];
235
256
  const status = ps?.status ?? "pending";
236
257
  const id = phase.id.padEnd(idW);
237
258
  const type = (phase.type ?? "agent").padEnd(typeW);
238
259
  const detail = phaseDetail(phase, ps, theme);
260
+
261
+ // Annotate only "long" edges — dependencies that skip past the adjacent
262
+ // layer. Edges into the immediately-preceding layer are implied by position
263
+ // (and the rail), so showing them would just add noise.
264
+ const longEdges = dependenciesOf(phase).filter((d) => !prevLayerIds.has(d));
265
+ const dep = longEdges.length
266
+ ? theme.fg("dim", ` ↳ ${longEdges.join(", ")}`)
267
+ : "";
268
+
269
+ const gutter = rail === " " ? " " : theme.fg("borderMuted", rail);
239
270
  text +=
240
- `\n ${icon(status, theme)} ` +
271
+ `\n ${gutter} ${icon(status, theme)} ` +
241
272
  theme.fg(status === "pending" ? "dim" : "text", id) +
242
273
  " " +
243
274
  theme.fg("dim", type) +
244
275
  " " +
245
- detail;
276
+ detail +
277
+ dep;
246
278
 
247
279
  // Live activity sub-line (only while running, only if we have a message).
248
280
  if (status === "running" && ps?.liveText) {
249
- const indent = " ".repeat(2 + 2 + idW + 2);
281
+ const indent = " ".repeat(2 + 2 + 2 + idW + 2);
250
282
  const msg = ps.liveText.replace(/\s+/g, " ").trim();
251
283
  const snip = msg.length > 88 ? `${msg.slice(0, 88)}…` : msg;
252
284
  text += `\n${indent}${theme.fg("dim", "› ")}${theme.fg("muted", snip)}`;
253
285
  }
286
+ rendered.add(phase.id);
287
+ };
288
+
289
+ let prevLayerIds = new Set<string>();
290
+ for (const layer of layers) {
291
+ const ordered = [...layer].sort((a, b) => (defIndex.get(a.id) ?? 0) - (defIndex.get(b.id) ?? 0));
292
+ ordered.forEach((phase, i) => renderRow(phase, railGlyph(i, ordered.length), prevLayerIds));
293
+ prevLayerIds = new Set(ordered.map((p) => p.id));
254
294
  }
295
+
296
+ // Safety net: render any phase a malformed DAG left out of the layering.
297
+ for (const phase of phases) {
298
+ if (!rendered.has(phase.id)) renderRow(phase, " ", prevLayerIds);
299
+ }
300
+
255
301
  return text;
256
302
  }
257
303
 
@@ -289,7 +289,7 @@ export async function runAgentTask(
289
289
  }
290
290
  if (tmpPromptDir) {
291
291
  try {
292
- fs.rmdirSync(tmpPromptDir);
292
+ fs.rmSync(tmpPromptDir, { recursive: true, force: true });
293
293
  } catch {
294
294
  /* ignore */
295
295
  }
@@ -259,12 +259,20 @@ async function executePhase(
259
259
 
260
260
  /** Resolve a `{steps.x.json}`-style ref directly to its parsed value (bypassing stringify). */
261
261
  function directRef(over: string, state: RunState): unknown {
262
- const m = over.match(/^\{steps\.([a-zA-Z0-9_]+)\.(output|json)\}$/);
262
+ const m = over.match(/^\{steps\.([a-zA-Z0-9_]+)\.(output|json)(?:\.([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*))?\}$/);
263
263
  if (!m) return undefined;
264
264
  const step = state.phases[m[1]];
265
265
  if (!step || step.status !== "done") return undefined;
266
- if (m[2] === "json") return step.json ?? safeParse(step.output ?? "");
267
- return safeParse(step.output ?? "");
266
+ let value: unknown;
267
+ if (m[2] === "json") value = step.json ?? safeParse(step.output ?? "");
268
+ else value = safeParse(step.output ?? "");
269
+ if (m[3]) {
270
+ for (const key of m[3].split(".")) {
271
+ if (value == null || typeof value !== "object") return undefined;
272
+ value = (value as Record<string, unknown>)[key];
273
+ }
274
+ }
275
+ return value;
268
276
  }
269
277
 
270
278
  function lastCompletedOutput(state: RunState, phase: Phase): string | undefined {
@@ -358,16 +366,19 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
358
366
  return;
359
367
  }
360
368
 
369
+ const startedAt = Date.now();
361
370
  state.phases[phase.id] = {
362
371
  ...(state.phases[phase.id] ?? { id: phase.id }),
363
372
  id: phase.id,
364
373
  status: "running",
365
- startedAt: Date.now(),
374
+ startedAt,
366
375
  };
367
376
  deps.onProgress?.(state);
368
377
 
369
378
  const ps = await executePhase(phase, state, deps, prior, () => deps.onProgress?.(state));
370
- state.phases[phase.id] = ps;
379
+ // Preserve the phase start time: executePhase returns a fresh PhaseState
380
+ // that omits startedAt (cached/resumed results carry their own).
381
+ state.phases[phase.id] = ps.startedAt ? ps : { ...ps, startedAt };
371
382
  if ((phase.type ?? "agent") === "gate" && ps.gate?.verdict === "block") {
372
383
  gateBlocked = true;
373
384
  gateReason = ps.gate.reason ?? "";
@@ -90,6 +90,88 @@ export type Phase = Static<typeof PhaseSchema>;
90
90
  export type Taskflow = Static<typeof TaskflowSchema>;
91
91
  export type ArgSpec = Static<typeof ArgSpecSchema>;
92
92
 
93
+ // ---------------------------------------------------------------------------
94
+ // Shorthand (non-DAG) specs — subagent-style ergonomics
95
+ // ---------------------------------------------------------------------------
96
+ //
97
+ // For simple delegations you should not have to author a phases DAG. A
98
+ // shorthand spec mirrors the subagent tool's modes and is desugared into a
99
+ // full Taskflow before validation/execution:
100
+ //
101
+ // { task, agent? } → one `agent` phase (single)
102
+ // { tasks: [{task, agent?}, ...] } → one `parallel` phase (parallel)
103
+ // { chain: [{task, agent?}, ...] } → sequential `agent` phases (chain)
104
+ //
105
+ // Chain steps reference the prior step's output with {previous.output}, exactly
106
+ // like the subagent tool's {previous} placeholder.
107
+
108
+ export interface ShorthandStep {
109
+ agent?: string;
110
+ task: string;
111
+ }
112
+
113
+ /** True when `def` is a shorthand spec (no `phases`, but a task/tasks/chain field). */
114
+ export function isShorthand(def: unknown): boolean {
115
+ if (typeof def !== "object" || def === null) return false;
116
+ const d = def as Record<string, unknown>;
117
+ if (Array.isArray(d.phases)) return false;
118
+ return Array.isArray(d.chain) || Array.isArray(d.tasks) || typeof d.task === "string";
119
+ }
120
+
121
+ function readStep(s: unknown): ShorthandStep {
122
+ if (typeof s === "string") return { task: s };
123
+ if (s && typeof s === "object") {
124
+ const o = s as Record<string, unknown>;
125
+ return { agent: typeof o.agent === "string" ? o.agent : undefined, task: String(o.task ?? "") };
126
+ }
127
+ return { task: "" };
128
+ }
129
+
130
+ /**
131
+ * Desugar a shorthand spec into a full Taskflow DAG. Throws if no recognizable
132
+ * shorthand field is present. Carries through optional name/description/
133
+ * concurrency/agentScope/args.
134
+ */
135
+ export function desugar(def: unknown): Taskflow {
136
+ if (typeof def !== "object" || def === null) throw new Error("Shorthand spec must be an object");
137
+ const d = def as Record<string, unknown>;
138
+
139
+ const meta: Partial<Taskflow> = {};
140
+ if (typeof d.description === "string") meta.description = d.description;
141
+ if (typeof d.concurrency === "number") meta.concurrency = d.concurrency;
142
+ if (d.agentScope === "user" || d.agentScope === "project" || d.agentScope === "both") meta.agentScope = d.agentScope;
143
+ if (d.args && typeof d.args === "object") meta.args = d.args as Taskflow["args"];
144
+ const nameOf = (fallback: string) => (typeof d.name === "string" && d.name.trim() ? d.name.trim() : fallback);
145
+
146
+ // chain → sequential agent phases
147
+ if (Array.isArray(d.chain) && d.chain.length > 0) {
148
+ const steps = d.chain.map(readStep);
149
+ const phases: Phase[] = steps.map((s, i) => {
150
+ const phase: Phase = { id: `step${i + 1}`, type: "agent", task: s.task };
151
+ if (s.agent) phase.agent = s.agent;
152
+ if (i > 0) phase.dependsOn = [`step${i}`];
153
+ if (i === steps.length - 1) phase.final = true;
154
+ return phase;
155
+ });
156
+ return { name: nameOf("chain"), ...meta, phases };
157
+ }
158
+
159
+ // tasks → one parallel phase (fan-out + merge), no extra aggregation agent
160
+ if (Array.isArray(d.tasks) && d.tasks.length > 0) {
161
+ const branches: ParallelTask[] = d.tasks.map(readStep).map((s) => (s.agent ? { task: s.task, agent: s.agent } : { task: s.task }));
162
+ return { name: nameOf("parallel"), ...meta, phases: [{ id: "parallel", type: "parallel", branches, final: true }] };
163
+ }
164
+
165
+ // single task → one agent phase
166
+ if (typeof d.task === "string") {
167
+ const phase: Phase = { id: "main", type: "agent", task: d.task, final: true };
168
+ if (typeof d.agent === "string") phase.agent = d.agent;
169
+ return { name: nameOf("task"), ...meta, phases: [phase] };
170
+ }
171
+
172
+ throw new Error("Shorthand spec needs one of: 'task' (single), 'tasks' (parallel), or 'chain' (sequential)");
173
+ }
174
+
93
175
  // ---------------------------------------------------------------------------
94
176
  // Validation (beyond schema: DAG integrity, phase-type requirements)
95
177
  // ---------------------------------------------------------------------------
@@ -114,7 +114,7 @@ export function saveFlow(
114
114
  def: Taskflow,
115
115
  scope: "user" | "project" = "project",
116
116
  ): { filePath: string } {
117
- const dir = scope === "user" ? userFlowsDir() : findProjectFlowsDir(cwd, true)!;
117
+ const dir = scope === "user" ? userFlowsDir() : (findProjectFlowsDir(cwd, true) ?? path.join(cwd, ".pi", "taskflows"));
118
118
  fs.mkdirSync(dir, { recursive: true });
119
119
  const safe = def.name.replace(/[^\w.-]+/g, "_");
120
120
  const filePath = path.join(dir, `${safe}.json`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
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",
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",
40
40
  "test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
41
41
  },
42
42
  "pi": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: taskflow
3
- description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use whenever a request spans a whole project or many items — deeply exploring / 探索 / auditing / 审计 / analyzing a codebase, reviewing or migrating many files or modules in parallel, cross-checked/adversarial review, codebase-wide research, or any repeatable orchestration you want to save and rerun. Prefer this over ad-hoc parallel subagents when the work has multiple phases or dynamic fan-out over a discovered list. Not for a single delegated task use the subagent tool for that.
3
+ description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use whenever a request spans a whole project or many items — deeply exploring / 探索 / auditing / 审计 / analyzing a codebase, reviewing or migrating many files or modules in parallel, cross-checked/adversarial review, codebase-wide research, or any repeatable orchestration you want to save and rerun. Prefer this over ad-hoc parallel subagents when the work has multiple phases or dynamic fan-out over a discovered list. Also supports subagent-style shorthand (single / parallel / chain) for simple non-DAG delegations you want tracked, resumable, or saveable.
4
4
  ---
5
5
 
6
6
  # Taskflow
@@ -16,7 +16,36 @@ the final answer — not every step's transcript.
16
16
  - You want **cross-checked / adversarial review** before reporting.
17
17
  - You want a **repeatable** orchestration saved as a `/tf:<name>` command.
18
18
 
19
- For a single delegated task, use the `subagent` tool instead.
19
+ For a single quick delegation you can use the **shorthand modes** below (no DSL),
20
+ or the plain `subagent` tool. Use the shorthand when you want the run tracked,
21
+ resumable, or saveable as a `/tf` command.
22
+
23
+ ## Shorthand (non-DAG) — like the subagent tool
24
+
25
+ Skip the DSL entirely for simple delegations. The runtime desugars these into a
26
+ proper flow, so you still get progress, persistence, resume, and `save`.
27
+
28
+ ```jsonc
29
+ // single — one agent, one task
30
+ { "task": "Summarize the architecture of src/", "agent": "explorer" }
31
+
32
+ // parallel — run several tasks at once, outputs merged
33
+ { "tasks": [
34
+ { "task": "Audit auth in src/api", "agent": "analyst" },
35
+ { "task": "Audit input validation in src/api", "agent": "analyst" }
36
+ ] }
37
+
38
+ // chain — run sequentially; reference the prior step with {previous.output}
39
+ { "chain": [
40
+ { "task": "List the public API of src/lib", "agent": "scout" },
41
+ { "task": "Write docs for:\n{previous.output}", "agent": "writer" }
42
+ ] }
43
+ ```
44
+
45
+ - `agent` is optional (defaults to the first available agent).
46
+ - Add `name` to label the run (and to `save` it as a `/tf:<name>` command).
47
+ - Precedence if several are given: `chain` > `tasks` > `task`.
48
+ - You can pass these as top-level tool params **or** inside `define`.
20
49
 
21
50
  ## How to author a taskflow
22
51
 
@@ -94,6 +123,20 @@ Review the audit results below. If any endpoint is missing auth, end with
94
123
  3. Reference upstream results explicitly with `{steps.ID...}` and set `dependsOn`.
95
124
  4. Mark the result-bearing phase with `"final": true` (else the last phase wins).
96
125
 
126
+ ## Configuration
127
+
128
+ For the full set of knobs — per-phase `model`/`thinking`/`tools`/`cwd`, the
129
+ two-level concurrency model, model/thinking/tools resolution precedence,
130
+ `agentScope` & agent discovery, `settings.json` overrides, environment
131
+ variables, and storage paths — read `configuration.md` (next to this file).
132
+
133
+ Quick reference:
134
+
135
+ - **Flow:** `name`, `description`, `concurrency` (default 8), `agentScope` (user|project|both), `args`.
136
+ - **Phase:** `model`, `thinking`, `tools` (whitelist), `cwd`, `output:"json"`, `concurrency` (map/parallel fan-out), `final`.
137
+ - **Precedence (model/thinking/tools):** phase value → `settings.subagents.agentOverrides[agent]` → agent frontmatter → global/default.
138
+ - **Concurrency:** same-layer phases use `flow.concurrency`; a `map`/`parallel` phase uses `phase.concurrency ?? flow.concurrency ?? 8`.
139
+
97
140
  ## Actions
98
141
 
99
142
  - `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.