pi-subagents 0.24.4 → 0.27.0

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +145 -27
  3. package/package.json +1 -1
  4. package/prompts/parallel-context-build.md +3 -1
  5. package/prompts/parallel-handoff-plan.md +3 -1
  6. package/prompts/review-loop.md +1 -1
  7. package/skills/pi-subagents/SKILL.md +71 -20
  8. package/src/agents/agent-management.ts +57 -15
  9. package/src/agents/agent-serializer.ts +3 -2
  10. package/src/agents/agents.ts +47 -16
  11. package/src/agents/chain-serializer.ts +120 -0
  12. package/src/extension/fanout-child.ts +171 -0
  13. package/src/extension/index.ts +7 -2
  14. package/src/extension/schemas.ts +138 -5
  15. package/src/intercom/result-intercom.ts +108 -0
  16. package/src/runs/background/async-execution.ts +185 -10
  17. package/src/runs/background/async-job-tracker.ts +41 -6
  18. package/src/runs/background/async-resume.ts +28 -15
  19. package/src/runs/background/async-status.ts +71 -31
  20. package/src/runs/background/result-watcher.ts +111 -54
  21. package/src/runs/background/run-id-resolver.ts +83 -0
  22. package/src/runs/background/run-status.ts +89 -4
  23. package/src/runs/background/stale-run-reconciler.ts +46 -1
  24. package/src/runs/background/subagent-runner.ts +648 -42
  25. package/src/runs/foreground/chain-execution.ts +331 -118
  26. package/src/runs/foreground/execution.ts +226 -10
  27. package/src/runs/foreground/subagent-executor.ts +377 -14
  28. package/src/runs/shared/acceptance-contract.ts +291 -0
  29. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  30. package/src/runs/shared/acceptance-finalization.ts +161 -0
  31. package/src/runs/shared/acceptance-reports.ts +127 -0
  32. package/src/runs/shared/acceptance.ts +22 -0
  33. package/src/runs/shared/chain-outputs.ts +101 -0
  34. package/src/runs/shared/completion-guard.ts +26 -3
  35. package/src/runs/shared/dynamic-fanout.ts +293 -0
  36. package/src/runs/shared/nested-events.ts +819 -0
  37. package/src/runs/shared/nested-path.ts +52 -0
  38. package/src/runs/shared/nested-render.ts +115 -0
  39. package/src/runs/shared/parallel-utils.ts +31 -1
  40. package/src/runs/shared/pi-args.ts +73 -5
  41. package/src/runs/shared/structured-output.ts +77 -0
  42. package/src/runs/shared/subagent-prompt-runtime.ts +77 -7
  43. package/src/runs/shared/workflow-graph.ts +206 -0
  44. package/src/shared/formatters.ts +2 -2
  45. package/src/shared/settings.ts +53 -4
  46. package/src/shared/types.ts +345 -0
  47. package/src/slash/slash-commands.ts +41 -3
  48. package/src/tui/render.ts +268 -43
@@ -10,7 +10,7 @@ description: |
10
10
 
11
11
  # Pi Subagents
12
12
 
13
- This skill is for the main parent orchestrator only. Do not inject or follow it inside spawned child subagents. The parent session owns delegation, orchestration, review fanout, and final fix-worker launches; child subagents should receive concrete role-specific tasks and should not run their own subagent workflows.
13
+ This skill is for the main parent orchestrator only. Do not inject or follow it inside spawned child subagents. The parent session owns delegation, orchestration, review fanout, and final fix-worker launches; child subagents should receive concrete role-specific tasks. Ordinary children should not run their own subagent workflows; the explicit exception is a delegated fanout child whose resolved builtin `tools` includes `subagent`, and that child may use `subagent` only for the fanout work the parent assigned.
14
14
 
15
15
  Use this skill when the parent orchestrator needs to launch a specialized subagent, compose multiple agents into a workflow, or create/edit agents and chains on demand.
16
16
 
@@ -32,7 +32,7 @@ Humans often use the slash-command layer instead:
32
32
  - `/run` — launch a single agent
33
33
  - `/chain` — launch a chain of steps
34
34
  - `/parallel` — launch top-level parallel tasks
35
- - `/run-chain` — launch a saved `.chain.md` workflow
35
+ - `/run-chain` — launch a saved `.chain.md` or `.chain.json` workflow
36
36
  - `/subagents-doctor` — diagnose setup, discovery, async paths, and intercom bridge state
37
37
 
38
38
  Prefer the tool when you are writing agent logic. Prefer the slash commands when
@@ -108,7 +108,40 @@ Use this at the start of non-trivial work. Launch `scout` for local context and
108
108
 
109
109
  ### Parallel cleanup technique
110
110
 
