pi-taskflow 0.0.6 → 0.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -13
- package/examples/conditional-research.json +1 -1
- package/examples/guarded-refactor.json +2 -2
- package/extensions/agents.ts +54 -34
- package/extensions/index.ts +19 -7
- package/extensions/interpolate.ts +25 -4
- package/extensions/render.ts +41 -36
- package/extensions/runner.ts +97 -15
- package/extensions/runs-view.ts +3 -0
- package/extensions/runtime.ts +216 -28
- package/extensions/schema.ts +151 -5
- package/extensions/store.ts +77 -7
- package/package.json +1 -1
- package/skills/taskflow/SKILL.md +112 -1
- package/skills/taskflow/configuration.md +0 -2
package/extensions/schema.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* to a subagent (an isolated `pi` process). Phases form a DAG via `dependsOn`.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import * as path from "node:path";
|
|
8
9
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
9
10
|
import { Type, type Static } from "typebox";
|
|
10
11
|
|
|
@@ -102,6 +103,18 @@ const PhaseSchema = Type.Object(
|
|
|
102
103
|
Type.Boolean({ description: "If true, a failure does not abort the run", default: false }),
|
|
103
104
|
),
|
|
104
105
|
concurrency: Type.Optional(Type.Number({ description: "Override max concurrency for map/parallel" })),
|
|
106
|
+
context: Type.Optional(
|
|
107
|
+
Type.Array(Type.String(), {
|
|
108
|
+
description:
|
|
109
|
+
"File paths or {steps.X} refs to pre-read and inject before the task. Resolves interpolated refs first, then reads each file (capped per-file). Eliminates O(N²) turn-cost exploration.",
|
|
110
|
+
}),
|
|
111
|
+
),
|
|
112
|
+
contextLimit: Type.Optional(
|
|
113
|
+
Type.Number({
|
|
114
|
+
description: "Max characters to read per file referenced in context (default 8000).",
|
|
115
|
+
default: 8000,
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
105
118
|
},
|
|
106
119
|
{ additionalProperties: false },
|
|
107
120
|
);
|
|
@@ -126,7 +139,20 @@ export const TaskflowSchema = Type.Object(
|
|
|
126
139
|
agentScope: Type.Optional(
|
|
127
140
|
StringEnum(["user", "project", "both"] as const, { description: "Agent discovery scope", default: "user" }),
|
|
128
141
|
),
|
|
142
|
+
strictInterpolation: Type.Optional(
|
|
143
|
+
Type.Boolean({
|
|
144
|
+
description:
|
|
145
|
+
"When true, unresolved interpolation placeholders and validation warnings about missing deps/args become hard errors",
|
|
146
|
+
default: false,
|
|
147
|
+
}),
|
|
148
|
+
),
|
|
129
149
|
phases: Type.Array(PhaseSchema, { minItems: 1, description: "Ordered phase definitions (DAG via dependsOn)" }),
|
|
150
|
+
implicitGate: Type.Optional(
|
|
151
|
+
Type.Boolean({
|
|
152
|
+
description: "When true (default), a reviewer gate is auto-injected after all phases if no explicit gate or approval exists",
|
|
153
|
+
default: true,
|
|
154
|
+
}),
|
|
155
|
+
),
|
|
130
156
|
},
|
|
131
157
|
{ additionalProperties: false },
|
|
132
158
|
);
|
|
@@ -164,7 +190,11 @@ export function isShorthand(def: unknown): boolean {
|
|
|
164
190
|
if (typeof def !== "object" || def === null) return false;
|
|
165
191
|
const d = def as Record<string, unknown>;
|
|
166
192
|
if (Array.isArray(d.phases)) return false;
|
|
167
|
-
return
|
|
193
|
+
return (
|
|
194
|
+
(Array.isArray(d.chain) && d.chain.length > 0) ||
|
|
195
|
+
(Array.isArray(d.tasks) && d.tasks.length > 0) ||
|
|
196
|
+
typeof d.task === "string"
|
|
197
|
+
);
|
|
168
198
|
}
|
|
169
199
|
|
|
170
200
|
function readStep(s: unknown): ShorthandStep {
|
|
@@ -190,6 +220,8 @@ export function desugar(def: unknown): Taskflow {
|
|
|
190
220
|
if (typeof d.concurrency === "number") meta.concurrency = d.concurrency;
|
|
191
221
|
if (d.agentScope === "user" || d.agentScope === "project" || d.agentScope === "both") meta.agentScope = d.agentScope;
|
|
192
222
|
if (d.args && typeof d.args === "object") meta.args = d.args as Taskflow["args"];
|
|
223
|
+
if (d.budget) meta.budget = d.budget;
|
|
224
|
+
if (typeof d.strictInterpolation === "boolean") meta.strictInterpolation = d.strictInterpolation;
|
|
193
225
|
const nameOf = (fallback: string) => (typeof d.name === "string" && d.name.trim() ? d.name.trim() : fallback);
|
|
194
226
|
|
|
195
227
|
// chain → sequential agent phases
|
|
@@ -228,20 +260,35 @@ export function desugar(def: unknown): Taskflow {
|
|
|
228
260
|
export interface ValidationResult {
|
|
229
261
|
ok: boolean;
|
|
230
262
|
errors: string[];
|
|
263
|
+
/** Non-fatal issues the user should fix; e.g. `{steps.X}` references that
|
|
264
|
+
* aren't declared in `dependsOn` (the phase will run in parallel with its
|
|
265
|
+
* producer and see the literal placeholder). */
|
|
266
|
+
warnings: string[];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface ValidationOptions {
|
|
270
|
+
/** Resolved invocation args, used for runtime checks like missing `{args.X}`. */
|
|
271
|
+
args?: Record<string, unknown>;
|
|
272
|
+
/** Runtime working directory, used for mismatch warnings (e.g. cwd vs args.codebase). */
|
|
273
|
+
cwd?: string;
|
|
274
|
+
/** Override the flow's own `strictInterpolation` flag for this validation call. */
|
|
275
|
+
strict?: boolean;
|
|
231
276
|
}
|
|
232
277
|
|
|
233
|
-
export function validateTaskflow(def: unknown): ValidationResult {
|
|
278
|
+
export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): ValidationResult {
|
|
234
279
|
const errors: string[] = [];
|
|
280
|
+
const warnings: string[] = [];
|
|
235
281
|
|
|
236
282
|
if (typeof def !== "object" || def === null) {
|
|
237
|
-
return { ok: false, errors: ["Taskflow must be an object"] };
|
|
283
|
+
return { ok: false, errors: ["Taskflow must be an object"], warnings };
|
|
238
284
|
}
|
|
239
285
|
const flow = def as Partial<Taskflow>;
|
|
286
|
+
const strict = opts.strict ?? flow.strictInterpolation === true;
|
|
240
287
|
|
|
241
288
|
if (!flow.name || typeof flow.name !== "string") errors.push("Missing or invalid 'name'");
|
|
242
289
|
if (!Array.isArray(flow.phases) || flow.phases.length === 0) {
|
|
243
290
|
errors.push("Taskflow must have at least one phase");
|
|
244
|
-
return { ok: false, errors };
|
|
291
|
+
return { ok: false, errors, warnings };
|
|
245
292
|
}
|
|
246
293
|
|
|
247
294
|
const ids = new Set<string>();
|
|
@@ -318,7 +365,106 @@ export function validateTaskflow(def: unknown): ValidationResult {
|
|
|
318
365
|
const finals = (flow.phases as Phase[]).filter((p) => p?.final);
|
|
319
366
|
if (finals.length > 1) errors.push(`Only one phase may be marked 'final' (found ${finals.length})`);
|
|
320
367
|
|
|
321
|
-
|
|
368
|
+
// --- Hard errors: {steps.X.*} references that aren't declared deps ------
|
|
369
|
+
// Catches the most common authoring mistake: the task talks about
|
|
370
|
+
// `{steps.review.output}` but `dependsOn: ["review"]` is missing, so the
|
|
371
|
+
// phase runs in parallel with `review` and the model sees the literal
|
|
372
|
+
// placeholder string. The runtime can't infer the intent — fail fast at
|
|
373
|
+
// validation time so the mistake is caught before the run starts.
|
|
374
|
+
//
|
|
375
|
+
// Phases with `join: "any"` are exempt: by design they only need ONE of
|
|
376
|
+
// their declared deps to complete, and may reference other phases as
|
|
377
|
+
// informational context (not as true dependencies).
|
|
378
|
+
if (errors.length === 0) {
|
|
379
|
+
const idToPhase = new Map((flow.phases as Phase[]).map((p) => [p.id, p]));
|
|
380
|
+
for (const p of flow.phases as Phase[]) {
|
|
381
|
+
if (!p?.id) continue;
|
|
382
|
+
const isJoinAny = p.join === "any";
|
|
383
|
+
if (isJoinAny) continue;
|
|
384
|
+
const deps = new Set(dependenciesOf(p));
|
|
385
|
+
const refs = collectRefs(p);
|
|
386
|
+
for (const ref of refs.steps) {
|
|
387
|
+
if (ref === p.id) {
|
|
388
|
+
errors.push(`Phase '${p.id}': references its own output via {steps.${ref}.*}; this is almost always a bug.`);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (!idToPhase.has(ref)) {
|
|
392
|
+
// Unknown ref is already an error from the dependsOn check, but
|
|
393
|
+
// {steps.X.*} can appear in a task without dependsOn. Don't
|
|
394
|
+
// double-warn — the dependsOn loop above already flags it.
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
if (!deps.has(ref)) {
|
|
398
|
+
errors.push(
|
|
399
|
+
`Phase '${p.id}': task references {steps.${ref}.*} but '${ref}' is not in dependsOn. ` +
|
|
400
|
+
`The phase will run in parallel with '${ref}' and see the literal placeholder. ` +
|
|
401
|
+
`Add "dependsOn": ["${ref}"] (or include '${ref}' transitively).`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// --- Runtime/invocation warnings: missing args + cwd/codebase mismatch -----
|
|
409
|
+
if (errors.length === 0 && opts.args) {
|
|
410
|
+
const argRefs = new Set<string>();
|
|
411
|
+
for (const p of flow.phases as Phase[]) {
|
|
412
|
+
if (!p?.id) continue;
|
|
413
|
+
for (const ref of collectRefs(p).args) argRefs.add(ref);
|
|
414
|
+
}
|
|
415
|
+
for (const ref of argRefs) {
|
|
416
|
+
if (!(ref in opts.args)) {
|
|
417
|
+
warnings.push(
|
|
418
|
+
`Taskflow references {args.${ref}} but the invocation did not provide '${ref}'. ` +
|
|
419
|
+
`The placeholder will remain literal unless a default or runtime arg is supplied.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (opts.cwd && typeof opts.args.codebase === "string" && opts.args.codebase.trim()) {
|
|
424
|
+
const cwd = path.resolve(opts.cwd);
|
|
425
|
+
const codebase = path.resolve(cwd, opts.args.codebase);
|
|
426
|
+
// Safe case: cwd is the codebase root or a subdirectory within it.
|
|
427
|
+
// Warn when cwd is a sibling, unrelated path, or a parent of the
|
|
428
|
+
// codebase (agents that rely on cwd would inspect too broad a tree).
|
|
429
|
+
if (!pathContains(codebase, cwd)) {
|
|
430
|
+
warnings.push(
|
|
431
|
+
`Invocation cwd '${cwd}' does not match args.codebase '${codebase}'. ` +
|
|
432
|
+
`Some agents may inspect the wrong repo if they rely on cwd. Prefer running from the codebase root or set phase.cwd explicitly.`,
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
if (strict && warnings.length) {
|
|
439
|
+
errors.push(...warnings.map((w) => `Strict interpolation: ${w}`));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return { ok: errors.length === 0, errors, warnings };
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
|
|
446
|
+
const steps = new Set<string>();
|
|
447
|
+
const args = new Set<string>();
|
|
448
|
+
const scan = (s: string | undefined) => {
|
|
449
|
+
if (!s) return;
|
|
450
|
+
let m: RegExpExecArray | null;
|
|
451
|
+
const stepRe = /\{steps\.([a-zA-Z0-9_-]+)/g;
|
|
452
|
+
while ((m = stepRe.exec(s)) !== null) steps.add(m[1]);
|
|
453
|
+
const argRe = /\{args\.([a-zA-Z0-9_-]+)/g;
|
|
454
|
+
while ((m = argRe.exec(s)) !== null) args.add(m[1]);
|
|
455
|
+
};
|
|
456
|
+
scan(phase.task);
|
|
457
|
+
scan(phase.over);
|
|
458
|
+
scan(phase.when);
|
|
459
|
+
for (const b of phase.branches ?? []) scan(b.task);
|
|
460
|
+
for (const v of Object.values(phase.with ?? {})) if (typeof v === "string") scan(v);
|
|
461
|
+
for (const c of phase.context ?? []) scan(c);
|
|
462
|
+
return { steps: Array.from(steps), args: Array.from(args) };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function pathContains(parent: string, child: string): boolean {
|
|
466
|
+
const rel = path.relative(parent, child);
|
|
467
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
322
468
|
}
|
|
323
469
|
|
|
324
470
|
/** Returns a cycle path if the DAG has one, else null. */
|
package/extensions/store.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import * as crypto from "node:crypto";
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
11
12
|
import * as path from "node:path";
|
|
12
13
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
13
14
|
import type { Taskflow } from "./schema.ts";
|
|
@@ -45,6 +46,12 @@ export interface PhaseState {
|
|
|
45
46
|
budgetTruncated?: boolean;
|
|
46
47
|
/** Human-in-the-loop outcome (approval phases only). */
|
|
47
48
|
approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean };
|
|
49
|
+
/** Non-fatal diagnostic warnings accumulated during this phase (e.g.
|
|
50
|
+
* unresolved interpolation placeholders, suspicious templates). */
|
|
51
|
+
warnings?: string[];
|
|
52
|
+
/** Truncated previews of interpolated strings used to execute this phase,
|
|
53
|
+
* useful when diagnosing why a model saw a literal placeholder. */
|
|
54
|
+
interpolation?: Array<{ source: string; text: string; missing?: string[] }>;
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
export interface RunState {
|
|
@@ -63,12 +70,20 @@ function userFlowsDir(): string {
|
|
|
63
70
|
return path.join(getAgentDir(), "taskflows");
|
|
64
71
|
}
|
|
65
72
|
|
|
66
|
-
function
|
|
73
|
+
function findProjectFlowsDirInternal(cwd: string, create = false): string | null {
|
|
67
74
|
// Prefer an existing .pi dir up the tree; else use cwd/.pi when creating.
|
|
75
|
+
// **Never treat `~/.pi/` as a project flow dir** — the home directory is
|
|
76
|
+
// the user-scope boundary, and the user's `~/.pi/` is the agent dir, not a
|
|
77
|
+
// project. We skip the home entry entirely during the walk-up, so even a
|
|
78
|
+
// deeply nested cwd under home will return null (create=false) when no
|
|
79
|
+
// project `.pi` exists on the path.
|
|
80
|
+
const home = os.homedir();
|
|
68
81
|
let dir = cwd;
|
|
69
82
|
while (true) {
|
|
70
|
-
|
|
71
|
-
|
|
83
|
+
if (dir !== home) {
|
|
84
|
+
const candidate = path.join(dir, ".pi");
|
|
85
|
+
if (fs.existsSync(candidate)) return path.join(candidate, "taskflows");
|
|
86
|
+
}
|
|
72
87
|
const parent = path.dirname(dir);
|
|
73
88
|
if (parent === dir) break;
|
|
74
89
|
dir = parent;
|
|
@@ -88,6 +103,11 @@ function readFlowFile(filePath: string, scope: "user" | "project"): SavedFlow |
|
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
/** List all saved flows (project overrides user on name collision). */
|
|
106
|
+
/** Internal-but-exported for tests: walk-up `.pi` finder with home-dir stop. */
|
|
107
|
+
export function findProjectFlowsDir(cwd: string, create = false): string | null {
|
|
108
|
+
return findProjectFlowsDirInternal(cwd, create);
|
|
109
|
+
}
|
|
110
|
+
|
|
91
111
|
export function listFlows(cwd: string): SavedFlow[] {
|
|
92
112
|
const map = new Map<string, SavedFlow>();
|
|
93
113
|
const dirs: Array<{ dir: string; scope: "user" | "project" }> = [{ dir: userFlowsDir(), scope: "user" }];
|
|
@@ -143,13 +163,56 @@ export function newRunId(flowName: string): string {
|
|
|
143
163
|
export function saveRun(state: RunState): void {
|
|
144
164
|
const dir = runsDir(state.cwd);
|
|
145
165
|
fs.mkdirSync(dir, { recursive: true });
|
|
146
|
-
|
|
147
|
-
|
|
166
|
+
// Clone before stamping updatedAt so the caller's RunState reference is not
|
|
167
|
+
// mutated as a hidden side effect (v0.0.6 audit, F-009). Shallow clone is
|
|
168
|
+
// sufficient: saveRun only serializes; it does not mutate nested objects.
|
|
169
|
+
const toSave = { ...state, updatedAt: Date.now() };
|
|
170
|
+
writeFileAtomic(path.join(dir, `${state.runId}.json`), JSON.stringify(toSave, null, 2));
|
|
148
171
|
}
|
|
149
172
|
|
|
150
173
|
export function loadRun(cwd: string, runId: string): RunState | null {
|
|
174
|
+
const dir = runsDir(cwd);
|
|
175
|
+
|
|
176
|
+
// Reject runIds that could be used for path traversal or filesystem abuse.
|
|
177
|
+
// Legitimate runIds are produced by newRunId() and contain only
|
|
178
|
+
// [A-Za-z0-9._-]; anything else (empty string, path separators, NUL bytes,
|
|
179
|
+
// backslashes on POSIX, forward slashes on Windows) is suspicious.
|
|
180
|
+
if (
|
|
181
|
+
typeof runId !== "string" ||
|
|
182
|
+
runId.length === 0 ||
|
|
183
|
+
runId.includes("/") ||
|
|
184
|
+
runId.includes("\\") ||
|
|
185
|
+
runId.includes("\0")
|
|
186
|
+
) {
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const filePath = path.resolve(dir, `${runId}.json`);
|
|
191
|
+
// Reject runIds that would escape the runs directory (e.g. "../etc/passwd").
|
|
192
|
+
// Compare with a path-separator suffix so legitimate filenames like "..foo"
|
|
193
|
+
// (a name that just happens to start with two dots) are not false-positives.
|
|
194
|
+
const rel = path.relative(dir, filePath);
|
|
195
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
196
|
+
|
|
197
|
+
// Resolve symlinks on both the runs dir and the file, so the containment
|
|
198
|
+
// check below is on a consistent physical path. Without normalizing `dir`,
|
|
199
|
+
// a legitimate run on macOS (where /var → /private/var) would compare a
|
|
200
|
+
// symlinked dir prefix to a real path and falsely flag traversal. A
|
|
201
|
+
// malicious file already placed inside the runs dir could otherwise also
|
|
202
|
+
// point at an arbitrary path on disk and bypass the lexical check above.
|
|
203
|
+
let realDir: string;
|
|
204
|
+
let realFilePath: string;
|
|
205
|
+
try {
|
|
206
|
+
realDir = fs.realpathSync(dir);
|
|
207
|
+
realFilePath = fs.realpathSync(filePath);
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const realRel = path.relative(realDir, realFilePath);
|
|
212
|
+
if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) return null;
|
|
213
|
+
|
|
151
214
|
try {
|
|
152
|
-
const raw = fs.readFileSync(
|
|
215
|
+
const raw = fs.readFileSync(realFilePath, "utf-8");
|
|
153
216
|
return JSON.parse(raw) as RunState;
|
|
154
217
|
} catch {
|
|
155
218
|
return null;
|
|
@@ -173,7 +236,14 @@ export function listRuns(cwd: string, limit = 20): RunState[] {
|
|
|
173
236
|
/* ignore */
|
|
174
237
|
}
|
|
175
238
|
}
|
|
176
|
-
|
|
239
|
+
// Guard against records missing/with non-numeric `updatedAt` — a bare
|
|
240
|
+
// `JSON.parse` may yield an object without it, and `undefined - undefined`
|
|
241
|
+
// is NaN, which makes `Array.prototype.sort` produce implementation-defined
|
|
242
|
+
// order. Drop those before sorting. (v0.0.8 audit, F-010.)
|
|
243
|
+
return runs
|
|
244
|
+
.filter((r) => typeof r.updatedAt === "number" && !Number.isNaN(r.updatedAt))
|
|
245
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
246
|
+
.slice(0, limit);
|
|
177
247
|
}
|
|
178
248
|
|
|
179
249
|
/** Stable hash of a phase's resolved task + inputs, for resume caching. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.8",
|
|
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",
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -172,6 +172,36 @@ Review the audit results below. If any endpoint is missing auth, end with
|
|
|
172
172
|
{steps.audit.output}
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
+
### Structured-verify phases (v0.0.8.1)
|
|
176
|
+
|
|
177
|
+
A "verify" phase typically runs `npx tsc --noEmit && npm test && git diff --stat`
|
|
178
|
+
and reports whether everything is green. **Don't** delegate this to a generic
|
|
179
|
+
verifier subagent that summarizes the output in prose — LLMs commonly misread
|
|
180
|
+
shell output (e.g., 234 tests reported as 230, 745 insertions as 599, "1 type
|
|
181
|
+
error" reported as "clean"). Instead, **use a dedicated agent whose task is a
|
|
182
|
+
structured shell pipeline** that echoes structured key/value lines the next
|
|
183
|
+
phase can parse directly. Recommended pattern:
|
|
184
|
+
|
|
185
|
+
```jsonc
|
|
186
|
+
{
|
|
187
|
+
"id": "verify",
|
|
188
|
+
"type": "agent",
|
|
189
|
+
"agent": "verifier",
|
|
190
|
+
"dependsOn": ["apply-fixes"],
|
|
191
|
+
"task": "Run the verification pipeline and report structured results.\n\nExecute:\n```bash\ncd $REPO && npx tsc --noEmit 2>&1 | tee /tmp/tsc.log\ncd $REPO && npm test 2>&1 | tee /tmp/test.log | tail -10\ncd $REPO && git diff --shortstat HEAD | tee /tmp/diff.log\n```\n\nReport EXACTLY in this format (one key=value pair per line, no prose):\ntypecheck=PASS|FAIL\ntests_total=N\ntests_pass=N\ntests_fail=N\ninsertions=N\ndeletions=N\nfiles_changed=N\n\nIf any field is missing, you failed the task — re-run the command and re-read the output.",
|
|
192
|
+
"tools": ["read", "edit", "write", "bash"]
|
|
193
|
+
}
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
The key insight: **LLMs are bad at summarizing shell output, good at copying
|
|
197
|
+
structured data**. Asking for `key=value` pairs with explicit fields and "if
|
|
198
|
+
missing, you failed" forces the agent to read each field carefully. Downstream
|
|
199
|
+
phases that consume `{steps.verify.output}` can then `safeParse`-it into a
|
|
200
|
+
JSON object and assert against expected values.
|
|
201
|
+
|
|
202
|
+
For audits where the upstream is LLM-generated prose (not shell output), use a
|
|
203
|
+
plain `gate` phase with `VERDICT:` instead.
|
|
204
|
+
|
|
175
205
|
### Interpolation
|
|
176
206
|
|
|
177
207
|
- `{args.X}` — invocation argument
|
|
@@ -188,6 +218,87 @@ Review the audit results below. If any endpoint is missing auth, end with
|
|
|
188
218
|
3. Reference upstream results explicitly with `{steps.ID...}` and set `dependsOn`.
|
|
189
219
|
4. Mark the result-bearing phase with `"final": true` (else the last phase wins).
|
|
190
220
|
|
|
221
|
+
## Common mistakes (the runtime will reject these at validation time)
|
|
222
|
+
|
|
223
|
+
The runtime validates your flow at startup. As of v0.0.8.1, the two most
|
|
224
|
+
common authoring mistakes below are **hard validation errors** (the flow
|
|
225
|
+
refuses to start). Fix the flow before running it.
|
|
226
|
+
|
|
227
|
+
### 1. Referencing `{steps.X}` without `dependsOn: ["X"]`
|
|
228
|
+
|
|
229
|
+
```jsonc
|
|
230
|
+
// ❌ WRONG — 'fix-issues' will run in parallel with 'code-review-1' and see the
|
|
231
|
+
// literal string "{steps.code-review-1.output}" instead of the review text.
|
|
232
|
+
{
|
|
233
|
+
"id": "code-review-1", "type": "agent", "task": "review code"
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
"id": "fix-issues", "type": "agent",
|
|
237
|
+
"task": "fix {steps.code-review-1.output}" // ← no dependsOn!
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Validation now rejects this with: `Phase 'fix-issues': task references
|
|
242
|
+
{steps.code-review-1.*} but 'code-review-1' is not in dependsOn. ...`
|
|
243
|
+
**Always declare the chain:**
|
|
244
|
+
|
|
245
|
+
```jsonc
|
|
246
|
+
// ✅ RIGHT
|
|
247
|
+
{
|
|
248
|
+
"id": "code-review-1", "type": "agent", "task": "review code"
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
"id": "fix-issues", "type": "agent",
|
|
252
|
+
"task": "fix {steps.code-review-1.output}",
|
|
253
|
+
"dependsOn": ["code-review-1"] // ← declared
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
"id": "code-review-2", "type": "agent",
|
|
257
|
+
"task": "re-review {steps.fix-issues.output}",
|
|
258
|
+
"dependsOn": ["fix-issues"]
|
|
259
|
+
}
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
Tip: write the `task` first (it tells you what each phase needs), then scan for
|
|
263
|
+
`{steps.*}` references and add the matching `dependsOn`. If a phase truly does
|
|
264
|
+
not depend on anything in its task, you can omit the reference.
|
|
265
|
+
|
|
266
|
+
Exception: phases with `join: "any"` are exempt from this check, since they
|
|
267
|
+
deliberately wait for only one of their declared deps to complete and may
|
|
268
|
+
reference others as informational context.
|
|
269
|
+
|
|
270
|
+
### 2. Assuming the runtime knows "this is a chain"
|
|
271
|
+
|
|
272
|
+
Phase order in the `phases` array is **documentation, not execution order**.
|
|
273
|
+
The DAG comes from `dependsOn`. If you list `code-review-1`, `fix-issues`,
|
|
274
|
+
`code-review-2`, `fix-final` in that order with no `dependsOn`, the runtime
|
|
275
|
+
treats them as four independent phases and runs all of them in **layer 0** in
|
|
276
|
+
parallel. A phase that finishes first may not be the one you expected.
|
|
277
|
+
|
|
278
|
+
```jsonc
|
|
279
|
+
// ❌ This is not a chain — it's 4 parallel phases, all racing.
|
|
280
|
+
"phases": [
|
|
281
|
+
{ "id": "code-review-1", ... },
|
|
282
|
+
{ "id": "fix-issues", ... },
|
|
283
|
+
{ "id": "code-review-2", ... },
|
|
284
|
+
{ "id": "fix-final", ... }
|
|
285
|
+
]
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
Use the shorthand if you literally just want `a → b → c → d`:
|
|
289
|
+
|
|
290
|
+
```jsonc
|
|
291
|
+
{ "chain": [
|
|
292
|
+
{ "agent": "reviewer", "task": "review code" },
|
|
293
|
+
{ "agent": "executor", "task": "fix {previous.output}" },
|
|
294
|
+
{ "agent": "reviewer", "task": "re-review" },
|
|
295
|
+
{ "agent": "executor", "task": "apply final fixes" }
|
|
296
|
+
] }
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
…or write the full DAG with explicit `dependsOn` (so reviewers/fixers can run
|
|
300
|
+
in parallel against multiple review streams when you want that).
|
|
301
|
+
|
|
191
302
|
## Configuration
|
|
192
303
|
|
|
193
304
|
For the full set of knobs — per-phase `model`/`thinking`/`tools`/`cwd`, the
|
|
@@ -197,7 +308,7 @@ variables, and storage paths — read `configuration.md` (next to this file).
|
|
|
197
308
|
|
|
198
309
|
Quick reference:
|
|
199
310
|
|
|
200
|
-
- **Flow:** `name`, `description`, `concurrency` (default 8), `budget` (`maxUSD`/`maxTokens`), `agentScope` (user|project|both), `args`.
|
|
311
|
+
- **Flow:** `name`, `description`, `concurrency` (default 8), `budget` (`maxUSD`/`maxTokens`), `agentScope` (user|project|both), `args`, `strictInterpolation`.
|
|
201
312
|
- **Phase:** `model`, `thinking`, `tools` (whitelist), `cwd`, `output:"json"`, `concurrency` (map/parallel fan-out), `when`, `join` (all|any), `retry`, `use`/`with` (flow), `final`.
|
|
202
313
|
- **Precedence (model/thinking/tools):** phase value → `settings.subagents.agentOverrides[agent]` → agent frontmatter → global/default.
|
|
203
314
|
- **Concurrency:** same-layer phases use `flow.concurrency`; a `map`/`parallel` phase uses `phase.concurrency ?? flow.concurrency ?? 8`.
|
|
@@ -86,7 +86,6 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s.
|
|
|
86
86
|
| `cwd` | all | flow cwd | Run this phase's subagent in a different directory. |
|
|
87
87
|
| `concurrency` | map, parallel | flow concurrency | Fan-out cap for this phase only. See §4. |
|
|
88
88
|
| `final` | all | last phase | Exactly one phase may be `final`; its output is returned. |
|
|
89
|
-
| `optional` | all | `false` | ⚠️ Declared in schema but **not yet enforced** — a failed phase still skips downstream. |
|
|
90
89
|
|
|
91
90
|
---
|
|
92
91
|
|
|
@@ -270,6 +269,5 @@ Taskflow shares the subagent settings file at `~/.pi/agent/settings.json`:
|
|
|
270
269
|
These keys validate but the runtime does **not** act on them yet — don't rely on
|
|
271
270
|
them for behavior:
|
|
272
271
|
|
|
273
|
-
- `phase.optional` — a failed phase still marks downstream phases as skipped.
|
|
274
272
|
- `arg.required` — missing required args are not rejected.
|
|
275
273
|
- `flow.version` — informational only.
|