pi-taskflow 0.0.12 → 0.0.14

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.
@@ -13,11 +13,27 @@ import { Type, type Static } from "typebox";
13
13
  // Phase types
14
14
  // ---------------------------------------------------------------------------
15
15
 
16
- export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow"] as const;
16
+ export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow", "loop", "tournament"] as const;
17
17
  export type PhaseType = (typeof PHASE_TYPES)[number];
18
18
 
19
+ /** Loop iteration bounds. Authors may lower the max; the hard cap is a runaway guard. */
20
+ export const LOOP_DEFAULT_MAX_ITERATIONS = 10;
21
+ export const LOOP_HARD_MAX_ITERATIONS = 100;
22
+
23
+ /** Tournament competitor bounds. */
24
+ export const TOURNAMENT_DEFAULT_VARIANTS = 3;
25
+ export const TOURNAMENT_HARD_MAX_VARIANTS = 20;
26
+ export const TOURNAMENT_MODES = ["best", "aggregate"] as const;
27
+ export type TournamentMode = (typeof TOURNAMENT_MODES)[number];
28
+
19
29
  export const OUTPUT_FORMATS = ["text", "json"] as const;
20
30
  export const JOIN_MODES = ["all", "any"] as const;
31
+ export const CACHE_SCOPES = ["run-only", "cross-run", "off"] as const;
32
+ export type CacheScope = (typeof CACHE_SCOPES)[number];
33
+ /** Allowed fingerprint entry prefixes. `glob!:` = content-hash variant of `glob:`. */
34
+ export const CACHE_FINGERPRINT_PREFIXES = ["git:", "glob:", "glob!:", "file:", "env:"] as const;
35
+ /** Phase types that must NOT be cached across runs (a fresh result is required each run). */
36
+ export const CACHE_CROSS_RUN_BLOCKED_TYPES = ["gate", "approval", "loop", "tournament"] as const;
21
37
 
