pi-taskflow 0.0.5 → 0.0.7

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.
@@ -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
 
@@ -12,10 +13,11 @@ import { Type, type Static } from "typebox";
12
13
  // Phase types
13
14
  // ---------------------------------------------------------------------------
14
15
 
15
- export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce"] as const;
16
+ export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow"] as const;
16
17
  export type PhaseType = (typeof PHASE_TYPES)[number];
17
18
 
18
19
  export const OUTPUT_FORMATS = ["text", "json"] as const;
20
+ export const JOIN_MODES = ["all", "any"] as const;
19
21
 
20
22
  const ParallelTaskSchema = Type.Object(
21
23
  {
@@ -25,6 +27,29 @@ const ParallelTaskSchema = Type.Object(
25
27
  { additionalProperties: false },
26
28
  );
27
29
 
30
+ /** Declarative retry policy for a phase's subagent call(s). */
31
+ const RetrySchema = Type.Object(
32
+ {
33
+ max: Type.Number({ description: "Max retry attempts after the first try (>= 0)" }),
34
+ backoffMs: Type.Optional(Type.Number({ description: "Base delay between attempts, in ms", default: 0 })),
35
+ factor: Type.Optional(
36
+ Type.Number({ description: "Backoff multiplier per attempt (1 = fixed, 2 = exponential)", default: 1 }),
37
+ ),
38
+ },
39
+ { additionalProperties: false },
40
+ );
41
+
42
+ /** Run-wide cost / token ceiling. Exceeding it halts the run (remaining phases skipped). */
43
+ const BudgetSchema = Type.Object(
44
+ {
45
+ maxUSD: Type.Optional(Type.Number({ description: "Halt the run once accumulated cost exceeds this many USD" })),
46
+ maxTokens: Type.Optional(
47
+ Type.Number({ description: "Halt the run once accumulated input+output tokens exceed this" }),
48
+ ),
49
+ },
50
+ { additionalProperties: false },
51
+ );
52
+
28
53
  const PhaseSchema = Type.Object(
29
54
  {
30
55
  id: Type.String({ description: "Unique phase identifier (referenced via {steps.<id>.output})" }),
@@ -46,7 +71,28 @@ const PhaseSchema = Type.Object(
46
71
  Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" }),
47
72
  ),
48
73
 
74
+ // sub-workflow (flow)
75
+ use: Type.Optional(Type.String({ description: "[flow] Name of a saved taskflow to run as this phase" })),
76
+ with: Type.Optional(
77
+ Type.Record(Type.String(), Type.Unknown(), {
78
+ description: "[flow] Args passed to the sub-flow (string values support interpolation)",
79
+ }),
80
+ ),
81
+
49
82
  dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Phase ids this phase depends on" })),
83
+ join: Type.Optional(
84
+ StringEnum(JOIN_MODES, {
85
+ description: "Dependency join: 'all' (default) waits for every dep; 'any' runs as soon as one dep completes",
86
+ default: "all",
87
+ }),
88
+ ),
89
+ when: Type.Optional(
90
+ Type.String({
91
+ description:
92
+ "Conditional guard: skip this phase unless the expression is truthy. Supports {refs} and == != < > <= >= && || ! ()",
93
+ }),
94
+ ),
95
+ retry: Type.Optional(RetrySchema),
50
96
  output: Type.Optional(StringEnum(OUTPUT_FORMATS, { description: "Parse output as text or json", default: "text" })),
51
97
  model: Type.Optional(Type.String({ description: "Model override for this phase" })),
52
98
  thinking: Type.Optional(Type.String({ description: "Thinking level override for this phase" })),
@@ -57,6 +103,18 @@ const PhaseSchema = Type.Object(
57
103
  Type.Boolean({ description: "If true, a failure does not abort the run", default: false }),
58
104
  ),
59
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
+ ),
60
118
  },
61
119
  { additionalProperties: false },
62
120
  );
@@ -77,9 +135,17 @@ export const TaskflowSchema = Type.Object(
77
135
  version: Type.Optional(Type.Number({ default: 1 })),
78
136
  args: Type.Optional(Type.Record(Type.String(), ArgSpecSchema, { description: "Declared invocation arguments" })),
79
137
  concurrency: Type.Optional(Type.Number({ description: "Default max concurrent subagents", default: 8 })),
138
+ budget: Type.Optional(BudgetSchema),
80
139
  agentScope: Type.Optional(
81
140
  StringEnum(["user", "project", "both"] as const, { description: "Agent discovery scope", default: "user" }),
82
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
+ ),
83
149
  phases: Type.Array(PhaseSchema, { minItems: 1, description: "Ordered phase definitions (DAG via dependsOn)" }),
84
150
  },
85
151
  { additionalProperties: false },
@@ -89,6 +155,9 @@ export type ParallelTask = Static<typeof ParallelTaskSchema>;
89
155
  export type Phase = Static<typeof PhaseSchema>;
90
156
  export type Taskflow = Static<typeof TaskflowSchema>;
91
157
  export type ArgSpec = Static<typeof ArgSpecSchema>;
158
+ export type RetryPolicy = Static<typeof RetrySchema>;
159
+ export type Budget = Static<typeof BudgetSchema>;
160
+ export type JoinMode = (typeof JOIN_MODES)[number];
92
161
 
93
162
  // ---------------------------------------------------------------------------
94
163
  // Shorthand (non-DAG) specs — subagent-style ergonomics
@@ -141,6 +210,8 @@ export function desugar(def: unknown): Taskflow {
141
210
  if (typeof d.concurrency === "number") meta.concurrency = d.concurrency;
142
211
  if (d.agentScope === "user" || d.agentScope === "project" || d.agentScope === "both") meta.agentScope = d.agentScope;
143
212
  if (d.args && typeof d.args === "object") meta.args = d.args as Taskflow["args"];
213
+ if (d.budget) meta.budget = d.budget;
214
+ if (typeof d.strictInterpolation === "boolean") meta.strictInterpolation = d.strictInterpolation;
144
215
  const nameOf = (fallback: string) => (typeof d.name === "string" && d.name.trim() ? d.name.trim() : fallback);
145
216
 
146
217
  // chain → sequential agent phases
@@ -179,20 +250,35 @@ export function desugar(def: unknown): Taskflow {
179
250
  export interface ValidationResult {
180
251
  ok: boolean;
181
252
  errors: string[];
253
+ /** Non-fatal issues the user should fix; e.g. `{steps.X}` references that
254
+ * aren't declared in `dependsOn` (the phase will run in parallel with its
255
+ * producer and see the literal placeholder). */
256
+ warnings: string[];
182
257
  }
183
258
 
184
- export function validateTaskflow(def: unknown): ValidationResult {
259
+ export interface ValidationOptions {
260
+ /** Resolved invocation args, used for runtime checks like missing `{args.X}`. */
261
+ args?: Record<string, unknown>;
262
+ /** Runtime working directory, used for mismatch warnings (e.g. cwd vs args.codebase). */
263
+ cwd?: string;
264
+ /** Override the flow's own `strictInterpolation` flag for this validation call. */
265
+ strict?: boolean;
266
+ }
267
+
268
+ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): ValidationResult {
185
269
  const errors: string[] = [];
270
+ const warnings: string[] = [];
186
271
 
187
272
  if (typeof def !== "object" || def === null) {
188
- return { ok: false, errors: ["Taskflow must be an object"] };
273
+ return { ok: false, errors: ["Taskflow must be an object"], warnings };
189
274
  }
190
275
  const flow = def as Partial<Taskflow>;
276
+ const strict = opts.strict ?? flow.strictInterpolation === true;
191
277
 
192
278
  if (!flow.name || typeof flow.name !== "string") errors.push("Missing or invalid 'name'");
193
279
  if (!Array.isArray(flow.phases) || flow.phases.length === 0) {
194
280
  errors.push("Taskflow must have at least one phase");
195
- return { ok: false, errors };
281
+ return { ok: false, errors, warnings };
196
282
  }
197
283
 
198
284
  const ids = new Set<string>();
@@ -227,6 +313,25 @@ export function validateTaskflow(def: unknown): ValidationResult {
227
313
  if (!p.from || p.from.length === 0) errors.push(`Phase '${p.id}' (reduce) requires 'from'`);
228
314
  if (!p.task) errors.push(`Phase '${p.id}' (reduce) requires 'task'`);
229
315
  }
316
+ if (type === "flow") {
317
+ if (!p.use) errors.push(`Phase '${p.id}' (flow) requires 'use' (a saved flow name)`);
318
+ }
319
+ if (p.retry) {
320
+ if (typeof p.retry.max !== "number" || p.retry.max < 0) {
321
+ errors.push(`Phase '${p.id}': retry.max must be a number >= 0`);
322
+ } else if (p.retry.max > 20) {
323
+ errors.push(`Phase '${p.id}': retry.max must be <= 20`);
324
+ }
325
+ if (p.retry.backoffMs !== undefined && (p.retry.backoffMs < 0 || p.retry.backoffMs > 60000)) {
326
+ errors.push(`Phase '${p.id}': retry.backoffMs must be between 0 and 60000`);
327
+ }
328
+ if (p.retry.factor !== undefined && (p.retry.factor < 1 || p.retry.factor > 10)) {
329
+ errors.push(`Phase '${p.id}': retry.factor must be between 1 and 10`);
330
+ }
331
+ }
332
+ if (p.join && !JOIN_MODES.includes(p.join as JoinMode)) {
333
+ errors.push(`Phase '${p.id}': unknown join mode '${p.join}'`);
334
+ }
230
335
  }
231
336
 
232
337
  // dependsOn / from references must exist
@@ -247,10 +352,102 @@ export function validateTaskflow(def: unknown): ValidationResult {
247
352
  }
248
353
 
249
354
  // Exactly handle final-phase resolution lazily (0 finals => last phase is final)
250
- const finals = (flow.phases as Phase[]).filter((p) => p.final);
355
+ const finals = (flow.phases as Phase[]).filter((p) => p?.final);
251
356
  if (finals.length > 1) errors.push(`Only one phase may be marked 'final' (found ${finals.length})`);
252
357
 
253
- return { ok: errors.length === 0, errors };
358
+ // --- Soft warnings: {steps.X.*} references that aren't declared deps -------
359
+ // Catches the most common authoring mistake: the task talks about
360
+ // `{steps.review.output}` but `dependsOn: ["review"]` is missing, so the
361
+ // phase runs in parallel with `review` and the model sees the literal
362
+ // placeholder string. The runtime can't infer the intent.
363
+ if (errors.length === 0) {
364
+ const idToPhase = new Map((flow.phases as Phase[]).map((p) => [p.id, p]));
365
+ for (const p of flow.phases as Phase[]) {
366
+ if (!p?.id) continue;
367
+ const deps = new Set(dependenciesOf(p));
368
+ const refs = collectRefs(p);
369
+ for (const ref of refs.steps) {
370
+ if (ref === p.id) {
371
+ warnings.push(`Phase '${p.id}': references its own output via {steps.${ref}.*}; this is almost always a bug.`);
372
+ continue;
373
+ }
374
+ if (!idToPhase.has(ref)) {
375
+ // Unknown ref is already an error from the dependsOn check, but
376
+ // {steps.X.*} can appear in a task without dependsOn. Don't
377
+ // double-warn — the dependsOn loop above already flags it.
378
+ continue;
379
+ }
380
+ if (!deps.has(ref)) {
381
+ warnings.push(
382
+ `Phase '${p.id}': task references {steps.${ref}.*} but '${ref}' is not in dependsOn. ` +
383
+ `The phase will run in parallel with '${ref}' and see the literal placeholder. ` +
384
+ `Add "dependsOn": ["${ref}"] (or include '${ref}' transitively).`,
385
+ );
386
+ }
387
+ }
388
+ }
389
+ }
390
+
391
+ // --- Runtime/invocation warnings: missing args + cwd/codebase mismatch -----
392
+ if (errors.length === 0 && opts.args) {
393
+ const argRefs = new Set<string>();
394
+ for (const p of flow.phases as Phase[]) {
395
+ if (!p?.id) continue;
396
+ for (const ref of collectRefs(p).args) argRefs.add(ref);
397
+ }
398
+ for (const ref of argRefs) {
399
+ if (!(ref in opts.args)) {
400
+ warnings.push(
401
+ `Taskflow references {args.${ref}} but the invocation did not provide '${ref}'. ` +
402
+ `The placeholder will remain literal unless a default or runtime arg is supplied.`,
403
+ );
404
+ }
405
+ }
406
+ if (opts.cwd && typeof opts.args.codebase === "string" && opts.args.codebase.trim()) {
407
+ const cwd = path.resolve(opts.cwd);
408
+ const codebase = path.resolve(cwd, opts.args.codebase);
409
+ // Safe case: cwd is the codebase root or a subdirectory within it.
410
+ // Warn when cwd is a sibling, unrelated path, or a parent of the
411
+ // codebase (agents that rely on cwd would inspect too broad a tree).
412
+ if (!pathContains(codebase, cwd)) {
413
+ warnings.push(
414
+ `Invocation cwd '${cwd}' does not match args.codebase '${codebase}'. ` +
415
+ `Some agents may inspect the wrong repo if they rely on cwd. Prefer running from the codebase root or set phase.cwd explicitly.`,
416
+ );
417
+ }
418
+ }
419
+ }
420
+
421
+ if (strict && warnings.length) {
422
+ errors.push(...warnings.map((w) => `Strict interpolation: ${w}`));
423
+ }
424
+
425
+ return { ok: errors.length === 0, errors, warnings };
426
+ }
427
+
428
+ function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
429
+ const steps = new Set<string>();
430
+ const args = new Set<string>();
431
+ const scan = (s: string | undefined) => {
432
+ if (!s) return;
433
+ let m: RegExpExecArray | null;
434
+ const stepRe = /\{steps\.([a-zA-Z0-9_-]+)/g;
435
+ while ((m = stepRe.exec(s)) !== null) steps.add(m[1]);
436
+ const argRe = /\{args\.([a-zA-Z0-9_-]+)/g;
437
+ while ((m = argRe.exec(s)) !== null) args.add(m[1]);
438
+ };
439
+ scan(phase.task);
440
+ scan(phase.over);
441
+ scan(phase.when);
442
+ for (const b of phase.branches ?? []) scan(b.task);
443
+ for (const v of Object.values(phase.with ?? {})) if (typeof v === "string") scan(v);
444
+ for (const c of phase.context ?? []) scan(c);
445
+ return { steps: Array.from(steps), args: Array.from(args) };
446
+ }
447
+
448
+ function pathContains(parent: string, child: string): boolean {
449
+ const rel = path.relative(parent, child);
450
+ return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
254
451
  }
255
452
 
256
453
  /** Returns a cycle path if the DAG has one, else null. */
@@ -341,3 +538,18 @@ export function topoLayers(phases: Phase[]): Phase[][] {
341
538
  export function finalPhase(phases: Phase[]): Phase {
342
539
  return phases.find((p) => p.final) ?? phases[phases.length - 1];
343
540
  }
541
+
542
+ /**
543
+ * Apply a flow's declared arg defaults over the provided values, then pass
544
+ * through any extra provided keys. Shared by the tool entrypoint (index) and the
545
+ * sub-flow (`flow`) phase (runtime).
546
+ */
547
+ export function resolveArgs(def: Taskflow, provided?: Record<string, unknown>): Record<string, unknown> {
548
+ const args: Record<string, unknown> = {};
549
+ for (const [key, spec] of Object.entries(def.args ?? {})) {
550
+ if (provided && key in provided) args[key] = provided[key];
551
+ else if (spec.default !== undefined) args[key] = spec.default;
552
+ }
553
+ if (provided) for (const [k, v] of Object.entries(provided)) if (!(k in args)) args[k] = v;
554
+ return args;
555
+ }
@@ -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 "./runner.ts";
14
+ import type { UsageStats } from "./usage.ts";
15
15
 
16
16
  export interface SavedFlow {
17
17
  name: string;
@@ -39,6 +39,18 @@ 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 };
48
+ /** Non-fatal diagnostic warnings accumulated during this phase (e.g.
49
+ * unresolved interpolation placeholders, suspicious templates). */
50
+ warnings?: string[];
51
+ /** Truncated previews of interpolated strings used to execute this phase,
52
+ * useful when diagnosing why a model saw a literal placeholder. */
53
+ interpolation?: Array<{ source: string; text: string; missing?: string[] }>;
42
54
  }
43
55
 
44
56
  export interface RunState {
@@ -118,7 +130,7 @@ export function saveFlow(
118
130
  fs.mkdirSync(dir, { recursive: true });
119
131
  const safe = def.name.replace(/[^\w.-]+/g, "_");
120
132
  const filePath = path.join(dir, `${safe}.json`);
121
- fs.writeFileSync(filePath, `${JSON.stringify(def, null, 2)}\n`, "utf-8");
133
+ writeFileAtomic(filePath, `${JSON.stringify(def, null, 2)}\n`);
122
134
  return { filePath };
123
135
  }
124
136
 
@@ -138,12 +150,52 @@ export function saveRun(state: RunState): void {
138
150
  const dir = runsDir(state.cwd);
139
151
  fs.mkdirSync(dir, { recursive: true });
140
152
  state.updatedAt = Date.now();
141
- fs.writeFileSync(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2), "utf-8");
153
+ writeFileAtomic(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2));
142
154
  }
143
155
 
144
156
  export function loadRun(cwd: string, runId: string): RunState | null {
157
+ const dir = runsDir(cwd);
158
+
159
+ // Reject runIds that could be used for path traversal or filesystem abuse.
160
+ // Legitimate runIds are produced by newRunId() and contain only
161
+ // [A-Za-z0-9._-]; anything else (empty string, path separators, NUL bytes,
162
+ // backslashes on POSIX, forward slashes on Windows) is suspicious.
163
+ if (
164
+ typeof runId !== "string" ||
165
+ runId.length === 0 ||
166
+ runId.includes("/") ||
167
+ runId.includes("\\") ||
168
+ runId.includes("\0")
169
+ ) {
170
+ return null;
171
+ }
172
+
173
+ const filePath = path.resolve(dir, `${runId}.json`);
174
+ // Reject runIds that would escape the runs directory (e.g. "../etc/passwd").
175
+ // Compare with a path-separator suffix so legitimate filenames like "..foo"
176
+ // (a name that just happens to start with two dots) are not false-positives.
177
+ const rel = path.relative(dir, filePath);
178
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
179
+
180
+ // Resolve symlinks on both the runs dir and the file, so the containment
181
+ // check below is on a consistent physical path. Without normalizing `dir`,
182
+ // a legitimate run on macOS (where /var → /private/var) would compare a
183
+ // symlinked dir prefix to a real path and falsely flag traversal. A
184
+ // malicious file already placed inside the runs dir could otherwise also
185
+ // point at an arbitrary path on disk and bypass the lexical check above.
186
+ let realDir: string;
187
+ let realFilePath: string;
188
+ try {
189
+ realDir = fs.realpathSync(dir);
190
+ realFilePath = fs.realpathSync(filePath);
191
+ } catch {
192
+ return null;
193
+ }
194
+ const realRel = path.relative(realDir, realFilePath);
195
+ if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) return null;
196
+
145
197
  try {
146
- const raw = fs.readFileSync(path.join(runsDir(cwd), `${runId}.json`), "utf-8");
198
+ const raw = fs.readFileSync(realFilePath, "utf-8");
147
199
  return JSON.parse(raw) as RunState;
148
200
  } catch {
149
201
  return null;
@@ -174,3 +226,23 @@ export function listRuns(cwd: string, limit = 20): RunState[] {
174
226
  export function hashInput(...parts: string[]): string {
175
227
  return crypto.createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
176
228
  }
229
+
230
+ /**
231
+ * Write a file atomically: write to a unique temp file in the same directory,
232
+ * then rename over the target (rename is atomic on the same filesystem). Prevents
233
+ * a crash or concurrent write from leaving a half-written, corrupt JSON file.
234
+ */
235
+ function writeFileAtomic(filePath: string, data: string): void {
236
+ const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
237
+ try {
238
+ fs.writeFileSync(tmp, data, "utf-8");
239
+ fs.renameSync(tmp, filePath);
240
+ } catch (e) {
241
+ try {
242
+ if (fs.existsSync(tmp)) fs.unlinkSync(tmp);
243
+ } catch {
244
+ /* ignore cleanup failure */
245
+ }
246
+ throw e;
247
+ }
248
+ }
@@ -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.5",
3
+ "version": "0.0.7",
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": {
@@ -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
 
@@ -123,6 +188,85 @@ Review the audit results below. If any endpoint is missing auth, end with
123
188
  3. Reference upstream results explicitly with `{steps.ID...}` and set `dependsOn`.
124
189
  4. Mark the result-bearing phase with `"final": true` (else the last phase wins).
125
190
 
191
+ ## Common mistakes (the runtime will warn you, but don't trip them)
192
+
193
+ The runtime validates your flow at startup and at each phase's interpolation.
194
+ Two patterns account for ~all the broken runs in the wild — avoid them. If you
195
+ want warnings like these to become hard failures, set `"strictInterpolation": true`
196
+ on the flow.
197
+
198
+ ### 1. Referencing `{steps.X}` without `dependsOn: ["X"]`
199
+
200
+ ```jsonc
201
+ // ❌ WRONG — 'fix-issues' will run in parallel with 'code-review-1' and see the
202
+ // literal string "{steps.code-review-1.output}" instead of the review text.
203
+ {
204
+ "id": "code-review-1", "type": "agent", "task": "review code"
205
+ },
206
+ {
207
+ "id": "fix-issues", "type": "agent",
208
+ "task": "fix {steps.code-review-1.output}" // ← no dependsOn!
209
+ }
210
+ ```
211
+
212
+ The runtime logs a warning at run start (`Phase 'fix-issues': task references
213
+ {steps.code-review-1.*} but 'code-review-1' is not in dependsOn`) and the phase
214
+ itself gets a `warnings` field with a non-fatal `unresolved placeholders` line.
215
+ The TUI shows a `⚠N` badge. **Always declare the chain:**
216
+
217
+ ```jsonc
218
+ // ✅ RIGHT
219
+ {
220
+ "id": "code-review-1", "type": "agent", "task": "review code"
221
+ },
222
+ {
223
+ "id": "fix-issues", "type": "agent",
224
+ "task": "fix {steps.code-review-1.output}",
225
+ "dependsOn": ["code-review-1"] // ← declared
226
+ },
227
+ {
228
+ "id": "code-review-2", "type": "agent",
229
+ "task": "re-review {steps.fix-issues.output}",
230
+ "dependsOn": ["fix-issues"]
231
+ }
232
+ ```
233
+
234
+ Tip: write the `task` first (it tells you what each phase needs), then scan for
235
+ `{steps.*}` references and add the matching `dependsOn`. If a phase truly does
236
+ not depend on anything in its task, you can ignore the warning.
237
+
238
+ ### 2. Assuming the runtime knows "this is a chain"
239
+
240
+ Phase order in the `phases` array is **documentation, not execution order**.
241
+ The DAG comes from `dependsOn`. If you list `code-review-1`, `fix-issues`,
242
+ `code-review-2`, `fix-final` in that order with no `dependsOn`, the runtime
243
+ treats them as four independent phases and runs all of them in **layer 0** in
244
+ parallel. A phase that finishes first may not be the one you expected.
245
+
246
+ ```jsonc
247
+ // ❌ This is not a chain — it's 4 parallel phases, all racing.
248
+ "phases": [
249
+ { "id": "code-review-1", ... },
250
+ { "id": "fix-issues", ... },
251
+ { "id": "code-review-2", ... },
252
+ { "id": "fix-final", ... }
253
+ ]
254
+ ```
255
+
256
+ Use the shorthand if you literally just want `a → b → c → d`:
257
+
258
+ ```jsonc
259
+ { "chain": [
260
+ { "agent": "reviewer", "task": "review code" },
261
+ { "agent": "executor", "task": "fix {previous.output}" },
262
+ { "agent": "reviewer", "task": "re-review" },
263
+ { "agent": "executor", "task": "apply final fixes" }
264
+ ] }
265
+ ```
266
+
267
+ …or write the full DAG with explicit `dependsOn` (so reviewers/fixers can run
268
+ in parallel against multiple review streams when you want that).
269
+
126
270
  ## Configuration
127
271
 
128
272
  For the full set of knobs — per-phase `model`/`thinking`/`tools`/`cwd`, the
@@ -132,8 +276,8 @@ variables, and storage paths — read `configuration.md` (next to this file).
132
276
 
133
277
  Quick reference:
134
278
 
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`.
279
+ - **Flow:** `name`, `description`, `concurrency` (default 8), `budget` (`maxUSD`/`maxTokens`), `agentScope` (user|project|both), `args`, `strictInterpolation`.
280
+ - **Phase:** `model`, `thinking`, `tools` (whitelist), `cwd`, `output:"json"`, `concurrency` (map/parallel fan-out), `when`, `join` (all|any), `retry`, `use`/`with` (flow), `final`.
137
281
  - **Precedence (model/thinking/tools):** phase value → `settings.subagents.agentOverrides[agent]` → agent frontmatter → global/default.
138
282
  - **Concurrency:** same-layer phases use `flow.concurrency`; a `map`/`parallel` phase uses `phase.concurrency ?? flow.concurrency ?? 8`.
139
283
 
@@ -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.