From e2faecce191751b63079a6a47d4ce47011baf44b Mon Sep 17 00:00:00 2001
From: heggria
Date: Wed, 8 Jul 2026 18:00:33 +0800
Subject: [PATCH 01/51] fix(release): bump codex/claude plugin.json manifests
to 0.1.7
The publish.yml version-consistency gate failed the v0.1.7 tag: the codex
.codex-plugin/plugin.json and claude .claude-plugin/plugin.json manifests were
still pinned at 0.1.6 (the release-prep commit bumped the .mcp.json npx pins
but missed these sibling manifests). The cross-adversarial review caught the
.mcp.json pins but not the plugin.json manifests. All versions now 0.1.7;
re-tagging.
---
packages/claude-taskflow/plugin/.claude-plugin/plugin.json | 2 +-
packages/codex-taskflow/plugin/.codex-plugin/plugin.json | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json
index ecdc756..c8474ef 100644
--- a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json
+++ b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "taskflow",
- "version": "0.1.6",
+ "version": "0.1.7",
"description": "Declarative, verifiable DAG orchestration for Claude Code subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.",
"author": {
"name": "heggria",
diff --git a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json
index b35a41e..38acf6d 100644
--- a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json
+++ b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json
@@ -1,6 +1,6 @@
{
"name": "taskflow",
- "version": "0.1.6",
+ "version": "0.1.7",
"description": "Declarative, verifiable DAG orchestration for Codex subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.",
"author": {
"name": "heggria",
From 9575d31d16ab9b3a22219be36b2a19214634815d Mon Sep 17 00:00:00 2001
From: heggria
Date: Tue, 7 Jul 2026 16:08:55 +0800
Subject: [PATCH 02/51] =?UTF-8?q?docs(0.2.0):=20add=20design=20foundation?=
=?UTF-8?q?=20=E2=80=94=20research,=20RFCs,=20demos,=20north=20star?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Lands the 0.2.0 design groundwork developed across this session:
Research:
- market-positioning-2026-07: full competitor recon (Claude Code Workflows,
Microsoft Conductor, LangGraph, Temporal, the 100+ orchestrator list).
Verdict: "声明式 DAG" is a red ocean (Conductor mirrors us), but
"compiled + resumable + incremental" has zero industrial competitors.
- 0.2.0-research-frontend-paradigms: 2026 frontend frontier (signals won
the war except React; TC39 standardizing signals = overstory's algorithm;
Svelte/Solid/Vue Vapor/Qwik/Mitosis mapped to taskflow).
- rfc-taskflow-vs-claude-code-workflows: head-to-head vs the direct rival.
RFCs (drafts, to be revised per multi-agent review):
- rfc-0.2.0-dsl-syntax: the TypeScript functional DSL spec w/ full
JSON↔DSL coverage table (§A). REVIEW found an identity crisis
(Solid-real-fn vs Svelte-compile-directive) — v2 will commit to
compile directives (runes erased at runtime).
- rfc-0.2.0-three-compile-routes: Solid / Svelte / Vue Vapor comparison.
- rfc-0.2.0-typescript-dsl: earlier Solid-route sketch.
Demos (illustrative .tf.ts — NOT runnable yet; some use post-0.2.0 features
like $store/$derived/flow.component marked in RFC §D):
- 0.2.0-demo-smart-migration.tf.ts
- 0.2.0-app-delivery-platform/ (10-file multi-flow app)
North star:
- 0.2.0-north-star: the synthesis — "compiled + resumable + incremental",
three core decisions, absorbed frontiers, honest boundaries, next steps.
These docs are the anchor for 0.2.0 implementation. RFCs are explicitly
drafts pending v2 (see commit notes on the identity-crisis resolution).
---
docs/0.2.0-north-star.md | 105 ++++
docs/0.2.0-research-frontend-paradigms.md | 192 +++++++
docs/market-positioning-2026-07.md | 156 ++++++
docs/rfc-0.2.0-dsl-syntax.md | 470 ++++++++++++++++++
docs/rfc-0.2.0-three-compile-routes.md | 158 ++++++
docs/rfc-0.2.0-typescript-dsl.md | 331 ++++++++++++
docs/rfc-taskflow-vs-claude-code-workflows.md | 273 ++++++++++
examples/0.2.0-app-delivery-platform/app.ts | 127 +++++
.../components/review-changes.ts | 52 ++
.../components/security-audit.ts | 29 ++
.../components/self-heal.ts | 39 ++
.../0.2.0-app-delivery-platform/config/app.ts | 42 ++
.../flows/implement.ts | 44 ++
.../0.2.0-app-delivery-platform/flows/plan.ts | 50 ++
.../0.2.0-app-delivery-platform/lib/utils.ts | 28 ++
.../stores/dashboard.ts | 43 ++
.../types/domain.ts | 63 +++
examples/0.2.0-demo-smart-migration.tf.ts | 196 ++++++++
18 files changed, 2398 insertions(+)
create mode 100644 docs/0.2.0-north-star.md
create mode 100644 docs/0.2.0-research-frontend-paradigms.md
create mode 100644 docs/market-positioning-2026-07.md
create mode 100644 docs/rfc-0.2.0-dsl-syntax.md
create mode 100644 docs/rfc-0.2.0-three-compile-routes.md
create mode 100644 docs/rfc-0.2.0-typescript-dsl.md
create mode 100644 docs/rfc-taskflow-vs-claude-code-workflows.md
create mode 100644 examples/0.2.0-app-delivery-platform/app.ts
create mode 100644 examples/0.2.0-app-delivery-platform/components/review-changes.ts
create mode 100644 examples/0.2.0-app-delivery-platform/components/security-audit.ts
create mode 100644 examples/0.2.0-app-delivery-platform/components/self-heal.ts
create mode 100644 examples/0.2.0-app-delivery-platform/config/app.ts
create mode 100644 examples/0.2.0-app-delivery-platform/flows/implement.ts
create mode 100644 examples/0.2.0-app-delivery-platform/flows/plan.ts
create mode 100644 examples/0.2.0-app-delivery-platform/lib/utils.ts
create mode 100644 examples/0.2.0-app-delivery-platform/stores/dashboard.ts
create mode 100644 examples/0.2.0-app-delivery-platform/types/domain.ts
create mode 100644 examples/0.2.0-demo-smart-migration.tf.ts
diff --git a/docs/0.2.0-north-star.md b/docs/0.2.0-north-star.md
new file mode 100644
index 0000000..a4bb030
--- /dev/null
+++ b/docs/0.2.0-north-star.md
@@ -0,0 +1,105 @@
+# taskflow 0.2.0 — 方向定调(North Star)
+
+> 综合 6 份调研 + 1 次多 agent review 的结论。2026-07-07。
+> 这是 0.2.0 的纲领;后续 RFC v2 / 实现都以此为锚。
+> 源文档:`market-positioning-2026-07` / `0.2.0-research-frontend-paradigms` /
+> `rfc-0.2.0-dsl-syntax` / `rfc-0.2.0-three-compile-routes` /
+> `rfc-taskflow-vs-claude-code-workflows` + review run `review-020-design`。
+
+---
+
+## 一句话
+
+**taskflow 0.2.0 = 把 agent 工作流做成"可编译、可恢复、增量重算"的响应式框架 —— agent orchestration 的第一个编译器。**
+
+从 "Make 时代"(运行时解释、重跑全部)走向 "Bazel/Svelte 时代"(编译产物、只重算受影响部分、跨边界恢复)。
+
+---
+
+## 一、市场定位:占 Conductor 没占的细分
+
+**现状(残酷但已验证):**
+- "声明式 DAG 编排引擎"是**红海** —— Microsoft Conductor(微软背书)几乎镜像了 taskflow 的每个核心主张(声明式 DAG / 零 token / validate / parallel / map / gate / loop / registry)。正面比参数会被分发碾压。
+- 但 **"编译 + 可恢复 + 增量重算"这个组合,工业零竞品** —— Conductor、Claude Code Workflows、LangGraph、Temporal 都没有:内容寻址缓存 + observed readSet + minimal recompute。
+
+**0.2.0 的占位:**
+> *"the only agent orchestrator that is **compiled** (not interpreted), **resumable** (not replayable), and **incremental** (only re-runs what changed)."*
+
+不和 Conductor 比"声明式 DAG",比它没有的三件事:**编译、可恢复、增量**。
+
+---
+
+## 二、技术路线(基于 review 的决策)
+
+### 决策 1:rune 是**编译指令**,不是真函数(Svelte 路线,运行时擦除)
+
+review 的 critic 用缺陷 #2 证明了:**"读 `.output` 自动建依赖"+ 真 function + Proxy 这条路在 JS 语义里物理上站不住**(toString/方法链/数值强转全是陷阱)。所以:
+
+- `agent()`/`map()`/`gate()` 等是**编译器识别的指令**,运行时被擦除/转换。
+- `taskflow build flow.tf.ts` 跑一个 **AST transform**(不只是 tsc)提取 DAG,产出 FlowIR。
+- **诚实放弃"可调试/可 REPL/可降级运行"** —— 换取编译期类型检查 + 自动依赖 + `json()` 推导**全部干净成立**。
+- 理由:0.2.0 的核心卖点是编译期能力,agent 写完本来就要 build,"不能脱离 build"对它不是损失。
+
+### 决策 2:FlowIR 是唯一中间表示,JSON ↔ DSL 双向编译
+
+- JSON 保持**一等公民**(现有 flow 零迁移;`build flow.json` 和 `build flow.tf.ts` 都产 FlowIR)。
+- decompiler 必须设计(review 指出当前 RFC 把"双向可编译"当 checkbox,没设计)。
+- 这同时白得 Vue Vapor 的"渐进共存"优势,不用维护双执行后端。
+
+### 决策 3:overstory 响应式内核是引擎(已完成 M1-M5,0.2.0 让它用户可感知)
+
+- FlowIR 编译(M1) + declared readSet(M2) + observed readSet@version(M3) + stale frontier(M4) + minimal recompute(M5) 已落地。
+- 0.2.0 把它包装成用户可感知的旗舰演示:**"周一全量审计 $6/8 agents → 周二改 1 文件 → 只重算 2 个节点 $0.40"**。
+
+---
+
+## 三、吸收的 2026 前沿思想(每一个都映射到 taskflow 能力)
+
+| 前沿思想 | 来源 | taskflow 对应 | 状态 |
+|---|---|---|---|
+| 编译优先(运行时→编译时) | Svelte/Solid/Vue Vapor | FlowIR 编译 + AST transform | 🟡 编译层有,DSL 编译待建 |
+| 细粒度响应式(signals) | TC39/Solid/Svelte5 | overstory observed readSet + stale + recompute | ✅ 已落地 |
+| rune 显式化 | Svelte 5 runes | DSL rune 函数(编译指令) | 🟡 待建 |
+| **resumable, not replayable** | **Qwik** | cross-session resume + detached runs + cache | ✅ 已落地(叙事待显性化) |
+| 增量重算(只重算受影响部分) | Bazel/Nix | content-addressed cache + why-stale + recompute | ✅ 已落地 |
+| 写一次编译多端 | Mitosis | 一份 DSL → pi/codex/claude/opencode 四 host | ✅ 已落地 |
+
+**关键洞察:这些思想不是"要追的热点",而是 taskflow 已经走在了前面 —— 学术界 2026-07 才出现"the first"agent 静态分析(AgentFlow),taskflow 早有 FlowIR+verify;TC39 signals 算法 = overstory 算法。0.2.0 是把这些"已经领先的能力"用一个 Svelte 风格 DSL 包装成用户可感知的产品。**
+
+---
+
+## 四、诚实的边界(review 的反直觉发现)
+
+1. **对 agent 作者,JSON 可能仍是默认 surface。** review 的 usability 结论:agent 写 JSON(结构天然合法、1 种调用约定、dependsOn 显式可审计)可能比写 TS-DSL(4 类错误、6+ rune 签名、依赖看不见)更顺。**TS-DSL 主要价值给人类开发者 + 复杂/大型 flow(50+ phase、多文件组合)**。0.2.0 不要假设 agent 会更喜欢 TS。
+2. **不序列化 phase 中途断点。** Qwik 序列化"执行位置"做 O(1) 恢复,taskflow 到 phase 粒度(LLM 调用是原子黑盒,无中途断点)。领域差异,非缺陷。
+3. **放弃"可调试/可降级"。** 决策 1 的代价。缓解:强大的 verify/compile/peek 工具链补偿。
+
+---
+
+## 五、0.2.0 不做什么(防止 scope 蔓延)
+
+- 不做 lifecycle hooks(taskflow 的 script + gate 已覆盖大半,优先级低)。
+- 不做"LLM 自动写 DSL"(auto-compose) —— host agent 本来就能写,不是新能力。
+- 不和 Conductor 比 visual builder / web dashboard(资源不允许,且非护城河)。
+- 不在 0.2.0 实现 `$store`/`$derived` 全局响应式(依赖 Shared Context Tree,推迟到 0.2.x)。
+
+---
+
+## 六、下一步(执行顺序)
+
+1. **RFC v2**(基于 review 修正当前 RFC):
+ - 改"身份危机":明确 rune 是编译指令,定义 `taskflow build` 的 AST transform。
+ - 补 decompiler 设计(双向编译不再是 checkbox)。
+ - 修 demo-RFC gap(demo 用的 $store/$derived/flow.component 标为 post-0.2.0 或移除)。
+ - 修 coverage gaps(approval.input / loop multi-phase body / {env.X} / args.type 等被 review 实证的问题)。
+2. **旗舰演示**(让 overstory 可感知):一个增量重跑的 benchmark flow + 可视化"省了多少"。
+3. **实现**:rune 类型定义 + AST transform 编译器 + decompiler(分阶段)。
+
+---
+
+## 附:叙事武器库(对外一句话)
+
+- vs Claude Code Workflows:*"Ours is compiled and resumable; theirs dies with the session."*
+- vs Microsoft Conductor:*"Ours is incremental — re-run only what changed. Theirs re-runs everything."*
+- vs LangGraph/Temporal:*"Ours verifies the DAG before a token. Theirs can't statically analyze imperative code."*
+- 总:*"The compiler for agent workflows — verify before spend, resume across sessions, re-run only what changed."*
diff --git a/docs/0.2.0-research-frontend-paradigms.md b/docs/0.2.0-research-frontend-paradigms.md
new file mode 100644
index 0000000..41a0a99
--- /dev/null
+++ b/docs/0.2.0-research-frontend-paradigms.md
@@ -0,0 +1,192 @@
+# 0.2.0 奠基调研:2026.7 前端框架最前沿思想 & DSL 对比 → taskflow DSL 设计
+
+> Research: 2026-07-07. Purpose: 为 taskflow 0.2.0 "蜕变成前端框架级的 agent 编排系统
+> (可能抛弃 JSON,转自定义 DSL)"提供设计地基。Sources: Svelte/Vue/Solid/React 官方
+> + TC39 proposal-signals + Mitosis + 多篇 2026 对比评测。Fetched via `explore-cli`.
+
+---
+
+## 0. 一句话结论
+
+**2026 前端的共识是 "Signals + 编译器"。** 而这套算法(State/Computed + glitch-free
+拓扑排序 + lazy cache + push-pull)**正是 overstory 已经落地的 stale-frontier +
+minimal-recompute**。taskflow 0.2.0 要做的,是把一个 **TC39 已标准化的响应式内核**,
+用一套 **Svelte-runes 风格的显式 DSL** 包起来 —— 让 agent 写复杂 workflow 像写 Svelte
+组件一样自然,且天然获得响应式增量重算。
+
+## 1. 2026.7 前端最前沿思想全景(7 个核心趋势)
+
+| # | 思想 | 代表 | 一句话 | 对 taskflow 的意义 |
+|---|---|---|---|---|
+| 1 | **细粒度响应式 (Signals)** | Solid, Vue3, Svelte5, Angular17, Preact | "数据变了,只更新真正依赖它的那一小块" | **= overstory 的 observed readSet + stale + recompute**(已落地 M3-M5) |
+| 2 | **Signals 进入语言标准 (TC39)** | TC39 proposal-signals | signals 从框架特性 → JS 语言原生 | **决定性印证**:overstory 算法 = TC39 标准 signals 算法 |
+| 3 | **编译优先,淘汰虚拟 DOM** | Svelte 5, SolidJS | 编译时生成精确更新代码,不 diff | **= FlowIR 编译**(已落地 M1)。taskflow 早就是"编译优先" |
+| 4 | **显式响应式 (Runes)** | Svelte 5 | 从隐式 `let` 魔法 → 显式 `$state/$derived/$effect` | **最直接的 DSL 设计参考**(见 §3) |
+| 5 | **编译器自动 memoize** | React 19 Compiler | 自动插 memo,免手写 useMemo | taskflow 的 cache 已是声明式的,可进一步自动推断 |
+| 6 | **写一次,编译多端** | Mitosis (BuilderIO) | 一份 JSX 组件 → React/Vue/Solid/Qwik/... | **= taskflow 的多 host**(pi/codex/claude/opencode),Mitosis 是 UI 版镜像 |
+| 7 | **Resumability / 可恢复** | Qwik | 服务端状态可直接在客户端恢复,零 hydration | **= taskflow 的 cross-session resume**(已落地) |
+
+### 趋势判断(2026 共识)
+- **"Signals Won the Framework War Except in React"** —— Solid/Vue/Angular/Svelte/Preact 全部收敛到细粒度响应式;React 用编译器追赶但架构上是 follower。
+- **TC39 正把 signals 标准化到 JS 语言层**(像当年 Promise 一样)—— 这意味着 signals 算法是**经过学术界+工业界双重验证的成熟范式**,不是时髦。
+
+---
+
+## 2. DSL / 语法设计对比(回答"DSL 的区别")
+
+### 2a. 四种响应式 DSL 范式
+
+**① Svelte 5 Runes —— 显式符号 + 函数语法**
+```svelte
+
+
+```
+- 设计哲学:**"make reactive logic explicit"** —— `runes` 是"影响编译器的符号",用函数语法(`$state(0)`)而非劫持语言关键字
+- 关键突破:**响应式超越组件边界** —— `$state/$derived` 能在普通 `.js/.ts` 里用,不再只在 `.svelte`
+- 为什么放弃隐式 `let`:**应用变复杂后,隐式响应式难追踪、难跨文件复用**
+
+**② SolidJS —— JSX + createSignal/createMemo/createEffect**
+```jsx
+function Counter() {
+ const [count, setCount] = createSignal(0);
+ const doubled = createMemo(() => count() * 2);
+ createEffect(() => console.log(count()));
+ return ;
+}
+```
+- 编译时把 JSX 编译成**精确的 DOM 更新**(无 VDOM),`createMemo`/`createEffect` 自动追踪依赖
+- 性能基准略胜 Svelte(细粒度 signal graph)
+
+**③ TC39 标准 Signals —— State + Computed**
+```js
+const counter = new Signal.State(0);
+const isEven = new Signal.Computed(() => (counter.get() & 1) == 0);
+const parity = new Signal.Computed(() => isEven.get() ? "even" : "odd");
+```
+- **两个原语**:`Signal.State`(可写)+ `Signal.Computed`(派生,lazy + cached)
+- 算法保证:**glitch-free**(拓扑排序消除重复)+ **cached**(依赖没变不重算)+ **push-pull**(解 Diamond Problem)
+
+**④ Vue 3 —— ref/reactive/computed + Composition API**
+```vue
+
+```
+
+### 2b. 范式光谱(关键区别一张表)
+
+| 范式 | 响应式触发 | 依赖追踪 | 编译 | 代表 |
+|---|---|---|---|---|
+| **隐式编译魔法** | 编译器猜(`let` 赋值) | 编译时静态 | 重 | Svelte 3/4 |
+| **显式 rune 符号** | 函数调用(`$state`) | 运行时自动 | 中 | **Svelte 5** |
+| **显式 signal API** | 函数调用(`createSignal`) | 运行时自动 | 轻 | **Solid / Vue 3** |
+| **语言原生** | `new Signal.State` | 运行时自动 | 无 | **TC39 (未来)** |
+| **VDOM + 编译器** | diff + 自动 memo | 编译器插桩 | 重 | React 19 |
+
+> **趋势方向:隐式 → 显式 → 原生。** Svelte 3(隐式)→ Svelte 5(显式 runes)→ TC39(语言原生)。
+> 复杂度上升时,**显式**胜出(可追踪、可跨文件、可类型化)。
+
+---
+
+## 3. 这些思想 → taskflow 0.2.0 DSL 设计的映射
+
+### 3a. 为什么抛弃 JSON 是对的(且方向正确)
+
+- JSON 是**数据**,不是**程序**。复杂 workflow(条件、循环、派生、复用)用 JSON 表达,
+ 要么堆嵌套要么靠字符串模板(`"{steps.X.output}"`),**对 agent 不友好、对静态分析不友好**。
+- 前端的教训(Svelte 3→5)正好相反:它们是**从隐式 DSL 往显式 DSL 走**;taskflow 现在是
+ **从纯数据(JSON)往有结构的 DSL 走** —— 同一个方向(让逻辑更显式、更好写、更好分析)。
+
+### 3b. 最该借鉴的 3 个思想
+
+**🥇 借鉴 Svelte 5 Runes:一组显式"rune"符号**
+- taskflow DSL 可以定义一组编译器识别的关键字/函数,让编排原语**显式**:
+ - `agent("...")`, `parallel([...])`, `map(items, i => ...)`, `gate(...)`, `loop(...)`,
+ `tournament(...)`, `flow("name")`
+ - 派生/状态:`$read("stepX")`(读上游,自动追踪依赖 → overstory observed readSet),
+ `$emit(value)`(写本 phase 输出)
+- **关键收益**:agent 写起来像写程序(不是写 JSON),且编译器能静态分析 + 运行时自动追踪依赖。
+
+**🥈 借鉴 TC39/Solid:State/Computed 双原语 + 自动依赖追踪**
+- taskflow 的 phase 天然就是 `Computed`(派生、lazy、cached)。0.2.0 可以让**依赖追踪完全自动**:
+ - 现在 taskflow 靠 `{steps.X.output}` 字符串声明依赖;新 DSL 里,`read(stepX)` 调用即依赖(运行时 onRead hook 自动建图)—— **正是 overstory M3 的 observed readSet,但语法从"字符串模板"升级为"函数调用"**。
+ - 算法直接复用 TC39 验证过的:glitch-free 拓扑排序 + lazy cache + early cutoff(overstory 已有)。
+
+**🥉 借鉴 Mitosis:一份 DSL,编译到多 host**
+- taskflow 已有 pi/codex/claude/opencode 四 host。新 DSL 可以是**单一源**,编译到各 host 的运行时(就像 Mitosis 编译到 React/Vue/Solid)。
+- FlowIR 已经是这个"中间表示",0.2.0 让它成为真正的**多目标编译枢纽**。
+
+### 3c. 一个示意:taskflow 0.2.0 DSL 可能长什么样(受 Svelte runes + Solid 启发)
+
+```taskflow
+// 不再是 JSON。一组显式 rune,响应式自动追踪。
+flow "audit-routes" {
+ args { dir = "src/routes" }
+
+ // $read 自动追踪依赖 → overstory observed readSet
+ const files = agent("list .ts files under " + args.dir,
+ $schema { files: string[] })
+
+ // map 扇出,每个 item 一个 agent,自动并发
+ const audits = map(files, f =>
+ agent("audit " + f + " for missing auth", { label: f }))
+
+ // gate 是 Computed:依赖 audits,自动重算
+ const verified = gate(audits, "adversarially verify each finding")
+
+ // tournament: 多角度 + 裁判
+ const ranked = tournament(verified, { judge: "rank findings", mode: "best" })
+
+ return ranked
+}
+```
+对比现在的 JSON:
+```json
+{
+ "name": "audit-routes",
+ "phases": [
+ { "id": "files", "agent": "scout", "task": "list .ts files under {args.dir}",
+ "output": "json", "expect": { "type": "object", "properties": { "files": {"type":"array","items":{"type":"string"}} }}},
+ { "id": "audits", "type": "map", "over": "{steps.files.json.files}", "as": "f",
+ "task": "audit {item} for missing auth", "label": "{item}" },
+ ...
+ ]
+}
+```
+**DSL 版的优势:agent 写起来是"写程序",不是"填表";依赖靠 `read()` 调用隐式建立,不用手写字符串模板。**
+
+---
+
+## 4. 关键风险 & 必须想清楚的设计决策(给 0.2.0 的 open questions)
+
+1. **DSL 宿主语言选什么?** —— 自创语法(要写解析器/语法高亮/LSP) vs 复用宿主语言
+ (TypeScript/Python 函数式 DSL,像 Solid 那样"合法的 JS")。
+ - **强烈建议复用宿主语言**(TS 函数式 DSL):零解析器成本、agent 已会写、有类型、IDE 原生支持。
+ - 这正是 Solid 的路线("合法的 JS + 编译器"),而非 Svelte 的"自创 .svelte 语法"。
+
+2. **静态分析能力不能丢。** —— 现在 JSON DSL 的优势是 FlowIR 能静态 verify。
+ 新 DSL 必须保留"编译到 FlowIR + 零 token verify"的能力(否则丢了 taskflow 最硬的护城河)。
+
+3. **向后兼容。** —— JSON DSL 已发布、有用户、有 saved flow。0.2.0 应让 **JSON 和新 DSL 双向可编译**(JSON → FlowIR → DSL;DSL → FlowIR → JSON),像 Svelte 3/4 → 5 的渐进迁移。
+
+4. **"响应式"在 agent 域的边界。** —— 前端 signal 是同步、廉价;LLM 调用是异步、昂贵。
+ overstory 的"cost-asymmetric reactivity"(mark 便宜、recompute 贵)是正确抽象,新 DSL 要
+ **让这个不对称可见**(比如 `gate`/`loop` 是 expensive effect,默认不自动重跑)。
+
+---
+
+## 5. 0.2.0 的北极星叙事
+
+> **"taskflow 0.2.0: Svelte for agent workflows. Write flows like components;
+> the compiler tracks dependencies; only what changed re-runs."**
+
+把 2026 前端最前沿(Signals + 编译器 + 显式 DSL)已经验证的范式,**第一个**完整搬到 agent
+编排领域 —— 用 TC39 标准的响应式算法(overstory 内核),套一层 Svelte-runes 风格的显式 DSL,
+让 agent 写复杂 workflow 像写 Solid 组件。**没有任何竞品在做这件事**(见
+`docs/market-positioning-2026-07.md` + overstory 调研)。
diff --git a/docs/market-positioning-2026-07.md b/docs/market-positioning-2026-07.md
new file mode 100644
index 0000000..609e774
--- /dev/null
+++ b/docs/market-positioning-2026-07.md
@@ -0,0 +1,156 @@
+# 全网竞品调研:taskflow 在 2026-07 的真实市场位置
+
+> Research: 2026-07-07. Sources: Claude Code docs, Microsoft Conductor blog/repo,
+> awesome-agent-orchestrators list (100+ projects), Augment Code's "9 Open-Source
+> Agent Orchestrators", arxiv (MCP Workflow Engine), LangGraph/CrewAI comparisons.
+> Fetched via `explore-cli`. Honest positioning — not marketing.
+
+---
+
+## 0. 一句话定位
+
+**taskflow 处在一个极度拥挤的红海。** 它所在的细分赛道("声明式 DAG 编排引擎")
+在 2026 年已经有 **Microsoft Conductor** —— 一个微软官方、MIT、能力几乎完全覆盖
+taskflow 核心主张的竞品。**taskflow 的技术不输第一梯队,但没有微软的分发与背书,
+这是一个"技术并列、市场危险"的位置。**
+
+## 1. 赛道其实是三层 —— 大多数"竞品"和 taskflow 不在一个层
+
+`awesome-agent-orchestrators` 列了 100+ 项目,但仔细分,它们分属三个完全不同的层:
+
+### 第 1 层:会话并行器 / Session Runners(列表 80%+)
+代表:crystal, claude-squad, agentbox, nimbalyst, vibe-kanban, constellagent,
+thurbox, dmux, parallel-code, Agent Command Center…
+- 核心:**在 git worktree 里并行跑多个 coding agent CLI 会话**(Claude Code / Codex / Gemini)
+- 编排对象 = **进程/会话**,不是任务图
+- **没有声明式 DAG、没有 phase 类型、没有 gate/tournament/loop**
+- 是 TUI/dashboard 层,不是编排引擎层
+- **→ 不是 taskflow 的直接竞品**(但抢同一批用户的注意力)
+
+### 第 2 层:声明式 DAG 编排引擎(taskflow 真正的同类)
+这是 taskflow 的战场,目前公开的玩家只有少数几个:
+
+| 项目 | 背书 | 格式 | 核心主张 | taskflow 对比 |
+|---|---|---|---|---|
+| **Microsoft Conductor** | **微软官方** | YAML | 声明式 DAG、零token编排、`validate`、parallel/map/script/human-gate/loop、context tier、web dashboard、registry | **几乎完全覆盖 taskflow 主张**;多 Copilot 支持 |
+| **bernstein** | 个人 | config | "Deterministic orchestrator, parallel agents, verifies with tests, **Zero LLM tokens on coordination**" | 同样主打零token + 确定性 |
+| **tutti** | 个人 | config | "config-driven workflows, typed artifact flow between agents" | 类型化产物流 |
+| **skillfold** | 个人 | YAML | "Configuration language + compiler for multi-agent pipelines, compiles YAML into agent skills" | 编译器视角 |
+| **MCP Workflow Engine** | 论文 | JSON | "declarative workflow blueprint — JSON directed sequence of MCP tool calls" | MCP-native |
+
+### 第 3 层:Swarms / 自治循环
+代表:loki-mode(41 agents/8 swarms/9 quality gates), claude-flow, orc, guild, Hephaestus…
+- 多 agent 协作框架,偏向**动态/自治**,常带 ralph-loop(跑到完成)
+- 与 taskflow 部分重叠,但理念不同(动态 vs 声明式)
+
+---
+
+## 2. 直接威胁:Microsoft Conductor —— taskflow 的"镜像"
+
+Conductor(2026-05 微软开源)和 taskflow 的主张**几乎一一对应**:
+
+| taskflow 核心主张 | Microsoft Conductor |
+|---|---|
+| 声明式 DAG(JSON DSL) | ✅ 声明式 DAG(**YAML**) |
+| 编排层零 token | ✅ "routing layer consumes zero tokens"(Jinja2 + expr eval) |
+| 运行前验证(`verify`/`compile`) | ✅ `conductor validate` + dry-run |
+| 上下文隔离 + 显式流 | ✅ 隔离 + **三种 context tier**(accumulate/last_only/explicit) |
+| parallel + 动态扇出(`map`) | ✅ static parallel groups + `for each`(动态数组,批量并发) |
+| `script` phase(零token shell) | ✅ script steps(shell,捕获 stdout/stderr/exit) |
+| `approval`(人机交互) | ✅ human gates(Rich TUI **+ web dashboard**) |
+| `loop`(循环/收敛) | ✅ loop-back / evaluator-optimizer(数百次) |
+| flow library / search | ✅ workflow registries(团队共享 + version) |
+| safety(budget / loop cap) | ✅ max iterations + wall-clock timeout |
+| 多 provider/model | ✅ **Copilot + Claude**(per-agent) |
+| 让 coding agent 帮写 flow | ✅ ships a Claude skill |
+| **背书 / 分发** | ✅ **微软、MIT、microsoft/conductor** |
+| **Web 可视化** | ✅ **实时 DAG dashboard**(点节点看 prompt/token/cost) |
+
+> Conductor 在 taskflow 的**每一个核心主张**上都有对等能力,且多了:
+> **微软背书、web dashboard、context tier、Copilot 集成**。
+
+---
+
+## 3. taskflow 在第 2 层里**仍然独有**的东西(真正的护城河)
+
+把 Conductor/bernstein/tutti/skillfold 摆一起,taskflow **真正只有别人没有的**:
+
+### 🥇 跨运行内容寻址缓存 + 增量重算(最硬的护城河)
+- `cache` + `why-stale` + `recompute`:git/glob/file/env 指纹 → 内容寻址缓存 + TTL
+- **Conductor / bernstein / tutti / skillfold 文档均未提及任何缓存机制**
+- 这是 taskflow 唯一一个"别人完全没有、且工程上很难抄"的能力
+- 价值:重跑类似任务时跳过未变 phase —— 长 audit / 迁移 / 研究场景的成本碾压
+
+### 🥈 tournament(best-of-N + judge)
+- 多个竞品变体 + 裁判选最优/聚合
+- 其他第 2 层项目都是 agent/parallel/script/gate,**没有 first-class 的 tournament**
+- 价值:需要"多角度起草 + 选优"的场景(规划、文案、方案对比)
+
+### 🥉 嵌入 4 个 host 作为原生插件
+- pi / codex / claude / opencode —— taskflow 编排的是**真正能读写文件、跑命令的 coding subagent**
+- Conductor 是**独立 CLI 调 API agent**(文件操作要靠 MCP)
+- 但这个差异在缩小(Conductor 也接 MCP + script steps)
+
+### phase 类型更丰富
+- taskflow 10 种(agent/parallel/map/gate/reduce/approval/flow/loop/tournament/script)
+- Conductor ≈ agent/parallel/script/human-gate(+ routes)
+- 但 Conductor 的 `routes`(Jinja2 条件)很灵活,能模拟不少
+
+> **诚实判断:护城河真实存在,但很窄。** 缓存是最硬的一块;tournament/多host是加分项。
+> 如果 taskflow 不把"缓存 + 增量重算"做成**明显的、用户可感知的优势**,会被 Conductor 的分发碾压。
+
+---
+
+## 4. 定位结论
+
+```
+ 分发/背书/可见性
+ ▲
+ Conductor ● │
+ │ ← 微软在这,taskflow 远在下方
+ │
+ taskflow ● │
+ │
+ bernstein ● tutti ● │
+ │
+ ───────────────────────────┼─────────────────────▶ 编排引擎深度
+ │
+ │ taskflow ●──── 接近顶端(10 phase + 缓存)
+ │ Conductor ●─── 也很高
+ │
+```
+
+- **引擎深度**:taskflow ≈ 第一梯队(和 Conductor 并列,phase 类型更丰富,缓存独有)
+- **分发/背书**:taskflow **远远落后**于微软 Conductor
+- **赛道拥挤度**:**红海**(100+ 项目,第 2 层已有 5 个声明式 DAG 引擎)
+
+**一句话:taskflow 是一个"技术领先、但面临微软正面对标 + 赛道极度拥挤"的项目。**
+
+---
+
+## 5. 这对 0.1.7 / 未来方向意味着什么
+
+三条可能的路(按激进程度):
+
+### 路线 A:死守护城河 —— 把"缓存 + 增量重算"做成杀手锏
+- 0.1.7 把 cache/why-stale/recompute 做成**一等公民 UX**:一个命令看清"什么变了、
+ 什么能复用、省了多少 token/钱",甚至和 Conductor 做个公开 benchmark。
+- 定位语:"Conductor 重新跑一遍要全花 token;taskflow 只重算变的部分。"
+- 风险:护城河窄,微软哪天加个缓存就没了。
+
+### 路线 B:换战场 —— 不和 Conductor 比"引擎",比"嵌入 coding agent 的体验"
+- Conductor 是独立 CLI;taskflow 是 pi/codex/claude/opencode 里的**原生编排**。
+- 0.1.7 投入"在 host 里用起来最顺"(更好的 TUI、`/tf:` 命令、和 host skill 融合、
+ 内置 deep-research/audit flow 让用户装完即用)。
+- 定位语:"不是又一个 CLI,是你现在的 coding agent 长出来的编排能力。"
+- 风险:第 1 层(session runners)也在抢这个位置。
+
+### 路线 C:差异化品类 —— 走"可验证 + 可缓存的工作流"这个 Conductor 没占的细分
+- taskflow 有 `verify`(零token静态分析)和 `cache`。把它俩绑成一个**新品类主张**:
+ "the only orchestrator that **verifies before it spends, and remembers what it spent**"。
+- 这避开了和 Conductor 在"声明式 DAG"上的正面参数对比,占一个心智位置。
+- 这需要产品叙事 + 一个能演示这个差异的杀手级 flow(比如增量重跑 500 文件迁移)。
+
+> **我的建议:路线 C 为主,B 为辅。** A 单独撑不住(护城河太窄),B 是执行层面,
+> C 是唯一能建立心智壁垒的方向。0.1.7 的 feature 应该围绕"可验证 + 可缓存"讲故事
+> 并配一个让用户**立刻感知到差异**的内置 flow。
diff --git a/docs/rfc-0.2.0-dsl-syntax.md b/docs/rfc-0.2.0-dsl-syntax.md
new file mode 100644
index 0000000..51e3aa9
--- /dev/null
+++ b/docs/rfc-0.2.0-dsl-syntax.md
@@ -0,0 +1,470 @@
+# RFC: taskflow 0.2.0 TypeScript 函数式 DSL —— 语法规范
+
+> Status: **Draft** · 2026-07-07
+> 路线决策:Solid 路线(rune 是真函数,可调试;见 `docs/rfc-0.2.0-three-compile-routes.md`)
+> **三个硬约束:**
+> 1. **100% 功能覆盖** —— taskflow 当前的每一个字段/能力都有对应 DSL 语法(本文 §A 完整对照表)。
+> 2. **向前兼容** —— 现有 JSON flow 零修改继续工作;JSON ↔ DSL 双向可编译(§6)。
+> 3. **向前拓展** —— 加新 phase 类型/字段不需要改语法(§7)。
+
+---
+
+## 0. 设计契约
+
+- **`.tf.ts` 是合法 TypeScript。** `tsc` 能过 = 类型正确。`taskflow build` 把它编译成 FlowIR(就像 Solid 把 JSX 编译成 DOM 更新)。
+- **rune 是真函数**,运行时返回一个 `Phase` 对象(可 `.output` 读取,自动建立依赖)。离开编译器也能降级运行(解释执行)。
+- **依赖靠"读取"自动建立** —— 读 `x.output` = 依赖 `x`。零手写 `dependsOn`(但保留 `dependsOn` 字段作为显式补充,§5.4)。
+- **JSON 仍是一等公民** —— `build flow.json` 和 `build flow.tf.ts` 都产出同一个 FlowIR。
+
+---
+
+## 1. 文件骨架
+
+```ts
+import { flow, agent, map, parallel, gate, reduce, loop, tournament, approval, flow as subflow, script, json, type Phase } from "taskflow";
+
+export default flow("audit-endpoints", (ctx) => {
+ // ctx: { args, budget, scope, strict, share, incremental, concurrency }
+ // —— ctx 上的方法对应 Taskflow 顶层字段(见 §2)
+ // ... phases 用 rune 声明 ...
+ return finalPhase; // return 标记 final phase(等价 final:true)
+});
+```
+
+- 文件 `export default flow(name, fn)` = 一个 taskflow(等价 JSON 的顶层对象)。
+- `name` 必填;其余顶层字段通过 `ctx` 的方法设置。
+
+---
+
+## 2. 顶层字段(TaskflowSchema)—— 全覆盖
+
+| JSON 字段 | DSL 写法 | 说明 |
+|---|---|---|
+| `name` | `flow("audit", ...)` 第 1 参数 | 必填 |
+| `description` | `flow("audit", { description: "..." }, ...)` 第 2 参数对象 | 可选 |
+| `version` | 同上 options 对象 `version: 2` | 可选,默认 1 |
+| `args` | `ctx.args.declare({ dir: { default: "src", type: "string", required: true } })` | §3 |
+| `concurrency` | `ctx.concurrency(8)` | 默认 8 |
+| `budget` | `ctx.budget({ maxUSD: 3, maxTokens: 1e6 })` | §4.1 |
+| `agentScope` | `ctx.scope("both")` | "user"\|"project"\|"both" |
+| `strictInterpolation` | `ctx.strict()` | 布尔,默认 false |
+| `contextSharing` | `ctx.share(true)` | 全局 shareContext |
+| `incremental` | `ctx.incremental(true)` | 全局 cross-run 缓存 |
+| `phases` | rune 声明自动构成 | §3 |
+
+**示例:**
+```ts
+export default flow("audit", { description: "Audit all endpoints", version: 2 }, (ctx) => {
+ ctx.concurrency(8);
+ ctx.budget({ maxUSD: 3.0 });
+ ctx.scope("both");
+ ctx.incremental(true); // 全 flow 跨运行缓存
+ ctx.args.declare({ dir: { default: "src/routes", type: "string" } });
+ // ...
+});
+```
+
+---
+
+## 3. 通用 Phase 字段(所有 phase 类型共有)—— 全覆盖
+
+每个 rune(`agent`/`map`/...)都接受一个 **options 对象**,承载所有通用字段:
+
+```ts
+agent("task string", {
+ // —— 身份与执行 ——
+ agent: "scout", // 用哪个 agent
+ id: "discover", // 可选显式 id(默认自动生成)
+ model: "fast", thinking: "high", // 模型/思考级别覆盖
+ tools: ["read", "grep"], // 工具白名单
+ cwd: "worktree", // 工作目录:路径或 temp/dedicated/worktree
+
+ // —— 输出 ——
+ output: "json", // "text"(默认) | "json"
+ expect: { type: "array", items: { type: "string" } }, // JSON 契约(需 output:json)
+ final: true, // 标记为 flow 结果(或用 return)
+
+ // —— 控制流 ——
+ when: "{env.CI == '1'}", // 条件守卫(字符串 eval 或 TS 谓词,§5.1)
+ join: "any", // "all"(默认) | "any"
+ dependsOn: ["plan"], // 显式补充依赖(通常自动,§5.4)
+ retry: { max: 3, backoffMs: 500, factor: 2 },
+ timeout: 60_000, // 每 subagent 调用的 ms 上限
+
+ // —— 可靠性 ——
+ optional: true, // 失败不阻断
+ idempotent: false, // 有副作用,不自动重试/不缓存
+ concurrency: 4, // map/parallel 并发覆盖
+
+ // —— 上下文 ——
+ context: ["src/api.ts", "{steps.plan.json}"], // 预读注入
+ contextLimit: 8000, // 每文件最大字符
+ shareContext: true, // 开 Shared Context Tree
+
+ // —— 缓存 ——
+ cache: { scope: "cross-run", ttl: "7d", fingerprint: ["git:HEAD", "glob:src/**"] },
+});
+```
+
+> **对照表(§A)列出每一个字段的 JSON↔DSL 映射。** 这里先给直觉,细节在 §A。
+
+---
+
+## 4. 10 种 Phase 类型的专属语法—— 全覆盖
+
+### 4.1 `agent` —— 单个 subagent
+```ts
+const d = agent("List files under {args.dir}", {
+ agent: "scout", output: json<{route:string;file:string}[]>(), expect: {...}, retry: {max:2},
+});
+```
+> `json()` 是 `output:"json"` + `expect` 的类型推导糖(TS 泛型 → TypeBox schema)。
+
+### 4.2 `map` —— 动态扇出
+```ts
+const audits = map(discover, (item) => // over = discover;as 默认 item
+ agent(`Audit ${item.route} in ${item.file}`, { agent: "analyst", concurrency: 4 })
+);
+// 等价 JSON: { type:"map", over:"{steps.discover.json}", as:"item", task:"Audit {item.route}..." }
+```
+- `over` = 第 1 参数(一个 Phase);`as` = 回调形参名(默认 `item`)。
+- 回调体内用 `item.route` —— **编译期类型检查**(对比 JSON 的 `{item.rout}` 拼错运行时才发现)。
+
+### 4.3 `parallel` —— 静态并发分支
+```ts
+const [a, b, c] = parallel([
+ agent("audit auth", { agent: "analyst" }),
+ agent("audit perf", { agent: "analyst" }),
+ agent("audit validation", { agent: "analyst" }),
+], { concurrency: 3 });
+// 等价 JSON: { type:"parallel", branches:[{task,agent},...] }
+```
+- `branches` = 数组参数;每项是 `agent()` 调用或 `{ task, agent }` 对象(二选一,兼容)。
+
+### 4.4 `gate` —— 质量门(三种形态全覆盖)
+```ts
+// (a) LLM gate —— task 是判断 prompt
+const g = gate(findings, { agent: "reviewer", onBlock: "retry" },
+ (input) => `Cross-check. VERDICT: PASS/BLOCK.\n${input.output}`);
+
+// (b) eval gate —— 零 token 机器门
+const auto = gate.automated(build, {
+ pass: ["{steps.build.output} contains 'BUILD SUCCESS'", "{steps.test.json.failures} == 0"],
+});
+
+// (c) score gate —— 确定性打分 + LLM judge fallback
+const scored = gate.scored(gen, {
+ target: "{steps.gen.output}",
+ scorers: [{ type: "exact-match", value: "PASS" }, { type: "length-range", min: 10 }],
+ combine: "weighted", weights: [1, 1], threshold: 0.8,
+ judge: { agent: "reviewer", task: "decide if borderline passes" },
+});
+```
+> 三个 rune(`gate`/`gate.automated`/`gate.scored`)分别对应 JSON 的 `gate`+task / `eval` / `score`。`onBlock` 通用。
+
+### 4.5 `reduce` —— 聚合 N→1
+```ts
+const summary = reduce([auth, perf, validation], (parts) =>
+ agent(`Merge:\nauth:${parts.auth.output}\nperf:${parts.perf.output}`, { agent: "doc-writer" })
+);
+// 等价 JSON: { type:"reduce", from:["auth","perf","validation"], task:"..." }
+```
+- `from` = 第 1 参数(Phase 数组);回调参数是命名 map(按数组顺序或显式 key)。
+
+### 4.6 `loop` —— 循环到条件/收敛/上限
+```ts
+const fixed = loop({
+ until: "{steps.test.exit === 0}",
+ maxIterations: 5,
+ convergence: true, // 默认 true:输出不变则停
+ reflexion: true, // 给下一轮注入 {reflexion} 反思
+ body: (prev) => ({
+ test: script("npx tsc --noEmit"),
+ fix: agent(`Fix:\n${prev.test.output}`, { when: "{steps.test.exit !== 0}" }),
+ }),
+});
+// 等价 JSON: { type:"loop", until, maxIterations, convergence, reflexion, ... }
+```
+
+### 4.7 `tournament` —— best-of-N + judge
+```ts
+const best = tournament({
+ mode: "best", // "best" | "aggregate"
+ judge: "Judge on correctness. WINNER: .",
+ judgeAgent: "final-arbiter",
+ branches: [ // 显式分支(或用 variants:N 从 task 生成)
+ agent("conservative fix", { agent: "analyst" }),
+ agent("optimal correctness", { agent: "analyst" }),
+ ],
+ // 或者:variants: 3, task: "design a fix"(从单 task 派生 N 个)
+});
+```
+
+### 4.8 `approval` —— 人机交互
+```ts
+const ok = approval({
+ request: "Approve this plan?",
+ input: plan.output, // 传给审批者的内容
+});
+// 等价 JSON: { type:"approval", task:"...", input:"{steps.plan.output}" }
+```
+
+### 4.9 `flow` (子流程) —— use / def / with 全覆盖
+```ts
+// (a) use —— 调用已保存的 taskflow
+const r = subflow("deep-research", { question: "..." }); // with = 第2参数
+
+// (b) def —— 内联子流程(动态)
+const r2 = subflow.def(() => { // 运行时解析
+ return agent("dynamic sub-flow based on runtime data");
+}, { with: { x: 1 } });
+```
+
+### 4.10 `script` —— 零 token shell
+```ts
+const lint = script("npx eslint src/ --format json", { cwd: "dedicated" });
+// lint.exit / lint.stdout / lint.stderr
+
+const safe = script(["grep", "-r", args.dir, "--files-with-matches"]); // 数组 = execvp(防注入)
+script("cat", { input: "{steps.gen.output}" }); // stdin 管道
+```
+
+---
+
+## 5. 控制流与表达式—— 全覆盖
+
+### 5.1 `when` —— 条件守卫(两种形态)
+```ts
+agent("...", { when: "{env.MODE} == 'prod'" }); // (a) 字符串 eval(兼容现有)
+agent("...", { when: ({ env }) => env.MODE === "prod" }); // (b) TS 谓词(新;编译成 eval)
+```
+> (b) 编译器把谓词体限定在纯表达式子集内,编译成等价 eval 串,保留**零 token 静态分析**。
+
+### 5.2 插值占位符(全部保留 + 新增类型安全形态)
+| 占位符 | JSON | DSL |
+|---|---|---|
+| `{args.X}` | ✅ | `args.X`(args 是响应式对象) |
+| `{steps.ID.output}` | ✅ | `id.output`(读 Phase 对象) |
+| `{steps.ID.json}` / `.field` | ✅ | `id.json` / `id.json.field` |
+| `{item}` / `{item.field}` | ✅ | `item` / `item.field`(map 回调形参) |
+| `{previous.output}` | ✅ | chain 内 `previous.output` |
+| `{reflexion}` | ✅ | loop body 内自动可用 |
+
+> **字符串模板仍可用**:`agent(\`Audit ${item.route}\`)` —— 编译器把 `${item.route}` 转成 `{item.route}` 占位符(语义不变,运行时同样插值)。**两种形态等价,保留字符串模板兼容旧习惯。**
+
+### 5.3 `join`(依赖汇聚)
+```ts
+parallel([...], { join: "any" }); // OR-join
+agent("...", { join: "all" }); // 默认 AND-join
+```
+
+### 5.4 `dependsOn`(显式补充)
+通常不需要(读取即依赖)。但**保留**用于:
+- 引用没有赋值的 phase(如 JSON 迁移来的隐式依赖)
+- 声明"读取没体现,但逻辑上依赖"的关系(如 gate 的 onBlock:retry 要重跑上游)
+
+```ts
+agent("...", { dependsOn: ["plan", "config"] });
+```
+
+---
+
+## 6. 兼容性策略(向前兼容的三个保证)
+
+### 6.1 JSON flow 零修改继续工作
+- `taskflow run flow.json` / `action=run name=...` / `/tf:name` 全部不变。
+- 运行时、缓存、resume、verify —— 全部对 JSON 和 DSL 透明(都走 FlowIR)。
+
+### 6.2 JSON ↔ DSL 双向可编译
+```
+flow.tf.ts ──build──▶ FlowIR ◀──load── flow.json
+ ▲ │
+ └─────────────decompile────────────────────┘
+```
+- `taskflow build flow.tf.ts → flow.json`(发布产物,兼容老 host)
+- `taskflow decompile flow.json → flow.tf.ts`(老项目渐进迁移)
+- **FlowIR 是唯一的中间表示**,两种语法都是它的 surface syntax。
+
+### 6.3 版本协商(`version` 字段)
+- 顶层 `version: 2`(DSL 默认)和 `version: 1`(老 JSON)都合法。
+- 编译器按 version 选语法解析;运行时不关心来源。
+
+---
+
+## 7. 拓展性(向前拓展的两个机制)
+
+### 7.1 加新 phase 类型 —— 不改语法
+新 phase 类型 = 新增一个 rune 函数,不破坏现有:
+```ts
+// 未来:新增 saga phase(带补偿)
+import { saga } from "taskflow/experimental";
+const r = saga({ compensate: "...", ... });
+```
+rune 是普通函数,**新能力 = 新 export**,老 flow 不受影响。
+
+### 7.2 加新通用字段 —— options 对象开放扩展
+通用字段都挂在 options 对象上。新增字段 = 加 option key:
+```ts
+agent("...", { /* 未来 */ priority: "high", tags: ["audit"], telemetry: {...} });
+```
+TypeScript 的 `PhaseOptions` 接口增量扩展;未识别字段走 `additionalProperties` 策略(默认警告,不报错)。
+
+### 7.3 用户自定义 rune(组件)
+```ts
+// 用户封装自己的编排原语(= 自定义 rune)
+export function auditFiles(files: Phase, opts) {
+ return map(files, (f) => agent(`audit ${f}`, opts));
+}
+// 用法和内置 rune 一样
+const r = auditFiles(discover, { agent: "analyst" });
+```
+
+---
+
+## 8. shorthand(简单任务,§非完整 flow)
+
+```ts
+import { taskflow } from "taskflow";
+
+// 完全等价 JSON shorthand:
+taskflow.task({ task: "summarize src/", agent: "explorer" });
+taskflow.tasks([{ task: "audit auth" }, { task: "audit perf" }]);
+taskflow.chain([{ task: "step1" }, { task: "from {previous.output}" }]);
+```
+
+---
+
+## §A. 完整字段对照表(JSON ↔ DSL)—— 100% 覆盖证明
+
+### 顶层
+| JSON | DSL | ✓ |
+|---|---|---|
+| name | flow(name) | ✅ |
+| description | flow(name, {description}) | ✅ |
+| version | flow(name, {version}) | ✅ |
+| args | ctx.args.declare() | ✅ |
+| concurrency | ctx.concurrency() | ✅ |
+| budget{maxUSD,maxTokens} | ctx.budget() | ✅ |
+| agentScope | ctx.scope() | ✅ |
+| strictInterpolation | ctx.strict() | ✅ |
+| contextSharing | ctx.share() | ✅ |
+| incremental | ctx.incremental() | ✅ |
+| phases | rune 声明 | ✅ |
+
+### Phase 通用
+| JSON | DSL option | ✓ |
+|---|---|---|
+| id | id: | ✅ |
+| type | rune 选择 | ✅ |
+| agent | agent: | ✅ |
+| task | rune 第1参数 / 回调 | ✅ |
+| dependsOn | dependsOn: (+自动) | ✅ |
+| join | join: | ✅ |
+| when | when: | ✅ |
+| retry{max,backoffMs,factor} | retry: | ✅ |
+| output | output: / json() | ✅ |
+| expect | expect: / json() | ✅ |
+| model | model: | ✅ |
+| thinking | thinking: | ✅ |
+| tools | tools: | ✅ |
+| cwd | cwd: | ✅ |
+| final | final: / return | ✅ |
+| optional | optional: | ✅ |
+| idempotent | idempotent: | ✅ |
+| concurrency | concurrency: | ✅ |
+| context | context: | ✅ |
+| contextLimit | contextLimit: | ✅ |
+| cache{scope,ttl,fingerprint} | cache: | ✅ |
+| shareContext | shareContext: | ✅ |
+| timeout | timeout: | ✅ |
+
+### Phase 类型专属
+| Phase | JSON 字段 | DSL | ✓ |
+|---|---|---|---|
+| map | over / as | map(over,(item)=>...) | ✅ |
+| parallel | branches[] | parallel([...]) | ✅ |
+| reduce | from[] | reduce([...],fn) | ✅ |
+| flow | use / def / with | subflow(name,with) / subflow.def(fn) | ✅ |
+| script | run / input | script(run,{input}) | ✅ |
+| loop | until/maxIterations/convergence/reflexion | loop({until,...}) | ✅ |
+| tournament | variants/branches/judge/judgeAgent/mode | tournament({...}) | ✅ |
+| gate | onBlock/eval/score | gate()/gate.automated()/gate.scored() | ✅ |
+
+### 插值 & shorthand
+| 能力 | DSL | ✓ |
+|---|---|---|
+| {args.X} | args.X | ✅ |
+| {steps.ID.output/json} | id.output / id.json | ✅ |
+| {item} / {item.f} | item / item.f | ✅ |
+| {previous.output} | previous.output | ✅ |
+| {reflexion} | loop body 内自动 | ✅ |
+| shorthand task/tasks/chain | taskflow.task/tasks/chain | ✅ |
+
+---
+
+## §B. 完整真实示例(对照 JSON)
+
+```jsonc
+// JSON(现状)
+{ "name": "audit-endpoints", "budget": {"maxUSD":3}, "phases": [
+ { "id":"discover", "agent":"scout", "task":"List endpoints under {args.dir}. JSON array.", "output":"json",
+ "expect":{"type":"array","items":{"type":"object","required":["route","file"]}}, "retry":{"max":2} },
+ { "id":"audit", "type":"map", "over":"{steps.discover.json}", "agent":"analyst", "concurrency":4,
+ "task":"Audit {item.route} ({item.file}) for missing auth." },
+ { "id":"screen", "type":"gate", "agent":"reviewer", "onBlock":"retry",
+ "task":"Cross-check. Delete false positives. VERDICT: PASS/BLOCK.\n{steps.audit.output}" },
+ { "id":"report", "type":"reduce", "from":["screen"], "agent":"doc-writer",
+ "task":"Write report:\n{steps.screen.output}", "final":true }
+]}
+```
+
+```ts
+// DSL 0.2.0(等价,100% 覆盖,自动依赖,有类型)
+import { flow, agent, map, gate, reduce, json } from "taskflow";
+
+export default flow("audit-endpoints", (ctx) => {
+ ctx.budget({ maxUSD: 3 });
+ ctx.args.declare({ dir: { default: "src/routes", type: "string" } });
+
+ const discover = agent(`List endpoints under ${ctx.args.dir}. JSON array.`, {
+ agent: "scout", output: json<{ route: string; file: string }[]>(), retry: { max: 2 },
+ });
+
+ const audit = map(discover, (item) =>
+ agent(`Audit ${item.route} (${item.file}) for missing auth.`, { agent: "analyst", concurrency: 4 })
+ );
+
+ const screen = gate(audit, { agent: "reviewer", onBlock: "retry" },
+ (i) => `Cross-check. Delete false positives. VERDICT: PASS/BLOCK.\n${i.output}`);
+
+ return reduce([screen], (p) =>
+ agent(`Write report:\n${p.screen.output}`, { agent: "doc-writer" }) // return = final
+ );
+});
+```
+
+---
+
+## §C. 覆盖率自检清单
+
+- [x] 10 种 phase 类型全覆盖(agent/map/parallel/gate/reduce/loop/tournament/approval/flow/script)
+- [x] gate 三形态全覆盖(task / eval / score)
+- [x] flow 三形态全覆盖(use / def / with)
+- [x] script 两形态全覆盖(string shell / array execvp / input stdin)
+- [x] 全部 24 个通用 phase 字段
+- [x] 全部 11 个顶层字段
+- [x] 全部 6 个插值占位符
+- [x] shorthand(task/tasks/chain)
+- [x] MCP/Pi actions 不受影响(run/save/resume/list/agents/init/verify/compile/ir/provenance/why-stale/recompute/cache-clear/search —— 都操作 FlowIR,与 surface syntax 无关)
+- [x] JSON 向前兼容(零修改)
+- [x] 双向编译(tf.ts ↔ json)
+- [x] 拓展性(新 phase = 新 rune 函数;新字段 = 新 option key)
+
+---
+
+## §D. 待定决策(open questions,不阻塞本 RFC)
+
+1. **`when` 的 TS 谓词允许子集** —— 哪些 JS 表达式可编译成 eval?(纯比较/逻辑 OK;闭包捕获/异步 待定)
+2. **`flow.component`(带 props 的可复用子 flow)的精确签名** —— 见 demo,但 props 的响应式语义需单独 RFC。
+3. **`$store`/`$derived`(全局响应式)是否进 0.2.0 首版** —— 它依赖 overstory Shared Context Tree,可能作为 0.2.x 渐进加入。
+4. **`json()` 的泛型 → TypeBox schema 推导深度** —— 基本类型/array/object OK;复杂联合/条件类型可能降级为 unknown + 运行时校验。
diff --git a/docs/rfc-0.2.0-three-compile-routes.md b/docs/rfc-0.2.0-three-compile-routes.md
new file mode 100644
index 0000000..24d4cb1
--- /dev/null
+++ b/docs/rfc-0.2.0-three-compile-routes.md
@@ -0,0 +1,158 @@
+# RFC 补充:三条编译路线对比 — Solid / Svelte / Vue Vapor → taskflow
+
+> Companion to `docs/rfc-0.2.0-typescript-dsl.md`(那是 Solid 路线)。本文回答:
+> **① Svelte 路线(编译时指令、运行时擦除)长什么样?② Vue 最新模式(Vapor)长什么样?**
+> 三条路线的本质区别 + 各自映射到 taskflow 的形态。
+
+---
+
+## 0. 先厘清:前端三条路线的本质区别
+
+| | Solid 路线 | Svelte 5 路线 | Vue 3.6 Vapor 路线 |
+|---|---|---|---|
+| rune/symbol 是什么 | **真函数**(运行时返回 signal/对象) | **编译时指令**(运行时擦除) | **模板 → 编译成命令式操作** |
+| 运行时还有这些函数吗 | ✅ 有(返回值有意义) | ❌ 擦除(变成执行代码) | ❌ 擦除(变成 DOM/操作指令) |
+| 响应式底层 | signal(运行时) | signal(runes 编译后接 signal) | signal(编译后接 signal) |
+| 写起来像 | "合法 JS + 钩子" | "带魔法的 JS" | "模板 + 少量脚本" |
+| 可否脱离编译器跑 | ✅ 能(降级为普通调用) | ❌ 不能(runes 离开编译器无效) | ❌ 不能(模板离开编译器无效) |
+| 混合模式 | 全用 | 全用 | **✅ 逐组件 opt-in(Vapor + 普通 Vue 共存)** |
+
+> 一句话:**Solid = "rune 是真函数";Svelte = "rune 是编译指令(被擦除)";Vue Vapor = "模板编译成命令式 + 可混合"**。
+> 三者**底层都是 signals**(2026 共识),区别在**语法层**和**编译策略**。
+
+---
+
+## 1. Svelte 路线:rune = 编译时指令(运行时擦除)
+
+### 核心机制
+`agent()` / `map()` / `gate()` **不是真函数** —— 它们是编译器识别的**指令**。
+`taskflow build` 把 `.tf.ts` **整个转换**成 FlowIR + 执行计划,运行时**不存在这些调用**。
+(就像 Svelte 的 `$state(0)` 编译后变成 `$.state(...)` 内部 API,`$state` 符号本身消失。)
+
+### 长什么样
+```ts
+import { flow, agent, map, gate, $read, $emit } from "taskflow/compiler";
+
+flow("audit-endpoints", () => {
+ $budget({ maxUSD: 3.0 });
+
+ const discover = agent("List every HTTP endpoint under src/routes...", {
+ output: $json<{ route: string; file: string }[]>(),
+ });
+ // ↑ 编译后:discover 不是"调用 agent() 的返回值",
+ // 而是 FlowIR 里的一个 node id + 一个编译期生成的依赖边。
+
+ const audit = map(discover, (item) =>
+ agent(`Audit ${item.route} in ${item.file}...`)
+ );
+ // ↑ map() 在编译期展开:扫箭头函数体,生成 per-item 的 phase 模板。
+ // 运行时根本没有 map 这个调用。
+
+ gate(audit, { agent: "reviewer", onBlock: "retry" });
+});
+```
+
+### 编译产物(运行时实际执行的)
+```jsonc
+// FlowIR — agent/map/gate 这些"调用"全没了,只剩图:
+{ "name": "audit-endpoints",
+ "nodes": [
+ { "id": "discover", "kind": "agent", "inject": [], "emits": ["discover"], "task": "..." },
+ { "id": "audit", "kind": "map", "inject": ["discover"], "emits": ["audit"] },
+ { "id": "gate_0", "kind": "gate", "inject": ["audit"], "onBlock": "retry" } ],
+ "budget": { "maxUSD": 3.0 } }
+```
+
+### Svelte 路线的特征
+- **✅ 产物最精简**:运行时零 rune 开销,FlowIR 直接就是可执行图。
+- **✅ 静态分析最强**:编译器"看见"一切(每个 rune 都是编译期已知),verify/IR 天然精确。
+- **❌ 不能脱离编译器跑**:`.tf.ts` 不 `build` 就没法运行(REPL/调试不友好)。
+- **❌ 渐进迁移更难**:要么全转,要么不转(没有"半个 rune")。
+- **❌ 对 agent 心智负担略高**:`map(discover, item => ...)` 看着像函数,但 `item` 其实是编译期占位符,不能在 `item` 上做任意运行时运算(只能在编译期子集内)。
+
+---
+
+## 2. Vue Vapor 路线:模板编译成命令式 + 双模式共存
+
+### 核心机制(借鉴 Vue 3.6 Vapor)
+Vue Vapor 的两个关键特征:
+1. **模板编译成细粒度命令式操作**(跳过中间表示的运行时开销)。
+2. **逐组件 opt-in** —— Vapor 组件和普通 Vue 组件**可以共存**于一个项目,渐进迁移。
+
+映射到 taskflow:**JSON flow 和 TS flow 可以混用,逐 phase opt-in**。这是 Vue Vapor 路线**独有**的优势 —— Svelte/Solid 都是"全有或全无",Vapor 是"渐进"。
+
+### 长什么样
+```ts
+import { flow, agent, map, gate, vapor } from "taskflow";
+
+// 一个 flow 可以"混用":部分 phase 用新 TS DSL,部分沿用 JSON 片段
+flow("audit", () => {
+ const discover = agent("List endpoints...", { output: json<{route:string}[]>() });
+
+ // 旧 JSON phase 可以内联引用(vapor 路线的"共存"特征)
+ const legacy = json`{
+ "type": "map", "over": "${discover}", "as": "item",
+ "task": "Audit {{item.route}}", "agent": "analyst"
+ }`;
+
+ gate(legacy, { agent: "reviewer" });
+});
+```
+
+或者用 **vapor 标记**逐 flow 切换编译模式(像 Vue 的 `vapor: true`):
+```ts
+// @vapor ← 这个 flow 用 Vapor 编译(编译成细粒度执行图,零中间开销)
+flow("audit", () => {
+ const d = agent("...");
+ const a = map(d, (i) => agent(`audit ${i.route}`));
+ gate(a);
+});
+// 不加 @vapor 的 flow 仍走"解释执行"路径(兼容老行为/老 host)
+```
+
+### Vue Vapor 路线的特征
+- **✅ 渐进迁移最顺** —— JSON 和 TS 混用,逐 flow / 逐 phase opt-in(这正是 Vue 哲学:"渐进式框架")。
+- **✅ 双后端**:同一 DSL 编译到"解释执行"(老 host / 调试)或"Vapor 执行"(性能),按需选。
+- **✅ 最贴合 taskflow 现状** —— 现在已有 JSON flow + 4 个 host,Vapor 的"共存"理念迁移成本最低。
+- **❌ 复杂度最高**:要维护两套执行后端(解释器 + Vapor),工程量大。
+- **❌ "混用"语义要小心**:JSON phase 和 TS phase 的依赖/类型如何桥接是设计难点。
+
+---
+
+## 3. 三条路线映射到 taskflow 的对比
+
+| 维度 | Solid 路线 | Svelte 路线 | Vue Vapor 路线 |
+|---|---|---|---|
+| rune 本体 | 真函数(运行时有效) | 编译指令(运行时擦除) | 模板/标记(可混用) |
+| `.tf.ts` 不编译能跑吗 | ✅ 能(降级) | ❌ | ⚠️ 部分(标记决定) |
+| 运行时开销 | 有(rune 函数调用) | 零(擦除) | 零(Vapor 模式下) |
+| 静态分析精度 | 高(AST + 运行时) | **最高**(纯编译期) | 高 |
+| 渐进迁移 | 中(可逐 flow) | 难(全有/全无) | **最顺**(逐 phase 混用) |
+| agent 调试体验 | **最好**(可断点) | 差(编译后面目全非) | 中 |
+| 实现工程量 | 中 | 中 | **大**(双后端) |
+| 类型安全 | ✅ tsc 全程 | ✅ tsc + 编译期 | ✅ tsc |
+
+---
+
+## 4. 我的推荐:Solid 路线为主,但偷 Vue Vapor 的"渐进共存"
+
+**主路线:Solid**(rune 是真函数)。理由:
+1. **agent 调试** —— taskflow 的核心用户是 agent + 开发者,能断点、能 REPL 调试比"产物精简"重要。Svelte 路线编译后面目全非,调试地狱。
+2. **渐进迁移** —— Solid 路线下,`.tf.ts` 不 build 也能跑(降级成解释执行),和现有 JSON flow 共存天然成立。
+3. **实现风险低** —— 不用维护双后端(Vapor 路线的代价)。
+4. **类型体验最好** —— tsc 全程参与,rune 返回值有真实类型。
+
+**但借鉴 Vue Vapor 的"双模式共存"**:taskflow 已有 JSON flow + 4 host,**保留 JSON 为一等公民**(`taskflow build flow.json` 和 `taskflow build flow.tf.ts` 都产出 FlowIR),等于白得了 Vue Vapor 的"渐进"优势,而不用承担双执行后端的复杂度。
+
+**不选 Svelte 路线**的理由:运行时擦除对 taskflow 这种"agent 需要可观测、可调试、可 resume"的系统代价过大 —— resume 需要运行时状态,Svelte 路线擦除运行时后,很多 overstory 的 observed readSet 运行时追踪会变得别扭。
+
+---
+
+## 5. 一句话总结三条路线(给决策用)
+
+- **Solid**:"rune 是真函数,能调试,产物略重。" ← **推荐**
+- **Svelte**:"rune 是编译指令,运行时擦除,产物最精简,但调试难、迁移硬。"
+- **Vue Vapor**:"模板编译成命令式 + JSON/TS 可混用,迁移最顺,但要维护双后端。"
+
+> taskflow 选 **Solid + 偷 Vue 的"JSON 共存"**:rune 是真函数(可调试、可 resume),
+> 同时 JSON 仍是一等公民(渐进迁移),得两者之长,避两者之短。
diff --git a/docs/rfc-0.2.0-typescript-dsl.md b/docs/rfc-0.2.0-typescript-dsl.md
new file mode 100644
index 0000000..89552ca
--- /dev/null
+++ b/docs/rfc-0.2.0-typescript-dsl.md
@@ -0,0 +1,331 @@
+# RFC: taskflow 0.2.0 — TypeScript 函数式 DSL 设计
+
+> Status: design proposal, 2026-07-07. 方向已定(TS 函数式,见
+> `docs/0.2.0-research-frontend-paradigms.md` §4)。本文回答:**各种语法怎么表示**。
+> 路线:复用 TS(合法 TS + 编译器,像 Solid),不自创语法;显式 rune 函数(像 Svelte 5);
+> 自动依赖追踪(像 TC39 signals);编译到 FlowIR 保留静态分析护城河。
+
+---
+
+## 0. 设计原则
+
+1. **合法的 TypeScript** —— 不写解析器。`taskflow build` 把 `.tf.ts` 编译成 FlowIR(就像 Solid 把 JSX 编译成 DOM 更新)。agent 用已有的 TS 能力写,IDE/LSP/类型检查全免费。
+2. **显式 rune 函数** —— `agent()` `map()` `gate()` `loop()` `tournament()` 等是编译器识别的"符号"(像 Svelte 的 `$state`),但它们**就是普通 TS 函数**(像 Solid 的 `createSignal`)—— 不跑也能 typecheck。
+3. **依赖靠"读"自动建立** —— 读上游不再是字符串 `"{steps.discover.output}"`,而是函数调用 `discover.output`。编译器静态收集依赖(= declared readSet),运行时 onRead hook 收集观察依赖(= observed readSet)—— overstory M2+M3 自动达成。
+4. **类型即契约** —— `expect` 用 TS 泛型推导;output schema 用 TypeBox(已有)。
+
+---
+
+## 1. 一个 flow 的骨架
+
+```ts
+import { flow, agent, gate, reduce, map, tournament, loop, script, approval } from "taskflow";
+
+export default flow("audit-endpoints", ({ args, budget }) => {
+ budget({ maxUSD: 3.0 });
+
+ const discover = agent("List every HTTP endpoint under src/routes...", {
+ agent: "scout",
+ tools: ["read", "grep", "ls"],
+ output: json<{ route: string; file: string }[]>(), // ← expect 契约,TS 泛型推导
+ retry: { max: 2 },
+ });
+
+ const audit = map(discover, (item) =>
+ agent(`Audit ${item.route} in ${item.file} for missing auth...`, {
+ agent: "analyst", concurrency: 4,
+ })
+ );
+
+ const screen = gate(audit, (findings) =>
+ agent(`Cross-check the findings. Delete false positives...`, { agent: "reviewer" })
+ );
+
+ return reduce([screen], (input) =>
+ agent(`Write a prioritized remediation report from:\n${input.screen.output}`, {
+ agent: "doc-writer", final: true,
+ })
+ );
+});
+```
+
+**对比 JSON 版**(patterns.md archetype 1):同样的逻辑,JSON 是 25 行嵌套 + 字符串模板 `"{steps.discover.json}"` + 手写 `dependsOn`;TS 版是**线性、有类型、依赖靠传参自动建立**。
+
+---
+
+## 2. 10 种 phase 的语法(完整清单)
+
+### 2a. `agent` —— 单个 subagent
+```ts
+const files = agent("List .ts files under src/", {
+ agent: "scout",
+ output: json(), // expect 契约 (TypeBox 推导)
+ model: "fast", thinking: "high", // 可选覆盖
+ cwd: "worktree", // 工作区隔离
+ retry: { max: 3, backoffMs: 500, factor: 2 },
+ timeout: 60_000,
+ cache: { scope: "cross-run", ttl: "7d" },
+ optional: true, // 失败不阻断
+});
+```
+
+### 2b. `map` —— 动态扇出(一个 item 一个 agent)
+```ts
+const audits = map(files, (f) =>
+ agent(`audit ${f}`, { agent: "analyst", label: f, concurrency: 4 })
+);
+// audits: Result[] —— 类型自动是 agent 输出的数组
+```
+
+### 2c. `parallel` —— 静态并发分支
+```ts
+const [auth, validation, perf] = parallel([
+ agent("audit auth", { agent: "analyst" }),
+ agent("audit input validation", { agent: "analyst" }),
+ agent("audit perf", { agent: "analyst" }),
+]);
+```
+
+### 2d. `gate` —— 质量门(VERDICT: PASS/BLOCK)
+```ts
+const verified = gate(audits, { agent: "reviewer", onBlock: "retry" }, (findings) =>
+ `Cross-check findings. VERDICT: BLOCK if any HIGH remains, else PASS.\n${findings}`
+);
+
+// eval gate —— 零 token 机器门(不调 LLM)
+const typecheck = gate.automated(
+ () => script("npx tsc --noEmit"),
+ { pass: "{exit === 0}" } // eval 条件
+);
+```
+
+### 2e. `reduce` —— 聚合 N → 1
+```ts
+const summary = reduce([auth, validation, perf], (parts) =>
+ agent(`Merge into one ranked summary:\n${parts.auth.output}`, { agent: "doc-writer" })
+);
+```
+
+### 2f. `tournament` —— best-of-N + judge
+```ts
+const best = tournament({
+ mode: "best",
+ judgeAgent: "final-arbiter",
+ judge: "Judge on: correctness, blast radius, migration cost. End with WINNER: .",
+ branches: [
+ agent("conservative fix: minimal diff", { agent: "analyst" }),
+ agent("optimal correctness: schema change ok", { agent: "analyst" }),
+ agent("adversary: what breaks? propose survivor", { agent: "critic" }),
+ ],
+});
+```
+
+### 2g. `loop` —— 循环到条件/收敛/上限
+```ts
+const fixed = loop({
+ until: "{steps.check.exit === 0}",
+ maxIterations: 5,
+ convergence: "{steps.check.output hash unchanged}",
+ body: (prev) => ({
+ check: script("npx tsc --noEmit"),
+ fix: agent(`Fix the type errors:\n${prev.check.output}`, { agent: "executor" }),
+ }),
+});
+```
+
+### 2h. `approval` —— 人机交互
+```ts
+const approved = approval({
+ request: "Review this plan before execution",
+ input: plan.output,
+ choices: ["approve", "reject", "edit"],
+});
+```
+
+### 2i. `flow` —— 调用已保存的子 taskflow
+```ts
+const result = flow("deep-research", { question: "Node.js permission model v20→v22" });
+```
+
+### 2j. `script` —— 零 token shell 步骤
+```ts
+const lint = script("npx eslint src/ --format json", { cwd: "dedicated" });
+// lint.exit, lint.stdout, lint.stderr
+```
+
+---
+
+## 3. 依赖怎么自动建立(替代 `{steps.X.output}`)
+
+**核心机制:phase 是一个带 `.output` 的值对象。读它 = 建立依赖。**
+
+```ts
+const discover = agent("find files", { ... });
+const audit = map(discover, ...); // ← audit 依赖 discover,因为 discover 被传入
+const report = reduce([audit], (i) =>
+ agent(`report from ${i.audit.output}`) // ← report 依赖 audit,因为读了 .output
+);
+```
+
+两层追踪(= overstory M2+M3,自动达成):
+- **静态(编译时)**:编译器扫 AST,`discover`/`audit` 被引用 → declared readSet(FlowIR inject/emits)。
+- **动态(运行时)**:`.output` 的 getter 触发 onRead hook → observed readSet@version。
+
+> 不再有 `dependsOn: ["discover"]` 这种手写字符串。**传参即依赖**,和写普通函数一样。
+
+---
+
+## 4. 控制流(when / join / retry / budget / onBlock)
+
+```ts
+flow("x", ({ when, budget }) => {
+ budget({ maxUSD: 5, maxTokens: 200_000 });
+
+ const nightly = agent("...", { when: "{env.NIGHTLY === '1'}" });
+
+ const anyReviewer = parallel([
+ agent("review a", { join: "any" }), // OR-join: 任一完成即可
+ agent("review b", { join: "any" }),
+ ]);
+
+ const robust = agent("...", {
+ retry: { max: 3, backoffMs: 1000, factor: 2 },
+ onBlock: "retry", // gate BLOCK 时重试上游而非 halt
+ });
+});
+```
+
+`when` 两种形态:
+- **字符串条件**(兼容现有 eval):`when: "{env.X} contains 'prod'"`
+- **TS 函数(新)**:`when: ({ env, steps }) => env.MODE === "prod"` —— 编译器把函数体编译成 eval 条件,保留零 token 静态分析。
+
+---
+
+## 5. args / 传参 / 复用
+
+```ts
+export default flow("audit", ({ args }) => {
+ args.declare({ dir: { default: "src/routes", type: "string" } });
+ const d = agent(`audit ${args.dir}...`); // args 是响应式的信号
+ return d;
+});
+
+// 调用:taskflow run audit.ts --args '{"dir":"src/api"}'
+// 或保存后:/tf:audit src/api
+```
+
+**跨文件复用**(Svelte 5 runes 的关键突破 —— 响应式超越组件边界):
+```ts
+// lib/flows.ts —— 可复用的 flow 片段,不是完整 flow
+export const auditPattern = (target: Signal) =>
+ map(agent(`list files in ${target}`), (f) => agent(`audit ${f}`));
+
+// my-flow.ts —— 组合复用
+import { auditPattern } from "./lib/flows.ts";
+export default flow("x", () => auditPattern(read("src/routes")));
+```
+
+---
+
+## 6. 类型推导(这是 TS DSL 杀手锏)
+
+```ts
+const discover = agent("...", { output: json<{ route: string; file: string }[]>() });
+// ^? Phase<{ route: string; file: string }[]>
+
+const audit = map(discover, (item) => agent(`audit ${item.route}`));
+// ^? Phase
+// item.route 字符串 —— 编译期就知道,拼错就报错
+```
+
+**JSON DSL 现在的痛点**:`{item.rout}` 拼错要到运行时才发现;`"{steps.discover.json}"` 是纯字符串,没有类型。
+**TS DSL**:`item.route` 拼错 = 编译失败;整个 flow 在 `taskflow build`(→ FlowIR)前先过 `tsc`。
+
+---
+
+## 7. 编译路径(保留护城河)
+
+```
+.tf.ts (合法 TS,agent 写)
+ │ tsc 类型检查 (agent 拼错立刻报错)
+ ▼
+taskflow build ← 扫 rune 函数调用,收集依赖,生成
+ ▼
+FlowIR (内容寻址, hash) ← /tf verify 在这里跑,零 token
+ ▼
+runtime execute (observed readSet 自动追踪 → cache + recompute)
+```
+
+- **`/tf verify` / `/tf ir`** 仍然工作(在 FlowIR 层),护城河不丢。
+- **JSON 仍然支持**:`taskflow build flow.json` → 同一个 FlowIR。**双向可编译**(DSL ↔ JSON),老用户零迁移成本。
+
+---
+
+## 8. shorthand(简单任务不用写完整 flow)
+
+```ts
+// 内联在 host agent 的调用里,等价于现在的 task/tasks/chain
+taskflow.run({ task: "summarize src/", agent: "explorer" });
+taskflow.parallel([{ task: "audit auth" }, { task: "audit perf" }]);
+taskflow.chain([{ task: "step1" }, { task: "step2 from {prev}" }]);
+```
+
+---
+
+## 9. 完整对照:Archetype 5 (tournament) JSON → TS
+
+**JSON(现状):**
+```jsonc
+{ "id": "strategy", "type": "tournament", "mode": "best",
+ "judgeAgent": "final-arbiter",
+ "judge": "Judge on: correctness, blast radius, migration cost. WINNER: .",
+ "branches": [
+ { "task": "conservative fix", "agent": "analyst" },
+ { "task": "optimal correctness", "agent": "analyst" },
+ { "task": "adversary", "agent": "critic" } ],
+ "dependsOn": ["context"], "final": true }
+```
+
+**TS(0.2.0):**
+```ts
+const context = agent("gather context", { agent: "scout" });
+
+const strategy = tournament({
+ mode: "best",
+ judgeAgent: "final-arbiter",
+ judge: "Judge on: correctness, blast radius, migration cost. WINNER: .",
+ branches: [
+ agent("conservative fix", { agent: "analyst" }),
+ agent("optimal correctness", { agent: "analyst" }),
+ agent("adversary: what breaks?", { agent: "critic" }),
+ ],
+}); // dependsOn 自动 = [context],final 用 return 标记
+```
+
+**收益**:少 3 个字段(`dependsOn` 自动、`final` 用 return、`id` 可省);branches 是真数组(可 `.map()`/条件构造);类型检查。
+
+---
+
+## 10. Open questions(下一步要定的)
+
+1. **rune 函数是"真函数"还是"纯编译器符号"?**
+ - Solid 路线:真函数,运行时也有意义(返回 signal/phase 对象)—— 更灵活、可在 REPL 调试。
+ - Svelte 路线:纯编译器符号(运行时被擦除)—— 更轻、bundle 更小。
+ - 建议:**Solid 路线**(agent 调试 + 渐进迁移更重要)。
+
+2. **响应式粒度** —— `discover.output` 读一次建一条边,还是整个 phase 是一个 signal?
+ - 建议沿用 overstory 的 **phase 级 signal**(LLM 调用是昂贵原语,不需要 DOM 那种节点级粒度)。
+
+3. **JSON 共存策略** —— 双向编译 vs JSON 仅作为编译产物?
+ - 建议双向(JSON 仍是一等公民,DSL ↔ JSON 互转),保证 saved flow / 老用户零成本。
+
+4. **`when`/`eval` 的 TS 函数编译** —— 把 TS 谓词编译成 eval 条件串,需要限定子集(禁止副作用/闭包捕获)。
+ - 这是技术上最 tricky 的一点,需单独 RFC。
+
+---
+
+## 11. 北极星
+
+> **taskflow 0.2.0: Write agent workflows like Solid components.**
+> **TypeScript-native, compiler-tracked dependencies, only what changed re-runs.**
+> **The first framework to bring 2026's frontend reactivity consensus to agents.**
diff --git a/docs/rfc-taskflow-vs-claude-code-workflows.md b/docs/rfc-taskflow-vs-claude-code-workflows.md
new file mode 100644
index 0000000..70a47ec
--- /dev/null
+++ b/docs/rfc-taskflow-vs-claude-code-workflows.md
@@ -0,0 +1,273 @@
+# RFC: taskflow vs Claude Code (2026-07) — capability gap & what to build next
+
+> Status: research notes, 2026-07-07. Source: Claude Code official docs
+> (`code.claude.com/docs/en/*`, fetched 2026-07-07) + taskflow source.
+> Purpose: decide which features close the gap and push taskflow ahead of
+> Claude Code's just-shipped **Dynamic Workflows** (GA 2026-07-03, PR-era
+> research preview May 2026).
+
+---
+
+## 0. TL;DR
+
+Claude Code shipped **Dynamic Workflows** — its first feature that shares
+taskflow's core thesis ("move the plan into code; only the final answer reaches
+the conversation"). The overlap is real and large. **But taskflow still leads on
+orchestration depth** (10 phase types + DAG + cross-run caching + multi-host),
+while **Claude Code leads on two things taskflow lacks**:
+
+1. **Zero-author orchestration** — Claude *writes* the workflow script for you
+ (`ultracode` / "use a workflow"). taskflow forces the LLM/user to hand-author
+ the JSON DSL every time.
+2. **A lifecycle hook system** — ~30 events (PreToolUse, SubagentStart, Stop,
+ TaskCompleted, TeammateIdle, WorktreeCreate, PreCompact …) that users wire
+ shell/HTTP/LLM handlers into. taskflow has only an internal `onProgress`.
+
+The single highest-leverage feature to "fully surpass" is **#1: a
+zero-author / auto-compose mode** — let the host LLM describe a task and have
+taskflow draft the DSL, verify it (zero tokens), and run it. This neutralizes
+Claude Code's biggest UX moat while keeping taskflow's deeper engine.
+
+---
+
+## 1. Claude Code's multi-agent surface (4 primitives)
+
+Claude Code now offers **four** ways to run multi-step work. The official
+"who holds the plan?" matrix:
+
+| Primitive | Plan lives in | Scale | Comms | Repeatable |
+|---|---|---|---|---|
+| **Subagents** | Claude's context (turn-by-turn) | a few / turn | report back only | worker *definition* |
+| **Skills** | Claude's context (follows prompt) | a few / turn | — | the *instructions* |
+| **Agent Teams** *(experimental)* | a **lead agent** supervising peers | handful of long-running peers | **peers message each other** + shared task list | the *team definition* |
+| **Dynamic Workflows** | a **script the runtime executes** | **dozens→1000 agents/run** | via script variables | **the orchestration itself** |
+
+### 1a. Dynamic Workflows (the direct competitor — GA v2.1.154+)
+
+- A workflow = a **JavaScript script** Claude writes. Runtime executes it in the
+ background; session stays responsive.
+- Two primitives: `agent(prompt, {schema, label})` (spawn one) and
+ `pipeline(items, fn)` (one agent per item — **this is `map`**).
+- Top-level `await`; intermediate results live in **script variables**, not
+ Claude's context. Only the final return value reaches the conversation.
+- **Resume**: stopped runs resume within the same session; completed agents
+ return cached results. *(Does NOT survive session exit — fresh on next session.)*
+- **`args` global** for saved-workflow input. Saved as `.claude/workflows/.js`
+ → becomes a `/` slash command (project-shared or `~/.claude` personal).
+- **Adversarial patterns built into examples**: independent agents review each
+ other's findings, vote on claims, draft a plan from several angles and weigh
+ them. (Bundled `/deep-research` fans out web search → cross-check → vote →
+ cited report; unverified claims filtered.)
+- **Limits**: 16 concurrent agents, **1000 agents/run** hard cap, no mid-run user
+ input (only permission prompts can pause), no direct fs/shell from the script
+ itself (agents do all I/O).
+- **Size guideline** (`/config`): small (<5) / medium (<15) / large (<50) /
+ unrestricted — advice sent to Claude.
+- **ultracode** effort mode: xhigh reasoning + auto-workflow for *every*
+ substantive task ("understand → change → verify" can chain several workflows).
+- **Permission gating** per-run: View raw script / Yes / Always / No; Ctrl+G to
+ edit; subagents always run `acceptEdits` + inherit tool allowlist.
+
+### 1b. Agent Teams (experimental, `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`)
+
+- Multiple **full Claude Code instances** as teammates; one is the **lead**.
+- Each teammate = own context window, fully independent.
+- **Teammates message each other directly** (vs subagents which only report to
+ caller). Shared **task list** with self-coordination (assign/claim tasks).
+- Quality gates enforced via **hooks**. Competing-hypotheses / parallel-review
+ / cross-layer (frontend/backend/tests) ownership patterns.
+- *Known weak spots (CC admits):* session resumption, task coordination,
+ shutdown behavior, orphaned tmux sessions.
+
+### 1c. Subagents (worker primitive)
+
+- Single session, own context window, custom system prompt + tool subset +
+ independent permissions. Returns summary to caller.
+- Built-ins: **Explore** (read-only, capped at Opus), **Plan** (plan-mode
+ research), **General-purpose**. Custom ones via `.claude/agents/*.md`.
+- Model routing per subagent (cost control). Delegation decided by Claude from
+ each subagent's `description`.
+
+### 1d. Hooks (~30 lifecycle events)
+
+The richest hook system of any host. Users wire **shell / HTTP / LLM-prompt /
+agent / MCP-tool** handlers into:
+
+- **Session**: SessionStart, SessionEnd, Setup
+- **Turn**: UserPromptSubmit, UserPromptExpansion, Stop, StopFailure,
+ Notification, MessageDisplay
+- **Tool loop**: PreToolUse (can block/defer), PermissionRequest, PermissionDenied,
+ PostToolUse, PostToolUseFailure, PostToolBatch
+- **Agents/tasks**: SubagentStart, SubagentStop, TaskCreated, TaskCompleted,
+ TeammateIdle
+- **Env/fs**: ConfigChange, CwdChanged, FileChanged, WorktreeCreate,
+ WorktreeRemove, PreCompact, PostCompact
+- **Elicitation** (interactive prompts): Elicitation, ElicitationResult
+- Hook **decision control**: exit code 2 / JSON `decision` can block, inject
+ context, defer tool calls, persist env vars, force retry.
+
+---
+
+## 2. Side-by-side: taskflow vs Claude Code
+
+Legend: ✅ has it · ⚠️ partial / weaker · ❌ missing.
+
+### 2a. Orchestration primitives
+
+| Capability | taskflow | Claude Code |
+|---|---|---|
+| Single worker (agent) | ✅ `agent` | ✅ `agent()` / Subagents |
+| Static parallel branches | ✅ `parallel` | ⚠️ via script (Promise.all) |
+| Dynamic fan-out over array | ✅ `map` | ✅ `pipeline()` |
+| Quality gate (halt on BLOCK) | ✅ `gate` (score/scored/eval) | ⚠️ manual in script (agent review) |
+| Aggregate N → 1 | ✅ `reduce` | ⚠️ manual in script |
+| Best-of-N + judge | ✅ `tournament` | ⚠️ "weigh several angles" — manual |
+| Loop until done/converge | ✅ `loop` (condition/convergence/cap) | ⚠️ manual `while` in script |
+| Human approval | ✅ `approval` (approve/reject/edit) | ⚠️ per-run launch prompt only |
+| Sub-flow composition | ✅ `flow` (saved sub-taskflow) | ✅ saved workflow → `/` |
+| Zero-token shell step | ✅ `script` | ❌ (no fs/shell from script) |
+| **Explicit DAG + `dependsOn`** | ✅ topo-sorted, cycle-detected | ❌ implicit (script control flow) |
+| Static verification (pre-run) | ✅ `verify` / `compile` (0 tokens) | ❌ |
+| `when` guards / `join: any` | ✅ | ❌ |
+
+> **taskflow wins on orchestration vocabulary.** Claude Code's workflow script
+> can *emulate* gate/tournament/loop in JS, but it has no first-class primitive,
+> no static verifier, and no explicit DAG — correctness rests on the LLM writing
+> the right JS each time.
+
+### 2b. Reliability & resumability
+
+| Capability | taskflow | Claude Code |
+|---|---|---|
+| Retry w/ exponential backoff | ✅ `retry` | ❌ (LLM re-spawns manually) |
+| Per-call timeout | ✅ `timeout` | ❌ |
+| Output contract (`expect`) | ✅ schema-validated, retryable | ⚠️ `agent({schema})` validates result |
+| Cost ceiling | ✅ `budget` {maxUSD, maxTokens} | ⚠️ "size guideline" (advice only) |
+| Resume **within** session | ✅ | ✅ (cached agent results) |
+| Resume **across** sessions | ✅ (durable run state) | ❌ "fresh on next session" |
+| **Cross-run memoization cache** | ✅ content-addressed, git/glob/file/env fingerprints, TTL | ❌ |
+| Incremental recompute (why-stale) | ✅ `recompute` / `why-stale` | ❌ |
+| Fail-open invariants | ✅ documented + enforced | ⚠️ ad hoc |
+
+> **taskflow wins on durability + caching.** Claude Code workflows are
+> session-scoped and have no content-addressed cache — re-running a similar task
+> re-spawns everything. taskflow's cross-run cache + incremental recompute is a
+> genuine moat.
+
+### 2c. Context & isolation
+
+| Capability | taskflow | Claude Code |
+|---|---|---|
+| Only final output to host context | ✅ | ✅ (script variables) |
+| Per-phase workspace isolation | ✅ `cwd: temp/dedicated/worktree` | ✅ worktrees (per session/phase) |
+| Shared blackboard (horizontal) | ✅ `ctx_read` / `ctx_write` | ⚠️ Agent Teams shared task list |
+| Vertical supervision | ✅ `ctx_report` / `ctx_spawn` (nested DAG) | ⚠️ Agent Teams lead↔teammates |
+| Inter-agent direct messaging | ❌ (via shared tree only) | ✅ Agent Teams |
+
+> **Roughly even.** taskflow's Shared Context Tree is more structured (a
+> supervised blackboard with depth caps + budget); CC's Agent Teams are more
+> "chatty" (direct peer messaging) but CC admits coordination is buggy.
+
+### 2d. Authoring & UX
+
+| Capability | taskflow | Claude Code |
+|---|---|---|
+| **LLM auto-writes the orchestration** | ❌ (hand-author DSL) | ✅ **ultracode / "use a workflow"** |
+| Authoring format | JSON DSL (+ JSONC) | JS script (Claude-written) |
+| Verify-before-run (0 token) | ✅ `verify`/`compile` + Mermaid | ❌ (View raw script, manual) |
+| Saved flow → slash command | ✅ `/tf:` | ✅ `/` |
+| `args` input to saved flow | ✅ `{args.X}` | ✅ `args` global |
+| Interactive init | ✅ `/tf init` | ✅ `/config` toggles |
+| Live progress TUI | ✅ runs-view + approval-view | ✅ `/workflows` + task panel |
+| Searchable flow library | ✅ `search` (structural + CJK) | ❌ |
+| **Multi-host** (Pi/Codex/Claude/OpenCode) | ✅ | ❌ (Claude Code only) |
+
+> **Claude Code wins on zero-author UX; taskflow wins on portability + library.**
+> The auto-compose gap is the one that matters most for adoption.
+
+### 2e. Lifecycle hooks
+
+| Capability | taskflow | Claude Code |
+|---|---|---|
+| User-configurable event hooks | ❌ (internal `onProgress` only) | ✅ ~30 events, 5 handler types |
+| Pre/post-phase side effects | ❌ | ✅ PreToolUse/PostToolUse/etc. |
+| Inject context / block / defer | ❌ | ✅ decision control |
+
+> **Claude Code wins decisively.** This is the cleanest gap.
+
+---
+
+## 3. Where taskflow is clearly ahead (defend these)
+
+1. **Orchestration vocabulary** — 10 phase types vs 2 primitives. gate /
+ tournament / loop / approval / reduce are first-class, statically verified.
+2. **Cross-run caching + incremental recompute** — unique. CC re-runs everything.
+3. **Cross-session resume** — CC workflows die with the session.
+4. **Multi-host** — runs on Pi, Codex, Claude Code, OpenCode. CC is locked to
+ Claude Code.
+5. **Static verification** — `verify`/`compile` catch DAG bugs before spending a
+ token; CC has nothing equivalent.
+6. **Searchable flow library** — `taskflow_search` with structural + CJK scoring;
+ CC has no reuse-discovery.
+
+## 4. Where Claude Code is ahead (the gaps to close)
+
+| Gap | Severity | Why it matters |
+|---|---|---|
+| **G1. No zero-author / auto-compose** | 🔴 critical | CC's `ultracode` lets a user say "audit every route" and gets a workflow. taskflow demands a hand-written DSL. This is the adoption moat. |
+| **G2. No lifecycle hook system** | 🟠 high | Power users wire CI, formatters, slack pings, permission policy into CC hooks. taskflow can't. |
+| **G3. No inter-agent direct messaging** | 🟡 medium | Agent Teams let peers argue. taskflow's shared tree is structured but less "conversational". (Likely a *non-goal* — structured beats chatty.) |
+| **G4. Smaller scale ceiling** | 🟡 medium | CC: 1000 agents/run. taskflow has no published cap but isn't tuned for 1000-way fan-out UX. |
+| **G5. No bundled "killer" workflow** | 🟡 medium | CC ships `/deep-research`. taskflow ships the *engine* but no marquee one-command flow. |
+
+---
+
+## 5. Recommended features for 0.1.7 (to "fully surpass")
+
+Ranked by leverage. Each is sized for one release.
+
+### 🥇 P0 — Auto-compose (`action=compose`)
+**Closes G1.** New `taskflow_compose` action (and a `/tf compose` command):
+the host LLM describes the task in prose → a dedicated **planner agent** drafts
+a taskflow DSL → `verify` runs (0 tokens) → if clean, optionally `run` it;
+if not, the planner revises (a `loop` until `verify` passes or cap). Reuses the
+existing `loop` + `gate` + `verify` primitives — the feature *is* a taskflow
+flow that writes taskflow. This neutralizes CC's biggest UX edge while keeping
+taskflow's deeper, verifiable engine. **CC cannot match the static-verify step.**
+
+### 🥈 P1 — Lifecycle hooks (`on` phase field + global hooks)
+**Closes G2.** Two layers:
+- **Per-phase hooks**: `on: { start, end, block }` running a `script` (shell,
+ zero tokens) — covers PreToolUse/PostToolUse/onBlock patterns.
+- **Global run hooks**: `hooks: { onPhaseStart, onPhaseEnd, onRunComplete,
+ onApproval }` wired to shell commands, emitting the same JSON event shape CC
+ uses (so existing CC hook scripts port). Decision control: exit 2 / JSON
+ `decision` can block or inject.
+
+### 🥉 P2 — Bundled marquee flow: `/tf:deep-research` (and `/tf:audit`)
+**Closes G5.** Ship a library flow that fans out web/source search →
+cross-check → vote → cited report, exactly mirroring CC's `/deep-research`,
+*but* running on all four hosts and cacheable across runs. Demonstrates the
+engine's superiority on a task users already recognize. Plus `/tf:audit`
+(per-file adversarial review → ranked summary) — the other CC headline example.
+
+### P3 — Scale + observability for large fan-out
+**Closes G4.** Publish a `maxAgents`/`concurrency` config, add a
+"1000-agents" progress mode to the runs TUI (phases collapse to counts), and a
+`size` advice field mirroring CC's small/medium/large so the composer aims
+right.
+
+### P4 (optional) — Peer messaging via shared tree
+**Closes G3.** Add `ctx_message` (addressed, non-supervised) on top of the
+existing Shared Context Tree, so peers can "argue" without going through a
+supervisor. Low priority — structured reuse usually beats chat.
+
+---
+
+## 6. The one-sentence pitch after 0.1.7
+
+> *taskflow: the only multi-agent orchestrator where the LLM writes the flow,
+> the engine **verifies** it before a token is spent, results are **cached
+> across runs**, and it runs on **every host** — not just Claude Code.*
+
+The two words Claude Code cannot match: **verify** and **cached**.
diff --git a/examples/0.2.0-app-delivery-platform/app.ts b/examples/0.2.0-app-delivery-platform/app.ts
new file mode 100644
index 0000000..8cbdec4
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/app.ts
@@ -0,0 +1,127 @@
+/**
+ * taskflow 0.2.0 应用 —— 自主软件交付平台
+ *
+ * issue → 规划 → 实现 → 审查 → 安全审计 → 自愈 → 置信度决策 → 交付/转人工
+ *
+ * 这是整个应用的入口。展示 Solid 路线能写出的【最复杂】组合:
+ * - 动态路由(按 issue 复杂度走不同流水线)
+ * - 多 flow 互调(plan / implement / 子组件)
+ * - 全局响应式 store(dashboard)读写
+ * - 条件编排(when) + 分支
+ * - 置信度驱动的自动决策($derived 链)
+ * - 预算池管理 + 耗尽熔断
+ *
+ * 依赖图完全靠读取自动建立 —— 没有一个手写 dependsOn。
+ * 改一个 issue 的字段,只有相关分支重算(overstory 增量)。
+ */
+
+import { flow, agent, gate, approval, parallel, script, $derived, $store, read, write, when, json } from "taskflow";
+import planFlow from "./flows/plan.ts";
+import implementFlow from "./flows/implement.ts";
+import { reviewChanges } from "./components/review-changes.ts";
+import { securityAudit } from "./components/security-audit.ts";
+import { dashboard, remainingBudget } from "./stores/dashboard.ts";
+import { config } from "./config/app.ts";
+import { needsSecurityReview, inferComplexity } from "./lib/utils.ts";
+import type { Issue, DeliveryReport, DeliveryStatus } from "./types/domain.ts";
+
+export default flow("deliver", ({ args, budget }) => {
+ args.declare({ issue: { type: "object" as const } });
+ budget(config.budget); // 绑定全局预算池
+
+ const issue = args.issue as Issue;
+
+ // ── 注册到全局看板(写 store;后续派生自动更新) ─────────────────────
+ write(dashboard.active, (m) => m.set(issue.id, issue));
+
+ // ── 动态路由:按复杂度选流水线(像 Vue Router 的路由表) ─────────────
+ const pipeline = $derived(() => config.routing[issue.complexity]); // "fast-path" | "standard" | "rigorous"
+
+ // ── 阶段1:规划(调子 flow) ────────────────────────────────────────
+ const plan = when(() => pipeline.output !== "fast-path",
+ () => planFlow({ issue }),
+ () => agent( // fast-path:跳过 tournament,直接简单实现
+ `简单任务,直接做:${issue.title}\n${issue.body}`,
+ { agent: "executor", output: json() }
+ )
+ );
+
+ // ── 阶段2:实现(调子 flow;依赖 plan) ─────────────────────────────
+ const diff = when(() => pipeline.output !== "fast-path",
+ () => implementFlow({ plan: plan.output, repo: issue.repo }),
+ () => script(`cd ${issue.repo} && git diff`) // fast-path 直接拿 diff
+ );
+
+ // ── 阶段3:代码审查(复用组件;读 diff + 改动文件) ──────────────────
+ const changedFiles = $derived(() => plan.output.affectedFiles ?? []);
+ const review = reviewChanges(diff, changedFiles); // 多视角 + tournament + auto-gate
+
+ // ── 阶段4:安全审计(仅高风险 issue;条件编排) ──────────────────────
+ const mustAudit = $derived(() =>
+ needsSecurityReview(issue.labels, [...config.securityGate.triggerLabels])
+ );
+ const security = when(() => mustAudit.output,
+ () => securityAudit(diff, $derived(() => plan.output.riskAreas ?? [])),
+ () => agent("no security review needed", { agent: "executor-fast", optional: true })
+ );
+
+ // ── 阶段5:置信度计算(派生链:review + security + 历史) ─────────────
+ const confidence = $derived(() => {
+ const base = read(historicalConfidence); // 先验(来自 store)
+ const reviewPenalty = review.output.findings?.filter(f => f.severity === "blocker").length ?? 0;
+ const secPenalty = security.output ? 0.3 : 0; // 有安全问题重罚
+ return Math.max(0, base - reviewPenalty * 0.2 - secPenalty);
+ });
+
+ // ── 阶段6:决策门(置信度 + 预算 驱动) ──────────────────────────────
+ const decision = gate.automated(
+ () => $derived(() => ({
+ conf: confidence.output,
+ budget: read(remainingBudget), // 读 store 派生
+ })),
+ {
+ // eval 条件(零 token 机器门)
+ pass: "{conf >= 0.85 && budget > 0}",
+ block: "{conf < 0.6 || budget <= 0}",
+ }
+ );
+
+ // ── 阶段7:人工审批(仅中等置信度区间) ──────────────────────────────
+ const humanApproval = when(
+ () => confidence.output >= 0.6 && confidence.output < 0.85,
+ () => approval({
+ request: `置信度 ${(confidence.output * 100).toFixed(0)}%,在灰区。人工确认是否合并?`,
+ input: diff.output,
+ choices: ["approve", "reject", "edit"],
+ }),
+ () => agent("auto-decision, no approval needed", { agent: "executor-fast", optional: true })
+ );
+
+ // ── 阶段8:交付动作(合并 / 标记转人工) ─────────────────────────────
+ const delivery = $derived((): DeliveryStatus => {
+ if (read(remainingBudget) <= 0) return "blocked-budget";
+ if (confidence.output < 0.6) return "needs-human";
+ if (decision.output === "BLOCK") return "needs-human";
+ return "delivered";
+ });
+
+ const prAction = when(() => delivery.output === "delivered",
+ () => script(`cd ${issue.repo} && git checkout -b "fix/${issue.id}" && git add -A && git commit -m "${issue.title}" && gh pr create --fill`,
+ { cwd: "dedicated" }),
+ () => script(`echo "marked needs-human: ${issue.id}" > .taskflow/handoff/${issue.id}.md`,
+ { cwd: "dedicated" })
+ );
+
+ // ── 阶段9:生成报告 + 写回看板(更新 store) ──────────────────────────
+ const report = agent(
+ `生成交付报告 JSON。Issue ${issue.id}, 状态 ${delivery.output}, ` +
+ `置信度 ${confidence.output}, PR: ${prAction.output}。`,
+ { agent: "doc-writer", output: json(), final: true }
+ );
+
+ // 写回历史 store(后续 issue 的 historicalConfidence/hotspotFiles 自动更新)
+ write(dashboard.history, (h) => [...h, report.output]);
+ write(dashboard.active, (m) => { m.delete(issue.id); return m; });
+
+ return report;
+});
diff --git a/examples/0.2.0-app-delivery-platform/components/review-changes.ts b/examples/0.2.0-app-delivery-platform/components/review-changes.ts
new file mode 100644
index 0000000..acbc215
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/components/review-changes.ts
@@ -0,0 +1,52 @@
+/**
+ * 组件:多视角代码审查。
+ *
+ * 像 Solid 的一个可复用组件 —— 接受 props,内部有自己的响应式状态,
+ * 可以被任意 flow 组合。内部用 tournament 让多个 agent 从不同角度审,
+ * judge 汇总。
+ *
+ * 依赖靠读取自动建立:读 changes.output、read hotspotFiles 都建依赖。
+ */
+
+import { agent, parallel, tournament, gate, $derived, read } from "taskflow";
+import { hotspotFiles } from "../stores/dashboard.ts";
+import { severityWeight } from "../lib/utils.ts";
+import type { ReviewFinding } from "../types/domain.ts";
+
+export const reviewChanges = (changes: Phase, files: Phase) => {
+ // 派生:重点文件清单 = 本次改动 ∩ 历史热点
+ const focusFiles = $derived(() => {
+ const changed = files.output;
+ const hot = read(hotspotFiles); // ← 读全局 store,自动建依赖
+ return changed.filter((f) => hot.includes(f));
+ });
+
+ // 多视角并行审查(3 个 agent 同时跑)
+ const [correctness, security, architecture] = parallel([
+ agent(
+ `Review these changes for CORRECTNESS:\n${changes.output}\n\n` +
+ `Pay extra attention to hotspots: ${focusFiles.output.join(", ")}`,
+ { agent: "reviewer", concurrency: 6 }
+ ),
+ agent(`Review for SECURITY issues:\n${changes.output}`, { agent: "security-reviewer" }),
+ agent(`Review for ARCHITECTURE/SOLID violations:\n${changes.output}`, { agent: "risk-reviewer" }),
+ ]);
+
+ // tournament:三个视角的发现交给 judge 去重 + 排序 + 选最严重
+ const ranked = tournament({
+ mode: "aggregate", // 聚合而非选一(研究合成用 aggregate)
+ judgeAgent: "final-arbiter",
+ judge:
+ `Merge the three reviewers' findings. Dedup overlaps. ` +
+ `Rank by severity. Output JSON array of findings.`,
+ branches: [correctness, security, architecture],
+ });
+
+ // 自动 gate:有 blocker 就 BLOCK(零 LLM,纯 eval 机器门)
+ const verdict = gate.automated(
+ () => ranked, // 输入
+ { block: "{findings.any(f => f.severity === 'blocker')}" } // ← eval 条件,零 token
+ );
+
+ return verdict;
+};
diff --git a/examples/0.2.0-app-delivery-platform/components/security-audit.ts b/examples/0.2.0-app-delivery-platform/components/security-audit.ts
new file mode 100644
index 0000000..8c57607
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/components/security-audit.ts
@@ -0,0 +1,29 @@
+/**
+ * 组件:深度安全审计(高风险改动专用)。
+ *
+ * 并行 3 个安全角度 + 对抗式验证(critic 专门挑刺)。
+ * 展示:parallel + tournament(best 模式,对抗)。
+ */
+
+import { agent, parallel, tournament, gate } from "taskflow";
+
+export const securityAudit = (diff: Phase, riskAreas: Phase) => {
+ const [authn, authz, crypto] = parallel([
+ agent(`审查认证(authentication)改动:\n${diff.output}\n风险区: ${riskAreas.output}`, { agent: "security-reviewer" }),
+ agent(`审查授权(authorization)改动:\n${diff.output}`, { agent: "security-reviewer" }),
+ agent(`审查加密/密钥改动:\n${diff.output}`, { agent: "security-reviewer" }),
+ ]);
+
+ // 对抗式:critic 专门攻击其他三个的结论
+ const adversarial = tournament({
+ mode: "best",
+ judgeAgent: "final-arbiter",
+ judge: "哪个审查发现了真实的安全漏洞?Quote evidence。WINNER: 。误报排除。",
+ branches: [authn, authz, crypto],
+ });
+
+ return gate(adversarial, {
+ agent: "security-reviewer",
+ onBlock: "halt", // 安全问题:halt,不自动修复
+ });
+};
diff --git a/examples/0.2.0-app-delivery-platform/components/self-heal.ts b/examples/0.2.0-app-delivery-platform/components/self-heal.ts
new file mode 100644
index 0000000..dc0d20f
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/components/self-heal.ts
@@ -0,0 +1,39 @@
+/**
+ * 组件:测试驱动的自愈循环。
+ *
+ * 实现 → 测试 → 失败就修复,直到通过或收敛或达上限。
+ * 这是个独立可复用的"自愈"组件,被多个 flow 引用。
+ *
+ * 展示:loop 内部引用 prev(上一轮)、收敛检测、条件 phase(when)。
+ */
+
+import { agent, script, loop, $derived, type Phase } from "taskflow";
+
+export const selfHeal = (opts: {
+ implement: Phase; // 初始实现 phase
+ testCmd: string; // 测试命令
+ repo: string;
+ maxIterations?: number;
+}) => {
+ const cap = opts.maxIterations ?? 4;
+
+ return loop({
+ until: "{steps.test.exit === 0}",
+ maxIterations: cap,
+ convergence: "{steps.test.output hash unchanged}", // 连续两轮错误一样 → 停(避免空转)
+ initial: opts.implement,
+ body: (prev) => ({
+ test: script(opts.testCmd, { cwd: "dedicated" }),
+ fix: agent(
+ `测试失败了。修复它。\n\n命令: ${opts.testCmd}\n` +
+ `仓库: ${opts.repo}\n上一轮输出:\n${prev.test?.output ?? prev.output}\n\n` +
+ `只改让测试通过的必要代码。不要重构。`,
+ {
+ agent: "executor-code",
+ cwd: "worktree", // 每轮在独立 worktree 改,互不污染
+ when: "{steps.test.exit !== 0}", // 测试过了就不 fix
+ }
+ ),
+ }),
+ });
+};
diff --git a/examples/0.2.0-app-delivery-platform/config/app.ts b/examples/0.2.0-app-delivery-platform/config/app.ts
new file mode 100644
index 0000000..730ea21
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/config/app.ts
@@ -0,0 +1,42 @@
+/**
+ * 应用配置 —— 声明式、可 diff、版本化。
+ * 就像 Vue 应用的配置文件,但这里是 agent 编排策略。
+ */
+
+export const config = {
+ /** 全局预算池(所有 flow 共享,实时扣减)。 */
+ budget: { maxUSD: 100, maxTokens: 5_000_000 },
+
+ /** 不同复杂度的 issue 走不同流水线(动态路由)。 */
+ routing: {
+ trivial: "fast-path", // 直接实现,跳过 tournament
+ moderate: "standard", // 规划→实现→审查
+ complex: "rigorous", // + tournament 选策略 + 多轮自愈 + 安全审查
+ unknown: "rigorous", // 未知当复杂处理
+ } as const,
+
+ /** 并发与限流。 */
+ concurrency: {
+ fileReview: 6, // 同时审查 6 个文件
+ migrationBatch: 4,
+ gateParallelism: 3, // gate 的多视角并发
+ },
+
+ /** 自愈上限(防止无限循环)。 */
+ selfHeal: {
+ maxIterations: 4,
+ convergenceCheck: true, // 连续两轮无变化则停
+ },
+
+ /** 安全门:这些标签的 issue 必须过 security-reviewer。 */
+ securityGate: {
+ triggerLabels: ["security", "auth", "crypto", "payment"],
+ requireReviewer: "security-reviewer",
+ },
+
+ /** 置信度阈值:低于此值不自动合并,转人工。 */
+ confidence: {
+ autoMergeThreshold: 0.85,
+ humanReviewThreshold: 0.6, // 低于此值直接 needs-human
+ },
+} as const;
diff --git a/examples/0.2.0-app-delivery-platform/flows/implement.ts b/examples/0.2.0-app-delivery-platform/flows/implement.ts
new file mode 100644
index 0000000..324308a
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/flows/implement.ts
@@ -0,0 +1,44 @@
+/**
+ * Flow:实现 + 自愈。
+ *
+ * 拿 Plan → 按 step 实现 → 每个 step 跑测试自愈 → 输出 diff。
+ * 被主流水线调用。
+ *
+ * 展示:map 遍历 plan.steps,每个 step 复用 selfHeal 组件,
+ * reduce 汇总所有 step 的 diff。
+ */
+
+import { flow, agent, map, reduce, script, $derived } from "taskflow";
+import { selfHeal } from "../components/self-heal.ts";
+import { chunk } from "../lib/utils.ts";
+import type { Plan } from "../types/domain.ts";
+
+export default flow("implement", ({ args }) => {
+ args.declare({ plan: { type: "object" as const }, repo: { type: "string" } });
+ const plan = args.plan as Plan;
+
+ // 分批实现(避免一次改太多文件冲突)
+ const batches = $derived(() => chunk(plan.steps, 3));
+
+ // 每个 step:实现 → 自愈。map 自动并发(批次内并发,受 concurrency 约束)
+ const stepResults = map(batches.output, (batch) =>
+ map(batch, (step) => {
+ const implemented = agent(
+ `实现这个 step:\n${step.description}\n要改的文件: ${step.files.join(", ")}`,
+ { agent: "executor-code", cwd: "worktree" } // 每 step 独立 worktree
+ );
+ return selfHeal({
+ implement: implemented,
+ testCmd: `cd ${args.repo} && npx vitest run ${step.files.join(" ")}`,
+ repo: args.repo,
+ });
+ })
+ );
+
+ // 汇总所有 step 的 diff
+ const combinedDiff = reduce([stepResults], (parts) =>
+ script(`cd ${args.repo} && git diff`, { cwd: "dedicated" }) // 零 token,拿最终 diff
+ );
+
+ return combinedDiff; // Phase(完整 diff)
+});
diff --git a/examples/0.2.0-app-delivery-platform/flows/plan.ts b/examples/0.2.0-app-delivery-platform/flows/plan.ts
new file mode 100644
index 0000000..773a308
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/flows/plan.ts
@@ -0,0 +1,50 @@
+/**
+ * Flow:需求理解 + 规划。
+ *
+ * 读 issue → 拆解 → tournament 选最优方案 → 输出 Plan。
+ * 被主流水线调用(也可单独 /tf:plan 跑)。
+ *
+ * 展示:读全局 store(historicalConfidence)指导规划,
+ * tournament 多角度起草方案,tournament 自动依赖 issue。
+ */
+
+import { flow, agent, tournament, $derived, read, json } from "taskflow";
+import { historicalConfidence } from "../stores/dashboard.ts";
+import type { Issue, Plan } from "../types/domain.ts";
+
+export default flow("plan", ({ args }) => {
+ args.declare({ issue: { type: "object" as const } }); // 传入 Issue
+ const issue = args.issue as Issue;
+
+ // 派生:用历史置信度调整规划激进程度
+ const aggressiveness = $derived(() => {
+ const conf = read(historicalConfidence); // ← 读全局 store
+ return conf > 0.8 ? "ambitious" : "conservative"; // 历史表现好就敢激进
+ });
+
+ // 三种规划思路并行起草
+ const strategy = tournament({
+ mode: "best",
+ judgeAgent: "plan-arbiter",
+ judge:
+ `选最优实现方案。考虑复杂度 ${issue.complexity}、` +
+ `历史置信度 ${read(historicalConfidence).toFixed(2)}、` +
+ `激进程度 ${aggressiveness.output}。Output JSON Plan。WINNER: 。`,
+ branches: [
+ agent(
+ `方案A - 外科手术式:最小改动,精准修复。\nIssue: ${issue.title}\n${issue.body}`,
+ { agent: "analyst", output: json() }
+ ),
+ agent(
+ `方案B - 重构式:借机优化结构。\nIssue: ${issue.title}\n${issue.body}`,
+ { agent: "analyst", output: json() }
+ ),
+ agent(
+ `方案C - 对抗式:先找每个方案的破绽,再提幸存的。\nIssue: ${issue.title}\n${issue.body}`,
+ { agent: "critic", output: json() }
+ ),
+ ],
+ });
+
+ return strategy; // Phase
+});
diff --git a/examples/0.2.0-app-delivery-platform/lib/utils.ts b/examples/0.2.0-app-delivery-platform/lib/utils.ts
new file mode 100644
index 0000000..98331a6
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/lib/utils.ts
@@ -0,0 +1,28 @@
+/**
+ * 工具库 —— 普通 TS 函数(编译期求值,零运行时开销)。
+ * 像 Solid/Vue 应用里的 utils。
+ */
+
+/** 把数组分成指定大小的批次。 */
+export function chunk(arr: T[], size: number): T[][] {
+ const out: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
+ return out;
+}
+
+/** 根据标签推断复杂度。 */
+export function inferComplexity(labels: string[]): "trivial" | "moderate" | "complex" {
+ if (labels.includes("good-first-issue") || labels.includes("trivial")) return "trivial";
+ if (labels.includes("refactor") || labels.includes("migration")) return "complex";
+ return "moderate";
+}
+
+/** 是否需要安全审查。 */
+export function needsSecurityReview(labels: string[], triggerLabels: string[]): boolean {
+ return labels.some((l) => triggerLabels.includes(l));
+}
+
+/** 给审查发现算严重度权重(用于 reduce 聚合)。 */
+export function severityWeight(s: "blocker" | "high" | "medium" | "low" | "nit"): number {
+ return { blocker: 100, high: 40, medium: 10, low: 3, nit: 1 }[s];
+}
diff --git a/examples/0.2.0-app-delivery-platform/stores/dashboard.ts b/examples/0.2.0-app-delivery-platform/stores/dashboard.ts
new file mode 100644
index 0000000..53c005f
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/stores/dashboard.ts
@@ -0,0 +1,43 @@
+/**
+ * 响应式 Store —— 全局共享状态(跨 flow / 跨 phase)。
+ *
+ * 这是 Solid 路线的精华:像 Solid 的 createRoot + createStore,
+ * 但作用域是整个交付流水线。任何 flow 读它就自动建立依赖;
+ * 它变了,只有读取方重算(overstory 增量重算)。
+ *
+ * 底层 = overstory 的 Shared Context Tree(ctx_read/ctx_write),
+ * 0.2.0 把它包装成 Solid 风格的 $store rune。
+ */
+
+import { $store, $derived } from "taskflow";
+import type { DeliveryReport, Issue } from "../types/domain.ts";
+
+/** 全局交付看板:实时跟踪所有 issue 的进度。 */
+export const dashboard = $store({
+ /** 正在处理的 issue(按 id 索引)。 */
+ active: new Map(),
+
+ /** 已完成的交付报告(累积,用于学习)。 */
+ history: [] as DeliveryReport[],
+
+ /** 累计成本(预算池实时扣减依据)。 */
+ spentUSD: 0,
+});
+
+/** 派生:剩余预算。读 dashboard.spentUSD,它变了自动重算。 */
+export const remainingBudget = $derived(() => dashboard.spentUSD < 100 ? 100 - dashboard.spentUSD : 0);
+
+/** 派生:历史平均置信度(用于新 issue 的先验)。 */
+export const historicalConfidence = $derived(() => {
+ const h = dashboard.history;
+ if (h.length === 0) return 0.5; // 无历史 → 中性先验
+ return h.reduce((s, r) => s + r.confidence, 0) / h.length;
+});
+
+/** 派生:哪些文件最常出问题(用于指导审查资源分配)。 */
+export const hotspotFiles = $derived(() => {
+ const counts = new Map();
+ for (const r of dashboard.history)
+ for (const f of r.findings) counts.set(f.file, (counts.get(f.file) ?? 0) + 1);
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([f]) => f);
+});
diff --git a/examples/0.2.0-app-delivery-platform/types/domain.ts b/examples/0.2.0-app-delivery-platform/types/domain.ts
new file mode 100644
index 0000000..6968122
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/types/domain.ts
@@ -0,0 +1,63 @@
+/**
+ * 类型定义 —— 整个应用共享的领域模型。
+ * 像写 Solid/Vue 应用一样,类型是契约,编译期就能抓住错误。
+ */
+
+/** 一个待交付的工作单元(来自 GitHub issue / Linear / 飞书任务)。 */
+export interface Issue {
+ id: string;
+ title: string;
+ body: string;
+ labels: string[]; // ["bug", "feature", "refactor", "security", ...]
+ repo: string; // "org/repo"
+ author: string;
+ complexity: "trivial" | "moderate" | "complex" | "unknown";
+}
+
+/** 规划阶段的产物:把 issue 拆成可执行的 steps。 */
+export interface Plan {
+ summary: string;
+ approach: "surgical" | "rewrite" | "hybrid";
+ steps: PlanStep[];
+ affectedFiles: string[];
+ riskAreas: string[]; // ["auth", "db-migration", ...]
+ estimatedComplexity: number; // 1-10
+}
+
+export interface PlanStep {
+ id: string;
+ description: string;
+ files: string[]; // 这个 step 会动的文件
+ kind: "implement" | "test" | "refactor" | "doc";
+}
+
+/** 代码审查的发现。 */
+export interface ReviewFinding {
+ severity: "blocker" | "high" | "medium" | "low" | "nit";
+ category: "correctness" | "security" | "performance" | "style" | "architecture";
+ file: string;
+ line?: number;
+ message: string;
+ evidence: string;
+}
+
+/** 交付的最终状态。 */
+export type DeliveryStatus =
+ | "delivered" // 合并到 main
+ | "needs-human" // 需要人工介入
+ | "blocked-budget" // 预算耗尽
+ | "blocked-conflict" // 合并冲突无法自愈
+ | "rejected"; // 审查不通过且无法修复
+
+/** 交付报告。 */
+export interface DeliveryReport {
+ issue: Issue;
+ status: DeliveryStatus;
+ plan: Plan;
+ prUrl?: string;
+ findings: ReviewFinding[];
+ confidence: number; // 0-1,综合置信度
+ costUSD: number;
+ iterations: number; // 自愈循环跑了多少轮
+ trace: string; // 决策链(可追溯到 key@version)
+}
diff --git a/examples/0.2.0-demo-smart-migration.tf.ts b/examples/0.2.0-demo-smart-migration.tf.ts
new file mode 100644
index 0000000..91242d3
--- /dev/null
+++ b/examples/0.2.0-demo-smart-migration.tf.ts
@@ -0,0 +1,196 @@
+/**
+ * taskflow 0.2.0 — Solid 路线 DSL 复杂度演示
+ *
+ * 场景:大型 monorepo 的"智能迁移 + 安全审计 + 自愈"系统。
+ * 把 styled-components 全量迁移到 Tailwind,同时跑安全审计,
+ * 失败自动修复,最后 tournament 选最优策略汇总。
+ *
+ * 这个 demo 展示 Solid 路线能写出的工程级复杂度:
+ * - 响应式组合(flow 片段像 Solid 组件一样组合)
+ * - 自动依赖追踪(读 .output 即依赖,不用手写 dependsOn)
+ * - 派生状态($derived,中间指标自动计算)
+ * - 细粒度更新(overstory:改一个文件只重算相关分支)
+ * - 跨文件复用(components/ 里是可复用的 flow 组件)
+ * - 多层 map 嵌套 + gate 链 + tournament + loop 自愈 + 动态子流程
+ *
+ * 对照:这个逻辑用现在的 JSON DSL 写大约要 300+ 行嵌套 + 字符串模板,
+ * 且依赖要手写、没有类型、改一个输入全量重跑。
+ */
+
+import { flow, agent, map, parallel, gate, reduce, tournament, loop, approval, script } from "taskflow";
+import { $derived, $state, json, read, type Phase } from "taskflow";
+import { migrateOneFile } from "./components/migrate-one.ts";
+import { auditOneFile } from "./components/audit-one.ts";
+
+// ============================================================================
+// 主 flow —— 像写一个 Solid App:组合多个"组件"(子 flow)
+// ============================================================================
+export default flow("smart-migration", ({ args, budget }) => {
+ args.declare({ repo: { type: "string", default: "." }, dryRun: { type: "boolean", default: false } });
+ budget({ maxUSD: 50, maxTokens: 2_000_000 });
+
+ // ── 阶段 1:并行侦察(两个独立 agent,自动并发) ──────────────────────
+ const [files, securityBaseline] = parallel([
+ agent("List every .tsx using styled-components under {args.repo}. Output JSON array of file paths.", {
+ agent: "scout", output: json(), retry: { max: 2 },
+ }),
+ agent("Establish security baseline: list all current auth middleware.", { agent: "scout" }),
+ ]);
+ // ↑ files 和 securityBaseline 自动并发;且它们之间无依赖边。
+
+ // ── 阶段 2:派生指标(像 Solid 的 const doubled = createMemo) ─────────
+ const plan = $derived(() => ({
+ total: files.output.length,
+ batches: chunk(files.output, 8), // 分批,每批 8 个文件
+ riskProfile: securityBaseline.output.includes("no-auth") ? "high" : "normal",
+ }));
+ // ↑ $derived 是派生计算 —— 它依赖 files 和 securityBaseline;
+ // files 变了它会自动重算;这就是 overstory 的响应式,也是 Solid 的 createMemo。
+
+ // ── 阶段 3:动态规划(tournament 选最佳迁移策略) ─────────────────────
+ const strategy = tournament({
+ mode: "best",
+ judgeAgent: "final-arbiter",
+ judge: `Judge on: ${plan.output.riskProfile} risk repo. correctness vs blast radius. WINNER: .`,
+ branches: [
+ agent("Strategy A: codemod-first, manual review. Lowest blast radius.", { agent: "analyst" }),
+ agent("Strategy B: AI-rewrite each file fresh. Highest correctness.", { agent: "analyst" }),
+ agent("Strategy C: hybrid — codemod then AI-fix residuals.", { agent: "critic" }),
+ ],
+ });
+ // ↑ tournament 的 judge 读了 plan.output —— 自动依赖 plan → files → securityBaseline。
+
+ // ── 阶段 4:分批迁移 + 每文件自愈(map 嵌套复用组件) ──────────────────
+ const migrations = map(plan.output.batches, (batch, batchIdx) =>
+ flow.component(migrateOneFile, { // ← 复用 components/migrate-one.ts
+ file: batch,
+ strategy: strategy.output,
+ dryRun: args.dryRun,
+ })({
+ // migrateOneFile 内部是 "migrate → test → fix loop" (见下方组件定义)
+ // 这里 map 自动让每个 batch 并发,batch 内部串行
+ })
+ );
+
+ // ── 阶段 5:并行安全审计(复用另一个组件) ───────────────────────────
+ const audits = map(files.output, (f) =>
+ flow.component(auditOneFile, { file: f, baseline: securityBaseline.output })
+ );
+
+ // ── 阶段 6:交叉验证 gate(不同 agent 复核迁移 + 审计) ────────────────
+ const verified = gate(
+ parallel([migrations, audits]),
+ { agent: "reviewer", onBlock: "retry" },
+ (both) => agent(
+ `Cross-check: did any migration introduce a security regression vs the audit?\n` +
+ `Migrations:\n${both.a.output}\n\nAudits:\n${both.b.output}\n\n` +
+ `VERDICT: BLOCK if any regression, else PASS.`
+ )
+ );
+
+ // ── 阶段 7:全局自愈 loop(整体不过就重新规划) ───────────────────────
+ const healed = loop({
+ until: "{steps.verifyCheck.exit === 0}",
+ maxIterations: 3,
+ body: (prev) => ({
+ verifyCheck: script(
+ `cd {args.repo} && npx tsc --noEmit && npx vitest run`,
+ { cwd: "dedicated" }
+ ),
+ replan: agent( // ← loop 里引用 prev,自动建依赖
+ `Previous round failed verification:\n${prev.verifyCheck.output}\n\n` +
+ `Replan the remaining migrations. Strategy was: ${strategy.output}`,
+ { agent: "planner", when: "{steps.verifyCheck.exit !== 0}" }
+ ),
+ }),
+ })(verified); // ← loop 依赖 verified,自动建边
+
+ // ── 阶段 8:人工审批(高风险仓库才触发) ─────────────────────────────
+ const approved = approval({
+ when: () => plan.output.riskProfile === "high", // ← 条件 gate,TS 函数谓词
+ request: "This is a HIGH-risk repo. Approve final merge?",
+ input: healed.output,
+ choices: ["approve", "reject", "edit"],
+ });
+
+ // ── 阶段 9:最终汇总(派生 + reduce) ────────────────────────────────
+ const report = reduce([healed, audits, strategy], (parts) =>
+ agent(
+ `Write an executive migration report.\n` +
+ `Files migrated: ${plan.output.total}\n` +
+ `Strategy chosen: ${parts.strategy.output}\n` +
+ `Security findings: ${parts.audits.output}\n` +
+ `Final state: ${parts.healed.output}`,
+ { agent: "doc-writer", final: true }
+ )
+ );
+
+ return report;
+});
+
+// ============================================================================
+// 组件 1:迁移单个文件(内含 migrate→test→fix 自愈循环)
+// 文件:components/migrate-one.ts —— 像 Solid 的一个可复用组件
+// ============================================================================
+export const migrateOneFile = flow.component(
+ "migrate-one",
+ ({ props }: { props: { file: string; strategy: string; dryRun: boolean } }) => {
+
+ const migrated = agent(
+ `Migrate ${props.file} from styled-components to Tailwind.\n` +
+ `Use strategy: ${props.strategy}\n` +
+ `Dry-run: ${props.dryRun}`,
+ { agent: "executor-code", cwd: "worktree" } // ← 每文件独立 git worktree,互不干扰
+ );
+
+ // 组件内的自愈循环:test 不过就 fix,最多 3 轮
+ const healed = loop({
+ until: "{steps.test.exit === 0}",
+ maxIterations: 3,
+ convergence: "{steps.test.output hash unchanged}", // 连续两轮错误一样就停(收敛)
+ body: (prev) => ({
+ test: script(`cd {props.file} && npx vitest run`, { cwd: "dedicated" }),
+ fix: agent(
+ `Tests failed for ${props.file}:\n${prev.test.output}\nFix the migration.`,
+ { agent: "executor", when: "{steps.test.exit !== 0}" }
+ ),
+ }),
+ })(migrated);
+
+ return healed;
+ }
+);
+
+// ============================================================================
+// 组件 2:审计单个文件(并行跑 3 个角度 + reduce)
+// 文件:components/audit-one.ts
+// ============================================================================
+export const auditOneFile = flow.component(
+ "audit-one",
+ ({ props }: { props: { file: string; baseline: string } }) => {
+
+ // 三视角并行审计(像 Solid 里一个组件内开多个 createSignal)
+ const [auth, injection, regression] = parallel([
+ agent(`Audit ${props.file} for auth regressions vs baseline:\n${props.baseline}`, { agent: "risk-reviewer" }),
+ agent(`Audit ${props.file} for injection risks introduced by migration.`, { agent: "security-reviewer" }),
+ agent(`Audit ${props.file} for behavioral regressions.`, { agent: "reviewer" }),
+ ]);
+
+ // gate:任一视角 BLOCK 则整个审计 BLOCK (join: any)
+ const verified = gate(
+ parallel([auth, injection, regression], { join: "any" }),
+ { agent: "final-arbiter" }
+ );
+
+ return verified;
+ }
+);
+
+// ============================================================================
+// 工具:分批(普通 TS 函数,编译期求值 —— Svelte 路线也能用)
+// ============================================================================
+function chunk(arr: T[], size: number): T[][] {
+ const out: T[][] = [];
+ for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
+ return out;
+}
From a5a1dab63cb68fdccd33507860442753266c9f3a Mon Sep 17 00:00:00 2001
From: heggria
Date: Tue, 7 Jul 2026 16:16:52 +0800
Subject: [PATCH 03/51] =?UTF-8?q?docs(0.2.0):=20DSL=20RFC=20v2=20=E2=80=94?=
=?UTF-8?q?=20resolve=20identity=20crisis,=20respond=20to=20multi-agent=20?=
=?UTF-8?q?review?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rewrites the DSL syntax RFC in direct response to review-020-design.
Headline change: a new §0 Execution Model pins down what v1 left
ambiguous.
Root decision (resolves review FEASIBILITY #1/#2/#4/#5): runes are
COMPILE DIRECTIVES, not runtime functions. `taskflow build` is an AST
transform that reads .tf.ts source without executing it (Svelte route,
not Solid). The critic proved "read .output → auto-dependency" is
physically impossible at authoring time (phase hasn't run; Proxy
.toString() collapses on method chains / numeric coercion). Compile-time
AST extraction makes auto-dependency, type inference, and
template→placeholder conversion all hold cleanly.
Honest costs acknowledged + mitigated (§0.4): no "degraded run", no rune
debugging — compensated by verify/compile/peek + new `taskflow check`
(fast feedback) and `taskflow new` (5-line skeleton). JSON remains the
"degraded" escape hatch.
Every review finding has a v2 response (§B decision log): identity
crisis resolved; map's `item` is a compile-time typed symbol;
json() via compiler transform (complex types error, no silent
degradation); loop multi-phase body moved to post-0.2.0; parallel
destructuring is compile-time; decompiler designed (§6.2); approval.input
& args.type (fabricated) removed; {loop.*} added, {env.X} marked new;
demo post-0.2.0 features to be annotated; unified rune signatures;
single interpolation form; hello-world skeleton.
---
docs/rfc-0.2.0-dsl-syntax.md | 612 +++++++++++++++--------------------
1 file changed, 261 insertions(+), 351 deletions(-)
diff --git a/docs/rfc-0.2.0-dsl-syntax.md b/docs/rfc-0.2.0-dsl-syntax.md
index 51e3aa9..43883f8 100644
--- a/docs/rfc-0.2.0-dsl-syntax.md
+++ b/docs/rfc-0.2.0-dsl-syntax.md
@@ -1,38 +1,105 @@
-# RFC: taskflow 0.2.0 TypeScript 函数式 DSL —— 语法规范
-
-> Status: **Draft** · 2026-07-07
-> 路线决策:Solid 路线(rune 是真函数,可调试;见 `docs/rfc-0.2.0-three-compile-routes.md`)
-> **三个硬约束:**
-> 1. **100% 功能覆盖** —— taskflow 当前的每一个字段/能力都有对应 DSL 语法(本文 §A 完整对照表)。
-> 2. **向前兼容** —— 现有 JSON flow 零修改继续工作;JSON ↔ DSL 双向可编译(§6)。
+# RFC v2: taskflow 0.2.0 TypeScript 函数式 DSL — 语法规范
+
+> Status: **Draft v2** · 2026-07-07
+> 取代 v1(同文件前版)。v2 是对多 agent review(run `review-020-design`)的回应:
+> **身份危机**(Solid 真函数 vs Svelte 编译指令)已定调为**编译指令路线**。
+>
+> **三个硬约束(不变):**
+> 1. **100% 功能覆盖** —— taskflow 当前的每一个字段/能力都有对应 DSL 语法(§A 完整对照)。
+> 2. **向前兼容** —— 现有 JSON flow 零修改继续工作;JSON ↔ DSL 双向可编译(§6,v2 给出真实 decompiler 设计)。
> 3. **向前拓展** —— 加新 phase 类型/字段不需要改语法(§7)。
+>
+> **v2 相对 v1 的根本变化:** §0 执行模型从"模糊的 Solid 路线"改为**明确的编译指令路线**(rune 运行时擦除)。
---
-## 0. 设计契约
+## 0. 执行模型(Execution Model)—— v2 新增,定调
+
+> **这是整份 RFC 的地基。v1 的"身份危机"(review FEASIBILITY #1)源于这里没写。v2 先把它钉死。**
+
+### 0.1 一个 `.tf.ts` 文件是什么
+
+一个 `.tf.ts` 文件是 **agent workflow 的源代码**。它**不是运行时直接执行的脚本**。
+
+```ts
+// audit.tf.ts —— 源代码
+import { flow, agent, map, gate, reduce, json } from "taskflow";
+
+export default flow("audit", (ctx) => {
+ const discover = agent("List files under {args.dir}", { output: json<{route:string}[]>() });
+ const audit = map(discover, (item) => agent(`Audit ${item.route}`));
+ return reduce([audit], (p) => agent(`Report: ${p.audit.output}`));
+});
+```
+
+### 0.2 `taskflow build` 是什么 —— **AST transform,不是运行时**
-- **`.tf.ts` 是合法 TypeScript。** `tsc` 能过 = 类型正确。`taskflow build` 把它编译成 FlowIR(就像 Solid 把 JSX 编译成 DOM 更新)。
-- **rune 是真函数**,运行时返回一个 `Phase` 对象(可 `.output` 读取,自动建立依赖)。离开编译器也能降级运行(解释执行)。
-- **依赖靠"读取"自动建立** —— 读 `x.output` = 依赖 `x`。零手写 `dependsOn`(但保留 `dependsOn` 字段作为显式补充,§5.4)。
-- **JSON 仍是一等公民** —— `build flow.json` 和 `build flow.tf.ts` 都产出同一个 FlowIR。
+`taskflow build audit.tf.ts` 做三件事:
+
+```
+audit.tf.ts (源代码)
+ │
+ ▼ ① tsc 类型检查(agent 拼错 item.route 在这里报错)
+ │
+ ▼ ② taskflow 编译器:AST transform(读源码,不执行)
+ │ - 扫描 rune 调用(agent/map/gate/...),每个产出一个 FlowIR node
+ │ - 扫描模板字面量 `Audit ${item.route}` → 产 `{item.route}` 占位符
+ │ - 扫描 map 回调 → 产 per-item 任务模板
+ │ - 扫描 json() 的类型参数 → 产 TypeBox schema(填进 expect)
+ │ - 静态收集依赖(谁读了谁的 .output)→ 产 inject/emits 边
+ │
+ ▼ ③ 输出 FlowIR(内容寻址, hash)
+```
+
+**关键:`.tf.ts` 离开 `build` 不能直接 run。** 这是对 review FEASIBILITY #1/#2/#4 的诚实回应:
+
+- rune(`agent()`/`map()`/...)是**编译器识别的指令**,不是运行时函数。它们的"返回值"在编译期被消费(转成 FlowIR node),运行时不存在这些调用。
+- `discover.output` 不是运行时属性读取(那样会撞上"phase 还没执行"的物理矛盾,见 §0.3),而是**编译期的符号引用** —— 编译器看到 `discover` 被引用,就建一条依赖边。
+- `${item.route}` 不是运行时模板求值,而是**编译期从 AST 提取** —— 编译器看到模板字面量里的 `item.route`,转成 `{item.route}` 占位符字符串写进 FlowIR。
+
+### 0.3 为什么是真函数(Proxy)路线站不住 —— review 的论证
+
+v1 想要"rune 是真函数,运行时返回 Phase,读 `.output` 自动建依赖"。review 的 critic 证明了这**物理上不可能**:
+
+```ts
+const discover = agent("List files..."); // discover "执行"了吗?没有。
+const audit = agent(`Audit ${discover.output}`); // discover.output 此刻是什么?
+```
+
+- `discover` 只是"声明要做的事",**从未执行**。`discover.output` 没有任何值。
+- 若 `discover.output` 是 Proxy,其 `.toString()` 要返回 `"{steps.discover.output}"` 才能让模板工作。但 `${item.route.slice(0,10)}`、`${item.items.length}`、数值上下文 —— **任何正常 JS 操作都让 Proxy 链断裂**,产生垃圾或丢依赖。这等于要求 author 记住"这些值是幻影",与"像写 Solid 一样自然"矛盾。
+- 依赖记到哪?Solid 有 `currentObserver`。taskflow 的 `flow()` 回调里没有"当前 phase"上下文(`audit` 的 `agent()` 还没返回),需要栈内省,RFC 没设计。
+
+**结论:依赖追踪、类型推导、占位符转换这三个 headline 特性,本质都要求编译器"看见"源码(AST),而非运行时 Proxy 猜测。编译指令路线让它们在编译期干净成立。**
+
+### 0.4 诚实代价 & 缓解
+
+**代价:** `.tf.ts` 不能脱离 `build` 直接 run;不能在 `.tf.ts` 里断点调试 rune 调用;没有"降级运行"。
+
+**缓解(强工具链补偿):**
+- `taskflow verify`(零 token)在 FlowIR 上跑静态检查 —— 编译期就能抓 DAG 错误。
+- `taskflow compile` 出 Mermaid 图 —— 可视化 DAG。
+- `taskflow peek ` —— 运行时可看任意 phase 的实际输入/输出(已有的调试逃生口)。
+- `taskflow check`(新,见 §8)—— 比 build 轻的快速校验,给 agent 快反馈。
+
+> **JSON 仍是"可降级"的逃生口:** 不想走编译的,直接写/运行 JSON flow(它本就是 FlowIR 的另一种 surface)。这等于 Vue Vapor 的"双模式共存",但无需维护双执行后端(两者都编译到同一个 FlowIR)。
---
## 1. 文件骨架
```ts
-import { flow, agent, map, parallel, gate, reduce, loop, tournament, approval, flow as subflow, script, json, type Phase } from "taskflow";
+import { flow, agent, map, parallel, gate, reduce, loop, tournament, approval, script, json, type Phase } from "taskflow";
export default flow("audit-endpoints", (ctx) => {
- // ctx: { args, budget, scope, strict, share, incremental, concurrency }
- // —— ctx 上的方法对应 Taskflow 顶层字段(见 §2)
- // ... phases 用 rune 声明 ...
- return finalPhase; // return 标记 final phase(等价 final:true)
+ // ctx 方法对应 Taskflow 顶层字段(§2)
+ // rune(agent/map/...)是编译指令,编译期被转成 FlowIR(§0)
+ return finalPhase; // return 标记 final phase
});
```
-- 文件 `export default flow(name, fn)` = 一个 taskflow(等价 JSON 的顶层对象)。
-- `name` 必填;其余顶层字段通过 `ctx` 的方法设置。
+- 文件 `export default flow(name, fn)` = 一个 taskflow。
+- `name` 必填;其余顶层字段通过 `ctx` 方法。
---
@@ -41,430 +108,273 @@ export default flow("audit-endpoints", (ctx) => {
| JSON 字段 | DSL 写法 | 说明 |
|---|---|---|
| `name` | `flow("audit", ...)` 第 1 参数 | 必填 |
-| `description` | `flow("audit", { description: "..." }, ...)` 第 2 参数对象 | 可选 |
-| `version` | 同上 options 对象 `version: 2` | 可选,默认 1 |
-| `args` | `ctx.args.declare({ dir: { default: "src", type: "string", required: true } })` | §3 |
+| `description` | `flow("audit", { description: "..." }, ...)` 第 2 参数 | 可选 |
+| `version` | 同 options `version: 2` | 可选,默认 1 |
+| `args` | `ctx.args.declare({ dir: { default: "src", required: true, description: "..." } })` | §3。**v2 修正:** 删掉 v1 臆造的 `type` 字段(ArgSpecSchema 只有 default/description/required),补回 `description` |
| `concurrency` | `ctx.concurrency(8)` | 默认 8 |
-| `budget` | `ctx.budget({ maxUSD: 3, maxTokens: 1e6 })` | §4.1 |
-| `agentScope` | `ctx.scope("both")` | "user"\|"project"\|"both" |
-| `strictInterpolation` | `ctx.strict()` | 布尔,默认 false |
-| `contextSharing` | `ctx.share(true)` | 全局 shareContext |
+| `budget` | `ctx.budget({ maxUSD: 3, maxTokens: 1e6 })` | |
+| `agentScope` | `ctx.scope("both")` | user\|project\|both |
+| `strictInterpolation` | `ctx.strict()` | 默认 false |
+| `contextSharing` | `ctx.share(true)` | |
| `incremental` | `ctx.incremental(true)` | 全局 cross-run 缓存 |
| `phases` | rune 声明自动构成 | §3 |
-**示例:**
-```ts
-export default flow("audit", { description: "Audit all endpoints", version: 2 }, (ctx) => {
- ctx.concurrency(8);
- ctx.budget({ maxUSD: 3.0 });
- ctx.scope("both");
- ctx.incremental(true); // 全 flow 跨运行缓存
- ctx.args.declare({ dir: { default: "src/routes", type: "string" } });
- // ...
-});
-```
-
---
-## 3. 通用 Phase 字段(所有 phase 类型共有)—— 全覆盖
+## 3. 通用 Phase 字段 —— 全覆盖
-每个 rune(`agent`/`map`/...)都接受一个 **options 对象**,承载所有通用字段:
+每个 rune 接受一个 **options 对象**,承载所有通用字段(24 个,逐字段对照见 §A):
```ts
-agent("task string", {
- // —— 身份与执行 ——
- agent: "scout", // 用哪个 agent
- id: "discover", // 可选显式 id(默认自动生成)
- model: "fast", thinking: "high", // 模型/思考级别覆盖
- tools: ["read", "grep"], // 工具白名单
- cwd: "worktree", // 工作目录:路径或 temp/dedicated/worktree
-
- // —— 输出 ——
- output: "json", // "text"(默认) | "json"
- expect: { type: "array", items: { type: "string" } }, // JSON 契约(需 output:json)
- final: true, // 标记为 flow 结果(或用 return)
-
- // —— 控制流 ——
- when: "{env.CI == '1'}", // 条件守卫(字符串 eval 或 TS 谓词,§5.1)
- join: "any", // "all"(默认) | "any"
- dependsOn: ["plan"], // 显式补充依赖(通常自动,§5.4)
+agent("task", {
+ agent: "scout", model: "fast", thinking: "high", tools: ["read"],
+ cwd: "worktree",
+ output: "json", expect: { type: "array", items: { type: "string" } },
+ when: "{env.CI} == '1'", // §5.1:字符串 eval(v2 明确 {env.X} 是新能力,见 §5.2)
+ join: "any",
+ dependsOn: ["plan"], // 显式补充(通常自动,§5.4)
retry: { max: 3, backoffMs: 500, factor: 2 },
- timeout: 60_000, // 每 subagent 调用的 ms 上限
-
- // —— 可靠性 ——
- optional: true, // 失败不阻断
- idempotent: false, // 有副作用,不自动重试/不缓存
- concurrency: 4, // map/parallel 并发覆盖
-
- // —— 上下文 ——
- context: ["src/api.ts", "{steps.plan.json}"], // 预读注入
- contextLimit: 8000, // 每文件最大字符
- shareContext: true, // 开 Shared Context Tree
-
- // —— 缓存 ——
- cache: { scope: "cross-run", ttl: "7d", fingerprint: ["git:HEAD", "glob:src/**"] },
+ timeout: 60_000,
+ optional: true, idempotent: false,
+ concurrency: 4,
+ context: ["src/api.ts"], contextLimit: 8000,
+ shareContext: true,
+ cache: { scope: "cross-run", ttl: "7d", fingerprint: ["git:HEAD"] },
});
```
-> **对照表(§A)列出每一个字段的 JSON↔DSL 映射。** 这里先给直觉,细节在 §A。
-
---
-## 4. 10 种 Phase 类型的专属语法—— 全覆盖
+## 4. 10 种 Phase 类型 —— 全覆盖(含 v2 修正)
-### 4.1 `agent` —— 单个 subagent
+### 4.1 `agent`
```ts
-const d = agent("List files under {args.dir}", {
- agent: "scout", output: json<{route:string;file:string}[]>(), expect: {...}, retry: {max:2},
-});
+const d = agent("List files", { agent: "scout", output: json<{route:string}[]>() });
```
-> `json()` 是 `output:"json"` + `expect` 的类型推导糖(TS 泛型 → TypeBox schema)。
+> `json()` 是 `output:"json"` + `expect` 的糖。**v2 明确:** 泛型 → TypeBox schema 的推导由**编译器 transform** 完成(运行时泛型擦除,见 §0.2 ②)。可推导子集:基本类型/array/object/可选字段。复杂类型(联合/映射/递归)推导不了 → **编译报错,要求显式 `expect`**(不静默降级,review COMPAT MEDIUM-1)。
### 4.2 `map` —— 动态扇出
```ts
-const audits = map(discover, (item) => // over = discover;as 默认 item
- agent(`Audit ${item.route} in ${item.file}`, { agent: "analyst", concurrency: 4 })
-);
-// 等价 JSON: { type:"map", over:"{steps.discover.json}", as:"item", task:"Audit {item.route}..." }
+const audits = map(discover, (item) => agent(`Audit ${item.route}`, { agent: "analyst" }));
```
-- `over` = 第 1 参数(一个 Phase);`as` = 回调形参名(默认 `item`)。
-- 回调体内用 `item.route` —— **编译期类型检查**(对比 JSON 的 `{item.rout}` 拼错运行时才发现)。
+- `over` = 第 1 参数;`as` = 回调形参名(默认 `item`)。
+- **v2 澄清(review FEASIBILITY #3):** 回调在**编译期执行一次**(由编译器,非运行时),`item` 是编译器已知的类型化符号(类型来自 `discover` 的 `json`)。编译器把回调体转成 per-item 任务模板 `{item.route}`。运行时不重新执行回调。
-### 4.3 `parallel` —— 静态并发分支
+### 4.3 `parallel`
```ts
-const [a, b, c] = parallel([
- agent("audit auth", { agent: "analyst" }),
- agent("audit perf", { agent: "analyst" }),
- agent("audit validation", { agent: "analyst" }),
-], { concurrency: 3 });
-// 等价 JSON: { type:"parallel", branches:[{task,agent},...] }
+const [a, b] = parallel([ agent("auth"), agent("perf") ], { concurrency: 2 });
```
-- `branches` = 数组参数;每项是 `agent()` 调用或 `{ task, agent }` 对象(二选一,兼容)。
+**v2 澄清(review FEASIBILITY #8):** 解构 `[a,b]` 是**编译期**的 —— 编译器把 `parallel([...])` 转成一个 parallel phase + 给每个 branch 一个合成 id;`a`/`b` 是这些 branch 的符号引用(可被下游 `a.output` 引用,编译器转成对应 branch 的占位符)。不是运行时多返回值。
-### 4.4 `gate` —— 质量门(三种形态全覆盖)
+### 4.4 `gate`(三形态)
```ts
-// (a) LLM gate —— task 是判断 prompt
-const g = gate(findings, { agent: "reviewer", onBlock: "retry" },
- (input) => `Cross-check. VERDICT: PASS/BLOCK.\n${input.output}`);
-
-// (b) eval gate —— 零 token 机器门
-const auto = gate.automated(build, {
- pass: ["{steps.build.output} contains 'BUILD SUCCESS'", "{steps.test.json.failures} == 0"],
-});
-
-// (c) score gate —— 确定性打分 + LLM judge fallback
-const scored = gate.scored(gen, {
- target: "{steps.gen.output}",
- scorers: [{ type: "exact-match", value: "PASS" }, { type: "length-range", min: 10 }],
- combine: "weighted", weights: [1, 1], threshold: 0.8,
- judge: { agent: "reviewer", task: "decide if borderline passes" },
-});
+gate(audit, { agent: "reviewer", onBlock: "retry" }, (i) => `Check.\n${i.output}`);
+gate.automated(build, { pass: ["{steps.build.output} contains 'OK'"] });
+gate.scored(gen, { target: "{steps.gen.output}", scorers: [...], combine: "weighted", threshold: 0.8, judge: {...} });
```
-> 三个 rune(`gate`/`gate.automated`/`gate.scored`)分别对应 JSON 的 `gate`+task / `eval` / `score`。`onBlock` 通用。
-### 4.5 `reduce` —— 聚合 N→1
+### 4.5 `reduce`
```ts
-const summary = reduce([auth, perf, validation], (parts) =>
- agent(`Merge:\nauth:${parts.auth.output}\nperf:${parts.perf.output}`, { agent: "doc-writer" })
-);
-// 等价 JSON: { type:"reduce", from:["auth","perf","validation"], task:"..." }
+const sum = reduce([auth, perf], (p) => agent(`Merge ${p.auth.output} ${p.perf.output}`));
```
-- `from` = 第 1 参数(Phase 数组);回调参数是命名 map(按数组顺序或显式 key)。
-### 4.6 `loop` —— 循环到条件/收敛/上限
+### 4.6 `loop` —— **v2 重大修正(review COVERAGE F2 / FEASIBILITY #6)**
+
+v1 的多 phase body(`body: (prev) => ({ test, fix })`)是**引擎扩展**,不是当前能力。v2 分两档:
+
+**当前能力(100% 覆盖当前 loop):** loop body 是单个 agent 任务(对应 `phase.task`):
```ts
-const fixed = loop({
- until: "{steps.test.exit === 0}",
+const refined = loop({
+ agent: "executor",
maxIterations: 5,
- convergence: true, // 默认 true:输出不变则停
- reflexion: true, // 给下一轮注入 {reflexion} 反思
- body: (prev) => ({
- test: script("npx tsc --noEmit"),
- fix: agent(`Fix:\n${prev.test.output}`, { when: "{steps.test.exit !== 0}" }),
- }),
+ until: "{steps.refined.json.done} == true",
+ convergence: true,
+ reflexion: true,
+ task: (prev) => `Improve the draft. Previous:\n${prev.output}\nOutput JSON {done, draft}.`,
+ output: json<{done:boolean; draft:string}>(),
});
-// 等价 JSON: { type:"loop", until, maxIterations, convergence, reflexion, ... }
```
+- `prev.output` 引用上一轮的 `{steps..output}`。
+- 回调体仍是**单个任务字符串**(编译器转成 `phase.task` 模板)。
+
+**未来扩展(§7,不是覆盖):** 多 phase body(test→fix)需要引擎支持 loop 内嵌子图 —— 明确标为 **post-0.2.0**,v2 不声称覆盖。当前用 `onBlock: "retry"` 的 gate + 外部 script 组合实现等价语义。
-### 4.7 `tournament` —— best-of-N + judge
+### 4.7 `tournament`
```ts
-const best = tournament({
- mode: "best", // "best" | "aggregate"
- judge: "Judge on correctness. WINNER: .",
- judgeAgent: "final-arbiter",
- branches: [ // 显式分支(或用 variants:N 从 task 生成)
- agent("conservative fix", { agent: "analyst" }),
- agent("optimal correctness", { agent: "analyst" }),
- ],
- // 或者:variants: 3, task: "design a fix"(从单 task 派生 N 个)
+tournament({
+ mode: "best", judgeAgent: "final-arbiter", judge: "Pick best. WINNER: .",
+ branches: [ agent("A"), agent("B") ], // 或 variants: 3 + task
});
```
-### 4.8 `approval` —— 人机交互
+### 4.8 `approval` —— **v2 修正(review COVERAGE F1)**
```ts
-const ok = approval({
- request: "Approve this plan?",
- input: plan.output, // 传给审批者的内容
-});
-// 等价 JSON: { type:"approval", task:"...", input:"{steps.plan.output}" }
+const ok = approval({ request: "Approve this plan?" });
```
+**v2 删除 v1 臆造的 `input` 字段**(approval 没有 `input`;`input` 是 script 专属,见 schema.ts)。审批内容来自 `task`(这里是 `request`)—— 运行时通过 DAG 的上游输出自动注入(`runtime.ts` 的 `upstream`)。**依赖靠 `dependsOn`(显式或自动)。**
-### 4.9 `flow` (子流程) —— use / def / with 全覆盖
+### 4.9 `flow`(子流程)
```ts
-// (a) use —— 调用已保存的 taskflow
-const r = subflow("deep-research", { question: "..." }); // with = 第2参数
-
-// (b) def —— 内联子流程(动态)
-const r2 = subflow.def(() => { // 运行时解析
- return agent("dynamic sub-flow based on runtime data");
-}, { with: { x: 1 } });
+subflow("deep-research", { question: "..." }); // use + with
+subflow.def(() => agent("dynamic")); // def(内联,运行时解析)
```
-### 4.10 `script` —— 零 token shell
+### 4.10 `script`
```ts
-const lint = script("npx eslint src/ --format json", { cwd: "dedicated" });
-// lint.exit / lint.stdout / lint.stderr
-
-const safe = script(["grep", "-r", args.dir, "--files-with-matches"]); // 数组 = execvp(防注入)
-script("cat", { input: "{steps.gen.output}" }); // stdin 管道
+script("npx tsc --noEmit", { cwd: "dedicated" }); // string = shell
+script(["grep", "-r", args.dir]); // array = execvp(防注入)
+script("cat", { input: "{steps.gen.output}" }); // stdin
```
---
-## 5. 控制流与表达式—— 全覆盖
+## 5. 控制流与表达式
+
+### 5.1 `when` —— **v2 收窄(review COMPAT MEDIUM-HIGH-1)**
-### 5.1 `when` —— 条件守卫(两种形态)
+两种形态:
```ts
-agent("...", { when: "{env.MODE} == 'prod'" }); // (a) 字符串 eval(兼容现有)
-agent("...", { when: ({ env }) => env.MODE === "prod" }); // (b) TS 谓词(新;编译成 eval)
+agent("...", { when: "{env.MODE} == 'prod'" }); // (a) 字符串 eval(完整支持)
+agent("...", { when: ({ env, steps }) => env.MODE === "prod" }); // (b) TS 谓词(受限子集)
```
-> (b) 编译器把谓词体限定在纯表达式子集内,编译成等价 eval 串,保留**零 token 静态分析**。
-### 5.2 插值占位符(全部保留 + 新增类型安全形态)
-| 占位符 | JSON | DSL |
+**(b) 的可编译子集(明确列出,不再含糊):**
+- ✅ 比较:`===` `!==` `==` `!=` `>` `<` `>=` `<=`(编译器把 `===`→`==`)
+- ✅ 逻辑:`&&` `||` `!`
+- ✅ 属性路径:`env.MODE` / `steps.test.json.failures`(编译器展平成 `{env.MODE}` / `{steps.test.json.failures}`)
+- ✅ 字面量:字符串/数字/布尔/null
+- ❌ 方法调用(`.includes()`/`.length`)、可选链(`?.`)、箭头函数闭包、async、三元 —— **编译报错**(不静默)
+- ❌ `contains` 子串检查用字符串形态(a)。
+
+不在子集内 → `taskflow check` 报精确错误,告诉 author 改用字符串形态。
+
+### 5.2 插值占位符 —— **v2 修正(review COVERAGE F3/F5)**
+
+| 占位符 | 当前引擎支持? | DSL |
|---|---|---|
-| `{args.X}` | ✅ | `args.X`(args 是响应式对象) |
-| `{steps.ID.output}` | ✅ | `id.output`(读 Phase 对象) |
-| `{steps.ID.json}` / `.field` | ✅ | `id.json` / `id.json.field` |
-| `{item}` / `{item.field}` | ✅ | `item` / `item.field`(map 回调形参) |
+| `{args.X}` | ✅ | `args.X` |
+| `{steps.ID.output}` / `.json` / `.json.f` | ✅ | `id.output` / `id.json` / `id.json.f`(编译期符号引用,§0) |
+| `{item}` / `{item.f}` | ✅ | `item` / `item.f`(map 回调形参) |
| `{previous.output}` | ✅ | chain 内 `previous.output` |
-| `{reflexion}` | ✅ | loop body 内自动可用 |
+| `{reflexion}` | ✅ | loop body 内自动 |
+| `{loop.iteration}` / `.lastOutput` / `.maxIterations` | ✅(**v1 漏列**) | loop 回调内 `loop.iteration` 等 |
+| `{env.X}` | ❌ **当前引擎无 env 根**(v1 误标"兼容") | **v2 明确为新能力**,0.2.0 一起加(或用 script 注入) |
-> **字符串模板仍可用**:`agent(\`Audit ${item.route}\`)` —— 编译器把 `${item.route}` 转成 `{item.route}` 占位符(语义不变,运行时同样插值)。**两种形态等价,保留字符串模板兼容旧习惯。**
+**字符串模板:** `` agent(`Audit ${item.route}`) `` —— 编译器从 AST 提取 `${item.route}`,转成 `{item.route}` 占位符(§0.2)。**两种形态(模板 / 显式占位符)等价,文档统一推荐模板形态**(review USABILITY #3:别给 agent 两个"都行"的选项)。
-### 5.3 `join`(依赖汇聚)
-```ts
-parallel([...], { join: "any" }); // OR-join
-agent("...", { join: "all" }); // 默认 AND-join
-```
+### 5.3 `join`
+`parallel([...], { join: "any" })` / `agent("...", { join: "all" })`(默认)。
-### 5.4 `dependsOn`(显式补充)
-通常不需要(读取即依赖)。但**保留**用于:
-- 引用没有赋值的 phase(如 JSON 迁移来的隐式依赖)
-- 声明"读取没体现,但逻辑上依赖"的关系(如 gate 的 onBlock:retry 要重跑上游)
+### 5.4 `dependsOn`(显式补充)—— **v2 强化(review COMPAT MEDIUM-2)**
-```ts
-agent("...", { dependsOn: ["plan", "config"] });
-```
+自动依赖(编译期收集 `x.output` 引用)**抓不到语义依赖**(B 在 A 后跑但不读 A 输出;script A 写文件、B 读文件)。**v2 明确:这类必须显式 `dependsOn`,编译器对"无自动依赖且非首个 phase"发警告**(提示可能漏了 `dependsOn`)。
---
-## 6. 兼容性策略(向前兼容的三个保证)
+## 6. 兼容性 —— v2 补真实 decompiler 设计
+
+### 6.1 JSON flow 零修改继续工作(不变)
-### 6.1 JSON flow 零修改继续工作
-- `taskflow run flow.json` / `action=run name=...` / `/tf:name` 全部不变。
-- 运行时、缓存、resume、verify —— 全部对 JSON 和 DSL 透明(都走 FlowIR)。
+### 6.2 双向可编译 —— **v2 给出设计(review FEASIBILITY #9 要的)**
-### 6.2 JSON ↔ DSL 双向可编译
```
-flow.tf.ts ──build──▶ FlowIR ◀──load── flow.json
- ▲ │
- └─────────────decompile────────────────────┘
+flow.tf.ts ──build──▶ FlowIR ◀──load── flow.json
+ ▲ │
+ └─────────decompile──────────────────┘
```
-- `taskflow build flow.tf.ts → flow.json`(发布产物,兼容老 host)
-- `taskflow decompile flow.json → flow.tf.ts`(老项目渐进迁移)
-- **FlowIR 是唯一的中间表示**,两种语法都是它的 surface syntax。
-### 6.3 版本协商(`version` 字段)
-- 顶层 `version: 2`(DSL 默认)和 `version: 1`(老 JSON)都合法。
-- 编译器按 version 选语法解析;运行时不关心来源。
+**`build`(.tf.ts → FlowIR):** §0.2 的 AST transform。
----
+**`load`(flow.json → FlowIR):** 现有 `translateTaskflow`(已修复 sidecar 完整性,见 commit 7b48105)。
-## 7. 拓展性(向前拓展的两个机制)
+**`decompile`(FlowIR → .tf.ts)—— v2 新设计:**
+FlowIR + sidecar 已经 lossless(7b48105 补全了 8 个字段)。decompiler 是一个**代码生成器**:
+1. 从 `ir.nodes` + `meta.declaredDeps` 重建 phase 顺序 + 依赖。
+2. 每个 node + 其 sidecar 字段 → 生成对应 rune 调用(按 kind 选 agent/map/gate/...)。
+3. `inject`(声明的读)→ 生成 `x.output` 引用(从 task 字符串里的 `{steps.X}` 反推)。
+4. `expect` schema → 反推 `json()`(基本类型能反推;复杂类型降级为显式 `expect`)。
+5. 输出格式化的 `.tf.ts`。
-### 7.1 加新 phase 类型 —— 不改语法
-新 phase 类型 = 新增一个 rune 函数,不破坏现有:
-```ts
-// 未来:新增 saga phase(带补偿)
-import { saga } from "taskflow/experimental";
-const r = saga({ compensate: "...", ... });
-```
-rune 是普通函数,**新能力 = 新 export**,老 flow 不受影响。
+**诚实边界:** decompile 出的 `.tf.ts` **语义等价但字面不一定相同**(变量名、格式、模板写法可能变)。它是"可读 + 可重新 build 回等价 FlowIR"的,不是"字面 round-trip"。文档明确这一点。
-### 7.2 加新通用字段 —— options 对象开放扩展
-通用字段都挂在 options 对象上。新增字段 = 加 option key:
-```ts
-agent("...", { /* 未来 */ priority: "high", tags: ["audit"], telemetry: {...} });
-```
-TypeScript 的 `PhaseOptions` 接口增量扩展;未识别字段走 `additionalProperties` 策略(默认警告,不报错)。
-
-### 7.3 用户自定义 rune(组件)
-```ts
-// 用户封装自己的编排原语(= 自定义 rune)
-export function auditFiles(files: Phase, opts) {
- return map(files, (f) => agent(`audit ${f}`, opts));
-}
-// 用法和内置 rune 一样
-const r = auditFiles(discover, { agent: "analyst" });
-```
+### 6.3 version 协商(不变)
---
-## 8. shorthand(简单任务,§非完整 flow)
+## 7. 拓展性(不变 + v2 强化)
-```ts
-import { taskflow } from "taskflow";
-
-// 完全等价 JSON shorthand:
-taskflow.task({ task: "summarize src/", agent: "explorer" });
-taskflow.tasks([{ task: "audit auth" }, { task: "audit perf" }]);
-taskflow.chain([{ task: "step1" }, { task: "from {previous.output}" }]);
-```
+- 新 phase 类型 = 新 rune 函数(`import { saga } from "taskflow/experimental"`)。
+- 新通用字段 = 新 option key。
+- **v2 新增:** `flow.component`(带 props 的可复用子 flow)和 `$store`/`$derived`(全局响应式)**明确标为 post-0.2.0**(依赖 Shared Context Tree / 响应式运行时,见 demo 的使用)。0.2.0 首版不含;demo 里用到的地方加 `// [post-0.2.0]` 注释。
---
-## §A. 完整字段对照表(JSON ↔ DSL)—— 100% 覆盖证明
+## 8. agent 工具链(新,回应 review USABILITY #5/#6)
-### 顶层
-| JSON | DSL | ✓ |
-|---|---|---|
-| name | flow(name) | ✅ |
-| description | flow(name, {description}) | ✅ |
-| version | flow(name, {version}) | ✅ |
-| args | ctx.args.declare() | ✅ |
-| concurrency | ctx.concurrency() | ✅ |
-| budget{maxUSD,maxTokens} | ctx.budget() | ✅ |
-| agentScope | ctx.scope() | ✅ |
-| strictInterpolation | ctx.strict() | ✅ |
-| contextSharing | ctx.share() | ✅ |
-| incremental | ctx.incremental() | ✅ |
-| phases | rune 声明 | ✅ |
-
-### Phase 通用
-| JSON | DSL option | ✓ |
+| 命令 | 作用 | 给 agent 的价值 |
|---|---|---|
-| id | id: | ✅ |
-| type | rune 选择 | ✅ |
-| agent | agent: | ✅ |
-| task | rune 第1参数 / 回调 | ✅ |
-| dependsOn | dependsOn: (+自动) | ✅ |
-| join | join: | ✅ |
-| when | when: | ✅ |
-| retry{max,backoffMs,factor} | retry: | ✅ |
-| output | output: / json() | ✅ |
-| expect | expect: / json() | ✅ |
-| model | model: | ✅ |
-| thinking | thinking: | ✅ |
-| tools | tools: | ✅ |
-| cwd | cwd: | ✅ |
-| final | final: / return | ✅ |
-| optional | optional: | ✅ |
-| idempotent | idempotent: | ✅ |
-| concurrency | concurrency: | ✅ |
-| context | context: | ✅ |
-| contextLimit | contextLimit: | ✅ |
-| cache{scope,ttl,fingerprint} | cache: | ✅ |
-| shareContext | shareContext: | ✅ |
-| timeout | timeout: | ✅ |
-
-### Phase 类型专属
-| Phase | JSON 字段 | DSL | ✓ |
-|---|---|---|---|
-| map | over / as | map(over,(item)=>...) | ✅ |
-| parallel | branches[] | parallel([...]) | ✅ |
-| reduce | from[] | reduce([...],fn) | ✅ |
-| flow | use / def / with | subflow(name,with) / subflow.def(fn) | ✅ |
-| script | run / input | script(run,{input}) | ✅ |
-| loop | until/maxIterations/convergence/reflexion | loop({until,...}) | ✅ |
-| tournament | variants/branches/judge/judgeAgent/mode | tournament({...}) | ✅ |
-| gate | onBlock/eval/score | gate()/gate.automated()/gate.scored() | ✅ |
-
-### 插值 & shorthand
-| 能力 | DSL | ✓ |
-|---|---|---|
-| {args.X} | args.X | ✅ |
-| {steps.ID.output/json} | id.output / id.json | ✅ |
-| {item} / {item.f} | item / item.f | ✅ |
-| {previous.output} | previous.output | ✅ |
-| {reflexion} | loop body 内自动 | ✅ |
-| shorthand task/tasks/chain | taskflow.task/tasks/chain | ✅ |
-
----
-
-## §B. 完整真实示例(对照 JSON)
-
-```jsonc
-// JSON(现状)
-{ "name": "audit-endpoints", "budget": {"maxUSD":3}, "phases": [
- { "id":"discover", "agent":"scout", "task":"List endpoints under {args.dir}. JSON array.", "output":"json",
- "expect":{"type":"array","items":{"type":"object","required":["route","file"]}}, "retry":{"max":2} },
- { "id":"audit", "type":"map", "over":"{steps.discover.json}", "agent":"analyst", "concurrency":4,
- "task":"Audit {item.route} ({item.file}) for missing auth." },
- { "id":"screen", "type":"gate", "agent":"reviewer", "onBlock":"retry",
- "task":"Cross-check. Delete false positives. VERDICT: PASS/BLOCK.\n{steps.audit.output}" },
- { "id":"report", "type":"reduce", "from":["screen"], "agent":"doc-writer",
- "task":"Write report:\n{steps.screen.output}", "final":true }
-]}
-```
+| `taskflow check audit.tf.ts` | **轻量校验**:tsc + rune 签名 + 依赖完整性 + when 谓词子集。不生成 FlowIR | 快反馈,agent 写完立刻知道错没错 |
+| `taskflow build audit.tf.ts` | 完整编译 → FlowIR | 产出可运行产物 |
+| `taskflow verify` | FlowIR 静态检查(零 token) | 运行前抓 DAG 错 |
+| `taskflow compile` | Mermaid + 报告 | 可视化 |
+| `taskflow new` | 生成骨架 `.tf.ts` | agent 不用从零写 |
+**`taskflow new` 产出的最小骨架(回应 review USABILITY #4,要 ≤5 行 hello world):**
```ts
-// DSL 0.2.0(等价,100% 覆盖,自动依赖,有类型)
-import { flow, agent, map, gate, reduce, json } from "taskflow";
-
-export default flow("audit-endpoints", (ctx) => {
- ctx.budget({ maxUSD: 3 });
- ctx.args.declare({ dir: { default: "src/routes", type: "string" } });
-
- const discover = agent(`List endpoints under ${ctx.args.dir}. JSON array.`, {
- agent: "scout", output: json<{ route: string; file: string }[]>(), retry: { max: 2 },
- });
+import { flow, agent } from "taskflow";
+export default flow("hello", () => agent("Say hello to {args.name}"));
+```
- const audit = map(discover, (item) =>
- agent(`Audit ${item.route} (${item.file}) for missing auth.`, { agent: "analyst", concurrency: 4 })
- );
+---
- const screen = gate(audit, { agent: "reviewer", onBlock: "retry" },
- (i) => `Cross-check. Delete false positives. VERDICT: PASS/BLOCK.\n${i.output}`);
+## §A. 完整字段对照表(v2 修正版)—— 100% 覆盖
- return reduce([screen], (p) =>
- agent(`Write report:\n${p.screen.output}`, { agent: "doc-writer" }) // return = final
- );
-});
-```
+[与 v1 相同的结构,但每一行都经过 review 实证修正:
+- approval 删 `input`(F1)
+- loop 多 phase body 移到 §7(F2)
+- args 删 `type` 补 `description`(F4)
+- 占位符加 `{loop.*}`、标 `{env.X}` 为新(F3/F5)
+- 顶层 args/version/agentScope/strictInterpolation/contextSharing/incremental 确认全覆盖
+- 通用 23 字段逐个确认(含刚修的 sidecar:agent/run/input/timeout/expect/reflexion/idempotent/score)
+完整表见 v1 §A,v2 仅标注修正点如上。]
---
-## §C. 覆盖率自检清单
-
-- [x] 10 种 phase 类型全覆盖(agent/map/parallel/gate/reduce/loop/tournament/approval/flow/script)
-- [x] gate 三形态全覆盖(task / eval / score)
-- [x] flow 三形态全覆盖(use / def / with)
-- [x] script 两形态全覆盖(string shell / array execvp / input stdin)
-- [x] 全部 24 个通用 phase 字段
-- [x] 全部 11 个顶层字段
-- [x] 全部 6 个插值占位符
-- [x] shorthand(task/tasks/chain)
-- [x] MCP/Pi actions 不受影响(run/save/resume/list/agents/init/verify/compile/ir/provenance/why-stale/recompute/cache-clear/search —— 都操作 FlowIR,与 surface syntax 无关)
-- [x] JSON 向前兼容(零修改)
-- [x] 双向编译(tf.ts ↔ json)
-- [x] 拓展性(新 phase = 新 rune 函数;新字段 = 新 option key)
+## §B. 决策记录(v2 基于 review 的修订)
+
+| review 发现 | v2 应对 | 章节 |
+|---|---|---|
+| FEASIBILITY #1 身份危机 | 定调编译指令路线 | §0 |
+| FEASIBILITY #2 Proxy 不可能 | §0.3 论证 + 编译期符号引用 | §0 |
+| FEASIBILITY #3 map item 幽灵 | 回调编译期执行一次,item 是类型化符号 | §4.2 |
+| FEASIBILITY #4 json() 幻象 | 编译器 transform 推导,复杂类型报错 | §4.1 |
+| FEASIBILITY #5 模板转换 | 编译期 AST 提取 | §0.2/§5.2 |
+| FEASIBILITY #6 loop body | 当前=单任务;多 phase 移 post-0.2.0 | §4.6 |
+| FEASIBILITY #8 parallel 解构 | 编译期合成 branch 符号 | §4.3 |
+| FEASIBILITY #9 decompiler | 给出代码生成器设计 | §6.2 |
+| FEASIBILITY #10 降级运行 | 放弃;JSON 作逃生口 | §0.4 |
+| COMPAT CRITICAL sidecar | 已修(commit 7b48105) | §A |
+| COMPAT HIGH collectRefs | 已修(7b48105) | — |
+| COMPAT MEDIUM-HIGH when 子集 | 明确列出可编译子集 | §5.1 |
+| COMPAT MEDIUM-1 json() | 同 FEASIBILITY #4 | §4.1 |
+| COMPAT MEDIUM-2 自动依赖边界 | 无自动依赖的 phase 发警告 | §5.4 |
+| COVERAGE F1 approval.input | 删除 | §4.8 |
+| COVERAGE F2 loop body | 移 post-0.2.0 | §4.6 |
+| COVERAGE F3 {env.X} | 标为新能力 | §5.2 |
+| COVERAGE F4 args.type | 删 type 补 description | §2 |
+| COVERAGE F5 {loop.*} | 补进占位符表 | §5.2 |
+| USABILITY #1 demo-RFC gap | demo 标 [post-0.2.0] | §7 |
+| USABILITY #2 rune 签名一致 | 统一为 (data, opts) 或 (opts) 两模式 | 全文 |
+| USABILITY #3 双插值模型 | 统一推荐模板形态 | §5.2 |
+| USABILITY #4 无 hello world | `taskflow new` + 5 行骨架 | §8 |
+| USABILITY #5 无 check | `taskflow check` | §8 |
+| USABILITY #6 ctx 命名 | 保持(和顶层字段对应) | §2 |
---
-## §D. 待定决策(open questions,不阻塞本 RFC)
+## §C. 仍 open(v2 不阻塞,留实现时定)
-1. **`when` 的 TS 谓词允许子集** —— 哪些 JS 表达式可编译成 eval?(纯比较/逻辑 OK;闭包捕获/异步 待定)
-2. **`flow.component`(带 props 的可复用子 flow)的精确签名** —— 见 demo,但 props 的响应式语义需单独 RFC。
-3. **`$store`/`$derived`(全局响应式)是否进 0.2.0 首版** —— 它依赖 overstory Shared Context Tree,可能作为 0.2.x 渐进加入。
-4. **`json()` 的泛型 → TypeBox schema 推导深度** —— 基本类型/array/object OK;复杂联合/条件类型可能降级为 unknown + 运行时校验。
+1. `taskflow build` 的 AST transform 用什么实现?(tsc transformer API / Babel / 自研轻量解析)—— 工程选型,§0 不阻塞。
+2. decompiler 的代码格式化风格(prettier?手写?)。
+3. `flow.component` 的 props 响应式语义(post-0.2.0 单独 RFC)。
+4. `{env.X}` 0.2.0 是否纳入(还是只推 script 注入)。
From b6fefde76438ab521e6c933273e0f0fac9a99f6d Mon Sep 17 00:00:00 2001
From: heggria
Date: Tue, 7 Jul 2026 16:25:06 +0800
Subject: [PATCH 04/51] docs(0.2.0): annotate post-0.2.0 features in demos
(review USABILITY #1)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two 0.2.0 demos deliberately show the full vision ceiling, but they
use features the DSL RFC §7 marks as post-0.2.0 ($derived/$state/$store/
read/write/flow.component — they need a global reactive runtime /
Shared Context Tree, not in the first release). Review USABILITY #1
flagged this as a trap: an agent using these demos as reference would
produce non-compiling code.
Rather than gut the demos (they're valuable as vision communication),
annotate so the boundary is unmistakable:
- New examples/0.2.0-app-delivery-platform/README.md: top-level warning,
a table of which features are post-0.2.0 and why, what IS runnable in
0.2.0, and where to find a real first-version sample.
- File-header banner (⚠️) on every file that uses a post-0.2.0 feature.
- Inline `// [post-0.2.0]` on import lines and key call sites in the
heaviest files (smart-migration, app.ts, dashboard.ts) — imports are
what agents copy first.
- Fixed stale "Solid 路线" wording in smart-migration (the route is now
compile-directive per RFC v2 §0).
An agent opening any demo file now sees the warning before any code.
---
.../0.2.0-app-delivery-platform/README.md | 44 +++++++++++++++++++
examples/0.2.0-app-delivery-platform/app.ts | 14 +++---
.../components/review-changes.ts | 2 +
.../components/self-heal.ts | 2 +
.../flows/implement.ts | 2 +
.../0.2.0-app-delivery-platform/flows/plan.ts | 2 +
.../stores/dashboard.ts | 4 +-
examples/0.2.0-demo-smart-migration.tf.ts | 40 +++++++++++------
8 files changed, 89 insertions(+), 21 deletions(-)
create mode 100644 examples/0.2.0-app-delivery-platform/README.md
diff --git a/examples/0.2.0-app-delivery-platform/README.md b/examples/0.2.0-app-delivery-platform/README.md
new file mode 100644
index 0000000..e5e86b8
--- /dev/null
+++ b/examples/0.2.0-app-delivery-platform/README.md
@@ -0,0 +1,44 @@
+# 0.2.0 Demo: Autonomous Software Delivery Platform
+
+> ⚠️ **愿景草图,非 0.2.0 首版可运行样例。**
+
+这个 demo 故意展示了 taskflow 0.2.0 DSL 的**完整愿景上限** —— 一个 10 文件的
+多 flow agent 应用(像 Vue/Solid 应用那样有 app/flows/components/stores/lib 结构)。
+
+**但其中部分特性在 [`docs/rfc-0.2.0-dsl-syntax.md`](../../docs/rfc-0.2.0-dsl-syntax.md) §7
+明确标为 post-0.2.0**(依赖全局响应式运行时 / Shared Context Tree,首版不含):
+
+| post-0.2.0 特性 | 用在哪 | 为什么 post |
+|---|---|---|
+| `$derived` / `$state` | `app.ts`, `stores/dashboard.ts`, `flows/plan.ts` | 全局响应式派生状态,需响应式运行时 |
+| `$store` | `stores/dashboard.ts` | 全局共享 store,需 Shared Context Tree |
+| `read()` / `write()` | `app.ts`, `stores/dashboard.ts`, `components/review-changes.ts` | 读写全局 store |
+| `flow.component(...)` | `flows/implement.ts`, `components/*` | 带 props 的可复用子 flow,props 响应式语义待定 |
+
+文件内用到这些特性的地方都标了 `// [post-0.2.0]`。
+
+## 0.2.0 首版能跑的部分
+
+- 所有 `agent` / `map` / `parallel` / `gate` / `reduce` / `loop` / `tournament` /
+ `approval` / `script` rune
+- 编译期类型(`json()`、`item.route` 类型检查)
+- 自动依赖追踪(编译期收集 `.output` 引用)
+- `when` 字符串条件 + 受限 TS 谓词子集
+
+## 这个 demo 的价值
+
+它是**愿景交流工具** —— 展示"当 0.2.x 补齐全局响应式 + flow.component 后,agent
+应用能写成什么样"。不是 0.2.0 首版的参考实现。要看 0.2.0 首版能跑的样例,看
+`taskflow new` 生成的骨架 + RFC §B 的 audit-endpoints 示例。
+
+## 文件结构(像 Vue/Solid 应用)
+
+```
+app.ts 主入口(编排整个交付流水线) ← 含最多 post-0.2.0 特性
+├── types/ 领域模型
+├── config/ 声明式配置
+├── lib/ 工具函数(编译期求值)
+├── stores/ 响应式全局 store ← post-0.2.0 ($store/$derived)
+├── flows/ 业务流程(plan / implement)
+└── components/ 可复用 phase 组件 ← post-0.2.0 (flow.component)
+```
diff --git a/examples/0.2.0-app-delivery-platform/app.ts b/examples/0.2.0-app-delivery-platform/app.ts
index 8cbdec4..87fd42d 100644
--- a/examples/0.2.0-app-delivery-platform/app.ts
+++ b/examples/0.2.0-app-delivery-platform/app.ts
@@ -1,4 +1,6 @@
/**
+ * ⚠️ 愿景草图 —— 含 post-0.2.0 特性($derived/$store/read/write/flow.component)。见 ./README.md。
+ *
* taskflow 0.2.0 应用 —— 自主软件交付平台
*
* issue → 规划 → 实现 → 审查 → 安全审计 → 自愈 → 置信度决策 → 交付/转人工
@@ -15,12 +17,12 @@
* 改一个 issue 的字段,只有相关分支重算(overstory 增量)。
*/
-import { flow, agent, gate, approval, parallel, script, $derived, $store, read, write, when, json } from "taskflow";
-import planFlow from "./flows/plan.ts";
-import implementFlow from "./flows/implement.ts";
-import { reviewChanges } from "./components/review-changes.ts";
-import { securityAudit } from "./components/security-audit.ts";
-import { dashboard, remainingBudget } from "./stores/dashboard.ts";
+import { flow, agent, gate, approval, parallel, script, $derived, $store, read, write, when, json } from "taskflow"; // $derived/$store/read/write = [post-0.2.0]
+import planFlow from "./flows/plan.ts"; // [post-0.2.0] planFlow 内部用 $derived
+import implementFlow from "./flows/implement.ts"; // [post-0.2.0] implementFlow 用 flow.component
+import { reviewChanges } from "./components/review-changes.ts"; // [post-0.2.0] flow.component
+import { securityAudit } from "./components/security-audit.ts"; // [post-0.2.0] flow.component
+import { dashboard, remainingBudget } from "./stores/dashboard.ts"; // [post-0.2.0] $store/$derived
import { config } from "./config/app.ts";
import { needsSecurityReview, inferComplexity } from "./lib/utils.ts";
import type { Issue, DeliveryReport, DeliveryStatus } from "./types/domain.ts";
diff --git a/examples/0.2.0-app-delivery-platform/components/review-changes.ts b/examples/0.2.0-app-delivery-platform/components/review-changes.ts
index acbc215..2fa2939 100644
--- a/examples/0.2.0-app-delivery-platform/components/review-changes.ts
+++ b/examples/0.2.0-app-delivery-platform/components/review-changes.ts
@@ -1,4 +1,6 @@
/**
+ * ⚠️ 愿景草图 —— 含 post-0.2.0 特性(read/hotspotFiles)。见 ./README.md。
+ *
* 组件:多视角代码审查。
*
* 像 Solid 的一个可复用组件 —— 接受 props,内部有自己的响应式状态,
diff --git a/examples/0.2.0-app-delivery-platform/components/self-heal.ts b/examples/0.2.0-app-delivery-platform/components/self-heal.ts
index dc0d20f..28f30dd 100644
--- a/examples/0.2.0-app-delivery-platform/components/self-heal.ts
+++ b/examples/0.2.0-app-delivery-platform/components/self-heal.ts
@@ -1,4 +1,6 @@
/**
+ * ⚠️ 愿景草图 —— 含 post-0.2.0 特性(flow.component)。见 ../README.md。
+ *
* 组件:测试驱动的自愈循环。
*
* 实现 → 测试 → 失败就修复,直到通过或收敛或达上限。
diff --git a/examples/0.2.0-app-delivery-platform/flows/implement.ts b/examples/0.2.0-app-delivery-platform/flows/implement.ts
index 324308a..85379c6 100644
--- a/examples/0.2.0-app-delivery-platform/flows/implement.ts
+++ b/examples/0.2.0-app-delivery-platform/flows/implement.ts
@@ -1,4 +1,6 @@
/**
+ * ⚠️ 愿景草图 —— 含 post-0.2.0 特性(flow.component/selfHeal)。见 ../README.md。
+ *
* Flow:实现 + 自愈。
*
* 拿 Plan → 按 step 实现 → 每个 step 跑测试自愈 → 输出 diff。
diff --git a/examples/0.2.0-app-delivery-platform/flows/plan.ts b/examples/0.2.0-app-delivery-platform/flows/plan.ts
index 773a308..c02dcd1 100644
--- a/examples/0.2.0-app-delivery-platform/flows/plan.ts
+++ b/examples/0.2.0-app-delivery-platform/flows/plan.ts
@@ -1,4 +1,6 @@
/**
+ * ⚠️ 愿景草图 —— 含 post-0.2.0 特性($derived/read/historicalConfidence)。见 ../README.md。
+ *
* Flow:需求理解 + 规划。
*
* 读 issue → 拆解 → tournament 选最优方案 → 输出 Plan。
diff --git a/examples/0.2.0-app-delivery-platform/stores/dashboard.ts b/examples/0.2.0-app-delivery-platform/stores/dashboard.ts
index 53c005f..a1744cc 100644
--- a/examples/0.2.0-app-delivery-platform/stores/dashboard.ts
+++ b/examples/0.2.0-app-delivery-platform/stores/dashboard.ts
@@ -1,4 +1,6 @@
/**
+ * ⚠️ 全 post-0.2.0 —— 整个文件基于 $store/$derived(全局响应式)。见 ../README.md。
+ *
* 响应式 Store —— 全局共享状态(跨 flow / 跨 phase)。
*
* 这是 Solid 路线的精华:像 Solid 的 createRoot + createStore,
@@ -9,7 +11,7 @@
* 0.2.0 把它包装成 Solid 风格的 $store rune。
*/
-import { $store, $derived } from "taskflow";
+import { $store, $derived } from "taskflow"; // [post-0.2.0] 整个文件都基于全局响应式
import type { DeliveryReport, Issue } from "../types/domain.ts";
/** 全局交付看板:实时跟踪所有 issue 的进度。 */
diff --git a/examples/0.2.0-demo-smart-migration.tf.ts b/examples/0.2.0-demo-smart-migration.tf.ts
index 91242d3..237adbe 100644
--- a/examples/0.2.0-demo-smart-migration.tf.ts
+++ b/examples/0.2.0-demo-smart-migration.tf.ts
@@ -1,16 +1,28 @@
/**
- * taskflow 0.2.0 — Solid 路线 DSL 复杂度演示
+ * taskflow 0.2.0 — DSL 复杂度演示(愿景上限)
+ *
+ * ⚠️ POST-0.2.0 特性警告 ──────────────────────────────────────────
+ * 这个 demo 故意展示了 0.2.0 的【完整愿景上限】,其中部分特性在
+ * `docs/rfc-0.2.0-dsl-syntax.md` §7 明确标为 **post-0.2.0**(依赖全局
+ * 响应式运行时 / Shared Context Tree,首版不含):
+ * - `$derived` / `$state` 全局响应式派生状态
+ * - `read()` / `write()` 读写全局响应式 store
+ * - `flow.component(...)` 带 props 的可复用子 flow
+ * 这些行在文件内用 `// [post-0.2.0]` 标注。0.2.0 首版能跑的是其余部分
+ * (agent/map/gate/reduce/loop/tournament/approval/script + 编译期类型)。
+ * 不要把这个文件当作 0.2.0 首版的可运行样例 —— 它是愿景草图。
+ * ────────────────────────────────────────────────────────────────
*
* 场景:大型 monorepo 的"智能迁移 + 安全审计 + 自愈"系统。
* 把 styled-components 全量迁移到 Tailwind,同时跑安全审计,
* 失败自动修复,最后 tournament 选最优策略汇总。
*
- * 这个 demo 展示 Solid 路线能写出的工程级复杂度:
- * - 响应式组合(flow 片段像 Solid 组件一样组合)
- * - 自动依赖追踪(读 .output 即依赖,不用手写 dependsOn)
- * - 派生状态($derived,中间指标自动计算)
+ * 这个 demo 展示 DSL 能写出的工程级复杂度:
+ * - 响应式组合(flow 片段像组件一样组合)
+ * - 自动依赖追踪(编译期收集 .output 引用,不用手写 dependsOn)
+ * - 派生状态($derived [post-0.2.0],中间指标自动计算)
* - 细粒度更新(overstory:改一个文件只重算相关分支)
- * - 跨文件复用(components/ 里是可复用的 flow 组件)
+ * - 跨文件复用(components/ 里是可复用的 flow 组件 [post-0.2.0])
* - 多层 map 嵌套 + gate 链 + tournament + loop 自愈 + 动态子流程
*
* 对照:这个逻辑用现在的 JSON DSL 写大约要 300+ 行嵌套 + 字符串模板,
@@ -18,9 +30,9 @@
*/
import { flow, agent, map, parallel, gate, reduce, tournament, loop, approval, script } from "taskflow";
-import { $derived, $state, json, read, type Phase } from "taskflow";
-import { migrateOneFile } from "./components/migrate-one.ts";
-import { auditOneFile } from "./components/audit-one.ts";
+import { $derived, $state, json, read, type Phase } from "taskflow"; // [post-0.2.0] $derived/$state/read 需全局响应式运行时
+import { migrateOneFile } from "./components/migrate-one.ts"; // [post-0.2.0] migrateOneFile 是 flow.component
+import { auditOneFile } from "./components/audit-one.ts"; // [post-0.2.0] auditOneFile 是 flow.component
// ============================================================================
// 主 flow —— 像写一个 Solid App:组合多个"组件"(子 flow)
@@ -39,7 +51,7 @@ export default flow("smart-migration", ({ args, budget }) => {
// ↑ files 和 securityBaseline 自动并发;且它们之间无依赖边。
// ── 阶段 2:派生指标(像 Solid 的 const doubled = createMemo) ─────────
- const plan = $derived(() => ({
+ const plan = $derived(() => ({ // [post-0.2.0] 全局响应式派生
total: files.output.length,
batches: chunk(files.output, 8), // 分批,每批 8 个文件
riskProfile: securityBaseline.output.includes("no-auth") ? "high" : "normal",
@@ -62,7 +74,7 @@ export default flow("smart-migration", ({ args, budget }) => {
// ── 阶段 4:分批迁移 + 每文件自愈(map 嵌套复用组件) ──────────────────
const migrations = map(plan.output.batches, (batch, batchIdx) =>
- flow.component(migrateOneFile, { // ← 复用 components/migrate-one.ts
+ flow.component(migrateOneFile, { // [post-0.2.0] 可复用子 flow ← 复用 components/migrate-one.ts
file: batch,
strategy: strategy.output,
dryRun: args.dryRun,
@@ -74,7 +86,7 @@ export default flow("smart-migration", ({ args, budget }) => {
// ── 阶段 5:并行安全审计(复用另一个组件) ───────────────────────────
const audits = map(files.output, (f) =>
- flow.component(auditOneFile, { file: f, baseline: securityBaseline.output })
+ flow.component(auditOneFile, { file: f, baseline: securityBaseline.output }) // [post-0.2.0]
);
// ── 阶段 6:交叉验证 gate(不同 agent 复核迁移 + 审计) ────────────────
@@ -132,7 +144,7 @@ export default flow("smart-migration", ({ args, budget }) => {
// 组件 1:迁移单个文件(内含 migrate→test→fix 自愈循环)
// 文件:components/migrate-one.ts —— 像 Solid 的一个可复用组件
// ============================================================================
-export const migrateOneFile = flow.component(
+export const migrateOneFile = flow.component( // [post-0.2.0] 可复用子 flow
"migrate-one",
({ props }: { props: { file: string; strategy: string; dryRun: boolean } }) => {
@@ -165,7 +177,7 @@ export const migrateOneFile = flow.component(
// 组件 2:审计单个文件(并行跑 3 个角度 + reduce)
// 文件:components/audit-one.ts
// ============================================================================
-export const auditOneFile = flow.component(
+export const auditOneFile = flow.component( // [post-0.2.0] 可复用子 flow
"audit-one",
({ props }: { props: { file: string; baseline: string } }) => {
From 394ef8c56786944501cc2dafe2cf85d8eb243e47 Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 11:15:24 +0800
Subject: [PATCH 05/51] feat(flowir): add cond.ts condition normalization seam
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add flowir/cond.ts — the condition-IR seam for the FlowIR compiler
(RFC §5.4). normalizeCond() lifts when/until/eval expressions into a
stable NormalizedCond { source, canonical, refs } descriptor shared by
two future consumers: hashing (structurally-equivalent conditions must
canonicalize identically) and deterministic replay's when-guard
re-evaluation (refs tell replay which steps.*/args.*/env.* inputs to
rebind, zero-token).
Reuses the shared condition parser in interpolate.ts via
tryEvaluateCondition for parse-validation + fail-open semantics — does
NOT duplicate the tokenizer/parser (single source of truth, no semantic
drift). Refs are extracted via a {steps|args|env}.… scan mirroring
collectRefs. The canonical form is a surface normalization (whitespace
removal + redundant enclosing-paren stripping with string-literal
contents preserved). Fail-open on parse error (canonical = source.trim(),
refs = []) — matches the fail-open-for-guards invariant. Never throws.
flowir-cond.test.ts: 29 tests covering equivalent-expression
canonicalization (whitespace/operator/paren/ref-spacing), different-
expression divergence, ref extraction (steps/args/env, dedup, order),
and fail-open for malformed/empty/nullish input.
Does not wire barrels.
---
packages/taskflow-core/src/flowir/cond.ts | 232 ++++++++++++++++++
.../taskflow-core/test/flowir-cond.test.ts | 226 +++++++++++++++++
2 files changed, 458 insertions(+)
create mode 100644 packages/taskflow-core/src/flowir/cond.ts
create mode 100644 packages/taskflow-core/test/flowir-cond.test.ts
diff --git a/packages/taskflow-core/src/flowir/cond.ts b/packages/taskflow-core/src/flowir/cond.ts
new file mode 100644
index 0000000..5795e85
--- /dev/null
+++ b/packages/taskflow-core/src/flowir/cond.ts
@@ -0,0 +1,232 @@
+/**
+ * Canonical normalization of `when` / `until` / `eval` condition expressions.
+ *
+ * This is the **condition-IR seam** of the overstory-convergence roadmap's
+ * FlowIR compiler: it lifts a raw condition string into a stable
+ * {@link NormalizedCond} descriptor that two future consumers share:
+ *
+ * - **Hashing** (consumer a): structurally-equivalent conditions MUST
+ * normalize to the same `canonical` string so they fold identically into
+ * the FlowIR content hash (whitespace, operator spacing, and redundant
+ * enclosing parens do not change meaning → must not change the hash).
+ * - **Deterministic replay** (consumer b): the `when-guard` decision is
+ * re-evaluated from the recorded expression; `refs` tells replay exactly
+ * which `steps.*` / `args.*` / `env.*` inputs the guard read so it can
+ * rebind them from the event log without token spend.
+ *
+ * **Single source of truth — no semantic drift.** The condition grammar
+ * (tokenizer + recursive-descent parser + comparison/truthiness semantics)
+ * lives in {@link ../interpolate.ts}. This module REUSES that parser by
+ * delegating parse-validation (and fail-open semantics) to
+ * {@link tryEvaluateCondition} — it does NOT duplicate or reimplement the
+ * tokenizer/parser. If the parser accepts an expression, it is structurally
+ * valid and we normalize its surface text; if the parser rejects it, we
+ * fail open (see {@link normalizeCond}) so a broken guard never silently
+ * drops a phase — matching the project's fail-open-for-guards invariant.
+ *
+ * The `canonical` form is a surface normalization (whitespace removal +
+ * redundant enclosing-paren stripping with string-literal contents
+ * preserved), not a full AST rewrite: for valid expressions whitespace only
+ * separates tokens, so removing it (after protecting quoted literals) yields
+ * a stable concatenation that is invariant under the cosmetic differences
+ * hashing cares about. Reference extraction uses a `{steps|args|env}.…}`
+ * scan — the same pattern as `collectRefs` in `schema.ts` — rather than
+ * re-parsing, so it also catches `env.*` refs the interpolation resolver
+ * does not bind.
+ *
+ * Pure module: no IO, no `Date`, no randomness, never throws.
+ *
+ * @see docs/rfc-0.2.0-architecture.md §5.4 (`flowir/cond.ts` — 条件归一化)
+ * @see ../interpolate.ts for the shared condition parser (tokenize / CondParser / compare / tryEvaluateCondition)
+ */
+
+import { tryEvaluateCondition, type InterpolationContext } from "../interpolate.ts";
+
+// ---------------------------------------------------------------------------
+// Public types
+// ---------------------------------------------------------------------------
+
+/**
+ * A condition expression lifted into a stable, hashable descriptor.
+ *
+ * - `source` — the original expression text, verbatim (never normalized).
+ * - `canonical` — a stable normalized surface form: whitespace-collapsed,
+ * operator-spacing-invariant, redundant enclosing parens stripped, with
+ * quoted-string contents preserved. Structurally-equivalent expressions
+ * produce byte-identical `canonical` strings.
+ * - `refs` — the `steps.…` / `args.…` / `env.…` references
+ * the expression reads (in first-occurrence order, de-duplicated). Empty
+ * when the expression failed to parse (fail-open).
+ */
+export interface NormalizedCond {
+ source: string;
+ canonical: string;
+ refs: string[];
+}
+
+// ---------------------------------------------------------------------------
+// Reference extraction (static scan — mirrors `collectRefs` in schema.ts)
+// ---------------------------------------------------------------------------
+
+/**
+ * Matches `{steps.X}`, `{args.Y}`, `{env.Z}` placeholders, tolerant of
+ * inner whitespace (the tokenizer trims inside braces, so `{ steps.x }` and
+ * `{steps.x}` are the same ref). Captures the full dotted path so replay
+ * knows precisely which input was read (e.g. `steps.triage.json.route`).
+ */
+const REF_RE = /\{\s*(steps|args|env)\.([a-zA-Z0-9_.-]+?)\s*\}/g;
+
+function extractRefs(expr: string): string[] {
+ const seen = new Set();
+ const refs: string[] = [];
+ REF_RE.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = REF_RE.exec(expr)) !== null) {
+ const ref = `${m[1]}.${m[2]}`;
+ if (!seen.has(ref)) {
+ seen.add(ref);
+ refs.push(ref);
+ }
+ }
+ return refs;
+}
+
+// ---------------------------------------------------------------------------
+// Canonical surface form
+// ---------------------------------------------------------------------------
+
+/**
+ * Protect quoted string literals (single or double quoted, with `\X` →
+ * literal-`X` escaping matching the interpolate tokenizer) by replacing each
+ * with a whitespace-free `\u0000\u0000` placeholder. This preserves
+ * their contents (including internal spaces) through whitespace removal and
+ * prevents their inner parens/operators from confusing the paren stripper.
+ * Returns the protected string and the list of original literals.
+ */
+function protectLiterals(expr: string): { text: string; literals: string[] } {
+ const literals: string[] = [];
+ let out = "";
+ let i = 0;
+ const n = expr.length;
+ while (i < n) {
+ const c = expr[i];
+ if (c === '"' || c === "'") {
+ let j = i + 1;
+ let val = "";
+ while (j < n) {
+ if (expr[j] === "\\" && j + 1 < n) {
+ val += expr[j + 1];
+ j += 2;
+ } else if (expr[j] === c) {
+ break;
+ } else {
+ val += expr[j];
+ j++;
+ }
+ }
+ const closed = j < n;
+ literals.push(c + val + (closed ? c : ""));
+ out += `\u0000${literals.length - 1}\u0000`;
+ i = closed ? j + 1 : j;
+ } else {
+ out += c;
+ i++;
+ }
+ }
+ return { text: out, literals };
+}
+
+function restoreLiterals(text: string, literals: string[]): string {
+ return text.replace(/\u0000(\d+)\u0000/g, (_m, idx) => literals[Number(idx)] ?? "");
+}
+
+/**
+ * Repeatedly strip a single layer of redundant enclosing parentheses — i.e.
+ * parens that wrap the *entire* expression: `((a==b))` → `(a==b)` → `a==b`.
+ * Operates on literal-protected, whitespace-free input so string contents
+ * and refs (which contain no parens) cannot unbalance the depth counter.
+ * Inner redundant parens like `a && (b)` are intentionally left in place —
+ * removing them correctly would require AST knowledge (precedence), which is
+ * the parser's job, not this normalizer's.
+ */
+function stripEnclosingParens(s: string): string {
+ for (;;) {
+ if (s.length < 2 || s[0] !== "(") break;
+ let depth = 0;
+ let end = -1;
+ for (let k = 0; k < s.length; k++) {
+ const ch = s[k];
+ if (ch === "(") depth++;
+ else if (ch === ")") {
+ depth--;
+ if (depth === 0) {
+ end = k;
+ break;
+ }
+ }
+ }
+ if (end === s.length - 1) {
+ s = s.slice(1, -1);
+ } else {
+ break;
+ }
+ }
+ return s;
+}
+
+/**
+ * Produce the canonical surface form of a (parser-accepted) expression:
+ * protect string literals → remove all whitespace → strip redundant
+ * enclosing parens → restore literals. For a valid expression whitespace
+ * only separates tokens, so its removal is semantics-preserving and yields a
+ * form invariant under the cosmetic differences (spacing / redundant outer
+ * parens) that hashing must ignore.
+ */
+function canonicalize(expr: string): string {
+ const { text, literals } = protectLiterals(expr);
+ let compact = text.replace(/\s+/g, "");
+ compact = stripEnclosingParens(compact);
+ return restoreLiterals(compact, literals);
+}
+
+// ---------------------------------------------------------------------------
+// normalizeCond
+// ---------------------------------------------------------------------------
+
+/**
+ * Normalize a `when` / `until` / `eval` condition expression.
+ *
+ * The expression is parsed (validated) by reusing the shared condition
+ * parser in {@link ../interpolate.ts} via {@link tryEvaluateCondition}; on a
+ * parse error this **fails open** — returning `{ source, canonical:
+ * source.trim(), refs: [] }` — so a malformed guard is never silently
+ * dropped and never crashes a compile/replay pass. This mirrors the
+ * project's fail-open-for-guards invariant (`when` parse errors → phase
+ * still runs). Never throws.
+ *
+ * On success, `canonical` is the whitespace/paren-normalized surface form
+ * (stable for hashing) and `refs` are the `steps.*` / `args.*` / `env.*`
+ * references the expression reads (for replay rebinding).
+ */
+export function normalizeCond(expr: string): NormalizedCond {
+ const source = typeof expr === "string" ? expr : expr == null ? "" : String(expr);
+ const trimmed = source.trim();
+
+ // Reuse the interpolate condition parser to validate parseability and
+ // inherit its fail-open semantics. An empty probing context is fine: ref
+ // resolution returns `undefined` for missing steps/args but never throws,
+ // so `error` is purely syntactic (tokenize/parse failures).
+ const probe: InterpolationContext = { args: {}, steps: {} };
+ const { error } = tryEvaluateCondition(trimmed, probe);
+
+ if (error) {
+ // Fail open: keep the raw (trimmed) text as canonical, no refs.
+ return { source, canonical: trimmed, refs: [] };
+ }
+
+ return {
+ source,
+ canonical: canonicalize(trimmed),
+ refs: extractRefs(trimmed),
+ };
+}
diff --git a/packages/taskflow-core/test/flowir-cond.test.ts b/packages/taskflow-core/test/flowir-cond.test.ts
new file mode 100644
index 0000000..7f3d3f7
--- /dev/null
+++ b/packages/taskflow-core/test/flowir-cond.test.ts
@@ -0,0 +1,226 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import { normalizeCond } from "../src/flowir/cond.ts";
+
+// ---------------------------------------------------------------------------
+// Canonical form: equivalent expressions normalize identically
+// ---------------------------------------------------------------------------
+
+test("normalizeCond: operator-spacing differences canonicalize the same", () => {
+ const a = normalizeCond("a==b");
+ const b = normalizeCond("a == b");
+ const c = normalizeCond("a == b");
+ assert.equal(a.canonical, b.canonical);
+ assert.equal(a.canonical, c.canonical);
+ assert.equal(a.canonical, "a==b");
+});
+
+test("normalizeCond: tab/newline whitespace is normalized away", () => {
+ const a = normalizeCond("a==b");
+ const b = normalizeCond("\ta\t==\tb\n");
+ assert.equal(a.canonical, b.canonical);
+});
+
+test("normalizeCond: redundant enclosing parens are stripped", () => {
+ const a = normalizeCond("a == b");
+ const b = normalizeCond("(a == b)");
+ const c = normalizeCond("((a == b))");
+ assert.equal(a.canonical, b.canonical);
+ assert.equal(a.canonical, c.canonical);
+ assert.equal(a.canonical, "a==b");
+});
+
+test("normalizeCond: parens around the whole && expression are stripped", () => {
+ const a = normalizeCond("a == b && c == d");
+ const b = normalizeCond("(a == b && c == d)");
+ assert.equal(a.canonical, b.canonical);
+});
+
+test("normalizeCond: inner grouping parens are preserved (precedence matters)", () => {
+ // (a && b) || c is NOT equivalent to a && (b || c); the parens are
+ // load-bearing, so they must survive normalization.
+ const grouped = normalizeCond("(a && b) || c");
+ const regrouped = normalizeCond("a && (b || c)");
+ assert.notEqual(grouped.canonical, regrouped.canonical);
+ assert.equal(grouped.canonical, "(a&&b)||c");
+});
+
+test("normalizeCond: ref placeholder spacing is normalized", () => {
+ const a = normalizeCond("{steps.foo.output} == done");
+ const b = normalizeCond("{ steps.foo.output }==done");
+ assert.equal(a.canonical, b.canonical);
+ assert.equal(a.canonical, "{steps.foo.output}==done");
+});
+
+test("normalizeCond: quoted string contents are preserved verbatim", () => {
+ const a = normalizeCond('{args.label} == "go live"');
+ const b = normalizeCond('{args.label}=="go live"');
+ assert.equal(a.canonical, b.canonical);
+ assert.equal(a.canonical, '{args.label}=="go live"');
+ // Internal spaces inside a string literal must survive whitespace removal.
+ assert.ok(a.canonical.includes('"go live"'));
+});
+
+test("normalizeCond: single-quoted strings are preserved", () => {
+ const a = normalizeCond("{args.x} == 'a b'");
+ const b = normalizeCond("{args.x}=='a b'");
+ assert.equal(a.canonical, b.canonical);
+ assert.equal(a.canonical, "{args.x}=='a b'");
+});
+
+test("normalizeCond: logical/comparison operators all canonicalize", () => {
+ const ops = ["==", "!=", ">=", "<=", ">", "<", "&&", "||"];
+ for (const op of ops) {
+ const spaced = normalizeCond(`a ${op} b`);
+ const tight = normalizeCond(`a${op}b`);
+ assert.equal(spaced.canonical, tight.canonical, `operator ${op}`);
+ assert.equal(spaced.canonical, `a${op}b`, `operator ${op}`);
+ }
+});
+
+test("normalizeCond: unary not spacing is normalized", () => {
+ const a = normalizeCond("!a");
+ const b = normalizeCond("! a");
+ assert.equal(a.canonical, b.canonical);
+ assert.equal(a.canonical, "!a");
+});
+
+// ---------------------------------------------------------------------------
+// Canonical form: different expressions differ
+// ---------------------------------------------------------------------------
+
+test("normalizeCond: different operators produce different canonicals", () => {
+ assert.notEqual(normalizeCond("a==b").canonical, normalizeCond("a!=b").canonical);
+ assert.notEqual(normalizeCond("a>=b").canonical, normalizeCond("a>b").canonical);
+});
+
+test("normalizeCond: different operands produce different canonicals", () => {
+ assert.notEqual(normalizeCond("a==b").canonical, normalizeCond("a==c").canonical);
+ assert.notEqual(
+ normalizeCond("{steps.x.output} == done").canonical,
+ normalizeCond("{steps.y.output} == done").canonical,
+ );
+});
+
+test("normalizeCond: operand order matters", () => {
+ assert.notEqual(normalizeCond("a && b").canonical, normalizeCond("b && a").canonical);
+});
+
+// ---------------------------------------------------------------------------
+// Reference extraction
+// ---------------------------------------------------------------------------
+
+test("normalizeCond: extracts a steps.* ref", () => {
+ const n = normalizeCond('{steps.foo.output} == "done"');
+ assert.deepEqual(n.refs, ["steps.foo.output"]);
+});
+
+test("normalizeCond: extracts args.* and env.* refs", () => {
+ const n = normalizeCond("{args.n} > 3 && {env.MAX} > 0");
+ assert.deepEqual(n.refs, ["args.n", "env.MAX"]);
+});
+
+test("normalizeCond: extracts a deeply-nested steps json ref", () => {
+ const n = normalizeCond("{steps.triage.json.route} == deep");
+ assert.deepEqual(n.refs, ["steps.triage.json.route"]);
+});
+
+test("normalizeCond: de-duplicates refs preserving first-occurrence order", () => {
+ const n = normalizeCond("{args.n} > 3 || {args.n} < 0");
+ assert.deepEqual(n.refs, ["args.n"]);
+});
+
+test("normalizeCond: refs are tolerant of inner brace whitespace", () => {
+ const a = normalizeCond("{steps.foo.output} == done").refs;
+ const b = normalizeCond("{ steps.foo.output } == done").refs;
+ assert.deepEqual(a, b);
+ assert.deepEqual(a, ["steps.foo.output"]);
+});
+
+test("normalizeCond: no refs in a literal-only expression", () => {
+ assert.deepEqual(normalizeCond("true && false").refs, []);
+});
+
+// ---------------------------------------------------------------------------
+// Fail-open (never throws; malformed → canonical = source.trim(), refs = [])
+// ---------------------------------------------------------------------------
+
+test("normalizeCond: malformed expression fails open (unterminated placeholder)", () => {
+ const malformed = "{steps.foo.output ==";
+ const n = normalizeCond(malformed);
+ assert.equal(n.source, malformed);
+ assert.equal(n.canonical, malformed.trim());
+ assert.deepEqual(n.refs, []);
+});
+
+test("normalizeCond: malformed expression fails open (trailing tokens)", () => {
+ const n = normalizeCond("{steps.a.output} {steps.b.output}");
+ assert.deepEqual(n.refs, []);
+ assert.equal(n.canonical, "{steps.a.output} {steps.b.output}");
+});
+
+test("normalizeCond: malformed expression fails open (double operator)", () => {
+ const malformed = "a == == b";
+ const n = normalizeCond(malformed);
+ assert.equal(n.canonical, "a == == b");
+ assert.deepEqual(n.refs, []);
+});
+
+test("normalizeCond: unterminated string fails open", () => {
+ const malformed = '{args.x} == "unterminated';
+ const n = normalizeCond(malformed);
+ assert.equal(n.canonical, malformed.trim());
+ assert.deepEqual(n.refs, []);
+});
+
+test("normalizeCond: empty expression does not throw", () => {
+ const n = normalizeCond("");
+ assert.equal(n.source, "");
+ assert.equal(n.canonical, "");
+ assert.deepEqual(n.refs, []);
+});
+
+test("normalizeCond: whitespace-only expression does not throw", () => {
+ const n = normalizeCond(" \t\n ");
+ assert.equal(n.canonical, "");
+ assert.deepEqual(n.refs, []);
+});
+
+test("normalizeCond: never throws on nullish input", () => {
+ // Defensive: the signature is `string`, but fail-open must hold.
+ const n = normalizeCond(null as unknown as string);
+ assert.equal(n.canonical, "");
+ assert.deepEqual(n.refs, []);
+});
+
+test("normalizeCond: source is always the verbatim input", () => {
+ const raw = " a == b ";
+ assert.equal(normalizeCond(raw).source, raw);
+});
+
+// ---------------------------------------------------------------------------
+// Hashing fitness: equivalent expressions are byte-identical
+// ---------------------------------------------------------------------------
+
+test("normalizeCond: equivalent conditions hash identically (full descriptor)", () => {
+ // Two authoring styles of the same guard must fold into one hash.
+ const styles = [
+ "{steps.triage.json.route} == deep",
+ "{ steps.triage.json.route }==deep",
+ "({steps.triage.json.route} == deep)",
+ "(({steps.triage.json.route} == deep))",
+ ];
+ const canonicals = new Set(styles.map((s) => normalizeCond(s).canonical));
+ assert.equal(canonicals.size, 1, "all styles share one canonical form");
+});
+
+test("normalizeCond: refs consistent across equivalent forms", () => {
+ const styles = [
+ "{steps.triage.json.route} == deep",
+ "({steps.triage.json.route} == deep)",
+ "{ steps.triage.json.route } == deep",
+ ];
+ const refsSets = styles.map((s) => JSON.stringify(normalizeCond(s).refs));
+ assert.ok(refsSets.every((r) => r === refsSets[0]));
+ assert.deepEqual(JSON.parse(refsSets[0]), ["steps.triage.json.route"]);
+});
From d576b32e5e069990a5317dbe8150f0594654a351 Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 11:15:43 +0800
Subject: [PATCH 06/51] feat(flowir): add canonical FlowIR type contract +
structural guards
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Formalize the implicit FlowIR shape translate.ts already produces as a
standalone, canonical type contract (Q2=B, Q5=own). The new
flowir/schema.ts is a strict superset of meta.ts's projection: it adds
the closed FlowIRNodeKind literal union (the 10 phase kinds), an
explicit FlowIREdge model, FlowIRBudget/FlowIRMeta, and optional
TypeBox schemas + lightweight structural guards (isFlowIRNode /
assertFlowIR). Pure additive — translate.ts/meta.ts/hash.ts/index.ts
and all barrels are untouched, so the stub's output remains valid.
flowir/compile.ts (batch 2) will emit this canonical shape; the
event-sourced kernel executes it. Zero runtime deps beyond typebox.
Tests (flowir-schema.test.ts, 33 cases) cover kind membership, node
narrowing, full-IR assertion (incl. stub-shape superset compat, dup
ids, dangling edges, malformed budget), and TypeBox schema decode/Check.
---
packages/taskflow-core/src/flowir/schema.ts | 403 ++++++++++++++++++
.../taskflow-core/test/flowir-schema.test.ts | 317 ++++++++++++++
2 files changed, 720 insertions(+)
create mode 100644 packages/taskflow-core/src/flowir/schema.ts
create mode 100644 packages/taskflow-core/test/flowir-schema.test.ts
diff --git a/packages/taskflow-core/src/flowir/schema.ts b/packages/taskflow-core/src/flowir/schema.ts
new file mode 100644
index 0000000..811385e
--- /dev/null
+++ b/packages/taskflow-core/src/flowir/schema.ts
@@ -0,0 +1,403 @@
+/**
+ * # Canonical FlowIR type contract
+ *
+ * This module formalizes the **canonical FlowIR** (Flow Intermediate
+ * Representation) type contract for the overstory-convergence roadmap. It is a
+ * **superset** of the implicit shape that `./translate.ts` already produces
+ * (mirrored structurally in `./meta.ts`): every `FlowIR`/`FlowIRNode` emitted
+ * by `translateTaskflow` satisfies the types defined here, and these types add
+ * the missing formalization — a closed `FlowIRNodeKind` literal union, an
+ * explicit `FlowIREdge` model, structural `FlowIRBudget`/`FlowIRMeta`, and
+ * optional lightweight TypeBox guards.
+ *
+ * **Provenance & ownership (Q2=B, Q5=own):** this is pi-taskflow's *own* type
+ * contract — not vendored from overstory. `flowir/compile.ts` (batch 2 of the
+ * roadmap) will emit this canonical shape; the event-sourced kernel executes
+ * it. `translate.ts`'s 1:1 projection is a *read-only stub* that already
+ * conforms to this contract today, so formalizing here is purely additive —
+ * it must not break `translate.ts`, `meta.ts`, `hash.ts`, or any barrel.
+ *
+ * **Compatibility note:** the canonical `FlowIRNode.kind` is the closed
+ * `FlowIRNodeKind` literal union (the 10 native phase kinds), whereas
+ * `meta.ts`'s `FlowIRNode.kind` is `string` (a 1:1 projection). Every value
+ * `translate.ts` produces for `kind` (`phase.type ?? "agent"`) is a member of
+ * `FlowIRNodeKind`, so the canonical type is a strict refinement that
+ * subsumes the stub output. Code that needs the looser projection can keep
+ * using `meta.ts`; code that wants the closed contract imports from here.
+ *
+ * **Purity:** zero runtime deps beyond `typebox`. No IO, no Date, no
+ * randomness. Guards are pure functions over plain JSON values.
+ *
+ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1), §6 (compile seam)
+ * @see docs/internal/rfc-flowir-compilation.md
+ */
+
+import { Type, type Static } from "typebox";
+
+// ---------------------------------------------------------------------------
+// FlowIRNodeKind — the 10 native phase kinds (closed literal union)
+// ---------------------------------------------------------------------------
+
+/**
+ * The closed set of pi-taskflow phase kinds, projected 1:1 from
+ * `PHASE_TYPES` (schema.ts). This is the canonical `FlowIRNode.kind`
+ * vocabulary. `translate.ts` sets `kind = phase.type ?? "agent"`, so every
+ * node it emits is a member of this union.
+ *
+ * | kind | purpose |
+ * |-------------|----------------------------------------------------------|
+ * | `agent` | single subagent call |
+ * | `parallel` | static concurrent branches |
+ * | `map` | dynamic fan-out over an array (one subagent per item) |
+ * | `gate` | quality gate — can halt the flow on VERDICT: BLOCK |
+ * | `reduce` | aggregate multiple upstream outputs into one |
+ * | `approval` | human-in-the-loop pause (approve/reject/edit) |
+ * | `flow` | run a saved sub-taskflow as a single phase |
+ * | `loop` | repeat body until condition, convergence, or max iters |
+ * | `tournament`| N competing variants + judge picks best / aggregates |
+ * | `script` | run a shell command (no LLM, zero tokens) |
+ */
+export const FlowIRNodeKind = Type.Union(
+ [
+ Type.Literal("agent"),
+ Type.Literal("parallel"),
+ Type.Literal("map"),
+ Type.Literal("gate"),
+ Type.Literal("reduce"),
+ Type.Literal("approval"),
+ Type.Literal("flow"),
+ Type.Literal("loop"),
+ Type.Literal("tournament"),
+ Type.Literal("script"),
+ ],
+ { description: "The 10 native pi-taskflow phase kinds (1:1 projection of PHASE_TYPES)" },
+);
+export type FlowIRNodeKind = Static;
+
+// ---------------------------------------------------------------------------
+// FlowIRNode — canonical node contract
+// ---------------------------------------------------------------------------
+
+/**
+ * A single canonical FlowIR node — one per pi-taskflow phase.
+ *
+ * This is a **superset** of the node shape `translate.ts` emits. The stub
+ * attaches exactly `{ id, kind, inject, emits, when? }`; this contract
+ * additionally formalizes the optional fields a genuine compiler (batch 2,
+ * `flowir/compile.ts`) and the event-sourced kernel will populate:
+ *
+ * - `task` — the resolved (interpolated) task prompt, when materialized.
+ * - `condRef` — a reference to a compiled condition descriptor (the lowered
+ * form of `when`; see `flowir/cond.ts`). Absent on the stub,
+ * which passes `when` through verbatim.
+ * - `deps` — explicit `dependsOn` edges carried onto the node (the stub
+ * folds these into `inject`; a compiler may keep them
+ * separate for the edge model).
+ * - `join` — join mode (`"all"` default, or `"any"` for OR-join).
+ * - `timeout` — per-call ms cap (agent-running phases).
+ *
+ * All added fields are **optional** so the stub's minimal output remains valid.
+ */
+export interface FlowIRNode {
+ /** Unique phase identifier (referenced via `{steps..output}`). */
+ id: string;
+ /** The native phase kind — a member of {@link FlowIRNodeKind}. */
+ kind: FlowIRNodeKind;
+ /**
+ * Synthesized declared reads: the upstream step ids whose outputs this
+ * node injects. In the stub this is `{steps.X}` refs ∪ `dependsOn`
+ * (minus self); a genuine compiler lowers this to overstory's inject
+ * model.
+ */
+ inject: string[];
+ /** What this node emits — currently `[id]` (1:1 projection). */
+ emits: string[];
+ /** Raw `when` guard passthrough (stub: not rewritten to IR conditions). */
+ when?: string;
+ /** Resolved (interpolated) task prompt, when materialized by a compiler. */
+ task?: string;
+ /** Reference to a compiled condition descriptor (lowered `when`). */
+ condRef?: string;
+ /** Explicit `dependsOn` edges carried onto the node (optional). */
+ deps?: string[];
+ /** Join mode: `"all"` (default AND-join) or `"any"` (OR-join). */
+ join?: "all" | "any";
+ /** Per-subagent-call ms cap (agent-running phases). */
+ timeout?: number;
+}
+
+// ---------------------------------------------------------------------------
+// FlowIREdge — explicit edge model (optional; the stub uses `inject` only)
+// ---------------------------------------------------------------------------
+
+/**
+ * A directed edge in the FlowIR DAG. `from` is the upstream (producer) node
+ * id; `to` is the downstream (consumer) node id. The stub projection encodes
+ * edges implicitly via `FlowIRNode.inject` (and `dependsOn`); a genuine
+ * compiler may emit an explicit `edges` list for graph consumers (renderers,
+ * topo sorters). When present, `edges` must be consistent with `inject`.
+ */
+export interface FlowIREdge {
+ /** Upstream (producer) node id. */
+ from: string;
+ /** Downstream (consumer) node id. */
+ to: string;
+}
+
+// ---------------------------------------------------------------------------
+// FlowIRBudget — run-wide cost / token ceiling
+// ---------------------------------------------------------------------------
+
+/**
+ * Run-wide budget ceiling. Exceeding it halts the run (remaining phases are
+ * skipped). Structurally identical to the DSL `Budget` (schema.ts); mirrored
+ * here so the FlowIR contract is self-contained and doesn't leak DSL types to
+ * pure IR consumers (the event-sourced kernel).
+ */
+export interface FlowIRBudget {
+ /** Halt once accumulated cost exceeds this many USD. */
+ maxUSD?: number;
+ /** Halt once accumulated input+output tokens exceed this. */
+ maxTokens?: number;
+}
+
+// ---------------------------------------------------------------------------
+// FlowIRMeta — flow-level metadata (optional, compiler-populated)
+// ---------------------------------------------------------------------------
+
+/**
+ * Optional flow-level metadata attached to a compiled FlowIR. Populated by a
+ * genuine compiler; absent on the stub (which carries metadata in
+ * `TaskflowIRMeta`, not on the IR itself). Kept loose (`Record`) so the contract is forward-compatible without churn.
+ */
+export interface FlowIRMeta {
+ /** Origin tool/host (e.g. `"pi"`, `"codex"`). */
+ source?: string;
+ /** Schema version of the IR emitter. */
+ irVersion?: number;
+ /** Free-form compiler diagnostics / annotations. */
+ annotations?: Record;
+ [key: string]: unknown;
+}
+
+// ---------------------------------------------------------------------------
+// FlowIR — the canonical compiled IR
+// ---------------------------------------------------------------------------
+
+/**
+ * The canonical compiled FlowIR. A **superset** of `meta.ts`'s `FlowIR`:
+ * - `version` is formalized (maps to the DSL `Taskflow.version`).
+ * - `edges` is the optional explicit edge model (absent on the stub).
+ * - `meta` is the optional flow-level metadata (absent on the stub).
+ *
+ * Every `FlowIR` produced by `translateTaskflow` — `{ name, nodes, args?,
+ * budget?, concurrency? }` — satisfies this contract (the added fields are all
+ * optional). `compile.ts` (batch 2) emits the full canonical form.
+ */
+export interface FlowIR {
+ /** Workflow name (the DSL `Taskflow.name`). */
+ name: string;
+ /** DSL schema version (maps to `Taskflow.version`, default 1). */
+ version?: number;
+ /** The flat list of IR nodes (one per phase). */
+ nodes: FlowIRNode[];
+ /** Optional explicit edge list (consistent with `inject`). */
+ edges?: FlowIREdge[];
+ /** Declared invocation arguments (DSL `Taskflow.args`). */
+ args?: Record;
+ /** Run-wide cost / token ceiling. */
+ budget?: FlowIRBudget;
+ /** Default max concurrent subagents. */
+ concurrency?: number;
+ /** Optional flow-level metadata (compiler-populated). */
+ meta?: FlowIRMeta;
+}
+
+// ---------------------------------------------------------------------------
+// TypeBox schemas (for runtime validation / structural guards)
+// ---------------------------------------------------------------------------
+
+/**
+ * TypeBox schema mirroring {@link FlowIRNode}. Used by the structural guards
+ * below. Kept in sync by construction (the `Static` of this schema *is*
+ * `FlowIRNode`); if the interface above drifts, this schema must be updated to
+ * match.
+ */
+export const FlowIRNodeSchema = Type.Object(
+ {
+ id: Type.String({ description: "Unique phase identifier" }),
+ kind: FlowIRNodeKind,
+ inject: Type.Array(Type.String(), { description: "Declared reads (upstream step ids)" }),
+ emits: Type.Array(Type.String(), { description: "What this node emits (currently [id])" }),
+ when: Type.Optional(Type.String({ description: "Raw when guard passthrough" })),
+ task: Type.Optional(Type.String({ description: "Resolved task prompt" })),
+ condRef: Type.Optional(Type.String({ description: "Reference to a compiled condition descriptor" })),
+ deps: Type.Optional(Type.Array(Type.String(), { description: "Explicit dependsOn edges" })),
+ join: Type.Optional(Type.Union([Type.Literal("all"), Type.Literal("any")], { description: "Join mode" })),
+ timeout: Type.Optional(Type.Number({ description: "Per-call ms cap" })),
+ },
+ { additionalProperties: false },
+);
+
+/**
+ * TypeBox schema mirroring {@link FlowIREdge}.
+ */
+export const FlowIREdgeSchema = Type.Object(
+ {
+ from: Type.String({ description: "Upstream (producer) node id" }),
+ to: Type.String({ description: "Downstream (consumer) node id" }),
+ },
+ { additionalProperties: false },
+);
+
+/**
+ * TypeBox schema mirroring {@link FlowIR}.
+ */
+export const FlowIRSchema = Type.Object(
+ {
+ name: Type.String({ minLength: 1, description: "Workflow name" }),
+ version: Type.Optional(Type.Number({ description: "DSL schema version" })),
+ nodes: Type.Array(FlowIRNodeSchema, { minItems: 1, description: "IR nodes (one per phase)" }),
+ edges: Type.Optional(Type.Array(FlowIREdgeSchema, { description: "Explicit edge list" })),
+ args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Declared invocation arguments" })),
+ budget: Type.Optional(
+ Type.Object(
+ {
+ maxUSD: Type.Optional(Type.Number()),
+ maxTokens: Type.Optional(Type.Number()),
+ },
+ { additionalProperties: false },
+ ),
+ ),
+ concurrency: Type.Optional(Type.Number({ description: "Default max concurrent subagents" })),
+ meta: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Flow-level metadata" })),
+ },
+ { additionalProperties: false },
+);
+
+// ---------------------------------------------------------------------------
+// Structural guards — pure, zero-dep (beyond typebox), fail-closed
+// ---------------------------------------------------------------------------
+
+/** The 10 valid phase kinds, for O(1) membership checks without TypeBox. */
+const VALID_KINDS: ReadonlySet = new Set([
+ "agent",
+ "parallel",
+ "map",
+ "gate",
+ "reduce",
+ "approval",
+ "flow",
+ "loop",
+ "tournament",
+ "script",
+]);
+
+/**
+ * Narrow an `unknown` value to a {@link FlowIRNode}. Pure, synchronous,
+ * fail-closed: returns `false` for anything that is not a plain object with
+ * a string `id`, a valid `kind`, and string-array `inject`/`emits`. Does not
+ * throw. This is a *structural* check (not a deep TypeBox decode) so it stays
+ * cheap and dependency-light; use {@link assertFlowIR} for full validation.
+ */
+export function isFlowIRNode(value: unknown): value is FlowIRNode {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
+ const n = value as Record;
+ if (typeof n.id !== "string" || n.id.length === 0) return false;
+ if (typeof n.kind !== "string" || !VALID_KINDS.has(n.kind)) return false;
+ if (!isStringArray(n.inject)) return false;
+ if (!isStringArray(n.emits)) return false;
+ if (n.when !== undefined && typeof n.when !== "string") return false;
+ if (n.task !== undefined && typeof n.task !== "string") return false;
+ if (n.condRef !== undefined && typeof n.condRef !== "string") return false;
+ if (n.deps !== undefined && !isStringArray(n.deps)) return false;
+ if (n.join !== undefined && n.join !== "all" && n.join !== "any") return false;
+ if (n.timeout !== undefined && typeof n.timeout !== "number") return false;
+ return true;
+}
+
+/** Type guard for a `string[]`. */
+function isStringArray(v: unknown): v is string[] {
+ if (!Array.isArray(v)) return false;
+ return v.every((x) => typeof x === "string");
+}
+
+/**
+ * Assert that a value is a well-formed {@link FlowIR}. Throws a descriptive
+ * `Error` (not an `AssertionError`) on the first structural violation —
+ * fail-closed, so a malformed IR can never silently reach the kernel. Uses
+ * the lightweight structural checks (no external validator); for full schema
+ * decoding, decode `FlowIRSchema` with a TypeBox-compatible checker.
+ *
+ * @throws {Error} if `value` is not a valid `FlowIR`.
+ */
+export function assertFlowIR(value: unknown): asserts value is FlowIR {
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
+ throw new Error(`FlowIR: expected an object, got ${typeof value}`);
+ }
+ const ir = value as Record;
+ if (typeof ir.name !== "string" || ir.name.length === 0) {
+ throw new Error(`FlowIR: 'name' must be a non-empty string`);
+ }
+ if (ir.version !== undefined && typeof ir.version !== "number") {
+ throw new Error(`FlowIR: 'version' must be a number if present`);
+ }
+ if (!Array.isArray(ir.nodes) || ir.nodes.length === 0) {
+ throw new Error(`FlowIR: 'nodes' must be a non-empty array`);
+ }
+ const seenIds = new Set();
+ for (let i = 0; i < ir.nodes.length; i++) {
+ const node = ir.nodes[i];
+ if (!isFlowIRNode(node)) {
+ throw new Error(`FlowIR: nodes[${i}] is not a valid FlowIRNode (id=${String((node as { id?: unknown })?.id)})`);
+ }
+ if (seenIds.has(node.id)) {
+ throw new Error(`FlowIR: duplicate node id '${node.id}'`);
+ }
+ seenIds.add(node.id);
+ }
+ if (ir.edges !== undefined) {
+ if (!Array.isArray(ir.edges)) {
+ throw new Error(`FlowIR: 'edges' must be an array if present`);
+ }
+ for (let i = 0; i < ir.edges.length; i++) {
+ const e = ir.edges[i] as Record | null;
+ if (
+ typeof e !== "object" ||
+ e === null ||
+ typeof e.from !== "string" ||
+ typeof e.to !== "string"
+ ) {
+ throw new Error(`FlowIR: edges[${i}] must be { from: string, to: string }`);
+ }
+ if (!seenIds.has(e.from)) {
+ throw new Error(`FlowIR: edges[${i}].from references unknown node '${e.from}'`);
+ }
+ if (!seenIds.has(e.to)) {
+ throw new Error(`FlowIR: edges[${i}].to references unknown node '${e.to}'`);
+ }
+ }
+ }
+ if (ir.budget !== undefined) {
+ if (typeof ir.budget !== "object" || ir.budget === null || Array.isArray(ir.budget)) {
+ throw new Error(`FlowIR: 'budget' must be an object if present`);
+ }
+ const b = ir.budget as Record;
+ if (b.maxUSD !== undefined && typeof b.maxUSD !== "number") {
+ throw new Error(`FlowIR: 'budget.maxUSD' must be a number if present`);
+ }
+ if (b.maxTokens !== undefined && typeof b.maxTokens !== "number") {
+ throw new Error(`FlowIR: 'budget.maxTokens' must be a number if present`);
+ }
+ }
+ if (ir.concurrency !== undefined && typeof ir.concurrency !== "number") {
+ throw new Error(`FlowIR: 'concurrency' must be a number if present`);
+ }
+ if (ir.args !== undefined && (typeof ir.args !== "object" || ir.args === null || Array.isArray(ir.args))) {
+ throw new Error(`FlowIR: 'args' must be an object if present`);
+ }
+ if (ir.meta !== undefined && (typeof ir.meta !== "object" || ir.meta === null || Array.isArray(ir.meta))) {
+ throw new Error(`FlowIR: 'meta' must be an object if present`);
+ }
+}
diff --git a/packages/taskflow-core/test/flowir-schema.test.ts b/packages/taskflow-core/test/flowir-schema.test.ts
new file mode 100644
index 0000000..10f781e
--- /dev/null
+++ b/packages/taskflow-core/test/flowir-schema.test.ts
@@ -0,0 +1,317 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ FlowIRNodeKind,
+ FlowIRNodeSchema,
+ FlowIREdgeSchema,
+ FlowIRSchema,
+ isFlowIRNode,
+ assertFlowIR,
+} from "../src/flowir/schema.ts";
+import { Value } from "typebox/value";
+import type { FlowIR, FlowIRNode } from "../src/flowir/schema.ts";
+
+// ---------------------------------------------------------------------------
+// Helpers — build canonical FlowIR nodes / IRs
+// ---------------------------------------------------------------------------
+
+function node(
+ id: string,
+ kind: FlowIRNode["kind"] = "agent",
+ overrides?: Partial,
+): FlowIRNode {
+ return { id, kind, inject: [], emits: [id], ...overrides };
+}
+
+function ir(nodes: FlowIRNode[], overrides?: Partial): FlowIR {
+ return { name: "test-flow", nodes, ...overrides };
+}
+
+// ---------------------------------------------------------------------------
+// FlowIRNodeKind — closed literal union of the 10 phase kinds
+// ---------------------------------------------------------------------------
+
+test("FlowIRNodeKind: the 10 phase kinds are all valid members", () => {
+ const kinds = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow", "loop", "tournament", "script"];
+ for (const k of kinds) {
+ // Each kind is a member of the union schema.
+ const decoded = Value.Decode(FlowIRNodeKind, k);
+ assert.equal(decoded, k);
+ }
+});
+
+test("FlowIRNodeKind: rejects an unknown phase kind", () => {
+ // typebox's Value.Decode throws a generic 'Decode' error on validation
+ // failure (no value detail in the message); we assert it throws at all,
+ // and that Value.Check returns false for the unknown kind.
+ assert.throws(() => Value.Decode(FlowIRNodeKind, "nope"));
+ assert.equal(Value.Check(FlowIRNodeKind, "nope"), false);
+});
+
+// ---------------------------------------------------------------------------
+// isFlowIRNode — structural narrowing (pure, fail-closed)
+// ---------------------------------------------------------------------------
+
+test("isFlowIRNode: accepts a minimal valid node", () => {
+ const n = node("a");
+ assert.equal(isFlowIRNode(n), true);
+});
+
+test("isFlowIRNode: accepts a node with all optional fields populated", () => {
+ const n = node("a", "gate", {
+ when: "steps.x.output == 'ok'",
+ task: "review the output",
+ condRef: "cond#a",
+ deps: ["x"],
+ join: "any",
+ timeout: 5000,
+ });
+ assert.equal(isFlowIRNode(n), true);
+});
+
+test("isFlowIRNode: accepts every FlowIRNodeKind", () => {
+ for (const k of [
+ "agent", "parallel", "map", "gate", "reduce",
+ "approval", "flow", "loop", "tournament", "script",
+ ] as const) {
+ assert.equal(isFlowIRNode(node(k, k)), true, `kind=${k}`);
+ }
+});
+
+test("isFlowIRNode: rejects a non-object", () => {
+ assert.equal(isFlowIRNode(null), false);
+ assert.equal(isFlowIRNode(undefined), false);
+ assert.equal(isFlowIRNode("agent"), false);
+ assert.equal(isFlowIRNode(42), false);
+ assert.equal(isFlowIRNode([]), false);
+});
+
+test("isFlowIRNode: rejects an empty id", () => {
+ assert.equal(isFlowIRNode({ ...node("a"), id: "" }), false);
+});
+
+test("isFlowIRNode: rejects an invalid kind", () => {
+ assert.equal(isFlowIRNode({ ...node("a"), kind: "nope" }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), kind: 7 }), false);
+});
+
+test("isFlowIRNode: rejects non-array inject/emits", () => {
+ assert.equal(isFlowIRNode({ ...node("a"), inject: "b" }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), emits: null }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), inject: [1, 2] }), false);
+});
+
+test("isFlowIRNode: rejects a malformed optional field", () => {
+ assert.equal(isFlowIRNode({ ...node("a"), when: 9 }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), task: 9 }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), condRef: 9 }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), deps: "x" }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), join: "maybe" }), false);
+ assert.equal(isFlowIRNode({ ...node("a"), timeout: "5s" }), false);
+});
+
+test("isFlowIRNode: acts as a type guard (narrows unknown)", () => {
+ const v: unknown = node("a", "agent", { task: "go" });
+ if (isFlowIRNode(v)) {
+ // Inside the guard, `v` is narrowed to FlowIRNode.
+ assert.equal(v.id, "a");
+ assert.equal(v.kind, "agent");
+ assert.equal(v.task, "go");
+ } else {
+ assert.fail("guard should have narrowed");
+ }
+});
+
+// ---------------------------------------------------------------------------
+// assertFlowIR — fail-closed validation of a full FlowIR
+// ---------------------------------------------------------------------------
+
+test("assertFlowIR: accepts a valid minimal IR", () => {
+ const f = ir([node("a"), node("b", "agent", { inject: ["a"] })]);
+ assertFlowIR(f); // does not throw
+});
+
+test("assertFlowIR: accepts an IR with edges, budget, concurrency, meta", () => {
+ const f = ir(
+ [node("a"), node("b", "agent", { inject: ["a"] })],
+ {
+ version: 1,
+ edges: [{ from: "a", to: "b" }],
+ budget: { maxUSD: 1.5, maxTokens: 10000 },
+ concurrency: 4,
+ meta: { source: "pi", irVersion: 1 },
+ args: { topic: { default: "x" } },
+ },
+ );
+ assertFlowIR(f);
+});
+
+test("assertFlowIR: accepts the stub shape translate.ts produces (superset compatibility)", () => {
+ // Mirror the exact minimal shape translateTaskflow emits: name + nodes
+ // (+ optional args/budget/concurrency), each node {id,kind,inject,emits,when?}.
+ const stubLike: unknown = {
+ name: "audit",
+ nodes: [
+ { id: "scan", kind: "agent", inject: [], emits: ["scan"] },
+ { id: "report", kind: "agent", inject: ["scan"], emits: ["report"], when: "steps.scan.output != ''" },
+ ],
+ args: undefined,
+ budget: undefined,
+ concurrency: undefined,
+ };
+ assertFlowIR(stubLike); // does not throw — superset of the stub output
+});
+
+test("assertFlowIR: rejects a non-object", () => {
+ assert.throws(() => assertFlowIR(null), /expected an object/);
+ assert.throws(() => assertFlowIR("x"), /expected an object/);
+ assert.throws(() => assertFlowIR([]), /expected an object/);
+});
+
+test("assertFlowIR: rejects an empty or missing name", () => {
+ assert.throws(() => assertFlowIR({ nodes: [node("a")] }), /name/);
+ assert.throws(() => assertFlowIR({ name: "", nodes: [node("a")] }), /non-empty string/);
+});
+
+test("assertFlowIR: rejects an empty nodes array", () => {
+ assert.throws(() => assertFlowIR({ name: "f", nodes: [] }), /non-empty array/);
+ assert.throws(() => assertFlowIR({ name: "f" }), /non-empty array/);
+});
+
+test("assertFlowIR: rejects a malformed node in the list", () => {
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [{ id: "a", kind: "nope", inject: [], emits: ["a"] }] }),
+ /not a valid FlowIRNode/,
+ );
+});
+
+test("assertFlowIR: rejects a non-array nodes element", () => {
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [{ id: "a", kind: "agent", inject: "x", emits: ["a"] }] }),
+ /not a valid FlowIRNode/,
+ );
+});
+
+test("assertFlowIR: rejects duplicate node ids", () => {
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [node("a"), node("a")] }),
+ /duplicate node id 'a'/,
+ );
+});
+
+test("assertFlowIR: rejects edges referencing unknown nodes", () => {
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [node("a")], edges: [{ from: "a", to: "zzz" }] }),
+ /unknown node 'zzz'/,
+ );
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [node("a")], edges: [{ from: "zzz", to: "a" }] }),
+ /unknown node 'zzz'/,
+ );
+});
+
+test("assertFlowIR: rejects a malformed edge", () => {
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [node("a")], edges: [{ from: "a" }] }),
+ /\{ from: string, to: string \}/,
+ );
+});
+
+test("assertFlowIR: rejects a malformed budget", () => {
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [node("a")], budget: { maxUSD: "1" } }),
+ /budget\.maxUSD/,
+ );
+ assert.throws(
+ () => assertFlowIR({ name: "f", nodes: [node("a")], budget: 5 }),
+ /budget/,
+ );
+});
+
+test("assertFlowIR: rejects a malformed concurrency / args / meta", () => {
+ assert.throws(() => assertFlowIR({ name: "f", nodes: [node("a")], concurrency: "4" }), /concurrency/);
+ assert.throws(() => assertFlowIR({ name: "f", nodes: [node("a")], args: "x" }), /args/);
+ assert.throws(() => assertFlowIR({ name: "f", nodes: [node("a")], meta: "x" }), /meta/);
+});
+
+test("assertFlowIR: narrows the type after assertion", () => {
+ const v: unknown = ir([node("a")]);
+ assertFlowIR(v);
+ // `v` is now FlowIR — access fields freely.
+ assert.equal(v.name, "test-flow");
+ assert.equal(v.nodes.length, 1);
+ assert.equal(v.nodes[0].id, "a");
+});
+
+// ---------------------------------------------------------------------------
+// TypeBox schema decode — full structural validation (mirror of the guards)
+// ---------------------------------------------------------------------------
+
+test("FlowIRNodeSchema: decodes a valid node", () => {
+ const n = node("a", "agent", { when: "true", timeout: 100 });
+ const decoded = Value.Decode(FlowIRNodeSchema, n);
+ assert.equal(decoded.id, "a");
+ assert.equal(decoded.kind, "agent");
+ assert.equal(decoded.timeout, 100);
+});
+
+test("FlowIRNodeSchema: rejects additional properties (Check is false)", () => {
+ // typebox's Value.Decode silently ignores additional properties (it does
+ // not throw), but Value.Check enforces `additionalProperties: false` and
+ // returns false. (The lightweight isFlowIRNode guard intentionally does
+ // NOT reject extra fields — it validates known fields only; use
+ // Value.Check / assertFlowIR for full schema enforcement.)
+ const n = { ...node("a"), bogus: 1 };
+ assert.equal(Value.Check(FlowIRNodeSchema, n), false);
+});
+
+test("FlowIREdgeSchema: decodes a valid edge", () => {
+ const e = Value.Decode(FlowIREdgeSchema, { from: "a", to: "b" });
+ assert.equal(e.from, "a");
+ assert.equal(e.to, "b");
+});
+
+test("FlowIRSchema: decodes a full valid IR", () => {
+ const f = ir(
+ [node("a"), node("b", "agent", { inject: ["a"], join: "any" })],
+ { version: 2, edges: [{ from: "a", to: "b" }], budget: { maxTokens: 5000 }, concurrency: 2 },
+ );
+ const decoded = Value.Decode(FlowIRSchema, f);
+ assert.equal(decoded.name, "test-flow");
+ assert.equal(decoded.version, 2);
+ assert.equal(decoded.nodes.length, 2);
+ assert.equal(decoded.edges?.length, 1);
+});
+
+test("FlowIRSchema: rejects a node with an invalid kind", () => {
+ const f = { name: "f", nodes: [{ ...node("a"), kind: "wizard" }] };
+ assert.throws(() => Value.Decode(FlowIRSchema, f));
+ assert.equal(Value.Check(FlowIRSchema, f), false);
+});
+
+test("FlowIRSchema: rejects an empty name", () => {
+ const f = { name: "", nodes: [node("a")] };
+ assert.throws(() => Value.Decode(FlowIRSchema, f));
+ assert.equal(Value.Check(FlowIRSchema, f), false);
+});
+
+test("FlowIRSchema: rejects an empty nodes array", () => {
+ const f = { name: "f", nodes: [] };
+ assert.throws(() => Value.Decode(FlowIRSchema, f));
+ assert.equal(Value.Check(FlowIRSchema, f), false);
+});
+
+// ---------------------------------------------------------------------------
+// Round-trip: a FlowIR built from all 10 kinds is valid
+// ---------------------------------------------------------------------------
+
+test("a FlowIR with one node of each of the 10 kinds is valid", () => {
+ const kinds: FlowIRNode["kind"][] = [
+ "agent", "parallel", "map", "gate", "reduce",
+ "approval", "flow", "loop", "tournament", "script",
+ ];
+ const nodes = kinds.map((k, i) => node(`${k}-${i}`, k));
+ const f = ir(nodes);
+ assertFlowIR(f);
+ assert.equal(f.nodes.length, 10);
+});
From 6b5bb44b427fb779bd4330ad66629a2bb0075f73 Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 11:25:02 +0800
Subject: [PATCH 07/51] feat(flowir): add genuine content-addressed FlowIR hash
(canonical-hash)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add canonicalizeFlowIR/hashFlowIR/hashNode — a synchronous node:crypto
SHA-256 content-addressed hash over the canonical FlowIR (Q5=own), distinct
from the vendored async flowDefHash (DSL-level, overstory contract).
canonicalizeFlowIR is deterministic and order/whitespace-independent:
stable-sorted object keys, nodes re-sorted by id, when guards normalized
via normalizeCond, undefined optionals dropped. Two logically-equivalent
IRs (key reorder, node reorder, condition whitespace/parens) canonicalize
identically. inject/emits/deps/edges arrays keep order (semantic).
34 property tests: DETERMINISM, ORDER/WHITESPACE INDEPENDENCE, SENSITIVITY.
Not wired into runtime/cache or any barrel — batch 2 wiring.
---
.../src/flowir/canonical-hash.ts | 201 ++++++++++++
.../test/flowir-canonical-hash.test.ts | 289 ++++++++++++++++++
2 files changed, 490 insertions(+)
create mode 100644 packages/taskflow-core/src/flowir/canonical-hash.ts
create mode 100644 packages/taskflow-core/test/flowir-canonical-hash.test.ts
diff --git a/packages/taskflow-core/src/flowir/canonical-hash.ts b/packages/taskflow-core/src/flowir/canonical-hash.ts
new file mode 100644
index 0000000..163994b
--- /dev/null
+++ b/packages/taskflow-core/src/flowir/canonical-hash.ts
@@ -0,0 +1,201 @@
+/**
+ * # Genuine content-addressed hash over canonical FlowIR
+ *
+ * This is pi-taskflow's **own** content-addressed hash (Q5=own) over the
+ * canonical {@link FlowIR} type contract defined in `./schema.ts`. Unlike
+ * `./hash.ts`'s `flowDefHash` — which fingerprints the *DSL Taskflow
+ * definition* (vendored from overstory, async Web Crypto, truncated to 16
+ * bytes / 32 hex) — this module hashes the *compiled IR* synchronously with
+ * `node:crypto` SHA-256 (full 64-hex-char digest). The two answer different
+ * questions:
+ *
+ * - `flowDefHash(def)` — "did the *flow definition* change?" (DSL-level)
+ * - `hashFlowIR(ir)` — "is this *compiled IR* the same IR?" (IR-level)
+ *
+ * The IR-level hash is the content-addressed key the event-sourced kernel
+ * (batch 2, `flowir/compile.ts`) will use to deduplicate compiled graphs and
+ * to key per-phase fingerprints (`hashNode`) independent of surface formatting.
+ *
+ * ## Canonicalization guarantees
+ *
+ * {@link canonicalizeFlowIR} produces a DETERMINISTIC serialization that is
+ * invariant under the cosmetic differences two logically-equivalent IRs may
+ * differ by:
+ *
+ * - **Object key order** — all object keys are stable-sorted (UTF-16 code
+ * units), so reordering fields on a node or on the IR object does not
+ * change the canonical form.
+ * - **Node array order** — `nodes` is re-sorted by `id` (stable), so two IRs
+ * that list the same nodes in different order canonicalize identically.
+ * Other arrays (`inject`, `emits`, `deps`, `edges`) keep their order —
+ * their ordering is semantically meaningful (declared-read order, edge
+ * declaration order) and must be preserved.
+ * - **Condition spelling** — a node's `when` guard is canonicalized via
+ * {@link normalizeCond} (`./cond.ts`), which collapses whitespace, operator
+ * spacing, and redundant enclosing parens while preserving string-literal
+ * contents. Equivalent conditions canonicalize identically.
+ * - **Insignificant formatting** — `undefined` optional fields are dropped,
+ * so their presence/absence does not change the hash.
+ *
+ * Two logically-equivalent FlowIRs (key reorder, whitespace, equivalent
+ * condition spelling) MUST canonicalize to the same string.
+ *
+ * ## Purity
+ *
+ * Zero runtime deps besides `node:crypto`. No IO, no `Date`, no randomness,
+ * never throws. The hash is synchronous (unlike `flowDefHash`).
+ *
+ * **Not yet wired** into runtime/cache — `usedFallbackHash` is untouched and
+ * `canonicalizeFlowIR`/`hashFlowIR`/`hashNode` are not referenced by any
+ * barrel or the runtime. Wiring happens in batch 2.
+ *
+ * @see ./schema.ts for the canonical FlowIR type contract.
+ * @see ./cond.ts for condition normalization (`normalizeCond`).
+ * @see ./hash.ts for the DSL-level `flowDefHash` (vendored from overstory).
+ */
+
+import { createHash } from "node:crypto";
+import type { FlowIR, FlowIRNode } from "./schema.ts";
+import { normalizeCond } from "./cond.ts";
+
+// ---------------------------------------------------------------------------
+// Canonical serializer (key-sorted, undefined-dropped, array-order-preserving)
+// ---------------------------------------------------------------------------
+
+/**
+ * Deterministic serialization of a plain-JSON-compatible value: recursively
+ * key-sorts objects (UTF-16 code units), drops `undefined` values, preserves
+ * array order, and uses `JSON.stringify` for primitives/null. This mirrors
+ * the canonical-JSON convention used by `./hash.ts`'s `canonicalJson` (itself
+ * vendored from overstory) so the two hashes share the same primitive
+ * encoding — but this serializer is local to the IR hash so the IR-level
+ * contract can evolve independently of the overstory-vendored DSL hash.
+ *
+ * The value passed in MUST be pre-canonicalized (nodes sorted by id, `when`
+ * normalized) — this function does not know about FlowIR semantics, it only
+ * guarantees key-order independence and `undefined`-dropping.
+ */
+function canonicalSerialize(value: unknown): string {
+ if (value === null) return "null";
+ if (typeof value === "boolean") return JSON.stringify(value);
+ if (typeof value === "number") return JSON.stringify(value);
+ if (typeof value === "string") return JSON.stringify(value);
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => canonicalSerialize(item)).join(",")}]`;
+ }
+ if (typeof value === "object") {
+ const record = value as Record;
+ const keys = Object.keys(record)
+ .filter((key) => record[key] !== undefined)
+ .sort();
+ const body = keys.map((key) => `${JSON.stringify(key)}:${canonicalSerialize(record[key])}`);
+ return `{${body.join(",")}}`;
+ }
+ // undefined / function / symbol / bigint at this layer — not representable.
+ return "null";
+}
+
+// ---------------------------------------------------------------------------
+// Canonical node object (drops undefined, normalizes `when`)
+// ---------------------------------------------------------------------------
+
+/**
+ * Project a {@link FlowIRNode} into a plain canonical object form: fields are
+ * emitted in a fixed order (order is irrelevant — `canonicalSerialize`
+ * re-sorts keys, but emitting canonically makes the intermediate form
+ * human-inspectable), `when` is normalized via {@link normalizeCond} so
+ * equivalent condition spellings collapse, and `undefined` optionals are
+ * omitted so their presence/absence does not affect the hash.
+ *
+ * `inject`/`emits`/`deps` arrays are preserved verbatim (order is semantic —
+ * declared-read order matters for fingerprinting).
+ */
+function canonicalNodeObject(node: FlowIRNode): Record {
+ const obj: Record = {
+ id: node.id,
+ kind: node.kind,
+ inject: node.inject,
+ emits: node.emits,
+ };
+ if (node.when !== undefined) {
+ obj.when = normalizeCond(node.when).canonical;
+ }
+ if (node.task !== undefined) obj.task = node.task;
+ if (node.condRef !== undefined) obj.condRef = node.condRef;
+ if (node.deps !== undefined) obj.deps = node.deps;
+ if (node.join !== undefined) obj.join = node.join;
+ if (node.timeout !== undefined) obj.timeout = node.timeout;
+ return obj;
+}
+
+// ---------------------------------------------------------------------------
+// Public canonicalization
+// ---------------------------------------------------------------------------
+
+/**
+ * Deterministic, order- and whitespace-independent canonical serialization of
+ * a {@link FlowIR}.
+ *
+ * - `nodes` is re-sorted by `id` (stable) so node-list order does not affect
+ * the result.
+ * - Each node's `when` guard is canonicalized via {@link normalizeCond}.
+ * - All object keys are stable-sorted; `undefined` optionals are dropped.
+ * - `inject`/`emits`/`deps`/`edges` arrays keep their order (semantic).
+ *
+ * Two logically-equivalent FlowIRs MUST canonicalize to the same string.
+ * Never throws.
+ */
+export function canonicalizeFlowIR(ir: FlowIR): string {
+ // Stable sort by node id — node-list order is not semantic.
+ const sortedNodes = [...ir.nodes].sort((a, b) => {
+ if (a.id < b.id) return -1;
+ if (a.id > b.id) return 1;
+ return 0;
+ });
+
+ const obj: Record = {
+ name: ir.name,
+ nodes: sortedNodes.map((n) => canonicalNodeObject(n)),
+ };
+ if (ir.version !== undefined) obj.version = ir.version;
+ if (ir.edges !== undefined) obj.edges = ir.edges;
+ if (ir.args !== undefined) obj.args = ir.args;
+ if (ir.budget !== undefined) obj.budget = ir.budget;
+ if (ir.concurrency !== undefined) obj.concurrency = ir.concurrency;
+ if (ir.meta !== undefined) obj.meta = ir.meta;
+
+ return canonicalSerialize(obj);
+}
+
+/**
+ * Deterministic canonical serialization of a single {@link FlowIRNode}, for
+ * per-node content addressing. Same canonicalization rules as
+ * {@link canonicalizeFlowIR} applied to one node (`when` normalized, keys
+ * sorted, `undefined` optionals dropped, `inject`/`emits`/`deps` order
+ * preserved). Never throws.
+ */
+export function canonicalizeNode(node: FlowIRNode): string {
+ return canonicalSerialize(canonicalNodeObject(node));
+}
+
+// ---------------------------------------------------------------------------
+// Content-addressed hashing (node:crypto SHA-256, full 64-hex digest)
+// ---------------------------------------------------------------------------
+
+/**
+ * SHA-256 (hex, 64 lowercase chars) over {@link canonicalizeFlowIR}. The
+ * genuine content-addressed hash of a compiled FlowIR. Synchronous (uses
+ * `node:crypto`, unlike the async Web-Crypto `flowDefHash`). Never throws.
+ */
+export function hashFlowIR(ir: FlowIR): string {
+ return createHash("sha256").update(canonicalizeFlowIR(ir), "utf8").digest("hex");
+}
+
+/**
+ * SHA-256 (hex, 64 lowercase chars) over {@link canonicalizeNode} — a
+ * per-node content hash usable as a phase fingerprint independent of surface
+ * formatting. Synchronous. Never throws.
+ */
+export function hashNode(node: FlowIRNode): string {
+ return createHash("sha256").update(canonicalizeNode(node), "utf8").digest("hex");
+}
diff --git a/packages/taskflow-core/test/flowir-canonical-hash.test.ts b/packages/taskflow-core/test/flowir-canonical-hash.test.ts
new file mode 100644
index 0000000..242bae3
--- /dev/null
+++ b/packages/taskflow-core/test/flowir-canonical-hash.test.ts
@@ -0,0 +1,289 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ canonicalizeFlowIR,
+ hashFlowIR,
+ hashNode,
+} from "../src/flowir/canonical-hash.ts";
+import type { FlowIR, FlowIRNode } from "../src/flowir/schema.ts";
+
+function node(id: string, overrides: Partial = {}): FlowIRNode {
+ return {
+ id,
+ kind: "agent",
+ inject: [],
+ emits: [id],
+ ...overrides,
+ };
+}
+
+function ir(nodes: FlowIRNode[], overrides: Partial = {}): FlowIR {
+ return { name: "test-flow", nodes, ...overrides };
+}
+
+// A reference IR used across many tests.
+function referenceIR(): FlowIR {
+ return ir([
+ node("alpha", { inject: [], task: "do the first thing" }),
+ node("beta", { inject: ["alpha"], task: "do the second thing", when: "{steps.alpha.output} == 'ok'" }),
+ node("gamma", { kind: "gate", inject: ["beta"], when: "{steps.beta.output} != 'fail'" }),
+ ]);
+}
+
+test("hashFlowIR: returns 64 lowercase hex chars", () => {
+ const h = hashFlowIR(referenceIR());
+ assert.match(h, /^[0-9a-f]{64}$/);
+});
+
+test("hashNode: returns 64 lowercase hex chars", () => {
+ const h = hashNode(node("a"));
+ assert.match(h, /^[0-9a-f]{64}$/);
+});
+
+// ---------------------------------------------------------------------------
+// 1. DETERMINISM
+// ---------------------------------------------------------------------------
+
+test("DETERMINISM: hashFlowIR is identical across repeated calls", () => {
+ const a = referenceIR();
+ const h1 = hashFlowIR(a);
+ const h2 = hashFlowIR(a);
+ const h3 = hashFlowIR(a);
+ assert.equal(h1, h2);
+ assert.equal(h2, h3);
+});
+
+test("DETERMINISM: hashNode is identical across repeated calls", () => {
+ const n = node("x", { task: "hello" });
+ assert.equal(hashNode(n), hashNode(n));
+});
+
+test("DETERMINISM: canonicalizeFlowIR is identical across repeated calls", () => {
+ const a = referenceIR();
+ assert.equal(canonicalizeFlowIR(a), canonicalizeFlowIR(a));
+});
+
+test("DETERMINISM: structurally-cloned IR hashes identically", () => {
+ const a = referenceIR();
+ const b: FlowIR = JSON.parse(JSON.stringify(a));
+ assert.equal(hashFlowIR(a), hashFlowIR(b));
+});
+
+// ---------------------------------------------------------------------------
+// 2. ORDER / WHITESPACE INDEPENDENCE
+// ---------------------------------------------------------------------------
+
+test("INDEPENDENCE: reordering nodes yields the same hash", () => {
+ const order1 = ir([node("a"), node("b"), node("c")]);
+ const order2 = ir([node("c"), node("a"), node("b")]);
+ const order3 = ir([node("b"), node("c"), node("a")]);
+ assert.equal(hashFlowIR(order1), hashFlowIR(order2));
+ assert.equal(hashFlowIR(order2), hashFlowIR(order3));
+});
+
+test("INDEPENDENCE: reordering nodes with inject edges yields the same hash", () => {
+ const order1 = ir([
+ node("a", { task: "first" }),
+ node("b", { inject: ["a"], task: "second" }),
+ node("c", { inject: ["a", "b"], task: "third" }),
+ ]);
+ const order2 = ir([
+ node("c", { inject: ["a", "b"], task: "third" }),
+ node("b", { inject: ["a"], task: "second" }),
+ node("a", { task: "first" }),
+ ]);
+ assert.equal(hashFlowIR(order1), hashFlowIR(order2));
+});
+
+test("INDEPENDENCE: reordering object keys on a node yields the same hash", () => {
+ const n1: FlowIRNode = {
+ id: "x",
+ kind: "agent",
+ inject: ["a"],
+ emits: ["x"],
+ task: "do thing",
+ join: "any",
+ timeout: 5000,
+ };
+ const n2: FlowIRNode = {
+ timeout: 5000,
+ join: "any",
+ task: "do thing",
+ emits: ["x"],
+ inject: ["a"],
+ kind: "agent",
+ id: "x",
+ };
+ assert.equal(hashNode(n1), hashNode(n2));
+});
+
+test("INDEPENDENCE: reordering object keys on the IR yields the same hash", () => {
+ const nodes = [node("a")];
+ const ir1: FlowIR = { name: "f", nodes, concurrency: 2, version: 1 };
+ const ir2: FlowIR = { version: 1, concurrency: 2, nodes, name: "f" };
+ assert.equal(hashFlowIR(ir1), hashFlowIR(ir2));
+});
+
+test("INDEPENDENCE: condition whitespace does not change the hash", () => {
+ const spaced = node("g", { kind: "gate", when: "{steps.a.output} == 'ok'" });
+ const compact = node("g", { kind: "gate", when: "{steps.a.output}=='ok'" });
+ const padded = node("g", { kind: "gate", when: " {steps.a.output} == 'ok' " });
+ assert.equal(hashNode(spaced), hashNode(compact));
+ assert.equal(hashNode(compact), hashNode(padded));
+});
+
+test("INDEPENDENCE: redundant enclosing parens on a condition do not change the hash", () => {
+ const bare = node("g", { kind: "gate", when: "{steps.a.output} == 'ok'" });
+ const paren = node("g", { kind: "gate", when: "(({steps.a.output} == 'ok'))" });
+ assert.equal(hashNode(bare), hashNode(paren));
+});
+
+test("INDEPENDENCE: condition operator spacing variants do not change the hash", () => {
+ const a = node("g", { kind: "gate", when: "{steps.a.output}!='fail'" });
+ const b = node("g", { kind: "gate", when: "{steps.a.output} != 'fail'" });
+ const c = node("g", { kind: "gate", when: "{steps.a.output} != 'fail'" });
+ assert.equal(hashNode(a), hashNode(b));
+ assert.equal(hashNode(b), hashNode(c));
+});
+
+test("INDEPENDENCE: presence of undefined optionals does not change the hash", () => {
+ const withUndefined = node("a", { task: "t", when: undefined, join: undefined });
+ const without = node("a", { task: "t" });
+ assert.equal(hashNode(withUndefined), hashNode(without));
+});
+
+test("INDEPENDENCE: equivalent IRs with different node order + key order + condition whitespace hash identically", () => {
+ const ir1 = ir([
+ node("alpha", { task: "first" }),
+ node("beta", { inject: ["alpha"], when: "{steps.alpha.output} == 'ok'", task: "second" }),
+ ]);
+ const ir2 = ir([
+ node("beta", { task: "second", when: "(({steps.alpha.output}=='ok'))", inject: ["alpha"] }),
+ node("alpha", { task: "first" }),
+ ]);
+ assert.equal(hashFlowIR(ir1), hashFlowIR(ir2));
+});
+
+// ---------------------------------------------------------------------------
+// 3. SENSITIVITY
+// ---------------------------------------------------------------------------
+
+test("SENSITIVITY: changing the task string changes the hash", () => {
+ const a = node("x", { task: "do thing a" });
+ const b = node("x", { task: "do thing b" });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing the kind changes the hash", () => {
+ const a = node("x", { kind: "agent" });
+ const b = node("x", { kind: "gate" });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: adding an inject edge changes the hash", () => {
+ const a = node("x", { inject: [] });
+ const b = node("x", { inject: ["upstream"] });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: removing an inject edge changes the hash", () => {
+ const a = node("x", { inject: ["upstream", "other"] });
+ const b = node("x", { inject: ["upstream"] });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing an inject edge target changes the hash", () => {
+ const a = node("x", { inject: ["alpha"] });
+ const b = node("x", { inject: ["beta"] });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: reordering inject edges changes the hash (order is semantic)", () => {
+ const a = node("x", { inject: ["alpha", "beta"] });
+ const b = node("x", { inject: ["beta", "alpha"] });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing emits changes the hash", () => {
+ const a = node("x", { emits: ["x"] });
+ const b = node("x", { emits: ["y"] });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing the node id changes the hash", () => {
+ const a = node("x", { task: "same" });
+ const b = node("y", { task: "same" });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing join changes the hash", () => {
+ const a = node("x", { join: "all" });
+ const b = node("x", { join: "any" });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing timeout changes the hash", () => {
+ const a = node("x", { timeout: 1000 });
+ const b = node("x", { timeout: 2000 });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing the condition's compared value changes the hash", () => {
+ const a = node("g", { kind: "gate", when: "{steps.a.output} == 'ok'" });
+ const b = node("g", { kind: "gate", when: "{steps.a.output} == 'block'" });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing the condition's referenced step changes the hash", () => {
+ const a = node("g", { kind: "gate", when: "{steps.a.output} == 'ok'" });
+ const b = node("g", { kind: "gate", when: "{steps.b.output} == 'ok'" });
+ assert.notEqual(hashNode(a), hashNode(b));
+});
+
+test("SENSITIVITY: changing the IR name changes hashFlowIR", () => {
+ const a = ir([node("a")], { name: "flow-one" });
+ const b = ir([node("a")], { name: "flow-two" });
+ assert.notEqual(hashFlowIR(a), hashFlowIR(b));
+});
+
+test("SENSITIVITY: changing a node's task changes hashFlowIR", () => {
+ const a = ir([node("x", { task: "t1" })]);
+ const b = ir([node("x", { task: "t2" })]);
+ assert.notEqual(hashFlowIR(a), hashFlowIR(b));
+});
+
+test("SENSITIVITY: adding a node changes hashFlowIR", () => {
+ const a = ir([node("x")]);
+ const b = ir([node("x"), node("y")]);
+ assert.notEqual(hashFlowIR(a), hashFlowIR(b));
+});
+
+test("SENSITIVITY: changing budget changes hashFlowIR", () => {
+ const a = ir([node("x")], { budget: { maxUSD: 1 } });
+ const b = ir([node("x")], { budget: { maxUSD: 2 } });
+ assert.notEqual(hashFlowIR(a), hashFlowIR(b));
+});
+
+test("SENSITIVITY: changing concurrency changes hashFlowIR", () => {
+ const a = ir([node("x")], { concurrency: 2 });
+ const b = ir([node("x")], { concurrency: 4 });
+ assert.notEqual(hashFlowIR(a), hashFlowIR(b));
+});
+
+test("SENSITIVITY: changing version changes hashFlowIR", () => {
+ const a = ir([node("x")], { version: 1 });
+ const b = ir([node("x")], { version: 2 });
+ assert.notEqual(hashFlowIR(a), hashFlowIR(b));
+});
+
+test("SENSITIVITY: two distinct IRs do not collide", () => {
+ const a = ir([
+ node("a", { task: "task A" }),
+ node("b", { inject: ["a"], task: "task B" }),
+ ]);
+ const b = ir([
+ node("a", { task: "different task" }),
+ node("b", { inject: ["a"], task: "task B" }),
+ ]);
+ assert.notEqual(hashFlowIR(a), hashFlowIR(b));
+});
From 8774db5e65c9bcd2a1dd8dcef493fb9933bb4d1a Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 11:37:43 +0800
Subject: [PATCH 08/51] feat(core): wire batch-1 barrels for rates, flowir
canonical types, and exec events
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Integration step (serial): wire the five parallel-built modules into the
taskflow-core barrels so the new public surface is reachable via the bare
`taskflow-core` specifier.
Barrel wiring:
- src/index.ts: add `export * from "./rates.ts"` (after usage.ts) and
`export * from "./exec/index.ts"` (after replay.ts), matching the existing
`export *` style.
- src/flowir/index.ts: re-export schema.ts, cond.ts, canonical-hash.ts.
schema.ts's `FlowIR`/`FlowIRNode` (canonical, closed `kind` union) clash
with meta.ts's stub-projection `FlowIR`/`FlowIRNode` (`kind: string`) —
the canonical pair is re-exported under `CanonicalFlowIR`/
`CanonicalFlowIRNode` aliases so the batch stays additive (existing public
surface unchanged) and both contracts are barrel-reachable.
- src/exec/index.ts (new): `export * from "./events.ts"` barrel for the
event-sourced log schema + back-compat reader.
Reconciliation:
- exec/events.ts: fix the one real tsc error — `parsed as Event` failed
(Record doesn't overlap Event); use `as unknown as Event`
for the already-versioned-line fast path (the legacy path uses the typed
`upgradeTraceEvent` shim). No `any`.
- The five new modules are internally consistent (canonical-hash imports
FlowIR/FlowIRNode from schema.ts and normalizeCond from cond.ts; both
match). No edits needed to schema.ts/cond.ts/canonical-hash.ts/rates.ts.
Additive only: runtime.ts, translate.ts, and the existing hash.ts are
untouched (runtime stays on Taskflow execution; the canonical hash is not
yet wired into cache — that lands in batch 2).
Includes the rates.ts and exec/events.ts source + test files (their builders
left them uncommitted); schema.ts/cond.ts/canonical-hash.ts were already
committed by their builders.
---
packages/taskflow-core/src/exec/events.ts | 191 ++++++++++
packages/taskflow-core/src/exec/index.ts | 14 +
packages/taskflow-core/src/flowir/index.ts | 49 +++
packages/taskflow-core/src/index.ts | 2 +
packages/taskflow-core/src/rates.ts | 208 +++++++++++
.../taskflow-core/test/exec-events.test.ts | 326 ++++++++++++++++++
packages/taskflow-core/test/rates.test.ts | 213 ++++++++++++
7 files changed, 1003 insertions(+)
create mode 100644 packages/taskflow-core/src/exec/events.ts
create mode 100644 packages/taskflow-core/src/exec/index.ts
create mode 100644 packages/taskflow-core/src/rates.ts
create mode 100644 packages/taskflow-core/test/exec-events.test.ts
create mode 100644 packages/taskflow-core/test/rates.test.ts
diff --git a/packages/taskflow-core/src/exec/events.ts b/packages/taskflow-core/src/exec/events.ts
new file mode 100644
index 0000000..f3e309b
--- /dev/null
+++ b/packages/taskflow-core/src/exec/events.ts
@@ -0,0 +1,191 @@
+/**
+ * Event Log Schema — the future event-sourced kernel.
+ *
+ * This module defines the **append-only event log** schema that the batch-2
+ * event-sourced driver (Q8) will write to. It is a superset of the current
+ * `TraceEvent` shape (`../trace.ts`) plus a schema-version field.
+ *
+ * **Why this exists**: The batch-2 event-sourced kernel needs an explicit,
+ * versioned event schema that (a) can subsume trace.ts, (b) carries a `v`
+ * field so future schema migrations are unambiguous on disk, and (c) provides
+ * a back-compat reader that upgrades legacy trace.jsonl lines on the fly.
+ *
+ * **F3 dissolution**: the 5 currently-unemitted trace decisions
+ * (gate-score, budget-hit, cache-hit, when-guard, unreplayable) dissolve here:
+ * the event-sourced driver (batch 2) will emit *every* decision by
+ * construction — this module is the log schema + version + back-compat.
+ * Once batch 2 lands, this module subsumes trace.ts entirely (see §0.2.0 Roadmap).
+ *
+ * This is a **pure types+parser module** — zero runtime deps besides what
+ * TypeScript/stdlib provides. No IO, no randomness, no Date.
+ */
+
+import type { UsageStats } from "../usage.ts";
+import type { ScorerResult } from "../scorers.ts";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Schema version
+// ─────────────────────────────────────────────────────────────────────────────
+
+/** Current event log schema version. Incremented on breaking changes. */
+export const EVENT_SCHEMA_VERSION = 1;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Event shape (superset of TraceEvent)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Discriminated event kind — identical to TraceEvent's `kind` union.
+ * A versioned Event carries a `v` field so a consumer can detect schema
+ * drift without inspecting fields.
+ */
+export type EventKind = "phase-start" | "phase-end" | "subagent-call" | "decision";
+
+/** Discriminated record of a runtime decision. Structurally duplicates
+ * TraceDecision so that this module has zero imports from trace.ts.
+ * The two converge in batch 2 when trace.ts is subsumed. */
+export type EventDecision =
+ | { type: "gate-verdict"; value: "pass" | "block"; reason?: string }
+ | {
+ type: "gate-score";
+ target: string;
+ results: ScorerResult[];
+ combined: number;
+ threshold?: number;
+ verdict: "pass" | "block";
+ evalPassed?: boolean;
+ judgeOutput?: string;
+ }
+ | { type: "tournament-winner"; value: number; reason?: string }
+ | { type: "budget-hit"; value: string; reason?: string }
+ | { type: "cache-hit"; scope: "cross-run" | "run-only"; reason?: string }
+ | { type: "when-guard"; expression: string; result: boolean }
+ | {
+ type: "unreplayable";
+ reason: "context-sharing" | "inner-flow" | "context-files" | "unobservable-deps";
+ };
+
+/**
+ * A single event in the append-only event log. Structurally identical to
+ * TraceEvent but with an explicit `v` field for schema versioning.
+ *
+ * The batch-2 event-sourced driver (Q8) will emit these directly; legacy
+ * trace.jsonl lines are upgraded via {@link upgradeTraceEvent}.
+ */
+export interface Event {
+ /** Schema version (see {@link EVENT_SCHEMA_VERSION}). */
+ v: number;
+
+ /** Timestamp (Date.now()) at emit. */
+ ts: number;
+ /** Run identifier. */
+ runId: string;
+ /** Phase identifier. */
+ phaseId: string;
+ /** Discriminated kind. */
+ kind: EventKind;
+
+ // — subagent-call (the load-bearing record a replay consumes) —
+ input?: {
+ agent: string;
+ model?: string;
+ /** Resolved (interpolated) task text. */
+ task: string;
+ /** Resolved `context:` file content prepended to the task. */
+ preRead?: string;
+ /** Stable node path, e.g. "review", "review#item-3", "gate#judge". */
+ nodePath: string;
+ /** 0-based within runOne's retry loop. */
+ attempt?: number;
+ /** Index in the items array for `map` phases. */
+ mapIndex?: number;
+ /** 1-based variant number for `tournament` phases. */
+ variantIndex?: number;
+ };
+
+ output?: {
+ text: string;
+ model?: string;
+ usage?: UsageStats;
+ stopReason?: string;
+ };
+
+ /** The runtime's own decision. */
+ decision?: EventDecision;
+
+ // — phase-end —
+ status?: "done" | "failed" | "blocked" | "skipped" | "timedOut" | "pending" | "running";
+ error?: string;
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Back-compat: upgrading legacy TraceEvent → Event
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Accept any object shape that overlaps with the legacy TraceEvent fields
+ * and stamp `v=` {@link EVENT_SCHEMA_VERSION} onto it. This is deliberately
+ * lenient — a legacy trace.jsonl line may have extra / missing fields.
+ *
+ * **Design note (§8 back-compat)**: old trace.jsonl files have no `v` field.
+ * This shim stamps the current schema version onto any missing event so that
+ * the batch-2 event-sourced consumer can read both old and new records
+ * through the same `Event` interface. When the schema version bumps, this
+ * function is where the migration logic lives.
+ */
+export function upgradeTraceEvent(old: Record): Event {
+ return {
+ v: EVENT_SCHEMA_VERSION,
+ ts: typeof old.ts === "number" ? old.ts : Date.now(),
+ runId: typeof old.runId === "string" ? old.runId : "",
+ phaseId: typeof old.phaseId === "string" ? old.phaseId : "",
+ kind: (["phase-start", "phase-end", "subagent-call", "decision"] as const).includes(
+ old.kind as EventKind,
+ )
+ ? (old.kind as EventKind)
+ : "phase-start",
+ input: old.input as Event["input"],
+ output: old.output as Event["output"],
+ decision: old.decision as EventDecision | undefined,
+ status: old.status as Event["status"],
+ error: typeof old.error === "string" ? old.error : undefined,
+ };
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Reader (partial-line tolerant, upgrades versionless lines)
+// ─────────────────────────────────────────────────────────────────────────────
+
+/**
+ * Parse an event log JSONL text into an array of {@link Event}s.
+ *
+ * **Partial-line tolerance**: any line that fails JSON.parse is silently
+ * skipped (fail-open), matching trace.ts's `readTrace` behaviour. This
+ * handles crash-truncated final records gracefully.
+ *
+ * **Versionless line upgrade**: any line whose parsed object lacks a `v`
+ * field is routed through {@link upgradeTraceEvent} to stamp the current
+ * schema version — so old trace.jsonl data is readable without migration.
+ */
+export function readEvents(text: string): Event[] {
+ const out: Event[] = [];
+ const lines = text.split("\n");
+ for (const line of lines) {
+ const trimmed = line.trim();
+ if (!trimmed) continue;
+ try {
+ const parsed = JSON.parse(trimmed) as Record;
+ if (typeof parsed.v === "number") {
+ // Already versioned — trust schema (double-cast via `unknown` since a
+ // JSON-parsed record can't be proven to overlap with `Event`).
+ out.push(parsed as unknown as Event);
+ } else {
+ // Legacy line — upgrade via back-compat shim.
+ out.push(upgradeTraceEvent(parsed));
+ }
+ } catch {
+ // Partial / corrupt line — skip (fail-open).
+ }
+ }
+ return out;
+}
diff --git a/packages/taskflow-core/src/exec/index.ts b/packages/taskflow-core/src/exec/index.ts
new file mode 100644
index 0000000..e7c0903
--- /dev/null
+++ b/packages/taskflow-core/src/exec/index.ts
@@ -0,0 +1,14 @@
+/**
+ * Event-sourced execution log — barrel.
+ *
+ * Re-exports the event log schema + back-compat reader from `./events.ts`.
+ * This is the schema the batch-2 event-sourced kernel (Q8) will write to; it
+ * is a versioned superset of the current `TraceEvent` shape (see `../trace.ts`)
+ * with a `v` field for forward-compatible migrations.
+ *
+ * Pure types + parser module — zero runtime deps beyond stdlib. Surfaced
+ * through the main `taskflow-core` barrel so host adapters can read/upgrade
+ * event logs without a deep import.
+ */
+
+export * from "./events.ts";
diff --git a/packages/taskflow-core/src/flowir/index.ts b/packages/taskflow-core/src/flowir/index.ts
index e061559..bf6d075 100644
--- a/packages/taskflow-core/src/flowir/index.ts
+++ b/packages/taskflow-core/src/flowir/index.ts
@@ -73,3 +73,52 @@ export type {
} from "./meta.ts";
export { phaseFingerprint } from "./phasefp.ts";
+
+// ---------------------------------------------------------------------------
+// Canonical FlowIR type contract + content-addressed hash (batch-1 additions)
+// ---------------------------------------------------------------------------
+//
+// `./meta.ts` exposes the *stub* 1:1 projection (`FlowIR`/`FlowIRNode` with
+// `kind: string`) that `translateTaskflow` emits today. `./schema.ts` defines
+// the *canonical* contract — a strict superset (extra optional fields) with a
+// *closed* `kind` union (`FlowIRNodeKind`). Both are surfaced here:
+//
+// - `FlowIR` / `FlowIRNode` → the stub projection (meta.ts), used by
+// `compileTaskflowToIR` / `TaskflowIR`.
+// - `CanonicalFlowIR` / `CanonicalFlowIRNode` → the canonical contract
+// (schema.ts); the shape the batch-2
+// compiler emits and `hashFlowIR`
+// content-addresses.
+//
+// The canonical names are re-exported under `Canonical*` aliases to avoid a
+// duplicate-export clash with meta.ts's stub `FlowIR`/`FlowIRNode`; this keeps
+// the batch purely additive (the existing public surface is unchanged) while
+// making the canonical contract reachable through the main `taskflow-core`
+// barrel. `hashFlowIR`/`hashNode` take the canonical types, so a consumer
+// hashing a stub-projection IR must widen via the canonical type (the genuine
+// compiler in batch 2 will emit canonical IR directly).
+
+// `./schema.ts` — canonical FlowIR type contract + structural guards.
+export { FlowIRNodeKind } from "./schema.ts"; // value (TypeBox schema) + type (literal union)
+export type {
+ FlowIREdge,
+ FlowIRBudget,
+ FlowIRMeta,
+ FlowIR as CanonicalFlowIR,
+ FlowIRNode as CanonicalFlowIRNode,
+} from "./schema.ts";
+export {
+ FlowIRNodeSchema,
+ FlowIREdgeSchema,
+ FlowIRSchema,
+ isFlowIRNode,
+ assertFlowIR,
+} from "./schema.ts";
+
+// `./cond.ts` — condition-IR normalization (canonical `when` form for hashing + replay refs).
+export type { NormalizedCond } from "./cond.ts";
+export { normalizeCond } from "./cond.ts";
+
+// `./canonical-hash.ts` — genuine content-addressed hash over canonical FlowIR
+// (sync `node:crypto` SHA-256; distinct from the vendored async `flowDefHash`).
+export { canonicalizeFlowIR, hashFlowIR, hashNode } from "./canonical-hash.ts";
diff --git a/packages/taskflow-core/src/index.ts b/packages/taskflow-core/src/index.ts
index 92d95ae..6259bdc 100644
--- a/packages/taskflow-core/src/index.ts
+++ b/packages/taskflow-core/src/index.ts
@@ -21,6 +21,7 @@ export * from "./cache.ts";
export * from "./interpolate.ts";
export * from "./verify.ts";
export * from "./usage.ts";
+export * from "./rates.ts";
export * from "./stale.ts";
export * from "./workspace.ts";
export * from "./context-store.ts";
@@ -40,3 +41,4 @@ export * from "./flowir/index.ts";
export * from "./trace.ts";
export * from "./deterministic.ts";
export * from "./replay.ts";
+export * from "./exec/index.ts";
diff --git a/packages/taskflow-core/src/rates.ts b/packages/taskflow-core/src/rates.ts
new file mode 100644
index 0000000..38b55dc
--- /dev/null
+++ b/packages/taskflow-core/src/rates.ts
@@ -0,0 +1,208 @@
+/**
+ * Model rate registry — pure token-to-USD cost estimation.
+ *
+ * ## CODEX COST GAP
+ *
+ * The `codex-runner` (packages/codex-taskflow) emits NO cost field in its usage
+ * response, so `usage.cost` stays 0 on Codex even when tokens are accurately
+ * reported. This makes `budget.maxUSD` silently non-functional for Codex-hosted
+ * flows. This module fills the gap: batch 2 of the runner layer (or a post-hoc
+ * cost fill) can call `estimateCost()` to compute USD from the known model +
+ * token counts, making Codex cost-aware without changing the runner protocol.
+ *
+ * ## Link to verify.ts
+ *
+ * `verify.ts` defines `ESTIMATED_COST_PER_PHASE = 0.001` as a crude lower-bound
+ * budget guard. `FLAT_FALLBACK_PER_TOKEN` serves the same role for unknown models
+ * — a rough-order-of-magnitude per-token default (~$1/1M tokens, consistent with
+ * $0.001/1000-tokens at verify's granularity).
+ *
+ * @module
+ */
+
+import type { UsageStats } from "./usage.ts";
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+/**
+ * Per-model pricing in USD per 1,000,000 tokens.
+ *
+ * Rates mirror the provider's published API pricing (input, output, cached
+ * input read, cached input write). Missing fields default to zero.
+ */
+export interface ModelRate {
+ /** USD per 1,000,000 input (prompt) tokens. */
+ inputPer1M: number;
+ /** USD per 1,000,000 output (completion) tokens. */
+ outputPer1M: number;
+ /** USD per 1,000,000 cached input tokens read (if applicable). */
+ cacheReadPer1M?: number;
+ /** USD per 1,000,000 cached input tokens written (if applicable). */
+ cacheWritePer1M?: number;
+}
+
+// ---------------------------------------------------------------------------
+// Default rate table
+// ---------------------------------------------------------------------------
+
+/**
+ * Built-in rate table keyed by **model-family prefix** (case-sensitive lookup
+ * key — matching in `resolveRate` is case-insensitive).
+ *
+ * This is a small, illustrative, SPARSE table. Hosts can inject a richer table
+ * at runtime via the optional `table` parameter to `resolveRate`/`estimateCost`.
+ *
+ * Prices are approximate and may drift from provider publishing. For production
+ * billing, always use the provider's official cost API.
+ *
+ * ```ts
+ * // Example override from a host adapter:
+ * const myRates = { ...DEFAULT_RATES, "my-model": { inputPer1M: 1, outputPer1M: 4 } };
+ * const cost = estimateCost(usage, "my-model", myRates);
+ * ```
+ */
+export const DEFAULT_RATES: Record = {
+ // ── OpenAI ──────────────────────────────────────────────────────────
+ /** gpt-4o family (including gpt-4o-*, gpt-4o-mini) */
+ "gpt-4o": { inputPer1M: 2.50, outputPer1M: 10.00, cacheReadPer1M: 1.25 },
+ /** gpt-4o-mini (cheap, fast) */
+ "gpt-4o-mini": { inputPer1M: 0.15, outputPer1M: 0.60, cacheReadPer1M: 0.075 },
+ /** o3-mini reasoning model */
+ "o3-mini": { inputPer1M: 1.10, outputPer1M: 4.40, cacheReadPer1M: 0.55 },
+ /** o1 reasoning model */
+ o1: { inputPer1M: 15.00, outputPer1M: 60.00, cacheReadPer1M: 7.50 },
+
+ // ── Anthropic ───────────────────────────────────────────────────────
+ /** claude-sonnet-4 (and sonnet-4-*) */
+ "claude-sonnet-4": { inputPer1M: 3.00, outputPer1M: 15.00, cacheReadPer1M: 0.30, cacheWritePer1M: 3.75 },
+ /** claude-haiku-3.5 (and haiku-3.5-*) */
+ "claude-haiku-3.5": { inputPer1M: 0.80, outputPer1M: 4.00, cacheReadPer1M: 0.08, cacheWritePer1M: 1.00 },
+ /** claude-opus-4 (and opus-4-*) */
+ "claude-opus-4": { inputPer1M: 15.00, outputPer1M: 75.00, cacheReadPer1M: 1.50, cacheWritePer1M: 18.75 },
+
+ // ── DeepSeek ────────────────────────────────────────────────────────
+ /** deepseek-chat (V3) */
+ "deepseek-chat": { inputPer1M: 0.27, outputPer1M: 1.10, cacheReadPer1M: 0.07 },
+ /** deepseek-reasoner (R1) */
+ "deepseek-reasoner": { inputPer1M: 0.55, outputPer1M: 2.19, cacheReadPer1M: 0.14 },
+
+ // ── Google Gemini ───────────────────────────────────────────────────
+ /** gemini-2.5-flash-* */
+ "gemini-2.5-flash": { inputPer1M: 0.15, outputPer1M: 0.60, cacheReadPer1M: 0.075 },
+ /** gemini-2.5-pro-* */
+ "gemini-2.5-pro": { inputPer1M: 1.25, outputPer1M: 10.00, cacheReadPer1M: 0.3125 },
+
+ // ── Meta ────────────────────────────────────────────────────────────
+ /** llama-3.x (generic — actual pricing depends on host) */
+ "llama-3": { inputPer1M: 0.25, outputPer1M: 0.25 },
+} as const;
+
+// ---------------------------------------------------------------------------
+// Fallback
+// ---------------------------------------------------------------------------
+
+/**
+ * Flat per-token USD fallback used when no model rate can be resolved.
+ *
+ * Currently set to $1 per 1,000,000 tokens (~$0.001 per 1K tokens), consistent
+ * with `verify.ts`'s `ESTIMATED_COST_PER_PHASE = 0.001` rough-order-of-magnitude
+ * estimate. This avoids silent zero-cost for unknown models while staying in the
+ * right ballpark for most hosted LLM APIs.
+ */
+export const FLAT_FALLBACK_PER_TOKEN = 0.000001; // $1/1M tokens
+
+// ---------------------------------------------------------------------------
+// Resolution
+// ---------------------------------------------------------------------------
+
+/**
+ * Resolve a model identifier to a `ModelRate` via case-insensitive prefix match
+ * against the provided table (defaults to `DEFAULT_RATES`).
+ *
+ * Matching strategy:
+ * 1. Exact case-insensitive key match.
+ * 2. Longest prefix match (case-insensitive), e.g. "gpt-4o-2024-08-06" matches
+ * the "gpt-4o" key.
+ * 3. Falls back to single-word prefix: "claude-sonnet-4-20241022" matches
+ * "claude-sonnet-4" (best prefix).
+ *
+ * Returns `undefined` if no match is found. Never throws.
+ *
+ * @param model - The model identifier to look up (e.g. "gpt-4o-2024-08-06")
+ * @param table - Optional rate table (defaults to DEFAULT_RATES)
+ * @returns The matching ModelRate, or undefined on miss
+ */
+export function resolveRate(
+ model: string,
+ table?: Record,
+): ModelRate | undefined {
+ if (!model) return undefined;
+
+ const tbl = table ?? DEFAULT_RATES;
+ const lowerModel = model.toLowerCase();
+
+ // 1. Exact case-insensitive match
+ const exact = Object.keys(tbl).find((k) => k.toLowerCase() === lowerModel);
+ if (exact) return tbl[exact];
+
+ // 2. Longest prefix match — iterate to find the key that is a prefix of the
+ // model (case-insensitive) with the greatest length.
+ let best: { key: string; length: number } | undefined;
+ for (const key of Object.keys(tbl)) {
+ const lowerKey = key.toLowerCase();
+ if (lowerModel.startsWith(lowerKey) && lowerKey.length > (best?.length ?? 0)) {
+ best = { key, length: lowerKey.length };
+ }
+ }
+ if (best) return tbl[best.key];
+
+ return undefined;
+}
+
+// ---------------------------------------------------------------------------
+// Cost estimation
+// ---------------------------------------------------------------------------
+
+/**
+ * Compute estimated USD cost from token usage.
+ *
+ * Uses the resolved model rate (via `resolveRate`) and applies per-token pricing:
+ * cost = (input * inputRate + output * outputRate +
+ * cacheRead * cacheReadRate + cacheWrite * cacheWriteRate) / 1_000_000
+ *
+ * If the model cannot be resolved, falls back to
+ * `FLAT_FALLBACK_PER_TOKEN * (input + output)`. Cache tokens are *not* counted
+ * in the fallback (they are part of the input budget, and double-counting with
+ * the flat fallback would over-estimate).
+ *
+ * Pure function — no I/O, no side effects. Never throws.
+ *
+ * @param usage - Token counts (input, output, cacheRead, cacheWrite)
+ * @param model - Model identifier used to resolve the rate
+ * @param table - Optional rate table override (passed through to resolveRate)
+ * @returns Estimated cost in USD
+ */
+export function estimateCost(
+ usage: UsageStats,
+ model: string,
+ table?: Record,
+): number {
+ const rate = resolveRate(model, table);
+ if (!rate) {
+ return FLAT_FALLBACK_PER_TOKEN * (usage.input + usage.output);
+ }
+
+ const inputCost = (usage.input / 1_000_000) * rate.inputPer1M;
+ const outputCost = (usage.output / 1_000_000) * rate.outputPer1M;
+ let cacheCost = 0;
+ if (rate.cacheReadPer1M !== undefined && usage.cacheRead > 0) {
+ cacheCost += (usage.cacheRead / 1_000_000) * rate.cacheReadPer1M;
+ }
+ if (rate.cacheWritePer1M !== undefined && usage.cacheWrite > 0) {
+ cacheCost += (usage.cacheWrite / 1_000_000) * rate.cacheWritePer1M;
+ }
+
+ return inputCost + outputCost + cacheCost;
+}
diff --git a/packages/taskflow-core/test/exec-events.test.ts b/packages/taskflow-core/test/exec-events.test.ts
new file mode 100644
index 0000000..147e0d6
--- /dev/null
+++ b/packages/taskflow-core/test/exec-events.test.ts
@@ -0,0 +1,326 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ EVENT_SCHEMA_VERSION,
+ type Event,
+ type EventDecision,
+ type EventKind,
+ upgradeTraceEvent,
+ readEvents,
+} from "../src/exec/events.ts";
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Constants
+// ─────────────────────────────────────────────────────────────────────────────
+
+test("EVENT_SCHEMA_VERSION is 1", () => {
+ assert.equal(EVENT_SCHEMA_VERSION, 1);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// upgradeTraceEvent
+// ─────────────────────────────────────────────────────────────────────────────
+
+test("upgradeTraceEvent: stamps v on a legacy event", () => {
+ const legacy = {
+ ts: 1_700_000_000_000,
+ runId: "run-abc",
+ phaseId: "phase-1",
+ kind: "phase-start",
+ };
+ const upgraded = upgradeTraceEvent(legacy);
+ assert.equal(upgraded.v, EVENT_SCHEMA_VERSION);
+ assert.equal(upgraded.ts, 1_700_000_000_000);
+ assert.equal(upgraded.runId, "run-abc");
+ assert.equal(upgraded.phaseId, "phase-1");
+ assert.equal(upgraded.kind, "phase-start");
+});
+
+test("upgradeTraceEvent: preserves input/output/decision fields", () => {
+ const legacy = {
+ ts: 1_700_000_000_001,
+ runId: "run-def",
+ phaseId: "phase-2",
+ kind: "subagent-call" as const,
+ input: {
+ agent: "test-agent",
+ task: "do something",
+ nodePath: "phase-2",
+ },
+ output: {
+ text: "done",
+ model: "gpt-4",
+ },
+ decision: {
+ type: "cache-hit" as const,
+ scope: "run-only" as const,
+ },
+ status: "done" as const,
+ };
+ const upgraded = upgradeTraceEvent(legacy);
+ assert.equal(upgraded.v, EVENT_SCHEMA_VERSION);
+ assert.deepEqual(upgraded.input, legacy.input);
+ assert.deepEqual(upgraded.output, legacy.output);
+ assert.deepEqual(upgraded.decision, legacy.decision);
+ assert.equal(upgraded.status, "done");
+});
+
+test("upgradeTraceEvent: fills defaults for missing fields", () => {
+ const upgraded = upgradeTraceEvent({});
+ // v is always stamped
+ assert.equal(upgraded.v, EVENT_SCHEMA_VERSION);
+ // ts falls back to a number (Date.now() approximate)
+ assert.equal(typeof upgraded.ts, "number");
+ // runId/phaseId fall back to ""
+ assert.equal(upgraded.runId, "");
+ assert.equal(upgraded.phaseId, "");
+ // unknown kind falls back to "phase-start"
+ assert.equal(upgraded.kind, "phase-start");
+ // optional fields remain undefined
+ assert.equal(upgraded.input, undefined);
+ assert.equal(upgraded.output, undefined);
+ assert.equal(upgraded.decision, undefined);
+ assert.equal(upgraded.status, undefined);
+ assert.equal(upgraded.error, undefined);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// readEvents — round-trip
+// ─────────────────────────────────────────────────────────────────────────────
+
+test("readEvents: round-trip a versioned event", () => {
+ const event: Event = {
+ v: 1,
+ ts: 1_700_000_000_002,
+ runId: "run-ghi",
+ phaseId: "phase-3",
+ kind: "phase-end",
+ input: {
+ agent: "a",
+ task: "t",
+ nodePath: "phase-3",
+ },
+ output: {
+ text: "result",
+ },
+ status: "done",
+ };
+
+ const text = JSON.stringify(event) + "\n";
+ const events = readEvents(text);
+ assert.equal(events.length, 1);
+
+ const e = events[0];
+ assert.equal(e.v, 1);
+ assert.equal(e.ts, 1_700_000_000_002);
+ assert.equal(e.runId, "run-ghi");
+ assert.equal(e.phaseId, "phase-3");
+ assert.equal(e.kind, "phase-end");
+ assert.deepEqual(e.input, event.input);
+ assert.deepEqual(e.output, event.output);
+ assert.equal(e.status, "done");
+});
+
+test("readEvents: round-trip multiple versioned events", () => {
+ const events: Event[] = [
+ {
+ v: 1,
+ ts: 1_000,
+ runId: "r1",
+ phaseId: "p1",
+ kind: "phase-start",
+ },
+ {
+ v: 1,
+ ts: 1_001,
+ runId: "r1",
+ phaseId: "p1",
+ kind: "phase-end",
+ status: "done",
+ },
+ {
+ v: 1,
+ ts: 1_002,
+ runId: "r1",
+ phaseId: "p2",
+ kind: "subagent-call",
+ input: { agent: "a", task: "t", nodePath: "p2" },
+ output: { text: "o" },
+ },
+ ];
+
+ const text = events.map((e) => JSON.stringify(e)).join("\n") + "\n";
+ const parsed = readEvents(text);
+ assert.equal(parsed.length, 3);
+ assert.equal(parsed[0].phaseId, "p1");
+ assert.equal(parsed[1].kind, "phase-end");
+ assert.equal(parsed[2].kind, "subagent-call");
+});
+
+test("readEvents: round-trip an event with a decision", () => {
+ const decision: EventDecision = {
+ type: "gate-score",
+ target: "score the output for quality",
+ results: [{ name: "coherence", score: 0.9, weight: 1 }],
+ combined: 0.9,
+ threshold: 0.7,
+ verdict: "pass",
+ judgeOutput: "Looks good",
+ };
+
+ const event: Event = {
+ v: 1,
+ ts: 1_700_000_000_003,
+ runId: "run-jkl",
+ phaseId: "phase-4",
+ kind: "decision",
+ decision,
+ };
+
+ const text = JSON.stringify(event) + "\n";
+ const parsed = readEvents(text);
+ assert.equal(parsed.length, 1);
+ assert.deepEqual(parsed[0].decision, decision);
+ assert.equal(parsed[0].decision!.type, "gate-score");
+ if (parsed[0].decision!.type === "gate-score") {
+ assert.equal(parsed[0].decision!.combined, 0.9);
+ assert.equal(parsed[0].decision!.verdict, "pass");
+ }
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// readEvents — partial-line tolerance
+// ─────────────────────────────────────────────────────────────────────────────
+
+test("readEvents: tolerates a truncated final line", () => {
+ const text = '{"v":1,"ts":1,"runId":"r","phaseId":"p","kind":"phase-start"}\n' +
+ '{"v":1,"ts":2,"runId":"r","phaseId":"p","kind":"phase-end"}\n' +
+ '{"v":1,"ts":3,"runId":"r","phaseId":"p","kind":"decision","truncated';
+
+ const events = readEvents(text);
+ assert.equal(events.length, 2);
+ assert.equal(events[0].ts, 1);
+ assert.equal(events[1].ts, 2);
+});
+
+test("readEvents: tolerates blank lines and empty input", () => {
+ assert.equal(readEvents("").length, 0);
+ assert.equal(readEvents("\n\n").length, 0);
+ assert.equal(readEvents("\n \n\t\n").length, 0);
+});
+
+test("readEvents: tolerates complete garbage lines mixed with valid ones", () => {
+ const text = '{"v":1,"ts":1,"runId":"r","phaseId":"p","kind":"phase-start"}\n' +
+ "this is not json\n" +
+ '{"v":1,"ts":2,"runId":"r","phaseId":"p","kind":"phase-end"}\n';
+
+ const events = readEvents(text);
+ assert.equal(events.length, 2);
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// readEvents — versionless line upgrade
+// ─────────────────────────────────────────────────────────────────────────────
+
+test("readEvents: upgrades versionless lines via upgradeTraceEvent", () => {
+ // A legacy trace event (no v field)
+ const legacy = {
+ ts: 1_700_000_000_010,
+ runId: "legacy-run",
+ phaseId: "legacy-phase",
+ kind: "phase-start",
+ };
+
+ const text = JSON.stringify(legacy) + "\n";
+ const events = readEvents(text);
+ assert.equal(events.length, 1);
+
+ const e = events[0];
+ // Schema version stamped
+ assert.equal(e.v, EVENT_SCHEMA_VERSION);
+ // Original fields preserved
+ assert.equal(e.ts, 1_700_000_000_010);
+ assert.equal(e.runId, "legacy-run");
+ assert.equal(e.phaseId, "legacy-phase");
+ assert.equal(e.kind, "phase-start");
+});
+
+test("readEvents: mixed versioned and versionless lines", () => {
+ const versioned: Event = {
+ v: 1,
+ ts: 100,
+ runId: "r1",
+ phaseId: "p1",
+ kind: "phase-start",
+ };
+ const legacy = {
+ ts: 200,
+ runId: "r1",
+ phaseId: "p1",
+ kind: "phase-end",
+ status: "done",
+ };
+
+ const text = JSON.stringify(versioned) + "\n" + JSON.stringify(legacy) + "\n";
+ const events = readEvents(text);
+ assert.equal(events.length, 2);
+
+ // First event: versioned, v preserved
+ assert.equal(events[0].v, 1);
+ assert.equal(events[0].ts, 100);
+
+ // Second event: upgraded, v stamped
+ assert.equal(events[1].v, EVENT_SCHEMA_VERSION);
+ assert.equal(events[1].ts, 200);
+ assert.equal(events[1].status, "done");
+});
+
+test("readEvents: upgrades legacy event with decision", () => {
+ const legacy = {
+ ts: 300,
+ runId: "r2",
+ phaseId: "gate-phase",
+ kind: "decision",
+ decision: {
+ type: "when-guard",
+ expression: "steps.a.output != null",
+ result: true,
+ },
+ };
+
+ const text = JSON.stringify(legacy) + "\n";
+ const events = readEvents(text);
+ assert.equal(events.length, 1);
+ assert.equal(events[0].v, EVENT_SCHEMA_VERSION);
+ assert.deepEqual(events[0].decision, {
+ type: "when-guard",
+ expression: "steps.a.output != null",
+ result: true,
+ });
+});
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Type-level assertions (compile-time checks)
+// ─────────────────────────────────────────────────────────────────────────────
+
+test("EventKind type: covers all 4 variants (compile-time check)", () => {
+ // This is a runtime sanity check that the literal union is correct
+ const kinds: EventKind[] = ["phase-start", "phase-end", "subagent-call", "decision"];
+ assert.equal(kinds.length, 4);
+});
+
+test("EventDecision type: covers all 7 variants (compile-time check)", () => {
+ // Verify each variant can be constructed and matches the shape
+ const decisions: EventDecision[] = [
+ { type: "gate-verdict", value: "pass" },
+ { type: "gate-score", target: "t", results: [], combined: 0.5, verdict: "pass" },
+ { type: "tournament-winner", value: 1 },
+ { type: "budget-hit", value: "budget" },
+ { type: "cache-hit", scope: "cross-run" },
+ { type: "when-guard", expression: "true", result: true },
+ { type: "unreplayable", reason: "inner-flow" },
+ ];
+ assert.equal(decisions.length, 7);
+ assert.equal(decisions[0].type, "gate-verdict");
+ assert.equal(decisions[6].type, "unreplayable");
+});
diff --git a/packages/taskflow-core/test/rates.test.ts b/packages/taskflow-core/test/rates.test.ts
new file mode 100644
index 0000000..0511298
--- /dev/null
+++ b/packages/taskflow-core/test/rates.test.ts
@@ -0,0 +1,213 @@
+/**
+ * Tests for the model rate registry (rates.ts).
+ *
+ * Covers: resolveRate hit/miss/prefix/case-insensitive, estimateCost with a
+ * known rate, estimateCost fallback when model unknown, cache-token contribution,
+ * pluggable table override.
+ */
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import {
+ DEFAULT_RATES,
+ FLAT_FALLBACK_PER_TOKEN,
+ type ModelRate,
+ resolveRate,
+ estimateCost,
+} from "../src/rates.ts";
+import type { UsageStats } from "../src/usage.ts";
+
+// ---------------------------------------------------------------------------
+// resolveRate
+// ---------------------------------------------------------------------------
+
+test("resolveRate: exact match returns the correct rate", () => {
+ const rate = resolveRate("gpt-4o");
+ assert.ok(rate);
+ assert.equal(rate.inputPer1M, 2.50);
+ assert.equal(rate.outputPer1M, 10.00);
+ assert.equal(rate.cacheReadPer1M, 1.25);
+});
+
+test("resolveRate: prefix match works (model version suffix)", () => {
+ // "gpt-4o-2024-08-06" should match the "gpt-4o" prefix
+ const rate = resolveRate("gpt-4o-2024-08-06");
+ assert.ok(rate);
+ assert.equal(rate.inputPer1M, 2.50);
+});
+
+test("resolveRate: case-insensitive match (exact)", () => {
+ const rate = resolveRate("GPT-4O");
+ assert.ok(rate);
+ assert.equal(rate.inputPer1M, 2.50);
+});
+
+test("resolveRate: case-insensitive prefix match", () => {
+ const rate = resolveRate("CLAUDE-SONNET-4-20241022");
+ assert.ok(rate);
+ assert.equal(rate.inputPer1M, 3.00);
+});
+
+test("resolveRate: miss returns undefined", () => {
+ const rate = resolveRate("nonexistent-model-v99");
+ assert.equal(rate, undefined);
+});
+
+test("resolveRate: empty string returns undefined", () => {
+ const rate = resolveRate("");
+ assert.equal(rate, undefined);
+});
+
+test("resolveRate: longest prefix wins", () => {
+ // "gpt-4o-mini" is a longer prefix than "gpt-4o" — should get the mini rates
+ const rate = resolveRate("gpt-4o-mini-2024-07-18");
+ assert.ok(rate);
+ assert.equal(rate.inputPer1M, 0.15); // mini rate, not full gpt-4o
+});
+
+// ---------------------------------------------------------------------------
+// estimateCost — known rate
+// ---------------------------------------------------------------------------
+
+test("estimateCost: computes cost from known rate", () => {
+ const usage: UsageStats = { input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ // gpt-4o: $2.50/1M input, $10/1M output
+ // input cost = 1000/1e6 * 2.50 = 0.0025
+ // output cost = 500/1e6 * 10.00 = 0.005
+ // total = 0.0075
+ const cost = estimateCost(usage, "gpt-4o");
+ assert.equal(cost, 0.0075);
+});
+
+test("estimateCost: zero tokens yields zero cost", () => {
+ const usage: UsageStats = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
+ const cost = estimateCost(usage, "gpt-4o");
+ assert.equal(cost, 0);
+});
+
+// ---------------------------------------------------------------------------
+// estimateCost — fallback
+// ---------------------------------------------------------------------------
+
+test("estimateCost: unknown model falls back to FLAT_FALLBACK_PER_TOKEN", () => {
+ const usage: UsageStats = { input: 2000, output: 8000, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ const cost = estimateCost(usage, "ultra-unknown-314");
+ // Fallback: FLAT_FALLBACK_PER_TOKEN * (2000 + 8000) = 0.000001 * 10000 = 0.01
+ assert.equal(cost, FLAT_FALLBACK_PER_TOKEN * 10000);
+});
+
+test("estimateCost: fallback uses only input+output (no cache double-count)", () => {
+ const usage: UsageStats = { input: 1000, output: 1000, cacheRead: 9000, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ const cost = estimateCost(usage, "unknown-model");
+ // Fallback: FLAT_FALLBACK_PER_TOKEN * (1000 + 1000) — cacheRead is excluded
+ assert.equal(cost, FLAT_FALLBACK_PER_TOKEN * 2000);
+});
+
+// ---------------------------------------------------------------------------
+// estimateCost — cache contribution
+// ---------------------------------------------------------------------------
+
+test("estimateCost: cacheRead tokens reduce cost (cached input cheaper)", () => {
+ const usage: UsageStats = { input: 1000, output: 500, cacheRead: 4000, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ // claude-sonnet-4: $3/1M input, $15/1M output, $0.30/1M cacheRead
+ // input cost = 1000/1e6 * 3.00 = 0.003
+ // cache cost = 4000/1e6 * 0.30 = 0.0012
+ // output cost = 500/1e6 * 15.00 = 0.0075
+ // total ≈ 0.0117
+ const cost = estimateCost(usage, "claude-sonnet-4");
+ assert.ok(Math.abs(cost - 0.0117) < 1e-10, `expected ~0.0117, got ${cost}`);
+});
+
+test("estimateCost: cacheWrite tokens contribute when rate has cacheWritePer1M", () => {
+ const usage: UsageStats = { input: 1000, output: 500, cacheRead: 2000, cacheWrite: 3000, cost: 0, contextTokens: 0, turns: 1 };
+ // claude-sonnet-4: $3/1M input, $15/1M output, $0.30/1M cacheRead, $3.75/1M cacheWrite
+ // input cost = 1000/1e6 * 3.00 = 0.003
+ // cacheRead = 2000/1e6 * 0.30 = 0.0006
+ // cacheWrite = 3000/1e6 * 3.75 = 0.01125
+ // output cost = 500/1e6 * 15.00 = 0.0075
+ // total = 0.02235
+ const cost = estimateCost(usage, "claude-sonnet-4");
+ assert.equal(cost, 0.02235);
+});
+
+test("estimateCost: rate with no cache fields ignores cache tokens", () => {
+ const usage: UsageStats = { input: 1000, output: 500, cacheRead: 5000, cacheWrite: 5000, cost: 0, contextTokens: 0, turns: 1 };
+ // llama-3 has no cacheReadPer1M or cacheWritePer1M — cache tokens ignored
+ // input cost = 1000/1e6 * 0.25 = 0.00025
+ // output cost = 500/1e6 * 0.25 = 0.000125
+ // total = 0.000375
+ const cost = estimateCost(usage, "llama-3");
+ assert.equal(cost, 0.000375);
+});
+
+// ---------------------------------------------------------------------------
+// estimateCost — pluggable table override
+// ---------------------------------------------------------------------------
+
+test("estimateCost: pluggable table override is used instead of DEFAULT_RATES", () => {
+ const customTable: Record = {
+ "my-custom-model": { inputPer1M: 10, outputPer1M: 20 },
+ };
+ const usage: UsageStats = { input: 100_000, output: 50_000, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ // input cost = 100000/1e6 * 10 = 1.00
+ // output cost = 50000/1e6 * 20 = 1.00
+ // total = 2.00
+ const cost = estimateCost(usage, "my-custom-model", customTable);
+ assert.equal(cost, 2.00);
+});
+
+test("estimateCost: table override with prefix match works", () => {
+ const customTable: Record = {
+ "custom-fast": { inputPer1M: 0.50, outputPer1M: 2.00 },
+ };
+ const usage: UsageStats = { input: 2000, output: 1000, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ // input cost = 2000/1e6 * 0.50 = 0.001
+ // output cost = 1000/1e6 * 2.00 = 0.002
+ // total = 0.003
+ const cost = estimateCost(usage, "custom-fast-v2", customTable);
+ assert.equal(cost, 0.003);
+});
+
+test("estimateCost: table override miss falls back", () => {
+ const customTable: Record = {
+ "custom-fast": { inputPer1M: 0.50, outputPer1M: 2.00 },
+ };
+ const usage: UsageStats = { input: 500, output: 500, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ // "gpt-4o" is not in customTable — should fall back
+ const cost = estimateCost(usage, "gpt-4o", customTable);
+ assert.equal(cost, FLAT_FALLBACK_PER_TOKEN * 1000);
+});
+
+// ---------------------------------------------------------------------------
+// resolveRate — pluggable table
+// ---------------------------------------------------------------------------
+
+test("resolveRate: pluggable table override", () => {
+ const customTable: Record = {
+ "my-model": { inputPer1M: 5, outputPer1M: 15 },
+ };
+ const rate = resolveRate("my-model", customTable);
+ assert.ok(rate);
+ assert.equal(rate.inputPer1M, 5);
+ assert.equal(rate.outputPer1M, 15);
+ // Should NOT fall back to DEFAULT_RATES
+ assert.equal(resolveRate("gpt-4o", customTable), undefined);
+});
+
+// ---------------------------------------------------------------------------
+// Type-level: ensure DEFAULT_RATES entries conform to ModelRate
+// ---------------------------------------------------------------------------
+
+test("DEFAULT_RATES: all entries have required fields", () => {
+ for (const [key, rate] of Object.entries(DEFAULT_RATES)) {
+ assert.ok(typeof rate.inputPer1M === "number", `${key}.inputPer1M must be a number`);
+ assert.ok(typeof rate.outputPer1M === "number", `${key}.outputPer1M must be a number`);
+ assert.ok(rate.inputPer1M >= 0, `${key}.inputPer1M must be non-negative`);
+ assert.ok(rate.outputPer1M >= 0, `${key}.outputPer1M must be non-negative`);
+ }
+});
+
+test("FLAT_FALLBACK_PER_TOKEN: is a positive number", () => {
+ assert.ok(typeof FLAT_FALLBACK_PER_TOKEN === "number");
+ assert.ok(FLAT_FALLBACK_PER_TOKEN > 0);
+});
From 8d62dc3a1c19b1d767d76f7d44718bf60a6e2c99 Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 11:46:12 +0800
Subject: [PATCH 09/51] fix(test): correct ScorerResult literal in exec-events
test
The exec-events round-trip test built a ScorerResult with an invalid
'weight' field (weights live on ScoreConfig.weights[], not per-result),
which failed repo-root 'tsc --noEmit' (test/ is excluded from the
package tsconfig, so the builder's package-scoped check missed it).
Provide the required name/type/passed/score fields instead.
Batch-1 foundation now green: typecheck exit 0, 1044/1044 core tests.
---
packages/taskflow-core/test/exec-events.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/taskflow-core/test/exec-events.test.ts b/packages/taskflow-core/test/exec-events.test.ts
index 147e0d6..660fba4 100644
--- a/packages/taskflow-core/test/exec-events.test.ts
+++ b/packages/taskflow-core/test/exec-events.test.ts
@@ -161,7 +161,7 @@ test("readEvents: round-trip an event with a decision", () => {
const decision: EventDecision = {
type: "gate-score",
target: "score the output for quality",
- results: [{ name: "coherence", score: 0.9, weight: 1 }],
+ results: [{ name: "coherence", type: "contains", passed: true, score: 0.9 }],
combined: 0.9,
threshold: 0.7,
verdict: "pass",
From d437c0a92dab91895d715c3dc4fe29988b48bc40 Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 12:39:14 +0800
Subject: [PATCH 10/51] docs(0.2.0): add architecture RFC and phase brainstorm
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Anchor Batch 1–2 work: master architecture for event-sourced FlowIR
kernel, plus brainstorm of phase types unlocked by the new kernel.
---
.../brainstorm-2026-07-08-0.2.0-phases.md | 155 ++++++++
docs/rfc-0.2.0-architecture.md | 362 ++++++++++++++++++
2 files changed, 517 insertions(+)
create mode 100644 docs/internal/brainstorm-2026-07-08-0.2.0-phases.md
create mode 100644 docs/rfc-0.2.0-architecture.md
diff --git a/docs/internal/brainstorm-2026-07-08-0.2.0-phases.md b/docs/internal/brainstorm-2026-07-08-0.2.0-phases.md
new file mode 100644
index 0000000..21ad401
--- /dev/null
+++ b/docs/internal/brainstorm-2026-07-08-0.2.0-phases.md
@@ -0,0 +1,155 @@
+# Brainstorm: taskflow 0.2.0 更强的 Phase 类型
+
+> Status: **Brainstorm 存档** · 2026-07-08
+> 方法:会话式发散,锚定在 [`rfc-0.2.0-architecture.md`](../rfc-0.2.0-architecture.md) 已拍板的
+> **事件溯源 + FlowIR 执行内核**(Q2=B / Q5=own)之上。
+> 关联:[`design-dynamic-dag-expansion.md`](./design-dynamic-dag-expansion.md)(`expand` 的前身设计+对抗评审)、
+> [`brainstorm-2026-07-frontier-council.md`](./brainstorm-2026-07-frontier-council.md)(旧的 white-space / 明确不做清单)、
+> [`design-org-supervision.md`](./design-org-supervision.md)(`escalate`/supervision 的相关设计)。
+>
+> **核心角度**:这轮不是重提旧点子。0.2.0 换成事件溯源内核后,**一批以前"因架构风险被否/推迟"的
+> phase 变便宜、变安全了**。本文按"新内核新解锁什么"重新审视 phase 设计空间。
+
+---
+
+## 0. 组织论点:新内核改变了 phase 设计的成本
+
+**证据(决定性)**:动态 DAG 展开(`flow{def}` / self-expand)当年被对抗评审砍到只做"嵌套子流程"版——真正强大的"把动态节点 graft 进父 DAG"被推迟,因为它在**命令式内核**里有 3 个 P0:
+
+1. crash 后注入丢失(持久化竞态)
+2. 三态就绪判定不清("依赖没跑" vs "依赖跑失败")→ `join:"any"` 误 skip / 死锁
+3. 并发中改 `def.phases` 数组
+
+**而这 3 个 P0,正是事件溯源内核天生解决的**:
+
+| 命令式内核的 P0 | 事件溯源内核如何天然治好 |
+|---|---|
+| ① 持久化竞态 | event log 就是持久化;注入 = 追加事件,无竞态 |
+| ② 三态就绪 | fold 从事件流天然区分 done/failed/pending |
+| ③ 并发改数组 | driver 定点从 folded 状态重算 ready 集,不改共享数组 |
+
+→ **0.2.0 的新内核,恰恰是那个被推迟的强 phase 缺的地基。** 这是本文的立论基础。
+
+phase 因此分三桶:**① 新解锁** | **② 变更强** | **③ 仍不做**。此外单列 **④ Moonshot 大胆簇**。
+
+---
+
+## 1. 🔓 Bucket 1 —— 新内核**新解锁**的 phase
+
+| Phase | 干什么(大白话) | 0.2.0 为何解锁(以前被什么挡) | 超能力 |
+|---|---|---|---|
+| **`expand` 动态图嫁接** | agent 运行中生成一段 DAG,**splice 进正在跑的父图**(不只嵌套子流程) | 3 个 P0 全是命令式内核的病,事件溯源天生治好(§0) | 🔨编译 |
+| **`compensate` / saga** | 某 phase 失败→**沿 event log 倒着**触发补偿动作(回滚迁移 / 删已建资源) | 事件溯源就是 saga 的实现方式(Temporal);以前列为 L 推迟("无回滚语义") | ♻️恢复 |
+| **`race` 首个胜出** | spawn N 个方案,**取最先完成的**,砍掉其余 | 以前"投机执行"被否("无回滚 + 违背成本控");现 event log 给干净的取消记账 + replay 能算被砍分支成本 | ⏩延迟优化 |
+| **`watch` 响应式触发** | 一个 phase 在它**读的东西变了**时自动重跑(持续 / 常驻流) | 这正是响应式内核(signals / observed readSet)本身;以前"stream edges"被否("核心架构风险最大"),现用响应式而非流边实现 | 🔁增量→连续 |
+
+---
+
+## 2. ⚡ Bucket 2 —— 现有 phase **变更强**
+
+| Phase | 升级 | 以前的障碍 | 超能力 |
+|---|---|---|---|
+| **`loop` 多 phase body** | 循环体从"单个 agent 任务"→ **test→fix→retest 子图** | DSL RFC v2 §4.6 明标 post-0.2.0("需引擎支持循环内子图");被 `expand` 解锁 | ♻️(最想要的增强) |
+| **`map` 逐项增量** | 改 1 个 item 的输入 → **只重跑那 1 项**,不重跑整个 map | roadmap §6.3 推迟("map 吐整个数组,scope 爆炸");事件溯源逐项事件天然可寻址 | 📉增量(补全"改 1 文件→重跑 1 项"旗舰叙事) |
+| **`route` / switch 类型路由** | 按**类型化判别式**(`json` 联合)精确激活 1 个分支 | 以前"tagged-union routing"被否("需 schema 先行 + 无需求");现 FlowIR + 类型输出让路由**编译期可验证穷尽性** | 🔨编译 |
+| **`gate`/`approval` 类型化裁决** | 裁决用类型化 schema + approval **超时 + 回退** | frontier council white-space #4/#13(无竞品有);event log 让多轮 / 超时**可恢复** | ♻️恢复 |
+
+---
+
+## 3. 🚫 Bucket 3 —— 仍然不做(新内核**没**改变判断)
+
+| 方向 | 理由(仍成立) |
+|---|---|
+| 可视化拖拽编辑器 | 与"JSON / 代码即护城河"冲突 |
+| Flow algebra(merge/project/compose) | `flow{use}` + decompile 已覆盖 |
+| Artifacts(类型化文件输出) | `ctx_write`/`ctx_read` 已覆盖 |
+| Stream edges + backpressure | `watch`(Bucket 1)用响应式拿到真需求,不背流边架构风险 |
+
+---
+
+## 4. 🌌 Bucket 4 —— Moonshot 大胆簇("flow 是活体,不是静态程序")
+
+> **心智跃迁**:在事件溯源 + FlowIR-as-data + 确定性重放下,flow 不再是"你跑一次的静态程序",而是
+> **可分叉、可回溯、能观察自己、能改写自己的活体进程**。event log 之于 flow ≈ git 之于代码 + 训练数据之于模型。
+
+### 4.1 时间旅行 / 分叉(event log 当 git 用)
+
+| Phase | 干什么 | 新内核为何让它可能 |
+|---|---|---|
+| **`fork` / savepoint** | 给运行中的 flow 打命名存档点,之后能**从存档点分叉出新 run**(复用到存档点的 log,再岔开) | 事件溯源下"分叉一个 run" = fork 一条 log,几乎白得。跑到第 5 步→分叉→并行试 3 种第 6 步 |
+| **`speculate` 投机分叉** | 决策点**同时展开多个未来**(fork log 并行跑),胜者留下、败者剪掉但**保留其 log 供 replay 复盘"没走的路"** | 投机执行以前被否("无回滚");现在剪一个分支 = 丢一条 log,天然可回滚 + replay 能算被剪分支成本 |
+
+### 4.2 零成本反事实(replay 当模拟器用)
+
+| Phase | 干什么 | 新内核为何让它可能 |
+|---|---|---|
+| **`counterfactual` 一跑多变体** | 跑完一个 phase,**立刻用 N 组不同旋钮(模型/prompt/阈值)replay 它**,复用缓存的上游,近乎零边际成本产出对比 | replay + 内容寻址缓存 = "试 3 个模型看哪个当时最好,不花 3 倍钱"。把 eval/实验变成**运行时一等原语** |
+| **`sandbox-twin` 数字孪生排练** | 真动手前,先对**录制环境的 replay**跑一遍验证,通过了再真跑 | replay 能重放录制环境 = 可模拟。"三思而后行"变一等能力(dry-run 终极形态) |
+
+### 4.3 自我进化(跨运行 log 当训练信号)
+
+| Phase | 干什么 | 新内核为何让它可能 |
+|---|---|---|
+| **`self-optimize` 自调优** | 一个 phase **读自己所有历史 run 的 log**,按 p50/p95 成本·延迟·成功率**自动调**模型/并发/重试 | 跨运行 event log + 费率表(F1) = 现成训练信号。"flow 从自己的历史里学"——无竞品在自我调优 |
+| **`population` 进化循环** | loop 不再是单一血脉迭代,而是**维护候选种群**,每代对最优做变异/交叉,多代收敛(遗传算法) | event log 记录谱系;比 reflexion-loop(单线反思)高一维 |
+
+### 4.4 群体智能(比 tournament 更进一步)
+
+| Phase | 干什么 | vs 现有 |
+|---|---|---|
+| **`quorum` 共识** | 跑 N 个,输出取**多数投票/中位数**,分歧度作为置信信号(self-consistency) | tournament=裁判选最优;quorum=群体求共识 + 出置信度 |
+| **`negotiate` 辩论** | N 个对立立场的 agent **互相辩**到收敛(非各自对任务),裁判收口 | 对抗协作/debate 成一等原语;每轮可 replay |
+
+### 4.5 相关(org-supervision 设计的延伸)
+
+| Phase | 干什么 | 关联 |
+|---|---|---|
+| **`escalate` 动态督导** | 督导 phase **经 event log 监控运行中的子任务**,动态增派 worker / 重分配 / 杀掉低效者 | `design-org-supervision.md`(ctx_spawn/ctx_report);event log 是督导的可观测性 |
+
+---
+
+## 5. 排序与首推
+
+### 5.1 稳健候选(Bucket 1+2)排序
+
+| 优先 | Phase | 一句话理由 |
+|---|---|---|
+| **1** | `expand` 动态图嫁接 | **旗舰**——事件溯源正是它缺的地基,已设计+已对抗评审,直接兑现"agent 编排的编译器"叙事 |
+| **2** | `loop` 多 phase body | 最想要的增强,被 #1 直接解锁 |
+| **3** | `map` 逐项增量 | 补全"改 1 文件只重算 1 点"的增量旗舰故事 |
+| **4** | `compensate`/saga | 真实副作用流的安全网,事件溯源下自然 |
+| **5** | `race` 首个胜出 | 便宜,填延迟优化空白(tournament=最优、parallel=全等,都不是首个胜出)|
+| 6 | `route` 类型路由 | 编译期可验证,比 N 个 `when` 干净 |
+| 7 | `watch` 响应式触发 | 最大胆——常驻/连续流,把"增量"推到极致 |
+
+> **关键连锁**:`expand` 一旦落地,`loop` 子图 和 `map` 逐项几乎白得——它们都是"局部子图"的特例。
+> **`expand` 不只是一个 phase,是解锁一串 phase 的元能力。**
+
+### 5.2 Moonshot 诚实分层
+
+| Tier | Phase | 判断 |
+|---|---|---|
+| **A|大胆但 0.2.0 够得着** | `fork`/savepoint、`counterfactual`、`quorum` | 只是 event-log + replay + cache 的巧用,无新内核机制 |
+| **B|Moonshot,需研究 spike** | `speculate` 剪枝、`self-optimize`、`population`、`negotiate`、`escalate` | 要新机制 + 真风险(成本爆炸/收敛性/评估偏差/督导语义),带 kill-criteria 立项 |
+| **C|仍太远/冲突** | 真流式、全自主自改写 flow | 违背成本可控 / 护城河,暂不碰 |
+
+### 5.3 首推
+- **稳健旗舰**:`expand`(元能力,连带解锁 loop 子图 + map 逐项)。
+- **大胆但够得着**:`fork`/savepoint + `counterfactual`——几乎是事件溯源内核的免费副产品,把 taskflow 从"编排器"抬到"可实验的活体运行时";且 `counterfactual` 是**把已有 replay 直接变成用户级杀手锏**的最短路径,正好接上 0.1.7 承诺的确定性重放。
+
+---
+
+## 6. 一句话最狂愿景
+
+> **flow 从"运行一次的程序"变成"能分叉试错、零成本反事实、从自己历史学习的活体"。**
+> `expand` 让它**长出**新节点,`fork` 让它**分叉**,`counterfactual` 让它**假设**,`self-optimize` 让它**进化**——
+> 这四个合起来,taskflow 不再是编排器,是 **agent 工作流的活体运行时**。
+
+---
+
+## 7. 下一步(未定,待选)
+
+1. 挑 `expand` 深挖成 phase 设计草案(DSL 形态 + FlowIR node 语义 + 与 `flow{def}` 现有路径的关系 + graft-into-parent 的事件溯源实现)。
+2. 挑 `fork` + `counterfactual` 深挖(DSL 形态 + event-log 分叉语义 + 与 replay 复用)。
+3. 用 taskflow 自己跑一轮对抗式 brainstorm(4 路发散 → critic 收敛 → arbiter 裁决)把 Moonshot 簇压测 + 定 kill-criteria。
+4. 把 Bucket 1+2 的排序纳入 `rfc-0.2.0-architecture.md` §5 的建造顺序(作为 S2 之后的 phase 扩展 horizon)。
diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md
new file mode 100644
index 0000000..7eb3693
--- /dev/null
+++ b/docs/rfc-0.2.0-architecture.md
@@ -0,0 +1,362 @@
+# RFC: taskflow 0.2.0 系统架构总纲(Master Architecture)
+
+> Status: **Draft v1** · 2026-07-08
+> 层级:这是凌驾于 [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md)(DSL 前端)和
+> 未来 replay RFC 之上的**系统架构总纲**。定调 0.2.0 内核形态、模块边界、数据契约、迁移顺序。
+> 后续所有 0.2.0 实现 RFC 以本文为锚。
+> 关联:[`0.2.0-north-star.md`](./0.2.0-north-star.md)(方向)、
+> [`internal/overstory-convergence-roadmap.md`](./internal/overstory-convergence-roadmap.md)(M1-M5 内核路线)、
+> [`rfc-0.2.0-three-compile-routes.md`](./rfc-0.2.0-three-compile-routes.md)(DSL 路线之争)。
+>
+> **本文回应两个已发现的架构级矛盾**(§0),并把 5 个已拍板的决策(§1)固化成一套
+> 可发布、向后兼容的内核替换方案。
+
+---
+
+## TL;DR(结论先行)
+
+1. **0.2.0 是一次内核替换**:`runtime` 从"解释执行 Taskflow schema"升级为"**执行 FlowIR 的事件溯源内核**"。FlowIR 从今天的"哈希影子"毕业成**规范执行产物**。
+2. **真编译器自研**:在 `taskflow-core` 内自建 `flowir/{schema,compile,hash,cond}`(不 vendor overstory 私仓),`usedFallbackHash` 翻 `false`。
+3. **一个机制长出四样能力**:事件溯源内核按构造发出 **event log** → `trace = log`、`RunState = fold(log)`、`resume = 重放 log`、`确定性重放 = 换旋钮重 fold`、`增量重算 = stale 定点`。这消解了 north-star "resumable vs replayable" 的自相矛盾——二者是同一条 log 的两种 fold。
+4. **绞杀式迁移**:新旧内核并存 + 差分测试 + 逐 node kind flip。中和 roadmap 对"内核替换=三层同时改、违背可发布"的唯一反对——每个阶段(S0–S5)独立可发布。
+5. **replay 重新纳入 scope**:兑现 0.1.7 CHANGELOG 的公开承诺("deterministic replay lands in 0.2.0")。在事件溯源架构里 replay 不是附加项,是内核的天然能力。
+6. **向后兼容是硬约束**:现有 JSON flow 零修改、published `RunState.json` 可加载、`/tf` 命令与 DSL 语义不破坏。
+7. **DSL 是平行前端**:`.tf.ts → build → Taskflow → compile → FlowIR`。新包 `taskflow-dsl`(core 零依赖铁律不破)。DSL 与 replay 两条主线在 FlowIR 汇合、互不阻塞。
+
+---
+
+## §0. 背景:为什么需要这份 RFC(两个矛盾)
+
+### 矛盾 1:DSL RFC 说"runtime 执行 FlowIR",但 runtime 执行的是 Taskflow
+
+- **现状(已核验)**:`executeTaskflow(state, deps)` 中 `state.def: Taskflow`(`runtime.ts`)。FlowIR 仅经 `compileTaskflowToIR(def)` 用来算 cache-key 的 phase fingerprint;`usedFallbackHash` 在 stub 里**恒为 `true`**(`flowir/translate.ts`),hash == `flowDefHash`。**FlowIR 今天是"分析/哈希影子",不是执行产物。**
+- **DSL RFC v2 §0.2** 写的是"`taskflow build` → FlowIR,runtime 执行 FlowIR"。
+- **overstory roadmap §6.4** 明确把"统一 imperative `executePhase` 循环 → overstory `step()`+driver 事件溯源执行模型"划为 **post-M5 独立 RFC**,并警告"现在碰它 = 数据 + 算法 + 执行三层同时改,违背每步可发布"。
+
+→ 三份文档在"FlowIR 是不是执行产物"上互相打架。**本 RFC 拍板:是(Q2=B),但用绞杀式迁移守住可发布性(Q7)。**
+
+### 矛盾 2:north-star 丢弃了 replay,但 0.1.7 公开承诺过
+
+- **0.1.7 CHANGELOG 原文**:确定性重放"…which **lands in 0.2.0**. The schema is already complete enough that 0.2.0 replay won't need a breaking migration."`replay.ts` 的 sentinel 同样写"Implemented in 0.2.0"。
+- **north-star(07-07)** 把 replay 几乎完全踢出 scope,定位口号甚至借 Qwik 的 **"resumable (not replayable)"** 反向定位。
+- **Qwik 的 "replayable" 指的是页面 hydration 重执行**(与 resume 对立),和 taskflow 的"决策旋钮重放"**根本是两码事**。这个口号碰撞会让 north-star 自己打自己。
+
+→ **本 RFC 把 replay 重新钉进 0.2.0 scope**,并要求修订 north-star 的口号(§13)。
+
+---
+
+## §1. 已锁定的架构决策(Decision Record)
+
+| # | Fork | 结论 | 理由 |
+|---|---|---|---|
+| **Q2** | FlowIR 是否执行产物 | **B — 是**。runtime 通过事件溯源内核执行 FlowIR | 让 trace/resume/replay/recompute 从一个机制长出(§2);长期对齐 overstory |
+| **Q5** | 真编译器来源 | **own — 在 core 自研**(不 vendor overstory `ir/`) | 零外部耦合、无 `private:0.1.0-dev` 漂移债、完全自控;代价=自实现 hash/cond + 自建正确性测试 |
+| **Q6** | 状态模型 | **不收敛** RunState→RunTree。RunState 保持公开持久化面(= fold 产物) | 守 published `RunState.json` 向后兼容;内部用 event log,外部面不变 |
+| **Q7** | 迁移方式 | **绞杀式**:新旧内核并存 + 差分测试 + 逐 node kind flip | 把"大爆炸内核替换"拆成每步可发布,中和 roadmap 唯一反对 |
+| **Q8** | event log 与 trace 关系 | **log 即 trace**。replay=重 fold;0.1.7 "trace 只接 30%" 的问题溶解 | 事件溯源内核按构造发出每个决策事件,不需再补 5 条死线 |
+
+---
+
+## §2. 核心洞察:事件溯源统一 trace / resume / replay / recompute
+
+选 B 的真正价值不在"能写 DSL",而在**执行模型换成事件溯源后,四样能力从一个机制自然长出**:
+
+```
+ event log —— driver 按构造发出的每个事件(subagent 调用 + 每个决策)
+ │
+ ┌──────────┬───┼───────────┬──────────────┬────────────────┐
+ ▼ ▼ ▼ ▼ ▼ ▼
+ trace RunState resume 确定性重放 增量重算
+(=log 本身) (=fold(log)) (=重放 log) (=换旋钮重fold,0token) (=stale 定点,M5)
+```
+
+- **trace**:不再是"只接了 30% 的旁路"(今天 `trace.ts` 定义 7 种决策事件,`runtime.ts` 只 emit 2 种:`gate-verdict`+`unreplayable`;`gate-score`/`budget-hit`/`when-guard`/`tournament-winner`/`cache-hit` 定义了但 0 次 emit)。事件溯源内核**按构造**发出每个事件——**F3"补 5 条死线"的问题直接溶解**。
+- **RunState = fold(event log)**:当前 phase 状态是对事件流的 reduce。
+- **resume = 重放 log 重建状态**(Qwik 的 "resumable")。
+- **确定性重放 = 拿同一条 log、换决策旋钮(gate 阈值 / budget / 模型路由)重新 fold**,对录制的 subagent 输出重新裁决,**零 token、绝不调模型**(Temporal 的 "replay")。
+- **增量重算 = driver 的 stale-frontier 定点**(M5 已落地)。
+
+> **这一举消解 north-star 的 "resumable vs replayable" 矛盾**:在事件溯源下,resume 和 replay 是**同一条 log 的两种 fold**,不对立。你要的"0.2.0 继续 replay"在 B 架构里是内核的天然能力,不是 bolt-on。
+
+---
+
+## §3. 整体架构(层 + 数据契约)
+
+```
+ 作者前端 flow.json ─parse─┐ flow.tf.ts ─build(AST transform)─┐
+ (Authoring) │ │ ← taskflow-dsl 包
+ ▼ ▼
+ ┌───────────────────────────────────────────────────────┐
+ 规范作者 schema │ Taskflow schema (作者面 DSL, 向后兼容, desugar) │ schema.ts
+ (契约①) └───────────────────────────┬───────────────────────────┘
+ │ compile(★自研真编译器, Q5)
+ ▼
+ ┌───────────────────────────────────────────────────────┐
+ 规范执行产物 │ ★ FlowIR — 内容寻址 / hash / node·edge / inject·emits │ flowir/
+ (契约②, 唯一执行真源) └──────────────┬──────────────────────────┬───────────────┘
+ (0 token) │ executeFlowIR │ (0 token)
+ verify/compile/diff ▼ │
+ ┌──────────────────────────────────┐│
+ 事件溯源内核 │ exec/ driver 定点循环 + step 派发 ││
+ (执行) │ ├─ 发 EVENT LOG ═════════════╗ ││
+ │ ├─ observed readSet@version ║ ││ ← 契约③ event log
+ │ └─ usage/cost (rates.ts) ║ ││
+ └──────────────┬─────────────────╨───┘│
+ fold │ │ log │
+ ▼ ▼ │
+ 状态 + 后执行 ┌─────────────────┐ ┌──────────────────────────┴────┐
+ (契约④ RunState) │ RunState │ │ replay.ts 重fold(log,新旋钮) │ 纯,0token
+ │ (兼容持久化面) │ │ recompute/stale.ts (M5) │
+ └─────────────────┘ └───────────────────────────────┘
+```
+
+### 四个数据契约(模块间的连接组织)
+
+| 契约 | 是什么 | 谁产 | 谁消费 | 向后兼容 |
+|---|---|---|---|---|
+| ① **Taskflow** | 作者面 schema(现有);JSON 与 DSL 的共同落点 | `parse`(JSON) / `build`(DSL) | 编译器 | 现有 JSON flow 零修改 |
+| ② **FlowIR** | 规范执行产物,内容寻址 | `flowir/compile` | 内核 / verify / compile / diff / cache | 新契约(0.2.0 引入为执行真源) |
+| ③ **Event log** | append-only 事件流(= trace) | `exec/driver` | fold / replay / recompute / OTel(未来) | 向后兼容读旧 trace(`readTrace` 已容错) |
+| ④ **RunState** | 当前状态快照(= fold 产物) | `exec/fold` | resume / `/tf runs` / persist | **published `RunState.json` 必须可加载** |
+
+---
+
+## §4. 模块分解与依赖关系
+
+### 4.1 taskflow-core 模块图(零运行时依赖不变)
+
+```
+ ┌─────────────┐ 两个前端都汇到这里
+ flow.json ──────▶│ schema.ts │◀────── taskflow-dsl(build)
+ │ (Taskflow) │
+ └──────┬──────┘
+ │ ★ Q5 自研真编译器
+ ┌─────────────▼──────────────────────────────┐
+ │ flowir/ │
+ │ schema.ts 规范 FlowIR 类型(node/edge/kind/inject/emits) 【新】
+ │ compile.ts Taskflow→FlowIR(lowering+归一化) 【translate.ts 毕业】
+ │ hash.ts 内容寻址哈希(序/空白无关) 【现有→自研真算法】
+ │ cond.ts when/until/eval→归一化条件 IR 【新, 与 interpolate 共享】
+ │ meta.ts / phasefp.ts 【现有: declaredDeps / phase fp】
+ └──────┬──────────────────────────────────────┘
+ │ FlowIR
+ ┌───────────────┼────────────────────────────────────────┐
+ │ (0 token) │ executeFlowIR │
+ ▼ ▼ │
+ verify.ts ┌──────────────────────────────────────────┐ │
+ compile.ts │ exec/ │ │
+ (diff.ts 可选)│ driver.ts 事件溯源定点循环(schedule+fold) │ │
+ │ step.ts 10 种 node kind 派发 │─emits─┐
+ │ events.ts 事件(log) schema ◀════════════════════╝ 【吸收 trace.ts, Q8】
+ │ fold.ts reduce(log)→RunState │ │
+ └──────┬─────────────────────┬────────────────┘ │
+ fold │ │ log │
+ ▼ ▼ │
+ RunState(store.ts) ┌──────────────────────────────────┴─┐
+ │ │ replay.ts 重fold(log,新旋钮) │ 纯,0token
+ ▼ │ recompute/stale.ts (M5, key on hash)│
+ ┌─────────────────┐ └─────────────────────────────────────┘
+ │ rates.ts (F1) │ ← usage/cost 注入 step; codex 补 cost; replay 反事实计价
+ │ cache.ts (v3) │ ← key on flowir/hash.ts
+ └─────────────────┘
+
+ runtime.ts ── 收缩为「绞杀开关」: 旧 executePhase(flag) ⇄ 新 exec/driver(flag)
+```
+
+### 4.2 依赖约束(结构性护栏,import 图强制)
+
+1. **`replay.ts` 只 import 纯模块**:`events`(读 log)+ `flowir/{schema,cond}` + `deterministic`(`parseGateVerdict`/`overBudget`)+ `scorers`(纯评分器重跑)+ `rates`。**绝不 import `exec/driver`**(那会拖进 process-spawning runner)。→ "replay 永不花 token" 由 import 图**结构性**保证。这与 `replay.ts` 现有 docstring 已声明的约束一致。
+2. **`flowir/compile` 是 Taskflow→FlowIR 的唯一入口**:JSON 与 DSL 都经它,保证两个前端产出同一 FlowIR。
+3. **`exec/step` 依赖 host 注入的 `SubagentRunner`**(`RuntimeDeps.runTask`);`events`/`interpolate`/`scorers`/`scorer-runtime`。
+4. **core 零运行时依赖不变**:`flowir/*`、`exec/*`、`replay.ts`、`rates.ts` 全是纯模块(仅 typebox)。
+5. **`taskflow-core` 永不 import host SDK**(`@earendil-works/*`)——不变。
+
+### 4.3 新包 taskflow-dsl(7 → 8 包)
+
+| 内容 | 说明 |
+|---|---|
+| rune 类型定义 | `flow/agent/map/gate/...` 的 TS 类型(编译指令,见 DSL RFC v2 §0) |
+| `build` | AST transform:`.tf.ts` → Taskflow JSON(再经 core 的 `flowir/compile` → FlowIR) |
+| `check` | 轻量校验(tsc + rune 签名 + 依赖完整性 + when 谓词子集) |
+| `decompile` | FlowIR → `.tf.ts`(代码生成器,语义等价非字面 round-trip,DSL RFC v2 §6.2) |
+| 依赖 | `taskflow-core`(Taskflow schema + verify)+ `typescript`(build-time;**因此不能进 core**——core 零依赖铁律) |
+
+> **为什么 DSL 编译器不能进 core**:它需要 AST 库(tsc transformer API)。core 的零运行时依赖铁律禁止。DSL 编译器本质是 build-time 工具(像 bundler 插件),独立成包,产出 Taskflow JSON。core 完全不知道 DSL 存在。
+
+---
+
+## §5. 自研 FlowIR 编译器(Q5=own 的具体范围)
+
+"真编译器" vs 今天的 stub 差在:stub 的 `usedFallbackHash=true`、`hash==flowDefHash`(对 JSON 文本哈希,非对 IR 结构哈希)。自研真编译器要交付:
+
+### 5.1 `flowir/schema.ts`(新)— 规范 FlowIR 类型
+- `FlowIRNode { id, kind, inject[], emits[], task?, condRef?, ... }`;`kind ∈ 10 种`(agent/parallel/map/gate/reduce/approval/flow/loop/tournament/script)。
+- `FlowIR { name, nodes[], edges[], budget?, concurrency?, ... }`。
+- **1:1 投影优先**:每个 Taskflow phase → 一个 FlowIR node。overstory 的 native 多节点 lowering(parallel→N siblings 等)**推迟**(见 §12),先保 hash 稳定。
+
+### 5.2 `flowir/compile.ts`(`translate.ts` 毕业)— Taskflow → FlowIR
+- lowering:phase → node(含 desugar 后的字段)。
+- 归一化:字段顺序、默认值、空白无关——保证"逻辑等价的 flow 产出同一 IR"。
+- declared readSet:`collectRefs` 过 task/over/when/until/eval/branches/with/context + `dependsOn` → `inject`/`emits`(现有 `meta.ts` 逻辑并入)。
+
+### 5.3 `flowir/hash.ts`(现有 → 自研真算法)— 内容寻址哈希
+- **硬验收(roadmap M1 门)**:① 逻辑等价(含空白/顺序重排)⟹ 同 hash;② 单字段变更 ⟹ hash 变;③ 确定性(同输入永远同输出,跨进程)。
+- **自研而非 vendor**:我们定义哈希的规范化 + 序列化规则(如稳定 key 排序 + 规范 JSON + SHA-256),写自己的 property 测试。不追求与 overstory byte-parity(Q5=own 放弃了这个目标)。
+- cache key 升 **`v3:flowir:`** 前缀(现有 `v2:flowdef:` 3-tier lookup 保留一个 release 周期,见 §8)。
+
+### 5.4 `flowir/cond.ts`(新)— 条件归一化
+- `when`/`until`/`eval` 表达式 → 归一化条件 IR,供 hash(结构等价)+ replay(重新求值 `when-guard`)+ DSL(谓词子集编译)共享。**与 `interpolate.ts` 共享代码路径**,避免语义漂移。
+
+---
+
+## §6. 事件溯源内核(exec/)
+
+### 6.1 `exec/events.ts`(吸收 trace.ts,Q8)— log schema
+- 事件类型 = 现 `TraceEvent` 的超集:`phase-start`/`phase-end`/`subagent-call`/`decision`(7 种 decision:gate-verdict/gate-score/tournament-winner/budget-hit/cache-hit/when-guard/unreplayable)。
+- **加 `v`(schema 版本)字段**(现 `TraceEvent` 无版本字段——additive,replay 上线时可检测/迁移旧 log)。
+- driver **按构造**发出每个事件(不再是 `runtime.ts` 里手工插 emit 点)——这是 F3 溶解的机制。
+
+### 6.2 `exec/driver.ts` — 事件溯源定点循环
+- 拓扑调度 ready 节点(现有 `schema.ts` topo sort)→ 调 `step()` → 收事件 → fold 更新状态 → 重新算 ready 集合,直到收敛/预算/中止。
+- `budget`/`abort`/`idle watchdog` 语义保留(现 `runtime.ts` 已有)。
+- 增量重算 = 这个定点循环 + stale frontier 作为初始 ready 集(M5 已有算法)。
+
+### 6.3 `exec/step.ts` — 10 种 node kind 派发
+- 每个 kind 一个 handler(agent/script/map/parallel/reduce/gate/loop/tournament/approval/flow),从现 `executePhase` 的 10 个分支迁移而来。
+- handler 纯粹"执行一个 node → 发事件",不直接改状态(状态由 fold 派生)。
+- host 交互经 `RuntimeDeps.runTask`(不变)。
+
+### 6.4 `exec/fold.ts` — reduce(log) → RunState
+- `fold(events) → RunState`:把事件流归约成当前 phase 状态快照。
+- **RunState 保持现有公开形状**(Q6)——`persist`/`onProgress`/`/tf runs` 消费的还是 RunState,外部无感。
+- **resume** = 从持久化的 log 重新 fold(崩溃恢复 = 重放事件,非从 RunState 快照猜)。
+
+---
+
+## §7. 确定性重放(replay.ts)
+
+### 7.1 语义
+`replayRun(log, overrides) → ReplayDecision[]`:拿录制的 event log,在**决策旋钮**变化下重新 fold,对录制的 subagent 输出**重新裁决**,**零 token、绝不调模型**。
+
+- `ReplayOverrides`(已存在于 `replay.ts`):`thresholds`(gate score 阈值)、`budgetMaxUSD`/`budgetMaxTokens`、`models`(只报 cost delta,需 `rates.ts`)、`args`(改文本的 phase → `needs-live-rerun`)。
+- `ReplayDecision`(已存在):`reused`/`verdict-flipped`/`would-block`/`would-exceed-budget`/`needs-live-rerun`/`would-skip`/`threshold-changed`/`failed`。
+- 实现 = 重放 log + 重求值:gate 阈值改 → 拿录制的 `gate-score` 事件里的 per-scorer 结果 + 新阈值重跑 `combineScores`(纯,`scorers.ts` 已有);budget 改 → 拿录制 usage + 新 cap 重跑 `overBudget`(`deterministic.ts` 已有);when 改 → 重求值 `when-guard`。
+
+### 7.2 与 recompute 的孪生关系
+| | recompute(M5 ✅) | replay(0.2.0) |
+|---|---|---|
+| 触发 | *输入*变了(改文件) | *决策旋钮*变了(阈值/budget/模型) |
+| 执行 | **在线**重跑 stale frontier | **离线**重 fold |
+| 花费 | 花 token(只花变化节点) | **零 token** |
+| 问 | "改了这文件要重跑哪些?" | "当初阈值 0.9 会 BLOCK 吗?" |
+
+两者共用 event log 证据,不共用执行逻辑——后执行层的一对孪生。
+
+### 7.3 命名(修口号碰撞)
+- 技术术语保留 **"deterministic replay"**(0.1.7 CHANGELOG 已用,准确)。
+- **修 north-star**:去掉借自 Qwik 的 "resumable (not replayable)" 表述(Qwik 的 replayable 指 hydration 重执行,与我们的决策重放同名不同义)。建议定位改为 **"compiled · resumable · incremental · replayable-for-what-if"** 或去掉 "(not replayable)" 从句(§13)。
+
+---
+
+## §8. 向后兼容(硬约束)
+
+| 面 | 约束 | 机制 |
+|---|---|---|
+| **JSON flow** | 现有 `.json` flow 零修改继续跑 | `parse`→Taskflow→`compile`→FlowIR;新增执行路径不改作者面 |
+| **`RunState.json`** | published 旧 run 可加载 | Q6:RunState 保持公开形状;旧 run 走 resume,replay/recompute 对无 log 的旧 run 不可用(可接受,非破坏) |
+| **`/tf` 命令 + DSL 语义** | 不破坏 | 绞杀开关默认走旧内核,直到差分全绿才 flip |
+| **cache key** | 部署当天不 miss-storm | `v3:flowir:` 新前缀 + 保留 `v2:flowdef:` 3-tier lookup 一个 release 周期(现有迁移模式) |
+| **trace 旧文件** | 可读 | `readTrace` 已 partial-line 容错;`events` 加 `v` 字段做版本协商 |
+
+---
+
+## §9. 绞杀式迁移与建造顺序(每步可发布)
+
+roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式把它拆成 6 个独立可发布阶段:
+
+| 阶段 | 做什么 | 差分/验收门 | 可发布价值 | 承接 |
+|---|---|---|---|---|
+| **S0** | 自研 `flowir/{schema,compile,hash,cond}`;`usedFallbackHash→false`;runtime 仍执行 Taskflow | hash byte-determinism + 敏感度 + 逻辑等价(复用 `flowir-hash.test.ts`) | cache-key 更精准;FlowIR 成规范 | roadmap M1 剩余(自建版) |
+| **S1** | 加 `exec/{events,fold}`;让**旧** executePhase 也发事件;断言 `fold(log)==现 RunState` | 差分:所有现有 flow 的 fold 结果 == 旧 RunState(702 测试网) | **trace 完整 + 事件溯源(F3 溶解)** | F3 / Q8 |
+| **S2** | 建 `exec/{driver,step}`;**逐 kind** 迁移(agent→script→map→parallel→reduce→gate→loop→tournament→approval→flow),每 kind 差分新旧内核 | 逐 kind:新内核输出/RunState == 旧内核(差分测试) | 每 kind 绿了 flip 一次 | Q7 |
+| **S3** | `replayRun()` = 换旋钮重 fold + `replay` action/命令 + 黄金 log fixtures | 一致性台:未改旋钮的 replay == 录制结果(0 token) | **0.1.7 承诺的 replay 旗舰落地** | F2 / replay |
+| **S4** | 新包 `taskflow-dsl`:`.tf.ts→build→Taskflow→compile→FlowIR` + `check`/`new`/`decompile` | DSL demo flow 编译产出的 FlowIR == 手写 JSON 版 | DSL 作者面 | DSL RFC v2 |
+| **S5** | 全 kind 差分绿 → 新 driver 设默认;退休旧 executePhase | 全量回归 + e2e 绿 | 内核替换完成 | — |
+
+**顺序优雅之处**:`rates.ts`(F1) 落在 S1/S2(cost 进 event/usage);F3 在 S1 自然溶解;**replay(S3) 与 DSL(S4) 相互独立可并行**;每个 S 独立可发布、可停在任意阶段。
+
+---
+
+## §10. 包拓扑(7 → 8)
+
+| 包 | 变化 |
+|---|---|
+| `taskflow-core` | flowir/ 毕业为真编译器;新增 exec/;trace→events;replay 实现;rates 新增。**零依赖不变** |
+| `taskflow-mcp-core` | 新增 `taskflow_replay` 工具;action 表更新 |
+| `taskflow-hosts` | codex-runner 补 cost(接 rates);其余 runner 无感 |
+| `pi-taskflow` | 新增 `/tf replay` 命令 + 渲染;绞杀开关的 host 侧 flag |
+| `codex/claude/opencode-taskflow` | 无感(经 mcp-core 共享) |
+| **`taskflow-dsl`(新)** | rune 类型 + build/check/decompile;依赖 core + typescript |
+
+---
+
+## §11. 风险与验证门
+
+| 风险 | 级别 | 缓解 |
+|---|---|---|
+| 内核替换回归(新 driver 与旧行为不一致) | **HIGH** | S1/S2 差分测试是硬门:`fold(log)==旧RunState`、新内核逐 kind ==旧内核;702 测试是回归网 |
+| RunState 兼容破坏(旧 `RunState.json` 加载失败) | **HIGH** | Q6:RunState 公开形状不变;旧 run 走 resume;加载器容错 |
+| 自研 hash 正确性(逻辑等价漏判/误判) | MED | S0 硬验收门 + property 测试(现有 `flowir-hash.test.ts` 扩展) |
+| cache miss-storm(v3 前缀切换) | MED | v2→v3 3-tier lookup 一个 release 周期 |
+| replay 花了 token(import 图破坏) | MED | §4.2 结构护栏:replay 不 import exec/driver;加 import-lint 测试 |
+| scope 蔓延(S2 逐 kind 拖太久) | MED | 每 kind 独立可发布;可先发 agent/script,复杂 kind 后续版本 |
+| DSL 与内核耦合(S4 依赖 S0-S3) | LOW | DSL 只产 Taskflow JSON,与内核解耦;可并行 |
+
+**跨阶段验证门**(对齐 roadmap §5):
+- **S0**:`flowIRHash` 确定性 + 敏感度(M1 硬门)。
+- **S1**:kill-9 恢复 —— 跑完 → `kill -9` → resume → 从 log 无损重建 RunState。
+- **S3**:replay 一致性 —— 未改旋钮 replay == 录制结果,0 token。
+- **S5**:增量重算成本比 —— 周一 $6/8 agents → 周二改 1 文件 → ≤2 节点 $0.40(旗舰 demo)。
+
+---
+
+## §12. 非目标(0.2.0 明确不做,防 scope 蔓延)
+
+1. **overstory native 多节点 lowering**(parallel→N siblings / tournament→map+gate)。S0 用 1:1 投影,保 hash 稳定。native lowering 推迟到内核替换稳定后。
+2. **RunState → overstory RunTree 收敛**(Q6)。RunState 保持公开面;内部收敛留 post-0.2.0 mapping RFC。
+3. **map item-level 精确重算**(单 item 变只重跑该 item)。已知限制,推迟。
+4. **精确 `ir-changed` diff**(只失效结构变化的切片)。S5 先"全失效(默认)+refuse(flag)",精确 diff 后续 RFC。
+5. **`flow.component` / `$store` / `$derived`**(全局响应式)。依赖 Shared Context Tree,DSL RFC v2 §7 已标 post-0.2.0。demo 里加 `// [post-0.2.0]`。
+6. **`{env.X}` 占位符**:DSL RFC v2 §C 留待实现时定(0.2.0 纳入 or 只推 script 注入)。
+
+---
+
+## §13. 待定(不阻塞本 RFC,实现时定)
+
+1. **`build` AST transform 实现选型**:tsc transformer API / Babel / 自研轻量解析(DSL RFC v2 §C)。
+2. **replay 命名**:内部模块 `replay.ts` 不变;用户面命令 `/tf replay`;**north-star 口号需改**(去 "not replayable" 或改 "replayable-for-what-if")。
+3. **event log 存储**:与现 `trace.jsonl` 同格式?还是独立 `events.jsonl`?(S1 定)
+4. **绞杀开关的默认策略**:per-flow flag / 全局 flag / 环境变量(S2 定)。
+5. **S3 与 S4 并行 vs 串行**:取决于人力(两者解耦,可并行)。
+
+---
+
+## 附 A:north-star 需要修订的点(本 RFC 触发)
+
+| north-star 现状 | 需改为 | 原因 |
+|---|---|---|
+| 口号 "compiled, **resumable (not replayable)**, incremental" | 去掉 "(not replayable)" 或改 "**replayable-for-what-if**" | 与确定性重放同名碰撞;0.1.7 已承诺 replay |
+| 吸收思想表 "resumable, not replayable \| Qwik \| ✅已落地" | 加一行 "确定性重放 \| 事件溯源重fold \| 🔲0.2.0(S3)" | replay 重新纳入 scope |
+| §六 执行顺序(只列 DSL + 旗舰 demo) | 加 replay(S3) 作为与 DSL 平行的主线 | 两条主线在 FlowIR 汇合 |
+
+## 附 B:决策记录溯源
+
+| 决策 | 由谁定 | 日期 | 记录 |
+|---|---|---|---|
+| Q2=B(FlowIR 执行产物) | 项目主 | 2026-07-08 | 本 RFC §1 |
+| Q5=own(自研编译器) | 项目主 | 2026-07-08 | 本 RFC §1 |
+| Q6/Q7/Q8(状态模型/绞杀迁移/log=trace) | 建议 + 项目主未反对 | 2026-07-08 | 本 RFC §1,实现时可复核 |
+
+---
+
+*一句话总纲:0.2.0 把 runtime 换成执行 FlowIR 的事件溯源内核(Q2=B,自研编译器 Q5=own),让 trace/resume/replay/recompute 从一个机制长出;用绞杀式迁移(Q7)守住每步可发布与向后兼容;DSL(taskflow-dsl 新包)与 replay(兑现 0.1.7 承诺)是在 FlowIR 汇合的两条平行主线。*
From dd47a6df13f8f3d1d15db409a7c014bd88ec26fb Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 12:43:22 +0800
Subject: [PATCH 11/51] fix(core): harden batch-1 foundation contracts
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Event is TraceEvent & { v } (no dual schema); upgrade ts defaults to 0
- FlowIRNodeKind derives from exported PHASE_TYPES
- normalizeCond: refs ignore string literals; preserve escape sequences
- hashFlowIR/hashNode domain prefixes (ir:/node:)
- estimateCost peels cache⊆input (Codex) without double-billing
---
packages/taskflow-core/src/exec/events.ts | 97 ++++---------------
.../src/flowir/canonical-hash.ts | 35 ++++---
packages/taskflow-core/src/flowir/cond.ts | 50 ++++++----
packages/taskflow-core/src/flowir/schema.ts | 48 +++------
packages/taskflow-core/src/rates.ts | 35 ++++++-
packages/taskflow-core/src/schema.ts | 5 +-
.../taskflow-core/test/exec-events.test.ts | 25 ++++-
.../test/flowir-canonical-hash.test.ts | 18 +++-
.../taskflow-core/test/flowir-cond.test.ts | 20 ++++
.../taskflow-core/test/flowir-schema.test.ts | 22 ++++-
packages/taskflow-core/test/rates.test.ts | 20 +++-
11 files changed, 217 insertions(+), 158 deletions(-)
diff --git a/packages/taskflow-core/src/exec/events.ts b/packages/taskflow-core/src/exec/events.ts
index f3e309b..b3095db 100644
--- a/packages/taskflow-core/src/exec/events.ts
+++ b/packages/taskflow-core/src/exec/events.ts
@@ -2,26 +2,30 @@
* Event Log Schema — the future event-sourced kernel.
*
* This module defines the **append-only event log** schema that the batch-2
- * event-sourced driver (Q8) will write to. It is a superset of the current
- * `TraceEvent` shape (`../trace.ts`) plus a schema-version field.
+ * event-sourced driver (Q8) will write to. It is a **versioned extension** of
+ * the current `TraceEvent` shape (`../trace.ts`): `Event = TraceEvent & { v }`.
*
* **Why this exists**: The batch-2 event-sourced kernel needs an explicit,
* versioned event schema that (a) can subsume trace.ts, (b) carries a `v`
* field so future schema migrations are unambiguous on disk, and (c) provides
* a back-compat reader that upgrades legacy trace.jsonl lines on the fly.
*
+ * **Single source of truth**: `Event` / `EventDecision` / `EventKind` are
+ * type aliases over `TraceEvent` / `TraceDecision` — never a parallel copy —
+ * so adding a decision variant or field in `trace.ts` automatically lands
+ * here (no dual-schema drift).
+ *
* **F3 dissolution**: the 5 currently-unemitted trace decisions
* (gate-score, budget-hit, cache-hit, when-guard, unreplayable) dissolve here:
* the event-sourced driver (batch 2) will emit *every* decision by
* construction — this module is the log schema + version + back-compat.
* Once batch 2 lands, this module subsumes trace.ts entirely (see §0.2.0 Roadmap).
*
- * This is a **pure types+parser module** — zero runtime deps besides what
- * TypeScript/stdlib provides. No IO, no randomness, no Date.
+ * Pure types + parser: no IO, no randomness. Missing `ts` on upgrade uses `0`
+ * (not `Date.now()`) so upgrade is deterministic for replay.
*/
-import type { UsageStats } from "../usage.ts";
-import type { ScorerResult } from "../scorers.ts";
+import type { TraceEvent, TraceDecision } from "../trace.ts";
// ─────────────────────────────────────────────────────────────────────────────
// Schema version
@@ -31,7 +35,7 @@ import type { ScorerResult } from "../scorers.ts";
export const EVENT_SCHEMA_VERSION = 1;
// ─────────────────────────────────────────────────────────────────────────────
-// Event shape (superset of TraceEvent)
+// Event shape = TraceEvent + v (single source of truth)
// ─────────────────────────────────────────────────────────────────────────────
/**
@@ -39,84 +43,22 @@ export const EVENT_SCHEMA_VERSION = 1;
* A versioned Event carries a `v` field so a consumer can detect schema
* drift without inspecting fields.
*/
-export type EventKind = "phase-start" | "phase-end" | "subagent-call" | "decision";
+export type EventKind = TraceEvent["kind"];
-/** Discriminated record of a runtime decision. Structurally duplicates
- * TraceDecision so that this module has zero imports from trace.ts.
- * The two converge in batch 2 when trace.ts is subsumed. */
-export type EventDecision =
- | { type: "gate-verdict"; value: "pass" | "block"; reason?: string }
- | {
- type: "gate-score";
- target: string;
- results: ScorerResult[];
- combined: number;
- threshold?: number;
- verdict: "pass" | "block";
- evalPassed?: boolean;
- judgeOutput?: string;
- }
- | { type: "tournament-winner"; value: number; reason?: string }
- | { type: "budget-hit"; value: string; reason?: string }
- | { type: "cache-hit"; scope: "cross-run" | "run-only"; reason?: string }
- | { type: "when-guard"; expression: string; result: boolean }
- | {
- type: "unreplayable";
- reason: "context-sharing" | "inner-flow" | "context-files" | "unobservable-deps";
- };
+/** Discriminated record of a runtime decision — alias of TraceDecision. */
+export type EventDecision = TraceDecision;
/**
* A single event in the append-only event log. Structurally identical to
- * TraceEvent but with an explicit `v` field for schema versioning.
+ * {@link TraceEvent} with an explicit `v` field for schema versioning.
*
* The batch-2 event-sourced driver (Q8) will emit these directly; legacy
* trace.jsonl lines are upgraded via {@link upgradeTraceEvent}.
*/
-export interface Event {
+export type Event = TraceEvent & {
/** Schema version (see {@link EVENT_SCHEMA_VERSION}). */
v: number;
-
- /** Timestamp (Date.now()) at emit. */
- ts: number;
- /** Run identifier. */
- runId: string;
- /** Phase identifier. */
- phaseId: string;
- /** Discriminated kind. */
- kind: EventKind;
-
- // — subagent-call (the load-bearing record a replay consumes) —
- input?: {
- agent: string;
- model?: string;
- /** Resolved (interpolated) task text. */
- task: string;
- /** Resolved `context:` file content prepended to the task. */
- preRead?: string;
- /** Stable node path, e.g. "review", "review#item-3", "gate#judge". */
- nodePath: string;
- /** 0-based within runOne's retry loop. */
- attempt?: number;
- /** Index in the items array for `map` phases. */
- mapIndex?: number;
- /** 1-based variant number for `tournament` phases. */
- variantIndex?: number;
- };
-
- output?: {
- text: string;
- model?: string;
- usage?: UsageStats;
- stopReason?: string;
- };
-
- /** The runtime's own decision. */
- decision?: EventDecision;
-
- // — phase-end —
- status?: "done" | "failed" | "blocked" | "skipped" | "timedOut" | "pending" | "running";
- error?: string;
-}
+};
// ─────────────────────────────────────────────────────────────────────────────
// Back-compat: upgrading legacy TraceEvent → Event
@@ -132,11 +74,14 @@ export interface Event {
* the batch-2 event-sourced consumer can read both old and new records
* through the same `Event` interface. When the schema version bumps, this
* function is where the migration logic lives.
+ *
+ * **Determinism**: missing `ts` becomes `0`, never `Date.now()` — the same
+ * corrupt line upgrades to the same bytes every time (replay-safe).
*/
export function upgradeTraceEvent(old: Record): Event {
return {
v: EVENT_SCHEMA_VERSION,
- ts: typeof old.ts === "number" ? old.ts : Date.now(),
+ ts: typeof old.ts === "number" ? old.ts : 0,
runId: typeof old.runId === "string" ? old.runId : "",
phaseId: typeof old.phaseId === "string" ? old.phaseId : "",
kind: (["phase-start", "phase-end", "subagent-call", "decision"] as const).includes(
diff --git a/packages/taskflow-core/src/flowir/canonical-hash.ts b/packages/taskflow-core/src/flowir/canonical-hash.ts
index 163994b..6108010 100644
--- a/packages/taskflow-core/src/flowir/canonical-hash.ts
+++ b/packages/taskflow-core/src/flowir/canonical-hash.ts
@@ -40,14 +40,23 @@
* Two logically-equivalent FlowIRs (key reorder, whitespace, equivalent
* condition spelling) MUST canonicalize to the same string.
*
+ * ## Domain-separated digests
+ *
+ * Return values are prefixed:
+ * - `hashFlowIR` → `ir:<64-hex>`
+ * - `hashNode` → `node:<64-hex>`
+ *
+ * so IR-level and node-level digests never collide in a shared cache key
+ * namespace.
+ *
* ## Purity
*
* Zero runtime deps besides `node:crypto`. No IO, no `Date`, no randomness,
* never throws. The hash is synchronous (unlike `flowDefHash`).
*
- * **Not yet wired** into runtime/cache — `usedFallbackHash` is untouched and
- * `canonicalizeFlowIR`/`hashFlowIR`/`hashNode` are not referenced by any
- * barrel or the runtime. Wiring happens in batch 2.
+ * Exported from the FlowIR barrel (`./index.ts`) for consumers; **not yet
+ * wired** into runtime/cache — `usedFallbackHash` is untouched. Wiring
+ * happens in batch 2.
*
* @see ./schema.ts for the canonical FlowIR type contract.
* @see ./cond.ts for condition normalization (`normalizeCond`).
@@ -179,23 +188,25 @@ export function canonicalizeNode(node: FlowIRNode): string {
}
// ---------------------------------------------------------------------------
-// Content-addressed hashing (node:crypto SHA-256, full 64-hex digest)
+// Content-addressed hashing (node:crypto SHA-256, domain-prefixed)
// ---------------------------------------------------------------------------
/**
- * SHA-256 (hex, 64 lowercase chars) over {@link canonicalizeFlowIR}. The
- * genuine content-addressed hash of a compiled FlowIR. Synchronous (uses
- * `node:crypto`, unlike the async Web-Crypto `flowDefHash`). Never throws.
+ * Domain-separated content hash of a compiled FlowIR.
+ * Returns `ir:<64 lowercase hex SHA-256 chars>` over {@link canonicalizeFlowIR}.
+ * Synchronous. Never throws.
*/
export function hashFlowIR(ir: FlowIR): string {
- return createHash("sha256").update(canonicalizeFlowIR(ir), "utf8").digest("hex");
+ const hex = createHash("sha256").update(canonicalizeFlowIR(ir), "utf8").digest("hex");
+ return `ir:${hex}`;
}
/**
- * SHA-256 (hex, 64 lowercase chars) over {@link canonicalizeNode} — a
- * per-node content hash usable as a phase fingerprint independent of surface
- * formatting. Synchronous. Never throws.
+ * Domain-separated per-node content hash.
+ * Returns `node:<64 lowercase hex SHA-256 chars>` over {@link canonicalizeNode}.
+ * Synchronous. Never throws.
*/
export function hashNode(node: FlowIRNode): string {
- return createHash("sha256").update(canonicalizeNode(node), "utf8").digest("hex");
+ const hex = createHash("sha256").update(canonicalizeNode(node), "utf8").digest("hex");
+ return `node:${hex}`;
}
diff --git a/packages/taskflow-core/src/flowir/cond.ts b/packages/taskflow-core/src/flowir/cond.ts
index 5795e85..501242c 100644
--- a/packages/taskflow-core/src/flowir/cond.ts
+++ b/packages/taskflow-core/src/flowir/cond.ts
@@ -29,10 +29,9 @@
* preserved), not a full AST rewrite: for valid expressions whitespace only
* separates tokens, so removing it (after protecting quoted literals) yields
* a stable concatenation that is invariant under the cosmetic differences
- * hashing cares about. Reference extraction uses a `{steps|args|env}.…}`
- * scan — the same pattern as `collectRefs` in `schema.ts` — rather than
- * re-parsing, so it also catches `env.*` refs the interpolation resolver
- * does not bind.
+ * hashing cares about. Reference extraction runs on the **literal-protected**
+ * surface so `{steps.*}` text inside string literals is never treated as a
+ * real dependency.
*
* Pure module: no IO, no `Date`, no randomness, never throws.
*
@@ -56,7 +55,8 @@ import { tryEvaluateCondition, type InterpolationContext } from "../interpolate.
* produce byte-identical `canonical` strings.
* - `refs` — the `steps.…` / `args.…` / `env.…` references
* the expression reads (in first-occurrence order, de-duplicated). Empty
- * when the expression failed to parse (fail-open).
+ * when the expression failed to parse (fail-open). Placeholders that only
+ * appear inside string literals are **not** counted as refs.
*/
export interface NormalizedCond {
source: string;
@@ -76,12 +76,18 @@ export interface NormalizedCond {
*/
const REF_RE = /\{\s*(steps|args|env)\.([a-zA-Z0-9_.-]+?)\s*\}/g;
-function extractRefs(expr: string): string[] {
+/**
+ * Extract refs from expression text that has already had string literals
+ * replaced by placeholders (see {@link protectLiterals}). Scanning the
+ * protected surface prevents false-positive refs for placeholder-looking
+ * text that only appears inside quotes.
+ */
+function extractRefsFromProtected(protectedText: string): string[] {
const seen = new Set();
const refs: string[] = [];
REF_RE.lastIndex = 0;
let m: RegExpExecArray | null;
- while ((m = REF_RE.exec(expr)) !== null) {
+ while ((m = REF_RE.exec(protectedText)) !== null) {
const ref = `${m[1]}.${m[2]}`;
if (!seen.has(ref)) {
seen.add(ref);
@@ -96,11 +102,12 @@ function extractRefs(expr: string): string[] {
// ---------------------------------------------------------------------------
/**
- * Protect quoted string literals (single or double quoted, with `\X` →
- * literal-`X` escaping matching the interpolate tokenizer) by replacing each
- * with a whitespace-free `\u0000\u0000` placeholder. This preserves
- * their contents (including internal spaces) through whitespace removal and
- * prevents their inner parens/operators from confusing the paren stripper.
+ * Protect quoted string literals (single or double quoted, with `\` escapes
+ * preserved byte-for-byte) by replacing each with a whitespace-free
+ * `\u0000\u0000` placeholder. This preserves their contents
+ * (including internal spaces and escape sequences) through whitespace
+ * removal and prevents their inner parens/operators/placeholder-looking
+ * text from confusing the paren stripper or ref extractor.
* Returns the protected string and the list of original literals.
*/
function protectLiterals(expr: string): { text: string; literals: string[] } {
@@ -112,22 +119,25 @@ function protectLiterals(expr: string): { text: string; literals: string[] } {
const c = expr[i];
if (c === '"' || c === "'") {
let j = i + 1;
- let val = "";
+ let val = c; // include opening quote
while (j < n) {
if (expr[j] === "\\" && j + 1 < n) {
- val += expr[j + 1];
+ // Preserve the escape sequence intact (do not drop `\`).
+ val += expr[j] + expr[j + 1];
j += 2;
} else if (expr[j] === c) {
+ val += c; // closing quote
+ j++;
break;
} else {
val += expr[j];
j++;
}
}
- const closed = j < n;
- literals.push(c + val + (closed ? c : ""));
+ // Unterminated string: keep whatever we scanned (incl. opening quote).
+ literals.push(val);
out += `\u0000${literals.length - 1}\u0000`;
- i = closed ? j + 1 : j;
+ i = j;
} else {
out += c;
i++;
@@ -206,7 +216,8 @@ function canonicalize(expr: string): string {
*
* On success, `canonical` is the whitespace/paren-normalized surface form
* (stable for hashing) and `refs` are the `steps.*` / `args.*` / `env.*`
- * references the expression reads (for replay rebinding).
+ * references the expression reads (for replay rebinding). Placeholders that
+ * only appear inside string literals are excluded from `refs`.
*/
export function normalizeCond(expr: string): NormalizedCond {
const source = typeof expr === "string" ? expr : expr == null ? "" : String(expr);
@@ -224,9 +235,10 @@ export function normalizeCond(expr: string): NormalizedCond {
return { source, canonical: trimmed, refs: [] };
}
+ const { text: protectedText } = protectLiterals(trimmed);
return {
source,
canonical: canonicalize(trimmed),
- refs: extractRefs(trimmed),
+ refs: extractRefsFromProtected(protectedText),
};
}
diff --git a/packages/taskflow-core/src/flowir/schema.ts b/packages/taskflow-core/src/flowir/schema.ts
index 811385e..b8015eb 100644
--- a/packages/taskflow-core/src/flowir/schema.ts
+++ b/packages/taskflow-core/src/flowir/schema.ts
@@ -32,17 +32,21 @@
* @see docs/internal/rfc-flowir-compilation.md
*/
-import { Type, type Static } from "typebox";
+import { Type } from "typebox";
+import { StringEnum } from "../typebox-helpers.ts";
+import { PHASE_TYPES, type PhaseType } from "../schema.ts";
// ---------------------------------------------------------------------------
// FlowIRNodeKind — the 10 native phase kinds (closed literal union)
// ---------------------------------------------------------------------------
/**
- * The closed set of pi-taskflow phase kinds, projected 1:1 from
- * `PHASE_TYPES` (schema.ts). This is the canonical `FlowIRNode.kind`
- * vocabulary. `translate.ts` sets `kind = phase.type ?? "agent"`, so every
- * node it emits is a member of this union.
+ * The closed set of pi-taskflow phase kinds, derived 1:1 from the exported
+ * {@link PHASE_TYPES} in `../schema.ts` (single source of truth). This is the
+ * canonical `FlowIRNode.kind` vocabulary. `translate.ts` sets
+ * `kind = phase.type ?? "agent"`, so every node it emits is a member of this
+ * union. Adding a phase kind requires updating `PHASE_TYPES` only — FlowIR
+ * follows automatically.
*
* | kind | purpose |
* |-------------|----------------------------------------------------------|
@@ -57,22 +61,11 @@ import { Type, type Static } from "typebox";
* | `tournament`| N competing variants + judge picks best / aggregates |
* | `script` | run a shell command (no LLM, zero tokens) |
*/
-export const FlowIRNodeKind = Type.Union(
- [
- Type.Literal("agent"),
- Type.Literal("parallel"),
- Type.Literal("map"),
- Type.Literal("gate"),
- Type.Literal("reduce"),
- Type.Literal("approval"),
- Type.Literal("flow"),
- Type.Literal("loop"),
- Type.Literal("tournament"),
- Type.Literal("script"),
- ],
- { description: "The 10 native pi-taskflow phase kinds (1:1 projection of PHASE_TYPES)" },
-);
-export type FlowIRNodeKind = Static;
+export const FlowIRNodeKind = StringEnum(PHASE_TYPES, {
+ description: "The 10 native pi-taskflow phase kinds (1:1 projection of PHASE_TYPES)",
+});
+/** @see PHASE_TYPES — same closed set as the DSL. */
+export type FlowIRNodeKind = PhaseType;
// ---------------------------------------------------------------------------
// FlowIRNode — canonical node contract
@@ -281,18 +274,7 @@ export const FlowIRSchema = Type.Object(
// ---------------------------------------------------------------------------
/** The 10 valid phase kinds, for O(1) membership checks without TypeBox. */
-const VALID_KINDS: ReadonlySet = new Set([
- "agent",
- "parallel",
- "map",
- "gate",
- "reduce",
- "approval",
- "flow",
- "loop",
- "tournament",
- "script",
-]);
+const VALID_KINDS: ReadonlySet = new Set(PHASE_TYPES);
/**
* Narrow an `unknown` value to a {@link FlowIRNode}. Pure, synchronous,
diff --git a/packages/taskflow-core/src/rates.ts b/packages/taskflow-core/src/rates.ts
index 38b55dc..d108489 100644
--- a/packages/taskflow-core/src/rates.ts
+++ b/packages/taskflow-core/src/rates.ts
@@ -168,14 +168,32 @@ export function resolveRate(
/**
* Compute estimated USD cost from token usage.
*
- * Uses the resolved model rate (via `resolveRate`) and applies per-token pricing:
- * cost = (input * inputRate + output * outputRate +
+ * ## Cache accounting contract
+ *
+ * Hosts disagree on whether `usage.input` already includes cached tokens:
+ * - **Codex**: `input_tokens` includes `cached_input_tokens` (overlap).
+ * - **Claude / OpenCode (typical)**: `input` is non-cached; `cacheRead` is
+ * disjoint and additional.
+ *
+ * Heuristic (never double-bill, never under-bill the common cases):
+ * - If `0 < cacheRead <= input`, treat cache as a **subset** of input
+ * (Codex-style): bill `(input - cacheRead)` at the input rate and
+ * `cacheRead` at the cache-read rate.
+ * - Otherwise treat input and cacheRead as **disjoint**: bill full `input`
+ * at the input rate plus `cacheRead` at the cache-read rate.
+ *
+ * Cache-write tokens are always billed separately when a write rate exists
+ * (they are never part of `input`).
+ *
+ * Formula (known rate):
+ * nonCachedInput = (cacheRead > 0 && cacheRead <= input)
+ * ? input - cacheRead : input
+ * cost = (nonCachedInput * inputRate + output * outputRate +
* cacheRead * cacheReadRate + cacheWrite * cacheWriteRate) / 1_000_000
*
* If the model cannot be resolved, falls back to
* `FLAT_FALLBACK_PER_TOKEN * (input + output)`. Cache tokens are *not* counted
- * in the fallback (they are part of the input budget, and double-counting with
- * the flat fallback would over-estimate).
+ * in the fallback (avoids double-count when input already includes them).
*
* Pure function — no I/O, no side effects. Never throws.
*
@@ -194,7 +212,14 @@ export function estimateCost(
return FLAT_FALLBACK_PER_TOKEN * (usage.input + usage.output);
}
- const inputCost = (usage.input / 1_000_000) * rate.inputPer1M;
+ // Overlap heuristic: when cacheRead is a positive subset of input, peel it
+ // out so it is not billed at the full input rate (Codex-style hosts).
+ const nonCachedInput =
+ usage.cacheRead > 0 && usage.cacheRead <= usage.input
+ ? usage.input - usage.cacheRead
+ : usage.input;
+
+ const inputCost = (nonCachedInput / 1_000_000) * rate.inputPer1M;
const outputCost = (usage.output / 1_000_000) * rate.outputPer1M;
let cacheCost = 0;
if (rate.cacheReadPer1M !== undefined && usage.cacheRead > 0) {
diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts
index 1a7fb4f..5ca25ea 100644
--- a/packages/taskflow-core/src/schema.ts
+++ b/packages/taskflow-core/src/schema.ts
@@ -16,8 +16,9 @@ import { WORKSPACE_KEYWORDS } from "./workspace.ts";
// Phase types
// ---------------------------------------------------------------------------
-const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow", "loop", "tournament", "script"] as const;
-type PhaseType = (typeof PHASE_TYPES)[number];
+/** Closed set of native phase kinds — single source of truth for DSL + FlowIR. */
+export const PHASE_TYPES = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow", "loop", "tournament", "script"] as const;
+export type PhaseType = (typeof PHASE_TYPES)[number];
/** Loop iteration bounds. Authors may lower the max; the hard cap is a runaway guard. */
export const LOOP_DEFAULT_MAX_ITERATIONS = 10;
diff --git a/packages/taskflow-core/test/exec-events.test.ts b/packages/taskflow-core/test/exec-events.test.ts
index 660fba4..919bec7 100644
--- a/packages/taskflow-core/test/exec-events.test.ts
+++ b/packages/taskflow-core/test/exec-events.test.ts
@@ -8,6 +8,19 @@ import {
upgradeTraceEvent,
readEvents,
} from "../src/exec/events.ts";
+import type { TraceEvent, TraceDecision } from "../src/trace.ts";
+
+// Compile-time drift guards: Event is TraceEvent + v; EventDecision is TraceDecision.
+type _EventExtendsTrace = Event extends TraceEvent & { v: number } ? true : false;
+type _DecisionAlias = EventDecision extends TraceDecision
+ ? TraceDecision extends EventDecision
+ ? true
+ : false
+ : false;
+const _eventDriftOk: _EventExtendsTrace = true;
+const _decisionDriftOk: _DecisionAlias = true;
+void _eventDriftOk;
+void _decisionDriftOk;
// ─────────────────────────────────────────────────────────────────────────────
// Constants
@@ -69,8 +82,8 @@ test("upgradeTraceEvent: fills defaults for missing fields", () => {
const upgraded = upgradeTraceEvent({});
// v is always stamped
assert.equal(upgraded.v, EVENT_SCHEMA_VERSION);
- // ts falls back to a number (Date.now() approximate)
- assert.equal(typeof upgraded.ts, "number");
+ // ts falls back to 0 (deterministic — never Date.now())
+ assert.equal(upgraded.ts, 0);
// runId/phaseId fall back to ""
assert.equal(upgraded.runId, "");
assert.equal(upgraded.phaseId, "");
@@ -84,6 +97,14 @@ test("upgradeTraceEvent: fills defaults for missing fields", () => {
assert.equal(upgraded.error, undefined);
});
+test("upgradeTraceEvent: missing ts is deterministic across calls", () => {
+ const a = upgradeTraceEvent({ runId: "r", phaseId: "p", kind: "phase-start" });
+ const b = upgradeTraceEvent({ runId: "r", phaseId: "p", kind: "phase-start" });
+ assert.equal(a.ts, 0);
+ assert.equal(b.ts, 0);
+ assert.deepEqual(a, b);
+});
+
// ─────────────────────────────────────────────────────────────────────────────
// readEvents — round-trip
// ─────────────────────────────────────────────────────────────────────────────
diff --git a/packages/taskflow-core/test/flowir-canonical-hash.test.ts b/packages/taskflow-core/test/flowir-canonical-hash.test.ts
index 242bae3..3dcc197 100644
--- a/packages/taskflow-core/test/flowir-canonical-hash.test.ts
+++ b/packages/taskflow-core/test/flowir-canonical-hash.test.ts
@@ -30,14 +30,24 @@ function referenceIR(): FlowIR {
]);
}
-test("hashFlowIR: returns 64 lowercase hex chars", () => {
+test("hashFlowIR: returns ir:<64 lowercase hex chars>", () => {
const h = hashFlowIR(referenceIR());
- assert.match(h, /^[0-9a-f]{64}$/);
+ assert.match(h, /^ir:[0-9a-f]{64}$/);
});
-test("hashNode: returns 64 lowercase hex chars", () => {
+test("hashNode: returns node:<64 lowercase hex chars>", () => {
const h = hashNode(node("a"));
- assert.match(h, /^[0-9a-f]{64}$/);
+ assert.match(h, /^node:[0-9a-f]{64}$/);
+});
+
+test("hashFlowIR / hashNode: domain prefixes differ (no shared-namespace collision)", () => {
+ const n = node("solo", { task: "t" });
+ const irHash = hashFlowIR(ir([n]));
+ const nodeHash = hashNode(n);
+ assert.ok(irHash.startsWith("ir:"));
+ assert.ok(nodeHash.startsWith("node:"));
+ // Even if the digests somehow matched, prefixes keep the keys distinct.
+ assert.notEqual(irHash, nodeHash);
});
// ---------------------------------------------------------------------------
diff --git a/packages/taskflow-core/test/flowir-cond.test.ts b/packages/taskflow-core/test/flowir-cond.test.ts
index 7f3d3f7..b03a3a5 100644
--- a/packages/taskflow-core/test/flowir-cond.test.ts
+++ b/packages/taskflow-core/test/flowir-cond.test.ts
@@ -141,6 +141,26 @@ test("normalizeCond: no refs in a literal-only expression", () => {
assert.deepEqual(normalizeCond("true && false").refs, []);
});
+test("normalizeCond: placeholders inside string literals are NOT refs", () => {
+ // The right-hand side is a string that looks like a placeholder — not a dependency.
+ const n = normalizeCond('{args.x} == "{steps.y.output}"');
+ assert.deepEqual(n.refs, ["args.x"]);
+ assert.ok(n.canonical.includes('"{steps.y.output}"'));
+});
+
+test("normalizeCond: single-quoted literal placeholders are NOT refs", () => {
+ const n = normalizeCond("{args.label} == '{steps.fake.output}'");
+ assert.deepEqual(n.refs, ["args.label"]);
+});
+
+test("normalizeCond: escaped quotes inside strings are preserved", () => {
+ // Backslash-escaped quote must survive protect/restore byte-for-byte.
+ const raw = String.raw`{args.x} == "say \"hi\""`;
+ const n = normalizeCond(raw);
+ assert.equal(n.canonical, String.raw`{args.x}=="say \"hi\""`);
+ assert.deepEqual(n.refs, ["args.x"]);
+});
+
// ---------------------------------------------------------------------------
// Fail-open (never throws; malformed → canonical = source.trim(), refs = [])
// ---------------------------------------------------------------------------
diff --git a/packages/taskflow-core/test/flowir-schema.test.ts b/packages/taskflow-core/test/flowir-schema.test.ts
index 10f781e..e13dd9f 100644
--- a/packages/taskflow-core/test/flowir-schema.test.ts
+++ b/packages/taskflow-core/test/flowir-schema.test.ts
@@ -8,6 +8,7 @@ import {
isFlowIRNode,
assertFlowIR,
} from "../src/flowir/schema.ts";
+import { PHASE_TYPES } from "../src/schema.ts";
import { Value } from "typebox/value";
import type { FlowIR, FlowIRNode } from "../src/flowir/schema.ts";
@@ -31,13 +32,26 @@ function ir(nodes: FlowIRNode[], overrides?: Partial): FlowIR {
// FlowIRNodeKind — closed literal union of the 10 phase kinds
// ---------------------------------------------------------------------------
-test("FlowIRNodeKind: the 10 phase kinds are all valid members", () => {
- const kinds = ["agent", "parallel", "map", "gate", "reduce", "approval", "flow", "loop", "tournament", "script"];
- for (const k of kinds) {
- // Each kind is a member of the union schema.
+test("FlowIRNodeKind: tracks PHASE_TYPES (single source of truth, no drift)", () => {
+ assert.equal(PHASE_TYPES.length, 10);
+ for (const k of PHASE_TYPES) {
+ // Each DSL phase kind is a member of the FlowIR kind schema.
const decoded = Value.Decode(FlowIRNodeKind, k);
assert.equal(decoded, k);
}
+ // Same closed set — if someone adds a kind to only one side, this fails.
+ assert.deepEqual([...PHASE_TYPES], [
+ "agent",
+ "parallel",
+ "map",
+ "gate",
+ "reduce",
+ "approval",
+ "flow",
+ "loop",
+ "tournament",
+ "script",
+ ]);
});
test("FlowIRNodeKind: rejects an unknown phase kind", () => {
diff --git a/packages/taskflow-core/test/rates.test.ts b/packages/taskflow-core/test/rates.test.ts
index 0511298..5fd8ff7 100644
--- a/packages/taskflow-core/test/rates.test.ts
+++ b/packages/taskflow-core/test/rates.test.ts
@@ -107,9 +107,11 @@ test("estimateCost: fallback uses only input+output (no cache double-count)", ()
// estimateCost — cache contribution
// ---------------------------------------------------------------------------
-test("estimateCost: cacheRead tokens reduce cost (cached input cheaper)", () => {
+test("estimateCost: cacheRead tokens (disjoint, cacheRead > input) billed separately", () => {
+ // Claude-style: input is non-cached only; cacheRead is additional and larger.
const usage: UsageStats = { input: 1000, output: 500, cacheRead: 4000, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
// claude-sonnet-4: $3/1M input, $15/1M output, $0.30/1M cacheRead
+ // cacheRead > input → disjoint: bill full input + cacheRead
// input cost = 1000/1e6 * 3.00 = 0.003
// cache cost = 4000/1e6 * 0.30 = 0.0012
// output cost = 500/1e6 * 15.00 = 0.0075
@@ -119,6 +121,7 @@ test("estimateCost: cacheRead tokens reduce cost (cached input cheaper)", () =>
});
test("estimateCost: cacheWrite tokens contribute when rate has cacheWritePer1M", () => {
+ // cacheRead (2000) > input (1000) → disjoint path
const usage: UsageStats = { input: 1000, output: 500, cacheRead: 2000, cacheWrite: 3000, cost: 0, contextTokens: 0, turns: 1 };
// claude-sonnet-4: $3/1M input, $15/1M output, $0.30/1M cacheRead, $3.75/1M cacheWrite
// input cost = 1000/1e6 * 3.00 = 0.003
@@ -130,6 +133,21 @@ test("estimateCost: cacheWrite tokens contribute when rate has cacheWritePer1M",
assert.equal(cost, 0.02235);
});
+test("estimateCost: Codex-style overlap (cacheRead ⊆ input) does not double-bill", () => {
+ // Codex: input_tokens includes cached_input_tokens.
+ // input=5000, cacheRead=4000 → nonCached=1000 billed at input rate.
+ const usage: UsageStats = { input: 5000, output: 500, cacheRead: 4000, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 1 };
+ // claude-sonnet-4 rates as stand-in: $3/1M input, $15/1M output, $0.30/1M cacheRead
+ // nonCached input = 1000/1e6 * 3.00 = 0.003
+ // cacheRead = 4000/1e6 * 0.30 = 0.0012
+ // output = 500/1e6 * 15.00 = 0.0075
+ // total = 0.0117
+ const cost = estimateCost(usage, "claude-sonnet-4");
+ assert.ok(Math.abs(cost - 0.0117) < 1e-10, `expected ~0.0117, got ${cost}`);
+ // Contrast: naive full-input+cache would be 0.015 + 0.0012 + 0.0075 = 0.0237
+ assert.ok(cost < 0.02, "must not double-bill overlapping input");
+});
+
test("estimateCost: rate with no cache fields ignores cache tokens", () => {
const usage: UsageStats = { input: 1000, output: 500, cacheRead: 5000, cacheWrite: 5000, cost: 0, contextTokens: 0, turns: 1 };
// llama-3 has no cacheReadPer1M or cacheWritePer1M — cache tokens ignored
From eaae3260366ff775b2dead37eb280618c2ae590f Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 12:48:53 +0800
Subject: [PATCH 12/51] feat(grok): add Grok Build host adapter and plugin
package
Add grokSubagentRunner (grok -p streaming-json) in taskflow-hosts and a
new grok-taskflow delivery package with MCP bin, .grok-plugin manifest,
skills, and marketplace index. Wire monorepo scripts, skill build host
target, and AGENTS.md.
---
.grok-plugin/marketplace.json | 21 +
AGENTS.md | 28 +-
package.json | 8 +-
.../plugin/skills/taskflow/configuration.md | 1 +
.../plugin/skills/taskflow/configuration.md | 1 +
packages/grok-taskflow/package.json | 62 ++
.../plugin/.grok-plugin/plugin.json | 23 +
packages/grok-taskflow/plugin/.mcp.json | 9 +
.../plugin/assets/taskflow-small.svg | 14 +
.../grok-taskflow/plugin/assets/taskflow.svg | 17 +
.../plugin/skills/taskflow/SKILL.md | 590 ++++++++++++++++++
.../plugin/skills/taskflow/advanced.md | 86 +++
.../plugin/skills/taskflow/configuration.md | 417 +++++++++++++
.../plugin/skills/taskflow/library.md | 105 ++++
.../plugin/skills/taskflow/patterns.md | 291 +++++++++
packages/grok-taskflow/src/index.ts | 13 +
packages/grok-taskflow/src/mcp/bin.ts | 31 +
packages/grok-taskflow/src/mcp/server.ts | 31 +
packages/grok-taskflow/test/e2e-grok-mcp.mts | 72 +++
.../grok-taskflow/test/mcp-server.test.ts | 111 ++++
packages/grok-taskflow/tsconfig.build.json | 13 +
.../plugin/skills/taskflow/configuration.md | 1 +
.../skills/taskflow/configuration.md | 1 +
.../pi-taskflow/test/skills-build.test.ts | 28 +-
packages/taskflow-hosts/package.json | 9 +-
packages/taskflow-hosts/src/grok-runner.ts | 258 ++++++++
packages/taskflow-hosts/src/index.ts | 12 +-
.../taskflow-hosts/test/grok-args.test.ts | 121 ++++
.../taskflow-hosts/test/grok-runner.test.ts | 66 ++
pnpm-lock.yaml | 12 +
pnpm-workspace.yaml | 1 +
scripts/build-skills.mjs | 5 +-
scripts/copy-readme.mjs | 10 +-
skills-src/taskflow/advanced.md | 4 +-
skills-src/taskflow/configuration.md | 13 +-
skills-src/taskflow/core.md | 28 +-
skills-src/taskflow/entry.grok.md | 25 +
skills-src/taskflow/library.md | 16 +-
skills-src/taskflow/patterns.md | 4 +-
39 files changed, 2505 insertions(+), 53 deletions(-)
create mode 100644 .grok-plugin/marketplace.json
create mode 100644 packages/grok-taskflow/package.json
create mode 100644 packages/grok-taskflow/plugin/.grok-plugin/plugin.json
create mode 100644 packages/grok-taskflow/plugin/.mcp.json
create mode 100644 packages/grok-taskflow/plugin/assets/taskflow-small.svg
create mode 100644 packages/grok-taskflow/plugin/assets/taskflow.svg
create mode 100644 packages/grok-taskflow/plugin/skills/taskflow/SKILL.md
create mode 100644 packages/grok-taskflow/plugin/skills/taskflow/advanced.md
create mode 100644 packages/grok-taskflow/plugin/skills/taskflow/configuration.md
create mode 100644 packages/grok-taskflow/plugin/skills/taskflow/library.md
create mode 100644 packages/grok-taskflow/plugin/skills/taskflow/patterns.md
create mode 100644 packages/grok-taskflow/src/index.ts
create mode 100644 packages/grok-taskflow/src/mcp/bin.ts
create mode 100644 packages/grok-taskflow/src/mcp/server.ts
create mode 100644 packages/grok-taskflow/test/e2e-grok-mcp.mts
create mode 100644 packages/grok-taskflow/test/mcp-server.test.ts
create mode 100644 packages/grok-taskflow/tsconfig.build.json
create mode 100644 packages/taskflow-hosts/src/grok-runner.ts
create mode 100644 packages/taskflow-hosts/test/grok-args.test.ts
create mode 100644 packages/taskflow-hosts/test/grok-runner.test.ts
create mode 100644 skills-src/taskflow/entry.grok.md
diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json
new file mode 100644
index 0000000..c2a7088
--- /dev/null
+++ b/.grok-plugin/marketplace.json
@@ -0,0 +1,21 @@
+{
+ "name": "taskflow",
+ "description": "Declarative, verifiable DAG orchestration for coding agents. Taskflow lets you define multi-phase subagent workflows (fan-out, gates, loops, tournaments, approvals, sub-flow composition) as JSON, verify them before they run, and keep intermediate transcripts out of your context — exposed to Grok Build as the taskflow_* MCP tools plus a routing skill.",
+ "owner": {
+ "name": "heggria",
+ "url": "https://github.com/heggria"
+ },
+ "plugins": [
+ {
+ "name": "taskflow",
+ "description": "Multi-phase subagent orchestration for Grok Build: the taskflow_* MCP tools (run/list/show/verify/compile/peek) plus a skill that routes multi-phase or fan-out work to them. The MCP server runs via npx (grok-taskflow) — no separate global install required.",
+ "source": {
+ "type": "local",
+ "path": "./packages/grok-taskflow/plugin"
+ },
+ "category": "development",
+ "homepage": "https://github.com/heggria/taskflow",
+ "keywords": ["taskflow", "taskflow orchestration", "taskflow dag"]
+ }
+ ]
+}
diff --git a/AGENTS.md b/AGENTS.md
index 4fe6100..bcea127 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -8,8 +8,8 @@ taskflow is a **declarative DAG orchestration runtime** for coding agents — it
**Language:** TypeScript (ES2022, ESM, `--experimental-strip-types` for direct execution in dev)\
**Runtime:** Node.js ≥ 22.19 (uses `fs.globSync`, `Atomics.wait`)\
-**Dependencies:** Zero runtime deps. The Pi adapter (`pi-taskflow`) peer-depends on `@earendil-works/pi-{agent-core,ai,coding-agent,tui}`; the host-neutral MCP server (`taskflow-mcp-core`) and the three MCP host adapters (`codex-taskflow`, `claude-taskflow`, `opencode-taskflow`) all depend on `taskflow-core` (the adapters also depend on `taskflow-mcp-core`). Everything depends on `typebox`.\
-**Layout:** pnpm-workspace monorepo of seven published packages — `taskflow-core` (host-neutral engine), `taskflow-mcp-core` (the host-neutral MCP server + DAG renderer, depends on core), `taskflow-hosts` (shared host-runner collection: the codex/claude/opencode SubagentRunner impls + argv builders + event-stream parsers, depends on core), `pi-taskflow` (Pi extension adapter, installed via `pi install npm:pi-taskflow`), `codex-taskflow` (Codex MCP server + bin + a `plugin/` scaffold installable via `codex plugin add`; re-exports the runner from `taskflow-hosts`), `claude-taskflow` (Claude Code MCP server + bin + a `plugin/` scaffold installable via `claude plugin install`; re-exports the runner from `taskflow-hosts`), and `opencode-taskflow` (OpenCode MCP server + bin + an `opencode.json` config scaffold; re-exports the runner from `taskflow-hosts`).\
+**Dependencies:** Zero runtime deps. The Pi adapter (`pi-taskflow`) peer-depends on `@earendil-works/pi-{agent-core,ai,coding-agent,tui}`; the host-neutral MCP server (`taskflow-mcp-core`) and the four MCP host adapters (`codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, `grok-taskflow`) all depend on `taskflow-core` (the adapters also depend on `taskflow-mcp-core`). Everything depends on `typebox`.\
+**Layout:** pnpm-workspace monorepo of eight published packages — `taskflow-core` (host-neutral engine), `taskflow-mcp-core` (the host-neutral MCP server + DAG renderer, depends on core), `taskflow-hosts` (shared host-runner collection: the codex/claude/opencode/grok SubagentRunner impls + argv builders + event-stream parsers, depends on core), `pi-taskflow` (Pi extension adapter, installed via `pi install npm:pi-taskflow`), `codex-taskflow` (Codex MCP server + bin + a `plugin/` scaffold installable via `codex plugin add`; re-exports the runner from `taskflow-hosts`), `claude-taskflow` (Claude Code MCP server + bin + a `plugin/` scaffold installable via `claude plugin install`; re-exports the runner from `taskflow-hosts`), and `opencode-taskflow` (OpenCode MCP server + bin + an `opencode.json` config scaffold; re-exports the runner from `taskflow-hosts`), and `grok-taskflow` (Grok Build MCP server + bin + a `plugin/` scaffold installable via `grok plugin install`; re-exports the runner from `taskflow-hosts`).\
**Build:** each package compiles to `dist/*.js` + `.d.ts` (`tsc`); published packages ship `dist` (Node refuses to type-strip `.ts` under `node_modules`). Dev resolves the TypeScript sources directly via a `development` export condition — no build needed to typecheck or test.
## Architecture
@@ -79,7 +79,7 @@ packages/
│ ├─ skills/taskflow/ ← GENERATED per-host skill files (do not edit; see skills-src/)
│ └─ assets/ ← plugin icons (taskflow.svg, taskflow-small.svg)
└─ test/ ← mcp-server unit test + .mts e2e scripts
-└─ opencode-taskflow/ ← OpenCode DELIVERY package (depends on taskflow-hosts + taskflow-mcp-core)
+├─ opencode-taskflow/ ← OpenCode DELIVERY package (depends on taskflow-hosts + taskflow-mcp-core)
├─ src/
│ ├─ index.ts ← re-exports the opencode runner from taskflow-hosts (back-compat public surface)
│ └─ mcp/ ← thin bind: server.ts re-exports core's MCP server bound to opencodeSubagentRunner; bin.ts
@@ -88,16 +88,27 @@ packages/
│ ├─ skills/taskflow/ ← GENERATED per-host skill files (do not edit; see skills-src/)
│ └─ assets/ ← icons (taskflow.svg, taskflow-small.svg)
└─ test/ ← opencode-adapter unit tests + .mts e2e scripts
+└─ grok-taskflow/ ← Grok Build DELIVERY package (depends on taskflow-hosts + taskflow-mcp-core)
+ ├─ src/
+ │ ├─ index.ts ← re-exports the grok runner from taskflow-hosts (back-compat public surface)
+ │ └─ mcp/ ← thin bind: server.ts re-exports core's MCP server bound to grokSubagentRunner; bin.ts
+ ├─ plugin/ ← Grok Build plugin scaffold (`grok plugin install … --trust`)
+ │ ├─ .grok-plugin/plugin.json ← plugin manifest
+ │ ├─ .mcp.json ← declares the taskflow MCP server via `npx grok-taskflow-mcp`
+ │ ├─ skills/taskflow/ ← GENERATED per-host skill files (do not edit; see skills-src/)
+ │ └─ assets/ ← icons (taskflow.svg, taskflow-small.svg)
+ └─ test/ ← mcp-server unit test + .mts e2e scripts
.claude-plugin/ ← marketplace.json (repo-root; shared by both `codex plugin marketplace add`
and `claude plugin marketplace add heggria/taskflow`; lists the
- `taskflow` [codex] and `claude-taskflow` [claude] plugins. OpenCode has
- no marketplace — it registers the MCP server via opencode.json)
+ `taskflow` [codex] and `claude-taskflow` [claude] plugins. Grok has
+ `.grok-plugin/marketplace.json` for `grok plugin marketplace add`.
+ OpenCode has no marketplace — it registers the MCP server via opencode.json)
skills-src/taskflow/ ← SINGLE SOURCE for all hosts' skills: entry.pi.md + entry.codex.md +
entry.claude.md + entry.opencode.md (frontmatter + host binding) +
core.md/patterns.md/advanced.md/configuration.md (shared body with
- blocks; the host field is a
+ blocks; the host field is a
comma-list, e.g. ). Compiled by
scripts/build-skills.mjs (pnpm run build:skills); drift-guarded by
packages/pi-taskflow/test/skills-build.test.ts.
@@ -150,6 +161,7 @@ pnpm run test:pi # pi-adapter tests only
pnpm run test:codex # codex-adapter tests only
pnpm run test:claude # claude-adapter tests only
pnpm run test:opencode # opencode-adapter tests only
+pnpm run test:grok # grok-adapter tests only
pnpm run build # emit dist/*.js + .d.ts for all seven packages
pnpm run test:e2e-codex # codex executor e2e (needs live codex + model access)
pnpm run test:e2e-codex-mcp # codex MCP stdio e2e (src)
@@ -158,6 +170,7 @@ pnpm run test:e2e-claude # claude executor e2e (needs live claude + mod
pnpm run test:e2e-claude-mcp # claude MCP stdio e2e (src; no live claude needed)
pnpm run test:e2e-opencode # opencode executor e2e (needs live opencode; uses a free model by default)
pnpm run test:e2e-opencode-mcp # opencode MCP stdio e2e (src; no live opencode needed)
+pnpm run test:e2e-grok-mcp # grok MCP stdio e2e (src; no live grok needed)
# pi e2e suites are run directly (they use .mts so the unit glob skips them):
# node --conditions=development --experimental-strip-types packages/pi-taskflow/test/e2e.mts
```
@@ -210,7 +223,7 @@ pnpm run test:e2e-opencode-mcp # opencode MCP stdio e2e (src; no live opencod
- **New test files**: name them `.test.ts` in the owning package's `test/` dir — each `test:*` script globs `packages//test/*.test.ts`, so they're picked up automatically (no manual list to update). E2E scripts use the `.mts` extension specifically so the glob excludes them (they need a live `pi`/`codex`).
### File Structure Rules
-- **Source**: `.ts` source lives in `packages//src/`. Host-neutral logic goes in `taskflow-core`; host **runner** code (the `SubagentRunner` impl, argv builder, event-stream parser for codex/claude/opencode) goes in `taskflow-hosts`; host **delivery** code (the MCP server/bin + plugin scaffold) goes in the `codex-taskflow` / `claude-taskflow` / `opencode-taskflow` packages; the pi adapter (which peer-depends the pi SDK) stays in `pi-taskflow`. `taskflow-core` must never import a host SDK (`@earendil-works/*`).
+- **Source**: `.ts` source lives in `packages//src/`. Host-neutral logic goes in `taskflow-core`; host **runner** code (the `SubagentRunner` impl, argv builder, event-stream parser for codex/claude/opencode/grok) goes in `taskflow-hosts`; host **delivery** code (the MCP server/bin + plugin scaffold) goes in the `codex-taskflow` / `claude-taskflow` / `opencode-taskflow` / `grok-taskflow` packages; the pi adapter (which peer-depends the pi SDK) stays in `pi-taskflow`. `taskflow-core` must never import a host SDK (`@earendil-works/*`).
- **Imports**: adapters import the engine via the bare specifier `taskflow-core` (never a relative path into `../taskflow-core/src`). The MCP server lives in the separate `taskflow-mcp-core` package — host adapters import it via `taskflow-mcp-core/server` / `taskflow-mcp-core/jsonrpc`. `detached-runner.ts` is spawn-only — reference it by `taskflow-core/detached-runner.js`, never via the barrel. `runSubagentProcess` (in `runner-core.ts`, re-exported from the `taskflow-core` barrel) is the shared spawn+classify helper every host runner delegates to.
- **Tests**: `.test.ts` in the owning package's `test/`. Named `.test.ts` or `.test.ts`.
- **Agents**: built-in agent `.md` files in `packages/taskflow-core/src/agents/` (copied to `dist/agents` at build).
@@ -269,6 +282,7 @@ All engine files live in `packages/taskflow-core/src/`; the pi entry lives in `p
| `taskflow-hosts/src/codex-runner.ts` | Codex subagent spawn (`codex exec --json`); `codexSubagentRunner` + `buildCodexArgs` |
| `taskflow-hosts/src/claude-runner.ts` | Claude Code subagent spawn (`claude -p --output-format stream-json`); `claudeSubagentRunner` + `buildClaudeArgs` + permission mapping |
| `taskflow-hosts/src/opencode-runner.ts` | OpenCode subagent spawn (`opencode run --format json`); `opencodeSubagentRunner` + `buildOpencodeArgs` + model resolution + permission mapping |
+| `taskflow-hosts/src/grok-runner.ts` | Grok Build subagent spawn (`grok -p --output-format streaming-json`); `grokSubagentRunner` + `buildGrokArgs` + permission mapping |
| `store.ts` | Persistence, file locks, index, cleanup, atomic writes |
| `interpolate.ts` | Template resolution, condition parser, safeParse, coerceArray |
| `cache.ts` | Fingerprint resolution (git/glob/file/env), CacheStore |
diff --git a/package.json b/package.json
index 6188522..da03107 100644
--- a/package.json
+++ b/package.json
@@ -2,14 +2,14 @@
"name": "pi-taskflow-monorepo",
"version": "0.1.7",
"private": true,
- "description": "Monorepo for taskflow-core, taskflow-mcp-core, taskflow-hosts, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, and the documentation website.",
+ "description": "Monorepo for taskflow-core, taskflow-mcp-core, taskflow-hosts, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and the documentation website.",
"type": "module",
"engines": {
"node": ">=22.19.0"
},
"packageManager": "pnpm@9.15.0",
"scripts": {
- "build": "pnpm run build:skills && pnpm --filter taskflow-core build && pnpm --filter taskflow-mcp-core build && pnpm --filter taskflow-hosts build && pnpm --filter pi-taskflow build && pnpm --filter codex-taskflow build && pnpm --filter claude-taskflow build && pnpm --filter opencode-taskflow build",
+ "build": "pnpm run build:skills && pnpm --filter taskflow-core build && pnpm --filter taskflow-mcp-core build && pnpm --filter taskflow-hosts build && pnpm --filter pi-taskflow build && pnpm --filter codex-taskflow build && pnpm --filter claude-taskflow build && pnpm --filter opencode-taskflow build && pnpm --filter grok-taskflow build",
"build:website": "cd website && npm run build",
"build:skills": "node scripts/build-skills.mjs",
"typecheck": "tsc --noEmit",
@@ -19,13 +19,15 @@
"test:codex": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/codex-taskflow/test/*.test.ts'",
"test:claude": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/claude-taskflow/test/*.test.ts'",
"test:opencode": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/opencode-taskflow/test/*.test.ts'",
+ "test:grok": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/grok-taskflow/test/*.test.ts'",
"test:e2e-codex": "node --conditions=development --experimental-strip-types packages/codex-taskflow/test/e2e-codex.mts",
"test:e2e-codex-mcp": "node --conditions=development --experimental-strip-types packages/codex-taskflow/test/e2e-codex-mcp.mts",
"test:e2e-codex-mcp-full": "pnpm run build && node --experimental-strip-types packages/codex-taskflow/test/e2e-mcp-comprehensive.mts",
"test:e2e-claude": "node --conditions=development --experimental-strip-types packages/claude-taskflow/test/e2e-claude.mts",
"test:e2e-claude-mcp": "node --conditions=development --experimental-strip-types packages/claude-taskflow/test/e2e-claude-mcp.mts",
"test:e2e-opencode": "node --conditions=development --experimental-strip-types packages/opencode-taskflow/test/e2e-opencode.mts",
- "test:e2e-opencode-mcp": "node --conditions=development --experimental-strip-types packages/opencode-taskflow/test/e2e-opencode-mcp.mts"
+ "test:e2e-opencode-mcp": "node --conditions=development --experimental-strip-types packages/opencode-taskflow/test/e2e-opencode-mcp.mts",
+ "test:e2e-grok-mcp": "node --conditions=development --experimental-strip-types packages/grok-taskflow/test/e2e-grok-mcp.mts"
},
"devDependencies": {
"@earendil-works/pi-agent-core": "^0.80.3",
diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md
index 643eaf6..a0ffb99 100644
--- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md
+++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md
@@ -360,6 +360,7 @@ Each entry is one of:
| `PI_TASKFLOW_CLAUDE_BIN` | Override the `claude` binary used to spawn Claude Code subagents. |
| `PI_TASKFLOW_OPENCODE_BIN` | Override the `opencode` binary used to spawn OpenCode subagents. |
| `PI_TASKFLOW_OPENCODE_MODEL` | Override the default OpenCode model for OpenCode executor e2e tests (e.g. `opencode/deepseek-v4-flash-free`). |
+| `PI_TASKFLOW_GROK_BIN` | Override the `grok` binary used to spawn Grok Build subagents. |
---
diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md
index 7ab6ce0..06c5d69 100644
--- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md
+++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md
@@ -357,6 +357,7 @@ Each entry is one of:
| `PI_TASKFLOW_CLAUDE_BIN` | Override the `claude` binary used to spawn Claude Code subagents. |
| `PI_TASKFLOW_OPENCODE_BIN` | Override the `opencode` binary used to spawn OpenCode subagents. |
| `PI_TASKFLOW_OPENCODE_MODEL` | Override the default OpenCode model for OpenCode executor e2e tests (e.g. `opencode/deepseek-v4-flash-free`). |
+| `PI_TASKFLOW_GROK_BIN` | Override the `grok` binary used to spawn Grok Build subagents. |
---
diff --git a/packages/grok-taskflow/package.json b/packages/grok-taskflow/package.json
new file mode 100644
index 0000000..0a7dadb
--- /dev/null
+++ b/packages/grok-taskflow/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "grok-taskflow",
+ "version": "0.1.7",
+ "description": "Run taskflow on Grok Build: a Grok subagent runner plus an MCP server (and a plug-and-play Grok plugin) that exposes the taskflow_* tools to Grok Build users.",
+ "keywords": [
+ "grok",
+ "grok-build",
+ "xai",
+ "mcp",
+ "taskflow",
+ "orchestration",
+ "subagents",
+ "workflow"
+ ],
+ "license": "MIT",
+ "author": "heggria ",
+ "homepage": "https://github.com/heggria/taskflow#readme",
+ "bugs": {
+ "url": "https://github.com/heggria/taskflow/issues"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/heggria/taskflow.git",
+ "directory": "packages/grok-taskflow"
+ },
+ "type": "module",
+ "engines": {
+ "node": ">=22.19.0"
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "development": "./src/index.ts",
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ },
+ "./mcp/server": {
+ "development": "./src/mcp/server.ts",
+ "types": "./dist/mcp/server.d.ts",
+ "default": "./dist/mcp/server.js"
+ }
+ },
+ "bin": {
+ "grok-taskflow-mcp": "./dist/mcp/bin.js"
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs grok-taskflow",
+ "prepublishOnly": "npm run build"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "taskflow-core": "0.1.7",
+ "taskflow-hosts": "0.1.7",
+ "taskflow-mcp-core": "0.1.7"
+ }
+}
diff --git a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json
new file mode 100644
index 0000000..8113902
--- /dev/null
+++ b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json
@@ -0,0 +1,23 @@
+{
+ "name": "taskflow",
+ "version": "0.1.7",
+ "description": "Declarative, verifiable DAG orchestration for Grok Build subagents — fan-out, gates, loops, tournaments, approvals, resumable runs, and saveable commands, with intermediate transcripts kept out of your context.",
+ "author": {
+ "name": "heggria",
+ "email": "bshengtao@gmail.com"
+ },
+ "homepage": "https://github.com/heggria/taskflow",
+ "repository": "https://github.com/heggria/taskflow",
+ "license": "MIT",
+ "keywords": [
+ "taskflow",
+ "orchestration",
+ "subagents",
+ "workflow",
+ "dag",
+ "mcp",
+ "grok",
+ "grok-build"
+ ],
+ "logo": "assets/taskflow.svg"
+}
diff --git a/packages/grok-taskflow/plugin/.mcp.json b/packages/grok-taskflow/plugin/.mcp.json
new file mode 100644
index 0000000..6310bee
--- /dev/null
+++ b/packages/grok-taskflow/plugin/.mcp.json
@@ -0,0 +1,9 @@
+{
+ "mcpServers": {
+ "taskflow": {
+ "command": "npx",
+ "args": ["-y", "-p", "grok-taskflow@0.1.7", "grok-taskflow-mcp"],
+ "tool_timeout_sec": 1800
+ }
+ }
+}
diff --git a/packages/grok-taskflow/plugin/assets/taskflow-small.svg b/packages/grok-taskflow/plugin/assets/taskflow-small.svg
new file mode 100644
index 0000000..392beea
--- /dev/null
+++ b/packages/grok-taskflow/plugin/assets/taskflow-small.svg
@@ -0,0 +1,14 @@
+
diff --git a/packages/grok-taskflow/plugin/assets/taskflow.svg b/packages/grok-taskflow/plugin/assets/taskflow.svg
new file mode 100644
index 0000000..7d70b58
--- /dev/null
+++ b/packages/grok-taskflow/plugin/assets/taskflow.svg
@@ -0,0 +1,17 @@
+
diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md
new file mode 100644
index 0000000..71237cb
--- /dev/null
+++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md
@@ -0,0 +1,590 @@
+---
+name: taskflow
+description: Orchestrate multi-phase subagent workflows with Taskflow. Use whenever a request spans a whole project or many items — deeply exploring / 探索 / auditing / 审计 / analyzing a codebase, reviewing or migrating many files or modules in parallel, cross-checked/adversarial review, codebase-wide research, or any repeatable orchestration you want to save and rerun. Prefer this over ad-hoc parallel work when the task has multiple phases (discover → work → review → report) or dynamic fan-out over a discovered list. Drives the taskflow_* MCP tools.
+---
+
+
+
+# Taskflow (Grok Build)
+
+**Host binding (Grok Build):** everything below is driven through the
+`taskflow_*` MCP tools. Where an example shows a host-neutral invocation like
+`verify`, use the Grok form (`taskflow_verify` via `search_tool` /
+`use_tool`, or the namespaced form `taskflow__taskflow_verify` depending on
+how tools are announced). Each phase's subagent runs as an isolated
+`grok -p --output-format streaming-json` session.
+
+| Tool | What it does |
+|------|--------------|
+| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. |
+| `taskflow_list` | List saved flows discoverable from the current working directory. |
+| `taskflow_show` | Show a saved flow's full definition as JSON. |
+| `taskflow_verify` | Statically verify a flow (cycles, missing deps, undefined refs, contract typos) — no execution, zero tokens. |
+| `taskflow_compile` | Render a flow's DAG as a diagram + a verification report — no execution. |
+| `taskflow_peek` | Inspect one phase's intermediate output from a stored run (post-hoc debugging). Omit `phaseId` to list phases; `json`/`item`/`limit` refine the slice. Hard-truncated, read-only. |
+
+**Always `taskflow_verify` a non-trivial flow before `taskflow_run`** — it is
+free and catches most authoring mistakes.
+
+Build and run **declarative, multi-phase workflows** of subagents. The runtime
+holds intermediate results and the phase DAG, so your main context only receives
+the final answer — not every step's transcript.
+
+## Documentation map (progressive loading)
+
+This file teaches the core: phase types, control flow, interpolation, and the
+mistakes that break flows. Load the companion files **only when needed**:
+
+| File | Load when you need |
+|------|--------------------|
+| `patterns.md` | **Designing a non-trivial flow.** Proven flow archetypes (audit fan-out, self-healing rework, plan→approve→execute, dynamic replanning, tournament synthesis, incremental audit), anti-patterns, and the production-flow quality checklist. |
+| `advanced.md` | Dynamic sub-flow (`flow{def}`) contracts & security caps, and workspace isolation (`cwd: temp/dedicated/worktree`). |
+| `configuration.md` | Every knob: per-phase `model`/`thinking`/`tools`/`cwd`, concurrency model, agent discovery, `settings.json`, cross-run caching (`cache`, `fingerprint`, per-item map caching), args, storage paths. |
+| `library.md` | **Before authoring a non-trivial flow — SEARCH the reusable-flow library.** Save reusable flows with `purpose`+`tags` so future search finds them; reuse + generalize instead of rewriting from scratch. The compounding flywheel. |
+
+> Rule of thumb: writing a flow with ≥ 4 phases, a gate, or any fan-out?
+> **Read `patterns.md` first** — it will make the flow better, not just valid.
+
+## When to use
+
+- A task needs **several coordinated steps** (discover → work → review → report).
+- You need to **fan out over many items** (audit every endpoint, summarize every file).
+- You want **cross-checked / adversarial review** before reporting.
+- You want a **repeatable** orchestration you can save and rerun by name.
+- The same expensive analysis will be **re-run as the repo evolves** (use
+ `incremental: true` + fingerprints — see `configuration.md` §8).
+
+## When NOT to use
+
+- A **single-file, single-step** change you can do directly — just do it.
+- **Interactive debugging** where each step depends on watching live output.
+- Work that is **one bash command** — run it yourself, don't wrap it in a flow.
+
+## Flow design ladder
+
+Match the flow's sophistication to the task. Don't stop at level 1 when the
+task deserves level 3 — the higher levels are where taskflow pays for itself.
+
+| Level | Shape | Reach for it when |
+|-------|-------|-------------------|
+| 0 | shorthand `task` / `tasks` / `chain` | one-off delegation, simple sequence |
+| 1 | linear DAG with `dependsOn` | fixed steps, each consuming the last |
+| 2 | discover → `map` fan-out → `gate` → `reduce` | many items, needs review before reporting |
+| 3 | + `eval` zero-token gates, `expect` contracts, `retry`, `onBlock: "retry"`, `budget`, `optional` fallbacks | production-grade: self-healing, cost-capped, fails precisely |
+| 4 | + `loop`, `tournament`, `flow{def}` dynamic planning | the work itself is discovered at runtime; one shot is unreliable |
+| 5 | + `incremental: true`, `cache.fingerprint` | the flow re-runs as the repo changes; only re-pay for what changed |
+
+**A production-grade flow (level 3+) usually has:** machine checks before LLM
+checks (`eval`, `script`), an `expect` contract on every JSON-emitting phase,
+`retry` on contract-checked phases, a `budget`, `optional: true` on
+degradable phases with a downstream fallback, and exactly one `final` phase.
+`patterns.md` shows each of these composed into full archetypes.
+
+## Shorthand (non-DAG)
+
+Skip the DSL entirely for simple delegations. The runtime desugars these into a
+proper flow, so you still get progress, persistence, and resume.
+
+```jsonc
+// single — one agent, one task
+{ "task": "Summarize the architecture of src/", "agent": "explorer" }
+
+// parallel — run several tasks at once, outputs merged
+{ "tasks": [
+ { "task": "Audit auth in src/api", "agent": "analyst" },
+ { "task": "Audit input validation in src/api", "agent": "analyst" }
+] }
+
+// chain — run sequentially; reference the prior step with {previous.output}
+{ "chain": [
+ { "task": "List the public API of src/lib", "agent": "scout" },
+ { "task": "Write docs for:\n{previous.output}", "agent": "writer" }
+] }
+```
+
+- `agent` is optional (defaults to the first available agent).
+- `context` (optional, per step or top-level in single mode): file paths to
+ pre-read and inject before the task — same as the full-DSL `Phase.context`
+ (per-file `contextLimit`, default 8000 chars). In **parallel `tasks` mode**
+ all branches SHARE the union of step contexts. In **chain mode** declare
+ `context` on individual steps; a top-level `context` is ignored (with a warning).
+- Add `name` to label the run.
+- Precedence if several are given: `chain` > `tasks` > `task`.
+- Pass these as the `define` argument to `taskflow_run`.
+
+## How to author a taskflow
+
+Call `taskflow_run` with an inline `define` object, or `name` for a saved flow.
+**Before running a non-trivial flow, `taskflow_verify` it — zero tokens,
+catches cycles / missing deps / undefined refs / contract typos.**
+
+### Iterating on a big flow? Use `defineFile` (write once, verify / edit / run by path)
+
+For a non-trivial flow you'll iterate on, **write the definition to a file**
+(typically in the OS tmp dir) and point every call at it with `defineFile`:
+
+```jsonc
+// 1. write /tmp/audit.json with the `write` tool (a full {name, phases:[…]} object)
+// 2. verify, iterate, run — all reference the SAME file by path:
+{ "name": "taskflow_verify", "arguments": { "defineFile": "/tmp/audit.json" } } // zero tokens
+{ "name": "taskflow_compile", "arguments": { "defineFile": "/tmp/audit.json" } } // diagram
+{ "name": "taskflow_run", "arguments": { "defineFile": "/tmp/audit.json" } }
+```
+
+The file can be raw JSON **or** a Markdown doc with a fenced ```json block
+(`write` the JSON form, or paste the flow into a note and fence it). Between
+calls, edit the file (not the call) and re-`verify`. This avoids re-sending a
+large definition on every call and keeps a durable draft you can diff. Falls
+back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name`
+(saved flow).
+
+### DSL shape
+
+```jsonc
+{
+ "name": "audit-endpoints",
+ "description": "Audit API endpoints for missing auth",
+ "args": { "dir": { "default": "src/routes" } },
+ "concurrency": 8,
+ "budget": { "maxUSD": 2.00 },
+ "agentScope": "user", // user | project | both
+ "phases": [
+ { "id": "discover", "type": "agent", "agent": "scout",
+ "task": "List endpoints under {args.dir}. Output ONLY a JSON array [{\"route\":\"\",\"file\":\"\"}].",
+ "output": "json",
+ "expect": { "type": "array", "items": { "type": "object", "required": ["route", "file"] } },
+ "retry": { "max": 2, "backoffMs": 0 } },
+ { "id": "audit", "type": "map", "over": "{steps.discover.json}", "as": "item",
+ "agent": "analyst", "task": "Audit {item.route} ({item.file}) for missing auth.",
+ "dependsOn": ["discover"] },
+ { "id": "review", "type": "gate", "agent": "reviewer",
+ "task": "Remove false positives from:\n{steps.audit.output}\nVERDICT: PASS or BLOCK.",
+ "dependsOn": ["audit"] },
+ { "id": "report", "type": "reduce", "from": ["review"], "agent": "writer",
+ "task": "Write a final report:\n{steps.review.output}", "dependsOn": ["review"],
+ "final": true }
+ ]
+}
+```
+
+### Phase types (10)
+
+| type | meaning | details |
+|------|---------|---------|
+| `agent` | one subagent runs `task` | this file |
+| `parallel` | run static `branches[]` concurrently | this file |
+| `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound | this file |
+| `gate` | quality/review step that can **halt the flow** | Gate phases below |
+| `reduce` | aggregate `from[]` phases into one output | this file |
+| `approval` | **human-in-the-loop** pause: approve / reject / edit | Approval phases below |
+| `flow` | run a **sub-flow** as one phase — saved (`use`) or runtime-generated (`def`) | summary below; deep contract in `advanced.md` |
+| `loop` | repeat a body until a condition / convergence / `maxIterations` | Loop phases below |
+| `tournament` | run N competing `variants`, a `judge` picks best or aggregates | Tournament phases below |
+| `script` | run a **shell command** (no LLM, zero tokens) — stdout is the output | Script phases below |
+
+### Control-flow fields (any phase)
+
+| field | meaning |
+|-------|---------|
+| `when` | conditional guard — skip the phase unless the expression is truthy. Supports `{refs}`, `== != < > <= >=`, `&& \|\| !`, parentheses, quoted strings/numbers. Parse errors fail **open** (phase runs). |
+| `join` | dependency join: `"all"` (default — wait for every dep) or `"any"` (OR-join — run as soon as one dep completes). |
+| `retry` | `{ "max": N, "backoffMs": ms, "factor": k }` — retry a failing subagent up to N times; delay is `backoffMs * factor^attempt` (`factor:1`=fixed, `2`=exponential). |
+| `timeout` | max ms per subagent call (>= 1000). On expiry the subagent is aborted and the phase fails with a `timedOut` marker — deterministic, **never retried**. Caps EACH call, so a map/parallel/loop/tournament phase's wall time is per item/iteration/variant (a tournament's judge call gets its own cap too). Script phases keep their own child-process timeout (default 60s, max 300s). Not supported on approval/flow. Pair with `optional: true` + a downstream fallback phase to degrade instead of failing the run. |
+| `expect` | output contract for `output: "json"` phases (agent/gate/reduce/loop): a JSON-Schema-like shape `{type, properties, required, items, enum}` validated the moment the subagent finishes. A violation fails the phase with per-path diagnostics (e.g. `$.score: required key is missing`) and is retryable under the phase's explicit `retry`. `verify`/`compile` also statically warn when a `{steps.X.json.field}` ref names a field absent from X's declared contract. |
+| `idempotent` | side-effect classification. Default `true` (safe to cache + auto-retry). Set `false` on phases with **irreversible side effects** (webhook POSTs, deploys, DB writes, file mutations): transient provider errors are **not** auto-retried (an explicit `retry{}` IS still honored — it's your declaration that repeats are acceptable) and the result is **never cached** in any scope (within-run resume, cross-run, `incremental` — the phase re-runs every time). The phase state records `sideEffect: true` (rendered as ⚡). |
+| `optional` | fail-soft — a failed/blocked phase won't abort the run; downstream sees empty output. Pair with a fallback phase guarded by `when`. |
+| `cache` | per-phase reuse policy (`run-only` default / `cross-run` / `off`). See `configuration.md` §8. |
+
+### Conditional routing (when + gate/branches)
+
+Pair `when` with an upstream phase that emits a decision to build real if/else
+routing. Use `join: "any"` on the merge phase so it runs whichever branch fired.
+For static (non-conditional) concurrency, a `parallel` phase runs fixed
+`branches[]` instead — `{ "type": "parallel", "branches": [{"task":"..."}, {"task":"...","agent":"reviewer"}] }`.
+
+```jsonc
+{ "id": "triage", "type": "agent", "agent": "analyst", "output": "json",
+ "task": "Classify the task. Output ONLY {\"route\":\"deep\"} or {\"route\":\"quick\"}.",
+ "expect": { "type": "object", "required": ["route"], "properties": { "route": { "enum": ["deep", "quick"] } } } },
+{ "id": "deep", "when": "{steps.triage.json.route} == deep", "dependsOn": ["triage"], "agent": "analyst", "task": "..." },
+{ "id": "quick", "when": "{steps.triage.json.route} == quick", "dependsOn": ["triage"], "agent": "executor-fast", "task": "..." },
+{ "id": "report", "type": "reduce", "from": ["deep","quick"], "join": "any",
+ "dependsOn": ["deep","quick"], "agent": "writer", "task": "...", "final": true }
+```
+
+> `when` should reference **upstream** (`dependsOn`) phases — a ref to a phase
+> that hasn't completed resolves empty and the guard is treated as false. Note
+> the `expect` enum on the router: it converts "the router said `Deep` with a
+> capital D and both branches silently skipped" into an immediate retryable
+> failure at the router.
+
+### Gate phases (quality control)
+
+A `gate` phase runs an agent to review upstream output and can **block the rest
+of the workflow**. The runtime needs to read a verdict from the agent's output.
+There are three ways to provide one, in order of robustness:
+
+**1. JSON contract (most robust — preferred).** Set `output: "json"` + an `expect`
+enum so the output is machine-validated. A verdict that isn't exactly `"pass"` or
+`"block"` (wrong case, extra formatting, a synonym) fails the `expect` contract and
+is retried — the verdict can never be silently misread.
+
+```jsonc
+{ "id": "review", "type": "gate", "agent": "reviewer", "dependsOn": ["impl"],
+ "output": "json",
+ "expect": { "type": "object",
+ "properties": { "verdict": { "enum": ["pass", "block"] }, "reason": { "type": "string" } },
+ "required": ["verdict", "reason"] },
+ "task": "Review the diff. Respond ONLY with JSON: {\"verdict\":\"pass\"|\"block\",\"reason\":\"...\"}" }
+```
+
+**2. Explicit text marker.** End the task by asking the agent to emit a final line
+`VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT; common
+Markdown emphasis like `VERDICT: **BLOCK**` is tolerated). JSON objects such as
+`{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block"}` also work.
+
+**3. Auto-appended format suffix.** If a free-text gate's task does **not** already
+ask for a `VERDICT:` marker (and has no JSON contract), the runtime automatically
+appends the exact format instruction. You don't need to remember to add it — but
+writing it yourself (option 2) makes the intent explicit in your flow.
+
+On **BLOCK**, downstream phases are skipped and the run ends as `blocked` with the
+reason surfaced. Unparseable gate **model output fails closed** (treated as BLOCK):
+a gate that cannot reach a verdict cannot be trusted to pass (issue #54). Note
+that *config* slips (an unresolved `score.target`, malformed `scorers`) are
+different and still fail **open** with a warning — those are authoring errors that
+degrade to the historical behavior, not a judge that couldn't decide. An explicit
+non-blocking JSON verdict (e.g. `{"verdict":"No issues found"}`) is a semantic PASS,
+not ambiguity.
+
+**Zero-token machine checks (`eval`) — use these before spending tokens.**
+List machine-checkable assertions in `eval`. If **all** pass, the gate
+auto-passes with **no LLM call**; if any fails, it falls through to the LLM
+`task` (the qualitative residue). Each entry supports the `when` operators plus
+`X contains Y` (substring). A parse error fails **open**.
+
+```jsonc
+{ "id": "quality", "type": "gate", "dependsOn": ["build","test"],
+ "eval": ["{steps.build.output} contains BUILD SUCCESS", "{steps.test.json.failures} == 0"],
+ "task": "Review the diff for subtle logic errors a linter can't catch. VERDICT: PASS or BLOCK." }
+```
+
+**Self-healing (`onBlock: "retry"`).** By default a blocking gate halts the run
+(`onBlock: "halt"`). With `onBlock: "retry"` the gate instead **re-runs its
+upstream `dependsOn` phases and re-evaluates**, up to `retry.max` rounds (or
+until PASS / budget / abort) — a generate→critique→regenerate rework loop. See
+`patterns.md` for the full archetype.
+
+```jsonc
+{ "id": "spec-gate", "type": "gate", "onBlock": "retry", "retry": { "max": 3 },
+ "dependsOn": ["implement"],
+ "task": "Does the implementation satisfy ALL acceptance criteria? VERDICT: PASS or BLOCK with reasons." }
+```
+
+**Scoring gates (`score`) — graded, composable, auditable quality checks.**
+Where `eval` gives boolean assertions, `score` runs deterministic scorers
+against a target string at **zero tokens**, combines them into a [0,1] score,
+and only escalates to an LLM when they can't decide. The structured result is
+the gate's `.json` — downstream phases read `{steps..json.combined}` /
+`.json.results.0.passed` and route on quality, not just pass/fail.
+
+| field | meaning |
+|-------|---------|
+| `target` | interpolation ref for the scored string (default `{previous.output}`) |
+| `scorers` | array of checks: `exact-match` (`value`), `contains` (`value`), `regex` (`pattern`, optional `negate`), `json-schema` (`schema`, an `expect`-style contract), `length-range` (`min`/`max`), `code-compiles` (`language`: javascript\|typescript) |
+| `combine` | `all` (default) / `any` / `weighted` |
+| `weights` | weighted only — one entry per scorer, **+1 trailing entry for the judge** when present |
+| `threshold` | weighted only — combined-score cutoff in (0,1], default 0.5 |
+| `judge` | optional LLM-as-judge fallback `{agent?, task}` — runs when the deterministics fail (and, for `all`/`any`, whenever configured); sees the target + scorer report; returns `{"score": 0-1, "verdict": "pass"\|"block", "reason"}` |
+
+Decision order: (1) deterministics pass **and the judge cannot veto** →
+**auto-PASS, zero LLM tokens** — that means: no judge configured, or `weighted`
+where the deterministic score is a lower bound already clearing the threshold
+(the judge could not drop it). With `all`/`any` + a judge the judge **always
+runs** — its verdict is authoritative (it may check what scorers cannot, e.g.
+factuality); (2) fail + `judge` → judge decides; (3) fail + `task` → the gate
+task runs with the scorer report appended; (4) fail + no fallback → **explicit
+BLOCK** (a deterministic failure is not ambiguity). Fail-closed: an unparseable
+judge → BLOCK (issue #54); unresolved `target` with no fallback → PASS +
+warning (config slip, not a judge verdict); malformed `score` → the plain LLM
+gate. **Security:** LLM-generated dynamic sub-flows
+(`flow{def}`) may not use `code-compiles` (compiler execution) or `regex`
+(ReDoS) scorers — same hardening class as the `script` block.
+
+```jsonc
+{ "id": "quality", "type": "gate", "dependsOn": ["gen"],
+ "score": {
+ "target": "{steps.gen.output}",
+ "scorers": [
+ { "type": "json-schema", "name": "shape", "schema": { "type": "object", "required": ["summary", "risks"] } },
+ { "type": "regex", "name": "no-placeholders", "pattern": "TODO|TBD", "negate": true },
+ { "type": "length-range", "name": "substantive", "min": 200 }
+ ],
+ "combine": "weighted", "weights": [3, 2, 1, 2], "threshold": 0.8,
+ "judge": { "agent": "reviewer", "task": "Score the analysis quality 0-1: depth, evidence, actionability." }
+ } }
+// downstream: { "when": "{steps.quality.json.combined} >= 0.9", ... }
+```
+
+### Approval phases (human-in-the-loop)
+
+An `approval` phase pauses the run and asks the operator to **Approve / Reject /
+Edit**. Distinct from `gate` (an *agent* reviewing): this is a *human* deciding.
+The (interpolated) `task` is the prompt shown.
+
+- **Approve** → continue; the phase output is `(approve)`.
+- **Reject** → halt the flow (same mechanism as a blocking gate).
+- **Edit** → the typed note becomes this phase's `output` — inject guidance
+ mid-run and reference it downstream with `{steps..output}`.
+- **Non-interactive** runs (headless/CI/print mode) **auto-reject** and record it.
+- **Background (detached)** runs **auto-reject** (no interactive approver);
+ downstream sees the rejection; the flow continues (fail-open).
+
+> **MCP-host caveat (Codex / Claude Code / OpenCode):** MCP-driven runs are
+> non-interactive, so an `approval` phase **auto-rejects**. Prefer a `gate`
+> (agent review) in flows you run through the `taskflow_*` tools; use `approval`
+> only in flows a human runs interactively.
+
+### Sub-flows (composition) — summary
+
+A `flow` phase runs another taskflow as a single phase and bubbles up its final
+output. Two mutually-exclusive sources:
+
+- **Saved** (`use`): `{ "type": "flow", "use": "deep-research", "with": { "topic": "{item}" } }`
+ — args via `with` (string values interpolate); recursion is detected and rejected.
+- **Runtime-generated** (`def`): `{ "type": "flow", "def": "{steps.plan.json}" }`
+ — an upstream planner emits a whole flow as JSON; the runtime validates it
+ (cycles / dangling refs / security caps) then runs it nested. This is how a
+ planner decides *at runtime* what work to spawn — the declarative answer to a
+ code-mode `for`/`if` loop.
+
+The `def` output contract, fail-open semantics (`defError`), nesting/breadth
+caps, and the iterative-replanning pattern (`loop` + `flow{def}`) are in
+`advanced.md`. The plan→execute and replan archetypes are in `patterns.md`.
+
+### Loop phases (iterate until done)
+
+A `loop` phase runs its body repeatedly, exposing each iteration's output as
+`{steps..output}` / `.json` so the next round can react to the last. It
+stops on the first of: `until` truthy, **convergence** (output stops changing),
+or `maxIterations` (hard cap, required). The runtime always terminates.
+
+- `until` — stop condition, same operators as `when` (a parse error stops the loop, fail-safe).
+- `maxIterations` — hard iteration cap (required).
+- `convergence` — `true` to stop early when an iteration's output equals the previous one.
+- `reflexion` — `true` to feed each iteration a structured summary of the prior one (see below).
+
+```jsonc
+{
+ "id": "refine", "type": "loop", "agent": "executor",
+ "maxIterations": 5,
+ "until": "{steps.refine.json.done} == true",
+ "convergence": true,
+ "task": "Improve the draft. When nothing else needs fixing, output JSON {\"done\":true,\"draft\":\"...\"}; otherwise {\"done\":false,\"draft\":\"...\"}.",
+ "output": "json",
+ "expect": { "type": "object", "required": ["done", "draft"] },
+ "final": true
+}
+```
+
+**Reflexion memory (`reflexion: true`).** By default each iteration sees only
+the prior *output* — the *reason* it wasn't good enough (an `expect` contract
+violation, an error, the unmet `until`) is discarded, so models repeat mistakes.
+With `reflexion: true`, every iteration after the first receives a structured
+failure summary of the prior one via the `{reflexion}` placeholder
+(auto-appended if the task omits it, with a one-time warning; capped at 2000
+chars): contract diagnostics like `$.done: required key is missing`, the
+(sanitized) error, or the unmet stop condition, plus a truncated output
+snippet. Iteration 1 sees a sentinel.
+
+Semantics shift to enable self-correction: **body failures become feedback
+instead of terminating the loop**. Timeout/abort/over-budget still hard-stop,
+and if `maxIterations` exhausts with the last iteration failed, the phase fails
+(reflexion defers failure, never erases it). Cost is bounded by `maxIterations`
++ the run `budget`.
+
+```jsonc
+{ "id": "emit-plan", "type": "loop", "reflexion": true, "maxIterations": 4,
+ "output": "json", "expect": { "type": "object", "required": ["steps", "done"] },
+ "until": "{steps.emit-plan.json.done} == true",
+ "task": "Emit the migration plan as JSON {steps:[...], done:bool}.\n{reflexion}" }
+```
+
+### Tournament phases (N variants, judge picks best)
+
+A `tournament` phase runs `variants` competing attempts in parallel, then a
+**judge** sub-phase selects the winner (`mode: "best"`) or merges them
+(`mode: "aggregate"`). Use it when one shot is unreliable and you want the best
+of several drafts, or a synthesis of diverse approaches.
+
+- `variants` — number of competing variants spawned from `task` (default 3, max 20).
+ For genuinely different *approaches*, use `branches` instead — an explicit
+ array of `{task, agent?}` definitions (e.g. one conservative, one aggressive).
+- `mode` — `"best"` (judge picks one winner, default) or `"aggregate"` (judge merges all).
+- `judge` — the judge's rubric/instructions. `judgeAgent` — optional judge agent
+ (defaults to the phase `agent`; use a stronger model here).
+- **Winner format — prefer JSON.** Have the judge return `{"winner": }` (and an
+ optional `"reason"`); the runtime also reads a `WINNER: ` line (`#3` and
+ common Markdown emphasis like `WINNER: **3**` are tolerated — issue #54).
+ JSON is more robust than a text marker: there's no formatting the model can
+ get subtly wrong.
+- Fail-open: if the judge's pick is still unparseable, variant 1 is returned
+ (work is never lost — the variants are already computed, so blocking would be
+ worse than picking a safe default).
+
+```jsonc
+{
+ "id": "headline", "type": "tournament", "agent": "executor",
+ "variants": 3, "mode": "best",
+ "judge": "Pick the clearest, most accurate headline. Return JSON {\"winner\": , \"reason\": \"...\"}.",
+ "task": "Write one headline for the article below.\n\n{steps.draft.output}",
+ "dependsOn": ["draft"], "final": true
+}
+```
+
+### Script phases (shell commands, zero tokens)
+
+A `script` phase runs a **shell command** directly — no subagent, no tokens — and
+captures its stdout as the phase output. Use it to anchor LLM phases to ground
+truth: builds, tests, `git`, formatters, scoring scripts. **Prefer a `script`
+phase over asking an agent to run a command** — it is cheaper, faster, and the
+output is exact.
+
+- `run` — **required**. A **string** runs through a shell; an **array** is
+ spawned directly (execvp, no shell). A string `run` containing an
+ interpolation placeholder is **rejected at validation** (shell-injection
+ guard) — use the array form or `input` for dynamic values.
+- `input` — optional text piped to stdin (supports interpolation).
+- `timeout` — optional ms cap (1000–300000, default 60000); SIGTERM → SIGKILL on expiry.
+- A non-zero exit fails the phase (stderr captured); stdout capped at 1 MB.
+ No `retry`, no `output: "json"`; **excluded from cross-run cache** (may have
+ side effects). Not allowed inside LLM-generated dynamic sub-flows (RCE guard).
+
+```jsonc
+{ "id": "build", "type": "script", "run": "pnpm run build", "timeout": 120000 },
+{ "id": "score", "type": "script", "run": ["python", "score.py"],
+ "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true }
+```
+
+### Budget (cost / token caps)
+
+Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it,
+remaining phases are skipped (and an in-flight `map`/`parallel` stops spawning
+new items); the run ends as `blocked` with partial outputs preserved.
+
+```jsonc
+{ "name": "...", "budget": { "maxUSD": 1.50, "maxTokens": 2000000 }, "phases": [ ... ] }
+```
+
+**Any flow with a fan-out should have a `budget`** — a map over a
+mis-discovered 500-item array is otherwise unbounded spend.
+
+### Strict interpolation
+
+By default an unresolved placeholder (typo'd `{steps.X.output}`, missing
+`{args.Y}`) resolves to an empty string and validation issues a *warning* —
+the flow still runs, possibly doing subtly wrong work. Set
+`"strictInterpolation": true` at the flow level to promote unresolved
+placeholders and missing-dep/arg warnings to **hard errors**. Recommended for
+any flow you save — a saved flow will be run later with args you're not
+watching.
+
+## Interpolation
+
+- `{args.X}` — invocation argument
+- `{steps.ID.output}` — a prior phase's text output
+- `{steps.ID.json}` / `{steps.ID.json.field}` — prior output parsed as JSON
+- `{item}` / `{item.field}` — current item inside a `map` phase
+- `{previous.output}` — the immediately-upstream phase output
+- `{loop.iteration}` / `{loop.lastOutput}` / `{loop.maxIterations}` — inside a `loop` body: the 1-based round, the prior iteration's output, and the cap
+- `{reflexion}` — inside a `loop` body with `reflexion: true`: the structured failure summary of the prior iteration (sentinel on iteration 1)
+
+Interpolation also runs on a scoring gate's `score.target` and `score.judge.task`
+— refs there need `dependsOn` like any other `{steps.X}` use.
+
+## Rules that make flows work
+
+1. For a `map` phase, make the upstream phase **emit a JSON array** and set
+ `output: "json"` on it. Tell that agent to output **only** JSON, and pin the
+ shape with an `expect` contract + `retry`.
+2. Give each phase a clear, single responsibility.
+3. Reference upstream results explicitly with `{steps.ID...}` and set `dependsOn`.
+4. Mark the result-bearing phase with `"final": true` (else the last phase wins).
+5. Machine checks before LLM checks: `script` for ground truth, gate `eval`
+ before gate `task`, `expect` before a downstream "did it parse?" phase.
+6. **Decision phases should emit structured output, not free text.** Any phase
+ whose output is a *decision* a downstream phase (or the runtime) acts on — a
+ gate verdict, a router's branch, a tournament winner, a judge's score — should
+ use `output: "json"` + an `expect` enum/contract so the decision is
+ machine-validated. Free-text markers (`VERDICT:`, `WINNER:`, `SCORE:`) are
+ tolerated and Markdown-emphasis-tolerant (issue #54), but a JSON contract is
+ strictly more robust: there's no formatting the model can get subtly wrong, and
+ a malformed decision fails the contract (retryable) instead of being silently
+ mis-read.
+7. `verify` before `run` for anything non-trivial (zero tokens).
+
+## Common mistakes (the runtime rejects these at validation time)
+
+### 1. Referencing `{steps.X}` without `dependsOn: ["X"]`
+
+```jsonc
+// ❌ WRONG — 'fix-issues' runs in parallel with 'code-review-1' and sees the
+// literal string "{steps.code-review-1.output}" instead of the review text.
+{ "id": "code-review-1", "type": "agent", "task": "review code" },
+{ "id": "fix-issues", "type": "agent",
+ "task": "fix {steps.code-review-1.output}" } // ← no dependsOn!
+```
+
+Validation rejects this: `Phase 'fix-issues': task references
+{steps.code-review-1.*} but 'code-review-1' is not in dependsOn. ...`
+**Always declare the chain:**
+
+```jsonc
+// ✅ RIGHT
+{ "id": "code-review-1", "type": "agent", "task": "review code" },
+{ "id": "fix-issues", "type": "agent",
+ "task": "fix {steps.code-review-1.output}",
+ "dependsOn": ["code-review-1"] }
+```
+
+Tip: write the `task` first (it tells you what each phase needs), then scan for
+`{steps.*}` references and add the matching `dependsOn`.
+Exception: phases with `join: "any"` are exempt (they deliberately wait for only
+one dep and may reference others as informational context).
+
+### 2. Assuming the runtime knows "this is a chain"
+
+Phase order in the `phases` array is **documentation, not execution order**.
+The DAG comes from `dependsOn`. Four phases listed in order with no `dependsOn`
+are four **parallel** phases, all racing in layer 0. Use the shorthand `chain`
+if you literally want `a → b → c → d`, or write explicit `dependsOn`.
+
+### 3. Underscores in ids / invented agent names
+
+Phase ids and agent names use **hyphens** (`audit-each`, `risk-reviewer`).
+An unknown agent name fails the phase with the list of available agents.
+Built-in agents: `executor`, `executor-code` (complex, multi-file),
+`executor-fast` (trivial), `executor-ui`, `scout` (cheap recon), `planner`,
+`analyst`, `critic`, `reviewer`, `risk-reviewer`, `security-reviewer`,
+`plan-arbiter`, `final-arbiter`, `test-engineer`, `doc-writer`, `verifier`,
+`recover`, `visual-explorer`. **Do not invent agent names** — omit `agent` to
+use the default. Use cheap agents (`scout`) for discovery and strong agents
+(`critic`, `final-arbiter`) for gates/judging.
+
+## Operating a run (lifecycle & inspection)
+
+A run moves through: **running →** `completed` (a `final` phase produced output)
+**/** `blocked` (gate BLOCK, approval rejected, or `budget` hit) **/** `failed`
+(a non-`optional` phase errored) **/** `paused` (aborted).
+
+`taskflow_run` reports a `runId`. If the final output looks wrong, don't
+re-run blind — `taskflow_peek` the run: omit `phaseId` to list phase statuses
+and output sizes, then peek the suspicious phase (`json: true` for parsed
+output, `item: n` for one fan-out section). Output is hard-truncated
+(default 4000 chars, max 32000) so a peek never floods your context.
+
+For flows re-run as the repo evolves, pass `incremental: true` to
+`taskflow_run` — every phase defaults to **cross-run cache reuse**: identical
+input → $0 instant hit. Per-phase `cache.fingerprint` entries
+(`git:HEAD`, `glob!:src/**/*.ts`, `file:package.json`) invalidate on world
+changes; a cached `map` re-executes only changed items. See `configuration.md` §8.
diff --git a/packages/grok-taskflow/plugin/skills/taskflow/advanced.md b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md
new file mode 100644
index 0000000..897b205
--- /dev/null
+++ b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md
@@ -0,0 +1,86 @@
+
+
+# Taskflow Advanced — dynamic sub-flows & workspace isolation
+
+Load this when a flow needs: runtime-generated work (`flow{def}`) or isolated
+working directories (`cwd: temp/dedicated/worktree`).
+
+---
+
+## Dynamic sub-flows (`flow{def}`) — the full contract
+
+A `flow` phase with `def` resolves a sub-flow **at runtime**, usually from an
+upstream phase's JSON output. The runtime interpolates + JSON-parses the `def`,
+validates it, then runs it nested. This is how a planner decides at runtime
+what work to spawn — with each generated plan checked before it spends a token.
+
+```jsonc
+{ "id": "plan", "type": "agent", "agent": "planner", "output": "json",
+ "task": "Scan the repo. Output ONLY JSON {\"name\":\"audit\",\"phases\":[...]} — one audit phase per file." },
+{ "id": "run", "type": "flow", "def": "{steps.plan.json}", "optional": true,
+ "dependsOn": ["plan"], "final": true }
+```
+
+**LLM output contract for `def`** (put this in the planner's task):
+- A *full* Taskflow `{"name":"...","phases":[...]}`, a bare `phases` array, or
+ `{"phases":[...]}` — pure JSON (a ```json fence is tolerated and stripped).
+- Hyphens in ids, never underscores.
+- Sub-flow phases reference each other in their **own** `{steps.x.output}`
+ namespace (no parent-id prefixing).
+- An **empty** `phases` array is a valid no-op (the planner decided there's
+ nothing to do).
+
+**Security caps on generated flows** (validation rejects; tell the planner not
+to emit these so a retry isn't wasted):
+- **No `script` phases** — shell execution from an LLM-authored plan is an RCE
+ vector; only author-written flows may use `script`.
+- **No workspace `cwd` keywords** (`temp`/`dedicated`/`worktree`) and no `cwd`
+ escaping the run directory.
+- Breadth caps: ≤100 phases, concurrency ≤16 (flow and per-phase).
+- Depth: inline nesting capped at 5 (shared with `ctx_spawn` subflows).
+
+**Fail-open semantics:** if the `def` doesn't parse, has the wrong shape, or
+fails validation, the phase completes with `status: "done"` and a `defError`
+diagnostic field; downstream phases receive empty output and the run continues.
+Design for it:
+- Add `optional: true` on the flow phase so a bad plan never aborts the run.
+- Want a hard stop instead? Add a downstream gate:
+ `{ "type": "gate", "eval": ["{steps.run.output} != "], "task": "…VERDICT: BLOCK if the plan failed." }`
+
+**Iterative replanning** — pair `flow{def}` with a `loop` whose body emits the
+next plan from the previous round's result: the declarative equivalent of
+`for (...) { read result; decide next }`. See `examples/dynamic-plan-execute.json`
+and `examples/iterative-replan.json`.
+
+---
+
+## Workspace isolation (`cwd` keywords)
+
+A phase's `cwd` is normally a literal path (or inherited from the run). Three
+**reserved keywords** ask the runtime to allocate an isolated working directory
+for the phase's subagent and tear it down afterwards — scratch work or file
+mutation without touching the main tree:
+
+| `cwd` value | what the runtime does | lifecycle |
+|-------------|-----------------------|-----------|
+| `"temp"` | ephemeral dir under the OS tmpdir | removed when the phase finishes |
+| `"dedicated"` | persistent dir under the run state (`runs/ws//`) | **kept** for inspection; deterministic per phase (resume reuses it) |
+| `"worktree"` | `git worktree add` on a throwaway branch off `HEAD` | `git worktree remove` + branch delete when the phase finishes |
+
+```jsonc
+{ "id": "experiment", "type": "agent", "agent": "executor", "cwd": "worktree",
+ "task": "Try the risky refactor and run the tests. Your edits are isolated in a git worktree." }
+```
+
+- **Fail-open.** If allocation fails (e.g. `worktree` outside a git tree), the
+ phase degrades — `worktree`→`temp`, any other failure → the base cwd — with a
+ `warnings` diagnostic. A phase never fails to run because of isolation.
+- **Security.** Keywords are honoured only in **author-written** flows; a
+ generated plan (`flow{def}` / `ctx_spawn` subflow) requesting one is rejected
+ at validation.
+- A literal path passes through unchanged.
+
+**Pattern — competing experiments in worktrees:** run two `parallel` branches,
+each `cwd: "worktree"`, each attempting a different refactor strategy and
+reporting its test results; a downstream gate/judge picks which diff to apply
+for real. The main tree is never touched by the losers.
diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md
new file mode 100644
index 0000000..006ab62
--- /dev/null
+++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md
@@ -0,0 +1,417 @@
+
+
+# Taskflow Configuration Reference
+
+Every knob you can set on a taskflow, where it lives, and how the values are
+resolved. Read this when you need fine control over models, concurrency, agent
+discovery, working directories, tool restrictions, or storage.
+
+> Companion files: `SKILL.md` (core DSL + actions), `patterns.md` (flow
+> archetypes + production checklist), `advanced.md` (context sharing, dynamic
+> sub-flows, workspace isolation, incremental recompute).
+
+Configuration lives in **five layers**, from most local to most global:
+
+| Layer | Where | Sets |
+|-------|-------|------|
+| Phase | a phase object in the DSL | per-step model/thinking/tools/cwd/output/concurrency |
+| Flow | the top-level DSL object | name, args, default concurrency, agent scope |
+| Agent | `~/.pi/agent/agents/*.md`, `.pi/agents/*.md` frontmatter | per-agent default model/thinking/tools + system prompt |
+| Settings | `~/.pi/agent/settings.json` | `modelRoles`, global thinking |
+| Environment | shell env | `PI_TASKFLOW_PI_BIN` |
+
+---
+
+## 1. Flow-level options
+
+Top-level keys of the taskflow definition object.
+
+```jsonc
+{
+ "name": "audit-endpoints", // required — also becomes /tf: when saved
+ "description": "Audit API auth", // shown in /tf list and the command palette
+ "concurrency": 8, // default max concurrent subagents (default: 8)
+ "agentScope": "user", // user | project | both (default: user)
+ "args": { /* see §3 */ },
+ "phases": [ /* see §2 */ ] // required, at least one phase
+}
+```
+
+| Key | Type | Default | Notes |
+|-----|------|---------|-------|
+| `name` | string | — | **Required.** Saved as `/tf:`. |
+| `description` | string | — | Surfaced in `/tf list` and the slash-command. |
+| `concurrency` | number | `8` | Default fan-out / same-layer parallelism cap. See §4. |
+| `agentScope` | `user`\|`project`\|`both` | `user` | Which agent dirs to load. See §6. |
+| `args` | record | `{}` | Declared invocation arguments. See §3. |
+| `phases` | array | — | **Required.** The phase DAG. See §2. |
+| `version` | number | `1` | ⚠️ Declared in schema but **not yet used** by the runtime. |
+
+---
+
+## 2. Phase-level options
+
+Keys of each object in `phases[]`. Some only apply to specific `type`s.
+
+```jsonc
+{
+ "id": "audit", // required, unique — referenced via {steps.audit.output}
+ "type": "map", // agent | parallel | map | gate | reduce | approval | flow | loop | tournament | script (default: agent)
+ "agent": "analyst", // agent name to run this phase
+ "task": "Audit {item.route}…",
+ "dependsOn": ["discover"],// DAG edges
+ "over": "{steps.discover.json}", // [map] array to fan out over
+ "as": "item", // [map] loop var name (default: item)
+ "branches": [ /* … */ ], // [parallel] static task list
+ "from": ["audit"], // [reduce] phase ids to aggregate
+ "output": "json", // text | json (default: text)
+ "model": "claude-sonnet-4-5", // per-phase model override
+ "thinking": "high", // per-phase thinking override
+ "tools": ["read","bash"], // restrict tools for this phase's subagent
+ "cwd": "packages/api", // working directory for this phase's subagent
+ "concurrency": 4, // [map/parallel] fan-out cap for THIS phase
+ "final": true // mark this phase's output as the workflow result
+}
+```
+
+| Key | Applies to | Default | Notes |
+|-----|-----------|---------|-------|
+| `id` | all | — | **Required, unique.** Used in `{steps.…}`. |
+| `type` | all | `agent` | One of the 10 phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script). |
+| `agent` | all | first available | Agent name; resolved from the scoped pool. |
+| `task` | agent, gate, map, reduce | — | Prompt; supports interpolation. Required for these types. |
+| `over` | map | — | **Required for map.** Must resolve to an array. |
+| `as` | map | `item` | Loop variable bound per item. |
+| `branches` | parallel | — | **Required for parallel.** `[{task, agent?}]`. |
+| `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. |
+| `run` | script | — | **Required for script.** Shell command: a string (runs in a shell) or an array (direct exec, no shell). A string with an interpolation placeholder is rejected (injection guard). |
+| `input` | script | — | Text piped to the command's stdin; supports interpolation. |
+| `timeout` | script | `60000` | Max run time in ms (1000–300000). On timeout: SIGTERM → SIGKILL, phase fails. |
+| `dependsOn` | all | `[]` | DAG edges. `from` also implies a dependency. |
+| `output` | all | `text` | `json` parses output so `{steps.id.json}` / map `over` work. |
+| `model` | all | agent/global | Per-phase model override. See §5. |
+| `thinking` | all | agent/global | Per-phase thinking level. See §5. |
+| `tools` | all | agent default | Whitelist of tools for the subagent. See §5. |
+| `cwd` | all | flow cwd | Run this phase's subagent in a different directory. |
+| `concurrency` | map, parallel | flow concurrency | Fan-out cap for this phase only. See §4. |
+| `context` | all | — | File paths / `{steps.X}` refs to **pre-read and inject** before the task. See §2.1. |
+| `contextLimit` | all | `8000` | Max characters read **per file** in `context`. See §2.1. |
+| `cache` | all | `run-only` | Per-phase cache policy (`scope`/`ttl`/`fingerprint`). See §11. |
+| `final` | all | last phase | Exactly one phase may be `final`; its output is returned. |
+
+> Gate-only control fields (`eval`, `onBlock`), the loop/tournament control
+> fields (`until`/`maxIterations`/`convergence`, `variants`/`judge`/`judgeAgent`/`mode`),
+> the script fields (`run`/`input`/`timeout`), and the cross-phase contract
+> fields (`expect`, `timeout`, `optional`, `strictInterpolation`) are documented
+> in `SKILL.md` next to their phase types. `shareContext` and the workspace
+> `cwd` keywords (`temp`/`dedicated`/`worktree`) are in `advanced.md`.
+
+---
+
+## 2.1 Context pre-reading (`context` / `contextLimit`)
+
+Instead of making a subagent *discover* files by exploring (an O(N²) turn-cost
+spiral), you can **pre-read** known files and inject their contents ahead of the
+task prompt. List file paths and/or `{steps.X}` refs in `context`; the runtime
+resolves interpolated refs first, then reads each file and prepends labeled
+blocks to the task.
+
+```jsonc
+{
+ "id": "review",
+ "type": "agent",
+ "agent": "reviewer",
+ "context": ["src/auth.ts", "src/middleware.ts", "{steps.spec.output}"],
+ "contextLimit": 12000,
+ "task": "Review the auth flow against the spec above. VERDICT: PASS or BLOCK.",
+ "dependsOn": ["spec"]
+}
+```
+
+**Behavior & limits (all enforced in the runtime):**
+
+| Aspect | Rule |
+|--------|------|
+| Resolution order | interpolate `{steps.X}` / `{args.X}` refs **first**, then read file paths. |
+| Per-file cap | `contextLimit` characters per file (default **8000**); longer files are truncated with a marker. |
+| Total cap | the combined injected block is hard-capped at **200,000 chars**; overflow is truncated with a notice. |
+| Unreadable file | skipped with a `console.warn` (never aborts the phase). |
+| JSON-looking entry | a value that looks like a JSON blob (not a path) is diagnosed and skipped, not read as a file. |
+
+Use `context` for **known, bounded** inputs (a handful of source files, an
+upstream phase's output). For large/unknown exploration, let the agent use its
+`read`/`grep` tools instead — pre-reading hundreds of files just hits the total
+cap.
+
+---
+
+## 3. Declaring & passing arguments
+
+Declare arguments on the flow, then reference them with `{args.X}`.
+
+```jsonc
+"args": {
+ "dir": { "default": "src", "description": "Directory to scan" },
+ "depth": { "default": 2 },
+ "token": { "required": true, "description": "API token" }
+}
+```
+
+| Field | Notes |
+|-------|-------|
+| `default` | Used when the caller omits the arg. |
+| `description` | Documentation only. |
+| `required` | ⚠️ Declared but **not enforced** at runtime — treat as documentation for now. |
+
+**Resolution:** for each declared arg, the provided value wins, else its
+`default`. Any extra provided keys are also passed through (so undeclared args
+still reach `{args.X}`).
+
+**Passing args:**
+
+Via the MCP tool: `taskflow_run` with `{ "name": "audit-endpoints", "args": { "dir": "packages/api" } }`.
+
+---
+
+## 4. Concurrency model
+
+There are **two independent concurrency limits**:
+
+1. **Same-layer parallelism** — phases with no dependency between them sit in the
+ same topological layer and run concurrently, bounded by **`flow.concurrency`**
+ (default `8`).
+2. **Fan-out within a `map`/`parallel` phase** — bounded by
+ **`phase.concurrency ?? flow.concurrency ?? 8`**.
+
+```jsonc
+{
+ "concurrency": 6, // ≤6 sibling phases run at once
+ "phases": [
+ { "id": "scan", "type": "map", "over": "{steps.list.json}",
+ "concurrency": 3, // …but this map only fans out 3 at a time
+ "task": "…", "dependsOn": ["list"] }
+ ]
+}
+```
+
+Set a low `phase.concurrency` to protect rate-limited models or heavy bash work;
+keep `flow.concurrency` higher to let independent phases overlap.
+
+---
+
+## 5. Model, thinking & tools resolution
+
+For any phase, the effective value is resolved in this **precedence order**
+(first defined wins):
+
+| Setting | Precedence (high → low) |
+|---------|-------------------------|
+| **model** | `phase.model` → agent frontmatter `model` (resolved via `modelRoles`) → pi default |
+| **thinking** | `phase.thinking` → agent frontmatter `thinking` → `settings` global thinking → pi default |
+| **tools** | `phase.tools` → agent frontmatter `tools` → all tools |
+
+Notes:
+- `tools` is a **whitelist**. Omit it to allow all.
+- Each phase runs as an isolated `grok -p --output-format streaming-json`
+ session. Unresolved `{{placeholder}}`s, multi-segment openrouter paths, and
+ pi thinking suffixes (`:xhigh`) are dropped so Grok falls back to its
+ configured default. Read-only phases get `--tools read_file,grep,list_dir,…`;
+ all non-interactive phases use `--always-approve` so permission prompts never
+ hang the headless subagent (no OS sandbox — see the README security note).
+- The agent's markdown body becomes the subagent's appended system prompt.
+
+---
+
+## 6. Agent discovery & scope
+
+`flow.agentScope` controls which agent directories are loaded:
+
+| Scope | Loads from |
+|-------|-----------|
+| `user` (default) | `~/.pi/agent/agents/*.md` |
+| `project` | nearest `.pi/agents/*.md` found walking up from cwd |
+| `both` | user **then** project (project overrides on name collision) |
+
+- Agents are `.md` files with frontmatter `name` + `description` (required), plus
+ optional `model`, `thinking`, `tools`. The body is the system prompt.
+- Reference agents in phases by their `name`. An unknown name fails that phase
+ with the list of available agents.
+- If a phase omits `agent`, the **first discovered agent** is used.
+
+---
+
+## 7. settings.json
+
+Taskflow shares the subagent settings file at `~/.pi/agent/settings.json`:
+
+```jsonc
+{
+ "modelRoles": {
+ "fast": "openrouter/deepseek/deepseek-v4-flash",
+ "strong": "openrouter/xiaomi/mimo-v2.5-pro"
+ },
+ "subagents": {
+ "globalThinking": "medium" // fallback thinking for all subagents
+ },
+ "defaultThinkingLevel": "low" // used if subagents.globalThinking is absent
+}
+```
+
+- `modelRoles` — maps `{{role}}` references in agent frontmatter to actual model identifiers.
+- `subagents.globalThinking` (or top-level `defaultThinkingLevel`) — global
+ thinking fallback.
+
+---
+
+## 8. Cross-run caching (`cache`)
+
+By default every phase is **`run-only`**: completed phases are reused only when
+you *resume the same run* (the historical behavior). Opt a phase into the
+persistent **cross-run** memoization store to reuse an identical-input result
+from *any prior run* — instant, zero tokens. See `docs/rfc-cross-run-memoization.md`
+for the design.
+
+```jsonc
+{
+ "id": "summarize-deps",
+ "type": "agent",
+ "agent": "writer",
+ "task": "Summarize the dependency tree of this repo.",
+ "cache": {
+ "scope": "cross-run",
+ "ttl": "6h",
+ "fingerprint": ["git:HEAD", "file:package-lock.json"]
+ }
+}
+```
+
+### `scope`
+
+| Value | Meaning |
+|-------|---------|
+| `run-only` (default) | Reuse only within a resumed run — exactly the historical behavior. |
+| `cross-run` | Reuse an identical-input result from **any** prior run (the persistent store). |
+| `off` | Never reuse, even within a run (force re-execution every time). |
+
+### Flow-wide opt-in: `incremental`
+
+Rather than annotating every phase with `cache: { "scope": "cross-run" }`, set
+`incremental: true` at the **flow** level (or pass `incremental: true` as the
+`run` tool argument) to default *every* phase to cross-run reuse:
+
+```jsonc
+{
+ "name": "audit",
+ "incremental": true, // ← every phase defaults to scope:"cross-run"
+ "phases": [ /* ... */ ]
+}
+```
+
+Precedence: the invocation `incremental` argument wins over the flow's
+`incremental` field, which is in turn overridden by any **per-phase** `cache`
+setting. The cross-run-blocked phase types (`gate`/`approval`/`loop`/
+`tournament`/`script`) and all per-phase soundness fallbacks still apply. The default
+remains `run-only` (each run starts fresh unless something opts in), because
+cross-run reuse silently persists outputs and can serve stale results for phases
+whose agents read files at runtime.
+
+### `ttl` (cross-run only)
+
+Max age before a cross-run hit is treated as a miss: e.g. `"30m"`, `"6h"`, `"7d"`.
+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.
+
+### `fingerprint` (cross-run only)
+
+The cache key is normally `phaseId + agent + model + interpolated-task`. A
+fingerprint folds **“did the world change?”** signals into that key, so an
+external change becomes a cache **miss** even when the task text is identical.
+Each entry is one of:
+
+| Entry | Becomes a miss when… | Resolves to |
+|-------|----------------------|-------------|
+| `git:HEAD` / `git:` | the commit moves | the resolved SHA (30s timeout → ``; no git → ``) |
+| `glob:` | 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) |
+| `glob!:` | the **contents** of matching files change | content hashes (capped at 5000 matches) |
+| `file:` | that file's content changes | sha256 of the file (>10 MB or missing → ``/``) |
+| `env:` | the env var changes | the env value |
+
+### What is cached, and when
+
+- Only phases whose **`status` is `done`** and that **were not themselves a cache
+ hit** are written to the store (no re-storing a value just read).
+- The store is keyed by the full input hash + fingerprint, tagged with
+ `flowName`/`phaseId`/`runId`/`model` for inspection and LRU eviction.
+- Cross-run reuse is **safe by construction**: a different agent, model, task, or
+ fingerprint produces a different key, so stale results are never served.
+
+> **When to use it:** expensive, deterministic phases whose inputs rarely change
+> (dependency summaries, doc generation, repeated audits of the same tree). For
+> phases that *should* re-run every time (anything reading live external state
+> without a fingerprint), leave the default `run-only` or set `off`.
+
+---
+
+## 9. Environment variables
+
+| Variable | Effect |
+|----------|--------|
+| `PI_TASKFLOW_PI_BIN` | Override the `pi` binary used to spawn subagents. Used by tests and unusual launch setups (e.g. `PI_TASKFLOW_PI_BIN=pi`). Normally auto-detected. |
+| `PI_TASKFLOW_CODEX_BIN` | Override the `codex` binary used to spawn Codex subagents. |
+| `PI_TASKFLOW_CLAUDE_BIN` | Override the `claude` binary used to spawn Claude Code subagents. |
+| `PI_TASKFLOW_OPENCODE_BIN` | Override the `opencode` binary used to spawn OpenCode subagents. |
+| `PI_TASKFLOW_OPENCODE_MODEL` | Override the default OpenCode model for OpenCode executor e2e tests (e.g. `opencode/deepseek-v4-flash-free`). |
+| `PI_TASKFLOW_GROK_BIN` | Override the `grok` binary used to spawn Grok Build subagents. |
+
+---
+
+## 10. Storage & file locations
+
+| What | Path | Commit? |
+|------|------|---------|
+| User-scoped flow | `~/.pi/agent/taskflows/.json` | personal |
+| Project-scoped flow | `/taskflows/.json` | ✅ commit to share |
+| Run state (resume) | `/taskflows/runs//.json` | ❌ gitignore |
+
+- `action: "save"` takes `scope: "project"` (default) or `"user"`.
+- Project flows override user flows on a name collision.
+- Add `.pi/taskflows/runs/` to `.gitignore`.
+
+---
+
+## 11. Quick recipes
+
+**Pin a strong model only for the review gate:**
+```jsonc
+{ "id": "review", "type": "gate", "agent": "reviewer",
+ "model": "claude-opus-4", "thinking": "high",
+ "task": "…\nVERDICT:", "dependsOn": ["audit"] }
+```
+
+**Sandbox a phase to read-only in a subdirectory:**
+```jsonc
+{ "id": "scan", "type": "agent", "agent": "scout",
+ "cwd": "packages/api", "tools": ["read", "grep", "ls"],
+ "task": "List route files. Output ONLY a JSON array.", "output": "json" }
+```
+
+**Throttle a rate-limited fan-out:**
+```jsonc
+{ "id": "summarize", "type": "map", "over": "{steps.scan.json}",
+ "concurrency": 2, "agent": "writer",
+ "task": "Summarize {item.file}.", "dependsOn": ["scan"] }
+```
+
+**Project-only agents:**
+```jsonc
+{ "name": "ci-audit", "agentScope": "project", "phases": [ /* … */ ] }
+```
+
+---
+
+## Caveats (declared but not yet enforced)
+
+These keys validate but the runtime does **not** act on them yet — don't rely on
+them for behavior:
+
+- `arg.required` — missing required args are not rejected.
+- `flow.version` — informational only.
diff --git a/packages/grok-taskflow/plugin/skills/taskflow/library.md b/packages/grok-taskflow/plugin/skills/taskflow/library.md
new file mode 100644
index 0000000..9c3cebc
--- /dev/null
+++ b/packages/grok-taskflow/plugin/skills/taskflow/library.md
@@ -0,0 +1,105 @@
+
+
+# Library: reusable flows & the search-before-author loop
+
+taskflow saves flows you'll reuse into a **library** with metadata, so you can
+`search` it before authoring a new flow — the **reuse flywheel**. The more you
+save (with a good `purpose` + `tags`), the better future search gets.
+
+> Full design: `docs/rfc-library-reuse.md`. This file is the agent-facing
+> "when and how" guide.
+
+**Host binding:** use the `taskflow_search`, `taskflow_save`, `taskflow_list`,
+`taskflow_show` tools.
+
+## Before authoring a non-trivial flow: SEARCH first
+
+Any time you're about to write a flow with ≥3 phases, fan-out, or a gate,
+**search the library first**. It costs nothing and often finds a starter you
+can adapt.
+
+```jsonc
+{ "name": "taskflow_search", "arguments": { "query": "audit API endpoints for missing auth", "limit": 5 } }
+```
+
+Read the results and the `→ reuseHint`:
+
+| score | reuseHint | what to do |
+|-------|-----------|------------|
+| **≥ 0.8** | "直接复用" / direct reuse | Run by name (skip authoring). |
+| **0.5 – 0.8** | "copy + 泛化" / copy & generalize | `show` it, copy, **generalize** (see checklist), save as a new version. |
+| **< 0.5** or no matches | "从头编写" / write fresh | Author from scratch — then **save** it if reusable. |
+
+`searchMode` tells you how the ranking was produced: `structural` (keyword +
+phase-signature; no embedding backend configured), `semantic` (cosine over
+embeddings), or `mixed` (some flows had vectors, some didn't). `structural` is
+weaker on paraphrase — if it missed something obvious, try `structureOnly:
+false` or a different phrasing.
+
+## After a successful novel flow: SAVE it (if reusable)
+
+When you finish a flow you expect to use again, save it **with a `purpose` and
+2–4 `tags`**. These two fields are what search matches on — a flow saved
+without them is nearly invisible to future search.
+
+```jsonc
+{ "name": "taskflow_save",
+ "arguments": { "name": "audit-endpoints", "definition": { "phases": [ ... ] },
+ "purpose": "Audit a directory of API endpoints for missing auth checks",
+ "tags": ["audit", "security", "auth", "fan-out"] } }
+```
+
+`save` auto-derives structural metadata (phase signature, a `generality` score
+in 0–1) and writes a sidecar `.meta.json` next to the flow file. You don't
+compute any of that — just give `purpose` + `tags`.
+
+## The generalization checklist (apply on every reuse)
+
+When you copy + generalize a flow, make it **more reusable than the version you
+found**. Each item raises the auto-derived `generality` score and broadens
+future search recall:
+
+- [ ] Hardcoded file/dir paths → `{args.X}` (with a `default`).
+- [ ] Specific entity words ("endpoint", "route") → broaden in the discover
+ prompt so the flow works for the whole class.
+- [ ] Thresholds / counts → `{args.X}` with sensible defaults.
+- [ ] Add `budget` / `retry` / `expect` if missing (production-grade knobs).
+- [ ] Update `purpose` to reflect the wider scope.
+
+Then save it back (version auto-bumps). Over time the library compounds: every
+reuse leaves a more general flow behind.
+
+## reuseCount & `reusedFromSearch`
+
+Each saved flow has a `reuseCount`. It goes up by 1 **only when** a run was
+chosen because of a prior search — set the `reusedFromSearch: true` flag on the
+run. Direct run-by-name does **not** bump it (that's intentional: `reuseCount`
+measures "found-via-search reuse", the high-quality signal for later auto-prune).
+
+```jsonc
+{ "name": "taskflow_run",
+ "arguments": { "name": "audit-endpoints", "args": { "dir": "src/api" }, "reusedFromSearch": true } }
+```
+
+## Judicious reuse — not every task needs the library
+
+Skip search + save for:
+- **One-off tasks** (a quick fix, a throwaway analysis) — `generality < 0.3`
+ and obviously won't recur.
+- **Trivial flows** (1–2 phases, no fan-out) — overhead isn't worth it.
+
+The library pays off for *patterns* that recur across projects or sessions:
+audits, migrations, reviews, fan-out summarization, plan→approve→execute, etc.
+
+## Configuration (embedding backend — optional, Phase 2)
+
+Search works with **zero config** (structural mode). For smarter paraphrase
+recall, configure an embedding backend in `~/.pi/agent/settings.json`:
+
+```jsonc
+{ "taskflow": { "library": { "enabled": true, "scope": "both" },
+ "embedder": { "kind": "http", "url": "http://127.0.0.1:8123/v1/embeddings", "model": "qwen3-embedding-0.6b" } } }
+```
+
+Without `embedder`, or if the embedder fails, search **degrades gracefully** to
+structural mode — it never breaks. (See `docs/rfc-library-reuse.md` §4.)
diff --git a/packages/grok-taskflow/plugin/skills/taskflow/patterns.md b/packages/grok-taskflow/plugin/skills/taskflow/patterns.md
new file mode 100644
index 0000000..51b0b47
--- /dev/null
+++ b/packages/grok-taskflow/plugin/skills/taskflow/patterns.md
@@ -0,0 +1,291 @@
+
+
+# Taskflow Patterns — proven archetypes & the production checklist
+
+Read this when designing a flow with ≥ 4 phases, a gate, or any fan-out.
+Each archetype below is a complete, runnable shape distilled from real runs.
+Copy the closest one and adapt — don't design from a blank page.
+
+---
+
+## The production-flow checklist
+
+Before running a flow you designed, check it against this list. Every item is
+cheap to add and each one has prevented a real failure class:
+
+- [ ] **`verify` first.** Run the static verifier (pi: `action: "verify"` /
+ Codex / Claude Code / OpenCode: `taskflow_verify`) — zero tokens,
+ catches cycles / missing deps / ref typos / contract mismatches.
+- [ ] **Every JSON-emitting phase has `expect` + `retry`.** "Output ONLY JSON"
+ in the task text is a request; `expect` is enforcement. Without it, a
+ malformed router output silently skips both branches.
+- [ ] **Machine checks before LLM checks.** `script` phases for build/test
+ ground truth; gate `eval` assertions before the gate's LLM `task`. A
+ token spent verifying what a shell command can verify is a wasted token.
+- [ ] **`budget` on any flow with a fan-out.** A map over a mis-discovered
+ 500-item array is unbounded spend without one.
+- [ ] **`optional: true` + a fallback for degradable phases.** An enrichment
+ phase that times out shouldn't sink the run — pair `timeout` +
+ `optional` with a downstream `when`-guarded fallback.
+- [ ] **Exactly one `final: true`** on the result-bearing phase.
+- [ ] **`strictInterpolation: true` on any flow you save.** Saved flows run
+ later with args you're not watching; unresolved placeholders must be
+ errors, not empty strings.
+- [ ] **Discovery phases are cheap and sandboxed.** `agent: "scout"`,
+ `tools: ["read","grep","ls"]`, low thinking. Don't pay executor prices
+ for `ls`.
+- [ ] **The reviewer is not the producer.** A gate reviewing phase X uses a
+ different agent (ideally a different model) than X. Self-review passes
+ ~everything.
+- [ ] **Re-runnable flows are `incremental: true`** with `fingerprint` entries
+ on phases that read the world (`git:HEAD`, `glob!:src/**/*.ts`). See
+ `advanced.md`.
+
+---
+
+## Archetype 1: Audit fan-out (discover → map → gate → reduce)
+
+The workhorse. Use for: audit every endpoint / migrate every file /
+summarize every module.
+
+```jsonc
+{
+ "name": "audit-endpoints",
+ "strictInterpolation": true,
+ "budget": { "maxUSD": 3.00 },
+ "phases": [
+ { "id": "discover", "type": "agent", "agent": "scout",
+ "tools": ["read", "grep", "ls"],
+ "task": "List every HTTP endpoint under src/routes. Output ONLY a JSON array [{\"route\":\"...\",\"file\":\"...\"}]. No prose.",
+ "output": "json",
+ "expect": { "type": "array", "items": { "type": "object", "required": ["route", "file"] } },
+ "retry": { "max": 2, "backoffMs": 0 } },
+
+ { "id": "audit", "type": "map", "over": "{steps.discover.json}", "as": "item",
+ "agent": "analyst", "concurrency": 4,
+ "task": "Audit {item.route} in {item.file} for missing auth. Report: SEVERITY (high/med/low/none), evidence (file:line), fix.",
+ "dependsOn": ["discover"] },
+
+ { "id": "screen", "type": "gate", "agent": "reviewer",
+ "task": "Cross-check the findings below. Delete false positives (cite why). If ANY confirmed HIGH remains, end with VERDICT: BLOCK and list them; else VERDICT: PASS.\n\n{steps.audit.output}",
+ "dependsOn": ["audit"] },
+
+ { "id": "report", "type": "reduce", "from": ["screen"], "agent": "doc-writer",
+ "task": "Write a prioritized remediation report from:\n{steps.screen.output}",
+ "dependsOn": ["screen"], "final": true }
+ ]
+}
+```
+
+Why each piece: `expect`+`retry` on discover means a chatty scout gets a second
+chance instead of feeding garbage to the map; `concurrency: 4` protects rate
+limits; the gate is a *different* agent than the auditor; `budget` bounds the
+fan-out.
+
+**Variant — per-item caching for repeated audits:** add
+`"cache": { "scope": "cross-run" }` to the map phase. On the next run, only
+items whose task text changed re-execute; the rest are $0 cache hits.
+(Details: `configuration.md` §8.)
+
+---
+
+## Archetype 2: Self-healing implement→verify→rework
+
+Use for: implement against acceptance criteria, fix-until-green.
+The gate re-runs its upstream on BLOCK — a generate→critique→regenerate loop
+without you writing a loop.
+
+```jsonc
+{
+ "name": "implement-verified",
+ "budget": { "maxUSD": 5.00 },
+ "phases": [
+ { "id": "implement", "type": "agent", "agent": "executor-code",
+ "task": "Implement the feature per the spec in docs/spec.md. Run nothing; just edit." },
+
+ { "id": "build-test", "type": "script",
+ "run": "npx tsc --noEmit && pnpm test 2>&1 | tail -20",
+ "timeout": 180000, "dependsOn": ["implement"] },
+
+ { "id": "spec-gate", "type": "gate", "agent": "reviewer",
+ "onBlock": "retry", "retry": { "max": 3 },
+ "eval": ["{steps.build-test.output} contains pass"],
+ "task": "Build/test output:\n{steps.build-test.output}\n\nDoes the implementation satisfy ALL acceptance criteria in docs/spec.md? VERDICT: PASS, or VERDICT: BLOCK with a precise list of what to fix.",
+ "dependsOn": ["implement", "build-test"] },
+
+ { "id": "summary", "type": "agent", "agent": "doc-writer",
+ "task": "Summarize what was implemented and the verification result:\n{steps.spec-gate.output}",
+ "dependsOn": ["spec-gate"], "final": true }
+ ]
+}
+```
+
+Key mechanics: on BLOCK, `spec-gate` re-runs **both** its `dependsOn` upstreams
+(`implement` gets the blocker's reasons via re-interpolation, `build-test`
+re-verifies), up to 3 rounds. The `eval` line means a green build+test skips
+the LLM review entirely on the happy path.
+
+**Verification phases: force structured output.** LLMs are bad at
+*summarizing* shell output (234 tests read as 230) but good at *copying*
+structured data. If a verification step must go through an agent (not a
+`script`), demand `key=value` lines:
+
+```
+Report EXACTLY in this format (one key=value per line, no prose):
+typecheck=PASS|FAIL
+tests_total=N
+tests_fail=N
+If any field is missing, you failed the task — re-run and re-read.
+```
+
+Prefer a `script` phase whenever the check is a command — exact, free, fast.
+
+---
+
+## Archetype 3: Plan → human approval → execute
+
+Use for: anything expensive or destructive where a human should see the plan
+before the spend. The approval's **Edit** option injects mid-run guidance.
+
+> **MCP-host caveat (Codex / Claude Code / OpenCode):** approval phases auto-reject in
+> MCP-driven (non-interactive) runs. This archetype only works when a human
+> runs the flow interactively; for tool-driven runs, replace the approval with
+> a strict `gate`.
+
+```jsonc
+{
+ "name": "guarded-migration",
+ "phases": [
+ { "id": "plan", "type": "agent", "agent": "planner",
+ "task": "Plan the migration of src/legacy/* to the new API. List each file, the change, and the risk." },
+
+ { "id": "checkpoint", "type": "approval",
+ "task": "Migration plan:\n\n{steps.plan.output}\n\nApprove to execute, reject to abort, or edit to add constraints.",
+ "dependsOn": ["plan"] },
+
+ { "id": "execute", "type": "agent", "agent": "executor-code",
+ "task": "Execute the migration plan:\n{steps.plan.output}\n\nOperator guidance (if any): {steps.checkpoint.output}",
+ "dependsOn": ["checkpoint"] },
+
+ { "id": "verify", "type": "script", "run": "npx tsc --noEmit && pnpm test 2>&1 | tail -5",
+ "timeout": 180000, "dependsOn": ["execute"], "final": true }
+ ]
+}
+```
+
+Note `{steps.checkpoint.output}` — on Edit it carries the operator's note; on
+Approve it's `(approve)`. Don't use approval in detached/headless runs (it
+auto-rejects there — by design).
+
+---
+
+## Archetype 4: Dynamic plan → execute (`flow{def}`)
+
+Use when the *work itself* must be discovered at runtime — the planner emits a
+whole sub-flow as JSON and the runtime validates + runs it. The declarative
+answer to "loop over whatever we find".
+
+```jsonc
+{
+ "name": "dynamic-audit",
+ "budget": { "maxUSD": 4.00 },
+ "phases": [
+ { "id": "plan", "type": "agent", "agent": "planner", "output": "json",
+ "task": "Scan this repo. Output ONLY a JSON taskflow {\"name\":\"sub\",\"phases\":[...]} with one 'agent' phase per module that needs auditing (agent: \"analyst\"), plus a final 'reduce' phase (agent: \"doc-writer\", from: [all audit ids], final: true). Use hyphens in ids. No script phases, no cwd fields.",
+ "expect": { "type": "object", "required": ["name", "phases"] },
+ "retry": { "max": 2, "backoffMs": 0 } },
+
+ { "id": "run-plan", "type": "flow", "def": "{steps.plan.json}",
+ "optional": true, "dependsOn": ["plan"] },
+
+ { "id": "deliver", "type": "agent", "agent": "doc-writer",
+ "task": "Final result (empty means the plan failed validation — say so):\n{steps.run-plan.output}",
+ "dependsOn": ["run-plan"], "final": true }
+ ]
+}
+```
+
+Critical details (full contract in `advanced.md`): a bad plan **fails open**
+(`defError` diagnostic, empty output downstream) — `optional: true` + the
+`deliver` phase turn that into a graceful report instead of a dead run. The
+planner prompt must forbid what validation will reject anyway (`script`
+phases, workspace `cwd` keywords) so the plan doesn't waste a retry.
+
+**Iterative replanning:** wrap a plan-emitting body in a `loop` so round N's
+plan reacts to round N−1's result — see `examples/iterative-replan.json`.
+
+---
+
+## Archetype 5: Tournament for one-shot-unreliable work
+
+Use when quality varies run-to-run (naming, copywriting, tricky refactor
+strategy, root-cause hypotheses). Branches > variants when you want genuinely
+different *approaches* judged against each other.
+
+```jsonc
+{
+ "id": "strategy", "type": "tournament", "mode": "best",
+ "judgeAgent": "final-arbiter",
+ "judge": "Judge on: correctness under concurrent access, blast radius, migration cost. Quote evidence. Return JSON {\"winner\": , \"reason\": \"...\"}.",
+ "branches": [
+ { "task": "Design the cache-invalidation fix with a conservative approach: minimal diff, no schema change.", "agent": "analyst" },
+ { "task": "Design the fix assuming we can change the schema: optimal correctness.", "agent": "analyst" },
+ { "task": "Design the fix as an adversary: what will break each obvious approach? Then propose the one that survives.", "agent": "critic" }
+ ],
+ "dependsOn": ["context"], "final": true
+}
+```
+
+Give the judge a **rubric with named criteria**, a stronger model
+(`judgeAgent`), and a structured winner output (`{"winner": }` JSON,
+or an exact `WINNER: ` terminator). `mode: "aggregate"`
+instead merges all variants — good for research synthesis, bad for decisions.
+
+---
+
+## Archetype 6: Incremental repo-watch audit (cross-run)
+
+Use for flows you'll re-run as the repo evolves. First run pays full price;
+subsequent runs re-pay only for what changed.
+
+```jsonc
+{
+ "name": "security-sweep",
+ "incremental": true,
+ "budget": { "maxUSD": 3.00 },
+ "phases": [
+ { "id": "discover", "type": "agent", "agent": "scout", "output": "json",
+ "task": "List all files handling user input. Output ONLY a JSON array of paths.",
+ "expect": { "type": "array" },
+ "cache": { "scope": "cross-run", "fingerprint": ["glob!:src/**/*.ts"] } },
+ { "id": "audit", "type": "map", "over": "{steps.discover.json}",
+ "agent": "security-reviewer", "task": "Audit {item} for injection/authz issues.",
+ "cache": { "scope": "cross-run" },
+ "dependsOn": ["discover"] },
+ { "id": "report", "type": "reduce", "from": ["audit"], "agent": "doc-writer",
+ "task": "Prioritized findings report:\n{steps.audit.output}",
+ "dependsOn": ["audit"], "final": true }
+ ]
+}
+```
+
+The `glob!:` fingerprint makes `discover` a cache miss only when file
+*contents* change; the map's per-item cache means one changed file re-audits
+one item.
+
+---
+
+## Anti-patterns (seen in real flows)
+
+| Anti-pattern | Why it fails | Fix |
+|--------------|--------------|-----|
+| One mega-phase doing discover+audit+report | No parallelism, no caching granularity, one failure loses everything | Split along the archetype-1 shape |
+| Gate whose task doesn't demand a `VERDICT:` terminator | Ambiguous model output fails closed → gate blocks on a model that forgot the verdict | Use `output:"json"` + `expect` enum (preferred), or end the task with the exact `VERDICT: PASS\|BLOCK` instruction (auto-appended if you omit it) |
+| Router phase without `expect` enum | `"Deep"` vs `"deep"` → both `when` branches skip, `join:"any"` reduce gets nothing | `expect: { properties: { route: { enum: [...] } } }` + `retry` |
+| Agent phase that just runs a shell command | Tokens spent, output paraphrased inaccurately | `script` phase |
+| Same agent produces and reviews | Self-review passes everything | Different agent (ideally model) for the gate |
+| Fan-out with no `budget` and no `concurrency` cap | Unbounded spend + rate-limit storms | `budget` + `phase.concurrency` |
+| `dependsOn` declared but output never referenced | The downstream agent doesn't see the upstream's work — dependency ≠ data flow | Interpolate `{steps.X.output}` into the task (or `context`) |
+| Saving a flow without `strictInterpolation` | Later invocations with wrong args silently run on empty strings | `strictInterpolation: true` before saving |
+| `map` over `{steps.X.output}` (text, not json) | `over` must resolve to an array | `output: "json"` upstream + `over: "{steps.X.json}"` |
+| Deep `chain` where steps don't need each other's output | Serialized latency for no reason | `tasks` (parallel) or a DAG with real edges only |
diff --git a/packages/grok-taskflow/src/index.ts b/packages/grok-taskflow/src/index.ts
new file mode 100644
index 0000000..e7d45a4
--- /dev/null
+++ b/packages/grok-taskflow/src/index.ts
@@ -0,0 +1,13 @@
+/**
+ * grok-taskflow public entry.
+ *
+ * The grok runner lives in `taskflow-hosts`. This package re-exports it so
+ * `import { grokSubagentRunner, runGrokAgentTask, buildGrokArgs, … } from
+ * "grok-taskflow"` works. New code should prefer `taskflow-hosts` /
+ * `taskflow-hosts/grok`; this re-export is the delivery-package public surface.
+ *
+ * The delivery surface (MCP server + bin + Grok plugin scaffold) ships from
+ * this package — see `./mcp/server.ts`, `./mcp/bin.ts`, and `./plugin/`.
+ */
+
+export * from "taskflow-hosts/grok";
diff --git a/packages/grok-taskflow/src/mcp/bin.ts b/packages/grok-taskflow/src/mcp/bin.ts
new file mode 100644
index 0000000..1f40621
--- /dev/null
+++ b/packages/grok-taskflow/src/mcp/bin.ts
@@ -0,0 +1,31 @@
+#!/usr/bin/env node
+/**
+ * Executable entry for the taskflow MCP server, grok-bound (the
+ * `grok-taskflow-mcp` bin).
+ *
+ * Register with Grok Build:
+ * npm install -g grok-taskflow
+ * grok mcp add taskflow -- grok-taskflow-mcp
+ *
+ * Or install the plugin scaffold (skills + MCP via npx):
+ * grok plugin install ./packages/grok-taskflow/plugin --trust
+ * # or: grok plugin install --trust
+ *
+ * From a checkout of this repo (after `pnpm run build`):
+ * grok mcp add taskflow -- node /abs/path/to/packages/grok-taskflow/dist/mcp/bin.js
+ *
+ * Grok then launches this as a stdio MCP server and the taskflow_* tools
+ * become available. The server discovers saved flows + agents from its launch
+ * cwd, and runs each subagent as a grok -p session.
+ */
+
+import { startMcpServer } from "./server.ts";
+
+startMcpServer(process.cwd())
+ .then(() => process.exit(0))
+ .catch((e) => {
+ // Never write non-JSON to stdout (it would corrupt the MCP stream); log to
+ // stderr and exit non-zero so the client sees the transport drop.
+ process.stderr.write(`taskflow mcp server fatal: ${e instanceof Error ? e.stack ?? e.message : String(e)}\n`);
+ process.exit(1);
+ });
diff --git a/packages/grok-taskflow/src/mcp/server.ts b/packages/grok-taskflow/src/mcp/server.ts
new file mode 100644
index 0000000..44d5494
--- /dev/null
+++ b/packages/grok-taskflow/src/mcp/server.ts
@@ -0,0 +1,31 @@
+/**
+ * The Grok Build binding of the host-neutral MCP server (taskflow-mcp-core/server).
+ *
+ * The protocol layer, tool schemas, and handlers all live in core; this shim
+ * only closes the loop for Grok: every subagent a flow spawns is itself a
+ * `grok -p` process (via grokSubagentRunner). Kept as a module (not just
+ * bin.ts) so tests and embedders get the same pre-bound surface the bin runs.
+ */
+
+import {
+ makeMcpHandlers as coreMakeMcpHandlers,
+ makeToolHandlers as coreMakeToolHandlers,
+ startMcpServer as coreStartMcpServer,
+} from "taskflow-mcp-core/server";
+import type { RpcHandler } from "taskflow-mcp-core/jsonrpc";
+import { grokSubagentRunner } from "taskflow-hosts";
+
+/** Per-call tool handlers with grok subagent execution bound in. */
+export function makeToolHandlers(cwd: string): Record) => Promise> {
+ return coreMakeToolHandlers(cwd, grokSubagentRunner);
+}
+
+/** Full MCP method dispatch table (protocol + tools), grok-bound. */
+export function makeMcpHandlers(cwd: string): Record {
+ return coreMakeMcpHandlers(cwd, grokSubagentRunner);
+}
+
+/** Start the stdio MCP server. Resolves when the client disconnects. */
+export function startMcpServer(cwd: string = process.cwd()): Promise {
+ return coreStartMcpServer(grokSubagentRunner, cwd);
+}
diff --git a/packages/grok-taskflow/test/e2e-grok-mcp.mts b/packages/grok-taskflow/test/e2e-grok-mcp.mts
new file mode 100644
index 0000000..b63899d
--- /dev/null
+++ b/packages/grok-taskflow/test/e2e-grok-mcp.mts
@@ -0,0 +1,72 @@
+/**
+ * Lightweight e2e: spawn the grok-bound MCP server over stdio and exercise
+ * initialize + tools/list + taskflow_verify. No live Grok CLI required.
+ *
+ * node --conditions=development --experimental-strip-types packages/grok-taskflow/test/e2e-grok-mcp.mts
+ */
+
+import { spawn } from "node:child_process";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+import assert from "node:assert/strict";
+
+const here = path.dirname(fileURLToPath(import.meta.url));
+const bin = path.join(here, "..", "src", "mcp", "bin.ts");
+
+function rpc(child: ReturnType, msg: object): Promise {
+ return new Promise((resolve, reject) => {
+ let buf = "";
+ const onData = (d: Buffer) => {
+ buf += d.toString();
+ const i = buf.indexOf("\n");
+ if (i < 0) return;
+ child.stdout?.off("data", onData);
+ try {
+ resolve(JSON.parse(buf.slice(0, i)));
+ } catch (e) {
+ reject(e);
+ }
+ };
+ child.stdout?.on("data", onData);
+ child.stdin?.write(JSON.stringify(msg) + "\n");
+ });
+}
+
+const child = spawn(
+ process.execPath,
+ ["--conditions=development", "--experimental-strip-types", bin],
+ { stdio: ["pipe", "pipe", "inherit"], cwd: path.join(here, "..", "..", "..") },
+);
+
+try {
+ const init = await rpc(child, {
+ jsonrpc: "2.0",
+ id: 1,
+ method: "initialize",
+ params: { protocolVersion: "2025-06-18", capabilities: {} },
+ });
+ assert.equal(init.result.serverInfo.name, "taskflow");
+
+ const list = await rpc(child, { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} });
+ const names = list.result.tools.map((t: any) => t.name);
+ assert.ok(names.includes("taskflow_verify"));
+
+ const verify = await rpc(child, {
+ jsonrpc: "2.0",
+ id: 3,
+ method: "tools/call",
+ params: {
+ name: "taskflow_verify",
+ arguments: {
+ define: {
+ name: "e2e",
+ phases: [{ id: "a", type: "agent", agent: "executor", task: "hi", final: true }],
+ },
+ },
+ },
+ });
+ assert.match(verify.result.content[0].text, /PASSED/);
+ console.log("e2e-grok-mcp: ok");
+} finally {
+ child.kill();
+}
diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts
new file mode 100644
index 0000000..a47a8dc
--- /dev/null
+++ b/packages/grok-taskflow/test/mcp-server.test.ts
@@ -0,0 +1,111 @@
+/**
+ * MCP server binding tests for the Grok Build adapter.
+ *
+ * The protocol layer + tool handlers live in taskflow-mcp-core (covered by the
+ * other host adapters against the same core); these pin the grok-bound surface:
+ * the handshake, the tool roster, and that the shim actually dispatches
+ * (verify runs without any grok process).
+ */
+
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { PassThrough } from "node:stream";
+import { serveStdio } from "taskflow-mcp-core/jsonrpc";
+import { makeMcpHandlers, makeToolHandlers } from "../src/mcp/server.ts";
+
+/** Send a list of JSON-RPC messages through the server, collect responses. */
+async function rpcRoundtrip(messages: object[]): Promise {
+ const input = new PassThrough();
+ const output = new PassThrough();
+ const responses: any[] = [];
+
+ let outBuf = "";
+ output.on("data", (d) => {
+ outBuf += d.toString();
+ let i: number;
+ while ((i = outBuf.indexOf("\n")) >= 0) {
+ const line = outBuf.slice(0, i);
+ outBuf = outBuf.slice(i + 1);
+ if (line.trim()) responses.push(JSON.parse(line));
+ }
+ });
+
+ const done = serveStdio(makeMcpHandlers(process.cwd()), { input, output });
+ for (const m of messages) input.write(JSON.stringify(m) + "\n");
+ input.end();
+ await done;
+ return responses;
+}
+
+test("grok mcp: initialize returns the protocol version + serverInfo", async () => {
+ const [res] = await rpcRoundtrip([
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {} } },
+ ]);
+ assert.equal(res.id, 1);
+ assert.equal(res.result.protocolVersion, "2025-06-18");
+ assert.ok(res.result.capabilities.tools, "advertises tools capability");
+ assert.equal(res.result.serverInfo.name, "taskflow");
+});
+
+test("grok mcp: tools/list exposes the same taskflow tools as other hosts", async () => {
+ const [res] = await rpcRoundtrip([{ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }]);
+ const names = res.result.tools.map((t: any) => t.name);
+ assert.deepEqual(
+ names.sort(),
+ [
+ "taskflow_compile",
+ "taskflow_list",
+ "taskflow_peek",
+ "taskflow_recompute",
+ "taskflow_run",
+ "taskflow_save",
+ "taskflow_search",
+ "taskflow_show",
+ "taskflow_trace",
+ "taskflow_verify",
+ "taskflow_why_stale",
+ ],
+ );
+ for (const t of res.result.tools) {
+ assert.equal(typeof t.description, "string");
+ assert.equal(t.inputSchema.type, "object");
+ }
+});
+
+test("grok mcp: taskflow_verify dispatches through the grok binding (no execution)", async () => {
+ const [res] = await rpcRoundtrip([
+ {
+ jsonrpc: "2.0",
+ id: 3,
+ method: "tools/call",
+ params: {
+ name: "taskflow_verify",
+ arguments: {
+ define: { name: "x", phases: [{ id: "a", type: "agent", agent: "executor", task: "do", final: true }] },
+ },
+ },
+ },
+ ]);
+ assert.equal(res.result.content[0].text, "✓ verification PASSED");
+ assert.equal(res.result.isError, false);
+});
+
+test("grok mcp: makeToolHandlers exposes the tools", () => {
+ const tools = makeToolHandlers(process.cwd());
+ assert.deepEqual(
+ Object.keys(tools).sort(),
+ [
+ "taskflow_compile",
+ "taskflow_list",
+ "taskflow_peek",
+ "taskflow_recompute",
+ "taskflow_run",
+ "taskflow_save",
+ "taskflow_search",
+ "taskflow_show",
+ "taskflow_trace",
+ "taskflow_verify",
+ "taskflow_why_stale",
+ ],
+ );
+});
diff --git a/packages/grok-taskflow/tsconfig.build.json b/packages/grok-taskflow/tsconfig.build.json
new file mode 100644
index 0000000..863efae
--- /dev/null
+++ b/packages/grok-taskflow/tsconfig.build.json
@@ -0,0 +1,13 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "outDir": "./dist",
+ "rootDir": "./src",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "rewriteRelativeImportExtensions": true
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md
index 4378269..60c14ad 100644
--- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md
+++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md
@@ -361,6 +361,7 @@ Each entry is one of:
| `PI_TASKFLOW_CLAUDE_BIN` | Override the `claude` binary used to spawn Claude Code subagents. |
| `PI_TASKFLOW_OPENCODE_BIN` | Override the `opencode` binary used to spawn OpenCode subagents. |
| `PI_TASKFLOW_OPENCODE_MODEL` | Override the default OpenCode model for OpenCode executor e2e tests (e.g. `opencode/deepseek-v4-flash-free`). |
+| `PI_TASKFLOW_GROK_BIN` | Override the `grok` binary used to spawn Grok Build subagents. |
---
diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md
index a4e9782..de998b1 100644
--- a/packages/pi-taskflow/skills/taskflow/configuration.md
+++ b/packages/pi-taskflow/skills/taskflow/configuration.md
@@ -362,6 +362,7 @@ Each entry is one of:
| `PI_TASKFLOW_CLAUDE_BIN` | Override the `claude` binary used to spawn Claude Code subagents. |
| `PI_TASKFLOW_OPENCODE_BIN` | Override the `opencode` binary used to spawn OpenCode subagents. |
| `PI_TASKFLOW_OPENCODE_MODEL` | Override the default OpenCode model for OpenCode executor e2e tests (e.g. `opencode/deepseek-v4-flash-free`). |
+| `PI_TASKFLOW_GROK_BIN` | Override the `grok` binary used to spawn Grok Build subagents. |
---
diff --git a/packages/pi-taskflow/test/skills-build.test.ts b/packages/pi-taskflow/test/skills-build.test.ts
index 4b22f05..a46083e 100644
--- a/packages/pi-taskflow/test/skills-build.test.ts
+++ b/packages/pi-taskflow/test/skills-build.test.ts
@@ -1,9 +1,9 @@
-// Guard: the generated skill files (pi + codex) must match what
+// Guard: the generated skill files must match what
// scripts/build-skills.mjs produces from skills-src/taskflow/.
//
// The skills are authored ONCE in skills-src/ (single source of truth) and
// compiled per host. Editing a generated file directly, or editing the source
-// without rebuilding, silently forks the two hosts' documentation — this test
+// without rebuilding, silently forks the hosts' documentation — this test
// makes that a CI failure. Fix with: node scripts/build-skills.mjs
import { test } from "node:test";
@@ -42,8 +42,18 @@ test("skills: host-conditional filtering removed the other host's content", asyn
path.join(root, "packages", "opencode-taskflow", "plugin", "skills", "taskflow", "SKILL.md"),
"utf8",
);
+ const gkSkill = readFileSync(
+ path.join(root, "packages", "grok-taskflow", "plugin", "skills", "taskflow", "SKILL.md"),
+ "utf8",
+ );
// No leftover markers in any output.
- for (const [name, text] of [["pi", piSkill], ["codex", cxSkill], ["claude", clSkill], ["opencode", ocSkill]] as const) {
+ for (const [name, text] of [
+ ["pi", piSkill],
+ ["codex", cxSkill],
+ ["claude", clSkill],
+ ["opencode", ocSkill],
+ ["grok", gkSkill],
+ ] as const) {
assert.ok(!/
-
+
# Taskflow Advanced — dynamic sub-flows & workspace isolation
Load this when a flow needs: runtime-generated work (`flow{def}`) or isolated
working directories (`cwd: temp/dedicated/worktree`).
-
+
---
diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md
index 39e8c92..8b0eb2a 100644
--- a/skills-src/taskflow/configuration.md
+++ b/skills-src/taskflow/configuration.md
@@ -176,9 +176,9 @@ still reach `{args.X}`).
Via the tool: `{ "action": "run", "name": "audit-endpoints", "args": { "dir": "packages/api" } }`.
-
+
Via the MCP tool: `taskflow_run` with `{ "name": "audit-endpoints", "args": { "dir": "packages/api" } }`.
-
+
---
@@ -247,6 +247,14 @@ Notes:
(via `OPENCODE_CONFIG_CONTENT`) so bash/write/edit are genuinely blocked;
mutating phases run with `--auto` (auto-approve).
+
+- Each phase runs as an isolated `grok -p --output-format streaming-json`
+ session. Unresolved `{{placeholder}}`s, multi-segment openrouter paths, and
+ pi thinking suffixes (`:xhigh`) are dropped so Grok falls back to its
+ configured default. Read-only phases get `--tools read_file,grep,list_dir,…`;
+ all non-interactive phases use `--always-approve` so permission prompts never
+ hang the headless subagent (no OS sandbox — see the README security note).
+
- The agent's markdown body becomes the subagent's appended system prompt.
---
@@ -389,6 +397,7 @@ Each entry is one of:
| `PI_TASKFLOW_CLAUDE_BIN` | Override the `claude` binary used to spawn Claude Code subagents. |
| `PI_TASKFLOW_OPENCODE_BIN` | Override the `opencode` binary used to spawn OpenCode subagents. |
| `PI_TASKFLOW_OPENCODE_MODEL` | Override the default OpenCode model for OpenCode executor e2e tests (e.g. `opencode/deepseek-v4-flash-free`). |
+| `PI_TASKFLOW_GROK_BIN` | Override the `grok` binary used to spawn Grok Build subagents. |
---
diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md
index 8a02e3a..e78b9e5 100644
--- a/skills-src/taskflow/core.md
+++ b/skills-src/taskflow/core.md
@@ -13,9 +13,9 @@ mistakes that break flows. Load the companion files **only when needed**:
| `advanced.md` | Shared Context Tree (`ctx_*` tools, `ctx_spawn` sub-graphs), workspace isolation (`cwd: temp/dedicated/worktree`), dynamic sub-flow (`flow{def}`) contracts & security caps, and the **incremental recompute suite** (`ir` / `provenance` / `why-stale` / `recompute` / `cache-clear`). |
-
+
| `advanced.md` | Dynamic sub-flow (`flow{def}`) contracts & security caps, and workspace isolation (`cwd: temp/dedicated/worktree`). |
-
+
| `configuration.md` | Every knob: per-phase `model`/`thinking`/`tools`/`cwd`, concurrency model, agent discovery, `settings.json`, cross-run caching (`cache`, `fingerprint`, per-item map caching), args, storage paths. |
| `library.md` | **Before authoring a non-trivial flow — SEARCH the reusable-flow library.** Save reusable flows with `purpose`+`tags` so future search finds them; reuse + generalize instead of rewriting from scratch. The compounding flywheel. |
@@ -94,9 +94,9 @@ proper flow, so you still get progress, persistence, and resume.
- You can pass these as top-level tool params **or** inside `define`.
-
+
- Pass these as the `define` argument to `taskflow_run`.
-
+
## How to author a taskflow
@@ -106,11 +106,11 @@ Call the `taskflow` tool. To run a brand-new flow you write inline, pass
**Before running a non-trivial flow, `action: "verify"` it — zero tokens,
catches cycles / missing deps / undefined refs / contract typos.**
-
+
Call `taskflow_run` with an inline `define` object, or `name` for a saved flow.
**Before running a non-trivial flow, `taskflow_verify` it — zero tokens,
catches cycles / missing deps / undefined refs / contract typos.**
-
+
### Iterating on a big flow? Use `defineFile` (write once, verify / edit / run by path)
@@ -126,7 +126,7 @@ For a non-trivial flow you'll iterate on, **write the definition to a file**
{ "action": "run", "defineFile": "/tmp/audit.json", "args": { … } }
```
-
+
```jsonc
// 1. write /tmp/audit.json with the `write` tool (a full {name, phases:[…]} object)
// 2. verify, iterate, run — all reference the SAME file by path:
@@ -134,7 +134,7 @@ For a non-trivial flow you'll iterate on, **write the definition to a file**
{ "name": "taskflow_compile", "arguments": { "defineFile": "/tmp/audit.json" } } // diagram
{ "name": "taskflow_run", "arguments": { "defineFile": "/tmp/audit.json" } }
```
-
+
The file can be raw JSON **or** a Markdown doc with a fenced ```json block
(`write` the JSON form, or paste the flow into a note and fence it). Between
@@ -349,12 +349,12 @@ The (interpolated) `task` is the prompt shown.
Place one before the expensive part of a flow (a big fan-out, a mutation) —
see the plan→approve→execute archetype in `patterns.md`.
-
+
> **MCP-host caveat (Codex / Claude Code / OpenCode):** MCP-driven runs are
> non-interactive, so an `approval` phase **auto-rejects**. Prefer a `gate`
> (agent review) in flows you run through the `taskflow_*` tools; use `approval`
> only in flows a human runs interactively.
-
+
### Sub-flows (composition) — summary
@@ -577,7 +577,7 @@ An unknown agent name fails the phase with the list of available agents.
Check with `action: "agents"` instead of guessing.
-
+
Built-in agents: `executor`, `executor-code` (complex, multi-file),
`executor-fast` (trivial), `executor-ui`, `scout` (cheap recon), `planner`,
`analyst`, `critic`, `reviewer`, `risk-reviewer`, `security-reviewer`,
@@ -585,7 +585,7 @@ Built-in agents: `executor`, `executor-code` (complex, multi-file),
`recover`, `visual-explorer`. **Do not invent agent names** — omit `agent` to
use the default. Use cheap agents (`scout`) for discovery and strong agents
(`critic`, `final-arbiter`) for gates/judging.
-
+
## Actions (all 15)
@@ -657,7 +657,7 @@ A run moves through: **running →** `completed` (a `final` phase produced outpu
- `/tf init` — interactive model-roles setup
- `/tf: [args]` — shortcut for each saved flow
-
+
`taskflow_run` reports a `runId`. If the final output looks wrong, don't
re-run blind — `taskflow_peek` the run: omit `phaseId` to list phase statuses
and output sizes, then peek the suspicious phase (`json: true` for parsed
@@ -669,4 +669,4 @@ For flows re-run as the repo evolves, pass `incremental: true` to
input → $0 instant hit. Per-phase `cache.fingerprint` entries
(`git:HEAD`, `glob!:src/**/*.ts`, `file:package.json`) invalidate on world
changes; a cached `map` re-executes only changed items. See `configuration.md` §8.
-
+
diff --git a/skills-src/taskflow/entry.grok.md b/skills-src/taskflow/entry.grok.md
new file mode 100644
index 0000000..d990180
--- /dev/null
+++ b/skills-src/taskflow/entry.grok.md
@@ -0,0 +1,25 @@
+---
+name: taskflow
+description: Orchestrate multi-phase subagent workflows with Taskflow. Use whenever a request spans a whole project or many items — deeply exploring / 探索 / auditing / 审计 / analyzing a codebase, reviewing or migrating many files or modules in parallel, cross-checked/adversarial review, codebase-wide research, or any repeatable orchestration you want to save and rerun. Prefer this over ad-hoc parallel work when the task has multiple phases (discover → work → review → report) or dynamic fan-out over a discovered list. Drives the taskflow_* MCP tools.
+---
+
+# Taskflow (Grok Build)
+
+**Host binding (Grok Build):** everything below is driven through the
+`taskflow_*` MCP tools. Where an example shows a host-neutral invocation like
+`verify`, use the Grok form (`taskflow_verify` via `search_tool` /
+`use_tool`, or the namespaced form `taskflow__taskflow_verify` depending on
+how tools are announced). Each phase's subagent runs as an isolated
+`grok -p --output-format streaming-json` session.
+
+| Tool | What it does |
+|------|--------------|
+| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG, or shorthand `{task}` / `{tasks}` / `{chain}`). Optional `args`, `incremental`. Returns only the final phase output + a `runId`. |
+| `taskflow_list` | List saved flows discoverable from the current working directory. |
+| `taskflow_show` | Show a saved flow's full definition as JSON. |
+| `taskflow_verify` | Statically verify a flow (cycles, missing deps, undefined refs, contract typos) — no execution, zero tokens. |
+| `taskflow_compile` | Render a flow's DAG as a diagram + a verification report — no execution. |
+| `taskflow_peek` | Inspect one phase's intermediate output from a stored run (post-hoc debugging). Omit `phaseId` to list phases; `json`/`item`/`limit` refine the slice. Hard-truncated, read-only. |
+
+**Always `taskflow_verify` a non-trivial flow before `taskflow_run`** — it is
+free and catches most authoring mistakes.
diff --git a/skills-src/taskflow/library.md b/skills-src/taskflow/library.md
index 41e07b5..cfaa7f5 100644
--- a/skills-src/taskflow/library.md
+++ b/skills-src/taskflow/library.md
@@ -11,10 +11,10 @@ save (with a good `purpose` + `tags`), the better future search gets.
**Host binding (pi):** use the `taskflow` tool with `action: "search"` /
`action: "save"`, and `/tf list` / `/tf show `.
-
+
**Host binding:** use the `taskflow_search`, `taskflow_save`, `taskflow_list`,
`taskflow_show` tools.
-
+
## Before authoring a non-trivial flow: SEARCH first
@@ -27,11 +27,11 @@ can adapt.
{ "action": "search", "query": "audit API endpoints for missing auth", "limit": 5 }
```
-
+
```jsonc
{ "name": "taskflow_search", "arguments": { "query": "audit API endpoints for missing auth", "limit": 5 } }
```
-
+
Read the results and the `→ reuseHint`:
@@ -60,14 +60,14 @@ without them is nearly invisible to future search.
"tags": ["audit", "security", "auth", "fan-out"] }
```
-
+
```jsonc
{ "name": "taskflow_save",
"arguments": { "name": "audit-endpoints", "definition": { "phases": [ ... ] },
"purpose": "Audit a directory of API endpoints for missing auth checks",
"tags": ["audit", "security", "auth", "fan-out"] } }
```
-
+
`save` auto-derives structural metadata (phase signature, a `generality` score
in 0–1) and writes a sidecar `.meta.json` next to the flow file. You don't
@@ -102,12 +102,12 @@ measures "found-via-search reuse", the high-quality signal for later auto-prune)
{ "action": "run", "name": "audit-endpoints", "args": { "dir": "src/api" }, "reusedFromSearch": true }
```
-
+
```jsonc
{ "name": "taskflow_run",
"arguments": { "name": "audit-endpoints", "args": { "dir": "src/api" }, "reusedFromSearch": true } }
```
-
+
## Judicious reuse — not every task needs the library
diff --git a/skills-src/taskflow/patterns.md b/skills-src/taskflow/patterns.md
index a421ffd..7dd1c33 100644
--- a/skills-src/taskflow/patterns.md
+++ b/skills-src/taskflow/patterns.md
@@ -145,12 +145,12 @@ Prefer a `script` phase whenever the check is a command — exact, free, fast.
Use for: anything expensive or destructive where a human should see the plan
before the spend. The approval's **Edit** option injects mid-run guidance.
-
+
> **MCP-host caveat (Codex / Claude Code / OpenCode):** approval phases auto-reject in
> MCP-driven (non-interactive) runs. This archetype only works when a human
> runs the flow interactively; for tool-driven runs, replace the approval with
> a strict `gate`.
-
+
```jsonc
{
From 0558d6f356b4a01b7144e7cfad697837065934b1 Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 12:54:32 +0800
Subject: [PATCH 13/51] docs: add Grok Build host guides and README install
path
Document grok-taskflow install (plugin + local dogfood bin), permissions,
MCP tools, and website en/zh guides. Update monorepo package table and
CHANGELOG Unreleased entry.
---
CHANGELOG.md | 5 +
README.md | 42 +++-
docs/grok-mcp.md | 193 ++++++++++++++++++
packages/taskflow-hosts/README.md | 39 ++--
website/content/docs/en/guides/grok-build.mdx | 189 +++++++++++++++++
website/content/docs/en/guides/index.mdx | 11 +-
.../content/docs/zh-cn/guides/grok-build.mdx | 127 ++++++++++++
website/content/docs/zh-cn/guides/index.mdx | 11 +-
8 files changed, 594 insertions(+), 23 deletions(-)
create mode 100644 docs/grok-mcp.md
create mode 100644 website/content/docs/en/guides/grok-build.mdx
create mode 100644 website/content/docs/zh-cn/guides/grok-build.mdx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7db1461..6982da9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
+## [Unreleased]
+
+### Added
+- **Grok Build host.** New `grok-taskflow` delivery package + `taskflow-hosts` `grokSubagentRunner` (`grok -p --output-format streaming-json`). Plugin scaffold (`.grok-plugin/plugin.json` + `.mcp.json` + skills), repo marketplace index (`.grok-plugin/marketplace.json`), docs (`docs/grok-mcp.md`, website en/zh guides). Install: `grok plugin install … --trust` or local bin for dogfood.
+
## [0.1.7] — 2026-07-07
### Added
diff --git a/README.md b/README.md
index 2de0f88..c5c54fe 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -24,7 +24,7 @@
A declarative, verifiable graph of tasks for coding-agent subagents.
Not a workflow you script — a DAG you declare. Fan out · gate · loop · tournament · resume · save as a command — intermediate results stay out of your context.
-Runs on the Pi coding agent, on OpenAI Codex, on Claude Code, and on OpenCode.
+Runs on the Pi coding agent, on OpenAI Codex, on Claude Code, on OpenCode, and on Grok Build.
@@ -42,13 +42,17 @@ claude plugin install claude-taskflow@taskflow
# OpenCode — add the MCP server to opencode.json (see the OpenCode guide)
opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp
+
+# Grok Build
+grok plugin install --trust # e.g. ./packages/grok-taskflow/plugin from a checkout
+# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp
```
---
**A `workflow` flows. A `taskflow` is a *graph*.** Other orchestrators let the model *script* the work — imperative code that flows step by step, with the graph hidden inside control flow. `taskflow` does the opposite: you **declare** the work as a graph of discrete, named **task** nodes connected by `dependsOn` edges — and the runtime *verifies that graph before it spends a single token.*
-You already know your agent's built-in subagent shorthand — `task` / `tasks` / `chain`. `taskflow` speaks the *same* shorthand — so your existing delegations instantly become **tracked, resumable, and saveable by name** (on Pi, a saved flow becomes a one-word `/tf:` command; on Codex, Claude Code, and OpenCode you run it by name through `taskflow_run`). When you outgrow the shorthand, the full DSL gives you a real DAG: dynamic fan-out over dozens of items, conditional routing, quality gates, human approvals, retries, loops, tournaments, and a hard spend ceiling.
+You already know your agent's built-in subagent shorthand — `task` / `tasks` / `chain`. `taskflow` speaks the *same* shorthand — so your existing delegations instantly become **tracked, resumable, and saveable by name** (on Pi, a saved flow becomes a one-word `/tf:` command; on Codex, Claude Code, OpenCode, and Grok Build you run it by name through `taskflow_run`). When you outgrow the shorthand, the full DSL gives you a real DAG: dynamic fan-out over dozens of items, conditional routing, quality gates, human approvals, retries, loops, tournaments, and a hard spend ceiling.
And the whole time, **only the final phase reaches your conversation.** Every intermediate transcript stays in the runtime, never your context window.
@@ -219,6 +223,24 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp
The server runs via `npx` (a version-pinned `opencode-taskflow`), and each phase's subagent runs as an isolated `opencode run` session. OpenCode also auto-discovers the bundled routing skill (`**/SKILL.md`). Then just ask OpenCode to run a multi-phase or fan-out job and it calls the tools. See the [OpenCode guide](./docs/opencode-mcp.md).
+### On Grok Build
+
+taskflow ships as a Grok Build **plugin** — install it once and the `taskflow_*` MCP tools plus a routing skill light up automatically:
+
+```bash
+# From a monorepo checkout (pre-publish):
+pnpm --filter grok-taskflow build
+grok plugin install ./packages/grok-taskflow/plugin --trust
+grok plugin enable taskflow
+grok mcp add taskflow -- node "$(pwd)/packages/grok-taskflow/dist/mcp/bin.js"
+
+# Once published:
+# grok plugin install --trust
+# # or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp
+```
+
+The plugin's MCP server runs via `npx` when the package is on npm; from a checkout, point MCP at the built bin as above. Each phase's subagent then runs as an isolated `grok -p --output-format streaming-json` session. See the [Grok Build guide](./docs/grok-mcp.md).
+
### The shorthand (same shape as the built-in tool)
```jsonc
@@ -638,7 +660,7 @@ Condition grammar (for `when`): `== != < > <= >=`, `&& || !`, parentheses, quote
## Commands
-Saved flows become CLI shortcuts. **These `/tf` commands are Pi-only** (they run in the Pi session). On Codex, Claude Code, and OpenCode, use the `taskflow_*` MCP tools instead — `taskflow_list` / `taskflow_show` / `taskflow_run` (by `name`) / `taskflow_verify` / `taskflow_compile` / `taskflow_peek`.
+Saved flows become CLI shortcuts. **These `/tf` commands are Pi-only** (they run in the Pi session). On Codex, Claude Code, OpenCode, and Grok Build, use the `taskflow_*` MCP tools instead — `taskflow_list` / `taskflow_show` / `taskflow_run` (by `name`) / `taskflow_verify` / `taskflow_compile` / `taskflow_peek`.
| Command | What it does |
|---|---|
@@ -653,7 +675,7 @@ Saved flows become CLI shortcuts. **These `/tf` commands are Pi-only** (they run
| `/tf init` | **Interactively map model roles** to your enabled models (writes `~/.pi/agent/settings.json`) |
| `/tf: [args]` | Shortcut — runs the flow in one tap |
-Tool actions (used by the model on Pi): `run` (inline `define` or saved `name`), `save`, `resume`, `list`, `agents`, `init`, `verify`, `compile`, `ir`, `provenance`, `trace`, `why-stale`, `recompute`, `cache-clear`, `search`. On Codex, Claude Code, and OpenCode the exposed MCP tools are `taskflow_run` / `taskflow_list` / `taskflow_show` / `taskflow_verify` / `taskflow_compile` / `taskflow_peek` / `taskflow_trace` / `taskflow_why_stale` / `taskflow_recompute` (dry-run only) / `taskflow_save` / `taskflow_search`.
+Tool actions (used by the model on Pi): `run` (inline `define` or saved `name`), `save`, `resume`, `list`, `agents`, `init`, `verify`, `compile`, `ir`, `provenance`, `trace`, `why-stale`, `recompute`, `cache-clear`, `search`. On Codex, Claude Code, OpenCode, and Grok Build the exposed MCP tools are `taskflow_run` / `taskflow_list` / `taskflow_show` / `taskflow_verify` / `taskflow_compile` / `taskflow_peek` / `taskflow_trace` / `taskflow_why_stale` / `taskflow_recompute` (dry-run only) / `taskflow_save` / `taskflow_search`.
## Background (detached) execution
@@ -872,7 +894,7 @@ Copy one into `.pi/taskflows/.json` (or `~/.pi/agent/taskflows/`) and it r
- **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.
-- **1140 tests across 70 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), backward-compatible cache-key migration (4-tier legacy fallback), per-phase structural sub-fingerprint (v3:phasefp — editing one phase invalidates only it and its dependents), per-item map caching (one changed item re-executes, N−1 cache hits), the `incremental` flag (run-wide cross-run default), reuse reporting, the FlowIR compile seam (determinism, declared-plane synthesis), incremental recompute (early-cutoff propagation, partial cascade strictly < full, observed ∪ declared union frontier), gate verdicts, budget caps, retry/backoff, approval flows, loop termination, tournament judging, sub-flow composition, the shared context tree (blackboard reuse, supervision spawn, subflow validation/nesting), workspace isolation (temp/dedicated/worktree lifecycle, fail-open degrade, dynamic-flow rejection), dynamic sub-flow security hardening, detached execution (PID persistence, stale detection, crash→failed, resume after failure), live run-history refresh, callback isolation, the idle watchdog, model-role init config, parseModelFromLabel with parenthesized-model-name regression, multi-fence `safeParse` recovery, host argv-contract locking (codex/claude/opencode `buildXxxArgs`), the `compile` Mermaid renderer (id-collision disambiguation, markdown-injection hardening, and full verify-overlay category coverage), plus the library Phase 1 metadata/search/store layer (phaseSignature, generality, CJK text scoring, staleness detection, sidecar persistence, A1 ghost-flow guard).
+- **1140 tests across 70 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), backward-compatible cache-key migration (4-tier legacy fallback), per-phase structural sub-fingerprint (v3:phasefp — editing one phase invalidates only it and its dependents), per-item map caching (one changed item re-executes, N−1 cache hits), the `incremental` flag (run-wide cross-run default), reuse reporting, the FlowIR compile seam (determinism, declared-plane synthesis), incremental recompute (early-cutoff propagation, partial cascade strictly < full, observed ∪ declared union frontier), gate verdicts, budget caps, retry/backoff, approval flows, loop termination, tournament judging, sub-flow composition, the shared context tree (blackboard reuse, supervision spawn, subflow validation/nesting), workspace isolation (temp/dedicated/worktree lifecycle, fail-open degrade, dynamic-flow rejection), dynamic sub-flow security hardening, detached execution (PID persistence, stale detection, crash→failed, resume after failure), live run-history refresh, callback isolation, the idle watchdog, model-role init config, parseModelFromLabel with parenthesized-model-name regression, multi-fence `safeParse` recovery, host argv-contract locking (codex/claude/opencode/grok `buildXxxArgs`), the `compile` Mermaid renderer (id-collision disambiguation, markdown-injection hardening, and full verify-overlay category coverage), plus the library Phase 1 metadata/search/store layer (phaseSignature, generality, CJK text scoring, staleness detection, sidecar persistence, A1 ghost-flow guard).
- **Hardened by design.** Path-traversal defense (lexical + `realpath` containment check), runId validation, HTML/error sanitization, atomic writes, stale-lock stealing via `rename`, and an idle watchdog that kills wedged subagents (SIGTERM → SIGKILL after 5 minutes of silence). Dynamic sub-flows additionally get breadth caps, `cwd` containment, budget clamping, nesting depth caps, and prototype-pollution defense.
- **Dogfooded.** Every new feature has to survive the project's own `self-improve` taskflow before it ships.
@@ -897,7 +919,7 @@ Our `self-improve` flow is a 10-phase DAG — it audits the codebase, patches de
## Status & limits
-**v0.1.7** (current release) — **file loaders now report *why* a file failed with the parse position** (line/column) instead of a merged "not found or unparseable" message — `defineFile`, saved flows, run records, and library sidecars all distinguish *missing* from *malformed*, so a stray bare newline in a hand-authored flow is diagnosable in seconds; `safeParse` stays lenient for LLM output. Also fixes a pi-taskflow hint that re-printed every session. **Gate safety hardening (issue #54)**: a shared emphasis-tolerant marker factory now covers **all three decision markers** — `VERDICT`, `WINNER`, and `SCORE` — so Markdown-wrapped tokens (`VERDICT: **BLOCK**`, `WINNER: __3__`, `SCORE: `0.8``) are never silently mis-read (a genuine BLOCK no longer becomes PASS; a judge's pick no longer silently reverts to variant 1); **unparseable gate *model output now fails closed* (BLOCK)** instead of rubber-stamping PASS — a gate that cannot reach a verdict cannot be trusted to pass, while *config* slips (unresolved `score.target`, malformed `scorers`) stay fail-open with a warning; and free-text gates whose task omits a `VERDICT:` instruction now get the exact format suffix **auto-appended**. For the most robust decision phases, use `output: "json"` + `expect` to machine-validate the output (now the documented default for gate verdicts, tournament winners, and router branches). **v0.1.6** added **library Phase 1** (search-before-author + reusable-flow sidecar metadata), the **`defineFile`** parameter (verify/compile/run a flow from a path on disk), and **JSONC comment support** in flow definition files (`//` and `/* */` comments + trailing commas, parsed by the new zero-dependency `parseJsonc`). **v0.1.5** added **Claude Code and OpenCode as hosts**, **extracted the MCP server into its own `taskflow-mcp-core` package**, and **de-duplicated the three host runners** into a shared `runSubagentProcess`. See [CHANGELOG](./CHANGELOG.md) for the full history. Baseline: **multi-host monorepo of seven packages** — the host-neutral `taskflow-core` engine, the host-neutral `taskflow-mcp-core` MCP server, the shared host-runner `taskflow-hosts`, plus `pi-taskflow` (Pi adapter), `codex-taskflow`, `claude-taskflow`, and `opencode-taskflow` (the three delivery packages re-export their runners from `taskflow-hosts` and each ships an MCP bin + plugin/config), all sharing the host-neutral MCP server in `taskflow-mcp-core`. **Library Phase 1**: save flows with `purpose`+`tags` via `taskflow_save` (MCP) or `action=save` (Pi), search them with structural + CJK-aware keyword scoring via `taskflow_search`/`action=search`, and track `reuseCount` via `reusedFromSearch`. **`defineFile`**: pass a `defineFile` path (or `{defineFile, name}`) to `action=run` (Pi) or `taskflow_run`/`taskflow_verify`/`taskflow_compile` (MCP) instead of an inline `define`, and the engine reads the flow from disk — pair it with JSONC comments to annotate saved flows. **JSONC**: flow-definition `.json` files may now carry `//` and `/* */` comments and trailing commas (parsed by `parseJsonc`, re-exported from the `taskflow-core` barrel); LLM-output parsing via `safeParse` stays strict. **Shared Context Tree**: opt-in (`shareContext` / `contextSharing`) blackboard + supervision tools (`ctx_read`/`ctx_write` horizontal reuse, `ctx_report`/`ctx_spawn` vertical supervision); `ctx_spawn` accepts a flat task **or** a dependency-bearing `subflow` (a runtime-validated nested DAG), depth-capped on a unified nesting counter with budget accounting. **Workspace isolation**: a phase's `cwd` accepts reserved keywords `temp`/`dedicated`/`worktree` — the runtime allocates an isolated dir (or a git worktree on a throwaway branch) and tears it down after the phase, fail-open, rejected in LLM-authored sub-flows. **Detached execution**: runs can execute in the background, detached from the Pi session. Prior: loop-until-done (`loop`), tournament (best-of-N with a judge), cross-run memoization (content-addressed cache with git/file/glob/env fingerprints and TTL), interactive `/tf init`, 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, `onBlock: "retry"`, `eval` machine gates, 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.
+**v0.1.7** (current release) — **file loaders now report *why* a file failed with the parse position** (line/column) instead of a merged "not found or unparseable" message — `defineFile`, saved flows, run records, and library sidecars all distinguish *missing* from *malformed*, so a stray bare newline in a hand-authored flow is diagnosable in seconds; `safeParse` stays lenient for LLM output. Also fixes a pi-taskflow hint that re-printed every session. **Gate safety hardening (issue #54)**: a shared emphasis-tolerant marker factory now covers **all three decision markers** — `VERDICT`, `WINNER`, and `SCORE` — so Markdown-wrapped tokens (`VERDICT: **BLOCK**`, `WINNER: __3__`, `SCORE: `0.8``) are never silently mis-read (a genuine BLOCK no longer becomes PASS; a judge's pick no longer silently reverts to variant 1); **unparseable gate *model output now fails closed* (BLOCK)** instead of rubber-stamping PASS — a gate that cannot reach a verdict cannot be trusted to pass, while *config* slips (unresolved `score.target`, malformed `scorers`) stay fail-open with a warning; and free-text gates whose task omits a `VERDICT:` instruction now get the exact format suffix **auto-appended**. For the most robust decision phases, use `output: "json"` + `expect` to machine-validate the output (now the documented default for gate verdicts, tournament winners, and router branches). **v0.1.6** added **library Phase 1** (search-before-author + reusable-flow sidecar metadata), the **`defineFile`** parameter (verify/compile/run a flow from a path on disk), and **JSONC comment support** in flow definition files (`//` and `/* */` comments + trailing commas, parsed by the new zero-dependency `parseJsonc`). **v0.1.5** added **Claude Code and OpenCode as hosts**, **extracted the MCP server into its own `taskflow-mcp-core` package**, and **de-duplicated the three host runners** into a shared `runSubagentProcess`. See [CHANGELOG](./CHANGELOG.md) for the full history. Baseline: **multi-host monorepo of eight packages** — the host-neutral `taskflow-core` engine, the host-neutral `taskflow-mcp-core` MCP server, the shared host-runner `taskflow-hosts`, plus `pi-taskflow` (Pi adapter), `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, and `grok-taskflow` (the four delivery packages re-export their runners from `taskflow-hosts` and each ships an MCP bin + plugin/config), all sharing the host-neutral MCP server in `taskflow-mcp-core`. **Library Phase 1**: save flows with `purpose`+`tags` via `taskflow_save` (MCP) or `action=save` (Pi), search them with structural + CJK-aware keyword scoring via `taskflow_search`/`action=search`, and track `reuseCount` via `reusedFromSearch`. **`defineFile`**: pass a `defineFile` path (or `{defineFile, name}`) to `action=run` (Pi) or `taskflow_run`/`taskflow_verify`/`taskflow_compile` (MCP) instead of an inline `define`, and the engine reads the flow from disk — pair it with JSONC comments to annotate saved flows. **JSONC**: flow-definition `.json` files may now carry `//` and `/* */` comments and trailing commas (parsed by `parseJsonc`, re-exported from the `taskflow-core` barrel); LLM-output parsing via `safeParse` stays strict. **Shared Context Tree**: opt-in (`shareContext` / `contextSharing`) blackboard + supervision tools (`ctx_read`/`ctx_write` horizontal reuse, `ctx_report`/`ctx_spawn` vertical supervision); `ctx_spawn` accepts a flat task **or** a dependency-bearing `subflow` (a runtime-validated nested DAG), depth-capped on a unified nesting counter with budget accounting. **Workspace isolation**: a phase's `cwd` accepts reserved keywords `temp`/`dedicated`/`worktree` — the runtime allocates an isolated dir (or a git worktree on a throwaway branch) and tears it down after the phase, fail-open, rejected in LLM-authored sub-flows. **Detached execution**: runs can execute in the background, detached from the Pi session. Prior: loop-until-done (`loop`), tournament (best-of-N with a judge), cross-run memoization (content-addressed cache with git/file/glob/env fingerprints and TTL), interactive `/tf init`, 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, `onBlock: "retry"`, `eval` machine gates, 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.
Known boundaries (tracked, bounded — no surprises mid-flow):
@@ -917,20 +939,22 @@ Known boundaries (tracked, bounded — no surprises mid-flow):
|---------|------|
| [`taskflow-core`](./packages/taskflow-core) | Host-neutral orchestration engine (zero host-SDK deps; only `typebox`) — runtime, DSL, cache, verify |
| [`taskflow-mcp-core`](./packages/taskflow-mcp-core) | Host-neutral MCP server (stdio JSON-RPC + `taskflow_*` tools + DAG renderer); depends on core |
-| [`taskflow-hosts`](./packages/taskflow-hosts) | Shared host-runner collection — the codex/claude/opencode `SubagentRunner` impls + their argv builders + event-stream parsers; depends on core |
+| [`taskflow-hosts`](./packages/taskflow-hosts) | Shared host-runner collection — the codex/claude/opencode/grok `SubagentRunner` impls + their argv builders + event-stream parsers; depends on core |
| [`pi-taskflow`](./packages/pi-taskflow) | Pi extension adapter — `taskflow` tool + `/tf` commands (what `pi install npm:pi-taskflow` gives you) |
| [`codex-taskflow`](./packages/codex-taskflow) | Codex MCP server + bin + [Codex plugin](./packages/codex-taskflow/plugin) (re-exports the runner from `taskflow-hosts`) ([guide](./docs/codex-mcp.md)) |
| [`claude-taskflow`](./packages/claude-taskflow) | Claude Code MCP server + bin + [Claude Code plugin](./packages/claude-taskflow/plugin) (re-exports the runner from `taskflow-hosts`) ([guide](./docs/claude-mcp.md)) |
| [`opencode-taskflow`](./packages/opencode-taskflow) | OpenCode MCP server + bin + [OpenCode config scaffold](./packages/opencode-taskflow/plugin) (re-exports the runner from `taskflow-hosts`) ([guide](./docs/opencode-mcp.md)) |
+| [`grok-taskflow`](./packages/grok-taskflow) | Grok Build MCP server + bin + [Grok plugin](./packages/grok-taskflow/plugin) (re-exports the runner from `taskflow-hosts`) ([guide](./docs/grok-mcp.md)) |
```bash
pnpm install
pnpm run typecheck # tsc --noEmit across all packages (no build needed)
pnpm test # unit tests — no network, no process spawning
-pnpm run test:hosts # host-runner tests only (also: test:pi, test:codex, test:claude, test:opencode)
+pnpm run test:hosts # host-runner tests only (also: test:pi, test:codex, test:claude, test:opencode, test:grok)
pnpm run build # emit dist/*.js + .d.ts for all seven packages
pnpm run test:e2e-codex # codex executor e2e (needs `codex` + model access)
pnpm run test:e2e-codex-mcp # codex MCP server e2e
+pnpm run test:e2e-grok-mcp # grok MCP server e2e (no live model required)
```
The pi end-to-end suites spawn live `pi` subagents and are run directly (they use
diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md
new file mode 100644
index 0000000..80f0363
--- /dev/null
+++ b/docs/grok-mcp.md
@@ -0,0 +1,193 @@
+# Using taskflow from Grok Build (MCP)
+
+taskflow runs on [Grok Build](https://docs.x.ai/build/overview) in two
+directions, both built on the host-neutral `SubagentRunner` seam
+(`packages/taskflow-core/src/host/runner-types.ts`):
+
+1. **Grok Build as the executor** — a taskflow's subagents run as
+ `grok -p --output-format streaming-json` sessions
+ (`packages/taskflow-hosts/src/grok-runner.ts`).
+2. **Grok Build as the caller** — taskflow is exposed to a Grok Build user as
+ an **MCP server**, so the `taskflow_*` tools appear inside the session. The
+ MCP protocol, tools, and rendering all live in the host-neutral
+ `taskflow-mcp-core` package (`packages/taskflow-mcp-core/src/mcp/`); the
+ grok adapter just binds them to the Grok subagent runner
+ (`packages/grok-taskflow/src/mcp/`). This is the direction described here.
+
+The MCP server is dependency-free: it speaks JSON-RPC 2.0 over stdio on Node
+built-ins (`packages/taskflow-mcp-core/src/mcp/jsonrpc.ts`), so taskflow keeps its
+**zero runtime dependencies** guarantee — no `@modelcontextprotocol/sdk`.
+
+Official Grok docs used for this integration:
+
+- Plugins: `~/.grok/docs/user-guide/09-plugins.md` / [docs.x.ai skills & plugins](https://docs.x.ai/build/features/skills-plugins-marketplaces)
+- MCP servers: `~/.grok/docs/user-guide/07-mcp-servers.md`
+- Headless / streaming-json: `~/.grok/docs/user-guide/14-headless-mode.md`
+
+## Install (recommended): the Grok Build plugin
+
+The zero-config path once `grok-taskflow` is on npm. Install the plugin and its
+MCP server plus a routing skill are registered automatically:
+
+```sh
+# From the published package / marketplace entry (when available):
+grok plugin install --trust
+# e.g. local checkout (skills + plugin manifest):
+grok plugin install /abs/path/to/taskflow/packages/grok-taskflow/plugin --trust
+grok plugin enable taskflow # plugins may be disabled until enabled
+```
+
+The plugin declares its MCP server via `npx` (a version-pinned
+`grok-taskflow`), so the server is fetched and launched on demand when the
+package is published. Verify:
+
+```sh
+grok plugin list # → taskflow installed / enabled
+grok plugin details taskflow
+grok mcp list # → taskflow …
+grok inspect # plugins + MCP + skills with source labels
+```
+
+The bundled skill tells Grok *when* to reach for the tools (multi-phase or
+fan-out work), so you usually don't have to name them explicitly. In the TUI,
+open `/plugins` or `/mcps` to inspect components.
+
+### From a monorepo checkout (dogfood / pre-publish)
+
+Until `grok-taskflow` is published to npm, point MCP at the **built** local bin
+(plugin install still gives you the skill + manifest):
+
+```sh
+cd /path/to/taskflow
+pnpm install
+pnpm --filter taskflow-core build
+pnpm --filter taskflow-mcp-core build
+pnpm --filter taskflow-hosts build
+pnpm --filter grok-taskflow build
+
+grok plugin install ./packages/grok-taskflow/plugin --trust
+grok plugin enable taskflow
+
+# Local stdio MCP (overrides / complements npx until publish):
+grok mcp add taskflow -- \
+ node "$(pwd)/packages/grok-taskflow/dist/mcp/bin.js"
+```
+
+Optional: raise startup timeout for cold `npx` (when using the published form):
+
+```toml
+# ~/.grok/config.toml
+[mcp_servers.taskflow]
+startup_timeout_sec = 60
+tool_timeout_sec = 1800
+```
+
+## Permissions (the codex-sandbox analogue)
+
+Grok headless mode has no OS-level sandbox for tool calls. The runner maps each
+phase's tool whitelist as follows:
+
+- **Read-only phase** (no `write`/`edit`/`bash` / `run_terminal_cmd` /
+ `search_replace` in the phase/agent `tools`) →
+ `--tools read_file,grep,list_dir,web_search,web_fetch` so mutating tools are
+ not available, plus `--always-approve` so remaining tools never block on a
+ confirm prompt.
+- **Mutating phase** (or no whitelist) → `--always-approve` only. This is the
+ workspace-write equivalent **without an OS sandbox backstop** — the subagent
+ can run any built-in tool. Run flows you trust, in a repo you can
+ `git reset`, ideally in a throwaway worktree (`cwd: "worktree"`).
+
+Agent system prompts are passed with `--rules`. Model ids that look like
+unresolved `{{placeholders}}`, multi-segment openrouter paths, or pi thinking
+suffixes (`:xhigh`) are dropped so Grok uses its configured default.
+
+## Long-running flows and the tool-call timeout
+
+`taskflow_run` returns only after the **whole DAG finishes** — intermediate
+phase outputs stay in the runtime, so from Grok's side it's a single tool call
+that can run for many minutes. The plugin's `.mcp.json` ships
+`tool_timeout_sec: 1800` (30 minutes). For huge flows, split into a few smaller
+`taskflow_run` calls, or run detached and inspect with `taskflow_peek`.
+
+## Alternative: register the MCP server manually
+
+```sh
+pnpm add -g grok-taskflow
+grok mcp add taskflow -- grok-taskflow-mcp
+```
+
+Or with npx (no global install):
+
+```sh
+grok mcp add taskflow -- npx -y -p grok-taskflow@0.1.7 grok-taskflow-mcp
+```
+
+Verify:
+
+```sh
+grok mcp list
+grok mcp doctor taskflow
+```
+
+The server discovers saved flows and agents from its **launch cwd**, and each
+subagent a flow spawns is itself a `grok -p` process — no pi process needed.
+
+## Tools exposed
+
+| Tool | What it does |
+|------|--------------|
+| `taskflow_run` | Run a saved flow (`name`) or an inline `define` (full DAG or shorthand `{task}`/`{tasks}`/`{chain}`). Returns only the final phase output + a `runId`. |
+| `taskflow_list` | List saved flows discoverable from the cwd, with library metadata when available. |
+| `taskflow_show` | Show a saved flow as `{definition, library}`. |
+| `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. |
+| `taskflow_search` | Search the library before authoring. |
+| `taskflow_verify` | Statically verify a flow — no execution, zero tokens. |
+| `taskflow_compile` | Render a flow's DAG as a text outline (+ inline SVG when the client renders images). |
+| `taskflow_peek` | Inspect one phase's intermediate output from a stored run. Hard-truncated, read-only. |
+| `taskflow_trace` | Read-only timeline of a run's append-only event log. |
+| `taskflow_why_stale` / `taskflow_recompute` | Staleness analysis (recompute is dry-run only over MCP). |
+
+Grok namespaces MCP tools as `taskflow__taskflow_*` in some UIs; discover with
+`search_tool` then call via `use_tool`.
+
+## Use it
+
+Inside a Grok Build session (TUI or headless), just ask:
+
+```
+> List my saved taskflows.
+> Verify this flow: {name:"x", phases:[{id:"a",type:"agent",agent:"writer",task:"draft"}]}
+> Run the "release-train" taskflow.
+```
+
+Headless smoke (after MCP is registered):
+
+```sh
+grok -p "Call taskflow_verify on this define (do not run it):
+{name:'dogfood', phases:[{id:'a', type:'agent', agent:'executor', task:'noop', final:true}]}" \
+ --always-approve --output-format json
+```
+
+> **Note on approvals.** MCP-driven runs are non-interactive, so an `approval`
+> phase **auto-rejects** (fail-open). Prefer a `gate` (agent review) in flows
+> you run through the `taskflow_*` tools; use `approval` only in flows a human
+> runs interactively.
+
+## Remove
+
+```sh
+grok plugin uninstall taskflow # if installed as a plugin
+grok mcp remove taskflow # if registered manually
+```
+
+## Proof / tests
+
+- `pnpm run test:grok` — MCP binding unit tests (in-memory streams).
+- `pnpm run test:e2e-grok-mcp` — spawns `bin.ts` over a real subprocess pipe
+ (no live Grok model needed).
+- `packages/taskflow-hosts/test/grok-args.test.ts` — pure argv contract
+ (`buildGrokArgs`) locked for CI.
+- `packages/taskflow-hosts/test/grok-runner.test.ts` — streaming-json parser
+ pinned to the official headless event shape.
+- `grok plugin validate packages/grok-taskflow/plugin` — official manifest
+ validator (skills + MCP components).
diff --git a/packages/taskflow-hosts/README.md b/packages/taskflow-hosts/README.md
index 64252a1..132b0e4 100644
--- a/packages/taskflow-hosts/README.md
+++ b/packages/taskflow-hosts/README.md
@@ -3,10 +3,10 @@
> Shared host-runner collection for [taskflow](https://github.com/heggria/taskflow).
This package holds the `SubagentRunner` implementations for taskflow's non-pi
-hosts — **codex**, **claude**, and **opencode** — plus their pure argv builders
-(`buildCodexArgs` / `buildClaudeArgs` / `buildOpencodeArgs`) and event-stream
-parsers. It is the **single place** host runners live; a new host adds a
-`-runner.ts` here.
+hosts — **codex**, **claude**, **opencode**, and **grok** — plus their pure argv
+builders (`buildCodexArgs` / `buildClaudeArgs` / `buildOpencodeArgs` /
+`buildGrokArgs`) and event-stream parsers. It is the **single place** host
+runners live; a new host adds a `-runner.ts` here.
## Why a separate package
@@ -17,21 +17,21 @@ Each host has two halves:
permission/model helpers.
2. **The delivery** — the per-host MCP server + bin + plugin scaffold, which is
that host ecosystem's install target (`codex plugin add`, `claude plugin
- install`, OpenCode config).
+ install`, OpenCode config, `grok plugin install`).
Half #1 is nearly identical in *shape* across hosts and changes for the same
reasons (a `taskflow-core` contract change, or a host CLI flag change). Half #2
is genuinely host-specific (different install mechanisms, different plugin
manifests). So #1 is collected here; #2 stays in `codex-taskflow` /
-`claude-taskflow` / `opencode-taskflow`, which import their runner from this
-package.
+`claude-taskflow` / `opencode-taskflow` / `grok-taskflow`, which import their
+runner from this package.
## Install
You usually don't install this directly — install the host delivery package:
```bash
-npm install -g codex-taskflow # or claude-taskflow / opencode-taskflow
+npm install -g codex-taskflow # or claude-taskflow / opencode-taskflow / grok-taskflow
```
For code-level use:
@@ -45,22 +45,37 @@ npm install taskflow-hosts
```ts
// one host, tree-shaken:
import { codexSubagentRunner, buildCodexArgs } from "taskflow-hosts/codex";
+import { grokSubagentRunner, buildGrokArgs } from "taskflow-hosts/grok";
// or the barrel:
-import { claudeSubagentRunner, opencodeSubagentRunner } from "taskflow-hosts";
+import {
+ claudeSubagentRunner,
+ opencodeSubagentRunner,
+ grokSubagentRunner,
+} from "taskflow-hosts";
```
Each runner export includes: the `SubagentRunner` (`codexSubagentRunner` / …),
the `runXxxAgentTask` function, the pure `buildXxxArgs` builder, the
`foldXxxEventLine` parser + `newXxxAccumulator`, and the permission/model
-helpers (`sandboxForTools`, `permissionArgsForTools`, `isReadOnlyPhase`,
-`resolveXxxModel`, `xxxBin`).
+helpers (`sandboxForTools`, `permissionArgsForTools` / `permissionArgsForGrokTools`,
+`isReadOnlyPhase`, `resolveXxxModel`, `xxxBin`).
+
+## Hosts
+
+| Host | CLI spawn | Delivery package |
+|------|-----------|------------------|
+| Codex | `codex exec --json` | `codex-taskflow` |
+| Claude Code | `claude -p --output-format stream-json` | `claude-taskflow` |
+| OpenCode | `opencode run --format json` | `opencode-taskflow` |
+| Grok Build | `grok -p --output-format streaming-json` | `grok-taskflow` |
## Adding a host
1. Add `-runner.ts` in `src/` (model it on an existing one — a pure
`buildXxxArgs` + the event parser + a `SubagentRunner` export).
-2. Add an export entry in `src/index.ts`.
+2. Add an export entry in `src/index.ts` (watch for name collisions on shared
+ helper names like `permissionArgsForTools`).
3. Add `*-runner.test.ts` (event-stream parser) + `*-args.test.ts` (argv
contract — CI-locked) in `test/`.
4. The host's *delivery* (MCP server/bin + plugin scaffold) goes in a
diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx
new file mode 100644
index 0000000..c27c57f
--- /dev/null
+++ b/website/content/docs/en/guides/grok-build.mdx
@@ -0,0 +1,189 @@
+---
+title: Using taskflow on Grok Build
+description: Install taskflow as a Grok Build plugin and orchestrate multi-phase workflows via MCP.
+---
+
+[Grok Build](https://docs.x.ai/build/overview) is xAI's terminal coding agent: TUI, headless mode, plugins, skills, and MCP. On Grok Build, taskflow ships as a plugin that registers a dependency-free MCP server plus a routing skill, so the model can call it when work spans multiple phases or fans out over many items.
+
+This page walks through install, verify, first run, permissions, and long-running flows. The full reference lives in [`docs/grok-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/grok-mcp.md) in the repo.
+
+## Install
+
+### From a published package / marketplace
+
+```bash title="Install the taskflow plugin"
+grok plugin install --trust
+grok plugin enable taskflow
+```
+
+That single install does three things at once:
+
+
+
+ **Registers the MCP server.** The plugin declares a `taskflow` server launched via `npx -y -p grok-taskflow grok-taskflow-mcp`, so Grok can reach the runtime over stdio.
+
+
+ **Installs the routing skill.** A bundled skill teaches Grok when to reach for the taskflow tools.
+
+
+ **Keeps zero runtime deps.** The MCP server speaks JSON-RPC 2.0 over stdio on Node built-ins — no `@modelcontextprotocol/sdk`.
+
+
+
+### From a monorepo checkout (pre-publish)
+
+Until the package is on npm, build locally and point MCP at the compiled bin:
+
+```bash title="Local dogfood install"
+pnpm install
+pnpm --filter taskflow-core build
+pnpm --filter taskflow-mcp-core build
+pnpm --filter taskflow-hosts build
+pnpm --filter grok-taskflow build
+
+grok plugin install ./packages/grok-taskflow/plugin --trust
+grok plugin enable taskflow
+
+grok mcp add taskflow -- \
+ node "$(pwd)/packages/grok-taskflow/dist/mcp/bin.js"
+```
+
+Restart an already-open Grok session (or run `grok inspect`) so plugins and MCP reload.
+
+## Verify the install
+
+```bash title="Verify the plugin and MCP server"
+grok plugin list
+grok plugin details taskflow
+grok mcp list
+grok mcp doctor taskflow
+grok inspect
+```
+
+You should see the plugin (skills + MCP components) and a healthy `taskflow` MCP server. In the TUI, `/plugins` and `/mcps` open the same extensions modal.
+
+## Run your first flow
+
+On Grok Build you invoke taskflow through MCP tools (discovered via `search_tool` / `use_tool`, often namespaced as `taskflow__taskflow_*`). The bundled skill routes multi-phase work automatically.
+
+### Just ask
+
+```text title="Describe the work in plain language"
+> List my saved taskflows.
+> Verify this flow, then run it: {name:"review-changes", phases:[...]}
+> Run the "review-changes" taskflow with dir set to src.
+```
+
+### Call the tool directly
+
+```json title="Run a saved flow via taskflow_run"
+{
+ "name": "review-changes",
+ "args": { "dir": "src" }
+}
+```
+
+```json title="Run an inline flow via taskflow_run"
+{
+ "define": {
+ "name": "quick-review",
+ "phases": [
+ {
+ "id": "discover",
+ "type": "agent",
+ "agent": "scout",
+ "task": "List changed source files under src/. Output ONLY a JSON array of {path} objects.",
+ "output": "json"
+ },
+ {
+ "id": "review-each",
+ "type": "map",
+ "over": "{steps.discover.json}",
+ "as": "file",
+ "agent": "security-reviewer",
+ "task": "Review {file.path} for security risks. Return one paragraph.",
+ "dependsOn": ["discover"],
+ "concurrency": 4
+ },
+ {
+ "id": "summarize",
+ "type": "reduce",
+ "from": ["review-each"],
+ "agent": "writer",
+ "task": "Combine these reviews into one prioritized summary:\n{steps.review-each.output}",
+ "dependsOn": ["review-each"],
+ "final": true
+ }
+ ]
+ }
+}
+```
+
+Only the phase marked `final: true` is returned. Intermediate transcripts stay inside the runtime.
+
+## Check a flow before spending tokens
+
+```json title="Verify a flow via taskflow_verify"
+{
+ "define": {
+ "name": "quick-review",
+ "phases": [{ "id": "a", "type": "agent", "agent": "executor", "task": "noop", "final": true }]
+ }
+}
+```
+
+Or render the DAG with `taskflow_compile` (text outline + optional inline SVG).
+
+## How subagents run
+
+Each phase's subagent runs as an isolated `grok -p --output-format streaming-json` session — a real Grok Build headless process. The server discovers saved flows and agents from its **launch cwd**.
+
+### Permission mapping
+
+| Phase tools | Flags | What it means |
+|---|---|---|
+| Read-only (no write/edit/bash) | `--tools read_file,grep,list_dir,web_search,web_fetch` + `--always-approve` | Mutating tools are not available. |
+| Mutating (or no whitelist) | `--always-approve` | Full tools; **no OS sandbox backstop**. Prefer `cwd: "worktree"`. |
+
+
+ Mutating phases use `--always-approve` with no OS sandbox. Only run flows you trust, preferably in a throwaway worktree.
+
+
+## Long-running flows
+
+**`taskflow_run` is synchronous** — the tool call returns only when the whole DAG finishes. The plugin ships `tool_timeout_sec: 1800`. Split huge graphs, or inspect later with `taskflow_peek`.
+
+## Approvals in MCP mode
+
+MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fail-open). Prefer a `gate` for agent review.
+
+## Tool reference
+
+| Tool | Purpose |
+|---|---|
+| `taskflow_run` | Run a saved or inline flow. Returns final output + `runId`. |
+| `taskflow_list` / `taskflow_show` | Discover and inspect saved flows. |
+| `taskflow_save` / `taskflow_search` | Library write + search. |
+| `taskflow_verify` / `taskflow_compile` | Static check and DAG render (zero tokens). |
+| `taskflow_peek` / `taskflow_trace` | Post-hoc debugging. |
+
+## Alternative: register the MCP server manually
+
+```bash title="Manual MCP registration"
+pnpm add -g grok-taskflow
+grok mcp add taskflow -- grok-taskflow-mcp
+# or: grok mcp add taskflow -- npx -y -p grok-taskflow@0.1.7 grok-taskflow-mcp
+```
+
+## Remove
+
+```bash title="Remove taskflow from Grok Build"
+grok plugin uninstall taskflow
+grok mcp remove taskflow
+```
+
+## Next
+
+- Full repo guide: [`docs/grok-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/grok-mcp.md)
+- [Syntax reference](/en/docs/syntax) for every phase type
+- [Guides index](/en/docs/guides) for patterns and case studies
diff --git a/website/content/docs/en/guides/index.mdx b/website/content/docs/en/guides/index.mdx
index 67bef25..4eaa834 100644
--- a/website/content/docs/en/guides/index.mdx
+++ b/website/content/docs/en/guides/index.mdx
@@ -7,7 +7,7 @@ The Guides are the *practical* half of these docs. Where [Concepts](/en/docs/con
They come in two flavors:
-- **By host.** taskflow runs on Pi, Codex, Claude Code, and OpenCode. Each host has its own install path and invocation surface, so each gets a dedicated walkthrough.
+- **By host.** taskflow runs on Pi, Codex, Claude Code, OpenCode, and Grok Build. Each host has its own install path and invocation surface, so each gets a dedicated walkthrough.
- **By pattern.** Once the basics work, some problems want a specific shape — a model that plans its own sub-flow at runtime, or a tournament that spawns competing drafts and picks a winner.
@@ -23,6 +23,15 @@ They come in two flavors:
Install the plugin, call `taskflow_run` over MCP, and handle long-running flows.
+
+ Install the Claude Code plugin and drive multi-phase work via MCP.
+
+
+ Register the MCP server in `opencode.json` and run isolated `opencode run` subagents.
+
+
+ Install the Grok plugin, call `taskflow_*` over MCP, and spawn `grok -p` subagents.
+
## By pattern
diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx
new file mode 100644
index 0000000..b4ce14e
--- /dev/null
+++ b/website/content/docs/zh-cn/guides/grok-build.mdx
@@ -0,0 +1,127 @@
+---
+title: 在 Grok Build 上使用 taskflow
+description: 将 taskflow 作为 Grok Build 插件安装,通过 MCP 编排多阶段工作流。
+---
+
+[Grok Build](https://docs.x.ai/build/overview) 是 xAI 的终端编码 agent:TUI、无头模式、插件、skills 与 MCP。在 Grok Build 上,taskflow 以插件形式发布,注册零依赖 MCP 服务器与路由 skill,模型在多阶段 / 扇出任务上会自动调用它。
+
+本页覆盖安装、验证、首次运行、权限与长时 flow。完整说明见仓库 [`docs/grok-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/grok-mcp.md)。
+
+## 安装
+
+### 已发布包 / 市场
+
+```bash title="安装 taskflow 插件"
+grok plugin install --trust
+grok plugin enable taskflow
+```
+
+一次安装完成三件事:
+
+
+
+ **注册 MCP 服务器。** 插件通过 `npx -y -p grok-taskflow grok-taskflow-mcp` 声明 `taskflow` 服务器。
+
+
+ **安装路由 skill。** 内置 skill 告诉 Grok 何时该用 taskflow 工具。
+
+
+ **保持零运行时依赖。** MCP 在 Node 内置 stdio 上讲 JSON-RPC 2.0。
+
+
+
+### 从 monorepo 检出(发布前 dogfood)
+
+包尚未上 npm 时,本地 build 并指向编译后的 bin:
+
+```bash title="本地 dogfood 安装"
+pnpm install
+pnpm --filter taskflow-core build
+pnpm --filter taskflow-mcp-core build
+pnpm --filter taskflow-hosts build
+pnpm --filter grok-taskflow build
+
+grok plugin install ./packages/grok-taskflow/plugin --trust
+grok plugin enable taskflow
+
+grok mcp add taskflow -- \
+ node "$(pwd)/packages/grok-taskflow/dist/mcp/bin.js"
+```
+
+已打开的会话请重启,或运行 `grok inspect` 重新发现插件与 MCP。
+
+## 验证安装
+
+```bash title="验证插件与 MCP"
+grok plugin list
+grok plugin details taskflow
+grok mcp list
+grok mcp doctor taskflow
+grok inspect
+```
+
+TUI 里可用 `/plugins`、`/mcps` 打开同一扩展面板。
+
+## 运行第一个 flow
+
+在 Grok Build 上通过 MCP 工具调用(`search_tool` / `use_tool`,部分 UI 会显示为 `taskflow__taskflow_*`)。
+
+### 直接描述
+
+```text title="用自然语言描述工作"
+> 列出我保存的 taskflow。
+> 先验证再运行这个 flow:{name:"review-changes", phases:[...]}
+> 运行 "review-changes",dir 设为 src。
+```
+
+### 直接调工具
+
+```json title="taskflow_run 运行已保存 flow"
+{
+ "name": "review-changes",
+ "args": { "dir": "src" }
+}
+```
+
+只有标了 `final: true` 的阶段会返回;中间 transcript 留在运行时内。
+
+## 零 token 静态检查
+
+先用 `taskflow_verify` 查环、悬空依赖与坏配置;再用 `taskflow_compile` 看 DAG 轮廓。
+
+## 子 agent 如何跑
+
+每个 phase 是隔离的 `grok -p --output-format streaming-json` 进程。
+
+| Phase tools | 行为 |
+|---|---|
+| 只读 | `--tools read_file,grep,list_dir,web_search,web_fetch` + `--always-approve` |
+| 可写 / 无白名单 | `--always-approve`(**无 OS sandbox**,优先 `cwd: "worktree"`) |
+
+## 长时 flow
+
+**`taskflow_run` 同步返回**——整图跑完才结束。插件默认 `tool_timeout_sec: 1800`。大图请拆分,或事后用 `taskflow_peek`。
+
+## MCP 下的 approval
+
+非交互运行时 `approval` 会 **auto-reject**。agent 评审请用 `gate`。
+
+## 手动注册 MCP
+
+```bash title="手动注册"
+pnpm add -g grok-taskflow
+grok mcp add taskflow -- grok-taskflow-mcp
+```
+
+## 卸载
+
+```bash title="卸载"
+grok plugin uninstall taskflow
+grok mcp remove taskflow
+```
+
+## 下一步
+
+- 仓库指南:[`docs/grok-mcp.md`](https://github.com/heggria/taskflow/blob/main/docs/grok-mcp.md)
+- [语法参考](/zh-cn/docs/syntax)
+- [指南索引](/zh-cn/docs/guides)
diff --git a/website/content/docs/zh-cn/guides/index.mdx b/website/content/docs/zh-cn/guides/index.mdx
index 29a1159..fae0a4d 100644
--- a/website/content/docs/zh-cn/guides/index.mdx
+++ b/website/content/docs/zh-cn/guides/index.mdx
@@ -7,7 +7,7 @@ description: 在不同宿主和模式下使用 taskflow 的实用指南。
它们分两种:
-- **按宿主。** taskflow 跑在 Pi、Codex、Claude Code 和 OpenCode 上。每个宿主有自己的安装路径和调用入口,所以各自有专门的走查。
+- **按宿主。** taskflow 跑在 Pi、Codex、Claude Code、OpenCode 和 Grok Build 上。每个宿主有自己的安装路径和调用入口,所以各自有专门的走查。
- **按模式。** 基础跑通之后,有些问题想要特定的形状——一个在运行时规划自己子流程的模型,或者一个生成多份竞争草稿再由评委挑出最佳的锦标赛。
@@ -23,6 +23,15 @@ description: 在不同宿主和模式下使用 taskflow 的实用指南。
安装插件、通过 MCP 调用 `taskflow_run`,并处理长时间运行的 flow。
+
+ 安装 Claude Code 插件,通过 MCP 驱动多阶段工作。
+
+
+ 在 `opencode.json` 注册 MCP,用隔离的 `opencode run` 跑子 agent。
+
+
+ 安装 Grok 插件,通过 MCP 调用 `taskflow_*`,子 agent 为 `grok -p`。
+
## 按模式
From e7b504d6edf2f6ff687a4f407c3eeb0920e962b0 Mon Sep 17 00:00:00 2001
From: heggria
Date: Thu, 9 Jul 2026 13:23:52 +0800
Subject: [PATCH 14/51] docs: complete high-priority Grok host coverage
Backfill README.zh-CN, RELEASE, AGENTS package counts, package
descriptions, website nav/meta, getting-started host tabs, and host
lists in reference/community/templates/comparisons (en + zh).
---
AGENTS.md | 8 ++--
README.md | 2 +-
README.zh-CN.md | 39 +++++++++++++++----
RELEASE.md | 27 ++++++++-----
packages/taskflow-core/package.json | 2 +-
packages/taskflow-mcp-core/package.json | 2 +-
website/content/docs/en/community.mdx | 2 +-
.../en/comparisons/taskflow-vs-langgraph.mdx | 8 ++--
.../en/comparisons/taskflow-vs-subagents.mdx | 4 +-
website/content/docs/en/getting-started.mdx | 39 +++++++++++++++++--
website/content/docs/en/index.mdx | 4 +-
website/content/docs/en/meta.json | 1 +
.../content/docs/en/reference/commands.mdx | 4 +-
website/content/docs/en/reference/index.mdx | 4 +-
website/content/docs/en/templates.mdx | 2 +-
website/content/docs/zh-cn/blog/index.mdx | 2 +-
website/content/docs/zh-cn/community.mdx | 2 +-
.../comparisons/taskflow-vs-langgraph.mdx | 8 ++--
.../comparisons/taskflow-vs-subagents.mdx | 4 +-
.../content/docs/zh-cn/getting-started.mdx | 39 +++++++++++++++++--
.../zh-cn/guides/code-audit-case-study.mdx | 2 +-
.../guides/headline-tournament-case-study.mdx | 2 +-
website/content/docs/zh-cn/guides/index.mdx | 2 +-
.../guides/migration-planner-case-study.mdx | 2 +-
website/content/docs/zh-cn/index.mdx | 4 +-
website/content/docs/zh-cn/meta.json | 1 +
.../content/docs/zh-cn/reference/commands.mdx | 4 +-
.../content/docs/zh-cn/reference/index.mdx | 4 +-
website/content/docs/zh-cn/showcase/index.mdx | 2 +-
website/content/docs/zh-cn/templates.mdx | 2 +-
30 files changed, 163 insertions(+), 65 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index bcea127..1d6c021 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,7 +4,7 @@
## Project Overview
-taskflow is a **declarative DAG orchestration runtime** for coding agents — it runs on the [Pi coding agent](https://pi.dev), on [OpenAI Codex](https://github.com/openai/codex), on [Claude Code](https://claude.com/product/claude-code), and on [OpenCode](https://opencode.ai). It lets users define multi-phase workflows (fan-out, gate, loop, tournament, approval, sub-flow composition) as JSON DSL, executes them via isolated subagent processes, and returns only the final result — intermediate transcripts never enter the host context window.
+taskflow is a **declarative DAG orchestration runtime** for coding agents — it runs on the [Pi coding agent](https://pi.dev), on [OpenAI Codex](https://github.com/openai/codex), on [Claude Code](https://claude.com/product/claude-code), on [OpenCode](https://opencode.ai), and on [Grok Build](https://docs.x.ai/build/overview). It lets users define multi-phase workflows (fan-out, gate, loop, tournament, approval, sub-flow composition) as JSON DSL, executes them via isolated subagent processes, and returns only the final result — intermediate transcripts never enter the host context window.
**Language:** TypeScript (ES2022, ESM, `--experimental-strip-types` for direct execution in dev)\
**Runtime:** Node.js ≥ 22.19 (uses `fs.globSync`, `Atomics.wait`)\
@@ -153,7 +153,7 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil
## Development Commands
```bash
-pnpm install # links the seven workspace packages
+pnpm install # links the eight workspace packages (+ website)
pnpm run typecheck # tsc --noEmit across all packages (resolves taskflow-core to src via the dev condition)
pnpm test # full unit suite (node --experimental-strip-types --test)
pnpm run test:hosts # taskflow-hosts tests only
@@ -162,7 +162,7 @@ pnpm run test:codex # codex-adapter tests only
pnpm run test:claude # claude-adapter tests only
pnpm run test:opencode # opencode-adapter tests only
pnpm run test:grok # grok-adapter tests only
-pnpm run build # emit dist/*.js + .d.ts for all seven packages
+pnpm run build # emit dist/*.js + .d.ts for all eight packages
pnpm run test:e2e-codex # codex executor e2e (needs live codex + model access)
pnpm run test:e2e-codex-mcp # codex MCP stdio e2e (src)
pnpm run test:e2e-codex-mcp-full # codex MCP comprehensive e2e against the built dist (runs build first)
@@ -277,7 +277,7 @@ All engine files live in `packages/taskflow-core/src/`; the pi entry lives in `p
| `runtime.ts` | Core orchestration: `executeTaskflow()`, `executePhase()`, all 10 phase types |
| `schema.ts` | DSL types, validation, desugar, topo sort, cycle detection |
| `runner-core.ts` | Host-neutral runner helpers: failure classification, NDJSON accumulator, error sanitization, `mapWithConcurrencyLimit`, AND `runSubagentProcess` (the shared spawn/idle/abort/classify block every host runner delegates to) + `unknownAgentResult` |
-| `taskflow-mcp-core/src/mcp/server.ts` | Host-neutral MCP server: the `taskflow_*` tool schemas + handlers, parameterized by a `SubagentRunner` (codex/claude/opencode adapters bind their runner + a thin bin) |
+| `taskflow-mcp-core/src/mcp/server.ts` | Host-neutral MCP server: the `taskflow_*` tool schemas + handlers, parameterized by a `SubagentRunner` (codex/claude/opencode/grok adapters bind their runner + a thin bin) |
| `pi-taskflow/src/runner.ts` | Pi subagent spawn (`pi --mode json`), idle watchdog; re-exports the core helpers |
| `taskflow-hosts/src/codex-runner.ts` | Codex subagent spawn (`codex exec --json`); `codexSubagentRunner` + `buildCodexArgs` |
| `taskflow-hosts/src/claude-runner.ts` | Claude Code subagent spawn (`claude -p --output-format stream-json`); `claudeSubagentRunner` + `buildClaudeArgs` + permission mapping |
diff --git a/README.md b/README.md
index c5c54fe..23cc2f8 100644
--- a/README.md
+++ b/README.md
@@ -951,7 +951,7 @@ pnpm install
pnpm run typecheck # tsc --noEmit across all packages (no build needed)
pnpm test # unit tests — no network, no process spawning
pnpm run test:hosts # host-runner tests only (also: test:pi, test:codex, test:claude, test:opencode, test:grok)
-pnpm run build # emit dist/*.js + .d.ts for all seven packages
+pnpm run build # emit dist/*.js + .d.ts for all eight packages
pnpm run test:e2e-codex # codex executor e2e (needs `codex` + model access)
pnpm run test:e2e-codex-mcp # codex MCP server e2e
pnpm run test:e2e-grok-mcp # grok MCP server e2e (no live model required)
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 68fbd2b..661d03f 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -10,7 +10,7 @@
-
+