22
38
  const ParallelTaskSchema = Type.Object(
23
39
  {
@@ -39,6 +55,36 @@ const RetrySchema = Type.Object(
39
55
  { additionalProperties: false },
40
56
  );
41
57
 
58
+ /**
59
+ * Per-phase cache policy. Defaults to `run-only` which is exactly the historical
60
+ * behavior (within-run resume only). `cross-run` opts a phase into the persistent
61
+ * cross-run memoization store; see docs/rfc-cross-run-memoization.md.
62
+ */
63
+ const CacheSchema = Type.Object(
64
+ {
65
+ scope: Type.Optional(
66
+ StringEnum(CACHE_SCOPES, {
67
+ description:
68
+ "Cache reuse scope. 'run-only' (default) = within-run resume only (historical behavior); 'cross-run' = reuse identical-input results from any prior run; 'off' = never reuse (even within-run).",
69
+ default: "run-only",
70
+ }),
71
+ ),
72
+ ttl: Type.Optional(
73
+ Type.String({
74
+ description:
75
+ "Max cache age before a cross-run hit is treated as a miss, e.g. '30m', '6h', '7d'. Omit for no time bound.",
76
+ }),
77
+ ),
78
+ fingerprint: Type.Optional(
79
+ Type.Array(Type.String(), {
80
+ description:
81
+ "Extra freshness inputs folded into the cache key so 'the world changed' becomes a cache miss. Each entry: 'git:HEAD' | 'glob:<pattern>' | 'glob!:<pattern>' (content-hash) | 'file:<path>' | 'env:<NAME>'.",
82
+ }),
83
+ ),
84
+ },
85
+ { additionalProperties: false },
86
+ );
87
+
42
88
  /** Run-wide cost / token ceiling. Exceeding it halts the run (remaining phases skipped). */
43
89
  const BudgetSchema = Type.Object(
44
90
  {
@@ -79,6 +125,51 @@ const PhaseSchema = Type.Object(
79
125
  }),
80
126
  ),
81
127
 
128
+ // loop-until-done
129
+ until: Type.Optional(
130
+ Type.String({
131
+ description:
132
+ "[loop] Stop condition evaluated after each iteration. The iteration's output is exposed as {steps.<thisId>.output}/.json. Supports the same operators as `when`. The loop stops when this is truthy, on convergence, or at maxIterations. A parse error stops the loop (fail-safe).",
133
+ }),
134
+ ),
135
+ maxIterations: Type.Optional(
136
+ Type.Number({
137
+ description: `[loop] Hard cap on iterations (default ${LOOP_DEFAULT_MAX_ITERATIONS}, max ${LOOP_HARD_MAX_ITERATIONS}). The loop always terminates within this bound even if 'until' never becomes truthy.`,
138
+ default: LOOP_DEFAULT_MAX_ITERATIONS,
139
+ }),
140
+ ),
141
+ convergence: Type.Optional(
142
+ Type.Boolean({
143
+ description:
144
+ "[loop] When true (default), stop early if an iteration's output is identical to the previous one (a fixed point — further iterations would not change anything).",
145
+ default: true,
146
+ }),
147
+ ),
148
+
149
+ // tournament: N variants compete, a judge picks the best (or aggregates)
150
+ variants: Type.Optional(
151
+ Type.Number({
152
+ description: `[tournament] Number of competing variants to spawn from 'task' (default ${TOURNAMENT_DEFAULT_VARIANTS}, max ${TOURNAMENT_HARD_MAX_VARIANTS}). Ignored when 'branches' is provided (those become the variants instead).`,
153
+ default: TOURNAMENT_DEFAULT_VARIANTS,
154
+ }),
155
+ ),
156
+ judge: Type.Optional(
157
+ Type.String({
158
+ description:
159
+ "[tournament] Judge prompt. The numbered variant outputs are injected before it. To pick a winner, end with a line like 'WINNER: <n>' or return JSON {\"winner\": <n>}. Defaults to a sensible built-in rubric.",
160
+ }),
161
+ ),
162
+ judgeAgent: Type.Optional(
163
+ Type.String({ description: "[tournament] Agent that runs the judge step (defaults to the phase 'agent')." }),
164
+ ),
165
+ mode: Type.Optional(
166
+ StringEnum(TOURNAMENT_MODES, {
167
+ description:
168
+ "[tournament] 'best' (default): output is the winning variant verbatim. 'aggregate': output is the judge's synthesized answer combining the variants.",
169
+ default: "best",
170
+ }),
171
+ ),
172
+
82
173
  dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Phase ids this phase depends on" })),
83
174
  join: Type.Optional(
84
175
  StringEnum(JOIN_MODES, {
@@ -115,6 +206,20 @@ const PhaseSchema = Type.Object(
115
206
  default: 8000,
116
207
  }),
117
208
  ),
209
+ onBlock: Type.Optional(
210
+ StringEnum(["halt", "retry"] as const, {
211
+ description:
212
+ "[gate] What to do when the gate blocks: 'halt' (default, stop the flow) or 'retry' (re-run upstream phases then re-evaluate the gate). Limited by 'retry.max'.",
213
+ default: "halt",
214
+ }),
215
+ ),
216
+ eval: Type.Optional(
217
+ Type.Array(Type.String(), {
218
+ description:
219
+ "[gate] Zero-token machine checks that run BEFORE the LLM gate. If ALL pass, the gate is skipped (PASS). If ANY fail, the LLM gate runs as normal. Each entry is a condition expression like '{steps.x.output} contains PASS' or '{steps.x.json.score} >= 0.8'. Supports same operators as 'when' plus 'contains' for substring checks.",
220
+ }),
221
+ ),
222
+ cache: Type.Optional(CacheSchema),
118
223
  },
119
224
  { additionalProperties: false },
120
225
  );
@@ -157,6 +262,7 @@ export type Taskflow = Static<typeof TaskflowSchema>;
157
262
  export type ArgSpec = Static<typeof ArgSpecSchema>;
158
263
  export type RetryPolicy = Static<typeof RetrySchema>;
159
264
  export type Budget = Static<typeof BudgetSchema>;
265
+ export type CachePolicy = Static<typeof CacheSchema>;
160
266
  export type JoinMode = (typeof JOIN_MODES)[number];
161
267
 
162
268
  // ---------------------------------------------------------------------------
@@ -260,6 +366,21 @@ export interface ValidationResult {
260
366
  warnings: string[];
261
367
  }
262
368
 
369
+ /**
370
+ * Parse a TTL string like '30m', '6h', '7d', '500ms', '90s' into milliseconds.
371
+ * Returns null for malformed or non-positive values. Plain integers = ms.
372
+ */
373
+ export function parseTtlMs(ttl: string): number | null {
374
+ if (typeof ttl !== "string") return null;
375
+ const m = ttl.trim().match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/i);
376
+ if (!m) return null;
377
+ const n = Number(m[1]);
378
+ if (!Number.isFinite(n) || n <= 0) return null;
379
+ const unit = (m[2] ?? "ms").toLowerCase();
380
+ const mult: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 };
381
+ return n * mult[unit];
382
+ }
383
+
263
384
  export interface ValidationOptions {
264
385
  /** Resolved invocation args, used for runtime checks like missing `{args.X}`. */
265
386
  args?: Record<string, unknown>;
@@ -320,6 +441,36 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
320
441
  if (type === "flow") {
321
442
  if (!p.use) errors.push(`Phase '${p.id}' (flow) requires 'use' (a saved flow name)`);
322
443
  }
444
+ if (type === "loop") {
445
+ if (!p.task) errors.push(`Phase '${p.id}' (loop) requires 'task' (the iteration body)`);
446
+ if (!p.until) errors.push(`Phase '${p.id}' (loop) requires 'until' (the stop condition)`);
447
+ if (p.maxIterations !== undefined) {
448
+ if (typeof p.maxIterations !== "number" || !Number.isFinite(p.maxIterations) || p.maxIterations < 1) {
449
+ errors.push(`Phase '${p.id}' (loop): maxIterations must be a number >= 1`);
450
+ } else if (p.maxIterations > LOOP_HARD_MAX_ITERATIONS) {
451
+ errors.push(`Phase '${p.id}' (loop): maxIterations must be <= ${LOOP_HARD_MAX_ITERATIONS}`);
452
+ }
453
+ }
454
+ }
455
+ if (type === "tournament") {
456
+ const hasBranches = Array.isArray(p.branches) && p.branches.length > 0;
457
+ if (!hasBranches && !p.task) {
458
+ errors.push(`Phase '${p.id}' (tournament) requires 'task' (the competitor prompt) or non-empty 'branches'`);
459
+ }
460
+ if (p.variants !== undefined) {
461
+ if (typeof p.variants !== "number" || !Number.isFinite(p.variants) || p.variants < 2) {
462
+ errors.push(`Phase '${p.id}' (tournament): variants must be a number >= 2`);
463
+ } else if (p.variants > TOURNAMENT_HARD_MAX_VARIANTS) {
464
+ errors.push(`Phase '${p.id}' (tournament): variants must be <= ${TOURNAMENT_HARD_MAX_VARIANTS}`);
465
+ }
466
+ }
467
+ if (hasBranches && p.branches!.length < 2) {
468
+ errors.push(`Phase '${p.id}' (tournament): 'branches' needs at least 2 competitors`);
469
+ }
470
+ if (p.mode && !TOURNAMENT_MODES.includes(p.mode as TournamentMode)) {
471
+ errors.push(`Phase '${p.id}' (tournament): unknown mode '${p.mode}'`);
472
+ }
473
+ }
323
474
  if (p.retry) {
324
475
  if (typeof p.retry.max !== "number" || p.retry.max < 0) {
325
476
  errors.push(`Phase '${p.id}': retry.max must be a number >= 0`);
@@ -337,6 +488,33 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
337
488
  errors.push(`Phase '${p.id}': unknown join mode '${p.join}'`);
338
489
  }
339
490
 
491
+ // Cache policy validation (cross-run memoization).
492
+ if (p.cache) {
493
+ const scope = p.cache.scope ?? "run-only";
494
+ if (!CACHE_SCOPES.includes(scope as CacheScope)) {
495
+ errors.push(`Phase '${p.id}': unknown cache.scope '${scope}' (expected one of ${CACHE_SCOPES.join(", ")})`);
496
+ }
497
+ // Gate B: gate/approval phases must produce a fresh result every run.
498
+ if (scope === "cross-run" && (CACHE_CROSS_RUN_BLOCKED_TYPES as readonly string[]).includes(type)) {
499
+ errors.push(
500
+ `Phase '${p.id}' (${type}): cache.scope 'cross-run' is not allowed for ${CACHE_CROSS_RUN_BLOCKED_TYPES.join("/")} phases — they must produce a fresh result each run. Use 'run-only'.`,
501
+ );
502
+ }
503
+ // Gate C: every fingerprint entry must use a known prefix (fail closed).
504
+ for (const fp of p.cache.fingerprint ?? []) {
505
+ const ok = CACHE_FINGERPRINT_PREFIXES.some((pre) => fp.startsWith(pre) && fp.length > pre.length);
506
+ if (!ok) {
507
+ errors.push(
508
+ `Phase '${p.id}': invalid cache.fingerprint entry '${fp}' (expected '<prefix><value>' with prefix one of ${CACHE_FINGERPRINT_PREFIXES.join(", ")})`,
509
+ );
510
+ }
511
+ }
512
+ // Gate D: TTL must parse to a positive duration when present.
513
+ if (p.cache.ttl !== undefined && parseTtlMs(p.cache.ttl) === null) {
514
+ errors.push(`Phase '${p.id}': invalid cache.ttl '${p.cache.ttl}' (expected e.g. '30m', '6h', '7d')`);
515
+ }
516
+ }
517
+
340
518
  // Agent name convention: hyphens only (per AGENTS.md naming convention)
341
519
  if (p.agent && typeof p.agent === "string" && p.agent.includes("_")) {
342
520
  errors.push(`Phase '${p.id}': agent name '${p.agent}' uses underscores — use hyphens (e.g. 'executor-code' not 'executor_code')`);
@@ -40,6 +40,10 @@ export interface PhaseState {
40
40
  model?: string;
41
41
  error?: string;
42
42
  inputHash?: string;
43
+ /** When this result was served from cache: 'cross-run' for the persistent
44
+ * cross-run store. (Within-run resume reuses prior state verbatim and is not
45
+ * flagged here.) */
46
+ cacheHit?: "cross-run";
43
47
  startedAt?: number;
44
48
  endedAt?: number;
45
49
  /** Live fan-out progress for map/parallel phases. */
@@ -54,6 +58,10 @@ export interface PhaseState {
54
58
  budgetTruncated?: boolean;
55
59
  /** Human-in-the-loop outcome (approval phases only). */
56
60
  approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean };
61
+ /** Loop iteration accounting (loop phases only). */
62
+ loop?: { iterations: number; stop: "until" | "converged" | "maxIterations" | "failed" };
63
+ /** Tournament outcome (tournament phases only). */
64
+ tournament?: { variants: number; winner: number; mode: "best" | "aggregate"; reason?: string };
57
65
  /** Non-fatal diagnostic warnings accumulated during this phase (e.g.
58
66
  * unresolved interpolation placeholders, suspicious templates). */
59
67
  warnings?: string[];
@@ -249,7 +257,7 @@ function releaseLock(lockPath: string): void {
249
257
  /**
250
258
  * Execute `fn` while holding a file lock. Guarantees release even on throw.
251
259
  */
252
- function withLock<T>(lockPath: string, fn: () => T): T {
260
+ export function withLock<T>(lockPath: string, fn: () => T): T {
253
261
  acquireLock(lockPath);
254
262
  try {
255
263
  return fn();
@@ -560,6 +568,12 @@ function runsDir(cwd: string): string {
560
568
  return path.join(projDir, "runs");
561
569
  }
562
570
 
571
+ /** Root dir for the cross-run memoization cache (sibling of `runs`). */
572
+ export function cacheDir(cwd: string): string {
573
+ const projDir = findProjectFlowsDir(cwd, true)!;
574
+ return path.join(projDir, "cache");
575
+ }
576
+
563
577
  export function newRunId(flowName: string): string {
564
578
  const safe = flowName.replace(/[^\w.-]+/g, "_").slice(0, 24);
565
579
  return `${safe}-${Date.now().toString(36)}-${crypto.randomBytes(3).toString("hex")}`;
@@ -743,7 +757,7 @@ export function hashInput(...parts: string[]): string {
743
757
  * then rename over the target (rename is atomic on the same filesystem). Prevents
744
758
  * a crash or concurrent write from leaving a half-written, corrupt JSON file.
745
759
  */
746
- function writeFileAtomic(filePath: string, data: string): void {
760
+ export function writeFileAtomic(filePath: string, data: string): void {
747
761
  // Ensure parent directory exists.
748
762
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
749
763
  const tmp = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString("hex")}.tmp`;
@@ -0,0 +1,367 @@
1
+ /**
2
+ * Static DAG verification — zero-token structural analysis.
3
+ *
4
+ * Runs *before* any agent is spawned. Catches dead-end phases, unreachable
5
+ * paths, gate exhaustion, budget overflow, and reference integrity issues
6
+ * purely through graph algorithms on the DAG — no LLM required.
7
+ */
8
+
9
+ import type { Phase } from "./schema.ts";
10
+ import { LOOP_DEFAULT_MAX_ITERATIONS } from "./schema.ts";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types
14
+ // ---------------------------------------------------------------------------
15
+
16
+ export type IssueCategory =
17
+ | "dead-end"
18
+ | "unreachable"
19
+ | "gate-exhaustion"
20
+ | "budget-overflow"
21
+ | "concurrency"
22
+ | "ref-integrity"
23
+ | "guard-contradiction";
24
+
25
+ export interface VerificationIssue {
26
+ /** Affected phase id, if applicable. */
27
+ phaseId?: string;
28
+ message: string;
29
+ severity: "error" | "warning";
30
+ category: IssueCategory;
31
+ }
32
+
33
+ export interface VerificationResult {
34
+ ok: boolean;
35
+ issues: VerificationIssue[];
36
+ }
37
+
38
+ /** A lightweight Taskflow shape for verification (accepts parsed Phase[] + name). */
39
+ export interface VerifiableFlow {
40
+ name: string;
41
+ phases: Phase[];
42
+ budget?: { maxUSD?: number; maxTokens?: number };
43
+ concurrency?: number;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Graph helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function successors(phases: Phase[]): Map<string, string[]> {
51
+ const m = new Map<string, string[]>();
52
+ for (const p of phases) m.set(p.id, []);
53
+ for (const p of phases) {
54
+ for (const d of p.dependsOn ?? []) {
55
+ const s = m.get(d);
56
+ if (s) s.push(p.id);
57
+ }
58
+ }
59
+ return m;
60
+ }
61
+
62
+ function descendants(phaseId: string, succ: Map<string, string[]>): Set<string> {
63
+ const visited = new Set<string>();
64
+ const queue = [phaseId];
65
+ while (queue.length) {
66
+ const id = queue.shift()!;
67
+ if (visited.has(id)) continue;
68
+ visited.add(id);
69
+ for (const s of succ.get(id) ?? []) queue.push(s);
70
+ }
71
+ return visited;
72
+ }
73
+
74
+ /** Phases with NO `dependsOn` — the DAG entry points. */
75
+ function entryPhases(phases: Phase[]): Phase[] {
76
+ return phases.filter((p) => !p.dependsOn || p.dependsOn.length === 0);
77
+ }
78
+
79
+ /** Phases with NO dependents (no one waits for them). */
80
+ function terminalPhases(phases: Phase[], succ: Map<string, string[]>): string[] {
81
+ const hasDependents = new Set<string>();
82
+ for (const p of phases) {
83
+ for (const d of p.dependsOn ?? []) hasDependents.add(d);
84
+ }
85
+ return phases.filter((p) => !hasDependents.has(p.id)).map((p) => p.id);
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Analyzers
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /** #1 Dead-end: a phase with no dependents that is neither `final` nor the last phase. */
93
+ function detectDeadEnds(phases: Phase[], succ: Map<string, string[]>): VerificationIssue[] {
94
+ const issues: VerificationIssue[] = [];
95
+ const terminal = new Set(terminalPhases(phases, succ));
96
+ const hasFinal = phases.some((p) => p.final);
97
+ const lastId = phases[phases.length - 1]?.id;
98
+
99
+ for (const p of phases) {
100
+ if (!terminal.has(p.id)) continue;
101
+ if (p.final) continue;
102
+ if (!hasFinal && p.id === lastId) continue;
103
+
104
+ issues.push({
105
+ phaseId: p.id,
106
+ message:
107
+ `Phase '${p.id}' is a terminal phase (no dependents) but not marked as 'final'. ` +
108
+ `Its output will be discarded. Add "final": true or a downstream phase that depends on it.`,
109
+ severity: "warning",
110
+ category: "dead-end",
111
+ });
112
+ }
113
+ return issues;
114
+ }
115
+
116
+ /** #2 Unreachable: phases not in the largest connected component. */
117
+ function detectUnreachable(phases: Phase[], succ: Map<string, string[]>): VerificationIssue[] {
118
+ const issues: VerificationIssue[] = [];
119
+
120
+ // Build undirected adjacency (dependsOn edges are bidirectional for
121
+ // connectivity analysis).
122
+ const adj = new Map<string, Set<string>>();
123
+ for (const p of phases) adj.set(p.id, new Set());
124
+ for (const p of phases) {
125
+ for (const d of p.dependsOn ?? []) {
126
+ if (!adj.has(d)) continue; // ref to non-existent phase (schema catches)
127
+ adj.get(p.id)!.add(d);
128
+ adj.get(d)!.add(p.id);
129
+ }
130
+ }
131
+
132
+ // Find connected components via BFS.
133
+ const visited = new Set<string>();
134
+ const components: Set<string>[] = [];
135
+ for (const p of phases) {
136
+ if (visited.has(p.id)) continue;
137
+ const comp = new Set<string>();
138
+ const queue = [p.id];
139
+ while (queue.length) {
140
+ const id = queue.shift()!;
141
+ if (visited.has(id)) continue;
142
+ visited.add(id);
143
+ comp.add(id);
144
+ for (const nb of adj.get(id) ?? []) {
145
+ if (!visited.has(nb)) queue.push(nb);
146
+ }
147
+ }
148
+ components.push(comp);
149
+ }
150
+
151
+ if (components.length <= 1) return issues;
152
+
153
+ // The largest component is the main DAG; flag the rest — but only if they
154
+ // have edges (dependsOn or successors). A standalone phase with no edges is
155
+ // a valid independent entry, not unreachable.
156
+ const succMap2 = successors(phases);
157
+ const largest = components.reduce((a, b) => (a.size >= b.size ? a : b));
158
+ for (const comp of components) {
159
+ if (comp === largest) continue;
160
+ for (const id of comp) {
161
+ const p = phases.find((ph) => ph.id === id);
162
+ const hasEdges = (p && (p.dependsOn?.length || 0) > 0) || (succMap2.get(id)?.length || 0) > 0;
163
+ if (!hasEdges) continue; // standalone entry — valid
164
+ issues.push({
165
+ phaseId: id,
166
+ message:
167
+ `Phase '${id}' is disconnected from the main DAG. ` +
168
+ `Add a 'dependsOn' edge to connect it, or remove it.`,
169
+ severity: "error",
170
+ category: "unreachable",
171
+ });
172
+ }
173
+ }
174
+ return issues;
175
+ }
176
+
177
+ /** True if there exists a path from `src` to `dst` that does NOT pass through `avoidId`. */
178
+ function hasBypassPath(
179
+ src: string,
180
+ dst: string,
181
+ avoidId: string,
182
+ succ: Map<string, string[]>,
183
+ visited: Set<string>,
184
+ ): boolean {
185
+ if (src === dst) return true;
186
+ if (visited.has(src)) return false;
187
+ visited.add(src);
188
+ for (const s of succ.get(src) ?? []) {
189
+ if (s === avoidId) continue;
190
+ if (hasBypassPath(s, dst, avoidId, succ, visited)) return true;
191
+ }
192
+ return false;
193
+ }
194
+
195
+ /** #3 Gate exhaustion: detect gates that are the sole path to a final phase. */
196
+ function detectGateExhaustion(phases: Phase[], succ: Map<string, string[]>): VerificationIssue[] {
197
+ const issues: VerificationIssue[] = [];
198
+ const gates = phases.filter((p) => p.type === "gate" || p.type === "approval");
199
+ const fp = phases.filter((p) => p.final);
200
+
201
+ for (const g of gates) {
202
+ const desc = descendants(g.id, succ);
203
+ const finalsDownstream = fp.filter((p) => desc.has(p.id));
204
+ if (finalsDownstream.length === 0) continue;
205
+
206
+ // Check: is there at least ONE path from an entry to each final
207
+ // that BYPASSES this gate?
208
+ let allBypassable = true;
209
+ for (const f of finalsDownstream) {
210
+ const bypassable = entryPhases(phases).some((entry) => {
211
+ const entryDesc = descendants(entry.id, succ);
212
+ if (!entryDesc.has(f.id)) return false;
213
+ return hasBypassPath(entry.id, f.id, g.id, succ, new Set());
214
+ });
215
+ if (!bypassable) {
216
+ allBypassable = false;
217
+ break;
218
+ }
219
+ }
220
+
221
+ if (!allBypassable) {
222
+ issues.push({
223
+ phaseId: g.id,
224
+ message:
225
+ `Gate '${g.id}' is the sole path to final phase(s) ` +
226
+ `${finalsDownstream.map((p) => "'" + p.id + "'").join(", ")}. ` +
227
+ `A block here halts the entire flow with no alternative route. ` +
228
+ `Consider adding a bypass or marking the flow's structure as intentional.`,
229
+ severity: "warning",
230
+ category: "gate-exhaustion",
231
+ });
232
+ }
233
+ }
234
+ return issues;
235
+ }
236
+
237
+ /** #4 Budget overflow: minimum possible cost exceeds budget. */
238
+ function detectBudgetOverflow(flow: VerifiableFlow): VerificationIssue[] {
239
+ const issues: VerificationIssue[] = [];
240
+ const budget = flow.budget;
241
+ if (!budget) return issues;
242
+
243
+ let minTokens = 0;
244
+ for (const p of flow.phases) {
245
+ if (p.type === "loop") {
246
+ const iters = p.maxIterations ?? LOOP_DEFAULT_MAX_ITERATIONS;
247
+ minTokens += Math.min(iters, 10);
248
+ } else if (p.type === "tournament") {
249
+ const variants = p.variants ?? 3;
250
+ minTokens += Math.min(variants + 1, 10);
251
+ } else {
252
+ minTokens += 1;
253
+ }
254
+ }
255
+
256
+ if (budget.maxTokens !== undefined && budget.maxTokens > 0 && minTokens > budget.maxTokens) {
257
+ issues.push({
258
+ message:
259
+ `Budget cap (${budget.maxTokens} tokens) is below the estimated minimum of ~${minTokens} tokens ` +
260
+ `for ${flow.phases.length} phase(s). The flow will likely be truncated before completion. ` +
261
+ `Increase maxTokens or reduce the number of phases.`,
262
+ severity: "warning",
263
+ category: "budget-overflow",
264
+ });
265
+ }
266
+
267
+ return issues;
268
+ }
269
+
270
+ /** #5 Concurrency warnings. */
271
+ function detectConcurrencyWarnings(flow: VerifiableFlow, _succ: Map<string, string[]>): VerificationIssue[] {
272
+ const issues: VerificationIssue[] = [];
273
+
274
+ for (const p of flow.phases) {
275
+ if (p.type === "parallel" && p.branches && p.branches.length > (flow.concurrency ?? 8)) {
276
+ if (!p.concurrency) {
277
+ issues.push({
278
+ phaseId: p.id,
279
+ message:
280
+ `Parallel phase '${p.id}' has ${p.branches.length} branches but the flow concurrency ` +
281
+ `is ${flow.concurrency ?? 8}. Consider adding a per-phase 'concurrency' cap.`,
282
+ severity: "warning",
283
+ category: "concurrency",
284
+ });
285
+ }
286
+ }
287
+ }
288
+
289
+ // Self-dependency
290
+ for (const p of flow.phases) {
291
+ if ((p.dependsOn ?? []).includes(p.id)) {
292
+ issues.push({
293
+ phaseId: p.id,
294
+ message: `Phase '${p.id}' depends on itself — remove self-reference from 'dependsOn'.`,
295
+ severity: "error",
296
+ category: "ref-integrity",
297
+ });
298
+ }
299
+ }
300
+
301
+ return issues;
302
+ }
303
+
304
+ /** #6 Guard contradictions (simple static analysis of `when` conditions). */
305
+ function detectGuardContradictions(phases: Phase[]): VerificationIssue[] {
306
+ const issues: VerificationIssue[] = [];
307
+
308
+ const groups = new Map<string, Phase[]>();
309
+ for (const p of phases) {
310
+ if (!p.when) continue;
311
+ const key = (p.dependsOn ?? []).sort().join(",");
312
+ if (!groups.has(key)) groups.set(key, []);
313
+ groups.get(key)!.push(p);
314
+ }
315
+
316
+ for (const [, group] of groups) {
317
+ if (group.length < 2) continue;
318
+ // Extract the ref keys from when conditions (to check same reference)
319
+ const refs = group.map((p) => {
320
+ const m = p.when!.match(/\{([^}]+)\}/g);
321
+ return m ? m.join(",") : "";
322
+ });
323
+ const uniqueRefs = new Set(refs.filter((r) => r.length > 0));
324
+ if (uniqueRefs.size === 1 && refs.every((r) => r.length > 0)) {
325
+ // Check the ORIGINAL when strings for opposing operators
326
+ const hasEq = group.some((p) => p.when!.includes("=="));
327
+ const hasNeq = group.some((p) => p.when!.includes("!="));
328
+ if (hasEq && hasNeq) {
329
+ issues.push({
330
+ message:
331
+ `Phases ${group.map((p) => `'${p.id}'`).join(", ")} have ` +
332
+ `the same dependency set and opposing 'when' conditions. ` +
333
+ `One branch will always be skipped. Verify this is intentional.`,
334
+ severity: "warning",
335
+ category: "guard-contradiction",
336
+ });
337
+ }
338
+ }
339
+ }
340
+ return issues;
341
+ }
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // Entry point
345
+ // ---------------------------------------------------------------------------
346
+
347
+ /**
348
+ * Run all static verification passes against a parsed taskflow.
349
+ *
350
+ * Returns issues found; `ok === true` means no errors (warnings are ok).
351
+ * This is a pure function — no I/O, no LLM, zero tokens.
352
+ */
353
+ export function verifyTaskflow(flow: VerifiableFlow): VerificationResult {
354
+ const phases = flow.phases;
355
+ const succ = successors(phases);
356
+ const issues: VerificationIssue[] = [];
357
+
358
+ issues.push(...detectDeadEnds(phases, succ));
359
+ issues.push(...detectUnreachable(phases, succ));
360
+ issues.push(...detectGateExhaustion(phases, succ));
361
+ issues.push(...detectBudgetOverflow(flow));
362
+ issues.push(...detectConcurrencyWarnings(flow, succ));
363
+ issues.push(...detectGuardContradictions(phases));
364
+
365
+ const ok = !issues.some((i) => i.severity === "error");
366
+ return { ok, issues };
367
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
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,8 +36,9 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "typecheck": "tsc --noEmit",
39
- "test": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= 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
- "test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
39
+ "test": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= 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/init.test.ts test/render.test.ts test/desugar.test.ts test/cache.test.ts test/loop.test.ts test/tournament.test.ts test/verify.test.ts test/gate-eval.test.ts",
40
+ "test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts",
41
+ "test:dogfood-cache": "node --experimental-strip-types test/dogfood-cache.mts"
41
42
  },
42
43
  "pi": {
43
44
  "extensions": [