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.
@@ -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.final);
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
+ }
@@ -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,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
- fs.writeFileSync(filePath, `${JSON.stringify(def, null, 2)}\n`, "utf-8");
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
- fs.writeFileSync(path.join(dir, `${state.runId}.json`), JSON.stringify(state, null, 2), "utf-8");
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.5",
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": {
@@ -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