pi-taskflow 0.0.20 → 0.0.21
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/CHANGELOG.md +9 -0
- package/README.md +6 -6
- package/extensions/agents.ts +8 -1
- package/extensions/cache.ts +1 -0
- package/extensions/detached-runner.ts +2 -2
- package/extensions/index.ts +19 -1
- package/extensions/runner.ts +1 -1
- package/extensions/runtime.ts +22 -7
- package/extensions/schema.ts +82 -17
- package/extensions/store.ts +1 -0
- package/package.json +1 -1
- package/skills/taskflow/SKILL.md +25 -8
- package/skills/taskflow/configuration.md +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
|
|
4
4
|
|
|
5
|
+
## [0.0.21] — 2026-06-10
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Per-step context pre-read in shorthand modes.** Single, chain, and tasks shorthand steps now accept `context` (file paths) and `contextLimit`, desugared directly onto the generated phases. This eliminates `O(N²)` file exploration without writing the full DSL. In parallel `tasks` mode all branches share the deduped union of step contexts; chain steps each carry their own context. A top-level `context` in chain mode produces a warning (no unsupported flow-level default). Context-file changes automatically invalidate phase caches.
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **Headless approval safety.** Approval phases now auto-reject (not auto-approve) when running in detached/background/CI mode, preventing silent bypass of human gates.
|
|
12
|
+
- **Step-reference validator accepts transitive ancestors.** The step-reference checker previously raised false positives on valid DAGs where dependencies span multiple levels of ancestry. Ancestor transitive closure is now fully resolved.
|
|
13
|
+
|
|
5
14
|
## [0.0.20] — 2026-06-10
|
|
6
15
|
|
|
7
16
|
### Added
|
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-43D9AD?style=flat-square" alt="MIT license"></a>
|
|
9
9
|
<a href="#whats-inside"><img src="https://img.shields.io/badge/runtime%20deps-0-43D9AD?style=flat-square" alt="zero runtime dependencies"></a>
|
|
10
10
|
<a href="https://github.com/heggria/pi-taskflow/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/heggria/pi-taskflow/ci.yml?branch=main&style=flat-square&label=CI" alt="CI status"></a>
|
|
11
|
-
<a href="#whats-inside"><img src="https://img.shields.io/badge/tests-
|
|
11
|
+
<a href="#whats-inside"><img src="https://img.shields.io/badge/tests-601-6E8BFF?style=flat-square" alt="601 tests"></a>
|
|
12
12
|
<a href="#whats-inside"><img src="https://img.shields.io/badge/dogfooded-%E2%9C%93-43D9AD?style=flat-square" alt="dogfooded"></a>
|
|
13
13
|
<a href="https://pi.dev"><img src="https://img.shields.io/badge/for-Pi%20coding%20agent-B692FF?style=flat-square" alt="for the Pi coding agent"></a>
|
|
14
14
|
</p>
|
|
@@ -304,7 +304,7 @@ Flow-level keys: `name`, `description`, `args`, `concurrency` (default 8), `agen
|
|
|
304
304
|
- **`when`** — skip a phase unless an expression is truthy. Supports `{refs}`, `== != < > <= >=`, `&& || !`, parentheses, and quoted strings/numbers. Pair with `join: "any"` on the merge phase for real if/else routing. Parse errors **fail open**.
|
|
305
305
|
- **`join: "any"`** — an OR-join: the phase runs as soon as *one* dependency completes (default `"all"` waits for all).
|
|
306
306
|
- **`retry`** — `{ "max": 2, "backoffMs": 500, "factor": 2 }` retries a failing subagent with fixed or exponential backoff; usage is summed and the attempt count shows as `↻N` in the TUI. Transient provider errors (rate-limit / 5xx / timeout) **auto-retry even without an explicit policy**; hard errors don't.
|
|
307
|
-
- **`approval`** — pause for a human (Approve / Reject / Edit). Reject halts the flow; Edit injects the typed note as the phase output for downstream steps. Non-interactive runs auto-
|
|
307
|
+
- **`approval`** — pause for a human (Approve / Reject / Edit). Reject halts the flow; Edit injects the typed note as the phase output for downstream steps. Non-interactive runs auto-reject (safety: approval gates are never bypassed).
|
|
308
308
|
- **`flow`** — `{ "type": "flow", "use": "deep-research", "with": { "topic": "{item}" } }` runs a **saved** flow as a phase (recursion is detected and rejected). Or **generate the sub-flow at runtime**: `{ "type": "flow", "def": "{steps.plan.json}" }` resolves an upstream phase's JSON output into a sub-flow, **validates it (cycles / dangling refs / duplicate ids), then runs it** — the number and shape of the generated phases is decided at runtime, not authored in advance. A malformed plan fails *open* (the phase is skipped with a `defError`, the run continues). This is how a planner decides *at runtime* what work to spawn — the declarative answer to a code-mode `for` loop, with each generated plan checked before it spends a token. Pair it with `loop` for **data-dependent iterative replanning** (round N's plan depends on round N-1's result). See [`examples/dynamic-plan-execute.json`](./examples/dynamic-plan-execute.json) and [`examples/iterative-replan.json`](./examples/iterative-replan.json).
|
|
309
309
|
|
|
310
310
|
### Loop-until-done (`loop`)
|
|
@@ -434,7 +434,7 @@ Resume is keyed on each phase's input hash — if an upstream output changed, de
|
|
|
434
434
|
```
|
|
435
435
|
.pi/taskflows/<name>.json # project-scoped definitions (commit to share)
|
|
436
436
|
~/.pi/agent/taskflows/<name>.json # user-scoped definitions
|
|
437
|
-
.pi/taskflows/runs/<runId>.json
|
|
437
|
+
.pi/taskflows/runs/<flowName>/<runId>.json # run state for resume (gitignore this)
|
|
438
438
|
```
|
|
439
439
|
|
|
440
440
|
> Commit `.pi/taskflows/` and your whole team shares the pipelines — no config sync, no onboarding doc. Run state is written atomically and guarded by a zero-dependency file lock, so concurrent runs never corrupt the index.
|
|
@@ -608,12 +608,12 @@ Copy one into `.pi/taskflows/<name>.json` (or `~/.pi/agent/taskflows/`) and it r
|
|
|
608
608
|
|
|
609
609
|
<div align="center">
|
|
610
610
|
|
|
611
|
-
**0 runtime dependencies** · **
|
|
611
|
+
**0 runtime dependencies** · **601 tests** · **9 phase types** · **cross-session resume** · **cross-run memoization** · **~7.7k LOC runtime**
|
|
612
612
|
|
|
613
613
|
</div>
|
|
614
614
|
|
|
615
615
|
- **Zero runtime dependencies.** No `dependencies` field — the runtime is built entirely on Node built-ins (`fs` / `path` / `os` / `child_process` / `crypto`). The file lock is `fs.openSync("wx")`, not a third-party library.
|
|
616
|
-
- **
|
|
616
|
+
- **601 tests across 25 test files** covering concurrency, atomic file locking (8-process race regressions), path-traversal hardening, cross-session resume, cross-run cache freshness (flow/thinking/tools key isolation, fingerprint invalidation, TTL/LRU eviction), gate verdicts, budget caps, retry/backoff, approval flows, loop termination, tournament judging, sub-flow composition, callback isolation, the idle watchdog, model-role init config, and parseModelFromLabel with parenthesized-model-name regression.
|
|
617
617
|
- **Hardened by design.** Path-traversal defense (lexical + `realpath`), runId validation, HTML/error sanitization, atomic writes, stale-lock stealing via `rename`, and an idle watchdog that kills wedged subagents.
|
|
618
618
|
- **Dogfooded.** Every new feature has to survive the project's own `self-improve` taskflow before it ships.
|
|
619
619
|
|
|
@@ -637,7 +637,7 @@ Our `self-improve` flow is a 10-phase DAG — it audits the codebase, patches de
|
|
|
637
637
|
|
|
638
638
|
## Status & limits
|
|
639
639
|
|
|
640
|
-
**v0.0.
|
|
640
|
+
**v0.0.20** — loop-until-done (`loop` phase: iterate to a condition, convergence, or cap), tournament (best-of-N with a judge), cross-run memoization (content-addressed cache with git/file/glob/env fingerprints and TTL), interactive `/tf init` with role-aware model pickers + diff preview + atomic merge-write, configurable built-in agents, 18 built-in agents with 6 model roles. Full control-flow & reliability layer (`when` guards, `join: any`, `retry`/backoff, `approval`, `flow` composition, `budget` caps, idle watchdog) on top of the DSL + DAG runtime (`agent`/`parallel`/`map`/`gate`/`reduce`). Inline + saved flows, cross-session resume, live progress, and isolated context. A run executes as one streaming tool call.
|
|
641
641
|
|
|
642
642
|
Known boundaries (tracked, bounded — no surprises mid-flow):
|
|
643
643
|
|
package/extensions/agents.ts
CHANGED
|
@@ -74,6 +74,7 @@ export interface AgentConfig {
|
|
|
74
74
|
filePath: string;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
/** @internal */
|
|
77
78
|
export interface AgentDiscoveryResult {
|
|
78
79
|
agents: AgentConfig[];
|
|
79
80
|
projectAgentsDir: string | null;
|
|
@@ -224,7 +225,13 @@ export interface SubagentSettings {
|
|
|
224
225
|
* E.g. `{{fast}}` → `openrouter/deepseek/deepseek-v4-flash` if modelRoles.fast is set.
|
|
225
226
|
* Returns undefined if the value is not a role reference or the role is unmapped.
|
|
226
227
|
*/
|
|
227
|
-
|
|
228
|
+
/**
|
|
229
|
+
* Resolve `{{roleName}}` model references against a role→model mapping.
|
|
230
|
+
* E.g. `{{fast}}` → `openrouter/deepseek/deepseek-v4-flash` if modelRoles.fast is set.
|
|
231
|
+
* Returns undefined if the value is not a role reference or the role is unmapped.
|
|
232
|
+
* @internal
|
|
233
|
+
*/
|
|
234
|
+
function resolveModelRole(model: string | undefined, roles?: Record<string, string>): string | undefined {
|
|
228
235
|
if (!model || !roles) return model;
|
|
229
236
|
const match = model.match(/^\{\{(\w+)\}\}$/);
|
|
230
237
|
if (!match) return model;
|
package/extensions/cache.ts
CHANGED
|
@@ -135,6 +135,7 @@ export function resolveFingerprint(entries: string[] | undefined, cwd: string):
|
|
|
135
135
|
// Cross-run cache store
|
|
136
136
|
// ---------------------------------------------------------------------------
|
|
137
137
|
|
|
138
|
+
/** @internal */
|
|
138
139
|
export interface CacheEntry {
|
|
139
140
|
/** The full cache key (== phase inputHash incl. fingerprint). */
|
|
140
141
|
key: string;
|
|
@@ -56,8 +56,8 @@ try {
|
|
|
56
56
|
agents,
|
|
57
57
|
globalThinking: settings.globalThinking,
|
|
58
58
|
persist: (s) => saveRun(s, cleanupConfig),
|
|
59
|
-
// No requestApproval — approval phases auto-reject in detached mode
|
|
60
|
-
// (
|
|
59
|
+
// No requestApproval — approval phases auto-reject in detached/CI mode
|
|
60
|
+
// (safety: approval gates are never bypassed; the run records the rejection).
|
|
61
61
|
loadFlow: (name: string) => getFlow(ctx.cwd, name)?.def,
|
|
62
62
|
});
|
|
63
63
|
|
package/extensions/index.ts
CHANGED
|
@@ -59,6 +59,15 @@ const ShorthandStep = Type.Object(
|
|
|
59
59
|
{
|
|
60
60
|
agent: Type.Optional(Type.String({ description: "Agent for this step (defaults to the first available agent)" })),
|
|
61
61
|
task: Type.String({ description: "Task prompt for this step (supports {previous.output} in chains)" }),
|
|
62
|
+
context: Type.Optional(
|
|
63
|
+
Type.Array(Type.String(), {
|
|
64
|
+
description:
|
|
65
|
+
"File paths to pre-read and inject before this step's task (same as Phase.context). In parallel `tasks` mode all branches SHARE the union of step contexts.",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
68
|
+
contextLimit: Type.Optional(
|
|
69
|
+
Type.Number({ description: "Max characters to read per context file (default 8000)." }),
|
|
70
|
+
),
|
|
62
71
|
},
|
|
63
72
|
{ additionalProperties: false },
|
|
64
73
|
);
|
|
@@ -82,6 +91,15 @@ const TaskflowParams = Type.Object({
|
|
|
82
91
|
task: Type.Optional(
|
|
83
92
|
Type.String({ description: "Shorthand single mode: the task prompt (like subagent single mode)" }),
|
|
84
93
|
),
|
|
94
|
+
context: Type.Optional(
|
|
95
|
+
Type.Array(Type.String(), {
|
|
96
|
+
description:
|
|
97
|
+
"Shorthand single mode: file paths to pre-read and inject before the task (same as Phase.context).",
|
|
98
|
+
}),
|
|
99
|
+
),
|
|
100
|
+
contextLimit: Type.Optional(
|
|
101
|
+
Type.Number({ description: "Shorthand single mode: max characters to read per context file (default 8000)." }),
|
|
102
|
+
),
|
|
85
103
|
tasks: Type.Optional(
|
|
86
104
|
Type.Array(ShorthandStep, {
|
|
87
105
|
description: "Shorthand parallel mode: run these tasks concurrently and merge results (like subagent parallel)",
|
|
@@ -573,7 +591,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
573
591
|
: params.tasks
|
|
574
592
|
? { tasks: params.tasks, name: params.name }
|
|
575
593
|
: params.task
|
|
576
|
-
? { task: params.task, agent: params.agent, name: params.name }
|
|
594
|
+
? { task: params.task, agent: params.agent, name: params.name, context: params.context, contextLimit: params.contextLimit }
|
|
577
595
|
: undefined);
|
|
578
596
|
|
|
579
597
|
if (shorthandSpec !== undefined) {
|
package/extensions/runner.ts
CHANGED
|
@@ -69,7 +69,7 @@ export interface RunOptions {
|
|
|
69
69
|
* 5 minutes is generous enough for slow reasoning/long tool calls while still
|
|
70
70
|
* bounding a true hang.
|
|
71
71
|
*/
|
|
72
|
-
|
|
72
|
+
const DEFAULT_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
73
73
|
|
|
74
74
|
export function isFailed(r: RunResult): boolean {
|
|
75
75
|
return r.exitCode !== 0 || r.stopReason === "error" || r.stopReason === "aborted";
|
package/extensions/runtime.ts
CHANGED
|
@@ -47,7 +47,7 @@ export interface RuntimeDeps {
|
|
|
47
47
|
onProgress?: (state: RunState) => void;
|
|
48
48
|
/** Injectable task runner (defaults to spawning a real subagent). Enables testing. */
|
|
49
49
|
runTask?: typeof runAgentTask;
|
|
50
|
-
/** Resolve an `approval` phase. Omit for non-interactive runs (auto-
|
|
50
|
+
/** Resolve an `approval` phase. Omit for non-interactive runs (auto-reject). */
|
|
51
51
|
requestApproval?: (req: ApprovalRequest) => Promise<ApprovalDecision>;
|
|
52
52
|
/** Resolve a saved taskflow by name for `flow` (sub-workflow) phases. */
|
|
53
53
|
loadFlow?: (name: string) => Taskflow | undefined;
|
|
@@ -392,6 +392,7 @@ async function executePhase(
|
|
|
392
392
|
runId: state.runId,
|
|
393
393
|
thinking: phase.thinking,
|
|
394
394
|
tools: phase.tools,
|
|
395
|
+
preRead,
|
|
395
396
|
};
|
|
396
397
|
|
|
397
398
|
const baseRun = (agentName: string, task: string, onLive?: (l: LiveUpdate) => void) =>
|
|
@@ -700,13 +701,16 @@ async function executePhase(
|
|
|
700
701
|
const cached = cachedPhase(cc, inputHash);
|
|
701
702
|
if (cached) return cached;
|
|
702
703
|
|
|
703
|
-
// Non-interactive (headless/CI/
|
|
704
|
+
// Non-interactive (headless/CI/detached): auto-REJECT, fail-open, but record it.
|
|
705
|
+
// Approval gates are safety boundaries — bypassing them silently in CI would
|
|
706
|
+
// let unreviewed work ship. Detached/CI runs must not bypass approval gates.
|
|
704
707
|
if (!deps.requestApproval) {
|
|
705
708
|
return {
|
|
706
709
|
id: phase.id,
|
|
707
710
|
status: "done",
|
|
708
|
-
output: "(auto-
|
|
709
|
-
approval: { decision: "
|
|
711
|
+
output: "(auto-rejected: no interactive approver available)",
|
|
712
|
+
approval: { decision: "reject", auto: true },
|
|
713
|
+
gate: { verdict: "block", reason: "(auto-rejected: no interactive approver available)" },
|
|
710
714
|
usage: emptyUsage(),
|
|
711
715
|
inputHash,
|
|
712
716
|
endedAt: Date.now(),
|
|
@@ -1185,15 +1189,26 @@ interface PhaseCacheCtx {
|
|
|
1185
1189
|
* silently serve a stale cross-run hit). */
|
|
1186
1190
|
thinking?: string;
|
|
1187
1191
|
tools?: string[];
|
|
1192
|
+
/** Resolved `context` pre-read content. Explicitly part of the cache identity
|
|
1193
|
+
* so a context-file change always invalidates the phase — independent of
|
|
1194
|
+
* whether a given branch happens to fold preRead into its task string
|
|
1195
|
+
* (previously this was only incidentally true via `fullTask`). */
|
|
1196
|
+
preRead?: string;
|
|
1188
1197
|
}
|
|
1189
1198
|
|
|
1190
1199
|
/** Fold the phase fingerprint into the base hash parts to form the final cache key. */
|
|
1191
1200
|
function cacheKey(cc: PhaseCacheCtx, baseParts: string[]): string {
|
|
1192
1201
|
// Fold the full cache identity into the hash: flow name (prevents collisions
|
|
1193
1202
|
// across different flows that share a phase.id + task + model), the per-phase
|
|
1194
|
-
// thinking/tools config (changing either changes the subagent's output),
|
|
1195
|
-
// the
|
|
1196
|
-
const parts = [
|
|
1203
|
+
// thinking/tools config (changing either changes the subagent's output), the
|
|
1204
|
+
// resolved context pre-read content, and the world-state fingerprint.
|
|
1205
|
+
const parts = [
|
|
1206
|
+
`flow:${cc.flowName}`,
|
|
1207
|
+
...baseParts,
|
|
1208
|
+
`think:${cc.thinking ?? ""}`,
|
|
1209
|
+
`tools:${JSON.stringify(cc.tools ?? [])}`,
|
|
1210
|
+
`ctx:${cc.preRead ?? ""}`,
|
|
1211
|
+
];
|
|
1197
1212
|
return cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts);
|
|
1198
1213
|
}
|
|
1199
1214
|
|
package/extensions/schema.ts
CHANGED
|
@@ -13,8 +13,8 @@ import { Type, type Static } from "typebox";
|
|
|
13
13
|
// Phase types
|
|
14
14
|
// ---------------------------------------------------------------------------
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow", "loop", "tournament"] as const;
|
|
17
|
+
type PhaseType = (typeof PHASE_TYPES)[number];
|
|
18
18
|
|
|
19
19
|
/** Loop iteration bounds. Authors may lower the max; the hard cap is a runaway guard. */
|
|
20
20
|
export const LOOP_DEFAULT_MAX_ITERATIONS = 10;
|
|
@@ -36,17 +36,18 @@ export const MAX_DYNAMIC_CONCURRENCY = 16;
|
|
|
36
36
|
/** Tournament competitor bounds. */
|
|
37
37
|
export const TOURNAMENT_DEFAULT_VARIANTS = 3;
|
|
38
38
|
export const TOURNAMENT_HARD_MAX_VARIANTS = 20;
|
|
39
|
-
|
|
39
|
+
const TOURNAMENT_MODES = ["best", "aggregate"] as const;
|
|
40
|
+
/** @internal */
|
|
40
41
|
export type TournamentMode = (typeof TOURNAMENT_MODES)[number];
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const OUTPUT_FORMATS = ["text", "json"] as const;
|
|
44
|
+
const JOIN_MODES = ["all", "any"] as const;
|
|
45
|
+
const CACHE_SCOPES = ["run-only", "cross-run", "off"] as const;
|
|
45
46
|
export type CacheScope = (typeof CACHE_SCOPES)[number];
|
|
46
47
|
/** Allowed fingerprint entry prefixes. `glob!:` = content-hash variant of `glob:`. */
|
|
47
|
-
|
|
48
|
+
const CACHE_FINGERPRINT_PREFIXES = ["git:", "glob:", "glob!:", "file:", "env:"] as const;
|
|
48
49
|
/** Phase types that must NOT be cached across runs (a fresh result is required each run). */
|
|
49
|
-
|
|
50
|
+
const CACHE_CROSS_RUN_BLOCKED_TYPES = ["gate", "approval", "loop", "tournament"] as const;
|
|
50
51
|
|
|
51
52
|
const ParallelTaskSchema = Type.Object(
|
|
52
53
|
{
|
|
@@ -282,7 +283,7 @@ export type ArgSpec = Static<typeof ArgSpecSchema>;
|
|
|
282
283
|
export type RetryPolicy = Static<typeof RetrySchema>;
|
|
283
284
|
export type Budget = Static<typeof BudgetSchema>;
|
|
284
285
|
export type CachePolicy = Static<typeof CacheSchema>;
|
|
285
|
-
|
|
286
|
+
type JoinMode = (typeof JOIN_MODES)[number];
|
|
286
287
|
|
|
287
288
|
// ---------------------------------------------------------------------------
|
|
288
289
|
// Shorthand (non-DAG) specs — subagent-style ergonomics
|
|
@@ -302,6 +303,10 @@ export type JoinMode = (typeof JOIN_MODES)[number];
|
|
|
302
303
|
export interface ShorthandStep {
|
|
303
304
|
agent?: string;
|
|
304
305
|
task: string;
|
|
306
|
+
/** Files to pre-read and inject before the task (pass-through to Phase.context). */
|
|
307
|
+
context?: string[];
|
|
308
|
+
/** Max characters per context file (pass-through to Phase.contextLimit). */
|
|
309
|
+
contextLimit?: number;
|
|
305
310
|
}
|
|
306
311
|
|
|
307
312
|
/** True when `def` is a shorthand spec (no `phases`, but a task/tasks/chain field). */
|
|
@@ -316,11 +321,22 @@ export function isShorthand(def: unknown): boolean {
|
|
|
316
321
|
);
|
|
317
322
|
}
|
|
318
323
|
|
|
324
|
+
/** Coerce an unknown value into a non-empty list of non-empty strings (or undefined). */
|
|
325
|
+
function readContextList(v: unknown): string[] | undefined {
|
|
326
|
+
if (!Array.isArray(v)) return undefined;
|
|
327
|
+
const list = v.filter((x): x is string => typeof x === "string" && x.trim().length > 0);
|
|
328
|
+
return list.length ? list : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
319
331
|
function readStep(s: unknown): ShorthandStep {
|
|
320
332
|
if (typeof s === "string") return { task: s };
|
|
321
333
|
if (s && typeof s === "object") {
|
|
322
334
|
const o = s as Record<string, unknown>;
|
|
323
|
-
|
|
335
|
+
const step: ShorthandStep = { agent: typeof o.agent === "string" ? o.agent : undefined, task: String(o.task ?? "") };
|
|
336
|
+
const ctx = readContextList(o.context);
|
|
337
|
+
if (ctx) step.context = ctx;
|
|
338
|
+
if (typeof o.contextLimit === "number") step.contextLimit = o.contextLimit;
|
|
339
|
+
return step;
|
|
324
340
|
}
|
|
325
341
|
return { task: "" };
|
|
326
342
|
}
|
|
@@ -345,10 +361,19 @@ export function desugar(def: unknown): Taskflow {
|
|
|
345
361
|
|
|
346
362
|
// chain → sequential agent phases
|
|
347
363
|
if (Array.isArray(d.chain) && d.chain.length > 0) {
|
|
364
|
+
// Spec-level context in chain mode would be a flow-level default (every
|
|
365
|
+
// step), which is deliberately NOT supported — declare it per step instead.
|
|
366
|
+
if (d.context !== undefined || d.contextLimit !== undefined) {
|
|
367
|
+
console.warn(
|
|
368
|
+
"[taskflow] Shorthand chain ignores top-level 'context'/'contextLimit' — put them on individual steps instead.",
|
|
369
|
+
);
|
|
370
|
+
}
|
|
348
371
|
const steps = d.chain.map(readStep);
|
|
349
372
|
const phases: Phase[] = steps.map((s, i) => {
|
|
350
373
|
const phase: Phase = { id: `step${i + 1}`, type: "agent", task: s.task };
|
|
351
374
|
if (s.agent) phase.agent = s.agent;
|
|
375
|
+
if (s.context) phase.context = s.context;
|
|
376
|
+
if (s.contextLimit !== undefined) phase.contextLimit = s.contextLimit;
|
|
352
377
|
if (i > 0) phase.dependsOn = [`step${i}`];
|
|
353
378
|
if (i === steps.length - 1) phase.final = true;
|
|
354
379
|
return phase;
|
|
@@ -356,16 +381,30 @@ export function desugar(def: unknown): Taskflow {
|
|
|
356
381
|
return { name: nameOf("chain"), ...meta, phases };
|
|
357
382
|
}
|
|
358
383
|
|
|
359
|
-
// tasks → one parallel phase (fan-out + merge), no extra aggregation agent
|
|
384
|
+
// tasks → one parallel phase (fan-out + merge), no extra aggregation agent.
|
|
385
|
+
// Context is SHARED across all branches (the runtime pre-reads per phase, not
|
|
386
|
+
// per branch): spec-level context plus the union of step-level contexts.
|
|
360
387
|
if (Array.isArray(d.tasks) && d.tasks.length > 0) {
|
|
361
|
-
const
|
|
362
|
-
|
|
388
|
+
const steps = d.tasks.map(readStep);
|
|
389
|
+
const branches: ParallelTask[] = steps.map((s) => (s.agent ? { task: s.task, agent: s.agent } : { task: s.task }));
|
|
390
|
+
const phase: Phase = { id: "parallel", type: "parallel", branches, final: true };
|
|
391
|
+
const shared = [...(readContextList(d.context) ?? []), ...steps.flatMap((s) => s.context ?? [])];
|
|
392
|
+
if (shared.length) phase.context = Array.from(new Set(shared));
|
|
393
|
+
const limits = [
|
|
394
|
+
typeof d.contextLimit === "number" ? d.contextLimit : undefined,
|
|
395
|
+
...steps.map((s) => s.contextLimit),
|
|
396
|
+
].filter((n): n is number => typeof n === "number");
|
|
397
|
+
if (limits.length) phase.contextLimit = Math.max(...limits);
|
|
398
|
+
return { name: nameOf("parallel"), ...meta, phases: [phase] };
|
|
363
399
|
}
|
|
364
400
|
|
|
365
|
-
// single task → one agent phase
|
|
401
|
+
// single task → one agent phase (the spec itself is the step)
|
|
366
402
|
if (typeof d.task === "string") {
|
|
367
403
|
const phase: Phase = { id: "main", type: "agent", task: d.task, final: true };
|
|
368
404
|
if (typeof d.agent === "string") phase.agent = d.agent;
|
|
405
|
+
const ctx = readContextList(d.context);
|
|
406
|
+
if (ctx) phase.context = ctx;
|
|
407
|
+
if (typeof d.contextLimit === "number") phase.contextLimit = d.contextLimit;
|
|
369
408
|
return { name: nameOf("task"), ...meta, phases: [phase] };
|
|
370
409
|
}
|
|
371
410
|
|
|
@@ -376,6 +415,7 @@ export function desugar(def: unknown): Taskflow {
|
|
|
376
415
|
// Validation (beyond schema: DAG integrity, phase-type requirements)
|
|
377
416
|
// ---------------------------------------------------------------------------
|
|
378
417
|
|
|
418
|
+
/** @internal */
|
|
379
419
|
export interface ValidationResult {
|
|
380
420
|
ok: boolean;
|
|
381
421
|
errors: string[];
|
|
@@ -618,16 +658,41 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
|
|
|
618
658
|
// placeholder string. The runtime can't infer the intent — fail fast at
|
|
619
659
|
// validation time so the mistake is caught before the run starts.
|
|
620
660
|
//
|
|
661
|
+
// The check uses TRANSITIVE ancestors: if phase B depends on A, and C depends
|
|
662
|
+
// on B, then C may reference {steps.A.*} transitively. Only truly unreachable
|
|
663
|
+
// refs are errors.
|
|
664
|
+
//
|
|
621
665
|
// Phases with `join: "any"` are exempt: by design they only need ONE of
|
|
622
666
|
// their declared deps to complete, and may reference other phases as
|
|
623
667
|
// informational context (not as true dependencies).
|
|
624
668
|
if (errors.length === 0) {
|
|
625
669
|
const idToPhase = new Map((flow.phases as Phase[]).map((p) => [p.id, p]));
|
|
670
|
+
// Precompute transitive ancestors for every phase via BFS over dependsOn.
|
|
671
|
+
const transitiveCache = new Map<string, Set<string>>();
|
|
672
|
+
const transitiveAncestors = (phaseId: string): Set<string> => {
|
|
673
|
+
const cached = transitiveCache.get(phaseId);
|
|
674
|
+
if (cached) return cached;
|
|
675
|
+
const result = new Set<string>();
|
|
676
|
+
const queue = [...(idToPhase.get(phaseId)?.dependsOn ?? []), ...(idToPhase.get(phaseId)?.from ?? [])];
|
|
677
|
+
while (queue.length) {
|
|
678
|
+
const id = queue.shift()!;
|
|
679
|
+
if (result.has(id)) continue;
|
|
680
|
+
result.add(id);
|
|
681
|
+
const dep = idToPhase.get(id);
|
|
682
|
+
if (dep) {
|
|
683
|
+
for (const d of [...(dep.dependsOn ?? []), ...(dep.from ?? [])]) {
|
|
684
|
+
if (!result.has(d)) queue.push(d);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
transitiveCache.set(phaseId, result);
|
|
689
|
+
return result;
|
|
690
|
+
};
|
|
626
691
|
for (const p of flow.phases as Phase[]) {
|
|
627
692
|
if (!p?.id) continue;
|
|
628
693
|
const isJoinAny = p.join === "any";
|
|
629
694
|
if (isJoinAny) continue;
|
|
630
|
-
const
|
|
695
|
+
const transitive = transitiveAncestors(p.id);
|
|
631
696
|
const refs = collectRefs(p);
|
|
632
697
|
for (const ref of refs.steps) {
|
|
633
698
|
if (ref === p.id) {
|
|
@@ -640,9 +705,9 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
|
|
|
640
705
|
// double-warn — the dependsOn loop above already flags it.
|
|
641
706
|
continue;
|
|
642
707
|
}
|
|
643
|
-
if (!
|
|
708
|
+
if (!transitive.has(ref)) {
|
|
644
709
|
errors.push(
|
|
645
|
-
`Phase '${p.id}': task references {steps.${ref}.*} but '${ref}' is not
|
|
710
|
+
`Phase '${p.id}': task references {steps.${ref}.*} but '${ref}' is not reachable via dependsOn. ` +
|
|
646
711
|
`The phase will run in parallel with '${ref}' and see the literal placeholder. ` +
|
|
647
712
|
`Add "dependsOn": ["${ref}"] (or include '${ref}' transitively).`,
|
|
648
713
|
);
|
package/extensions/store.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.21",
|
|
4
4
|
"description": "A declarative, verifiable graph of task nodes for the Pi coding agent — not a workflow you script, but a DAG you declare: statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -43,10 +43,25 @@ proper flow, so you still get progress, persistence, resume, and `save`.
|
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
- `agent` is optional (defaults to the first available agent).
|
|
46
|
+
- `context` (optional, per step or top-level in single mode): file paths to
|
|
47
|
+
pre-read and inject before the task — same as the full-DSL `Phase.context`
|
|
48
|
+
(per-file `contextLimit`, default 8000 chars). In **parallel `tasks` mode**
|
|
49
|
+
all branches SHARE the union of step contexts (the runtime pre-reads per
|
|
50
|
+
phase, not per branch). In **chain mode** declare `context` on individual
|
|
51
|
+
steps; a top-level `context` is ignored (with a warning).
|
|
46
52
|
- Add `name` to label the run (and to `save` it as a `/tf:<name>` command).
|
|
47
53
|
- Precedence if several are given: `chain` > `tasks` > `task`.
|
|
48
54
|
- You can pass these as top-level tool params **or** inside `define`.
|
|
49
55
|
|
|
56
|
+
```jsonc
|
|
57
|
+
// context pre-read in shorthand — the file content is injected before the task
|
|
58
|
+
{ "chain": [
|
|
59
|
+
{ "task": "Map the public API of src/lib", "agent": "scout" },
|
|
60
|
+
{ "task": "Write docs for:\n{previous.output}", "agent": "doc-writer",
|
|
61
|
+
"context": ["AGENTS.md", "docs/style-guide.md"] }
|
|
62
|
+
] }
|
|
63
|
+
```
|
|
64
|
+
|
|
50
65
|
## How to author a taskflow
|
|
51
66
|
|
|
52
67
|
Call the `taskflow` tool. To run a brand-new flow you write inline, pass
|
|
@@ -128,7 +143,7 @@ deciding. The (interpolated) `task` is the prompt shown.
|
|
|
128
143
|
- **Reject** → halt the flow (same mechanism as a blocking gate).
|
|
129
144
|
- **Edit** → the typed note becomes this phase's `output`, so you can inject
|
|
130
145
|
guidance mid-run: reference it downstream with `{steps.<id>.output}`.
|
|
131
|
-
- **Non-interactive** runs (headless/CI/print mode) **auto-
|
|
146
|
+
- **Non-interactive** runs (headless/CI/print mode) **auto-reject** and record it — approval gates are safety boundaries that must never be silently bypassed.
|
|
132
147
|
- **Background (detached)** runs **auto-reject** (no interactive approver) — downstream sees the rejection; the flow continues (fail-open).
|
|
133
148
|
|
|
134
149
|
```jsonc
|
|
@@ -170,9 +185,10 @@ Use hyphens in ids, never underscores. Sub-flow phases reference each other in
|
|
|
170
185
|
their **own** `{steps.x.output}` namespace (no parent-id prefixing needed).
|
|
171
186
|
|
|
172
187
|
**Fail-open & limits:** if the `def` doesn't parse, has the wrong shape, or fails
|
|
173
|
-
validation, the phase
|
|
174
|
-
|
|
175
|
-
|
|
188
|
+
validation, the phase completes with `status: "done"` and carries a `defError`
|
|
189
|
+
diagnostic field; downstream phases receive empty output. Authors who want a
|
|
190
|
+
hard failure can add a gate that checks for `defError`. The run continues
|
|
191
|
+
(add `optional: true` on the flow phase so a bad plan never aborts the run). An **empty** `phases` array is a
|
|
176
192
|
valid no-op (the planner decided there's nothing to do). Inline nesting is capped
|
|
177
193
|
at `MAX_DYNAMIC_NESTING` (5) to bound runaway self-spawning.
|
|
178
194
|
|
|
@@ -217,7 +233,7 @@ A `tournament` phase runs `variants` competing attempts in parallel, then a
|
|
|
217
233
|
(`mode: "aggregate"`). Use it when one shot is unreliable and you want the best
|
|
218
234
|
of several drafts, or a synthesis of diverse approaches.
|
|
219
235
|
|
|
220
|
-
- `variants` —
|
|
236
|
+
- `variants` — a number specifying how many competing variants to spawn from 'task' (default 3, max 20). For genuinely different approaches, use the `branches` field instead — an explicit array of `{task, agent?}` definitions.
|
|
221
237
|
- `mode` — `"best"` (judge picks one winner, default) or `"aggregate"` (judge merges all into one output).
|
|
222
238
|
- `judge` — the judge's rubric/instructions (how to choose or merge).
|
|
223
239
|
- `judgeAgent` — *(optional)* the agent that runs the judge step; defaults to the phase `agent`.
|
|
@@ -450,12 +466,13 @@ Add `detach: true` to `action: "run"` to spawn the flow in a detached child proc
|
|
|
450
466
|
|
|
451
467
|
## Operating a run (lifecycle, resume, inspection)
|
|
452
468
|
|
|
453
|
-
A run moves through: **running →** `completed` (a `final` phase produced output) **/** `blocked` (a gate emitted BLOCK, an `approval` was rejected, or the `budget` cap was hit) **/** `failed` (a non-`optional` phase errored) **/** `paused` (the run was aborted). `failed` and `paused` runs are resumable
|
|
469
|
+
A run moves through: **running →** `completed` (a `final` phase produced output) **/** `blocked` (a gate emitted BLOCK, an `approval` was rejected, or the `budget` cap was hit) **/** `failed` (a non-`optional` phase errored) **/** `paused` (the run was aborted). `failed` and `paused` runs are resumable.
|
|
454
470
|
|
|
455
|
-
-
|
|
471
|
+
- **`blocked` runs:** a blocked status halts the current run — the flow status is set to `blocked` and remaining phases are skipped. Re-running the flow resumes from the last completed state: `done` phases with matching input hashes are skipped; blocked/failed/skipped phases are re-attempted. Fix the gate condition or budget before re-running.
|
|
472
|
+
- **Resume is cache-aware.** `action: "resume"` re-runs only what didn't finish: every phase already `done` is reused from its recorded output (within-run cache), so resuming after a crash or a failed/blocked stop never repeats completed work. A phase that was mid-flight is re-executed cleanly (stale `error`/`endedAt` are cleared first).
|
|
456
473
|
- **When to resume vs. re-run.** Resume when the inputs are unchanged and you just want to continue/retry the tail (fixed a gate, raised the budget, approved a checkpoint). Re-run from scratch when the task or upstream inputs changed — resume would reuse now-stale outputs. (For reuse *across* runs, opt a phase into `cache: {scope:"cross-run"}` — see configuration.md.)
|
|
457
474
|
- **Budget mid-run.** When the run-wide `budget` is exceeded, remaining phases are skipped and an in-flight `map`/`parallel` stops spawning new items; the run ends `blocked` with the partial outputs preserved.
|
|
458
|
-
- **Inspect runs.** `/tf runs` lists recent runs with status; `/tf show <name>` prints a saved flow's definition. Run state lives at `<project .pi>/taskflows/runs/<runId>.json` (gitignored).
|
|
475
|
+
- **Inspect runs.** `/tf runs` lists recent runs with status; `/tf show <name>` prints a saved flow's definition. Run state lives at `<project .pi>/taskflows/runs/<flowName>/<runId>.json` (gitignored).
|
|
459
476
|
|
|
460
477
|
## User commands
|
|
461
478
|
|
|
@@ -286,7 +286,7 @@ for the design.
|
|
|
286
286
|
### `ttl` (cross-run only)
|
|
287
287
|
|
|
288
288
|
Max age before a cross-run hit is treated as a miss: e.g. `"30m"`, `"6h"`, `"7d"`.
|
|
289
|
-
Omit for no time bound. A hit older than the TTL re-executes the phase.
|
|
289
|
+
Omit for no time bound. A hit older than the TTL re-executes the phase. Cross-run cache entries are hard-evicted after 90 days regardless of per-entry TTL. This ceiling is not configurable.
|
|
290
290
|
|
|
291
291
|
### `fingerprint` (cross-run only)
|
|
292
292
|
|
|
@@ -298,7 +298,7 @@ Each entry is one of:
|
|
|
298
298
|
| Entry | Becomes a miss when… | Resolves to |
|
|
299
299
|
|-------|----------------------|-------------|
|
|
300
300
|
| `git:HEAD` / `git:<ref>` | the commit moves | the resolved SHA (30s timeout → `<timeout>`; no git → `<no-git>`) |
|
|
301
|
-
| `glob:<pattern>` | the **set of matching paths** changes | sorted path list (mtime-
|
|
301
|
+
| `glob:<pattern>` | the **set of matching paths** or their metadata changes | sorted path list with size + mtime (content-hashed globs use `glob!:` instead, which is mtime-independent) |
|
|
302
302
|
| `glob!:<pattern>` | the **contents** of matching files change | content hashes (capped at 5000 matches) |
|
|
303
303
|
| `file:<path>` | that file's content changes | sha256 of the file (>10 MB or missing → `<skip>`/`<missing>`) |
|
|
304
304
|
| `env:<NAME>` | the env var changes | the env value |
|
|
@@ -333,7 +333,7 @@ Each entry is one of:
|
|
|
333
333
|
|------|------|---------|
|
|
334
334
|
| User-scoped flow | `~/.pi/agent/taskflows/<name>.json` | personal |
|
|
335
335
|
| Project-scoped flow | `<nearest .pi>/taskflows/<name>.json` | ✅ commit to share |
|
|
336
|
-
| Run state (resume) | `<project .pi>/taskflows/runs/<runId>.json` | ❌ gitignore |
|
|
336
|
+
| Run state (resume) | `<project .pi>/taskflows/runs/<flowName>/<runId>.json` | ❌ gitignore |
|
|
337
337
|
|
|
338
338
|
- `action: "save"` takes `scope: "project"` (default) or `"user"`.
|
|
339
339
|
- Saved flows auto-register as `/tf:<name>` (immediately for the current session,
|