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.
- package/DESIGN.md +15 -1
- package/README.md +115 -16
- package/examples/conditional-research.json +56 -0
- package/examples/guarded-refactor.json +50 -0
- package/extensions/agents.ts +8 -1
- package/extensions/index.ts +42 -18
- package/extensions/interpolate.ts +232 -1
- package/extensions/render.ts +47 -35
- package/extensions/runner.ts +127 -80
- package/extensions/runtime.ts +480 -54
- package/extensions/schema.ts +218 -6
- package/extensions/store.ts +76 -4
- package/extensions/usage.ts +42 -0
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +146 -2
- 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
|
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
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,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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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": {
|
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
|
|
|
@@ -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.
|