pi-taskflow 0.0.5 → 0.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +15 -1
- package/README.md +45 -10
- package/examples/conditional-research.json +56 -0
- package/examples/guarded-refactor.json +50 -0
- package/extensions/agents.ts +8 -1
- package/extensions/index.ts +30 -15
- package/extensions/interpolate.ts +231 -0
- package/extensions/render.ts +14 -3
- package/extensions/runner.ts +61 -78
- package/extensions/runtime.ts +364 -44
- package/extensions/schema.ts +85 -2
- package/extensions/store.ts +29 -3
- package/extensions/usage.ts +42 -0
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +67 -2
package/DESIGN.md
CHANGED
|
@@ -204,6 +204,19 @@ pi-taskflow/
|
|
|
204
204
|
| `map` | 对上游数组**动态 fan-out**,每项一个 agent | ≤concurrency |
|
|
205
205
|
| `gate` | 质量门 / 对抗 review(可决定是否继续) | 1+ |
|
|
206
206
|
| `reduce` | 把多结果聚合为一(synthesize) | 1 |
|
|
207
|
+
| `approval` | **人在环**:暂停等待 approve / reject / edit | 1 |
|
|
208
|
+
| `flow` | 把一个**已保存的 taskflow** 当作单个 phase 运行(组合复用) | 子流程并发 |
|
|
209
|
+
|
|
210
|
+
### 3.3b 控制流 / 可靠性字段(任意 phase)
|
|
211
|
+
|
|
212
|
+
| 字段 | 语义 |
|
|
213
|
+
|------|------|
|
|
214
|
+
| `when` | 条件守卫:表达式为假则 skip 该 phase。支持 `{refs}`、`== != < > <= >=`、`&& \|\| !`、括号、字符串/数字字面量。解析失败 fail-open(仍运行) |
|
|
215
|
+
| `join` | 依赖 join:`all`(默认,等全部 dep)/ `any`(OR-join,任一 dep 完成即运行) |
|
|
216
|
+
| `retry` | `{max, backoffMs, factor}`:失败重试,延迟 = `backoffMs * factor^attempt` |
|
|
217
|
+
| `use` / `with` | `flow` 子流程的名字与入参(入参字符串值会插值) |
|
|
218
|
+
|
|
219
|
+
顶层 `budget: {maxUSD, maxTokens}`:累计成本/token 超限即停(剩余 phase skip,运行态 `blocked`)。
|
|
207
220
|
|
|
208
221
|
### 3.4 模板插值
|
|
209
222
|
|
|
@@ -293,7 +306,8 @@ export async function runTaskflow(def, args, ctx): Promise<TaskflowResult>
|
|
|
293
306
|
| **v0.1** | DSL + schema + runtime(agent/parallel/map/reduce)+ `taskflow` 工具 + `/tf run` + 内存隔离 + 流式进度 | ✅ 已发布 (npm 0.0.1) |
|
|
294
307
|
| **v0.2** | 保存/动态命令注册 + 跨 session 恢复 + `gate` 真门控 + run 历史交互 TUI | ✅ 已完成 (npm 0.0.3) |
|
|
295
308
|
| **v0.3** | examples + SKILL.md(教 LLM 写定义)+ YAML 支持 + 发布 npm | 🚧 examples/SKILL/npm 已做;YAML 待办 |
|
|
296
|
-
| **v0.
|
|
309
|
+
| **v0.6** | 控制流 & 可靠性:`when` 条件分支 + `join:any` OR-join + 声明式 `retry` + `approval` 人在环 + `flow` 子流程组合 + `budget` 成本上限 | ✅ 已完成 |
|
|
310
|
+
| **v0.7+** | 真·后台执行(detached + 轮询)+ 事件/cron 触发 + 成本**预估** + mermaid DAG 导出 + 内置 `deep-research` 工作流 | ⏳ 待办 |
|
|
297
311
|
|
|
298
312
|
---
|
|
299
313
|
|
package/README.md
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
<img src="./assets/hero.png" alt="pi-taskflow — declarative, multi-phase subagent workflows" width="880">
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<a href="https://www.npmjs.com/package/pi-taskflow"><img src="https://img.shields.io/npm/v/pi-taskflow?style=flat-square&color=B692FF&label=npm" alt="npm version"></a>
|
|
7
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-43D9AD?style=flat-square" alt="MIT license"></a>
|
|
8
|
+
<a href="https://pi.dev"><img src="https://img.shields.io/badge/for-Pi%20coding%20agent-6E8BFF?style=flat-square" alt="for the Pi coding agent"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
</div>
|
|
6
12
|
|
|
7
13
|
> Lightweight workflow orchestration for the [Pi coding agent](https://pi.dev).
|
|
8
14
|
|
|
@@ -172,9 +178,34 @@ writes and verifies its own test suites, caught here mid-block by a quality gate
|
|
|
172
178
|
| `map` | fan out over an array — one subagent per item, `{item}` bound | `over`, `task` |
|
|
173
179
|
| `gate` | quality/review step that can **halt the flow** | `task` |
|
|
174
180
|
| `reduce` | aggregate `from[]` phase outputs into one | `from`, `task` |
|
|
181
|
+
| `approval` | **human-in-the-loop** pause — approve / reject / edit before continuing | — |
|
|
182
|
+
| `flow` | run a **saved sub-flow** as one phase (composition/reuse) | `use` |
|
|
175
183
|
|
|
176
184
|
Every phase needs `id`. Optional fields: `agent`, `dependsOn`, `output`,
|
|
177
|
-
`model`, `thinking`, `tools`, `cwd`, `concurrency`, `final`, `optional
|
|
185
|
+
`model`, `thinking`, `tools`, `cwd`, `concurrency`, `final`, `optional`,
|
|
186
|
+
`when` (conditional guard), `join` (`all`\|`any` dependency join), `retry`
|
|
187
|
+
(`{max, backoffMs, factor}`), and `with` (args for a `flow` phase).
|
|
188
|
+
Run-wide: `budget: {maxUSD, maxTokens}` halts the flow when exceeded.
|
|
189
|
+
|
|
190
|
+
### Control flow & reliability
|
|
191
|
+
|
|
192
|
+
- **`when`** — skip a phase unless an expression is truthy. Supports `{refs}`,
|
|
193
|
+
`== != < > <= >=`, `&& || !`, parentheses, and quoted strings/numbers, e.g.
|
|
194
|
+
`"when": "{steps.triage.json.route} == deep"`. Pair with `join: "any"` on the
|
|
195
|
+
merge phase to build real if/else routing. Parse errors **fail open**.
|
|
196
|
+
- **`join: "any"`** — an OR-join: the phase runs as soon as *one* dependency
|
|
197
|
+
completes (default `"all"` waits for every dep).
|
|
198
|
+
- **`retry`** — `{ "max": 2, "backoffMs": 500, "factor": 2 }` retries a failing
|
|
199
|
+
subagent with fixed (`factor:1`) or exponential backoff; usage is summed and
|
|
200
|
+
the attempt count shows as `↻N` in the TUI.
|
|
201
|
+
- **`approval`** — pause for a human (`select`: Approve / Reject / Edit). Reject
|
|
202
|
+
halts the flow; Edit injects the typed note as the phase output for downstream
|
|
203
|
+
steps. Non-interactive runs auto-approve.
|
|
204
|
+
- **`flow`** — `{ "type": "flow", "use": "deep-research", "with": { "topic": "{item}" } }`
|
|
205
|
+
runs a saved flow as a phase (recursion is detected and rejected).
|
|
206
|
+
- **`budget`** — a run-wide `{maxUSD, maxTokens}` ceiling; once exceeded, pending
|
|
207
|
+
phases are skipped (and in-flight fan-out stops spawning) and the run is
|
|
208
|
+
`blocked`.
|
|
178
209
|
|
|
179
210
|
### `output` format
|
|
180
211
|
|
|
@@ -265,7 +296,10 @@ are honored, letting you tweak model, thinking, or tools per agent across all fl
|
|
|
265
296
|
|
|
266
297
|
## Status & limits
|
|
267
298
|
|
|
268
|
-
- **v0.0.
|
|
299
|
+
- **v0.0.6** — control flow & reliability: conditional `when` guards, `join: any`
|
|
300
|
+
OR-joins, declarative `retry`/backoff, `approval` (human-in-the-loop) phases,
|
|
301
|
+
`flow` (saved sub-flow composition), and run-wide `budget` caps — on top of the
|
|
302
|
+
DSL + DAG runtime (`agent`/`parallel`/`map`/`gate`/`reduce`),
|
|
269
303
|
inline + saved flows, cross-session resume, live progress, isolated context.
|
|
270
304
|
Default `concurrency` is 8 (set on the flow; per-phase `concurrency` overrides
|
|
271
305
|
for that phase).
|
|
@@ -279,7 +313,8 @@ are honored, letting you tweak model, thinking, or tools per agent across all fl
|
|
|
279
313
|
### What it doesn't do (yet)
|
|
280
314
|
|
|
281
315
|
- **No detached background execution.** A run needs the pi session to stay open.
|
|
282
|
-
True background execution is on the
|
|
316
|
+
True background execution (and event/cron triggers on top of it) is on the
|
|
317
|
+
roadmap.
|
|
283
318
|
- **No `output: "file"`.** Outputs are text/JSON only. Write files via agent
|
|
284
319
|
tool calls if needed.
|
|
285
320
|
- **`map` requires a JSON array.** The `over` field must resolve to
|
|
@@ -293,9 +328,9 @@ are honored, letting you tweak model, thinking, or tools per agent across all fl
|
|
|
293
328
|
npm install
|
|
294
329
|
npm run typecheck
|
|
295
330
|
node --experimental-strip-types --test test/interpolate.test.ts \
|
|
296
|
-
test/
|
|
297
|
-
test/
|
|
298
|
-
test/desugar.test.ts
|
|
331
|
+
test/condition.test.ts test/schema.test.ts test/usage.test.ts \
|
|
332
|
+
test/runtime.test.ts test/features.test.ts test/runner.test.ts \
|
|
333
|
+
test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts
|
|
299
334
|
|
|
300
335
|
# real end-to-end (spawns live subagents; needs model access)
|
|
301
336
|
PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "conditional-research",
|
|
3
|
+
"description": "Triage a topic, route to a deep or quick research branch (conditional `when` + OR-join), gate the result, and report.",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"args": {
|
|
6
|
+
"topic": { "description": "The subject to research" }
|
|
7
|
+
},
|
|
8
|
+
"concurrency": 4,
|
|
9
|
+
"agentScope": "user",
|
|
10
|
+
"budget": { "maxUSD": 1.0 },
|
|
11
|
+
"phases": [
|
|
12
|
+
{
|
|
13
|
+
"id": "triage",
|
|
14
|
+
"type": "agent",
|
|
15
|
+
"agent": "analyst",
|
|
16
|
+
"task": "Decide how much research the topic \"{args.topic}\" needs. Output ONLY JSON: {\"route\": \"deep\"} for broad/ambiguous topics, or {\"route\": \"quick\"} for narrow/well-defined ones.",
|
|
17
|
+
"output": "json"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "deep",
|
|
21
|
+
"type": "agent",
|
|
22
|
+
"agent": "explorer",
|
|
23
|
+
"when": "{steps.triage.json.route} == deep",
|
|
24
|
+
"dependsOn": ["triage"],
|
|
25
|
+
"task": "Do a thorough, multi-angle investigation of \"{args.topic}\". Cover background, key players, trade-offs, and open questions.",
|
|
26
|
+
"retry": { "max": 2, "backoffMs": 500, "factor": 2 }
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"id": "quick",
|
|
30
|
+
"type": "agent",
|
|
31
|
+
"agent": "explorer",
|
|
32
|
+
"when": "{steps.triage.json.route} == quick",
|
|
33
|
+
"dependsOn": ["triage"],
|
|
34
|
+
"task": "Give a concise, focused answer on \"{args.topic}\" with the 3 most important points.",
|
|
35
|
+
"retry": { "max": 2, "backoffMs": 500, "factor": 2 }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "review",
|
|
39
|
+
"type": "gate",
|
|
40
|
+
"agent": "critic",
|
|
41
|
+
"join": "any",
|
|
42
|
+
"from": ["deep", "quick"],
|
|
43
|
+
"dependsOn": ["deep", "quick"],
|
|
44
|
+
"task": "Review the research below for unsupported claims. If it is solid, end with 'VERDICT: PASS'; if it is too thin, end with 'VERDICT: BLOCK' and one reason.\n\n{steps.deep.output}{steps.quick.output}"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "report",
|
|
48
|
+
"type": "reduce",
|
|
49
|
+
"from": ["review"],
|
|
50
|
+
"dependsOn": ["review"],
|
|
51
|
+
"agent": "doc-writer",
|
|
52
|
+
"task": "Write a clean markdown brief on \"{args.topic}\" from the validated research:\n\n{steps.deep.output}{steps.quick.output}",
|
|
53
|
+
"final": true
|
|
54
|
+
}
|
|
55
|
+
]
|
|
56
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "guarded-refactor",
|
|
3
|
+
"description": "Plan a change, pause for human approval before touching code (HITL), then implement with retries and a final review gate. Budget-capped.",
|
|
4
|
+
"version": 1,
|
|
5
|
+
"args": {
|
|
6
|
+
"target": { "default": "src", "description": "Directory or module to refactor" },
|
|
7
|
+
"goal": { "description": "What the refactor should achieve" }
|
|
8
|
+
},
|
|
9
|
+
"concurrency": 4,
|
|
10
|
+
"agentScope": "user",
|
|
11
|
+
"budget": { "maxUSD": 2.0 },
|
|
12
|
+
"phases": [
|
|
13
|
+
{
|
|
14
|
+
"id": "plan",
|
|
15
|
+
"type": "agent",
|
|
16
|
+
"agent": "planner",
|
|
17
|
+
"task": "Produce a concrete, step-by-step refactor plan for {args.target} to achieve: {args.goal}. List the files you would change and the risks."
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "approve",
|
|
21
|
+
"type": "approval",
|
|
22
|
+
"dependsOn": ["plan"],
|
|
23
|
+
"task": "Review the refactor plan above. Approve to proceed, reject to abort, or edit to add constraints the implementer must follow."
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "implement",
|
|
27
|
+
"type": "agent",
|
|
28
|
+
"agent": "executor_code",
|
|
29
|
+
"dependsOn": ["approve"],
|
|
30
|
+
"task": "Implement the approved plan for {args.target}.\nPlan:\n{steps.plan.output}\nExtra human guidance (if any):\n{steps.approve.output}",
|
|
31
|
+
"retry": { "max": 1, "backoffMs": 1000 }
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
"id": "review",
|
|
35
|
+
"type": "gate",
|
|
36
|
+
"agent": "reviewer",
|
|
37
|
+
"dependsOn": ["implement"],
|
|
38
|
+
"task": "Review the implementation report below. If it is correct and complete, end with 'VERDICT: PASS'; otherwise 'VERDICT: BLOCK' with reasons.\n\n{steps.implement.output}"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "summary",
|
|
42
|
+
"type": "reduce",
|
|
43
|
+
"from": ["review"],
|
|
44
|
+
"dependsOn": ["review"],
|
|
45
|
+
"agent": "doc-writer",
|
|
46
|
+
"task": "Write a short changelog entry summarizing what was done:\n\n{steps.implement.output}",
|
|
47
|
+
"final": true
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
package/extensions/agents.ts
CHANGED
|
@@ -55,7 +55,14 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
|
|
55
55
|
continue;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
const { frontmatter, body } =
|
|
58
|
+
const { frontmatter, body } = (() => {
|
|
59
|
+
try {
|
|
60
|
+
return parseFrontmatter<Record<string, string>>(content);
|
|
61
|
+
} catch {
|
|
62
|
+
// A single malformed agent file must not break discovery for every flow.
|
|
63
|
+
return { frontmatter: {} as Record<string, string>, body: "" };
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
59
66
|
if (!frontmatter.name || !frontmatter.description) continue;
|
|
60
67
|
|
|
61
68
|
const tools = frontmatter.tools
|
package/extensions/index.ts
CHANGED
|
@@ -18,8 +18,8 @@ import { Type } from "typebox";
|
|
|
18
18
|
import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
|
|
19
19
|
import { renderRunResult, summarizeRun } from "./render.ts";
|
|
20
20
|
import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
|
|
21
|
-
import { executeTaskflow, type RuntimeResult } from "./runtime.ts";
|
|
22
|
-
import { finalPhase, type Taskflow, validateTaskflow, desugar, isShorthand } from "./schema.ts";
|
|
21
|
+
import { executeTaskflow, type ApprovalDecision, type ApprovalRequest, type RuntimeResult } from "./runtime.ts";
|
|
22
|
+
import { finalPhase, resolveArgs, type Taskflow, validateTaskflow, desugar, isShorthand } from "./schema.ts";
|
|
23
23
|
import {
|
|
24
24
|
getFlow,
|
|
25
25
|
listFlows,
|
|
@@ -86,17 +86,6 @@ const TaskflowParams = Type.Object({
|
|
|
86
86
|
),
|
|
87
87
|
});
|
|
88
88
|
|
|
89
|
-
function resolveArgs(def: Taskflow, provided: Record<string, unknown> | undefined): Record<string, unknown> {
|
|
90
|
-
const args: Record<string, unknown> = {};
|
|
91
|
-
for (const [key, spec] of Object.entries(def.args ?? {})) {
|
|
92
|
-
if (provided && key in provided) args[key] = provided[key];
|
|
93
|
-
else if (spec.default !== undefined) args[key] = spec.default;
|
|
94
|
-
}
|
|
95
|
-
// also pass through any extra provided args
|
|
96
|
-
if (provided) for (const [k, v] of Object.entries(provided)) if (!(k in args)) args[k] = v;
|
|
97
|
-
return args;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
89
|
function makeRunState(def: Taskflow, args: Record<string, unknown>, cwd: string): RunState {
|
|
101
90
|
return {
|
|
102
91
|
runId: newRunId(def.name),
|
|
@@ -153,6 +142,29 @@ async function runFlow(
|
|
|
153
142
|
(heartbeat as { unref?: () => void }).unref?.();
|
|
154
143
|
}
|
|
155
144
|
|
|
145
|
+
// Human-in-the-loop approver — only when an interactive UI is available.
|
|
146
|
+
const requestApproval = ctx.hasUI
|
|
147
|
+
? async (req: ApprovalRequest): Promise<ApprovalDecision> => {
|
|
148
|
+
if (req.upstream?.trim()) {
|
|
149
|
+
const snip = req.upstream.replace(/\s+/g, " ").trim();
|
|
150
|
+
ctx.ui.notify(`[${def.name}/${req.phaseId}] ${snip.length > 280 ? `${snip.slice(0, 280)}…` : snip}`, "info");
|
|
151
|
+
}
|
|
152
|
+
const choice = await ctx.ui.select(
|
|
153
|
+
`Taskflow approval — ${req.phaseId}: ${req.message}`,
|
|
154
|
+
["Approve", "Reject", "Edit / add guidance"],
|
|
155
|
+
{ signal },
|
|
156
|
+
);
|
|
157
|
+
if (!choice || choice === "Reject") return { decision: "reject" };
|
|
158
|
+
if (choice.startsWith("Edit")) {
|
|
159
|
+
const note = await ctx.ui.input("Guidance passed downstream as this phase's output", "type guidance…", {
|
|
160
|
+
signal,
|
|
161
|
+
});
|
|
162
|
+
return { decision: "edit", note: note ?? "" };
|
|
163
|
+
}
|
|
164
|
+
return { decision: "approve" };
|
|
165
|
+
}
|
|
166
|
+
: undefined;
|
|
167
|
+
|
|
156
168
|
try {
|
|
157
169
|
const result = await executeTaskflow(state, {
|
|
158
170
|
cwd: ctx.cwd,
|
|
@@ -160,6 +172,8 @@ async function runFlow(
|
|
|
160
172
|
globalThinking: settings.globalThinking,
|
|
161
173
|
signal,
|
|
162
174
|
persist: persistThrottled,
|
|
175
|
+
requestApproval,
|
|
176
|
+
loadFlow: (name: string) => getFlow(ctx.cwd, name)?.def,
|
|
163
177
|
});
|
|
164
178
|
return result;
|
|
165
179
|
} finally {
|
|
@@ -199,11 +213,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
199
213
|
label: "Taskflow",
|
|
200
214
|
description: [
|
|
201
215
|
"Orchestrate a multi-phase workflow of subagents from a declarative definition.",
|
|
202
|
-
"Phases (agent, parallel, map, gate, reduce) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.",
|
|
216
|
+
"Phases (agent, parallel, map, gate, reduce, approval, flow) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.",
|
|
203
217
|
"Use action=run with an inline `define` (you write the DSL) or a saved `name`.",
|
|
204
218
|
"For simple non-DAG delegations (like the subagent tool) skip the DSL: pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` to run in parallel, or `chain:[{task,agent?}]` to run sequentially (reference the prior step with {previous.output}).",
|
|
205
219
|
"Use action=save to persist a definition as a reusable /tf:<name> command. action=resume continues a paused run. action=list shows saved flows.",
|
|
206
|
-
"DSL: {name, args?, concurrency?, phases:[{id, type, agent, task, dependsOn?, over?(map), as?(map), branches?(parallel), from?(reduce), output?:'json', final?}]}.",
|
|
220
|
+
"DSL: {name, args?, concurrency?, budget?:{maxUSD,maxTokens}, phases:[{id, type, agent, task, dependsOn?, join?:'all'|'any', when?, retry?:{max,backoffMs,factor}, over?(map), as?(map), branches?(parallel), from?(reduce), use?(flow), with?(flow), output?:'json', final?}]}.",
|
|
221
|
+
"Phase types: agent (one subagent), parallel (static branches), map (dynamic fan-out over an array), gate (VERDICT: PASS/BLOCK quality gate), reduce (aggregate from N phases), approval (human-in-the-loop pause), flow (run a saved sub-flow). join:'any' is an OR-join; when is a conditional guard; retry adds backoff; budget caps run cost.",
|
|
207
222
|
"Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
|
|
208
223
|
].join(" "),
|
|
209
224
|
parameters: TaskflowParams,
|
|
@@ -149,3 +149,234 @@ export function coerceArray(value: unknown): unknown[] | null {
|
|
|
149
149
|
}
|
|
150
150
|
return null;
|
|
151
151
|
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Conditional expressions (phase.when)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
//
|
|
157
|
+
// A tiny, safe boolean expression language — NO eval / Function. Operands are
|
|
158
|
+
// either interpolation placeholders `{...}` (resolved to their raw value) or
|
|
159
|
+
// literals (quoted string, number, true/false/null, or a bare word treated as
|
|
160
|
+
// a string). Operators, by precedence (low → high):
|
|
161
|
+
//
|
|
162
|
+
// || logical or
|
|
163
|
+
// && logical and
|
|
164
|
+
// == != == >= <= > < comparison
|
|
165
|
+
// ! logical not / unary
|
|
166
|
+
// ( ) grouping
|
|
167
|
+
//
|
|
168
|
+
// A bare operand is evaluated for truthiness. Parse errors fail OPEN (return
|
|
169
|
+
// true) so a malformed guard never silently drops a phase.
|
|
170
|
+
|
|
171
|
+
type Tok =
|
|
172
|
+
| { t: "ref"; v: string }
|
|
173
|
+
| { t: "str"; v: string }
|
|
174
|
+
| { t: "num"; v: number }
|
|
175
|
+
| { t: "bool"; v: boolean }
|
|
176
|
+
| { t: "null" }
|
|
177
|
+
| { t: "op"; v: string };
|
|
178
|
+
|
|
179
|
+
const OPS = ["&&", "||", "==", "!=", ">=", "<=", ">", "<", "!", "(", ")"];
|
|
180
|
+
|
|
181
|
+
function tokenize(input: string): Tok[] {
|
|
182
|
+
const toks: Tok[] = [];
|
|
183
|
+
let i = 0;
|
|
184
|
+
const n = input.length;
|
|
185
|
+
while (i < n) {
|
|
186
|
+
const c = input[i];
|
|
187
|
+
if (c === " " || c === "\t" || c === "\n" || c === "\r") {
|
|
188
|
+
i++;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// placeholder {path.to.value}
|
|
192
|
+
if (c === "{") {
|
|
193
|
+
const end = input.indexOf("}", i);
|
|
194
|
+
if (end === -1) throw new Error("unterminated placeholder");
|
|
195
|
+
toks.push({ t: "ref", v: input.slice(i + 1, end).trim() });
|
|
196
|
+
i = end + 1;
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// quoted string
|
|
200
|
+
if (c === '"' || c === "'") {
|
|
201
|
+
const end = input.indexOf(c, i + 1);
|
|
202
|
+
if (end === -1) throw new Error("unterminated string");
|
|
203
|
+
toks.push({ t: "str", v: input.slice(i + 1, end) });
|
|
204
|
+
i = end + 1;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
// multi/single char operators
|
|
208
|
+
const op = OPS.find((o) => input.startsWith(o, i));
|
|
209
|
+
if (op) {
|
|
210
|
+
toks.push({ t: "op", v: op });
|
|
211
|
+
i += op.length;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
// number
|
|
215
|
+
const numMatch = /^-?\d+(?:\.\d+)?/.exec(input.slice(i));
|
|
216
|
+
if (numMatch) {
|
|
217
|
+
toks.push({ t: "num", v: Number(numMatch[0]) });
|
|
218
|
+
i += numMatch[0].length;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
// bareword → literal (true/false/null keywords, else string)
|
|
222
|
+
const word = /^[^\s&|!=<>()"'{}]+/.exec(input.slice(i));
|
|
223
|
+
if (word) {
|
|
224
|
+
const w = word[0];
|
|
225
|
+
if (w === "true") toks.push({ t: "bool", v: true });
|
|
226
|
+
else if (w === "false") toks.push({ t: "bool", v: false });
|
|
227
|
+
else if (w === "null") toks.push({ t: "null" });
|
|
228
|
+
else toks.push({ t: "str", v: w });
|
|
229
|
+
i += w.length;
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
throw new Error(`unexpected char '${c}'`);
|
|
233
|
+
}
|
|
234
|
+
return toks;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function isNumeric(v: unknown): boolean {
|
|
238
|
+
if (typeof v === "number") return Number.isFinite(v);
|
|
239
|
+
if (typeof v === "string" && v.trim() !== "") return Number.isFinite(Number(v));
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function truthy(v: unknown): boolean {
|
|
244
|
+
if (v === undefined || v === null) return false;
|
|
245
|
+
if (typeof v === "boolean") return v;
|
|
246
|
+
if (typeof v === "number") return v !== 0;
|
|
247
|
+
if (typeof v === "string") {
|
|
248
|
+
const s = v.trim().toLowerCase();
|
|
249
|
+
return !(s === "" || s === "false" || s === "0" || s === "no" || s === "off" || s === "null");
|
|
250
|
+
}
|
|
251
|
+
if (Array.isArray(v)) return v.length > 0;
|
|
252
|
+
if (typeof v === "object") return Object.keys(v as object).length > 0;
|
|
253
|
+
return Boolean(v);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function compare(a: unknown, op: string, b: unknown): boolean {
|
|
257
|
+
if (isNumeric(a) && isNumeric(b)) {
|
|
258
|
+
const x = Number(a);
|
|
259
|
+
const y = Number(b);
|
|
260
|
+
switch (op) {
|
|
261
|
+
case "==": return x === y;
|
|
262
|
+
case "!=": return x !== y;
|
|
263
|
+
case ">": return x > y;
|
|
264
|
+
case "<": return x < y;
|
|
265
|
+
case ">=": return x >= y;
|
|
266
|
+
case "<=": return x <= y;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
const sa = a === undefined || a === null ? "" : String(a);
|
|
270
|
+
const sb = b === undefined || b === null ? "" : String(b);
|
|
271
|
+
switch (op) {
|
|
272
|
+
case "==": return sa === sb;
|
|
273
|
+
case "!=": return sa !== sb;
|
|
274
|
+
case ">": return sa > sb;
|
|
275
|
+
case "<": return sa < sb;
|
|
276
|
+
case ">=": return sa >= sb;
|
|
277
|
+
case "<=": return sa <= sb;
|
|
278
|
+
}
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Recursive-descent parser/evaluator over the token stream. */
|
|
283
|
+
class CondParser {
|
|
284
|
+
private pos = 0;
|
|
285
|
+
private readonly toks: Tok[];
|
|
286
|
+
private readonly ctx: InterpolationContext;
|
|
287
|
+
constructor(toks: Tok[], ctx: InterpolationContext) {
|
|
288
|
+
this.toks = toks;
|
|
289
|
+
this.ctx = ctx;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
parse(): unknown {
|
|
293
|
+
const v = this.parseOr();
|
|
294
|
+
if (this.pos < this.toks.length) throw new Error("trailing tokens");
|
|
295
|
+
return v;
|
|
296
|
+
}
|
|
297
|
+
private peek(): Tok | undefined {
|
|
298
|
+
return this.toks[this.pos];
|
|
299
|
+
}
|
|
300
|
+
private eat(op: string): boolean {
|
|
301
|
+
const t = this.peek();
|
|
302
|
+
if (t && t.t === "op" && t.v === op) {
|
|
303
|
+
this.pos++;
|
|
304
|
+
return true;
|
|
305
|
+
}
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
private parseOr(): unknown {
|
|
309
|
+
let left = this.parseAnd();
|
|
310
|
+
while (this.eat("||")) {
|
|
311
|
+
const right = this.parseAnd();
|
|
312
|
+
left = truthy(left) || truthy(right);
|
|
313
|
+
}
|
|
314
|
+
return left;
|
|
315
|
+
}
|
|
316
|
+
private parseAnd(): unknown {
|
|
317
|
+
let left = this.parseNot();
|
|
318
|
+
while (this.eat("&&")) {
|
|
319
|
+
const right = this.parseNot();
|
|
320
|
+
left = truthy(left) && truthy(right);
|
|
321
|
+
}
|
|
322
|
+
return left;
|
|
323
|
+
}
|
|
324
|
+
private parseNot(): unknown {
|
|
325
|
+
if (this.eat("!")) return !truthy(this.parseNot());
|
|
326
|
+
return this.parseComparison();
|
|
327
|
+
}
|
|
328
|
+
private parseComparison(): unknown {
|
|
329
|
+
const left = this.parsePrimary();
|
|
330
|
+
const t = this.peek();
|
|
331
|
+
if (t && t.t === "op" && ["==", "!=", ">", "<", ">=", "<="].includes(t.v)) {
|
|
332
|
+
this.pos++;
|
|
333
|
+
const right = this.parsePrimary();
|
|
334
|
+
return compare(left, t.v, right);
|
|
335
|
+
}
|
|
336
|
+
return left;
|
|
337
|
+
}
|
|
338
|
+
private parsePrimary(): unknown {
|
|
339
|
+
if (this.eat("(")) {
|
|
340
|
+
const v = this.parseOr();
|
|
341
|
+
if (!this.eat(")")) throw new Error("missing )");
|
|
342
|
+
return v;
|
|
343
|
+
}
|
|
344
|
+
const t = this.peek();
|
|
345
|
+
if (!t) throw new Error("unexpected end");
|
|
346
|
+
this.pos++;
|
|
347
|
+
switch (t.t) {
|
|
348
|
+
case "ref": return resolvePath(t.v, this.ctx);
|
|
349
|
+
case "str": return t.v;
|
|
350
|
+
case "num": return t.v;
|
|
351
|
+
case "bool": return t.v;
|
|
352
|
+
case "null": return null;
|
|
353
|
+
default: throw new Error(`unexpected operator '${(t as { v: string }).v}'`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Evaluate a `when` expression to a boolean. Returns `{ value, error }`.
|
|
360
|
+
* Parse errors set `error` and fail OPEN (`value: true`) so a broken guard
|
|
361
|
+
* never silently drops a phase.
|
|
362
|
+
*/
|
|
363
|
+
export function tryEvaluateCondition(
|
|
364
|
+
expr: string,
|
|
365
|
+
ctx: InterpolationContext,
|
|
366
|
+
): { value: boolean; error?: string } {
|
|
367
|
+
const trimmed = (expr ?? "").trim();
|
|
368
|
+
if (!trimmed) return { value: true };
|
|
369
|
+
try {
|
|
370
|
+
const toks = tokenize(trimmed);
|
|
371
|
+
if (toks.length === 0) return { value: true };
|
|
372
|
+
const result = new CondParser(toks, ctx).parse();
|
|
373
|
+
return { value: truthy(result) };
|
|
374
|
+
} catch (e) {
|
|
375
|
+
return { value: true, error: e instanceof Error ? e.message : String(e) };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Boolean convenience wrapper over {@link tryEvaluateCondition}. */
|
|
380
|
+
export function evaluateCondition(expr: string, ctx: InterpolationContext): boolean {
|
|
381
|
+
return tryEvaluateCondition(expr, ctx).value;
|
|
382
|
+
}
|
package/extensions/render.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
10
|
-
import { formatTokens, type UsageStats } from "./
|
|
10
|
+
import { formatTokens, type UsageStats } from "./usage.ts";
|
|
11
11
|
import type { PhaseState, RunState } from "./store.ts";
|
|
12
12
|
import { dependenciesOf, type Phase, topoLayers } from "./schema.ts";
|
|
13
13
|
|
|
@@ -121,7 +121,7 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
121
121
|
return theme.fg("muted", `skipped · ${snip}`);
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
const isFanout = type === "map" || type === "parallel";
|
|
124
|
+
const isFanout = type === "map" || type === "parallel" || type === "flow";
|
|
125
125
|
|
|
126
126
|
if (ps.status === "failed") {
|
|
127
127
|
const e = (ps.error ?? "failed").replace(/\s+/g, " ");
|
|
@@ -171,6 +171,14 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
171
171
|
// single-agent done
|
|
172
172
|
const model = shortModel(ps.model);
|
|
173
173
|
const u = compactUsage(ps.usage, theme);
|
|
174
|
+
if (ps.approval) {
|
|
175
|
+
const d = ps.approval.decision;
|
|
176
|
+
const color = d === "reject" ? "error" : d === "edit" ? "warning" : "success";
|
|
177
|
+
let a = theme.fg(color as Parameters<typeof theme.fg>[0], theme.bold(d.toUpperCase()));
|
|
178
|
+
if (ps.approval.auto) a += theme.fg("dim", " auto");
|
|
179
|
+
if (time) a += ` ${time}`;
|
|
180
|
+
return a;
|
|
181
|
+
}
|
|
174
182
|
if (ps.gate) {
|
|
175
183
|
const badge =
|
|
176
184
|
ps.gate.verdict === "block" ? theme.fg("error", theme.bold("BLOCK")) : theme.fg("success", "PASS");
|
|
@@ -186,6 +194,7 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
186
194
|
let s = "";
|
|
187
195
|
if (model) s += theme.fg("accent", model);
|
|
188
196
|
if (u) s += (s ? " " : "") + u;
|
|
197
|
+
if (ps.attempts && ps.attempts > 1) s += theme.fg("warning", ` ↻${ps.attempts - 1}`);
|
|
189
198
|
if (time) s += ` ${time}`;
|
|
190
199
|
return s || theme.fg("dim", "done");
|
|
191
200
|
}
|
|
@@ -217,7 +226,9 @@ function headerLine(state: RunState, theme: Theme): string {
|
|
|
217
226
|
if (failed) line += theme.fg("error", ` · ${failed}✗`);
|
|
218
227
|
if (state.status === "blocked") line += theme.fg("error", " · blocked");
|
|
219
228
|
const cost = aggregateCost(state);
|
|
220
|
-
|
|
229
|
+
const budget = state.def.budget;
|
|
230
|
+
if (budget?.maxUSD !== undefined) line += theme.fg("muted", ` · $${cost.toFixed(3)}/$${budget.maxUSD}`);
|
|
231
|
+
else if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
|
|
221
232
|
const el = runElapsed(state);
|
|
222
233
|
if (el) line += theme.fg("dim", ` · ${elapsed(el)}`);
|
|
223
234
|
return line;
|