111
- Use this after implementation when the user wants cleanup review or when a final pass would reduce AI-slop. Launch two fresh-context `reviewer` tasks with `output: false` and `progress: false`: one deslop pass and one verbosity pass. If the `deslop` or `verbosity-cleaner` skills are available, pass the relevant skill to that reviewer; otherwise inline the criteria. Both reviewers are review-only and should flag concrete issues with severity, file/line references, and smallest safe fixes. Review-only/no-edit beats progress-writing or artifact-writing instructions. The parent decides what to apply and asks before making changes unless cleanup was already authorized.
111
+ Use this after implementation when the user wants cleanup review or when a final pass would reduce AI-slop. Launch two fresh-context `reviewer` tasks with `output: false` and `progress: false`: one deslop pass and one verbosity pass. If the `deslop` or `verbosity-cleaner` skills are available, pass the relevant skill to that reviewer; otherwise inline the criteria. Both reviewers are review-only and should flag concrete issues with severity, file/line references, and smallest safe fixes. Phrase the constraint as “Do not modify project/source files; returning findings through the configured output artifact is allowed” when you use `output` or `outputMode: "file-only"`. The parent decides what to apply and asks before making changes unless cleanup was already authorized.
112
+
113
+ ### Staged fix orchestration technique
114
+
115
+ Use this when a broad diff has known reviewer findings across several items and the user wants the parent to “orchestrate subagents like a boss.” Keep the active worktree safe with a three-stage chain:
116
+
117
+ 1. A parallel read-only planning fanout, one planner/reviewer per issue cluster. Each child inspects the real diff and returns exact files, line refs, proposed fixes, and focused validation. They must not edit.
118
+ 2. One writer worker. It receives the planner summaries through `{previous}`, the parent’s accepted scope, stop rules, and verification contract. It is the only child allowed to edit the active worktree.
119
+ 3. A parallel read-only validation fanout. Validators inspect the worker diff from fresh context with distinct angles, report pass/fail, remaining blockers, and missing verification.
120
+
121
+ Prefer `async: true`, `context: "fresh"` for planners/validators, `outputMode: "file-only"` for large summaries, and per-stage output names that will not collide. Add `phase` and `label` to make async status readable, and use `as` plus `{outputs.name}` when a later step needs a specific earlier result instead of the whole `{previous}` blob. Use this pattern instead of launching several writer workers into a dirty worktree. Include non-blocking suggestions in the writer prompt only when they are small, safe, and do not expand product scope; otherwise record them as deferred.
122
+
123
+ When the first step can return a structured target list, prefer dynamic fanout instead of hand-authoring a static parallel group. Use `outputSchema` and `as` on the producer, then an `expand` step with `from: { output, path }`, an explicit `maxItems`, one `parallel` child template, and `collect.as`. Item templates may use `{item}` or a named item such as `{target.path}`. Do not use dynamic fanout for prose outputs, nested fanout, dynamic agent selection, reducers, `when` conditions, or arbitrary expressions; `.chain.md` does not support this syntax, so use direct JSON or a saved `.chain.json`.
124
+
125
+ Example shape:
126
+
127
+ ```typescript
128
+ subagent({
129
+ async: true,
130
+ context: "fresh",
131
+ chain: [
132
+ { parallel: [
133
+ { agent: "reviewer", phase: "Planning", label: "Deploy docs", as: "deployPlan", task: "Plan fixes for deploy docs/workflow. Inspect the current diff. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "plans/deploy.md", outputMode: "file-only" },
134
+ { agent: "reviewer", phase: "Planning", label: "Scheduler contract", as: "schedulerPlan", task: "Plan fixes for scheduler contract. Inspect the current diff. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "plans/scheduler.md", outputMode: "file-only" },
135
+ { agent: "reviewer", phase: "Planning", label: "Sandbox/security", as: "sandboxPlan", task: "Plan fixes for sandbox/security. Inspect the current diff. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "plans/sandbox.md", outputMode: "file-only" }
136
+ ], concurrency: 3 },
137
+ { agent: "worker", phase: "Implementation", label: "Apply accepted fixes", as: "workerResult", task: "Apply only the accepted fixes from these planning summaries. You are the sole writer for the active worktree. Run focused validation and report changed files, commands, failures, and remaining issues.\n\nDeploy plan:\n{outputs.deployPlan}\n\nScheduler plan:\n{outputs.schedulerPlan}\n\nSandbox plan:\n{outputs.sandboxPlan}", output: "worker/fixes.md", outputMode: "file-only", progress: true },
138
+ { parallel: [
139
+ { agent: "reviewer", phase: "Validation", label: "Deploy/scheduler validation", task: "Validate the post-worker diff for deploy and scheduler fixes. Start from the worker result: {outputs.workerResult}. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "validation/deploy-scheduler.md", outputMode: "file-only" },
140
+ { agent: "reviewer", phase: "Validation", label: "Sandbox validation", task: "Validate the post-worker diff for sandbox/security fixes. Start from the worker result: {outputs.workerResult}. Do not modify project/source files; returning findings via the configured output artifact is allowed.", output: "validation/sandbox.md", outputMode: "file-only" }
141
+ ], concurrency: 2 }
142
+ ]
143
+ })
144
+ ```
112
145
 
113
146
  ## Builtin Agents
114
147
 
@@ -144,7 +177,7 @@ A strong subagent prompt usually includes:
144
177
  - **Goal**: the concrete outcome the child should produce.
145
178
  - **Context/evidence**: relevant plan paths, files, diffs, decisions, or user constraints already approved.
146
179
  - **Success criteria**: what must be true before the child can finish.
147
- - **Hard constraints**: true invariants only, such as no edits for review-only tasks, one writer thread, child must not run subagents, or escalation for unapproved decisions.
180
+ - **Hard constraints**: true invariants only, such as no edits for review-only tasks, one writer thread, child must not run subagents unless it is an explicitly assigned `tools: subagent` fanout child, or escalation for unapproved decisions.
148
181
  - **Validation**: targeted checks to run, or the next-best check when validation is impossible.
149
182
  - **Output**: the expected summary shape, artifact path, or finding format.
150
183
  - **Stop rules**: when to ask via `intercom`, when to stop after enough evidence, and when not to keep searching.
@@ -186,10 +219,10 @@ Agent files can live in:
186
219
  - legacy `.agents/**/*.md` — still read for compatibility, but `.pi/agents/` wins on conflicts
187
220
 
188
221
  Chains live in:
189
- - `~/.pi/agent/chains/**/*.chain.md` — user scope
190
- - `.pi/chains/**/*.chain.md` — project scope
222
+ - `~/.pi/agent/chains/**/*.chain.md` and `~/.pi/agent/chains/**/*.chain.json` — user scope
223
+ - `.pi/chains/**/*.chain.md` and `.pi/chains/**/*.chain.json` — project scope
191
224
 
