pi-subagents 0.25.0 → 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 (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +129 -17
  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/skills/pi-subagents/SKILL.md +32 -17
  7. package/src/agents/agent-management.ts +57 -15
  8. package/src/agents/agent-serializer.ts +3 -2
  9. package/src/agents/agents.ts +47 -16
  10. package/src/agents/chain-serializer.ts +120 -0
  11. package/src/extension/fanout-child.ts +1 -0
  12. package/src/extension/index.ts +1 -0
  13. package/src/extension/schemas.ts +138 -5
  14. package/src/runs/background/async-execution.ts +84 -6
  15. package/src/runs/background/async-status.ts +11 -1
  16. package/src/runs/background/run-status.ts +10 -1
  17. package/src/runs/background/subagent-runner.ts +600 -31
  18. package/src/runs/foreground/chain-execution.ts +325 -118
  19. package/src/runs/foreground/execution.ts +222 -10
  20. package/src/runs/foreground/subagent-executor.ts +67 -0
  21. package/src/runs/shared/acceptance-contract.ts +291 -0
  22. package/src/runs/shared/acceptance-evaluation.ts +221 -0
  23. package/src/runs/shared/acceptance-finalization.ts +161 -0
  24. package/src/runs/shared/acceptance-reports.ts +127 -0
  25. package/src/runs/shared/acceptance.ts +22 -0
  26. package/src/runs/shared/chain-outputs.ts +101 -0
  27. package/src/runs/shared/completion-guard.ts +26 -3
  28. package/src/runs/shared/dynamic-fanout.ts +293 -0
  29. package/src/runs/shared/parallel-utils.ts +31 -1
  30. package/src/runs/shared/pi-args.ts +11 -0
  31. package/src/runs/shared/structured-output.ts +77 -0
  32. package/src/runs/shared/subagent-prompt-runtime.ts +53 -3
  33. package/src/runs/shared/workflow-graph.ts +206 -0
  34. package/src/shared/formatters.ts +2 -2
  35. package/src/shared/settings.ts +53 -4
  36. package/src/shared/types.ts +250 -0
  37. package/src/slash/slash-commands.ts +41 -3
  38. package/src/tui/render.ts +162 -34
@@ -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
  }
@@ -1,6 +1,9 @@
1
1
  import type { ChainConfig, ChainStepConfig } from "./agents.ts";
2
2
  import { buildRuntimeName, frontmatterNameForConfig, parsePackageName } from "./identity.ts";
3
3
  import { parseFrontmatter } from "./frontmatter.ts";
4
+ import { ChainOutputValidationError, validateChainOutputBindings } from "../runs/shared/chain-outputs.ts";
5
+ import { validateAcceptanceInput } from "../runs/shared/acceptance.ts";
6
+ import type { ChainStep } from "../shared/settings.ts";
4
7
 
5
8
  function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
6
9
  const lines = sectionBody.split("\n");
@@ -20,6 +23,25 @@ function parseStepBody(agent: string, sectionBody: string): ChainStepConfig {
20
23
  else if (rawValue) step.output = rawValue;
21
24
  continue;
22
25
  }
26
+ if (key === "phase") {
27
+ if (rawValue) step.phase = rawValue;
28
+ continue;
29
+ }
30
+ if (key === "label") {
31
+ if (rawValue) step.label = rawValue;
32
+ continue;
33
+ }
34
+ if (key === "as") {
35
+ if (rawValue) step.as = rawValue;
36
+ continue;
37
+ }
38
+ if (key === "outputschema") {
39
+ if (rawValue.startsWith("{") || rawValue.startsWith("[")) {
40
+ throw new Error("Inline outputSchema values are not supported in .chain.md files; use a schema file path.");
41
+ }
42
+ if (rawValue) step.outputSchema = rawValue;
43
+ continue;
44
+ }
23
45
  if (key === "outputmode") {
24
46
  if (rawValue === "inline" || rawValue === "file-only") step.outputMode = rawValue;
25
47
  continue;
@@ -102,6 +124,100 @@ export function parseChain(content: string, source: "user" | "project", filePath
102
124
  };
103
125
  }
