pi-taskflow 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +15 -1
- package/README.md +45 -10
- package/examples/conditional-research.json +56 -0
- package/examples/guarded-refactor.json +50 -0
- package/extensions/agents.ts +8 -1
- package/extensions/index.ts +30 -15
- package/extensions/interpolate.ts +231 -0
- package/extensions/render.ts +14 -3
- package/extensions/runner.ts +61 -78
- package/extensions/runtime.ts +364 -44
- package/extensions/schema.ts +85 -2
- package/extensions/store.ts +29 -3
- package/extensions/usage.ts +42 -0
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +67 -2
package/extensions/schema.ts
CHANGED
|
@@ -12,10 +12,11 @@ import { Type, type Static } from "typebox";
|
|
|
12
12
|
// Phase types
|
|
13
13
|
// ---------------------------------------------------------------------------
|
|
14
14
|
|
|
15
|
-
export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce"] as const;
|
|
15
|
+
export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow"] as const;
|
|
16
16
|
export type PhaseType = (typeof PHASE_TYPES)[number];
|
|
17
17
|
|
|
18
18
|
export const OUTPUT_FORMATS = ["text", "json"] as const;
|
|
19
|
+
export const JOIN_MODES = ["all", "any"] as const;
|
|
19
20
|
|
|
20
21
|
const ParallelTaskSchema = Type.Object(
|
|
21
22
|
{
|
|
@@ -25,6 +26,29 @@ const ParallelTaskSchema = Type.Object(
|
|
|
25
26
|
{ additionalProperties: false },
|
|
26
27
|
);
|
|
27
28
|
|
|
29
|
+
/** Declarative retry policy for a phase's subagent call(s). */
|
|
30
|
+
const RetrySchema = Type.Object(
|
|
31
|
+
{
|
|
32
|
+
max: Type.Number({ description: "Max retry attempts after the first try (>= 0)" }),
|
|
33
|
+
backoffMs: Type.Optional(Type.Number({ description: "Base delay between attempts, in ms", default: 0 })),
|
|
34
|
+
factor: Type.Optional(
|
|
35
|
+
Type.Number({ description: "Backoff multiplier per attempt (1 = fixed, 2 = exponential)", default: 1 }),
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
{ additionalProperties: false },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
/** Run-wide cost / token ceiling. Exceeding it halts the run (remaining phases skipped). */
|
|
42
|
+
const BudgetSchema = Type.Object(
|
|
43
|
+
{
|
|
44
|
+
maxUSD: Type.Optional(Type.Number({ description: "Halt the run once accumulated cost exceeds this many USD" })),
|
|
45
|
+
maxTokens: Type.Optional(
|
|
46
|
+
Type.Number({ description: "Halt the run once accumulated input+output tokens exceed this" }),
|
|
47
|
+
),
|
|
48
|
+
},
|
|
49
|
+
{ additionalProperties: false },
|
|
50
|
+
);
|
|
51
|
+
|
|
28
52
|
const PhaseSchema = Type.Object(
|
|
29
53
|
{
|
|
30
54
|
id: Type.String({ description: "Unique phase identifier (referenced via {steps.<id>.output})" }),
|
|
@@ -46,7 +70,28 @@ const PhaseSchema = Type.Object(
|
|
|
46
70
|
Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" }),
|
|
47
71
|
),
|
|
48
72
|
|
|
73
|
+
// sub-workflow (flow)
|
|
74
|
+
use: Type.Optional(Type.String({ description: "[flow] Name of a saved taskflow to run as this phase" })),
|
|
75
|
+
with: Type.Optional(
|
|
76
|
+
Type.Record(Type.String(), Type.Unknown(), {
|
|
77
|
+
description: "[flow] Args passed to the sub-flow (string values support interpolation)",
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
|
|
49
81
|
dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Phase ids this phase depends on" })),
|
|
82
|
+
join: Type.Optional(
|
|
83
|
+
StringEnum(JOIN_MODES, {
|
|
84
|
+
description: "Dependency join: 'all' (default) waits for every dep; 'any' runs as soon as one dep completes",
|
|
85
|
+
default: "all",
|
|
86
|
+
}),
|
|
87
|
+
),
|
|
88
|
+
when: Type.Optional(
|
|
89
|
+
Type.String({
|
|
90
|
+
description:
|
|
91
|
+
"Conditional guard: skip this phase unless the expression is truthy. Supports {refs} and == != < > <= >= && || ! ()",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
retry: Type.Optional(RetrySchema),
|
|
50
95
|
output: Type.Optional(StringEnum(OUTPUT_FORMATS, { description: "Parse output as text or json", default: "text" })),
|
|
51
96
|
model: Type.Optional(Type.String({ description: "Model override for this phase" })),
|
|
52
97
|
thinking: Type.Optional(Type.String({ description: "Thinking level override for this phase" })),
|
|
@@ -77,6 +122,7 @@ export const TaskflowSchema = Type.Object(
|
|
|
77
122
|
version: Type.Optional(Type.Number({ default: 1 })),
|
|
78
123
|
args: Type.Optional(Type.Record(Type.String(), ArgSpecSchema, { description: "Declared invocation arguments" })),
|
|
79
124
|
concurrency: Type.Optional(Type.Number({ description: "Default max concurrent subagents", default: 8 })),
|
|
125
|
+
budget: Type.Optional(BudgetSchema),
|
|
80
126
|
agentScope: Type.Optional(
|
|
81
127
|
StringEnum(["user", "project", "both"] as const, { description: "Agent discovery scope", default: "user" }),
|
|
82
128
|
),
|
|
@@ -89,6 +135,9 @@ export type ParallelTask = Static<typeof ParallelTaskSchema>;
|
|
|
89
135
|
export type Phase = Static<typeof PhaseSchema>;
|
|
90
136
|
export type Taskflow = Static<typeof TaskflowSchema>;
|
|
91
137
|
export type ArgSpec = Static<typeof ArgSpecSchema>;
|
|
138
|
+
export type RetryPolicy = Static<typeof RetrySchema>;
|
|
139
|
+
export type Budget = Static<typeof BudgetSchema>;
|
|
140
|
+
export type JoinMode = (typeof JOIN_MODES)[number];
|
|
92
141
|
|
|
93
142
|
// ---------------------------------------------------------------------------
|
|
94
143
|
// Shorthand (non-DAG) specs — subagent-style ergonomics
|
|
@@ -227,6 +276,25 @@ export function validateTaskflow(def: unknown): ValidationResult {
|
|
|
227
276
|
if (!p.from || p.from.length === 0) errors.push(`Phase '${p.id}' (reduce) requires 'from'`);
|
|
228
277
|
if (!p.task) errors.push(`Phase '${p.id}' (reduce) requires 'task'`);
|
|
229
278
|
}
|
|
279
|
+
if (type === "flow") {
|
|
280
|
+
if (!p.use) errors.push(`Phase '${p.id}' (flow) requires 'use' (a saved flow name)`);
|
|
281
|
+
}
|
|
282
|
+
if (p.retry) {
|
|
283
|
+
if (typeof p.retry.max !== "number" || p.retry.max < 0) {
|
|
284
|
+
errors.push(`Phase '${p.id}': retry.max must be a number >= 0`);
|
|
285
|
+
} else if (p.retry.max > 20) {
|
|
286
|
+
errors.push(`Phase '${p.id}': retry.max must be <= 20`);
|
|
287
|
+
}
|
|
288
|
+
if (p.retry.backoffMs !== undefined && (p.retry.backoffMs < 0 || p.retry.backoffMs > 60000)) {
|
|
289
|
+
errors.push(`Phase '${p.id}': retry.backoffMs must be between 0 and 60000`);
|
|
290
|
+
}
|
|
291
|
+
if (p.retry.factor !== undefined && (p.retry.factor < 1 || p.retry.factor > 10)) {
|
|
292
|
+
errors.push(`Phase '${p.id}': retry.factor must be between 1 and 10`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (p.join && !JOIN_MODES.includes(p.join as JoinMode)) {
|
|
296
|
+
errors.push(`Phase '${p.id}': unknown join mode '${p.join}'`);
|
|
297
|
+
}
|
|
230
298
|
}
|
|
231
299
|
|
|
232
300
|
// dependsOn / from references must exist
|
|
@@ -247,7 +315,7 @@ export function validateTaskflow(def: unknown): ValidationResult {
|
|
|
247
315
|
}
|
|
248
316
|
|
|
249
317
|
// Exactly handle final-phase resolution lazily (0 finals => last phase is final)
|
|
250
|
-
const finals = (flow.phases as Phase[]).filter((p) => p
|
|
318
|
+
const finals = (flow.phases as Phase[]).filter((p) => p?.final);
|
|
251
319
|
if (finals.length > 1) errors.push(`Only one phase may be marked 'final' (found ${finals.length})`);
|
|
252
320
|
|
|
253
321
|
return { ok: errors.length === 0, errors };
|
|
@@ -341,3 +409,18 @@ export function topoLayers(phases: Phase[]): Phase[][] {
|
|
|
341
409
|
export function finalPhase(phases: Phase[]): Phase {
|
|
342
410
|
return phases.find((p) => p.final) ?? phases[phases.length - 1];
|
|
343
411
|
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Apply a flow's declared arg defaults over the provided values, then pass
|
|
415
|
+
* through any extra provided keys. Shared by the tool entrypoint (index) and the
|
|
416
|
+
* sub-flow (`flow`) phase (runtime).
|
|
417
|
+
*/
|
|
418
|
+
export function resolveArgs(def: Taskflow, provided?: Record<string, unknown>): Record<string, unknown> {
|
|
419
|
+
const args: Record<string, unknown> = {};
|
|
420
|
+
for (const [key, spec] of Object.entries(def.args ?? {})) {
|
|
421
|
+
if (provided && key in provided) args[key] = provided[key];
|
|
422
|
+
else if (spec.default !== undefined) args[key] = spec.default;
|
|
423
|
+
}
|
|
424
|
+
if (provided) for (const [k, v] of Object.entries(provided)) if (!(k in args)) args[k] = v;
|
|
425
|
+
return args;
|
|
426
|
+
}
|
package/extensions/store.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as fs from "node:fs";
|
|
|
11
11
|
import * as path from "node:path";
|
|
12
12
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import type { Taskflow } from "./schema.ts";
|
|
14
|
-
import type { UsageStats } from "./
|
|
14
|
+
import type { UsageStats } from "./usage.ts";
|
|
15
15
|
|
|
16
16
|
export interface SavedFlow {
|
|
17
17
|
name: string;
|
|
@@ -39,6 +39,12 @@ export interface PhaseState {
|
|
|
39
39
|
liveText?: string;
|
|
40
40
|
/** Gate verdict (gate phases only). */
|
|
41
41
|
gate?: { verdict: "pass" | "block"; reason?: string };
|
|
42
|
+
/** Total subagent attempts incl. retries (when > calls, a retry happened). */
|
|
43
|
+
attempts?: number;
|
|
44
|
+
/** True when a map/parallel fan-out was cut short by the budget cap. */
|
|
45
|
+
budgetTruncated?: boolean;
|
|
46
|
+
/** Human-in-the-loop outcome (approval phases only). */
|
|
47
|
+
approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean };
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
export interface RunState {
|
|
@@ -118,7 +124,7 @@ export function saveFlow(
|
|
|
118
124
|
fs.mkdirSync(dir, { recursive: true });
|
|
119
125
|
const safe = def.name.replace(/[^\w.-]+/g, "_");
|
|
120
126
|
const filePath = path.join(dir, `${safe}.json`);
|
|
121
|
-
|
|
127
|
+
writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`);
|
|
122
128
|
return { filePath };
|
|
123
129
|
}
|
|
124
130
|
|
|
@@ -138,7 +144,7 @@ export function saveRun(state: RunState): void {
|
|
|
138
144
|
const dir = runsDir(state.cwd);
|
|
139
145
|
fs.mkdirSync(dir, { recursive: true });
|
|
140
146
|
state.updatedAt = Date.now();
|
|
141
|
-
|
|
147
|
+
writeFileAtomic(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2));
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
export function loadRun(cwd: string, runId: string): RunState | null {
|
|
@@ -174,3 +180,23 @@ export function listRuns(cwd: string, limit = 20): RunState[] {
|
|
|
174
180
|
export function hashInput(...parts: string[]): string {
|
|
175
181
|
return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
|
|
176
182
|
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Write a file atomically: write to a unique temp file in the same directory,
|
|
186
|
+
* then rename over the target (rename is atomic on the same filesystem). Prevents
|
|
187
|
+
* a crash or concurrent write from leaving a half-written, corrupt JSON file.
|
|
188
|
+
*/
|
|
189
|
+
function writeFileAtomic(filePath: string, data: string): void {
|
|
190
|
+
const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
|
|
191
|
+
try {
|
|
192
|
+
fs.writeFileSync(tmp, data, "utf-8");
|
|
193
|
+
fs.renameSync(tmp, filePath);
|
|
194
|
+
} catch (e) {
|
|
195
|
+
try {
|
|
196
|
+
if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
|
|
197
|
+
} catch {
|
|
198
|
+
/* ignore cleanup failure */
|
|
199
|
+
}
|
|
200
|
+
throw e;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage accounting — token/cost stats shared across the runner (producer),
|
|
3
|
+
* runtime (aggregation), store (persistence), and render (display).
|
|
4
|
+
*
|
|
5
|
+
* Kept in its own leaf module so persistence and TUI don't have to depend on
|
|
6
|
+
* the process-spawning layer (`runner.ts`) just for these types/helpers.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface UsageStats {
|
|
10
|
+
input: number;
|
|
11
|
+
output: number;
|
|
12
|
+
cacheRead: number;
|
|
13
|
+
cacheWrite: number;
|
|
14
|
+
cost: number;
|
|
15
|
+
contextTokens: number;
|
|
16
|
+
turns: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function emptyUsage(): UsageStats {
|
|
20
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Sum numeric usage fields across runs. `contextTokens` is intentionally excluded (it is a point-in-time gauge, not additive). */
|
|
24
|
+
export function aggregateUsage(usages: UsageStats[]): UsageStats {
|
|
25
|
+
const total = emptyUsage();
|
|
26
|
+
for (const u of usages) {
|
|
27
|
+
total.input += u.input;
|
|
28
|
+
total.output += u.output;
|
|
29
|
+
total.cacheRead += u.cacheRead;
|
|
30
|
+
total.cacheWrite += u.cacheWrite;
|
|
31
|
+
total.cost += u.cost;
|
|
32
|
+
total.turns += u.turns;
|
|
33
|
+
}
|
|
34
|
+
return total;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatTokens(count: number): string {
|
|
38
|
+
if (count < 1000) return count.toString();
|
|
39
|
+
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
|
40
|
+
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
|
41
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6",
|
|
4
4
|
"description": "Lightweight workflow orchestration for the Pi coding agent — declarative multi-phase taskflows with dynamic fan-out, isolated subagent context, resumable runs, and saveable commands.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
38
|
"typecheck": "tsc --noEmit",
|
|
39
|
-
"test": "node --experimental-strip-types --test test/interpolate.test.ts test/schema.test.ts test/runtime.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
|
|
39
|
+
"test": "node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
|
|
40
40
|
"test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
|
|
41
41
|
},
|
|
42
42
|
"pi": {
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -86,6 +86,71 @@ Call the `taskflow` tool. To run a brand-new flow you write inline, pass
|
|
|
86
86
|
| `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound |
|
|
87
87
|
| `gate` | quality/review step that can **halt the flow** (see below) |
|
|
88
88
|
| `reduce` | aggregate `from[]` phases into one output |
|
|
89
|
+
| `approval` | **human-in-the-loop** pause: ask a person to approve / reject / edit before continuing |
|
|
90
|
+
| `flow` | run a **saved sub-flow** (by `use`) as a single phase — composition/reuse |
|
|
91
|
+
|
|
92
|
+
### Control-flow fields (any phase)
|
|
93
|
+
|
|
94
|
+
| field | meaning |
|
|
95
|
+
|-------|---------|
|
|
96
|
+
| `when` | conditional guard — skip the phase unless the expression is truthy. Supports `{refs}`, `== != < > <= >=`, `&& \|\| !`, parentheses, quoted strings/numbers. Parse errors fail **open** (phase runs). |
|
|
97
|
+
| `join` | dependency join: `"all"` (default — wait for every dep) or `"any"` (OR-join — run as soon as one dep completes). |
|
|
98
|
+
| `retry` | `{ "max": N, "backoffMs": ms, "factor": k }` — retry a failing subagent up to N times; delay is `backoffMs * factor^attempt` (`factor:1`=fixed, `2`=exponential). |
|
|
99
|
+
|
|
100
|
+
### Conditional routing (when + gate/branches)
|
|
101
|
+
|
|
102
|
+
Pair `when` with an upstream phase that emits a decision to build real if/else
|
|
103
|
+
routing. Use `join: "any"` on the merge phase so it runs whichever branch fired:
|
|
104
|
+
|
|
105
|
+
```jsonc
|
|
106
|
+
{ "id": "triage", "type": "agent", "agent": "analyst", "output": "json",
|
|
107
|
+
"task": "Classify the task. Output ONLY {\"route\":\"deep\"} or {\"route\":\"quick\"}." },
|
|
108
|
+
{ "id": "deep", "when": "{steps.triage.json.route} == deep", "dependsOn": ["triage"], "agent": "analyst", "task": "..." },
|
|
109
|
+
{ "id": "quick", "when": "{steps.triage.json.route} == quick", "dependsOn": ["triage"], "agent": "executor_fast", "task": "..." },
|
|
110
|
+
{ "id": "report", "type": "reduce", "from": ["deep","quick"], "join": "any",
|
|
111
|
+
"dependsOn": ["deep","quick"], "agent": "writer", "task": "...", "final": true }
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
> `when` should reference **upstream** (`dependsOn`) phases — a ref to a phase
|
|
115
|
+
> that hasn't completed resolves empty and the guard is treated as false.
|
|
116
|
+
|
|
117
|
+
### Approval phases (human-in-the-loop)
|
|
118
|
+
|
|
119
|
+
An `approval` phase pauses the run and asks the operator to **Approve / Reject /
|
|
120
|
+
Edit**. Distinct from `gate` (which is an *agent* reviewing): this is a *human*
|
|
121
|
+
deciding. The (interpolated) `task` is the prompt shown.
|
|
122
|
+
|
|
123
|
+
- **Approve** → continue; the phase output is `(approve)`.
|
|
124
|
+
- **Reject** → halt the flow (same mechanism as a blocking gate).
|
|
125
|
+
- **Edit** → the typed note becomes this phase's `output`, so you can inject
|
|
126
|
+
guidance mid-run: reference it downstream with `{steps.<id>.output}`.
|
|
127
|
+
- **Non-interactive** runs (headless/CI/print mode) **auto-approve** and record it.
|
|
128
|
+
|
|
129
|
+
```jsonc
|
|
130
|
+
{ "id": "checkpoint", "type": "approval", "dependsOn": ["plan"],
|
|
131
|
+
"task": "Review the plan above before the expensive fan-out. Approve, reject, or add guidance." }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Sub-flows (composition)
|
|
135
|
+
|
|
136
|
+
A `flow` phase runs another **saved** taskflow by name and bubbles up its final
|
|
137
|
+
output. Pass args via `with` (string values interpolate). Recursion is detected
|
|
138
|
+
and rejected.
|
|
139
|
+
|
|
140
|
+
```jsonc
|
|
141
|
+
{ "id": "research", "type": "flow", "use": "deep-research",
|
|
142
|
+
"with": { "topic": "{item}" }, "dependsOn": ["plan"] }
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Budget (cost / token caps)
|
|
146
|
+
|
|
147
|
+
Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it,
|
|
148
|
+
remaining phases are skipped (and an in-flight `map`/`parallel` stops spawning
|
|
149
|
+
new items); the run ends as `blocked`.
|
|
150
|
+
|
|
151
|
+
```jsonc
|
|
152
|
+
{ "name": "...", "budget": { "maxUSD": 1.50, "maxTokens": 2000000 }, "phases": [ ... ] }
|
|
153
|
+
```
|
|
89
154
|
|
|
90
155
|
### Gate phases (quality control)
|
|
91
156
|
|
|
@@ -132,8 +197,8 @@ variables, and storage paths — read `configuration.md` (next to this file).
|
|
|
132
197
|
|
|
133
198
|
Quick reference:
|
|
134
199
|
|
|
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`.
|
|
200
|
+
- **Flow:** `name`, `description`, `concurrency` (default 8), `budget` (`maxUSD`/`maxTokens`), `agentScope` (user|project|both), `args`.
|
|
201
|
+
- **Phase:** `model`, `thinking`, `tools` (whitelist), `cwd`, `output:"json"`, `concurrency` (map/parallel fan-out), `when`, `join` (all|any), `retry`, `use`/`with` (flow), `final`.
|
|
137
202
|
- **Precedence (model/thinking/tools):** phase value → `settings.subagents.agentOverrides[agent]` → agent frontmatter → global/default.
|
|
138
203
|
- **Concurrency:** same-layer phases use `flow.concurrency`; a `map`/`parallel` phase uses `phase.concurrency ?? flow.concurrency ?? 8`.
|
|
139
204
|
|