192
- Discovery is recursive. `.chain.md` files do not define agents. Agents and chains can set optional frontmatter `package: code-analysis`; `name: scout` plus `package: code-analysis` registers as runtime name `code-analysis.scout` while serialization keeps `name` and `package` separate.
225
+ Discovery is recursive. `.chain.md` files do not define agents. Use `.chain.md` for simple saved chains and `.chain.json` for dynamic fanout or inline schema objects. Agents and chains can set optional frontmatter/package metadata; `name: scout` plus `package: code-analysis` registers as runtime name `code-analysis.scout` while serialization keeps `name` and `package` separate.
193
226
 
194
227
  Precedence is by parsed runtime name:
195
228
  1. project scope
@@ -245,7 +278,7 @@ subagent({
245
278
  })
246
279
  ```
247
280
 
248
- Avoid duplicate output paths in parallel tasks. Concurrent children should not write to the same file. For large saved outputs, set `outputMode: "file-only"` together with an `output` path. The parent result then contains only a compact reference like `Output saved to: /abs/report.md (48.2 KB, 2847 lines). Read this file if needed.` instead of the full saved content. Do not use `output: false` for this; `output: false` means no file output. Failed runs and save errors still return inline details for debugging.
281
+ Avoid duplicate output paths in parallel tasks. Concurrent children should not write to the same file. For large saved outputs, set `outputMode: "file-only"` together with an `output` path. The parent result then contains only a compact reference like `Output saved to: /abs/report.md (48.2 KB, 2847 lines). Read this file if needed.` instead of the full saved content. Do not use `output: false` for this; `output: false` means no file output. When a task is review-only, say “do not modify project/source files” rather than “do not write files” if you also configured `output`; otherwise the child may treat the output artifact as forbidden. Failed runs and save errors still return inline details for debugging.
249
282
 
250
283
  ### Chain execution
251
284
 
@@ -259,9 +292,13 @@ subagent({
259
292
  })
260
293
  ```
261
294
 
262
- Chain steps can use templated variables such as `{task}`, `{previous}`, and
263
- `{chain_dir}`. This is the main way to pass structured summaries between steps
264
- without forcing each step to rediscover everything.
295
+ Chain steps can use templated variables such as `{task}`, `{previous}`,
296
+ `{chain_dir}`, and `{outputs.name}`. Use `as: "name"` on a successful step or
297
+ parallel task to make that output available to later steps. Prefer named outputs
298
+ when a later step needs one specific result; keep `{previous}` for simple linear
299
+ handoffs or full fan-in summaries. Use `phase` and `label` for status readability.
300
+ Use `outputSchema` when later steps need reliable structured data; the child must
301
+ call `structured_output` with schema-valid JSON, or the step fails.
265
302
 
266
303
  ### Async/background
267
304
 