104
126
 
127
+ export function parseJsonChain(content: string, source: "user" | "project", filePath: string): ChainConfig {
128
+ let parsed: unknown;
129
+ try {
130
+ parsed = JSON.parse(content);
131
+ } catch (error) {
132
+ const message = error instanceof Error ? error.message : String(error);
133
+ throw new Error(`Invalid JSON chain '${filePath}': ${message}`);
134
+ }
135
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
136
+ throw new Error(`JSON chain '${filePath}' must contain an object root.`);
137
+ }
138
+ const input = parsed as Record<string, unknown>;
139
+ if (typeof input.name !== "string" || !input.name.trim()) {
140
+ throw new Error(`JSON chain '${filePath}' must include string name.`);
141
+ }
142
+ if (typeof input.description !== "string" || !input.description.trim()) {
143
+ throw new Error(`JSON chain '${filePath}' must include string description.`);
144
+ }
145
+ if (!Array.isArray(input.chain)) {
146
+ throw new Error(`JSON chain '${filePath}' must include array chain.`);
147
+ }
148
+ for (let i = 0; i < input.chain.length; i++) {
149
+ const step = input.chain[i];
150
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
151
+ throw new Error(`JSON chain '${filePath}' step ${i + 1} must be an object.`);
152
+ }
153
+ const stepRecord = step as Record<string, unknown>;
154
+ const parallel = stepRecord.parallel;
155
+ if (Array.isArray(parallel) && Object.hasOwn(stepRecord, "acceptance")) {
156
+ throw new Error(`Invalid JSON chain '${filePath}': step ${i + 1} acceptance is not supported on static parallel groups; set acceptance on each parallel task.`);
157
+ }
158
+ if (parallel && typeof parallel === "object" && !Array.isArray(parallel) && Object.hasOwn(stepRecord, "acceptance")) {
159
+ throw new Error(`Invalid JSON chain '${filePath}': step ${i + 1} acceptance is not supported on dynamic fanout groups; set acceptance on the dynamic template.`);
160
+ }
161
+ const acceptanceErrors = validateAcceptanceInput(stepRecord.acceptance, `step ${i + 1} acceptance`);
162
+ if (acceptanceErrors.length > 0) {
163
+ throw new Error(`Invalid JSON chain '${filePath}': ${acceptanceErrors.join(" ")}`);
164
+ }
165
+ if (Array.isArray(parallel)) {
166
+ for (let taskIndex = 0; taskIndex < parallel.length; taskIndex++) {
167
+ const task = parallel[taskIndex];
168
+ if (!task || typeof task !== "object" || Array.isArray(task)) continue;
169
+ const taskErrors = validateAcceptanceInput((task as Record<string, unknown>).acceptance, `step ${i + 1} parallel task ${taskIndex + 1} acceptance`);
170
+ if (taskErrors.length > 0) {
171
+ throw new Error(`Invalid JSON chain '${filePath}': ${taskErrors.join(" ")}`);
172
+ }
173
+ }
174
+ } else if (parallel && typeof parallel === "object") {
175
+ const templateErrors = validateAcceptanceInput((parallel as Record<string, unknown>).acceptance, `step ${i + 1} dynamic template acceptance`);
176
+ if (templateErrors.length > 0) {
177
+ throw new Error(`Invalid JSON chain '${filePath}': ${templateErrors.join(" ")}`);
178
+ }
179
+ }
180
+ }
181
+ try {
182
+ validateChainOutputBindings(input.chain as ChainStep[], { maxItems: Number.MAX_SAFE_INTEGER });
183
+ } catch (error) {
184
+ if (error instanceof ChainOutputValidationError) throw new Error(`Invalid JSON chain '${filePath}': ${error.message}`);
185
+ throw error;
186
+ }
187
+ const parsedPackage = parsePackageName(typeof input.package === "string" ? input.package : undefined, `Chain '${input.name}' package`);
188
+ if (parsedPackage.error) throw new Error(parsedPackage.error);
189
+ const extraFields: Record<string, string> = {};
190
+ for (const [key, value] of Object.entries(input)) {
191
+ if (key === "name" || key === "package" || key === "description" || key === "chain") continue;
192
+ if (typeof value === "string") extraFields[key] = value;
193
+ }
194
+ return {
195
+ name: buildRuntimeName(input.name.trim(), parsedPackage.packageName),
196
+ localName: input.name.trim(),
197
+ packageName: parsedPackage.packageName,
198
+ description: input.description.trim(),
199
+ source,
200
+ filePath,
201
+ steps: input.chain as ChainStepConfig[],
202
+ extraFields: Object.keys(extraFields).length > 0 ? extraFields : undefined,
203
+ };
204
+ }
205
+
206
+ export function serializeJsonChain(config: ChainConfig): string {
207
+ const root: Record<string, unknown> = {
208
+ name: frontmatterNameForConfig(config),
209
+ description: config.description,
210
+ chain: config.steps,
211
+ };
212
+ if (config.packageName) root.package = config.packageName;
213
+ if (config.extraFields) {
214
+ for (const [key, value] of Object.entries(config.extraFields)) {
215
+ if (key !== "name" && key !== "description" && key !== "package" && key !== "chain") root[key] = value;
216
+ }
217
+ }
218
+ return `${JSON.stringify(root, null, 2)}\n`;
219
+ }
220
+
105
221
  export function serializeChain(config: ChainConfig): string {
106
222
  const lines: string[] = [];
107
223
  lines.push("---");
@@ -121,6 +237,10 @@ export function serializeChain(config: ChainConfig): string {
121
237
  lines.push(`## ${step.agent}`);
122
238
  if (step.output === false) lines.push("output: false");
123
239
  else if (step.output) lines.push(`output: ${step.output}`);
240
+ if (step.phase) lines.push(`phase: ${step.phase}`);
241
+ if (step.label) lines.push(`label: ${step.label}`);
242
+ if (step.as) lines.push(`as: ${step.as}`);
243
+ if (step.outputSchema) lines.push(`outputSchema: ${step.outputSchema}`);
124
244
  if (step.outputMode) lines.push(`outputMode: ${step.outputMode}`);
125
245
  if (step.reads === false) lines.push("reads: false");
126
246
  else if (Array.isArray(step.reads) && step.reads.length > 0) lines.push(`reads: ${step.reads.join(", ")}`);
@@ -156,6 +156,7 @@ export default function registerFanoutChildSubagentExtension(pi: ExtensionAPI):
156
156
  label: "Subagent",
157
157
  description: [
158
158
  "Delegate to subagents from child-safe fanout mode.",
159
+ "For goal-style requests such as /goal, goal, active goal, or work until evidence says done, use explicit acceptance on the delegated run: criteria for the target, evidence/verify for proof, stopRules for constraints, and maxFinalizationTurns for the bounded loop.",
159
160
  "Allowed management/control actions: list, get, status, interrupt, resume, doctor.",
160
161
  "Agent config mutation actions create, update, and delete are blocked in this mode.",
161
162
  ].join("\n"),
@@ -395,6 +395,7 @@ EXECUTION (use exactly ONE mode):
395
395
  • CHAIN: { chain: [{agent:"agent-a"}, {parallel:[{agent:"agent-b",count:3}]}] } - sequential pipeline with optional parallel fan-out
396
396
  • PARALLEL: { tasks: [{agent,task,count?,output?,reads?,progress?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
397
397
  • Optional context: { context: "fresh" | "fork" } (default: if any requested agent has defaultContext: "fork", the whole invocation uses fork; otherwise "fresh"; inspect agent defaults via { action: "list" })
398
+ • Goal-style requests: when the user says “/goal”, “goal”, “active goal”, “work until evidence says done”, or “verify against a goal”, model that as explicit acceptance. Use acceptance.criteria for the target, acceptance.evidence/verify for proof, acceptance.stopRules for constraints, and acceptance.maxFinalizationTurns for the bounded loop.
398
399
 
399
400
  CHAIN TEMPLATE VARIABLES (use in task strings):
400
401
  • {task} - The original task/request from the user
@@ -35,9 +35,82 @@ const ReadsOverride = Type.Unsafe({
35
35
  description: "Files to read before running (array of filenames), or false to disable",
36
36
  });
37
37
 
38
+ const JsonSchemaObject = Type.Unsafe({
39
+ type: "object",
40
+ additionalProperties: true,
41
+ description: "JSON Schema object for strict structured output. Non-object roots are rejected.",
42
+ });
43
+
44
+ const AcceptanceEvidenceKind = Type.String({
45
+ enum: [
46
+ "changed-files",
47
+ "tests-added",
48
+ "commands-run",
49
+ "validation-output",
50
+ "residual-risks",
51
+ "no-staged-files",
52
+ "diff-summary",
53
+ "review-findings",
54
+ "manual-notes",
55
+ ],
56
+ });
57
+
58
+ const AcceptanceGateSchema = Type.Object({
59
+ id: Type.String(),
60
+ must: Type.String(),
61
+ evidence: Type.Optional(Type.Array(AcceptanceEvidenceKind)),
62
+ severity: Type.Optional(Type.String({ enum: ["required", "recommended"] })),
63
+ }, { additionalProperties: false });
64
+
65
+ const AcceptanceVerifyCommandSchema = Type.Object({
66
+ id: Type.String(),
67
+ command: Type.String(),
68
+ timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })),
69
+ cwd: Type.Optional(Type.String()),
70
+ env: Type.Optional(Type.Unsafe({ type: "object", additionalProperties: { type: "string" } })),
71
+ allowFailure: Type.Optional(Type.Boolean()),
72
+ }, { additionalProperties: false });
73
+
74
+ const AcceptanceReviewGateSchema = Type.Object({
75
+ agent: Type.Optional(Type.String()),
76
+ focus: Type.Optional(Type.String()),
77
+ required: Type.Optional(Type.Boolean()),
78
+ }, { additionalProperties: false });
79
+
80
+ const AcceptanceOverride = Type.Unsafe({
81
+ type: "object",
82
+ properties: {
83
+ criteria: {
84
+ type: "array",
85
+ items: {
86
+ anyOf: [
87
+ { type: "string" },
88
+ AcceptanceGateSchema,
89
+ ],
90
+ },
91
+ },
92
+ evidence: { type: "array", items: AcceptanceEvidenceKind },
93
+ verify: { type: "array", items: AcceptanceVerifyCommandSchema },
94
+ review: AcceptanceReviewGateSchema,
95
+ stopRules: { type: "array", items: { type: "string" } },
96
+ maxFinalizationTurns: { type: "integer", minimum: 1, maximum: 10 },
97
+ },
98
+ additionalProperties: false,
99
+ allOf: [{
100
+ anyOf: [
101
+ { required: ["criteria"] },
102
+ { required: ["evidence"] },
103
+ { required: ["verify"] },
104
+ { required: ["review"] },
105
+ { required: ["stopRules"] },
106
+ ],
107
+ }],
108
+ description: "Optional acceptance contract. Use this for goal-style requests such as /goal, goal, active goal, or work until evidence says done: criteria define the target, evidence/verify define proof, stopRules define constraints, and maxFinalizationTurns defines the bounded loop. When present, the child must complete a same-session self-review/repair loop before acceptance is evaluated.",
109
+ });
110
+
38
111
  const TaskItem = Type.Object({
39
- agent: Type.String(),
40
- task: Type.String(),
112
+ agent: Type.String(),
113
+ task: Type.String(),
41
114
  cwd: Type.Optional(Type.String()),
42
115
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
43
116
  output: Type.Optional(OutputOverride),
@@ -46,12 +119,17 @@ const TaskItem = Type.Object({
46
119
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking for this task" })),
47
120
  model: Type.Optional(Type.String({ description: "Override model for this task (e.g. 'google/gemini-3-pro')" })),
48
121
  skill: Type.Optional(SkillOverride),
122
+ acceptance: Type.Optional(AcceptanceOverride),
49
123
  });
50
124
 
51
125
  // Parallel task item (within a parallel step)
52
126
  const ParallelTaskSchema = Type.Object({
53
127
  agent: Type.String(),
54
128
  task: Type.Optional(Type.String({ description: "Task template with {task}, {previous}, {chain_dir} variables. Defaults to {previous}." })),
129
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
130
+ label: Type.Optional(Type.String({ description: "Optional user-facing label for this parallel task." })),
131
+ as: Type.Optional(Type.String({ description: "Optional safe identifier used as {outputs.name} in later chain steps." })),
132
+ outputSchema: Type.Optional(JsonSchemaObject),
55
133
  cwd: Type.Optional(Type.String()),
56
134
  count: Type.Optional(Type.Integer({ minimum: 1, description: "Repeat this parallel task N times with the same settings." })),
57
135
  output: Type.Optional(OutputOverride),
@@ -60,14 +138,51 @@ const ParallelTaskSchema = Type.Object({
60
138
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
61
139
  skill: Type.Optional(SkillOverride),
62
140
  model: Type.Optional(Type.String({ description: "Override model for this task" })),
141
+ acceptance: Type.Optional(AcceptanceOverride),
63
142
  });
64
143
 
144
+ const DynamicExpandSchema = Type.Object({
145
+ from: Type.Object({
146
+ output: Type.String({ description: "Prior named structured output to expand from." }),
147
+ path: Type.String({ description: "JSON Pointer into the structured output, e.g. /items." }),
148
+ }, { additionalProperties: false }),
149
+ item: Type.Optional(Type.String({ description: "Template variable name for each item. Defaults to item." })),
150
+ key: Type.Optional(Type.String({ description: "JSON Pointer relative to each item for stable child ids." })),
151
+ maxItems: Type.Optional(Type.Integer({ minimum: 0, description: "Required fanout bound unless configured globally." })),
152
+ onEmpty: Type.Optional(Type.String({ enum: ["skip", "fail"], description: "Empty input behavior. Defaults to skip." })),
153
+ }, { additionalProperties: false });
154
+
155
+ const DynamicParallelTemplateSchema = Type.Object({
156
+ agent: Type.String(),
157
+ task: Type.Optional(Type.String({ description: "Task template with {item}, {item.path}, {task}, {previous}, {chain_dir}, and {outputs.name} variables." })),
158
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
159
+ label: Type.Optional(Type.String({ description: "Optional user-facing label; item templates are supported." })),
160
+ outputSchema: Type.Optional(JsonSchemaObject),
161
+ cwd: Type.Optional(Type.String()),
162
+ output: Type.Optional(OutputOverride),
163
+ outputMode: Type.Optional(OutputModeOverride),
164
+ reads: Type.Optional(ReadsOverride),
165
+ progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
166
+ skill: Type.Optional(SkillOverride),
167
+ model: Type.Optional(Type.String({ description: "Override model for this task" })),
168
+ acceptance: Type.Optional(AcceptanceOverride),
169
+ }, { additionalProperties: false });
170
+
171
+ const DynamicCollectSchema = Type.Object({
172
+ as: Type.String({ description: "Safe output name for the ordered collected result array." }),
173
+ outputSchema: Type.Optional(JsonSchemaObject),
174
+ }, { additionalProperties: false });
175
+
65
176
  // Flattened so chain steps do not need an object-shape anyOf/oneOf union.
66
177
  const ChainItem = Type.Object({
67
178
  agent: Type.Optional(Type.String({ description: "Sequential step agent name" })),
68
179
  task: Type.Optional(Type.String({
69
- description: "Task template with variables: {task}=original request, {previous}=prior step's text response, {chain_dir}=shared folder. Required for first step, defaults to '{previous}' for subsequent steps."
180
+ description: "Task template with variables: {task}=original request, {previous}=prior step's text response, {chain_dir}=shared folder, {outputs.name}=prior named output. Required for first step, defaults to '{previous}' for subsequent steps."
70
181
  })),
182
+ phase: Type.Optional(Type.String({ description: "Optional phase/group label for status and graph rendering." })),
183
+ label: Type.Optional(Type.String({ description: "Optional user-facing label for this chain step." })),
184
+ as: Type.Optional(Type.String({ description: "Optional safe identifier used as {outputs.name} in later chain steps." })),
185
+ outputSchema: Type.Optional(JsonSchemaObject),
71
186
  cwd: Type.Optional(Type.String()),
72
187
  output: Type.Optional(OutputOverride),
73
188
  outputMode: Type.Optional(OutputModeOverride),
@@ -75,13 +190,30 @@ const ChainItem = Type.Object({
75
190
  progress: Type.Optional(Type.Boolean({ description: "Enable progress.md tracking in {chain_dir}" })),
76
191
  skill: Type.Optional(SkillOverride),
77
192
  model: Type.Optional(Type.String({ description: "Override model for this step" })),
78
- parallel: Type.Optional(Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" })),
193
+ acceptance: Type.Optional(AcceptanceOverride),
194
+ parallel: Type.Optional(Type.Unsafe({
195
+ anyOf: [
196
+ Type.Array(ParallelTaskSchema, { minItems: 1, description: "Tasks to run in parallel" }),
197
+ DynamicParallelTemplateSchema,
198
+ ],
199
+ description: "Static parallel tasks array, or a single dynamic fanout child template when expand/collect are present.",
200
+ })),
201
+ expand: Type.Optional(DynamicExpandSchema),
202
+ collect: Type.Optional(DynamicCollectSchema),
79
203
  concurrency: Type.Optional(Type.Number({ description: "Max concurrent tasks (default: 4)" })),
80
204
  failFast: Type.Optional(Type.Boolean({ description: "Stop on first failure (default: false)" })),
81
205
  worktree: Type.Optional(Type.Boolean({
82
206
  description: "Create isolated git worktrees for each parallel task."
83
207
  })),
84
- }, { description: "Chain step: use {agent, task?, ...} for sequential or {parallel: [...]} for concurrent execution" });
208
+ }, {
209
+ description: "Chain step: use {agent, task?, ...} for sequential, {parallel: [...]} for static concurrent execution, or {expand, parallel: {...}, collect} for dynamic fanout.",
210
+ additionalProperties: false,
211
+ allOf: [
212
+ { if: { required: ["expand"] }, then: { required: ["parallel", "collect"], properties: { parallel: { type: "object" } } } },
213
+ { if: { required: ["collect"] }, then: { required: ["expand", "parallel"], properties: { parallel: { type: "object" } } } },
214
+ { not: { required: ["expand"], properties: { parallel: { type: "array", items: {} } } } },
215
+ ],
216
+ });
85
217
 
86
218
  const ControlOverrides = Type.Object({
87
219
  enabled: Type.Optional(Type.Boolean({ description: "Enable/disable subagent control attention tracking for this run" })),
@@ -165,4 +297,5 @@ export const SubagentParams = Type.Object({
165
297
  outputMode: Type.Optional(OutputModeOverride),
166
298
  skill: Type.Optional(SkillOverride),
167
299
  model: Type.Optional(Type.String({ description: "Override model for single agent (e.g. 'anthropic/claude-sonnet-4')" })),
300
+ acceptance: Type.Optional(AcceptanceOverride),
168
301
  });