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 +237 -53
- package/extensions/index.ts +71 -10
- package/extensions/render.ts +52 -6
- package/extensions/runner.ts +1 -1
- package/extensions/runtime.ts +16 -5
- package/extensions/schema.ts +82 -0
- package/extensions/store.ts +1 -1
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +45 -2
- package/skills/taskflow/configuration.md +275 -0
package/README.md
CHANGED
|
@@ -1,94 +1,238 @@
|
|
|
1
1
|
# pi-taskflow
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/pi-taskflow)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
[](https://pi.dev)
|
|
6
|
+
|
|
3
7
|
> Lightweight workflow orchestration for the [Pi coding agent](https://pi.dev).
|
|
4
8
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
17
|
-
coordinated steps
|
|
18
|
-
or a
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
87
|
+
Try it inline — tell the model something like:
|
|
40
88
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
104
|
+
"concurrency": 8,
|
|
64
105
|
"phases": [
|
|
65
106
|
{ "id": "discover", "type": "agent", "agent": "scout",
|
|
66
|
-
"task": "List source files under {args.dir}
|
|
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",
|
|
69
|
-
"
|
|
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"],
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
`.pi/agents/*.md`)
|
|
105
|
-
|
|
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
|
|
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
|
-
##
|
|
304
|
+
## Contributing
|
|
119
305
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
package/extensions/index.ts
CHANGED
|
@@ -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.
|
|
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
|
|
239
|
+
// resolve the definition: inline `define` / shorthand (single|parallel|chain), else saved `name`.
|
|
213
240
|
let def: Taskflow | undefined;
|
|
214
|
-
|
|
215
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
260
|
-
let
|
|
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)
|
|
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
|
|
package/extensions/render.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
package/extensions/runner.ts
CHANGED
package/extensions/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
267
|
-
|
|
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
|
|
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
|
-
|
|
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 ?? "";
|
package/extensions/schema.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
package/extensions/store.ts
CHANGED
|
@@ -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
|
+
"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": {
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
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.
|