@@ -293,13 +330,14 @@ const run = subagent({
293
330
  // Continue local inspection, then later call status with the returned id.
294
331
  ```
295
332
 
296
- Inspect async runs with `subagent({ action: "status", id: "..." })` or `subagent({ action: "status" })` for active runs.
333
+ Inspect async runs with `subagent({ action: "status", id: "..." })` or `subagent({ action: "status" })` for active runs. If a delegated fanout child launches nested runs, the parent status view shows them as a tree and you can target a nested run directly with its nested id.
297
334
 
298
335
  Use `resume` for follow-up work after a delegated run:
299
336
 
300
337
  ```typescript
301
338
  subagent({ action: "resume", id: "run-id", message: "Follow up on this point." })
302
339
  subagent({ action: "resume", id: "run-id", index: 1, message: "Continue reviewer 2." })
340
+ subagent({ action: "resume", id: "nested-run-id", message: "Continue this nested reviewer." })
303
341
  ```
304
342
 
305
343
  Resume behavior:
@@ -307,6 +345,7 @@ Resume behavior:
307
345
  - If an async child has completed, `resume` revives it by starting a new async child from the persisted child session file.
308
346
  - Multi-child async runs require `index` unless only one running child is selectable.
309
347
  - Completed foreground single, parallel, and chain runs can also be revived by `index` while their run metadata remains in extension state.
348
+ - Nested runs can be resumed by nested id when a live route or persisted nested session metadata is available.
310
349
  - Revive starts a new child process from the old session context; it does not restart the same OS process.
311
350
  - If the chosen child has no persisted `.jsonl` session file, resume fails and reports that directly.
312
351
 
@@ -330,13 +369,14 @@ Use soft interrupt when a child is clearly blocked or drifting and the parent ne
330
369
  subagent({ action: "interrupt" })
331
370
  ```
332
371
 
333
- Pass `id` when targeting a specific controllable run:
372
+ Pass `id` when targeting a specific controllable run, including a nested run shown in the parent status tree:
334
373
 
335
374
  ```typescript
336
375
  subagent({ action: "interrupt", id: "abc123" })
376
+ subagent({ action: "interrupt", id: "nested-run-id" })
337
377
  ```
338
378
 
339
- A soft interrupt cancels the current child turn and leaves the run paused. It does not mean the delegated task succeeded or failed. After an interrupt, decide the next explicit action: resume with clearer instructions, replace the task, ask the user, or stop the workflow.
379
+ A soft interrupt cancels the current child turn and leaves the run paused. It does not mean the delegated task succeeded or failed. Bare `interrupt` does not target hidden nested descendants; use the explicit nested id. After an interrupt, decide the next explicit action: resume with clearer instructions, replace the task, ask the user, or stop the workflow.
340
380
 
341
381
  Per-run control thresholds can be overridden when a task legitimately runs without observable output for longer than usual:
342
382
 
@@ -430,7 +470,7 @@ Use `contact_supervisor` with `reason: "need_decision"` when:
430
470
  - a child needs clarification instead of guessing
431
471
  - an approval, product, API, or scope choice is required before continuing safely
432
472
 
433
- Do not use `contact_supervisor` just to resolve review-only/no-edit versus progress-writing or artifact-writing instructions. No-edit wins, and the child should return review findings without touching files.
473
+ Do not use `contact_supervisor` just to resolve review-only/no-project-edit versus progress-writing or output-artifact instructions. The child must not modify project/source files, but returning findings through its normal response or configured output artifact is allowed unless the parent explicitly set `output: false`.
434
474
 
435
475
  Use `contact_supervisor` with `reason: "progress_update"` when:
436
476
  - a child is explicitly asked for progress
@@ -440,7 +480,7 @@ Use `contact_supervisor` with `reason: "progress_update"` when:
440
480
  Message conventions:
441
481
  - `reason: "need_decision"` waits for the parent reply and returns it to the child.
442
482
  - `reason: "progress_update"` is non-blocking and should stay concise.
443
- - Child-side routine completion handoffs are not expected. With the intercom bridge active, parent-side `pi-subagents` sends grouped completion results through `pi-intercom`: one grouped message per foreground parent run and one per completed async result file. Acknowledged foreground delivery returns a compact receipt with artifact/session paths; if unacknowledged, the normal full output is preserved. Grouped messages include child intercom targets and full child summaries.
483
+ - Child-side routine completion handoffs are not expected. With the intercom bridge active, parent-side `pi-subagents` sends grouped completion results through `pi-intercom`: one grouped message per foreground parent run and one per completed async result file. Acknowledged foreground delivery returns a compact receipt with artifact/session paths; if unacknowledged, the normal full output is preserved. Grouped messages include child intercom targets, full child summaries, and compact nested summaries under the parent child that launched them.
444
484
 
445
485
  If bridge instructions provide the child-facing tool, a child can ask:
446
486
 
@@ -650,18 +690,24 @@ For feature work, use this sequence as scaffolding for parent-agent behavior:
650
690
  clarify → validation contract → planner → async worker → parallel async fresh-context reviewers/validators → async fix worker → follow-up review when warranted → parent review
651
691
  ```
652
692
 
653
- The validation contract defines what done means before code is written: expected behavior, acceptance checks, commands or user flows to exercise, and evidence the worker should return. Keep it lightweight for small tasks, but make it explicit enough that reviewers and validators are checking the intended outcome rather than the worker’s own assumptions.
693
+ The validation contract defines acceptance before code is written: expected behavior, acceptance checks, commands or user flows to exercise, and evidence the worker should return. Keep it lightweight for small tasks, but make it explicit enough that reviewers and validators are checking the intended outcome rather than the worker’s own assumptions.
694
+
695
+ Use the structured `acceptance` field when the run should carry an explicit acceptance contract. If omitted, the run stays lightweight. When present, acceptance is object-only: define concrete `criteria`, required `evidence`, optional runtime `verify` commands, optional independent `review`, and optionally `maxFinalizationTurns`. The runtime continues the same child session for a bounded self-review/repair loop before evaluating the final report, so set `acceptance` on single runs, sequential chain steps, parallel task items, and dynamic fanout child templates, not on static parallel or dynamic fanout groups. Do not call a run reviewed just because the worker says it is done; reviewed means a reviewer gate returned a result. Child-reported command success is evidence, not runtime verification.
696
+
697
+ Goal-style requests map to `acceptance`. If the user says `/goal`, “goal”, “active goal”, “continue until evidence says done”, or “verify against a goal” for a subagent run, create an explicit run-scoped acceptance contract: `criteria` for the target, `evidence` and `verify` for proof, `stopRules` for constraints, and `maxFinalizationTurns` for the bounded loop budget.
654
698
 
655
699
  The first `worker` implements the approved plan. The parent continues with independent inspection or validation prep while it runs, not parallel edits to the same worktree. When the async worker completes, treat its handoff as the transition into review, not as final completion, unless the user explicitly asked for worker-only work, review-only output, or to stop after implementation. Parallel reviewers inspect the resulting diff from fresh context. Validators check behavior with the best available evidence: commands, tests, browser/CLI interaction, screenshots, logs, or manual reproduction notes. The final `worker` applies synthesized review fixes in forked context, then the parent looks over the final diff before completing. The parent may launch these steps as an initial async chain when the workflow is already clear, or as follow-up subagent runs after each async completion. Initial chains should pass `async: true` so the main chat is unblocked; avoid `clarify: true` unless the user asked for foreground clarification. Do not stop after parallel review unless the user explicitly asked for review-only output or the review surfaced a decision that needs approval first.
656
700
 
657
701
  For complex work, risky changes, broad refactors, or many changed lines, increase review and validation fanout rather than trusting one reviewer. Use distinct angles such as correctness/regressions, tests/validation, simplicity/maintainability, security/privacy, performance, docs/API contracts, and user-flow behavior. When reviewers find non-trivial issues or the fix worker touches many lines, run another focused review round before final validation.
658
702
 
703
+ When review has already produced concrete findings across several independent areas, use staged fix orchestration: parallel read-only planners for each issue cluster, one sole writer worker for the active worktree, then parallel fresh-context validators. This is the safest way to handle a dirty worktree with many prior changes because it parallelizes judgment without parallelizing writes. Non-blocking suggestions may go into the writer prompt only if they are small, safe, and inside the approved scope; otherwise defer them explicitly.
704
+
659
705
  For very large work, split into serial milestones instead of launching a swarm of writers. Each milestone gets one writer, a validation contract, fresh-context review/validation, a fix pass, and parent acceptance before the next milestone starts. Use parallel subagents inside a milestone for read-only context, research, review, and validation only.
660
706
 
661
- Keep orchestration authority in the parent session. Child subagents should not launch more subagents, read this skill, or run their own orchestration loops. Spawned subagents do not receive the `pi-subagents` skill, parent-only status/control/slash messages, prior parent `subagent` tool-call/tool-result artifacts, or the `subagent` extension tool. Child context filtering also strips old hidden orchestration-instruction messages when they appear in inherited history. Every child also receives a boundary instruction that says the parent owns orchestration, the child must not propose or run subagents, and implementation children must call real edit/write tools instead of printing pseudo tool calls. Pass children concrete role-specific work instead.
707
+ Keep orchestration authority in the parent session. Child subagents should not launch more subagents, read this skill, or run their own orchestration loops unless the parent intentionally selected a fanout agent whose builtin `tools` includes `subagent`. Spawned subagents do not receive the `pi-subagents` skill, parent-only status/control/slash messages, or prior parent `subagent` tool-call/tool-result artifacts. Ordinary children also do not receive the `subagent` extension tool. Child context filtering strips old hidden orchestration-instruction messages when they appear in inherited history. Every child receives a boundary instruction: ordinary children are told the parent owns orchestration and they must not propose or run subagents; explicit fanout children are told to use `subagent` only for the assigned fanout work, with `maxSubagentDepth` still enforced. Implementation children must call real edit/write tools instead of printing pseudo tool calls. Pass children concrete role-specific work instead.
662
708
 
663
709
  1. Clarify first. This is mandatory. Gather code context with `scout` or `context-builder`, add `researcher` only when external evidence matters, then ask the user clarifying questions with `interview` until scope, acceptance criteria, constraints, and non-goals are clear.
664
- 2. Define the validation contract. State what done means before implementation: expected behavior, checks to run, user flows to exercise, and evidence required in the worker handoff. For UI, CLI, integration, or workflow changes, include at least one validator angle that uses the product the way a user would rather than only reading code.
710
+ 2. Define the validation contract. State acceptance before implementation: expected behavior, checks to run, user flows to exercise, and evidence required in the worker handoff. For UI, CLI, integration, or workflow changes, include at least one validator angle that uses the product the way a user would rather than only reading code.
665
711
  3. Plan when useful. For complex work, call `planner` or write a plan doc yourself and get approval before implementation. For simple work, confirm shared understanding and explicitly note why planning is skipped.
666
712
  4. Implement with one writer. After approval, launch `worker` asynchronously with a proper meta prompt that includes clarified requirements, relevant context, plan path or summary, the validation contract, and output expectations. Packaged `worker` defaults to forked context; pass `context: "fresh"` only when you intentionally want a fresh child. While it runs, prepare validation or inspect adjacent code instead of editing the same worktree.
667
713
  5. Require a useful worker handoff. Ask the worker to report changed files, what was implemented, what was left undone, commands run with exit codes, validation evidence, surprises or new risks, decisions made inside approved scope, and decisions needing parent approval.
@@ -676,6 +722,11 @@ Example implementation handoff after clarification and optional planning:
676
722
  subagent({
677
723
  agent: "worker",
678
724
  task: "Implement the approved feature.\n\nClarified requirements:\n- ...\n\nPlan: see ~/Documents/docs/...-plan.md\n\nValidation contract:\n- ...\n\nReturn a handoff with changed files, what was implemented, what was left undone, commands run with exit codes, validation evidence, surprises/new risks, and decisions needing parent approval.",
725
+ acceptance: {
726
+ criteria: ["Implement the approved feature without widening scope"],
727
+ evidence: ["changed-files", "tests-added", "commands-run", "residual-risks", "no-staged-files"],
728
+ maxFinalizationTurns: 3
729
+ },
679
730
  async: true
680
731
  })
681
732
  ```
@@ -730,7 +781,7 @@ subagent({
730
781
  /run-chain review-chain -- review this branch
731
782
  ```
732
783
 
733
- Use saved `.chain.md` workflows when the user wants a repeatable multi-agent flow without rewriting the chain each time.
784
+ Use saved `.chain.md` or `.chain.json` workflows when the user wants a repeatable multi-agent flow without rewriting the chain each time. Prefer `.chain.json` for dynamic fanout or inline `outputSchema` objects; `.chain.md` remains the simple sequential/static authoring format.
734
785
 
735
786
  ## Error Handling
736
787
 
@@ -17,7 +17,7 @@ import {
17
17
  parsePackageName,
18
18
  } from "./agents.ts";
19
19
  import { serializeAgent } from "./agent-serializer.ts";
20
- import { serializeChain } from "./chain-serializer.ts";
20
+ import { serializeChain, serializeJsonChain } from "./chain-serializer.ts";
21
21
  import { discoverAvailableSkills } from "./skills.ts";
22
22
  import type { Details } from "../shared/types.ts";
23
23
 
@@ -169,6 +169,22 @@ function parseStepList(raw: unknown): { steps?: ChainStepConfig[]; error?: strin
169
169
  const s = item as Record<string, unknown>;
170
170
  if (typeof s.agent !== "string" || !s.agent.trim()) return { error: `config.steps[${i}].agent must be a non-empty string.` };
171
171
  const step: ChainStepConfig = { agent: s.agent.trim(), task: typeof s.task === "string" ? s.task : "" };
172
+ if (hasKey(s, "phase")) {
173
+ if (typeof s.phase === "string") step.phase = s.phase;
174
+ else return { error: `config.steps[${i}].phase must be a string.` };
175
+ }
176
+ if (hasKey(s, "label")) {
177
+ if (typeof s.label === "string") step.label = s.label;
178
+ else return { error: `config.steps[${i}].label must be a string.` };
179
+ }
180
+ if (hasKey(s, "as")) {
181
+ if (typeof s.as === "string") step.as = s.as;
182
+ else return { error: `config.steps[${i}].as must be a string.` };
183
+ }
184
+ if (hasKey(s, "outputSchema")) {
185
+ if (typeof s.outputSchema === "string") step.outputSchema = s.outputSchema;
186
+ else return { error: `config.steps[${i}].outputSchema must be a schema file path string for saved chains.` };
187
+ }
172
188
  if (hasKey(s, "output")) {
173
189
  if (s.output === false) step.output = false;
174
190
  else if (typeof s.output === "string") step.output = s.output;
@@ -339,7 +355,7 @@ function renamePath(
339
355
  cwd: string,
340
356
  ): { filePath?: string; error?: string } {
341
357
  if (nameExistsInScope(cwd, scope, newName, currentPath)) return { error: `Name '${newName}' already exists in ${scope} scope.` };
342
- const ext = kind === "agent" ? ".md" : ".chain.md";
358
+ const ext = kind === "agent" ? ".md" : currentPath.endsWith(".chain.json") ? ".chain.json" : ".chain.md";
343
359
  const filePath = path.join(path.dirname(currentPath), `${newName}${ext}`);
344
360
  if (fs.existsSync(filePath) && filePath !== currentPath) {
345
361
  return { error: `File already exists at ${filePath} but is not a valid ${kind} definition. Remove or rename it first.` };
@@ -375,6 +391,41 @@ function formatAgentDetail(agent: AgentConfig): string {
375
391
  return lines.join("\n");
376
392
  }
377
393
 
394
+ function formatChainStepDetail(step: ChainStepConfig, index: number): string[] {
395
+ const lines: string[] = [];
396
+ if (step.expand || step.collect) {
397
+ const parallel = step.parallel && !Array.isArray(step.parallel) && typeof step.parallel === "object" ? step.parallel as { agent?: unknown; task?: unknown; label?: unknown; outputSchema?: unknown } : undefined;
398
+ const expand = step.expand && typeof step.expand === "object" ? step.expand as { from?: { output?: unknown; path?: unknown }; item?: unknown; key?: unknown; maxItems?: unknown; onEmpty?: unknown } : undefined;
399
+ const collect = step.collect && typeof step.collect === "object" ? step.collect as { as?: unknown; outputSchema?: unknown } : undefined;
400
+ lines.push(`${index + 1}. Dynamic fanout${typeof collect?.as === "string" ? ` -> ${collect.as}` : ""}`);
401
+ if (expand?.from) lines.push(` Expand: ${String(expand.from.output ?? "?")}${String(expand.from.path ?? "")}`);
402
+ if (typeof expand?.item === "string") lines.push(` Item variable: ${expand.item}`);
403
+ if (typeof expand?.key === "string") lines.push(` Key: ${expand.key}`);
404
+ if (typeof expand?.maxItems === "number") lines.push(` Max items: ${expand.maxItems}`);
405
+ if (typeof expand?.onEmpty === "string") lines.push(` On empty: ${expand.onEmpty}`);
406
+ if (parallel?.agent) lines.push(` Agent: ${String(parallel.agent)}`);
407
+ if (typeof parallel?.label === "string") lines.push(` Label: ${parallel.label}`);
408
+ if (typeof parallel?.task === "string" && parallel.task.trim()) lines.push(` Task: ${parallel.task}`);
409
+ if (parallel?.outputSchema) lines.push(" Structured output: true");
410
+ if (collect?.outputSchema) lines.push(" Collect schema: true");
411
+ if (step.concurrency !== undefined) lines.push(` Concurrency: ${step.concurrency}`);
412
+ if (step.failFast !== undefined) lines.push(` Fail fast: ${step.failFast ? "true" : "false"}`);
413
+ return lines;
414
+ }
415
+ lines.push(`${index + 1}. ${step.agent}`);
416
+ if (step.task?.trim()) lines.push(` Task: ${step.task}`);
417
+ if (step.output === false) lines.push(" Output: false");
418
+ else if (step.output) lines.push(` Output: ${step.output}`);
419
+ if (step.outputMode) lines.push(` Output mode: ${step.outputMode}`);
420
+ if (step.reads === false) lines.push(" Reads: false");
421
+ else if (Array.isArray(step.reads) && step.reads.length > 0) lines.push(` Reads: ${step.reads.join(", ")}`);
422
+ if (step.model) lines.push(` Model: ${step.model}`);
423
+ if (step.skills === false) lines.push(" Skills: false");
424
+ else if (Array.isArray(step.skills) && step.skills.length > 0) lines.push(` Skills: ${step.skills.join(", ")}`);
425
+ if (step.progress !== undefined) lines.push(` Progress: ${step.progress ? "true" : "false"}`);
426
+ return lines;
427
+ }
428
+
378
429
  function formatChainDetail(chain: ChainConfig): string {
379
430
  const lines: string[] = [`Chain: ${chain.name} (${chain.source})`, `Path: ${chain.filePath}`, `Description: ${chain.description}`];
380
431
  if (chain.packageName) {
@@ -383,18 +434,7 @@ function formatChainDetail(chain: ChainConfig): string {
383
434
  }
384
435
  lines.push("", "Steps:");
385
436
  for (let i = 0; i < chain.steps.length; i++) {
386
- const s = chain.steps[i]!;
387
- lines.push(`${i + 1}. ${s.agent}`);
388
- if (s.task.trim()) lines.push(` Task: ${s.task}`);
389
- if (s.output === false) lines.push(" Output: false");
390
- else if (s.output) lines.push(` Output: ${s.output}`);
391
- if (s.outputMode) lines.push(` Output mode: ${s.outputMode}`);
392
- if (s.reads === false) lines.push(" Reads: false");
393
- else if (Array.isArray(s.reads) && s.reads.length > 0) lines.push(` Reads: ${s.reads.join(", ")}`);
394
- if (s.model) lines.push(` Model: ${s.model}`);
395
- if (s.skills === false) lines.push(" Skills: false");
396
- else if (Array.isArray(s.skills) && s.skills.length > 0) lines.push(` Skills: ${s.skills.join(", ")}`);
397
- if (s.progress !== undefined) lines.push(` Progress: ${s.progress ? "true" : "false"}`);
437
+ lines.push(...formatChainStepDetail(chain.steps[i]!, i));
398
438
  }
399
439
  return lines.join("\n");
400
440
  }
@@ -405,6 +445,7 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
405
445
  const scopedAgents = allAgents(d).filter((a) => scope === "both" || a.source === "builtin" || a.source === scope).sort((a, b) => a.name.localeCompare(b.name));
406
446
  const agents = scopedAgents.filter((a) => !a.disabled);
407
447
  const chains = d.chains.filter((c) => scope === "both" || c.source === scope).sort((a, b) => a.name.localeCompare(b.name));
448
+ const diagnostics = d.chainDiagnostics.filter((entry) => scope === "both" || entry.source === scope);
408
449
  const lines = [
409
450
  "Executable agents:",
410
451
  ...(agents.length
@@ -413,6 +454,7 @@ export function handleList(params: ManagementParams, ctx: ManagementContext): Ag
413
454
  "",
414
455
  "Chains:",
415
456
  ...(chains.length ? chains.map((c) => `- ${c.name} (${c.source}): ${c.description}`) : ["- (none)"]),
457
+ ...(diagnostics.length ? ["", "Chain diagnostics:", ...diagnostics.map((entry) => `- ${entry.filePath}: ${entry.error}`)] : []),
416
458
  ];
417
459
  return result(lines.join("\n"));
418
460
  }
@@ -608,7 +650,7 @@ export function handleUpdate(params: ManagementParams, ctx: ManagementContext):
608
650
  if (renamed.error) return result(renamed.error, true);
609
651
  updated.filePath = renamed.filePath!;
610
652
  }
611
- fs.writeFileSync(updated.filePath, serializeChain(updated), "utf-8");
653
+ fs.writeFileSync(updated.filePath, updated.filePath.endsWith(".chain.json") ? serializeJsonChain(updated) : serializeChain(updated), "utf-8");
612
654
  const headline = updated.name === oldName
613
655
  ? `Updated chain '${updated.name}' at ${updated.filePath}.`
614
656
  : `Updated chain '${oldName}' to '${updated.name}' at ${updated.filePath}.`;
@@ -67,8 +67,9 @@ export function serializeAgent(config: AgentConfig): string {
67
67
 
68
68
  if (config.defaultProgress) lines.push("defaultProgress: true");
69
69
  if (config.interactive) lines.push("interactive: true");
70
- if (Number.isInteger(config.maxSubagentDepth) && config.maxSubagentDepth >= 0) {
71
- lines.push(`maxSubagentDepth: ${config.maxSubagentDepth}`);
70
+ const maxSubagentDepth = config.maxSubagentDepth;
71
+ if (typeof maxSubagentDepth === "number" && Number.isInteger(maxSubagentDepth) && maxSubagentDepth >= 0) {
72
+ lines.push(`maxSubagentDepth: ${maxSubagentDepth}`);
72
73
  }
73
74
  if (config.completionGuard === false) lines.push("completionGuard: false");
74
75
 
@@ -6,10 +6,10 @@ import * as fs from "node:fs";
6
6
  import * as os from "node:os";
7
7
  import * as path from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import type { OutputMode } from "../shared/types.ts";
9
+ import type { AcceptanceInput, OutputMode } from "../shared/types.ts";
10
10
  import { getAgentDir } from "../shared/utils.ts";
11
11
  import { KNOWN_FIELDS } from "./agent-serializer.ts";
12
- import { parseChain } from "./chain-serializer.ts";
12
+ import { parseChain, parseJsonChain } from "./chain-serializer.ts";
13
13
  import { mergeAgentsForScope } from "./agent-selection.ts";
14
14
  import { parseFrontmatter } from "./frontmatter.ts";
15
15
  import { buildRuntimeName, parsePackageName } from "./identity.ts";
@@ -108,14 +108,25 @@ interface SubagentSettings {
108
108
  const EMPTY_SUBAGENT_SETTINGS: SubagentSettings = { overrides: {} };
109
109
 
110
110
  export interface ChainStepConfig {
111
- agent: string;
112
- task: string;
111
+ agent?: string;
112
+ task?: string;
113
+ phase?: string;
114
+ label?: string;
115
+ as?: string;
116
+ outputSchema?: string | Record<string, unknown>;
113
117
  output?: string | false;
114
118
  outputMode?: OutputMode;
115
119
  reads?: string[] | false;
116
120
  model?: string;
117
121
  skills?: string[] | false;
118
122
  progress?: boolean;
123
+ parallel?: unknown;
124
+ expand?: unknown;
125
+ collect?: unknown;
126
+ concurrency?: number;
127
+ failFast?: boolean;
128
+ worktree?: boolean;
129
+ acceptance?: AcceptanceInput;
119
130
  }
120
131
 
121
132
  export interface ChainConfig {
@@ -129,6 +140,12 @@ export interface ChainConfig {
129
140
  extraFields?: Record<string, string>;
130
141
  }
131
142
 
143
+ export interface ChainDiscoveryDiagnostic {
144
+ source: "user" | "project";
145
+ filePath: string;
146
+ error: string;
147
+ }
148
+
132
149
  interface AgentDiscoveryResult {
133
150
  agents: AgentConfig[];
134
151
  projectAgentsDir: string | null;
@@ -535,7 +552,7 @@ export function removeBuiltinAgentOverride(cwd: string, name: string, scope: "us
535
552
  return filePath;
536
553
  }
537
554
 
538
- function listMarkdownFilesRecursive(dir: string, predicate: (fileName: string) => boolean): string[] {
555
+ function listFilesRecursive(dir: string, predicate: (fileName: string) => boolean): string[] {
539
556
  const files: string[] = [];
540
557
  if (!fs.existsSync(dir)) return files;
541
558
 
@@ -549,7 +566,7 @@ function listMarkdownFilesRecursive(dir: string, predicate: (fileName: string) =
549
566
  for (const entry of entries) {
550
567
  const filePath = path.join(dir, entry.name);
551
568
  if (entry.isDirectory()) {
552
- files.push(...listMarkdownFilesRecursive(filePath, predicate));
569
+ files.push(...listFilesRecursive(filePath, predicate));
553
570
  continue;
554
571
  }
555
572
  if (!entry.isFile() && !entry.isSymbolicLink()) continue;
@@ -562,7 +579,7 @@ function listMarkdownFilesRecursive(dir: string, predicate: (fileName: string) =
562
579
  function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
563
580
  const agents: AgentConfig[] = [];
564
581
 
565
- for (const filePath of listMarkdownFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
582
+ for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".md") && !fileName.endsWith(".chain.md"))) {
566
583
  let content: string;
567
584
  try {
568
585
  content = fs.readFileSync(filePath, "utf-8");
@@ -689,10 +706,11 @@ function loadAgentsFromDir(dir: string, source: AgentSource): AgentConfig[] {
689
706
  return agents;
690
707
  }
691
708
 
692
- function loadChainsFromDir(dir: string, source: AgentSource): ChainConfig[] {
693
- const chains: ChainConfig[] = [];
709
+ function loadChainsFromDir(dir: string, source: "user" | "project"): { chains: ChainConfig[]; diagnostics: ChainDiscoveryDiagnostic[] } {
710
+ const chains = new Map<string, ChainConfig>();
711
+ const diagnostics: ChainDiscoveryDiagnostic[] = [];
694
712
 
695
- for (const filePath of listMarkdownFilesRecursive(dir, (fileName) => fileName.endsWith(".chain.md"))) {
713
+ for (const filePath of listFilesRecursive(dir, (fileName) => fileName.endsWith(".chain.md") || fileName.endsWith(".chain.json"))) {
696
714
  let content: string;
697
715
  try {
698
716
  content = fs.readFileSync(filePath, "utf-8");
@@ -701,13 +719,17 @@ function loadChainsFromDir(dir: string, source: AgentSource): ChainConfig[] {
701
719
  }
702
720
 
703
721
  try {
704
- chains.push(parseChain(content, source, filePath));
705
- } catch {
722
+ const chain = filePath.endsWith(".chain.json") ? parseJsonChain(content, source, filePath) : parseChain(content, source, filePath);
723
+ const existing = chains.get(chain.name);
724
+ if (existing && existing.filePath.endsWith(".chain.json") && filePath.endsWith(".chain.md")) continue;
725
+ chains.set(chain.name, chain);
726
+ } catch (error) {
727
+ diagnostics.push({ source, filePath, error: error instanceof Error ? error.message : String(error) });
706
728
  continue;
707
729
  }
708
730
  }
709
731
 
710
- return chains;
732
+ return { chains: Array.from(chains.values()), diagnostics };
711
733
  }
712
734
 
713
735
  function isDirectory(p: string): boolean {
@@ -779,6 +801,7 @@ export function discoverAgentsAll(cwd: string): {
779
801
  user: AgentConfig[];
780
802
  project: AgentConfig[];
781
803
  chains: ChainConfig[];
804
+ chainDiagnostics: ChainDiscoveryDiagnostic[];
782
805
  userDir: string;
783
806
  projectDir: string | null;
784
807
  userChainDir: string;
@@ -816,17 +839,25 @@ export function discoverAgentsAll(cwd: string): {
816
839
  const project = Array.from(projectMap.values());
817
840
 
818
841
  const chainMap = new Map<string, ChainConfig>();
842
+ const projectChainDiagnostics: ChainDiscoveryDiagnostic[] = [];
819
843
  for (const dir of projectChainDirs) {
820
- for (const chain of loadChainsFromDir(dir, "project")) {
844
+ const loaded = loadChainsFromDir(dir, "project");
845
+ projectChainDiagnostics.push(...loaded.diagnostics);
846
+ for (const chain of loaded.chains) {
821
847
  chainMap.set(chain.name, chain);
822
848
  }
823
849
  }
850
+ const userChains = loadChainsFromDir(userChainDir, "user");
824
851
  const chains = [
825
- ...loadChainsFromDir(userChainDir, "user"),
852
+ ...userChains.chains,
826
853
  ...Array.from(chainMap.values()),
827
854
  ];
855
+ const chainDiagnostics = [
856
+ ...userChains.diagnostics,
857
+ ...projectChainDiagnostics,
858
+ ];
828
859
 
829
860
  const userDir = process.env.PI_CODING_AGENT_DIR ? userDirOld : fs.existsSync(userDirNew) ? userDirNew : userDirOld;
830
861
 
831
- return { builtin, user, project, chains, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
862
+ return { builtin, user, project, chains, chainDiagnostics, userDir, projectDir, userChainDir, projectChainDir, userSettingsPath, projectSettingsPath };
832
863
  }