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 @@ CI status 1140 tests dogfooded - runs on Pi, Codex, Claude Code, and OpenCode + runs on Pi, Codex, Claude Code, OpenCode, and Grok Build

@@ -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 @@ CI status 1140 tests dogfooded - runs on Pi, Codex, Claude Code, and OpenCode + runs on Pi, Codex, Claude Code, OpenCode, and Grok Build

@@ -30,7 +30,7 @@

面向编码智能体子代理(subagent)的声明式、可验证的「任务图」。
不是你要去「写脚本」的 workflow——而是你去「声明」的一张 DAG。并发分发(fan out)· 门控(gate)· 恢复(resume)· 保存为命令——中间结果始终远离你的上下文窗口(context window)。
-可运行于 Pi 编码智能体、OpenAI CodexClaude CodeOpenCode

+可运行于 Pi 编码智能体、OpenAI CodexClaude CodeOpenCodeGrok Build

@@ -48,13 +48,17 @@ claude plugin install claude-taskflow@taskflow # OpenCode — 向 opencode.json 添加 MCP server(见 OpenCode 指南) opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build +grok plugin install --trust # 例如 checkout 中: ./packages/grok-taskflow/plugin +# 或: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` --- **`workflow` 是在「流动」,而 `taskflow` 是一张「图」。** 其他编排框架让模型去「写脚本」——命令式的代码逐步流动,而那张图藏在控制流里。`taskflow` 恰恰相反:你把工作**声明**为一张由离散、具名的**任务(task)节点**、通过 `dependsOn` 边连接而成的图——而运行时会在花掉一个 token 之前,*先验证这张图。* -你已经熟悉内置子代理(subagent)工具的 `task` / `tasks` / `chain` 了。`taskflow` 使用**完全相同的简写语法**——所以你现有的委托立刻就能变成**可追踪、可恢复、可按名保存**的流程(在 Pi 上,已保存的流程会变成一条 `/tf:` 命令;在 Codex、Claude Code、OpenCode 上,用 `taskflow_run` 按名运行)。当你超越简写语法时,完整的 DSL 为你提供真正的 DAG:针对数十个项目的动态并发分发、条件路由、质量门控、人工审批、重试,以及硬性费用上限。 +你已经熟悉内置子代理(subagent)工具的 `task` / `tasks` / `chain` 了。`taskflow` 使用**完全相同的简写语法**——所以你现有的委托立刻就能变成**可追踪、可恢复、可按名保存**的流程(在 Pi 上,已保存的流程会变成一条 `/tf:` 命令;在 Codex、Claude Code、OpenCode、Grok Build 上,用 `taskflow_run` 按名运行)。当你超越简写语法时,完整的 DSL 为你提供真正的 DAG:针对数十个项目的动态并发分发、条件路由、质量门控、人工审批、重试,以及硬性费用上限。 而且自始至终,**只有最终阶段(final phase)才会进入你的对话。** 每一个中间转录都留在运行时中,永远不会进入你的上下文窗口。 @@ -91,7 +95,7 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp | **拓扑结构** | 链式 / 平面并行 | **带分层并发 + 路由的 DAG** | | **中间结果** | 在你的上下文窗口中 | **在运行时中——不在你的上下文里** | | **规模** | 少量任务 | **动态 `map` 并发分发,覆盖数十个项目** | -| **可复用** | 每次重新描述 | **按名保存(Pi 上为 `/tf:`;Codex、Claude Code、OpenCode 上用 `taskflow_run` 按名运行)** | +| **可复用** | 每次重新描述 | **按名保存(Pi 上为 `/tf:`;Codex、Claude Code、OpenCode、Grok Build 上用 `taskflow_run` 按名运行)** | | **可恢复** | ✗ | **✓ 跨会话(cross-session)——已缓存的阶段自动跳过** | | **质量门控** | ✗ | **`gate` 阶段,在 `VERDICT: BLOCK` 时停止** | | **条件路由** | ✗ | **`when` 守卫 + `join: any` 或连接(OR-join)** | @@ -124,7 +128,7 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp ## 与其他 Pi 扩展的对比 -> 本节为 **Pi 专属** ——它将 `pi-taskflow` 与 Pi 生态中的其他包对比。如果你在 Codex、Claude Code 或 OpenCode 上,可直接跳到[阶段类型](#阶段类型);引擎与 DSL 完全相同。 +> 本节为 **Pi 专属** ——它将 `pi-taskflow` 与 Pi 生态中的其他包对比。如果你在 Codex、Claude Code、OpenCode 或 Grok Build 上,可直接跳到[阶段类型](#阶段类型);引擎与 DSL 完全相同。 Pi 生态现在有 **20 多个委托、工作流和编排扩展**——每个在各自领域都很出色。以下是一份诚实的定位图(已对照每个包截至 2026 年 6 月的最新 npm 发布版核实)。完整的对比——每个包的优缺点——请参见 [`PI-ECOSYSTEM.md`](./docs/internal/PI-ECOSYSTEM.md)。更广泛的非 Pi 生态对比(LangGraph、Temporal、CrewAI、Mastra……)请参见 [`COMPETITORS.md`](./docs/internal/COMPETITORS.md)。 @@ -211,6 +215,24 @@ opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp 服务器通过 `npx`(`opencode-taskflow`)拉起,每个阶段的子代理以隔离的 `opencode run` 会话运行;OpenCode 还会自动发现随包的路由 skill(`**/SKILL.md`)。参见 [OpenCode 指南](./docs/opencode-mcp.md)。 +### 在 Grok Build 上 + +taskflow 以 Grok Build **插件** 的形式发布——安装一次,`taskflow_*` MCP 工具与路由 skill 便自动生效: + +```bash +# 从 monorepo checkout(发布前 dogfood): +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 plugin install --trust +# # 或: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp +``` + +插件在包上 npm 后通过 `npx` 拉起 MCP;checkout 时可指向本地 build 的 bin。每个阶段的子代理以隔离的 `grok -p --output-format streaming-json` 会话运行。参见 [Grok Build 指南](./docs/grok-mcp.md)。 + ### 简写语法(与内置工具相同的格式) ```jsonc @@ -513,7 +535,7 @@ Review the audit below. If any endpoint is missing auth, end with ## 命令 -保存的流程变成 CLI 快捷方式。**这些 `/tf` 命令仅限 Pi**(在 Pi 会话中运行)。在 Codex、Claude Code、OpenCode 上改用 `taskflow_*` MCP 工具——`taskflow_list` / `taskflow_show` / `taskflow_run`(按 `name`)/ `taskflow_verify` / `taskflow_compile` / `taskflow_peek`。 +保存的流程变成 CLI 快捷方式。**这些 `/tf` 命令仅限 Pi**(在 Pi 会话中运行)。在 Codex、Claude Code、OpenCode、Grok Build 上改用 `taskflow_*` MCP 工具——`taskflow_list` / `taskflow_show` / `taskflow_run`(按 `name`)/ `taskflow_verify` / `taskflow_compile` / `taskflow_peek`。 | 命令 | 功能 | |---|---| @@ -526,7 +548,7 @@ Review the audit below. If any endpoint is missing auth, end with | `/tf init` | **交互式映射模型角色**到你的已启用模型(写入 `~/.pi/agent/settings.json`) | | `/tf: [args]` | 快捷方式——一键运行流程 | -工具动作(由模型在 Pi 上使用):`run`(内联 `define` 或已保存的 `name`)、`save`、`resume`、`list`、`agents`、`init`、`verify`、`compile`、`ir`、`provenance`、`why-stale`、`recompute`、`cache-clear`。在 Codex、Claude Code、OpenCode 上暴露的 MCP 工具为 `taskflow_run` / `taskflow_list` / `taskflow_show` / `taskflow_verify` / `taskflow_compile` / `taskflow_peek`。 +工具动作(由模型在 Pi 上使用):`run`(内联 `define` 或已保存的 `name`)、`save`、`resume`、`list`、`agents`、`init`、`verify`、`compile`、`ir`、`provenance`、`why-stale`、`recompute`、`cache-clear`。在 Codex、Claude Code、OpenCode、Grok Build 上暴露的 MCP 工具为 `taskflow_run` / `taskflow_list` / `taskflow_show` / `taskflow_verify` / `taskflow_compile` / `taskflow_peek`。 ## 后台(detached)执行 @@ -770,7 +792,7 @@ provided files. Report violations grouped by file. No fixes. ## 状态与边界 -**v0.1.7**——当前发布版。完整历史详见 [CHANGELOG](./CHANGELOG.md)。本版修复:**文件 loader 现在会报告文件失败的原因 + 解析位置**(行/列),而非合并成一句"not found or unparseable"——`defineFile`、saved flow、run 记录、library sidecar 都区分*缺失*与*损坏*,手写流程里一个裸换行几秒就能定位;`safeParse` 对 LLM 输出仍保持宽松。同时修复了 pi-taskflow 升级提示每会话重复打印的问题。**v0.1.6** 新增 **库 Phase 1**(先搜后写 + 可复用流程资产)、**`defineFile` 参数**(从磁盘路径 verify/compile/run 流程)、以及流程定义文件的 **JSONC 注释支持**(`//` 与 `/* */` 注释 + 尾逗号,由零依赖的 `parseJsonc` 解析)。**v0.1.5** 新增了 **Claude Code 与 OpenCode 两个宿主**、**将 MCP 服务器拆为独立的 `taskflow-mcp-core` 包**,并**将三个宿主运行器去重**为共享的 `runSubagentProcess`。基线:**七个包的多宿主 monorepo**——宿主无关的 `taskflow-core` 引擎、宿主无关的 `taskflow-mcp-core` MCP 服务器、共享宿主运行器的 `taskflow-hosts`,加上 `pi-taskflow`(Pi 适配器)、`codex-taskflow`、`claude-taskflow`、`opencode-taskflow`(后三者为交付包,通过 `taskflow-hosts` 复用 runner + MCP bin + 插件/配置),共享 `taskflow-mcp-core` 中的宿主无关 MCP 服务器。**共享上下文树**:可选开启(`shareContext` / `contextSharing`)的黑板 + 监督工具(`ctx_read`/`ctx_write` 水平复用、`ctx_report`/`ctx_spawn` 垂直监督)。**工作区隔离**:阶段的 `cwd` 接受保留关键字 `temp`/`dedicated`/`worktree`,运行时分配隔离目录(或一条一次性分支上的 git worktree)并在阶段结束后拆除。**后台(detached)执行**:运行可脱离会话后台执行。早期功能:循环至完成(`loop`)、锦标赛(best-of-N 带评判者)、跨运行记忆化(基于 git/文件/glob/环境指纹和 TTL 的内容寻址缓存)、交互式 `/tf init`、18 个内置代理及模型角色。完整的控制流与可靠性层(`when` 守卫、`join: any`、`retry`/回退、`approval`、`flow` 组合、`budget` 上限、`eval` 机器门控、空闲看门狗)构建在 DSL + DAG 运行时(`agent`/`parallel`/`map`/`gate`/`reduce`)之上。支持内联 + 已保存流程、跨会话恢复、实时进度和上下文隔离。一次运行作为一个流式工具调用执行。 +**v0.1.7**——当前发布版。完整历史详见 [CHANGELOG](./CHANGELOG.md)。本版修复:**文件 loader 现在会报告文件失败的原因 + 解析位置**(行/列),而非合并成一句"not found or unparseable"——`defineFile`、saved flow、run 记录、library sidecar 都区分*缺失*与*损坏*,手写流程里一个裸换行几秒就能定位;`safeParse` 对 LLM 输出仍保持宽松。同时修复了 pi-taskflow 升级提示每会话重复打印的问题。**v0.1.6** 新增 **库 Phase 1**(先搜后写 + 可复用流程资产)、**`defineFile` 参数**(从磁盘路径 verify/compile/run 流程)、以及流程定义文件的 **JSONC 注释支持**(`//` 与 `/* */` 注释 + 尾逗号,由零依赖的 `parseJsonc` 解析)。**v0.1.5** 新增了 **Claude Code 与 OpenCode 两个宿主**、**将 MCP 服务器拆为独立的 `taskflow-mcp-core` 包**,并**将三个宿主运行器去重**为共享的 `runSubagentProcess`。基线:**八个包的多宿主 monorepo**——宿主无关的 `taskflow-core` 引擎、宿主无关的 `taskflow-mcp-core` MCP 服务器、共享宿主运行器的 `taskflow-hosts`(含 codex/claude/opencode/grok),加上 `pi-taskflow`(Pi 适配器)、`codex-taskflow`、`claude-taskflow`、`opencode-taskflow`、`grok-taskflow`(后四者为交付包,通过 `taskflow-hosts` 复用 runner + MCP bin + 插件/配置),共享 `taskflow-mcp-core` 中的宿主无关 MCP 服务器。**共享上下文树**:可选开启(`shareContext` / `contextSharing`)的黑板 + 监督工具(`ctx_read`/`ctx_write` 水平复用、`ctx_report`/`ctx_spawn` 垂直监督)。**工作区隔离**:阶段的 `cwd` 接受保留关键字 `temp`/`dedicated`/`worktree`,运行时分配隔离目录(或一条一次性分支上的 git worktree)并在阶段结束后拆除。**后台(detached)执行**:运行可脱离会话后台执行。早期功能:循环至完成(`loop`)、锦标赛(best-of-N 带评判者)、跨运行记忆化(基于 git/文件/glob/环境指纹和 TTL 的内容寻址缓存)、交互式 `/tf init`、18 个内置代理及模型角色。完整的控制流与可靠性层(`when` 守卫、`join: any`、`retry`/回退、`approval`、`flow` 组合、`budget` 上限、`eval` 机器门控、空闲看门狗)构建在 DSL + DAG 运行时(`agent`/`parallel`/`map`/`gate`/`reduce`)之上。支持内联 + 已保存流程、跨会话恢复、实时进度和上下文隔离。一次运行作为一个流式工具调用执行。 已知边界(已追踪、有限定——不会在流程中途出现意外): @@ -795,6 +817,7 @@ provided files. Report violations grouped by file. No fixes. | [`codex-taskflow`](./packages/codex-taskflow) | Codex 子代理运行器 + MCP bin,及 [Codex 插件](./packages/codex-taskflow/plugin)([指南](./docs/codex-mcp.md)) | | [`claude-taskflow`](./packages/claude-taskflow) | Claude Code 子代理运行器 + MCP bin,及 [Claude Code 插件](./packages/claude-taskflow/plugin)([指南](./docs/claude-mcp.md)) | | [`opencode-taskflow`](./packages/opencode-taskflow) | OpenCode 子代理运行器 + MCP bin,及 [OpenCode 配置脚手架](./packages/opencode-taskflow/plugin)([指南](./docs/opencode-mcp.md)) | +| [`grok-taskflow`](./packages/grok-taskflow) | Grok Build 子代理运行器 + MCP bin,及 [Grok 插件](./packages/grok-taskflow/plugin)([指南](./docs/grok-mcp.md)) | ```bash pnpm install diff --git a/RELEASE.md b/RELEASE.md index 81e53ac..6c6ab0c 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,22 +1,23 @@ # Release Guide (monorepo) -taskflow is a monorepo of seven independently published packages: +taskflow is a monorepo of eight independently published packages: | Package | npm name | What it is | |---------|----------|------------| | `packages/taskflow-core` | **`taskflow-core`** | Host-neutral engine (DSL, runtime, cache, verify). Zero host SDK deps. | | `packages/taskflow-mcp-core` | **`taskflow-mcp-core`** | Host-neutral MCP server (stdio JSON-RPC + taskflow_* tools + DAG renderer). Depends on core. | -| `packages/taskflow-hosts` | **`taskflow-hosts`** | Shared host-runner collection: codex/claude/opencode `SubagentRunner` impls + argv builders + event-stream parsers. Depends on core. | +| `packages/taskflow-hosts` | **`taskflow-hosts`** | Shared host-runner collection: codex/claude/opencode/grok `SubagentRunner` impls + argv builders + event-stream parsers. Depends on core. | | `packages/pi-taskflow` | **`pi-taskflow`** | Pi extension adapter. Keeps the original published name (no break for existing users). | | `packages/codex-taskflow` | **`codex-taskflow`** | Codex delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + plugin. | | `packages/claude-taskflow` | **`claude-taskflow`** | Claude Code delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + plugin. | | `packages/opencode-taskflow` | **`opencode-taskflow`** | OpenCode delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + config scaffold. | +| `packages/grok-taskflow` | **`grok-taskflow`** | Grok Build delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + plugin. | -Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `pi-taskflow`, `codex-taskflow`, `claude-taskflow`, and `opencode-taskflow` all depend on `taskflow-core` (`taskflow-mcp-core` and `taskflow-hosts` directly; the adapters via both `taskflow-hosts` and `taskflow-mcp-core`), so **core publishes first, then taskflow-mcp-core, then taskflow-hosts, then the adapters**. +Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `pi-taskflow`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, and `grok-taskflow` all depend on `taskflow-core` (`taskflow-mcp-core` and `taskflow-hosts` directly; the adapters via both `taskflow-hosts` and `taskflow-mcp-core`), so **core publishes first, then taskflow-mcp-core, then taskflow-hosts, then the adapters**. ## One-time setup -All seven names are non-scoped and available on public npm — **no npm org needed**. `pi-taskflow` is already owned by `heggria`; the rest (`taskflow-core`, `taskflow-mcp-core`, `taskflow-hosts`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`) are unclaimed (publishing creates them). +All eight names are non-scoped and available on public npm — **no npm org needed**. `pi-taskflow` is already owned by `heggria`; the rest (`taskflow-core`, `taskflow-mcp-core`, `taskflow-hosts`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, `grok-taskflow`) are unclaimed until first publish (publishing creates them). ```sh # 1. Point at PUBLIC npm (the repo's default registry may be a private mirror) @@ -34,7 +35,7 @@ pnpm whoami --registry=https://registry.npmjs.org/ # expect: heggria (or the o pnpm install # links the workspaces pnpm run typecheck # 0 errors (resolves taskflow-core to src via the dev condition) pnpm test # 1140/1140 green -pnpm run build # emit dist/ for all seven packages (tsc → .js + .d.ts) +pnpm run build # emit dist/ for all eight packages (tsc → .js + .d.ts) ``` ### Skill coverage check (before every release) @@ -51,7 +52,7 @@ this release's CHANGELOG section, verify: **source** layer: `core.md` (core DSL + actions), `patterns.md` (if it changes best practice), `advanced.md` (context sharing / dynamic flows / isolation / recompute), `configuration.md` (knobs), or the per-host - entry files (`entry.pi.md` / `entry.codex.md`) for host bindings. + entry files (`entry.pi.md` / `entry.codex.md` / `entry.claude.md` / `entry.opencode.md` / `entry.grok.md`) for host bindings. - [ ] Host-only capabilities are wrapped in `` / `` blocks — never teach a host a tool it can't reach. - [ ] `node scripts/build-skills.mjs` ran and the generated files are committed. @@ -75,22 +76,23 @@ pnpm publish --filter pi-taskflow --registry=https://registry.npmjs.org/ --p pnpm publish --filter codex-taskflow --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter claude-taskflow --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter opencode-taskflow --registry=https://registry.npmjs.org/ --provenance +pnpm publish --filter grok-taskflow --registry=https://registry.npmjs.org/ --provenance ``` `publishConfig.access: public` is set on each package, so scoped/unscoped both publish publicly. > **Note on `taskflow-core` as a dependency.** `taskflow-mcp-core`, `taskflow-hosts`, and the host adapters -> (`pi-taskflow` / `codex-taskflow` / `claude-taskflow` / `opencode-taskflow`) +> (`pi-taskflow` / `codex-taskflow` / `claude-taskflow` / `opencode-taskflow` / `grok-taskflow`) > declare `"taskflow-core": "0.1.7"` (an exact version, not `workspace:*`), so the > published tarballs resolve the real npm package once it exists. Always publish -> `taskflow-core` first and bump all seven in lockstep. (`taskflow-mcp-core` and `taskflow-hosts` are the -> other internal dependencies: the MCP host adapters pin `"taskflow-mcp-core"`; the codex/claude/opencode +> `taskflow-core` first and bump all eight in lockstep. (`taskflow-mcp-core` and `taskflow-hosts` are the +> other internal dependencies: the MCP host adapters pin `"taskflow-mcp-core"`; the codex/claude/opencode/grok > delivery packages pin `"taskflow-hosts"`.) ## Tag + GitHub Release (automated) Pushing a `v*` tag triggers `.github/workflows/publish.yml`, which verifies all -seven package versions match the tag, publishes them in order, and cuts a GitHub +eight package versions match the tag, publishes them in order, and cuts a GitHub Release from the matching `CHANGELOG.md` section. ```sh @@ -107,6 +109,7 @@ pnpm view pi-taskflow version --registry=https://registry.npmjs.org/ pnpm view codex-taskflow version --registry=https://registry.npmjs.org/ pnpm view claude-taskflow version --registry=https://registry.npmjs.org/ pnpm view opencode-taskflow version --registry=https://registry.npmjs.org/ +pnpm view grok-taskflow version --registry=https://registry.npmjs.org/ ``` ## Install (end users) @@ -125,4 +128,8 @@ claude plugin install claude-taskflow@taskflow # OpenCode users (MCP server): opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build +grok plugin install --trust +# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index 0033300..c248c49 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-core", "version": "0.1.7", - "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration — the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, and opencode-taskflow.", + "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration \u2014 the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, and grok-taskflow.", "keywords": [ "taskflow", "dag", diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index 53d354e..c2785bd 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-mcp-core", "version": "0.1.7", - "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode adapters — depends only on taskflow-core.", + "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok adapters \u2014 depends only on taskflow-core.", "keywords": [ "taskflow", "mcp", diff --git a/website/content/docs/en/community.mdx b/website/content/docs/en/community.mdx index 905f400..456d9cf 100644 --- a/website/content/docs/en/community.mdx +++ b/website/content/docs/en/community.mdx @@ -114,7 +114,7 @@ Questions are welcome, and a well-asked question gets a fast answer. Before you **Include the flow and the run id.** Paste the smallest flow that reproduces the problem (trim phases until removing one makes it go away), the exact `args` you passed, and the run id if you have one. The run state on disk is the fastest path to a diagnosis. - **Say what you expected and what you got.** "I expected the `map` to fan out over 4 items, but only 2 ran" is actionable. "It didn't work" is not. Include the host (Pi, Codex, Claude Code, OpenCode) and the agent name if relevant. + **Say what you expected and what you got.** "I expected the `map` to fan out over 4 items, but only 2 ran" is actionable. "It didn't work" is not. Include the host (Pi, Codex, Claude Code, OpenCode, Grok Build) and the agent name if relevant. diff --git a/website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx b/website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx index b66d33e..09af7a3 100644 --- a/website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx +++ b/website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx @@ -13,13 +13,13 @@ This page is an honest comparison — including where LangGraph is clearly the b |---|---|---| | **What it is** | A library for stateful LLM app graphs (Python/JS) | A declarative task-DAG runtime for coding-agent subagents | | **You write** | Python or JS graph code | JSON data (the DSL) | -| **Runs inside** | your own application process | your coding agent (Pi, Codex, Claude Code, OpenCode) | +| **Runs inside** | your own application process | your coding agent (Pi, Codex, Claude Code, OpenCode, Grok Build) | | **Nodes are** | arbitrary functions (LLM calls, tools, logic) | **subagent invocations** (scoped, model-routed agent calls) | | **State** | a typed shared state object, reduced over edges | the Shared Context Tree (blackboard + supervision) | | **Persistence** | checkpointers (SQLite/Postgres/in-memory) | file-backed run store + cross-run cache | | **Human-in-the-loop** | interrupt + resume on state | `approval` phases (approve/reject/edit) | | **Verification** | runtime graph validation | **static pre-flight: cycles, dead-ends, budget, dangling refs — before any token** | -| **Host coupling** | framework-agnostic (you bring the model + tools) | **agent-host-native** (installs as a Pi extension / Codex/Claude/OpenCode plugin) | +| **Host coupling** | framework-agnostic (you bring the model + tools) | **agent-host-native** (installs as a Pi extension / Codex/Claude/OpenCode/Grok plugin) | ## Where they genuinely overlap @@ -55,7 +55,7 @@ This makes taskflow much more concise for the "fan out N scoped agents over N it LangGraph is **framework-agnostic** — you embed it in your own app, bring your own model client and tools. It's a library. -taskflow is **agent-host-native**: it installs *into* an existing coding agent (Pi extension, Codex/Claude Code/OpenCode plugin) and orchestrates *that host's* subagents. You don't write a new application — you declare flows your agent runs. If you're not already inside one of these coding agents, taskflow isn't the tool; LangGraph might be. +taskflow is **agent-host-native**: it installs *into* an existing coding agent (Pi extension, Codex/Claude Code/OpenCode/Grok plugin) and orchestrates *that host's* subagents. You don't write a new application — you declare flows your agent runs. If you're not already inside one of these coding agents, taskflow isn't the tool; LangGraph might be. ### 5. Persistence shape @@ -74,7 +74,7 @@ LangGraph is the better choice for **product-grade agent applications** — anyt ## When to pick taskflow - **Pick taskflow when** you're working **inside a coding agent** (Pi / Codex / Claude Code / OpenCode) and want to turn a repeatable multi-step delegation (review, audit, migration, research, release) into a **named, verified, resumable pipeline** whose intermediate transcripts stay out of your context. + **Pick taskflow when** you're working **inside a coding agent** (Pi / Codex / Claude Code / OpenCode / Grok Build) and want to turn a repeatable multi-step delegation (review, audit, migration, research, release) into a **named, verified, resumable pipeline** whose intermediate transcripts stay out of your context. taskflow is the better choice for **developer workflows** — pipelines you run on your codebase, by name, from inside the agent you already use. diff --git a/website/content/docs/en/comparisons/taskflow-vs-subagents.mdx b/website/content/docs/en/comparisons/taskflow-vs-subagents.mdx index 966e51c..616df4e 100644 --- a/website/content/docs/en/comparisons/taskflow-vs-subagents.mdx +++ b/website/content/docs/en/comparisons/taskflow-vs-subagents.mdx @@ -3,7 +3,7 @@ title: taskflow vs built-in subagents description: When your agent's native task/tasks/chain shorthand is enough, and when you outgrow it — DAGs, gates, resume, and context isolation that the built-in tool can't give you. --- -Every modern coding agent ships a built-in subagent shorthand — on Pi it's `task` / `tasks` / `chain`; on Codex, Claude Code, and OpenCode the same idea exists under different names. **taskflow does not replace it.** taskflow speaks the *same* shorthand, so your existing delegations instantly become tracked, resumable, and saveable. Then, when you outgrow the shorthand, the full DSL gives you a real DAG. +Every modern coding agent ships a built-in subagent shorthand — on Pi it's `task` / `tasks` / `chain`; on Codex, Claude Code, OpenCode, and Grok Build the same idea exists under different names. **taskflow does not replace it.** taskflow speaks the *same* shorthand, so your existing delegations instantly become tracked, resumable, and saveable. Then, when you outgrow the shorthand, the full DSL gives you a real DAG. The honest question is: **when is the built-in shorthand enough, and when do you need taskflow?** @@ -84,7 +84,7 @@ The other big shift is **reuse**. Describe a pipeline once, save it: ``` ```bash -# Codex / Claude Code / OpenCode — run a saved flow by name +# Codex / Claude Code / OpenCode / Grok — run a saved flow by name taskflow_run code-audit ``` diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index 0f4942f..1eea622 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -26,7 +26,7 @@ Save this as `hello.json`: Then run it. - + ```bash title="Run on Pi" /tf run hello @@ -34,7 +34,22 @@ Then run it. ```bash title="Run on Codex" - taskflow_run { "name": "hello", "def": { "name": "hello", "phases": [{ "id": "greet", "type": "agent", "task": "Write a one-sentence welcome message for a taskflow user." }] } } + # Ask Codex to call taskflow_run with the hello define (or saved name). + ``` + + + ```bash title="Run on Claude Code" + # Ask Claude to call taskflow_run with the hello define (or saved name). + ``` + + + ```bash title="Run on OpenCode" + # Ask OpenCode to call taskflow_run with the hello define (or saved name). + ``` + + + ```bash title="Run on Grok Build" + # Ask Grok to call taskflow_verify / taskflow_run via MCP tools. ``` @@ -45,7 +60,7 @@ You should see the runtime execute one phase and return the final output. That i If you have not installed taskflow yet, pick your host: - + ```bash title="Install pi-taskflow" pi install npm:pi-taskflow @@ -57,6 +72,24 @@ If you have not installed taskflow yet, pick your host: codex plugin add taskflow@taskflow ``` + + ```bash title="Install Claude Code plugin" + claude plugin marketplace add heggria/taskflow + claude plugin install claude-taskflow@taskflow + ``` + + + ```bash title="Register OpenCode MCP" + opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + ``` + + + ```bash title="Install Grok Build plugin" + grok plugin install --trust + # From a monorepo checkout: ./packages/grok-taskflow/plugin + # Then: grok mcp add taskflow -- node path/to/dist/mcp/bin.js (pre-publish) + ``` + ## A real example: review changed files diff --git a/website/content/docs/en/index.mdx b/website/content/docs/en/index.mdx index 138b516..5d8746b 100644 --- a/website/content/docs/en/index.mdx +++ b/website/content/docs/en/index.mdx @@ -23,7 +23,7 @@ taskflow is for anyone who delegates multi-step work to a coding agent and has h - **Context-window pressure.** Every subagent transcript floods your conversation, and a five-step audit eats your context before you ever see a result. - **Plans that only exist while they run.** You describe a workflow in prose, the model re-derives it every run, and there is nothing to diff, reuse, or hand to a reviewer. - **No recovery.** A run dies at the last step and you start over from scratch — paying for every subagent call again. -- **Reusable pipelines.** You want to name a workflow once and invoke it by name, on Pi, Codex, Claude Code, or OpenCode. +- **Reusable pipelines.** You want to name a workflow once and invoke it by name, on Pi, Codex, Claude Code, OpenCode, or Grok Build. If any of that sounds familiar, taskflow was built for you. @@ -45,7 +45,7 @@ The docs are organized as a learning path. Read them in order the first time; ju **[Syntax Reference](/en/docs/syntax)** — the *details*. Every field on every phase type, control flow, caching, and budget. - **[Guides](/en/docs/guides)** — the *practice*. Per-host walkthroughs (Pi, Codex) and pattern guides (dynamic planning, tournament). + **[Guides](/en/docs/guides)** — the *practice*. Per-host walkthroughs (Pi, Codex, Claude Code, OpenCode, Grok Build) and pattern guides (dynamic planning, tournament). diff --git a/website/content/docs/en/meta.json b/website/content/docs/en/meta.json index 3c642a9..f180eb7 100644 --- a/website/content/docs/en/meta.json +++ b/website/content/docs/en/meta.json @@ -25,6 +25,7 @@ "guides/codex", "guides/claude-code", "guides/opencode", + "guides/grok-build", "guides/agents-and-model-roles", "guides/dynamic-planning", "guides/tournament", diff --git a/website/content/docs/en/reference/commands.mdx b/website/content/docs/en/reference/commands.mdx index 653781b..8b3ee3b 100644 --- a/website/content/docs/en/reference/commands.mdx +++ b/website/content/docs/en/reference/commands.mdx @@ -1,6 +1,6 @@ --- title: Commands -description: Pi slash commands and Codex MCP tools. +description: Pi slash commands and MCP tools (Codex, Claude, OpenCode, Grok). --- taskflow exposes the same operations two ways. On **Pi**, you type slash commands like `/tf run` and `/tf:review-changes`. On **Codex** (and Claude Code, OpenCode), the same operations are MCP tools the model calls — `taskflow_run`, `taskflow_list`, and so on. @@ -71,7 +71,7 @@ On Pi, the model can also invoke a `taskflow` tool directly. It takes an `action | `cache-clear` | Clear the cross-run memoization cache. | | `init` | Configure model roles. | -## Codex / Claude / OpenCode MCP tools +## Codex / Claude / OpenCode / Grok MCP tools On MCP hosts, taskflow exposes these tools (the model calls them; you usually don't type them): diff --git a/website/content/docs/en/reference/index.mdx b/website/content/docs/en/reference/index.mdx index ae5554d..354bbcc 100644 --- a/website/content/docs/en/reference/index.mdx +++ b/website/content/docs/en/reference/index.mdx @@ -8,7 +8,7 @@ The Reference pages are *lookup material*. They are not a tutorial — read them Two pages live here: - **[Shorthand](/en/docs/reference/shorthand)** — the `task`, `tasks`, and `chain` shortcuts, and the exact full-flow JSON each one expands into. -- **[Commands](/en/docs/reference/commands)** — every Pi `/tf` command and every Codex / Claude / OpenCode MCP tool, grouped by purpose, with a worked example session. +- **[Commands](/en/docs/reference/commands)** — every Pi `/tf` command and every Codex / Claude / OpenCode / Grok MCP tool, grouped by purpose, with a worked example session. On Pi, saved flows become slash-command shortcuts automatically: `/tf:review-changes` is the same as `/tf run review-changes`. On MCP hosts, you run a saved flow by name through `taskflow_run`. @@ -21,6 +21,6 @@ Two pages live here: `task`, `tasks`, `chain` — the quick-delegation shortcuts. - Pi `/tf` commands and Codex / Claude / OpenCode MCP tools. + Pi `/tf` commands and Codex / Claude / OpenCode / Grok MCP tools. diff --git a/website/content/docs/en/templates.mdx b/website/content/docs/en/templates.mdx index c2eded9..925b951 100644 --- a/website/content/docs/en/templates.mdx +++ b/website/content/docs/en/templates.mdx @@ -6,7 +6,7 @@ description: Five ready-to-run taskflow templates you can save and adapt. Templates are complete, runnable taskflow definitions for common agent workflows. Each one is a real JSON graph you can save verbatim and run, then tune for your own repo. They are not toy examples — every phase, dependency, and field here is one you would write in production. - These templates run identically on Pi (`/tf run`) and on Codex / Claude Code / OpenCode (`taskflow_run`). See the [Pi guide](/en/docs/guides/pi) or [Codex guide](/en/docs/guides/codex) for the host-specific invocation surface. + These templates run identically on Pi (`/tf run`) and on Codex / Claude Code / OpenCode / Grok Build (`taskflow_run`). See the [Pi](/en/docs/guides/pi), [Codex](/en/docs/guides/codex), [Claude Code](/en/docs/guides/claude-code), [OpenCode](/en/docs/guides/opencode), or [Grok Build](/en/docs/guides/grok-build) guide for host-specific install. ## The gallery diff --git a/website/content/docs/zh-cn/blog/index.mdx b/website/content/docs/zh-cn/blog/index.mdx index 3151b5c..753c8f0 100644 --- a/website/content/docs/zh-cn/blog/index.mdx +++ b/website/content/docs/zh-cn/blog/index.mdx @@ -1,6 +1,6 @@ --- title: 博客 -description: 关于编排编程 agent subagent(Codex、Claude Code、OpenCode、Pi)的实用指南与深度文章——用声明式任务图。 +description: 关于编排编程 agent subagent(Codex、Claude Code、OpenCode、Grok Build、Pi)的实用指南与深度文章——用声明式任务图。 --- taskflow 博客讲的是编排编程 agent subagent 的实用模式:如何扇出审查、构建可复用流水线、控制花费,以及让中间逐字稿不进你的上下文窗口。 diff --git a/website/content/docs/zh-cn/community.mdx b/website/content/docs/zh-cn/community.mdx index 8bff0d6..86020d1 100644 --- a/website/content/docs/zh-cn/community.mdx +++ b/website/content/docs/zh-cn/community.mdx @@ -114,7 +114,7 @@ taskflow 在开放中构建,而最好的 flow 往往来自每天运行它们 **附上 flow 和 run id。** 贴出能复现问题的最小 flow(不断删 phase,直到删掉某一个后问题消失),你传的精确 `args`,以及如果有的话 run id。磁盘上的 run 状态是最快的诊断路径。 - **说明你期望什么、实际得到什么。** "我期望 `map` 对 4 个 item fan-out,但只跑了 2 个"是可行动的。"它不work"则不是。附上宿主(Pi、Codex、Claude Code、OpenCode),以及相关的话 agent 名。 + **说明你期望什么、实际得到什么。** "我期望 `map` 对 4 个 item fan-out,但只跑了 2 个"是可行动的。"它不work"则不是。附上宿主(Pi、Codex、Claude Code、OpenCode、Grok Build),以及相关的话 agent 名。 diff --git a/website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx b/website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx index e8f7541..10a15cb 100644 --- a/website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx +++ b/website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx @@ -13,13 +13,13 @@ description: 面向编程 agent 的任务图(taskflow)与 LangGraph 的差 |---|---|---| | **是什么** | 一个用于有状态 LLM 应用图的库(Python/JS) | 面向编程 agent subagent 的声明式任务 DAG runtime | | **你写的是** | Python 或 JS 图代码 | JSON 数据(DSL) | -| **跑在** | 你自己的应用进程里 | 你的编程 agent 里(Pi、Codex、Claude Code、OpenCode) | +| **跑在** | 你自己的应用进程里 | 你的编程 agent 里(Pi、Codex、Claude Code、OpenCode、Grok Build) | | **节点是** | 任意函数(LLM 调用、工具、逻辑) | **subagent 调用**(有作用域、按模型路由的 agent 调用) | | **状态** | 一个有类型的共享状态对象,沿边 reduce | 共享上下文树(黑板 + 监督) | | **持久化** | checkpointer(SQLite/Postgres/内存) | 文件式运行存储 + 跨运行缓存 | | **人在环中** | 基于 state 的中断 + 恢复 | `approval` 阶段(批准/拒绝/编辑) | | **校验** | 运行时图校验 | **静态预检:环、死端、预算、悬空引用——在任何 token 之前** | -| **host 耦合** | 框架无关(你自己带模型 + 工具) | **agent-host 原生**(作为 Pi 扩展 / Codex/Claude/OpenCode 插件安装) | +| **host 耦合** | 框架无关(你自己带模型 + 工具) | **agent-host 原生**(作为 Pi 扩展 / Codex/Claude/OpenCode/Grok 插件安装) | ## 真正重叠的地方 @@ -55,7 +55,7 @@ LangGraph 节点是你写的函数——你选模型、写提示词、解析结 LangGraph **框架无关**——你把它嵌入自己的应用,自带模型客户端和工具。它是一个库。 -taskflow 是 **agent-host 原生**:它*安装进*一个已有的编程 agent(Pi 扩展、Codex/Claude Code/OpenCode 插件),编排*那个 host 的* subagent。你不是在写一个新应用——你在声明你的 agent 要跑的流程。如果你不在这些编程 agent 里,taskflow 不是你要的工具;LangGraph 可能是。 +taskflow 是 **agent-host 原生**:它*安装进*一个已有的编程 agent(Pi 扩展、Codex/Claude Code/OpenCode/Grok 插件),编排*那个 host 的* subagent。你不是在写一个新应用——你在声明你的 agent 要跑的流程。如果你不在这些编程 agent 里,taskflow 不是你要的工具;LangGraph 可能是。 ### 5. 持久化的形状 @@ -74,7 +74,7 @@ LangGraph 更适合**产品级 agent 应用**——任何你会*作为*服务发 ## 什么时候选 taskflow - **选 taskflow**:你在**编程 agent 内部**(Pi / Codex / Claude Code / OpenCode)工作,想把一个可重复的多步委派(review、审计、迁移、调研、发布)变成一条**有名字、可校验、可续跑、中间逐字稿不进你上下文**的流水线。 + **选 taskflow**:你在**编程 agent 内部**(Pi / Codex / Claude Code / OpenCode / Grok Build)工作,想把一个可重复的多步委派(review、审计、迁移、调研、发布)变成一条**有名字、可校验、可续跑、中间逐字稿不进你上下文**的流水线。 taskflow 更适合**开发者工作流**——你在自己用的 agent 里、按名字、对自己的代码库跑的流水线。 diff --git a/website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx b/website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx index 64e6847..5c23c9e 100644 --- a/website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx +++ b/website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx @@ -3,7 +3,7 @@ title: taskflow vs 内置 subagent description: agent 原生的 task/tasks/chain 简写什么时候够用,什么时候你会超越它——内置工具给不了的 DAG、gate、续跑与上下文隔离。 --- -每个现代编程 agent 都自带 subagent 简写——Pi 上是 `task`/`tasks`/`chain`;Codex、Claude Code、OpenCode 上是名字不同、思路相同的东西。**taskflow 并不取代它。** taskflow 说的是*同一套*简写,所以你已有的委派立刻变成可追踪、可续跑、可保存的。等你超越了简写,完整的 DSL 会给你一张真正的 DAG。 +每个现代编程 agent 都自带 subagent 简写——Pi 上是 `task`/`tasks`/`chain`;Codex、Claude Code、OpenCode、Grok Build 上是名字不同、思路相同的东西。**taskflow 并不取代它。** taskflow 说的是*同一套*简写,所以你已有的委派立刻变成可追踪、可续跑、可保存的。等你超越了简写,完整的 DSL 会给你一张真正的 DAG。 诚实的问题是:**什么时候内置简写够用,什么时候你需要 taskflow?** @@ -84,7 +84,7 @@ Agent:[12 次 subagent 调用 → 12 份完整逐字稿进入你的上下文] ``` ```bash -# Codex / Claude Code / OpenCode——按名字运行保存的流程 +# Codex / Claude Code / OpenCode / Grok——按名字运行保存的流程 taskflow_run code-audit ``` diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index c0b6651..cac829d 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -26,7 +26,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 然后运行它。 - + ```bash title="在 Pi 上运行" /tf run hello @@ -34,7 +34,22 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 ```bash title="在 Codex 上运行" - taskflow_run { "name": "hello", "def": { "name": "hello", "phases": [{ "id": "greet", "type": "agent", "task": "Write a one-sentence welcome message for a taskflow user." }] } } + # 让 Codex 通过 taskflow_run 调用 hello define(或已保存名称)。 + ``` + + + ```bash title="在 Claude Code 上运行" + # 让 Claude 通过 taskflow_run 调用 hello define(或已保存名称)。 + ``` + + + ```bash title="在 OpenCode 上运行" + # 让 OpenCode 通过 taskflow_run 调用 hello define(或已保存名称)。 + ``` + + + ```bash title="在 Grok Build 上运行" + # 让 Grok 通过 MCP 调用 taskflow_verify / taskflow_run。 ``` @@ -45,7 +60,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 如果你还没有安装 taskflow,选择你的宿主: - + ```bash title="安装 pi-taskflow" pi install npm:pi-taskflow @@ -57,6 +72,24 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 codex plugin add taskflow@taskflow ``` + + ```bash title="安装 Claude Code 插件" + claude plugin marketplace add heggria/taskflow + claude plugin install claude-taskflow@taskflow + ``` + + + ```bash title="注册 OpenCode MCP" + opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + ``` + + + ```bash title="安装 Grok Build 插件" + grok plugin install --trust + # monorepo checkout: ./packages/grok-taskflow/plugin + # 发布前: grok mcp add taskflow -- node path/to/dist/mcp/bin.js + ``` + ## 一个真实例子:审查改动的文件 diff --git a/website/content/docs/zh-cn/guides/code-audit-case-study.mdx b/website/content/docs/zh-cn/guides/code-audit-case-study.mdx index 3f4db3e..fc97f22 100644 --- a/website/content/docs/zh-cn/guides/code-audit-case-study.mdx +++ b/website/content/docs/zh-cn/guides/code-audit-case-study.mdx @@ -8,7 +8,7 @@ description: 逐步构建一个多阶段审查 flow——发现、扇出、gate 本指南构建一个 taskflow 来正确地完成这次审查。我们从一个发现变更文件的阶段开始,加入按文件扇出的安全审查,再并行叠加一个架构审查,用 gate 对风险把关,最后产出一份合并的报告。读完之后,你会得到一个完整、可保存的 flow,可以在每个 PR 上运行。 - 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 + 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 ## 问题 diff --git a/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx b/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx index 225e173..7af2e4e 100644 --- a/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx +++ b/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx @@ -8,7 +8,7 @@ description: 生成多个竞争的标题变体,让裁判挑出最强的一条 本指南构建一个 taskflow 来正确地完成标题。我们先从朴素的单智能体基线开始,说明它为什么弱,再用一个 `tournament` 阶段替换它——它扇出四个竞争变体,由裁判选出赢家。读完之后,你会得到一个完整、可保存的 flow,可以在每次发布时运行。 - 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 + 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 ## 问题 diff --git a/website/content/docs/zh-cn/guides/index.mdx b/website/content/docs/zh-cn/guides/index.mdx index fae0a4d..37b45f9 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 和 Grok Build 上。每个宿主有自己的安装路径和调用入口,所以各自有专门的走查。 +- **按宿主。** taskflow 跑在 Pi、Codex、Claude Code、OpenCode、Grok Build 和 Grok Build 上。每个宿主有自己的安装路径和调用入口,所以各自有专门的走查。 - **按模式。** 基础跑通之后,有些问题想要特定的形状——一个在运行时规划自己子流程的模型,或者一个生成多份竞争草稿再由评委挑出最佳的锦标赛。 diff --git a/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx b/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx index dacb07d..ce1282a 100644 --- a/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx +++ b/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx @@ -8,7 +8,7 @@ description: 在运行时规划迁移、验证计划、执行它,并循环到 本指南构建一个 taskflow 来安全地完成这次迁移。我们从一个一次性的"发现+规划"开始,加一个 gate 对计划做健全性检查,通过生成的子 flow 执行计划,最后用一个循环重新规划,直到旧的调用彻底消失。读完之后,你会得到一个完整、可保存的 flow,可以指向任何机械式迁移。 - 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 + 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 ## 问题 diff --git a/website/content/docs/zh-cn/index.mdx b/website/content/docs/zh-cn/index.mdx index d715973..2a93f24 100644 --- a/website/content/docs/zh-cn/index.mdx +++ b/website/content/docs/zh-cn/index.mdx @@ -23,7 +23,7 @@ taskflow 是为那些把多步骤工作委托给编程智能体、并且遇到 - **上下文窗口压力。** 每个子代理的中间记录都涌入你的对话,一次五步审计还没看到结果就把上下文吃光了。 - **计划只存在于运行时。** 你用自然语言描述一个工作流,模型每次运行都重新推导一遍,既没有东西可以 diff、复用,也没有东西可以交给评审者。 - **没有恢复能力。** 一次运行死在最后一步,你只能从头再来——每个子代理调用都得重新付费。 -- **可复用的流水线。** 你想给一个工作流命名一次,然后按名称调用它,无论在 Pi、Codex、Claude Code 还是 OpenCode 上。 +- **可复用的流水线。** 你想给一个工作流命名一次,然后按名称调用它,无论在 Pi、Codex、Claude Code、OpenCode 还是 Grok Build 上。 如果其中任何一条听起来耳熟,taskflow 就是为你做的。 @@ -45,7 +45,7 @@ taskflow 是为那些把多步骤工作委托给编程智能体、并且遇到 **[语法参考](/zh-cn/docs/syntax)** —— *细节*。每种阶段类型的每个字段、控制流、缓存和预算。 - **[指南](/zh-cn/docs/guides)** —— *实践*。各宿主(Pi、Codex)的走查和模式指南(动态规划、锦标赛)。 + **[指南](/zh-cn/docs/guides)** —— *实践*。各宿主(Pi、Codex、Claude Code、OpenCode、Grok Build)的走查和模式指南(动态规划、锦标赛)。 diff --git a/website/content/docs/zh-cn/meta.json b/website/content/docs/zh-cn/meta.json index b5024b7..0dd6bde 100644 --- a/website/content/docs/zh-cn/meta.json +++ b/website/content/docs/zh-cn/meta.json @@ -25,6 +25,7 @@ "guides/codex", "guides/claude-code", "guides/opencode", + "guides/grok-build", "guides/agents-and-model-roles", "guides/dynamic-planning", "guides/tournament", diff --git a/website/content/docs/zh-cn/reference/commands.mdx b/website/content/docs/zh-cn/reference/commands.mdx index 2aab8d7..6db0ed9 100644 --- a/website/content/docs/zh-cn/reference/commands.mdx +++ b/website/content/docs/zh-cn/reference/commands.mdx @@ -1,6 +1,6 @@ --- title: 命令 -description: Pi 斜杠命令和 Codex MCP 工具。 +description: Pi 斜杠命令与 MCP 工具(Codex、Claude、OpenCode、Grok)。 --- taskflow 以两种方式暴露相同的操作。在 **Pi** 上,你输入像 `/tf run` 和 `/tf:review-changes` 这样的斜杠命令。在 **Codex**(以及 Claude Code、OpenCode)上,相同的操作是模型调用的 MCP 工具——`taskflow_run`、`taskflow_list` 等等。 @@ -71,7 +71,7 @@ taskflow 以两种方式暴露相同的操作。在 **Pi** 上,你输入像 `/ | `cache-clear` | 清除跨运行记忆化缓存。 | | `init` | 配置模型角色。 | -## Codex / Claude / OpenCode MCP 工具 +## Codex / Claude / OpenCode / Grok / Grok / Grok MCP 工具 在 MCP 宿主上,taskflow 暴露这些工具(模型调用它们;你通常不输入它们): diff --git a/website/content/docs/zh-cn/reference/index.mdx b/website/content/docs/zh-cn/reference/index.mdx index 0198bac..cce7160 100644 --- a/website/content/docs/zh-cn/reference/index.mdx +++ b/website/content/docs/zh-cn/reference/index.mdx @@ -8,7 +8,7 @@ description: 简写和命令速查。 这里有两页: - **[简写](/zh-cn/docs/reference/shorthand)** —— `task`、`tasks` 和 `chain` 快捷方式,以及每一个展开成的确切完整 flow JSON。 -- **[命令](/zh-cn/docs/reference/commands)** —— 每一条 Pi `/tf` 命令和每一个 Codex / Claude / OpenCode 的 MCP 工具,按用途分组,并附一个完整示例会话。 +- **[命令](/zh-cn/docs/reference/commands)** —— 每一条 Pi `/tf` 命令和每一个 Codex / Claude / OpenCode / Grok / Grok / Grok 的 MCP 工具,按用途分组,并附一个完整示例会话。 在 Pi 上,保存的 flow 会自动变成斜杠命令快捷方式:`/tf:review-changes` 等同于 `/tf run review-changes`。在 MCP 宿主上,你通过 `taskflow_run` 按名称运行保存的 flow。 @@ -21,6 +21,6 @@ description: 简写和命令速查。 `task`、`tasks`、`chain` —— 快速委托的快捷方式。 - Pi `/tf` 命令和 Codex / Claude / OpenCode 的 MCP 工具。 + Pi `/tf` 命令和 Codex / Claude / OpenCode / Grok / Grok / Grok 的 MCP 工具。 diff --git a/website/content/docs/zh-cn/showcase/index.mdx b/website/content/docs/zh-cn/showcase/index.mdx index 3528c09..f1ccff0 100644 --- a/website/content/docs/zh-cn/showcase/index.mdx +++ b/website/content/docs/zh-cn/showcase/index.mdx @@ -5,7 +5,7 @@ description: 端到端案例研究——用 taskflow 解决真实问题,逐阶 实战案例是把各个部件拼到一起的地方。每个案例都从一个你大概率遇到过的具体问题出发,一次加一个阶段地构建一个 taskflow,并解释*为什么*每个阶段长成那样。每个案例结束时,你都会得到一个完整、可保存、可直接运行的 flow。 -它们不是宿主走查——同一个 flow 在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode(`taskflow_run`)上跑起来完全一样。它们是*模式*指南:读它们是为了学会这个形状,再把形状套到你自己的问题上。 +它们不是宿主走查——同一个 flow 在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上跑起来完全一样。它们是*模式*指南:读它们是为了学会这个形状,再把形状套到你自己的问题上。 刚接触?[什么是 taskflow?](/zh-cn/docs/what-is-taskflow) 用一页讲清楚模型,[快速上手](/zh-cn/docs/getting-started) 五分钟跑起一个 flow。想看模型在一个真实问题上被拉满时,再回到这里。 diff --git a/website/content/docs/zh-cn/templates.mdx b/website/content/docs/zh-cn/templates.mdx index 71424c0..8eb1725 100644 --- a/website/content/docs/zh-cn/templates.mdx +++ b/website/content/docs/zh-cn/templates.mdx @@ -6,7 +6,7 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 模板库是面向常见智能体工作流的完整、可运行 taskflow 定义。每一个都是真实的 JSON 图——你可以原样保存并运行,再针对自己的仓库调整。它们不是玩具示例:这里的每个阶段、依赖和字段都是你在生产环境中会真正写下的内容。 - 这些模板在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode(`taskflow_run`)上运行方式完全一致。宿主特定的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 + 这些模板在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上运行方式完全一致。宿主特定安装见 [Pi](/zh-cn/docs/guides/pi)、[Codex](/zh-cn/docs/guides/codex)、[Claude Code](/zh-cn/docs/guides/claude-code)、[OpenCode](/zh-cn/docs/guides/opencode) 或 [Grok Build](/zh-cn/docs/guides/grok-build) 指南。 ## 模板列表 From e92b6a59cc4d5943d336bbd3aa400377f9ba71a4 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 13:27:04 +0800 Subject: [PATCH 15/51] docs: medium-priority Grok host coverage (DECISIONS, i18n, RFCs) Update living architecture decisions for eight packages and grok-runner; expand i18n README install blocks; align north-star/research/library RFCs and website template host tabs with MCP hosts including Grok. --- DECISIONS.md | 57 ++++++++++++----------- docs/0.2.0-north-star.md | 2 +- docs/0.2.0-research-frontend-paradigms.md | 4 +- docs/i18n/README.ar.md | 13 +++++- docs/i18n/README.bn.md | 13 +++++- docs/i18n/README.es.md | 13 +++++- docs/i18n/README.hi.md | 13 +++++- docs/i18n/README.pt.md | 13 +++++- docs/i18n/README.ru.md | 13 +++++- docs/market-positioning-2026-07.md | 2 +- docs/rfc-0.2.0-architecture.md | 2 +- docs/rfc-library-reuse-review.md | 2 +- docs/rfc-library-reuse.md | 6 +-- website/content/docs/en/templates.mdx | 35 ++++++++------ website/content/docs/zh-cn/templates.mdx | 30 ++++++------ 15 files changed, 146 insertions(+), 72 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index de820d0..f9bde6d 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -2,7 +2,7 @@ > Status: **living document**. Records the structural decisions behind the > multi-host layout (taskflow-core / taskflow-mcp-core / taskflow-hosts / pi-taskflow / -> codex-taskflow / claude-taskflow / opencode-taskflow), the trade-offs that +> codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow), the trade-offs that > were considered, and the direction to take as the host count grows. > > Written as the output of the PR #26 (claude + opencode hosts) architecture @@ -14,8 +14,7 @@ ## The invariant that must never break **The engine (`taskflow-core/src/runtime.ts`) is host-agnostic.** It speaks -only to the `SubagentRunner` contract (`runTask → RunResult`). Adding 4 hosts -(codex, claude, opencode, + pi) changed **zero** lines of engine code. Any +only to the `SubagentRunner` contract (`runTask → RunResult`). Adding hosts (codex, claude, opencode, grok, + pi) changed **zero** lines of engine code. Any future restructuring MUST preserve this seam — it is the single reason a host SDK breaking change cannot force an engine release. @@ -30,19 +29,20 @@ bug waiting to happen (the original 3-way copy-paste already diverged on ## Decision A — taskflow-hosts: a shared host-runner package (DONE) ### Status -**Implemented.** The three host runners (codex / claude / opencode) now live -in a single `taskflow-hosts` package. The three legacy delivery packages -(`codex-taskflow` / `claude-taskflow` / `opencode-taskflow`) keep their npm +**Implemented.** Non-pi host runners (codex / claude / opencode / **grok**) live +in a single `taskflow-hosts` package. Delivery packages +(`codex-taskflow` / `claude-taskflow` / `opencode-taskflow` / `grok-taskflow`) keep their npm names, install paths, version pins, and plugin scaffolds, and import their runner from `taskflow-hosts`; each also re-exports the runner so its existing -public surface (`import ... from "codex-taskflow"`) is unchanged. A 4th host -now lands as one `-runner.ts` in `taskflow-hosts`, not a whole new -runner-owning package. +public surface (`import ... from "codex-taskflow"`) is unchanged. A new host +lands as one `-runner.ts` in `taskflow-hosts`, not a whole new +runner-owning package. **Grok Build** shipped this way (`grok-runner.ts` + +`grok-taskflow` delivery + `.grok-plugin` scaffold). ### Context -codex-taskflow, claude-taskflow, and opencode-taskflow are each published as a +codex-taskflow, claude-taskflow, opencode-taskflow, and grok-taskflow are each published as a **separate npm package**. This was reasonable at 1 host (codex) and tolerable -at 3. The review's concern: at ~10–15 hosts this becomes the dominant +at 3–4 delivery adapters. The review's concern: at ~10–15 hosts this becomes the dominant maintenance cost. ### The tension @@ -51,17 +51,17 @@ host ecosystem's delivery artifact (`codex plugin add taskflow@taskflow`, `npm i -g codex-taskflow`, an MCP server a user points their client at). But there is *no* reason their release cadence is independent — adapters almost never change except when `taskflow-core`'s contract changes or a host CLI -changes its flags. Today all seven packages are **lockstep versioned** at the +changes its flags. Today all **eight** packages are **lockstep versioned** at the same number, which makes a per-package semver meaningless: a codex flag fix forces a new version of the untouched core engine. ### Cost projection at N hosts -| | 3 hosts (now) | 12 hosts (projected) | +| | 5 hosts (now: pi + 4 MCP) | 12 hosts (projected) | |---|---|---| -| npm publishes per release | 7 | 7 | -| `package.json` to keep version-pinned | 7 | 7 | -| README/CHANGELOG package rows | 7 | 7 | -| npm names consumed (`*-taskflow`) | 3 | 3 (new hosts live inside `taskflow-hosts`) | +| npm publishes per release | 8 | 8 (+ thin delivery only if install brand requires it) | +| `package.json` to keep version-pinned | 8 | ~8 | +| README/CHANGELOG package rows | 8 | ~8 | +| npm names consumed (`*-taskflow` delivery) | 4 | 4+ only when a host needs its own install brand | ### Decision **Keep the three existing packages for backward compatibility** (their names @@ -75,12 +75,13 @@ taskflow-hosts ├─ codex-runner.ts ← lives here directly ├─ claude-runner.ts ← lives here directly ├─ opencode-runner.ts ← lives here directly +├─ grok-runner.ts ← lives here directly (Grok Build) ├─ .ts ← lives here directly ├─ test/ ← all host arg-contract + parser tests -└─ index.ts ← barrel re-exporting the three runners +└─ index.ts ← barrel re-exporting the host runners ``` -- Current publishes: `core → mcp → hosts → pi/codex/claude/opencode` (7 publishes). Future hosts ship inside `taskflow-hosts`, so the publish count stops growing. +- Current publishes: `core → mcp → hosts → pi/codex/claude/opencode/grok` (8 publishes). Future *runners* ship inside `taskflow-hosts`; new delivery packages only when the host needs a branded install (`grok plugin install`, `codex plugin add`, …). - Future hosts ship in `taskflow-hosts` and are discovered via `npx -p taskflow-hosts -mcp` or static import. - The three legacy packages can later become thin re-exports of @@ -89,15 +90,17 @@ taskflow-hosts ### Trigger to act ✅ Done at 3 hosts (the moment adoption is lowest, so the migration is -cheapest). `taskflow-hosts` now exists; future hosts go here. +cheapest). `taskflow-hosts` now exists; **Grok Build was added as the 4th +non-pi runner without a second process-lifecycle copy** — future hosts go here. ### What we explicitly reject - **A unified `HostConfig` interface / generic command-builder.** Each host's argv genuinely differs (codex pastes the prompt; claude uses - `--append-system-prompt`; opencode uses `provider/model` ids where codex/claude - use flat ids; permission models are sandbox vs `--allowedTools` vs `--auto`). + `--append-system-prompt`; opencode uses `provider/model` ids where codex/claude/grok + use flat ids; permission models are sandbox vs `--allowedTools` vs `--auto` vs + Grok's `--tools` + `--always-approve`). Forcing these into one interface produces an abstraction with a per-host - parameter for every flag — more complex than the three ~15-line builders it + parameter for every flag — more complex than the short pure builders it replaces. Instead: each host owns a **pure, exported `buildXxxArgs`** builder (extracted in this PR) that is independently unit-tested. Shared *shape*, not shared *code*. @@ -107,7 +110,7 @@ cheapest). `taskflow-hosts` now exists; future hosts go here. ## Decision B — host CLI contracts are locked by unit tests, not e2e ### Context -The executor e2e suites (`e2e-codex.mts`, `e2e-claude.mts`, `e2e-opencode.mts`) +The executor e2e suites (`e2e-codex.mts`, `e2e-claude.mts`, `e2e-opencode.mts`, plus `e2e-grok-mcp.mts` for MCP) spawn a **live** host CLI and need auth + spend tokens, so they never run in CI. That left each host's argv construction (the `--json` / `--format json` / `--output-format stream-json` flags, the permission→flag mapping, the @@ -141,7 +144,7 @@ CI-checked. A flag rename trips a unit test, not a user. ### Context Cross-host debugging ("why did my flow fail?") previously had only `taskflow_peek` (phase output) and a 64KB-capped raw stderr per child. With 4 -hosts, each child's stderr has a different CLI prefix (`codex exec`, `claude +hosts, each child's stderr has a different CLI prefix (`codex exec`, `grok -p`, `claude -p`, `opencode run`), making it hard to tell which agent/bin produced a given error blob. @@ -171,9 +174,9 @@ error blob. comma host list; the skill body is shared. Do not per-host the skill body. - **MCP server is its own package (`taskflow-mcp-core`)** — a pure presentation layer over core. Pi users never pull MCP code. This boundary is correct. -- **Lockstep versioning is kept for now** (all seven packages share a version). +- **Lockstep versioning is kept for now** (all eight packages share a version). It is crude but it is *less* work than tracking which subset of packages need - a given bump, and at 7 packages the cost is still low. `taskflow-hosts` now + a given bump, and at 8 packages the cost is still low. `taskflow-hosts` now exists; the next revisit is when core genuinely needs to move on its own cadence independent of host CLI flag churn. diff --git a/docs/0.2.0-north-star.md b/docs/0.2.0-north-star.md index a4bb030..a4b1689 100644 --- a/docs/0.2.0-north-star.md +++ b/docs/0.2.0-north-star.md @@ -62,7 +62,7 @@ review 的 critic 用缺陷 #2 证明了:**"读 `.output` 自动建依赖"+ 真 | 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 | ✅ 已落地 | +| 写一次编译多端 | Mitosis | 一份 DSL → pi/codex/claude/opencode/grok 五 host | ✅ 已落地 | **关键洞察:这些思想不是"要追的热点",而是 taskflow 已经走在了前面 —— 学术界 2026-07 才出现"the first"agent 静态分析(AgentFlow),taskflow 早有 FlowIR+verify;TC39 signals 算法 = overstory 算法。0.2.0 是把这些"已经领先的能力"用一个 Svelte 风格 DSL 包装成用户可感知的产品。** diff --git a/docs/0.2.0-research-frontend-paradigms.md b/docs/0.2.0-research-frontend-paradigms.md index 41a0a99..98abb62 100644 --- a/docs/0.2.0-research-frontend-paradigms.md +++ b/docs/0.2.0-research-frontend-paradigms.md @@ -23,7 +23,7 @@ minimal-recompute**。taskflow 0.2.0 要做的,是把一个 **TC39 已标准化 | 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 版镜像 | +| 6 | **写一次,编译多端** | Mitosis (BuilderIO) | 一份 JSX 组件 → React/Vue/Solid/Qwik/... | **= taskflow 的多 host**(pi/codex/claude/opencode/grok),Mitosis 是 UI 版镜像 | | 7 | **Resumability / 可恢复** | Qwik | 服务端状态可直接在客户端恢复,零 hydration | **= taskflow 的 cross-session resume**(已落地) | ### 趋势判断(2026 共识) @@ -119,7 +119,7 @@ watch(count, (n) => console.log(n)) - 算法直接复用 TC39 验证过的:glitch-free 拓扑排序 + lazy cache + early cutoff(overstory 已有)。 **🥉 借鉴 Mitosis:一份 DSL,编译到多 host** -- taskflow 已有 pi/codex/claude/opencode 四 host。新 DSL 可以是**单一源**,编译到各 host 的运行时(就像 Mitosis 编译到 React/Vue/Solid)。 +- taskflow 已有 pi/codex/claude/opencode/grok 五 host。新 DSL 可以是**单一源**,编译到各 host 的运行时(就像 Mitosis 编译到 React/Vue/Solid)。 - FlowIR 已经是这个"中间表示",0.2.0 让它成为真正的**多目标编译枢纽**。 ### 3c. 一个示意:taskflow 0.2.0 DSL 可能长什么样(受 Svelte runes + Solid 启发) diff --git a/docs/i18n/README.ar.md b/docs/i18n/README.ar.md index 2c129fb..3bf85a5 100644 --- a/docs/i18n/README.ar.md +++ b/docs/i18n/README.ar.md @@ -2,7 +2,7 @@ > ⚠️ **هذه الترجمة قديمة.** يُرجى مراجعة [README بالإنجليزية](../../README.md) للحصول على أحدث المعلومات. -taskflow هو *رسم بياني للمهام* تصريحي وقابل للتحقق لوكلاء البرمجة — يعمل على [Pi coding agent](https://pi.dev) وعلى [OpenAI Codex](https://github.com/openai/codex) — ليس workflow تكتبه كبرنامج نصي، بل DAG تُعلِنه ويتحقق منه وقت التشغيل قبل إنفاق أي token. بدون أي تبعيات وقت تشغيل، 872 اختبارًا، 9 أنواع من المراحل. +taskflow هو *رسم بياني للمهام* تصريحي وقابل للتحقق لوكلاء البرمجة — يعمل على [Pi](https://pi.dev) و [OpenAI Codex](https://github.com/openai/codex) و [Claude Code](https://claude.com/product/claude-code) و [OpenCode](https://opencode.ai) و [Grok Build](https://docs.x.ai/build/overview) — ليس workflow تكتبه كبرنامج نصي، بل DAG تُعلِنه ويتحقق منه وقت التشغيل قبل إنفاق أي token. بدون أي تبعيات وقت تشغيل، 872 اختبارًا، 9 أنواع من المراحل. > **لماذا "taskflow" وليس "workflow"؟** الـ *workflow* (بنمط code-mode) هو برنامج أمري *يتدفق*، ورسمه البياني مخبأ داخل تدفق التحكم. أما الـ *taskflow* فينقل الخطة إلى رسم بياني تصريحي من عقد مهام منفصلة — يمكن التحقق منه ساكنًا وعرضه واستئنافه وحفظه كأمر. نحن نستبدل القدرة التعبيرية بالقابلية للتحقق، عن قصد. @@ -13,6 +13,17 @@ pi install npm:pi-taskflow # Codex codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow + +# Claude Code +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow + +# OpenCode +opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build (from monorepo checkout pre-publish, or published source) +grok plugin install --trust +# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` [GitHub](https://github.com/heggria/taskflow) · [README بالإنجليزية](../../README.md) · [README بالصينية](../../README.zh-CN.md) diff --git a/docs/i18n/README.bn.md b/docs/i18n/README.bn.md index 2f15ee8..bcef2f8 100644 --- a/docs/i18n/README.bn.md +++ b/docs/i18n/README.bn.md @@ -2,7 +2,7 @@ > ⚠️ **এই অনুবাদটি পুরোনো।** অনুগ্রহ করে সর্বশেষ তথ্যের জন্য [ইংরেজি README](../../README.md) দেখুন। -taskflow কোডিং এজেন্টের জন্য একটি ডিক্লারেটিভ এবং যাচাইযোগ্য *টাস্ক গ্রাফ* — এটি [Pi কোডিং এজেন্ট](https://pi.dev) এবং [OpenAI Codex](https://github.com/openai/codex) উভয়েই চলে — এটি এমন কোনো workflow নয় যা আপনি স্ক্রিপ্ট করেন, বরং একটি DAG যা আপনি ঘোষণা করেন এবং runtime একটি token খরচের আগেই যাচাই করে। শূন্য রানটাইম নির্ভরতা, ৮৭২টি পরীক্ষা, ৯টি ফেজ টাইপ। +taskflow কোডিং এজেন্টের জন্য একটি ডিক্লারেটিভ এবং যাচাইযোগ্য *টাস্ক গ্রাফ* — এটি [Pi](https://pi.dev), [OpenAI Codex](https://github.com/openai/codex), [Claude Code](https://claude.com/product/claude-code), [OpenCode](https://opencode.ai) ও [Grok Build](https://docs.x.ai/build/overview)-এ চলে — এটি এমন কোনো workflow নয় যা আপনি স্ক্রিপ্ট করেন, বরং একটি DAG যা আপনি ঘোষণা করেন এবং runtime একটি token খরচের আগেই যাচাই করে। শূন্য রানটাইম নির্ভরতা, ৮৭২টি পরীক্ষা, ৯টি ফেজ টাইপ। > **কেন "taskflow", "workflow" নয় কেন?** একটি *workflow* (code-mode) হল একটি ইম্পারেটিভ স্ক্রিপ্ট যা *প্রবাহিত হয়*, যার গ্রাফ কন্ট্রোল ফ্লোর মধ্যে লুকানো। একটি *taskflow* পরিকল্পনাকে পৃথক টাস্ক নোডের একটি ডিক্লারেটিভ গ্রাফে সরিয়ে নেয় — যা স্থিরভাবে যাচাই, দৃশ্যমান, পুনরারম্ভ এবং একটি কমান্ড হিসেবে সংরক্ষণ করা যায়। আমরা ইচ্ছাকৃতভাবে এক্সপ্রেসিভনেসকে যাচাইযোগ্যতার বিনিময়ে দিই। @@ -13,6 +13,17 @@ pi install npm:pi-taskflow # Codex codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow + +# Claude Code +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow + +# OpenCode +opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build (from monorepo checkout pre-publish, or published source) +grok plugin install --trust +# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` [GitHub](https://github.com/heggria/taskflow) · [ইংরেজি README](../../README.md) · [চীনা README](../../README.zh-CN.md) diff --git a/docs/i18n/README.es.md b/docs/i18n/README.es.md index e6cc603..36eb8c6 100644 --- a/docs/i18n/README.es.md +++ b/docs/i18n/README.es.md @@ -2,7 +2,7 @@ > ⚠️ **Esta traducción está desactualizada.** Consulta el [README en inglés](../../README.md) para obtener la información más reciente. -taskflow es un *grafo de tareas* declarativo y verificable para agentes de codificación — funciona en el [Pi coding agent](https://pi.dev) y en [OpenAI Codex](https://github.com/openai/codex): no un workflow que escribes como script, sino un DAG que declaras y que el runtime verifica antes de gastar un solo token. Cero dependencias en tiempo de ejecución, 872 pruebas, 9 tipos de fase. +taskflow es un *grafo de tareas* declarativo y verificable para agentes de codificación — funciona en [Pi](https://pi.dev), [OpenAI Codex](https://github.com/openai/codex), [Claude Code](https://claude.com/product/claude-code), [OpenCode](https://opencode.ai) y [Grok Build](https://docs.x.ai/build/overview): no un workflow que escribes como script, sino un DAG que declaras y que el runtime verifica antes de gastar un solo token. Cero dependencias en tiempo de ejecución, 872 pruebas, 9 tipos de fase. > **¿Por qué "taskflow" y no "workflow"?** Un *workflow* (estilo code-mode) es un script imperativo que *fluye*, con el grafo oculto en el control de flujo. Un *taskflow* mueve el plan a un grafo declarativo de nodos de tarea discretos — que se puede verificar estáticamente, visualizar, reanudar y guardar como un comando. Cambiamos expresividad por verificabilidad, a propósito. @@ -13,6 +13,17 @@ pi install npm:pi-taskflow # Codex codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow + +# Claude Code +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow + +# OpenCode +opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build (from monorepo checkout pre-publish, or published source) +grok plugin install --trust +# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` [GitHub](https://github.com/heggria/taskflow) · [README en inglés](../../README.md) · [README en chino](../../README.zh-CN.md) diff --git a/docs/i18n/README.hi.md b/docs/i18n/README.hi.md index d5636c5..4fa537d 100644 --- a/docs/i18n/README.hi.md +++ b/docs/i18n/README.hi.md @@ -2,7 +2,7 @@ > ⚠️ **यह अनुवाद पुराना है।** कृपया नवीनतम जानकारी के लिए [अंग्रेज़ी README](../../README.md) देखें। -taskflow कोडिंग एजेंटों के लिए एक घोषणात्मक और सत्यापन-योग्य *टास्क ग्राफ* है — यह [Pi कोडिंग एजेंट](https://pi.dev) और [OpenAI Codex](https://github.com/openai/codex) दोनों पर चलता है — वह workflow नहीं जिसे आप स्क्रिप्ट करते हैं, बल्कि एक DAG जिसे आप घोषित करते हैं और जिसे runtime एक भी token खर्च करने से पहले सत्यापित करता है। शून्य रनटाइम निर्भरता, 872 परीक्षण, 9 चरण प्रकार। +taskflow कोडिंग एजेंटों के लिए एक घोषणात्मक और सत्यापन-योग्य *टास्क ग्राफ* है — यह [Pi](https://pi.dev), [OpenAI Codex](https://github.com/openai/codex), [Claude Code](https://claude.com/product/claude-code), [OpenCode](https://opencode.ai) और [Grok Build](https://docs.x.ai/build/overview) पर चलता है — वह workflow नहीं जिसे आप स्क्रिप्ट करते हैं, बल्कि एक DAG जिसे आप घोषित करते हैं और जिसे runtime एक भी token खर्च करने से पहले सत्यापित करता है। शून्य रनटाइम निर्भरता, 872 परीक्षण, 9 चरण प्रकार। > **"taskflow" क्यों, "workflow" क्यों नहीं?** एक *workflow* (code-mode) एक आज्ञात्मक स्क्रिप्ट है जो *बहता* है, जिसका ग्राफ कंट्रोल फ़्लो में छिपा होता है। एक *taskflow* योजना को अलग-अलग टास्क नोड्स के एक घोषणात्मक ग्राफ में ले जाता है — जिसे स्थिर रूप से सत्यापित, दृश्य, पुनः शुरू और एक कमांड के रूप में सहेजा जा सकता है। हम जान-बूझकर अभिव्यक्ति को सत्यापन-योग्यता से बदलते हैं। @@ -13,6 +13,17 @@ pi install npm:pi-taskflow # Codex codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow + +# Claude Code +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow + +# OpenCode +opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build (from monorepo checkout pre-publish, or published source) +grok plugin install --trust +# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` [GitHub](https://github.com/heggria/taskflow) · [अंग्रेज़ी README](../../README.md) · [चीनी README](../../README.zh-CN.md) diff --git a/docs/i18n/README.pt.md b/docs/i18n/README.pt.md index 9d34dd6..dfc9117 100644 --- a/docs/i18n/README.pt.md +++ b/docs/i18n/README.pt.md @@ -2,7 +2,7 @@ > ⚠️ **Esta tradução está desatualizada.** Consulte o [README em inglês](../../README.md) para obter as informações mais recentes. -taskflow é um *grafo de tarefas* declarativo e verificável para agentes de codificação — funciona no [Pi coding agent](https://pi.dev) e no [OpenAI Codex](https://github.com/openai/codex) — não um workflow que você escreve como script, mas um DAG que você declara e que o runtime verifica antes de gastar um único token. Zero dependências em tempo de execução, 872 testes, 9 tipos de fase. +taskflow é um *grafo de tarefas* declarativo e verificável para agentes de codificação — funciona no [Pi](https://pi.dev), [OpenAI Codex](https://github.com/openai/codex), [Claude Code](https://claude.com/product/claude-code), [OpenCode](https://opencode.ai) e [Grok Build](https://docs.x.ai/build/overview) — não um workflow que você escreve como script, mas um DAG que você declara e que o runtime verifica antes de gastar um único token. Zero dependências em tempo de execução, 872 testes, 9 tipos de fase. > **Por que "taskflow" e não "workflow"?** Um *workflow* (estilo code-mode) é um script imperativo que *flui*, com o grafo escondido no controle de fluxo. Um *taskflow* move o plano para um grafo declarativo de nós de tarefa discretos — que pode ser verificado estaticamente, visualizado, retomado e salvo como um comando. Trocamos expressividade por verificabilidade, de propósito. @@ -13,6 +13,17 @@ pi install npm:pi-taskflow # Codex codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow + +# Claude Code +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow + +# OpenCode +opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build (from monorepo checkout pre-publish, or published source) +grok plugin install --trust +# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` [GitHub](https://github.com/heggria/taskflow) · [README em inglês](../../README.md) · [README em chinês](../../README.zh-CN.md) diff --git a/docs/i18n/README.ru.md b/docs/i18n/README.ru.md index b6cee28..30c0316 100644 --- a/docs/i18n/README.ru.md +++ b/docs/i18n/README.ru.md @@ -2,7 +2,7 @@ > ⚠️ **Этот перевод устарел.** Пожалуйста, обратитесь к [английскому README](../../README.md) за актуальной информацией. -taskflow — это декларативный и проверяемый *граф задач* для агентов кодирования — работает в [Pi coding agent](https://pi.dev) и в [OpenAI Codex](https://github.com/openai/codex) — не workflow, который вы пишете как скрипт, а DAG, который вы объявляете и который runtime проверяет до того, как потратить хотя бы один токен. Нулевые зависимости времени выполнения, 872 теста, 9 типов фаз. +taskflow — это декларативный и проверяемый *граф задач* для агентов кодирования — работает в [Pi](https://pi.dev), [OpenAI Codex](https://github.com/openai/codex), [Claude Code](https://claude.com/product/claude-code), [OpenCode](https://opencode.ai) и [Grok Build](https://docs.x.ai/build/overview) — не workflow, который вы пишете как скрипт, а DAG, который вы объявляете и который runtime проверяет до того, как потратить хотя бы один токен. Нулевые зависимости времени выполнения, 872 теста, 9 типов фаз. > **Почему "taskflow", а не "workflow"?** *Workflow* (в стиле code-mode) — это императивный скрипт, который *течёт*, а его граф спрятан в потоке управления. *Taskflow* переносит план в декларативный граф из дискретных узлов-задач — его можно статически проверить, визуализировать, возобновить и сохранить как команду. Мы осознанно меняем выразительность на проверяемость. @@ -13,6 +13,17 @@ pi install npm:pi-taskflow # Codex codex plugin marketplace add heggria/taskflow codex plugin add taskflow@taskflow + +# Claude Code +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow + +# OpenCode +opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp + +# Grok Build (from monorepo checkout pre-publish, or published source) +grok plugin install --trust +# or: grok mcp add taskflow -- npx -y -p grok-taskflow grok-taskflow-mcp ``` [GitHub](https://github.com/heggria/taskflow) · [README на английском](../../README.md) · [README на китайском](../../README.zh-CN.md) diff --git a/docs/market-positioning-2026-07.md b/docs/market-positioning-2026-07.md index 609e774..5105cd1 100644 --- a/docs/market-positioning-2026-07.md +++ b/docs/market-positioning-2026-07.md @@ -139,7 +139,7 @@ Conductor(2026-05 微软开源)和 taskflow 的主张**几乎一一对应**: - 风险:护城河窄,微软哪天加个缓存就没了。 ### 路线 B:换战场 —— 不和 Conductor 比"引擎",比"嵌入 coding agent 的体验" -- Conductor 是独立 CLI;taskflow 是 pi/codex/claude/opencode 里的**原生编排**。 +- Conductor 是独立 CLI;taskflow 是 pi/codex/claude/opencode/grok 里的**原生编排**。 - 0.1.7 投入"在 host 里用起来最顺"(更好的 TUI、`/tf:` 命令、和 host skill 融合、 内置 deep-research/audit flow 让用户装完即用)。 - 定位语:"不是又一个 CLI,是你现在的 coding agent 长出来的编排能力。" diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index 7eb3693..74ff90e 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -295,7 +295,7 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 | `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 共享) | +| `codex/claude/opencode/grok-taskflow` | 无感(经 mcp-core 共享) | | **`taskflow-dsl`(新)** | rune 类型 + build/check/decompile;依赖 core + typescript | --- diff --git a/docs/rfc-library-reuse-review.md b/docs/rfc-library-reuse-review.md index 8ac630e..df86412 100644 --- a/docs/rfc-library-reuse-review.md +++ b/docs/rfc-library-reuse-review.md @@ -24,7 +24,7 @@ | ID | 问题 | 解决方式 | |---|---|---| | A3 | `why`/`reuseHint` 生成机制未定义——是模板还是 LLM?与零 token 承诺矛盾 | §5.3:定义 Tier 1 始终模板化(零 token),Tier 2 LLM 增强留 Phase 3;给出完整模板代码 | -| A5 | 无 `taskflow_save` / `taskflow_search` MCP 工具——codex/claude/opencode 无法保存或搜索 | §5.4:新增两个 MCP 工具,含完整 input schema;pi 适配器扩展 `purpose`/`tags`/`notes` 透传 | +| A5 | 无 `taskflow_save` / `taskflow_search` MCP 工具——codex/claude/opencode/grok 无法保存或搜索 | §5.4:新增两个 MCP 工具,含完整 input schema;pi 适配器扩展 `purpose`/`tags`/`notes` 透传 | | A6 | reuseCount 两半都未定义:无检测机制 + 无并发合约 | §七 Phase 1:新增 `reusedFromSearch` 标记,递增在 tool handler 层 + `withLock` 保护,sub-flow 不计 | | A7 | embedding 输入文本构造完全未定义——直接影响语义检索质量 | §3.4:给出完整算法(5 段拼接)+ 512 字符预算 + 截断优先级 | | C3 | 降级矩阵遗漏 malformed-vector 场景(维度错误/NaN/Infinity) | §4.1 + §4.3:新增 `validateEmbedding()` 接口,写入前必校验,失败 → `null` + warn | diff --git a/docs/rfc-library-reuse.md b/docs/rfc-library-reuse.md index 9c8d75d..d7ef120 100644 --- a/docs/rfc-library-reuse.md +++ b/docs/rfc-library-reuse.md @@ -264,9 +264,9 @@ pi 适配器在构造 tool handler 时创建 `LibraryDeps` 并传给 `saveFlowWi - **冷启动警告**:文档须在 `command` kind 段落显著标注:「⚠️ 若 embedding 工具有冷启动延迟(如 board-cli 60s+),每次 save 都会阻塞等冷启动。推荐改用 `http` kind 指向已预热的 proxy。」 - **实现约束(R2R1 安全修复)**:**必须使用 `child_process.spawn(command[0], command.slice(1), { stdio: ["pipe", "pipe", "pipe"] })`**,禁止使用 `child_process.exec()` 或任何 shell 调用。输入文本通过 `stdin.write()` 传入,不经过 shell 解析。此约束消除 command 数组元素中 shell 元字符(`; | & $()` 等)的注入风险。 -**pi 适配器的默认 wiring**:pi 适配器读 `settings.json`,若 `taskflow.embedder` 存在且 `library.enabled`,构造一个 `Embedder` 实现注入 `LibraryDeps`。**其他宿主(codex/claude/opencode)的 MCP server 同样读这个配置**——配置在 `settings.json`,跨宿主一致。 +**pi 适配器的默认 wiring**:pi 适配器读 `settings.json`,若 `taskflow.embedder` 存在且 `library.enabled`,构造一个 `Embedder` 实现注入 `LibraryDeps`。**其他宿主(codex/claude/opencode/grok)的 MCP server 同样读这个配置**——配置在 `settings.json`,跨宿主一致。 -**跨宿主配置路径说明(C4 修复)**:所有宿主读 `~/.pi/agent/settings.json`,这是 taskflow 的现有跨宿主惯例。非 Pi 用户(codex/claude/opencode):须手动创建此文件,或使用 `-taskflow init`(若该命令提供)。**不引入备用路径**(如 `~/.taskflow/settings.json`),以避免偏离现有跨宿主约定。 +**跨宿主配置路径说明(C4 修复)**:所有宿主读 `~/.pi/agent/settings.json`,这是 taskflow 的现有跨宿主惯例。非 Pi 用户(codex/claude/opencode/grok):须手动创建此文件,或使用 `-taskflow init`(若该命令提供)。**不引入备用路径**(如 `~/.taskflow/settings.json`),以避免偏离现有跨宿主约定。 ### 4.3 降级矩阵(核心保证:永远能搜) @@ -288,7 +288,7 @@ pi 适配器在构造 tool handler 时创建 `LibraryDeps` 并传给 `saveFlowWi ```jsonc // pi 形式 { "action": "search", "query": "审计 API endpoint 是否缺少鉴权", "limit": 5 } -// MCP 形式(codex/claude/opencode)—— 新工具 taskflow_search(见 §5.4) +// MCP 形式(codex/claude/opencode/grok)—— 新工具 taskflow_search(见 §5.4) { "name": "taskflow_search", "arguments": { "query": "...", "limit": 5 } } ``` diff --git a/website/content/docs/en/templates.mdx b/website/content/docs/en/templates.mdx index 925b951..68523a5 100644 --- a/website/content/docs/en/templates.mdx +++ b/website/content/docs/en/templates.mdx @@ -105,7 +105,7 @@ Discover the files a pull request touches, fan out a per-file security review wi } ``` - + ```bash title="Run on Pi" /tf run pr-security-audit base=origin/main @@ -116,8 +116,9 @@ Discover the files a pull request touches, fan out a per-file security review wi /tf:pr-security-audit base=origin/main ``` - - ```bash title="Run on Codex" + + ```bash title="Run on Codex / Claude / OpenCode / Grok" + # Ask the host to call taskflow_run (MCP). Same tool on every MCP host: taskflow_run { "name": "pr-security-audit", "args": { "base": "origin/main" } } ``` @@ -175,7 +176,7 @@ Research the feature into a shared set of facts, run a four-variant tournament t } ``` - + ```bash title="Run on Pi" /tf run release-note feature="subagent context isolation" @@ -185,8 +186,9 @@ Research the feature into a shared set of facts, run a four-variant tournament t /tf save release-note ``` - - ```bash title="Run on Codex" + + ```bash title="Run on Codex / Claude / OpenCode / Grok" + # Ask the host to call taskflow_run (MCP). Same tool on every MCP host: taskflow_run { "name": "release-note", "args": { "feature": "subagent context isolation" } } ``` @@ -234,7 +236,7 @@ Discover every call site of a legacy API, plan the migration per file, gate the } ``` - + ```bash title="Run on Pi" /tf run codebase-migration oldApi=legacyAuth\(\) newApi=authorize\(\) @@ -244,8 +246,9 @@ Discover every call site of a legacy API, plan the migration per file, gate the /tf save codebase-migration ``` - - ```bash title="Run on Codex" + + ```bash title="Run on Codex / Claude / OpenCode / Grok" + # Ask the host to call taskflow_run (MCP). Same tool on every MCP host: taskflow_run { "name": "codebase-migration", "args": { "oldApi": "legacyAuth()", "newApi": "authorize()" } } ``` @@ -305,7 +308,7 @@ Discover the public API surface of a directory, draft reference documentation fo } ``` - + ```bash title="Run on Pi" /tf run api-docs dir=src @@ -315,8 +318,9 @@ Discover the public API surface of a directory, draft reference documentation fo /tf save api-docs ``` - - ```bash title="Run on Codex" + + ```bash title="Run on Codex / Claude / OpenCode / Grok" + # Ask the host to call taskflow_run (MCP). Same tool on every MCP host: taskflow_run { "name": "api-docs", "args": { "dir": "src" } } ``` @@ -384,7 +388,7 @@ Enumerate the project's outdated dependencies, evaluate each for breaking-change } ``` - + ```bash title="Run on Pi" /tf run dep-upgrade-review manager=npm @@ -394,8 +398,9 @@ Enumerate the project's outdated dependencies, evaluate each for breaking-change /tf save dep-upgrade-review ``` - - ```bash title="Run on Codex" + + ```bash title="Run on Codex / Claude / OpenCode / Grok" + # Ask the host to call taskflow_run (MCP). Same tool on every MCP host: taskflow_run { "name": "dep-upgrade-review", "args": { "manager": "npm" } } ``` diff --git a/website/content/docs/zh-cn/templates.mdx b/website/content/docs/zh-cn/templates.mdx index 8eb1725..025b1c0 100644 --- a/website/content/docs/zh-cn/templates.mdx +++ b/website/content/docs/zh-cn/templates.mdx @@ -105,7 +105,7 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 } ``` - + ```bash title="在 Pi 上运行" /tf run pr-security-audit base=origin/main @@ -116,8 +116,8 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 /tf:pr-security-audit base=origin/main ``` - - ```bash title="在 Codex 上运行" + + ```bash title="在 Codex / Claude / OpenCode / Grok 上运行" taskflow_run { "name": "pr-security-audit", "args": { "base": "origin/main" } } ``` @@ -175,7 +175,7 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 } ``` - + ```bash title="在 Pi 上运行" /tf run release-note feature="subagent context isolation" @@ -185,8 +185,8 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 /tf save release-note ``` - - ```bash title="在 Codex 上运行" + + ```bash title="在 Codex / Claude / OpenCode / Grok 上运行" taskflow_run { "name": "release-note", "args": { "feature": "subagent context isolation" } } ``` @@ -234,7 +234,7 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 } ``` - + ```bash title="在 Pi 上运行" /tf run codebase-migration oldApi=legacyAuth\(\) newApi=authorize\(\) @@ -244,8 +244,8 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 /tf save codebase-migration ``` - - ```bash title="在 Codex 上运行" + + ```bash title="在 Codex / Claude / OpenCode / Grok 上运行" taskflow_run { "name": "codebase-migration", "args": { "oldApi": "legacyAuth()", "newApi": "authorize()" } } ``` @@ -305,7 +305,7 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 } ``` - + ```bash title="在 Pi 上运行" /tf run api-docs dir=src @@ -315,8 +315,8 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 /tf save api-docs ``` - - ```bash title="在 Codex 上运行" + + ```bash title="在 Codex / Claude / OpenCode / Grok 上运行" taskflow_run { "name": "api-docs", "args": { "dir": "src" } } ``` @@ -384,7 +384,7 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 } ``` - + ```bash title="在 Pi 上运行" /tf run dep-upgrade-review manager=npm @@ -394,8 +394,8 @@ description: 五个可直接运行、可保存并改写的 taskflow 模板。 /tf save dep-upgrade-review ``` - - ```bash title="在 Codex 上运行" + + ```bash title="在 Codex / Claude / OpenCode / Grok 上运行" taskflow_run { "name": "dep-upgrade-review", "args": { "manager": "npm" } } ``` From 3f9937c8eeb96429ef6f71e8795cc8d78518b136 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 13:28:44 +0800 Subject: [PATCH 16/51] docs: low-priority Grok coverage (blogs, case studies, showcase) Add cross-host footers on Codex/Claude blogs, expand case-study and showcase host lists, fix zh README package count, and align test comments. --- README.zh-CN.md | 2 +- packages/taskflow-core/test/runner-process.test.ts | 2 +- website/content/docs/en/blog/claude-code-mcp-workflow.mdx | 5 +++++ website/content/docs/en/blog/orchestrate-codex-subagents.mdx | 5 +++++ website/content/docs/en/guides/code-audit-case-study.mdx | 2 +- .../docs/en/guides/headline-tournament-case-study.mdx | 2 +- .../content/docs/en/guides/migration-planner-case-study.mdx | 2 +- website/content/docs/en/showcase/index.mdx | 2 +- website/content/docs/zh-cn/blog/claude-code-mcp-workflow.mdx | 5 +++++ .../content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx | 5 +++++ website/content/docs/zh-cn/guides/code-audit-case-study.mdx | 2 +- .../docs/zh-cn/guides/headline-tournament-case-study.mdx | 2 +- .../docs/zh-cn/guides/migration-planner-case-study.mdx | 2 +- 13 files changed, 29 insertions(+), 9 deletions(-) diff --git a/README.zh-CN.md b/README.zh-CN.md index 661d03f..b3822f0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -824,7 +824,7 @@ pnpm install pnpm run typecheck # 跨所有包做 tsc --noEmit(无需构建) pnpm test # 单元测试——无网络,无进程派生 pnpm run test:hosts # 仅 taskflow-hosts 测试(另有 test:pi、test:codex、test:claude、test:opencode) -pnpm run build # 为七个包生成 dist/*.js + .d.ts +pnpm run build # 为八个包生成 dist/*.js + .d.ts pnpm run test:e2e-codex # codex executor 端到端(需 `codex` + 模型访问权限) pnpm run test:e2e-codex-mcp # codex MCP 服务器端到端 pnpm run test:e2e-claude-mcp # claude MCP 服务器端到端(无需实时 claude) diff --git a/packages/taskflow-core/test/runner-process.test.ts b/packages/taskflow-core/test/runner-process.test.ts index acc4af5..d497685 100644 --- a/packages/taskflow-core/test/runner-process.test.ts +++ b/packages/taskflow-core/test/runner-process.test.ts @@ -2,7 +2,7 @@ * Unit tests for the shared `runSubagentProcess` (runner-core.ts). * * This is the spawn / idle-watchdog / abort / signal-kill / stderr-cap / post-exit - * classify block shared by the codex/claude/opencode runners. It has no other + * classify block shared by the codex/claude/opencode/grok runners. It has no other * direct unit tests — the host parsers are tested in their own packages, but the * shared process/classify contract (the highest-blast-radius code: a bug here * affects all 3 non-pi hosts) is exercised here against REAL short-lived child diff --git a/website/content/docs/en/blog/claude-code-mcp-workflow.mdx b/website/content/docs/en/blog/claude-code-mcp-workflow.mdx index b663dbf..d3d6c05 100644 --- a/website/content/docs/en/blog/claude-code-mcp-workflow.mdx +++ b/website/content/docs/en/blog/claude-code-mcp-workflow.mdx @@ -110,3 +110,8 @@ Now every PR review is `taskflow_run pr-security-review` — no re-prompting, no - Only the final report returns to your session — transcripts stay isolated. For the full Claude Code surface (permissions, model routing, all available tools), see the [Claude Code guide](/en/docs/guides/claude-code). The [templates gallery](/en/docs/templates) has four more runnable pipelines. + + +## Also available on other hosts + +The same engine and `taskflow_*` tools run on **Pi**, **Codex**, **OpenCode**, and **[Grok Build](/en/docs/guides/grok-build)** — only the install path changes. See the [guides index](/en/docs/guides). diff --git a/website/content/docs/en/blog/orchestrate-codex-subagents.mdx b/website/content/docs/en/blog/orchestrate-codex-subagents.mdx index df05af8..3b6c7fc 100644 --- a/website/content/docs/en/blog/orchestrate-codex-subagents.mdx +++ b/website/content/docs/en/blog/orchestrate-codex-subagents.mdx @@ -138,3 +138,8 @@ Don't reach for taskflow for a one-off "go look at X." The built-in subagent sho - Only the final phase reaches your Codex context. Grab the [templates gallery](/en/docs/templates) for five more ready-to-run flows, or read the [Codex guide](/en/docs/guides/codex) for the full host-specific surface. + + +## Also available on other hosts + +The same engine and `taskflow_*` tools run on **Pi**, **Claude Code**, **OpenCode**, and **[Grok Build](/en/docs/guides/grok-build)** — only the install path changes. See the [guides index](/en/docs/guides). diff --git a/website/content/docs/en/guides/code-audit-case-study.mdx b/website/content/docs/en/guides/code-audit-case-study.mdx index 826b810..62d6b3c 100644 --- a/website/content/docs/en/guides/code-audit-case-study.mdx +++ b/website/content/docs/en/guides/code-audit-case-study.mdx @@ -8,7 +8,7 @@ A real PR lands and it is big: **47 files changed across 3 packages.** You need This guide builds a taskflow that does the review properly. We start with one phase that discovers the changed files, add a fan-out for per-file security review, layer in a parallel architecture review, gate the whole thing on risk, and finish with a single merged report. By the end you will have a complete, saveable flow you can run on every PR. - This is a *pattern* guide, not a host guide. It works the same on Pi (`/tf run`) and on Codex / Claude Code / OpenCode (`taskflow_run`). See the [Pi guide](/en/docs/guides/pi) or [Codex guide](/en/docs/guides/codex) for the host-specific invocation surface. + This is a *pattern* guide, not a host guide. It works the same on Pi (`/tf run`) and on Codex / Claude Code / OpenCode / Grok Build (`taskflow_run`). See the [Pi](/en/docs/guides/pi), [Codex](/en/docs/guides/codex), [Claude Code](/en/docs/guides/claude-code), [OpenCode](/en/docs/guides/opencode), or [Grok Build](/en/docs/guides/grok-build) guide for host-specific install. ## The problem diff --git a/website/content/docs/en/guides/headline-tournament-case-study.mdx b/website/content/docs/en/guides/headline-tournament-case-study.mdx index be7f16b..85ea8ca 100644 --- a/website/content/docs/en/guides/headline-tournament-case-study.mdx +++ b/website/content/docs/en/guides/headline-tournament-case-study.mdx @@ -8,7 +8,7 @@ You shipped a feature and now you need one thing: the release-note headline. It This guide builds a taskflow that does the headline properly. We start with the naive single-agent baseline, show why it is weak, then replace it with a `tournament` phase that spawns four competing variants and a judge that picks the winner. By the end you will have a complete, saveable flow you can run for every release. - This is a *pattern* guide, not a host guide. It works the same on Pi (`/tf run`) and on Codex / Claude Code / OpenCode (`taskflow_run`). See the [Pi guide](/en/docs/guides/pi) or [Codex guide](/en/docs/guides/codex) for the host-specific invocation surface. + This is a *pattern* guide, not a host guide. It works the same on Pi (`/tf run`) and on Codex / Claude Code / OpenCode / Grok Build (`taskflow_run`). See the [Pi](/en/docs/guides/pi), [Codex](/en/docs/guides/codex), [Claude Code](/en/docs/guides/claude-code), [OpenCode](/en/docs/guides/opencode), or [Grok Build](/en/docs/guides/grok-build) guide for host-specific install. ## The problem diff --git a/website/content/docs/en/guides/migration-planner-case-study.mdx b/website/content/docs/en/guides/migration-planner-case-study.mdx index 5a5c2ac..fec7946 100644 --- a/website/content/docs/en/guides/migration-planner-case-study.mdx +++ b/website/content/docs/en/guides/migration-planner-case-study.mdx @@ -8,7 +8,7 @@ You need to migrate from an old HTTP client to a new one. The old call site is ` This guide builds a taskflow that does the migration safely. We start with a one-shot discover-and-plan, add a gate that sanity-checks the plan, execute the plan via a generated sub-flow, and finish with a loop that re-plans until no old calls remain. By the end you will have a complete, saveable flow you can point at any mechanical migration. - This is a *pattern* guide, not a host guide. It works the same on Pi (`/tf run`) and on Codex / Claude Code / OpenCode (`taskflow_run`). See the [Pi guide](/en/docs/guides/pi) or [Codex guide](/en/docs/guides/codex) for the host-specific invocation surface. + This is a *pattern* guide, not a host guide. It works the same on Pi (`/tf run`) and on Codex / Claude Code / OpenCode / Grok Build (`taskflow_run`). See the [Pi](/en/docs/guides/pi), [Codex](/en/docs/guides/codex), [Claude Code](/en/docs/guides/claude-code), [OpenCode](/en/docs/guides/opencode), or [Grok Build](/en/docs/guides/grok-build) guide for host-specific install. ## The problem diff --git a/website/content/docs/en/showcase/index.mdx b/website/content/docs/en/showcase/index.mdx index c2361e9..78f2225 100644 --- a/website/content/docs/en/showcase/index.mdx +++ b/website/content/docs/en/showcase/index.mdx @@ -5,7 +5,7 @@ description: End-to-end case studies — real problems solved with taskflow, bui The Showcase is where the pieces come together. Each case study starts from a concrete problem you have probably already hit, builds a taskflow one phase at a time, and explains *why* each phase looks the way it does. By the end of each one you have a complete, saveable flow you can run yourself. -They are not host walkthroughs — the same flow runs identically on Pi (`/tf run`) and on Codex / Claude Code / OpenCode (`taskflow_run`). They are *pattern* guides: read them to learn the shape, then adapt the shape to your own problem. +They are not host walkthroughs — the same flow runs identically on Pi (`/tf run`) and on Codex / Claude Code / OpenCode / Grok Build (`taskflow_run`). They are *pattern* guides: read them to learn the shape, then adapt the shape to your own problem. New here? The [What is taskflow?](/en/docs/what-is-taskflow) page explains the model in one page, and [Getting Started](/en/docs/getting-started) gets a flow running in five minutes. Come back here when you want to see the model stretched across a real problem. diff --git a/website/content/docs/zh-cn/blog/claude-code-mcp-workflow.mdx b/website/content/docs/zh-cn/blog/claude-code-mcp-workflow.mdx index bdc219f..6c5e575 100644 --- a/website/content/docs/zh-cn/blog/claude-code-mcp-workflow.mdx +++ b/website/content/docs/zh-cn/blog/claude-code-mcp-workflow.mdx @@ -110,3 +110,8 @@ taskflow_save pr-security-review pr-security-review.json - 只有最终报告返回你的会话——逐字稿保持隔离。 完整的 Claude Code 接口(权限、模型路由、所有可用工具)见 [Claude Code 指南](/zh-cn/docs/guides/claude-code)。[模板库](/zh-cn/docs/templates) 还有四个可运行流水线。 + + +## 也适用于其他宿主 + +同一引擎与 `taskflow_*` 工具也在 **Pi**、**Codex**、**OpenCode** 和 **[Grok Build](/zh-cn/docs/guides/grok-build)** 上运行——只是安装路径不同。见[指南索引](/zh-cn/docs/guides)。 diff --git a/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx b/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx index fcbc69a..1fb1ce7 100644 --- a/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx +++ b/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx @@ -138,3 +138,8 @@ taskflow_resume - 只有最终阶段进入你的 Codex 上下文。 去 [模板库](/zh-cn/docs/templates) 拿另外五个开箱即用的流程,或读 [Codex 指南](/zh-cn/docs/guides/codex) 看完整的 host 专属接口。 + + +## 也适用于其他宿主 + +同一引擎与 `taskflow_*` 工具也在 **Pi**、**Claude Code**、**OpenCode** 和 **[Grok Build](/zh-cn/docs/guides/grok-build)** 上运行——只是安装路径不同。见[指南索引](/zh-cn/docs/guides)。 diff --git a/website/content/docs/zh-cn/guides/code-audit-case-study.mdx b/website/content/docs/zh-cn/guides/code-audit-case-study.mdx index fc97f22..41a0be3 100644 --- a/website/content/docs/zh-cn/guides/code-audit-case-study.mdx +++ b/website/content/docs/zh-cn/guides/code-audit-case-study.mdx @@ -8,7 +8,7 @@ description: 逐步构建一个多阶段审查 flow——发现、扇出、gate 本指南构建一个 taskflow 来正确地完成这次审查。我们从一个发现变更文件的阶段开始,加入按文件扇出的安全审查,再并行叠加一个架构审查,用 gate 对风险把关,最后产出一份合并的报告。读完之后,你会得到一个完整、可保存的 flow,可以在每个 PR 上运行。 - 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 + 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主安装见 [Pi](/zh-cn/docs/guides/pi)、[Codex](/zh-cn/docs/guides/codex)、[Claude Code](/zh-cn/docs/guides/claude-code)、[OpenCode](/zh-cn/docs/guides/opencode) 或 [Grok Build](/zh-cn/docs/guides/grok-build) 指南。 ## 问题 diff --git a/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx b/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx index 7af2e4e..6dcd15b 100644 --- a/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx +++ b/website/content/docs/zh-cn/guides/headline-tournament-case-study.mdx @@ -8,7 +8,7 @@ description: 生成多个竞争的标题变体,让裁判挑出最强的一条 本指南构建一个 taskflow 来正确地完成标题。我们先从朴素的单智能体基线开始,说明它为什么弱,再用一个 `tournament` 阶段替换它——它扇出四个竞争变体,由裁判选出赢家。读完之后,你会得到一个完整、可保存的 flow,可以在每次发布时运行。 - 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 + 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主安装见 [Pi](/zh-cn/docs/guides/pi)、[Codex](/zh-cn/docs/guides/codex)、[Claude Code](/zh-cn/docs/guides/claude-code)、[OpenCode](/zh-cn/docs/guides/opencode) 或 [Grok Build](/zh-cn/docs/guides/grok-build) 指南。 ## 问题 diff --git a/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx b/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx index ce1282a..a2ab33a 100644 --- a/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx +++ b/website/content/docs/zh-cn/guides/migration-planner-case-study.mdx @@ -8,7 +8,7 @@ description: 在运行时规划迁移、验证计划、执行它,并循环到 本指南构建一个 taskflow 来安全地完成这次迁移。我们从一个一次性的"发现+规划"开始,加一个 gate 对计划做健全性检查,通过生成的子 flow 执行计划,最后用一个循环重新规划,直到旧的调用彻底消失。读完之后,你会得到一个完整、可保存的 flow,可以指向任何机械式迁移。 - 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主相关的调用入口请参见 [Pi 指南](/zh-cn/docs/guides/pi)或 [Codex 指南](/zh-cn/docs/guides/codex)。 + 这是一份*模式*指南,不是宿主指南。它在 Pi(`/tf run`)和 Codex / Claude Code / OpenCode / Grok Build(`taskflow_run`)上的行为完全一致。宿主安装见 [Pi](/zh-cn/docs/guides/pi)、[Codex](/zh-cn/docs/guides/codex)、[Claude Code](/zh-cn/docs/guides/claude-code)、[OpenCode](/zh-cn/docs/guides/opencode) 或 [Grok Build](/zh-cn/docs/guides/grok-build) 指南。 ## 问题 From f5b1ae4956078c697f4760bd5177aa00bd441b23 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 13:35:48 +0800 Subject: [PATCH 17/51] =?UTF-8?q?feat(core):=20phase=202=20S0=E2=80=93S3?= =?UTF-8?q?=20foundations=20=E2=80=94=20FlowIR=20compile,=20fold,=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement genuine Taskflow→FlowIR compilation with content-addressed hashFlowIR (usedFallbackHash false), pure foldEvents for event logs, and replayRun for offline threshold/budget what-if re-fold. Runtime still uses the imperative path; driver/step migration is next. --- CHANGELOG.md | 4 + packages/taskflow-core/src/exec/fold.ts | 136 ++++++++++ packages/taskflow-core/src/exec/index.ts | 1 + packages/taskflow-core/src/flowir/compile.ts | 241 ++++++++++++++++++ packages/taskflow-core/src/flowir/index.ts | 79 +++--- packages/taskflow-core/src/flowir/meta.ts | 8 +- packages/taskflow-core/src/replay.ts | 224 +++++++++++++--- packages/taskflow-core/test/exec-fold.test.ts | 93 +++++++ packages/taskflow-core/test/flowir.test.ts | 84 +++--- packages/taskflow-core/test/replay.test.ts | 83 ++++++ 10 files changed, 821 insertions(+), 132 deletions(-) create mode 100644 packages/taskflow-core/src/exec/fold.ts create mode 100644 packages/taskflow-core/src/flowir/compile.ts create mode 100644 packages/taskflow-core/test/exec-fold.test.ts create mode 100644 packages/taskflow-core/test/replay.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6982da9..4c312ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ All notable changes to taskflow are documented here. This project follows [Keep ### 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.2.0 Phase 2 / S0–S3 foundations (event-sourced kernel path):** + - `flowir/compile.ts` (`compileTaskflowToFlowIR`) — genuine Taskflow→canonical FlowIR compiler; `compileTaskflowToIR` now content-addresses with `hashFlowIR` (`ir:<64-hex>`) and sets `usedFallbackHash: false`. + - `exec/fold.ts` — pure `foldEvents(log) →` per-phase snapshot (S1 differential building block). + - `replay.ts` — implements `replayRun(log, overrides)` (threshold / budget / model / args knobs; zero tokens; no import of runtime/driver). ## [0.1.7] — 2026-07-07 diff --git a/packages/taskflow-core/src/exec/fold.ts b/packages/taskflow-core/src/exec/fold.ts new file mode 100644 index 0000000..9ef50c6 --- /dev/null +++ b/packages/taskflow-core/src/exec/fold.ts @@ -0,0 +1,136 @@ +/** + * fold(event log) → RunState-shaped phase snapshot (RFC §6.4, S1). + * + * Pure reduce over {@link Event} / legacy TraceEvent-compatible records. + * Does **not** spawn processes or touch disk. Used by: + * - S1 differential: assert fold(log) matches phase statuses the imperative + * runtime would have left in RunState + * - S3 replay: re-fold under alternate knobs (thresholds/budget) without + * re-entering the driver + * + * Never throws. + */ + +import type { Event, EventDecision } from "./events.ts"; +import type { UsageStats } from "../usage.ts"; +import { emptyUsage } from "../usage.ts"; + +/** Per-phase folded snapshot (subset of PhaseState the runtime persists). */ +export interface FoldedPhase { + phaseId: string; + status: "pending" | "running" | "done" | "failed" | "blocked" | "skipped" | "timedOut"; + /** Last recorded text output (subagent or decision summary). */ + output?: string; + error?: string; + /** Accumulated usage from subagent-call events. */ + usage: UsageStats; + /** Last decision event for this phase (gate / when / cache / …). */ + decision?: EventDecision; + /** Number of subagent-call events seen. */ + subagentCalls: number; + startedAt?: number; + endedAt?: number; +} + +/** Whole-run fold result. */ +export interface FoldedRun { + runId: string; + phases: Record; + /** Events that could not be associated with a phaseId (malformed). */ + orphans: number; + /** Total events folded. */ + eventCount: number; +} + +function emptyPhase(phaseId: string): FoldedPhase { + return { + phaseId, + status: "pending", + usage: emptyUsage(), + subagentCalls: 0, + }; +} + +function ensurePhase(map: Record, phaseId: string): FoldedPhase { + if (!map[phaseId]) map[phaseId] = emptyPhase(phaseId); + return map[phaseId]; +} + +function addUsage(a: UsageStats, b: UsageStats | undefined): void { + if (!b) return; + a.input += b.input ?? 0; + a.output += b.output ?? 0; + a.cacheRead += b.cacheRead ?? 0; + a.cacheWrite += b.cacheWrite ?? 0; + a.cost += b.cost ?? 0; + a.turns += b.turns ?? 0; + if (typeof b.contextTokens === "number") a.contextTokens = b.contextTokens; +} + +/** + * Reduce an ordered event list into a per-phase snapshot. + * Accepts {@link Event} (with `v`) or plain TraceEvent-shaped records. + */ +export function foldEvents(events: readonly Event[]): FoldedRun { + const phases: Record = {}; + let runId = ""; + let orphans = 0; + + for (const ev of events) { + if (!ev || typeof ev !== "object") { + orphans++; + continue; + } + if (typeof ev.runId === "string" && ev.runId) runId = ev.runId; + const phaseId = typeof ev.phaseId === "string" ? ev.phaseId : ""; + if (!phaseId) { + orphans++; + continue; + } + const p = ensurePhase(phases, phaseId); + const kind = ev.kind; + + if (kind === "phase-start") { + p.status = "running"; + if (typeof ev.ts === "number") p.startedAt = ev.ts; + } else if (kind === "phase-end") { + if (ev.status && ev.status !== "pending" && ev.status !== "running") { + p.status = ev.status; + } else if (p.status === "running" || p.status === "pending") { + p.status = "done"; + } + if (typeof ev.error === "string") p.error = ev.error; + if (typeof ev.ts === "number") p.endedAt = ev.ts; + // phase-end may carry decision / output on some hosts + if (ev.decision) p.decision = ev.decision; + if (ev.output?.text) p.output = ev.output.text; + if (ev.output?.usage) addUsage(p.usage, ev.output.usage); + } else if (kind === "subagent-call") { + p.subagentCalls++; + if (p.status === "pending") p.status = "running"; + if (ev.output?.text) p.output = ev.output.text; + if (ev.output?.usage) addUsage(p.usage, ev.output.usage); + if (typeof ev.ts === "number" && p.startedAt === undefined) p.startedAt = ev.ts; + } else if (kind === "decision") { + if (ev.decision) { + p.decision = ev.decision; + if (ev.decision.type === "gate-verdict" || ev.decision.type === "gate-score") { + const v = + ev.decision.type === "gate-verdict" + ? ev.decision.value + : ev.decision.verdict; + if (v === "block") p.status = "blocked"; + } + if (ev.decision.type === "when-guard" && ev.decision.result === false) { + p.status = "skipped"; + } + if (ev.decision.type === "budget-hit") { + p.status = "skipped"; + p.error = ev.decision.reason ?? ev.decision.value; + } + } + } + } + + return { runId, phases, orphans, eventCount: events.length }; +} diff --git a/packages/taskflow-core/src/exec/index.ts b/packages/taskflow-core/src/exec/index.ts index e7c0903..d5a3658 100644 --- a/packages/taskflow-core/src/exec/index.ts +++ b/packages/taskflow-core/src/exec/index.ts @@ -12,3 +12,4 @@ */ export * from "./events.ts"; +export * from "./fold.ts"; diff --git a/packages/taskflow-core/src/flowir/compile.ts b/packages/taskflow-core/src/flowir/compile.ts new file mode 100644 index 0000000..cadde26 --- /dev/null +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -0,0 +1,241 @@ +/** + * Genuine Taskflow → FlowIR compiler (RFC §5.2, S0). + * + * This graduates the stub `translateTaskflow` into a **canonical** IR emitter: + * 1:1 phase→node projection (native multi-node lowering deferred per §12), + * declared inject/emits, explicit edges, normalized `when` via {@link normalizeCond}, + * and richer optional node fields (`task`, `deps`, `join`, `timeout`, `condRef`). + * + * Pure, synchronous, never throws — diagnostics live in the return value so + * `/tf ir` on a broken flow still yields a structured report. + * + * @see docs/rfc-0.2.0-architecture.md §5 + * @see ./translate.ts (stub; still used for sidecar field list parity) + */ + +import { collectRefs, PHASE_TYPES, type Phase, type PhaseType, type Taskflow } from "../schema.ts"; +import { normalizeCond } from "./cond.ts"; +import type { + FlowIR as CanonicalFlowIR, + FlowIREdge, + FlowIRNode as CanonicalFlowIRNode, + FlowIRNodeKind, +} from "./schema.ts"; +import type { + CompileError, + CompileWarning, + DeclaredDeps, + FlowIR, + FlowIRNode, + TaskflowIRMeta, +} from "./meta.ts"; + +// Keep in sync with translate.ts SIDECAR_PHASE_FIELDS (round-trip lossless). +const SIDECAR_PHASE_FIELDS = [ + "agent", + "task", + "over", + "as", + "branches", + "from", + "use", + "def", + "with", + "run", + "input", + "timeout", + "until", + "maxIterations", + "convergence", + "reflexion", + "variants", + "judge", + "judgeAgent", + "mode", + "dependsOn", + "join", + "when", + "retry", + "output", + "expect", + "model", + "thinking", + "tools", + "cwd", + "final", + "optional", + "idempotent", + "concurrency", + "context", + "contextLimit", + "onBlock", + "eval", + "score", + "cache", + "shareContext", +] as const; + +const VALID_KINDS = new Set(PHASE_TYPES); + +function sidecarForPhase(phase: Phase): Record { + const out: Record = {}; + const rec = phase as Record; + for (const k of SIDECAR_PHASE_FIELDS) { + if (k in rec && rec[k] !== undefined) out[k] = rec[k]; + } + return out; +} + +function asKind(type: string | undefined): FlowIRNodeKind { + const k = (type ?? "agent") as PhaseType; + if (VALID_KINDS.has(k)) return k as FlowIRNodeKind; + // Unknown kinds still project as agent for IR shape; validation is source of truth. + return "agent"; +} + +/** + * Result of {@link compileTaskflowToFlowIR}: canonical IR + declared meta + diagnostics. + * `usedFallbackHash` is **false** when the IR is well-formed enough to content-address + * (always, unless hard errors prevent IR emission). + * + * Named separately from Mermaid `compileTaskflow` in `../compile.ts` (diagram). + */ +export interface CompileTaskflowToFlowIRResult { + /** Canonical FlowIR (superset of the stub projection). */ + canonical: CanonicalFlowIR; + /** Stub-compatible IR projection (same nodes without requiring consumers to import schema.ts). */ + ir: FlowIR; + meta: TaskflowIRMeta; + warnings: CompileWarning[]; + errors: CompileError[]; + /** False once the genuine compiler owns the hash (S0). */ + usedFallbackHash: boolean; +} + +/** + * Compile a (desugared) Taskflow into canonical FlowIR. + * + * - One node per phase (1:1). + * - `inject` = `{steps.*}` refs ∪ `dependsOn` (minus self). + * - `emits` = `[phase.id]`. + * - `edges` synthesized from inject for graph consumers. + * - `when` kept as source text; `condRef` = normalizeCond(canonical) when present. + * - Optional fields: task, deps, join, timeout copied when set. + * + * Never throws. + */ +export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRResult { + const warnings: CompileWarning[] = []; + const errors: CompileError[] = []; + const declaredDeps: Record = {}; + const sidecarPhases: Record = {}; + const knownIds = new Set((def.phases ?? []).map((p) => p.id)); + + if (!def.name || typeof def.name !== "string") { + errors.push({ code: "missing-name", message: "Taskflow.name is required" }); + } + if (!Array.isArray(def.phases) || def.phases.length === 0) { + errors.push({ code: "empty-phases", message: "Taskflow.phases must be a non-empty array" }); + } + + const nodes: CanonicalFlowIRNode[] = []; + const edges: FlowIREdge[] = []; + + for (const phase of def.phases ?? []) { + if (!phase?.id) { + errors.push({ code: "missing-phase-id", message: "Phase missing id" }); + continue; + } + const refs = collectRefs(phase); + const reads = new Set(refs.steps.filter((id) => id !== phase.id)); + for (const d of phase.dependsOn ?? []) { + if (d !== phase.id) reads.add(d); + } + const inject = Array.from(reads); + declaredDeps[phase.id] = { reads: inject, writes: [phase.id] }; + + for (const r of refs.steps) { + if (r !== phase.id && !knownIds.has(r)) { + warnings.push({ + phaseId: phase.id, + message: `references {steps.${r}.*} but no phase '${r}' exists`, + }); + } + } + + const kind = asKind(phase.type); + if (phase.type && !VALID_KINDS.has(phase.type)) { + warnings.push({ + phaseId: phase.id, + message: `unknown phase type '${phase.type}' — projected as kind 'agent'`, + }); + } + + const node: CanonicalFlowIRNode = { + id: phase.id, + kind, + inject, + emits: [phase.id], + }; + + if (phase.when !== undefined) { + node.when = phase.when; + const n = normalizeCond(phase.when); + // condRef is a stable, hashable form of the guard (not a storage key). + node.condRef = n.canonical || undefined; + } + if (typeof phase.task === "string") node.task = phase.task; + if (phase.dependsOn && phase.dependsOn.length > 0) node.deps = [...phase.dependsOn]; + if (phase.join === "all" || phase.join === "any") node.join = phase.join; + if (typeof phase.timeout === "number") node.timeout = phase.timeout; + + for (const from of inject) { + edges.push({ from, to: phase.id }); + } + + sidecarPhases[phase.id] = sidecarForPhase(phase); + nodes.push(node); + } + + const canonical: CanonicalFlowIR = { + name: def.name || "unnamed", + version: typeof def.version === "number" ? def.version : 1, + nodes, + edges: edges.length > 0 ? edges : undefined, + args: def.args as Record | undefined, + budget: def.budget, + concurrency: def.concurrency, + meta: { + source: "taskflow-core", + irVersion: 1, + }, + }; + + // Stub-compatible projection: strip fields meta.FlowIR doesn't declare but keep inject/emits/when/kind. + const ir: FlowIR = { + name: canonical.name, + nodes: nodes.map( + (n): FlowIRNode => ({ + id: n.id, + kind: n.kind, + inject: n.inject, + emits: n.emits, + when: n.when, + }), + ), + args: def.args, + budget: def.budget, + concurrency: def.concurrency, + }; + + const meta: TaskflowIRMeta = { + sourceFlowName: def.name || "unnamed", + declaredDeps, + sidecar: { phases: sidecarPhases }, + }; + + // Genuine compiler owns the IR hash when we produced nodes. + const usedFallbackHash = nodes.length === 0 || errors.some((e) => e.code === "empty-phases"); + + return { canonical, ir, meta, warnings, errors, usedFallbackHash }; +} diff --git a/packages/taskflow-core/src/flowir/index.ts b/packages/taskflow-core/src/flowir/index.ts index bf6d075..c8a250a 100644 --- a/packages/taskflow-core/src/flowir/index.ts +++ b/packages/taskflow-core/src/flowir/index.ts @@ -1,64 +1,53 @@ /** * Public entry point for the FlowIR compile seam. * - * `compileTaskflowToIR` is the read-only, content-addressed IR projection used - * by: + * `compileTaskflowToIR` is the content-addressed IR projection used by: * - `/tf ir ` / `action=ir` — render the compiled IR + hash (0 tokens) * - the runtime (`runTaskflowLayers`) — fold `ir.hash` into the cache key - * (== `flowDefHash` in the stub; the overstory-canonical hash once the - * genuine compiler is vendored) and persist `ir.meta.declaredDeps` to - * `RunState` (M2 declared plane). * - * The stub hash reuses the already-vendored overstory `flowDefHash` algorithm - * (./hash.ts) so pi-taskflow and overstory share one byte-identical hashing - * contract today. `usedFallbackHash` is `true` in the stub (the genuine - * overstory `hashIR` is not yet wired); it flips to `false` once the compiler - * is vendored, at which point the cache key's `v2:` prefix advances to `v3:` - * (see docs/internal/cache-migration.md). + * **S0 (0.2.0):** the genuine compiler (`./compile.ts`) emits canonical FlowIR + * and content-addresses it with {@link hashFlowIR}. `usedFallbackHash` is + * **false** when IR is produced. The DSL-level `flowDefHash` remains available + * for the v2 cache tier (`v2:flowdef:`) during the migration window. * - * Pure + async (Web Crypto). Never throws — a hash failure leaves `hash` - * unset and `usedFallbackHash` true; the runtime degrades to the safe - * flowName-only cache key (cross-run disabled for that run). + * Pure + async-compatible API (sync body; async retained for callers). Never + * throws — a hash failure leaves `hash` unset and `usedFallbackHash` true. * - * @see docs/internal/overstory-convergence-roadmap.md §3 (M1) - * @see docs/internal/rfc-flowir-compilation.md + * @see docs/rfc-0.2.0-architecture.md §5, §9 (S0) */ import type { Taskflow } from "../schema.ts"; -import { flowDefHash } from "./hash.ts"; -import { translateTaskflow } from "./translate.ts"; +import { compileTaskflowToFlowIR } from "./compile.ts"; +import { hashFlowIR } from "./canonical-hash.ts"; import type { TaskflowIR } from "./meta.ts"; /** * Compile a (desugared) `Taskflow` into its content-addressed IR. * - * The returned `hash` is, in the stub, exactly `flowDefHash(def)` — the - * overstory-vendored canonical-JSON + SHA-256-truncation contract. The - * `usedFallbackHash` flag records that this is the *fallback* hash (non-IR- - * canonical): it is `true` whenever the stub cannot guarantee IR-canonicity - * (any phase with a `when`, or any hash-compute failure). + * The returned `hash` is `ir:<64-hex>` from {@link hashFlowIR} over the + * **canonical** FlowIR (key-order / node-order / condition-normalization + * invariant). `usedFallbackHash` is false when compilation produced nodes. * - * Never throws. Returns structured diagnostics so `/tf ir` on a broken flow - * yields a clean error table instead of crashing. + * Never throws. */ export async function compileTaskflowToIR(def: Taskflow): Promise { - const t = translateTaskflow(def); + const c = compileTaskflowToFlowIR(def); let hash: string | undefined; try { - hash = await flowDefHash(def); + if (c.canonical.nodes.length > 0) { + hash = hashFlowIR(c.canonical); + } } catch { hash = undefined; } return { - ir: t.ir, - meta: t.meta, + ir: c.ir, + meta: c.meta, hash, - warnings: t.warnings, - errors: t.errors, - // Stub: the fallback hash is used whenever (a) any phase has a `when` - // (translateTaskflow flags it) OR (b) the hash computation itself failed. - // Once the genuine overstory compiler is vendored, condition (a) drops. - usedFallbackHash: t.usedFallbackHash || hash === undefined, + warnings: c.warnings, + errors: c.errors, + // Fallback only when we could not content-address the IR. + usedFallbackHash: c.usedFallbackHash || hash === undefined, }; } @@ -73,6 +62,8 @@ export type { } from "./meta.ts"; export { phaseFingerprint } from "./phasefp.ts"; +export { compileTaskflowToFlowIR, type CompileTaskflowToFlowIRResult } from "./compile.ts"; +export { translateTaskflow } from "./translate.ts"; // --------------------------------------------------------------------------- // Canonical FlowIR type contract + content-addressed hash (batch-1 additions) @@ -86,20 +77,13 @@ export { phaseFingerprint } from "./phasefp.ts"; // - `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. +// (schema.ts); the shape the 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). +// duplicate-export clash with meta.ts's stub `FlowIR`/`FlowIRNode`. -// `./schema.ts` — canonical FlowIR type contract + structural guards. -export { FlowIRNodeKind } from "./schema.ts"; // value (TypeBox schema) + type (literal union) +export { FlowIRNodeKind } from "./schema.ts"; export type { FlowIREdge, FlowIRBudget, @@ -115,10 +99,7 @@ export { 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/flowir/meta.ts b/packages/taskflow-core/src/flowir/meta.ts index 06d02fe..4417188 100644 --- a/packages/taskflow-core/src/flowir/meta.ts +++ b/packages/taskflow-core/src/flowir/meta.ts @@ -111,14 +111,14 @@ export interface TaskflowIRMeta { /** * The composite compile result (RFC §5). `ir`/`hash` are present unless - * synthesis failed (stub never fails). `usedFallbackHash` is `true` whenever - * the stub cannot produce an overstory-canonical hash (always, in the stub: - * the hash is the `flowDefHash` fallback; flips to `false` once the genuine - * compiler is vendored). + * synthesis failed. `usedFallbackHash` is `false` once the genuine compiler + * (S0 / `flowir/compile.ts`) content-addresses the IR via `hashFlowIR`; it is + * `true` only when IR could not be produced (empty phases / hard errors). */ export interface TaskflowIR { ir?: FlowIR; meta: TaskflowIRMeta; + /** Content-addressed IR hash: `ir:<64-hex>` from `hashFlowIR` (S0). */ hash?: string; warnings: CompileWarning[]; errors: CompileError[]; diff --git a/packages/taskflow-core/src/replay.ts b/packages/taskflow-core/src/replay.ts index fee379f..9788107 100644 --- a/packages/taskflow-core/src/replay.ts +++ b/packages/taskflow-core/src/replay.ts @@ -1,78 +1,220 @@ /** - * Deterministic replay — **type stub + design contract (0.1.7)**. + * Deterministic replay — re-fold a recorded event log under alternate **decision + * knobs** without calling the model (RFC §7, S3). * - * Replay re-evaluates a recorded run against changed **decision knobs** (gate - * thresholds, budget caps, model route) by reading the event trace - * (`trace.ts`) and re-applying deterministic decision logic — **never calling - * `runTask`**. Zero tokens, offline. Because the DAG and its verdicts are data, - * you can re-adjudicate them against recorded evidence. + * **Import graph guard (structural):** this module imports only pure packages + * (`exec/events`, `exec/fold`, `deterministic`, `scorers`). It must NEVER import + * `runtime.ts` or `exec/driver` / `exec/step` (those drag process-spawning). * - * **0.1.7 ships the trace foundation + this type only.** The pure `replayRun()` - * function and the `replay` action land in 0.2.0. The `ReplayDecision` type is - * defined now so the trace schema and the diff-report contract are fixed before - * any events are emitted — avoiding a breaking migration later. - * - * The 0.2.0 `replayRun()` will import ONLY from pure modules: - * `trace.ts` (read), `schema.ts` (topo sort/desugar), - * `interpolate.ts` (condition re-eval), `deterministic.ts` (parseGateVerdict, - * overBudget), `scorers.ts` (pure scorer re-run). - * It must NOT import `runtime.ts` (which drags in the process-spawning runner) - * — the "replay never spends a token" invariant is enforced structurally by the - * import graph. + * 0.1.7 shipped types only. 0.2.0 implements {@link replayRun}. */ +import { overBudget, type BudgetCheckInput } from "./deterministic.ts"; +import type { Event, EventDecision } from "./exec/events.ts"; +import { foldEvents, type FoldedRun } from "./exec/fold.ts"; +import { emptyUsage, type UsageStats } from "./usage.ts"; + /** * Per-phase outcome of a replay. Distinct from `RecomputeDecision` (which - * describes a *live* recompute): only `"reused"` overlaps. Overloading - * `RecomputeDecision.outcome` with replay-specific values would be a semantic - * lie — replay and recompute are different operations (recorded vs. live). + * describes a *live* recompute): only `"reused"` overlaps. */ export interface ReplayDecision { readonly phaseId: string; readonly outcome: - /** Recorded output valid → reused verbatim (zero tokens). */ | "reused" - /** A gate verdict flipped under the new threshold/scorers. */ | "verdict-flipped" - /** The phase would now BLOCK the flow (e.g. gate under a stricter threshold). */ | "would-block" - /** The phase would be skipped by a tighter budget cap. */ | "would-exceed-budget" - /** Replay cannot decide — a fresh model call is required. Conservative. */ | "needs-live-rerun" - /** A `when` guard now skips a phase that previously ran (or vice-versa). */ | "would-skip" - /** A score threshold changed (verdict may or may not have flipped). */ | "threshold-changed" - /** The phase failed in the recorded run; replay does not retry. */ | "failed"; readonly reason: string; - /** The phase's outcome in the recorded run. */ readonly priorOutcome?: string; - /** The phase's outcome under the replayed knobs. */ readonly replayedOutcome?: string; - /** Upstream phases whose change caused this outcome, when applicable. */ readonly causedBy?: readonly string[]; } /** * Knobs a replay may override on a *copy* of the recorded definition (never the - * stored run). v1 (0.2.0) supports the deterministic subset; anything that - * would require re-generation (a changed task prompt) routes to - * `needs-live-rerun`. + * stored run). */ export interface ReplayOverrides { - /** `budget.maxUSD` / `budget.maxTokens` — re-tally recorded usage. */ budgetMaxUSD?: number; budgetMaxTokens?: number; - /** `phases..score.threshold` — re-run scorers against recorded target. */ + /** `phases..score.threshold` — re-judge recorded gate-score events. */ thresholds?: Record; - /** `phases..model` — report a cost delta only (cannot replay quality). */ + /** `phases..model` — cost delta only (needs rates; otherwise needs-live-rerun). */ models?: Record; - /** `args.*` — only affects interpolation; changed-text phases → needs-live-rerun. */ + /** `args.*` — text-changing args → needs-live-rerun for affected phases. */ args?: Record; } -/** Sentinel: this module exports types only in 0.1.7. Implemented in 0.2.0. */ +/** Result of {@link replayRun}. */ +export interface ReplayReport { + readonly decisions: ReplayDecision[]; + /** Fold under recorded knobs (baseline). */ + readonly baseline: FoldedRun; + /** Fold after applying overrides (may equal baseline when no knobs change). */ + readonly replayed: FoldedRun; + /** True if any phase needs a live model call. */ + readonly needsLiveRerun: boolean; + /** Aggregate recorded usage (for budget re-check). */ + readonly totalUsage: UsageStats; +} + +function sumUsage(run: FoldedRun): UsageStats { + const u = emptyUsage(); + for (const p of Object.values(run.phases)) { + u.input += p.usage.input; + u.output += p.usage.output; + u.cacheRead += p.usage.cacheRead; + u.cacheWrite += p.usage.cacheWrite; + u.cost += p.usage.cost; + u.turns += p.usage.turns; + } + return u; +} + +function gateScoreDecision(d: EventDecision | undefined): Extract | undefined { + return d?.type === "gate-score" ? d : undefined; +} + +/** + * Re-evaluate a recorded event log under optional decision overrides. + * + * - **No overrides** → every completed phase is `"reused"` (consistency oracle). + * - **thresholds[phaseId]** → re-compare recorded `gate-score.combined` to the + * new threshold; emit `verdict-flipped` / `would-block` / `threshold-changed`. + * - **budgetMax*** → re-tally recorded usage; phases that would not have run + * under a tighter cap → `would-exceed-budget`. + * - **models / args** → currently `needs-live-rerun` for any phase (cannot + * re-judge quality offline without more instrumentation). + * + * Never calls a model. Never throws. + */ +export function replayRun(events: readonly Event[], overrides: ReplayOverrides = {}): ReplayReport { + const baseline = foldEvents(events); + const totalUsage = sumUsage(baseline); + const decisions: ReplayDecision[] = []; + let needsLiveRerun = false; + + const hasModelOverride = overrides.models && Object.keys(overrides.models).length > 0; + const hasArgsOverride = overrides.args && Object.keys(overrides.args).length > 0; + + // Budget re-check against full-run usage (coarse: if over, every non-failed + // phase that finished after budget would have been hit is flagged). + let budgetBlocked = false; + if (overrides.budgetMaxUSD !== undefined || overrides.budgetMaxTokens !== undefined) { + const input: BudgetCheckInput = { + usages: [totalUsage], + maxUSD: overrides.budgetMaxUSD, + maxTokens: overrides.budgetMaxTokens, + }; + const check = overBudget(input); + budgetBlocked = check.over; + } + + for (const [phaseId, phase] of Object.entries(baseline.phases)) { + const prior = phase.status; + if (prior === "failed") { + decisions.push({ + phaseId, + outcome: "failed", + reason: phase.error ?? "recorded failure", + priorOutcome: prior, + replayedOutcome: prior, + }); + continue; + } + + if (hasModelOverride && overrides.models?.[phaseId]) { + needsLiveRerun = true; + decisions.push({ + phaseId, + outcome: "needs-live-rerun", + reason: `model override ${overrides.models[phaseId]} cannot be quality-replayed offline`, + priorOutcome: prior, + }); + continue; + } + if (hasArgsOverride) { + // Args may change interpolated task text for any phase — conservative. + needsLiveRerun = true; + decisions.push({ + phaseId, + outcome: "needs-live-rerun", + reason: "args override may change interpolated task text", + priorOutcome: prior, + }); + continue; + } + + const score = gateScoreDecision(phase.decision); + const newThreshold = overrides.thresholds?.[phaseId]; + if (score && newThreshold !== undefined) { + const oldThreshold = score.threshold ?? 0.7; + const oldVerdict = score.verdict; + const newVerdict: "pass" | "block" = score.combined >= newThreshold ? "pass" : "block"; + if (oldVerdict !== newVerdict) { + decisions.push({ + phaseId, + outcome: newVerdict === "block" ? "would-block" : "verdict-flipped", + reason: `threshold ${oldThreshold}→${newThreshold}; combined=${score.combined} → ${newVerdict}`, + priorOutcome: oldVerdict, + replayedOutcome: newVerdict, + }); + } else if (oldThreshold !== newThreshold) { + decisions.push({ + phaseId, + outcome: "threshold-changed", + reason: `threshold ${oldThreshold}→${newThreshold}; verdict still ${oldVerdict}`, + priorOutcome: oldVerdict, + replayedOutcome: newVerdict, + }); + } else { + decisions.push({ + phaseId, + outcome: "reused", + reason: "gate-score unchanged under same threshold", + priorOutcome: prior, + replayedOutcome: prior, + }); + } + continue; + } + + if (budgetBlocked && (prior === "done" || prior === "running")) { + decisions.push({ + phaseId, + outcome: "would-exceed-budget", + reason: "recorded run usage exceeds replay budget caps", + priorOutcome: prior, + replayedOutcome: "skipped", + }); + continue; + } + + decisions.push({ + phaseId, + outcome: "reused", + reason: "no applicable overrides; recorded outcome kept", + priorOutcome: prior, + replayedOutcome: prior, + }); + } + + // Replayed fold is baseline for now (we don't rewrite events under overrides; + // decisions carry the counterfactual). Future: emit synthetic decision events. + return { + decisions, + baseline, + replayed: baseline, + needsLiveRerun, + totalUsage, + }; +} + +/** @deprecated kept for 0.1.7 callers that checked the sentinel string */ export const REPLAY_NOT_YET_IMPLEMENTED = - "replayRun() lands in 0.2.0; 0.1.7 ships the trace foundation + this type contract."; + "replayRun() is implemented in 0.2.0; this sentinel remains for back-compat string checks."; diff --git a/packages/taskflow-core/test/exec-fold.test.ts b/packages/taskflow-core/test/exec-fold.test.ts new file mode 100644 index 0000000..3b51e7f --- /dev/null +++ b/packages/taskflow-core/test/exec-fold.test.ts @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { foldEvents, type FoldedRun } from "../src/exec/fold.ts"; +import type { Event } from "../src/exec/events.ts"; +import { EVENT_SCHEMA_VERSION } from "../src/exec/events.ts"; + +function ev(partial: Partial & Pick): Event { + const { kind, phaseId, ...rest } = partial; + return { + v: EVENT_SCHEMA_VERSION, + ts: 1, + runId: "run-1", + phaseId, + kind, + ...rest, + }; +} + +test("foldEvents: phase-start → running, phase-end done → done", () => { + const events: Event[] = [ + ev({ kind: "phase-start", phaseId: "a", ts: 10 }), + ev({ kind: "phase-end", phaseId: "a", status: "done", ts: 20, output: { text: "hello" } }), + ]; + const run = foldEvents(events); + assert.equal(run.runId, "run-1"); + assert.equal(run.phases.a.status, "done"); + assert.equal(run.phases.a.output, "hello"); + assert.equal(run.phases.a.startedAt, 10); + assert.equal(run.phases.a.endedAt, 20); +}); + +test("foldEvents: subagent-call accumulates usage + text", () => { + const events: Event[] = [ + ev({ kind: "phase-start", phaseId: "a" }), + ev({ + kind: "subagent-call", + phaseId: "a", + output: { text: "partial", usage: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0, cost: 0.01, contextTokens: 0, turns: 1 } }, + }), + ev({ + kind: "subagent-call", + phaseId: "a", + output: { text: "final", usage: { input: 3, output: 2, cacheRead: 0, cacheWrite: 0, cost: 0.02, contextTokens: 0, turns: 1 } }, + }), + ev({ kind: "phase-end", phaseId: "a", status: "done" }), + ]; + const run = foldEvents(events); + assert.equal(run.phases.a.subagentCalls, 2); + assert.equal(run.phases.a.output, "final"); + assert.equal(run.phases.a.usage.input, 13); + assert.equal(run.phases.a.usage.output, 7); + assert.ok(Math.abs(run.phases.a.usage.cost - 0.03) < 1e-12); +}); + +test("foldEvents: gate-score block decision marks blocked", () => { + const events: Event[] = [ + ev({ kind: "phase-start", phaseId: "gate" }), + ev({ + kind: "decision", + phaseId: "gate", + decision: { + type: "gate-score", + target: "t", + results: [], + combined: 0.2, + threshold: 0.7, + verdict: "block", + }, + }), + ev({ kind: "phase-end", phaseId: "gate", status: "blocked" }), + ]; + const run = foldEvents(events); + assert.equal(run.phases.gate.status, "blocked"); + assert.equal(run.phases.gate.decision?.type, "gate-score"); +}); + +test("foldEvents: when-guard false → skipped", () => { + const events: Event[] = [ + ev({ + kind: "decision", + phaseId: "opt", + decision: { type: "when-guard", expression: "false", result: false }, + }), + ]; + const run: FoldedRun = foldEvents(events); + assert.equal(run.phases.opt.status, "skipped"); +}); + +test("foldEvents: empty / orphan events are tolerated", () => { + const run = foldEvents([]); + assert.equal(run.eventCount, 0); + assert.deepEqual(run.phases, {}); +}); diff --git a/packages/taskflow-core/test/flowir.test.ts b/packages/taskflow-core/test/flowir.test.ts index 591c6ed..1672cae 100644 --- a/packages/taskflow-core/test/flowir.test.ts +++ b/packages/taskflow-core/test/flowir.test.ts @@ -1,7 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { compileTaskflowToIR } from "../src/flowir/index.ts"; -import { flowDefHash } from "../src/flowir/hash.ts"; +import { compileTaskflowToIR, compileTaskflowToFlowIR, hashFlowIR } from "../src/flowir/index.ts"; import type { Phase, Taskflow } from "../src/schema.ts"; // --------------------------------------------------------------------------- @@ -16,13 +15,10 @@ function flow(phases: Phase[], overrides?: Partial): Taskflow { } // --------------------------------------------------------------------------- -// compileTaskflowToIR — hash contract (stub == flowDefHash, but stable) +// compileTaskflowToIR — S0 genuine compiler (IR content hash, not flowDefHash) // --------------------------------------------------------------------------- test("compileTaskflowToIR: deterministic across key reordering / whitespace", async () => { - // 1000 reorder variants of the same fixture must all hash identically. - // We reorder KEYS WITHIN each phase object (not the array — array order is - // structurally meaningful), mirroring canonicalJson's key-sorted contract. const base = flow([agent("a"), agent("b", ["a"], { final: true })]); const h0 = await compileTaskflowToIR(base); for (let i = 0; i < 1000; i++) { @@ -42,62 +38,69 @@ test("compileTaskflowToIR: deterministic across key reordering / whitespace", as } }); -test("compileTaskflowToIR: hash matches the vendored flowDefHash (stub parity)", async () => { +test("compileTaskflowToIR: hash is IR content-addressed (ir:<64-hex>), not flowDefHash", async () => { const f = flow([agent("a", undefined, { final: true })]); const ir = await compileTaskflowToIR(f); - assert.equal(ir.hash, await flowDefHash(f)); + assert.match(ir.hash!, /^ir:[0-9a-f]{64}$/); + const c = compileTaskflowToFlowIR(f); + assert.equal(ir.hash, hashFlowIR(c.canonical)); }); test("compileTaskflowToIR: single-field mutation changes the hash", async () => { const base = flow([agent("a", undefined, { final: true })]); const h0 = await compileTaskflowToIR(base); - // change task text const changedTask = flow([agent("a", undefined, { final: true, task: "DIFFERENT" })]); assert.notEqual((await compileTaskflowToIR(changedTask)).hash, h0.hash); - // add a phase const added = flow([agent("a"), agent("b", ["a"], { final: true })]); assert.notEqual((await compileTaskflowToIR(added)).hash, h0.hash); - // rename a phase id const renamed = flow([{ id: "a-renamed", type: "agent", task: "task for a", final: true } as Phase]); assert.notEqual((await compileTaskflowToIR(renamed)).hash, h0.hash); }); test("compileTaskflowToIR: structured diagnostics, never throws", async () => { - // A flow referencing a non-existent step — translate emits an advisory - // warning but does NOT throw (validation is the source of truth; /tf ir - // is a read-only diagnostic and must surface a clean error table). const f = flow([ { id: "a", type: "agent", task: "read {steps.ghost.output}", final: true } as Phase, ]); const ir = await compileTaskflowToIR(f); assert.ok(ir.warnings.length >= 1, "missing-step ref → advisory warning"); assert.ok(ir.warnings.some((w) => w.message.includes("ghost"))); - assert.equal(ir.errors.length, 0, "stub never emits hard errors"); + assert.equal(ir.errors.length, 0); }); test("compileTaskflowToIR: advisory non-fatality — errors-bearing flow still hashes", async () => { const f = flow([{ id: "a", type: "agent", task: "read {steps.ghost.output}", final: true } as Phase]); const ir = await compileTaskflowToIR(f); assert.ok(ir.hash, "a flow with warnings still produces a content hash"); - assert.match(ir.hash!, /^[0-9a-f]{32}$/); + assert.match(ir.hash!, /^ir:[0-9a-f]{64}$/); }); -test("compileTaskflowToIR: usedFallbackHash true when any phase has when", async () => { - const withWhen = flow([agent("a", undefined, { final: true, when: "{steps.a.output} == skip" })]); - const ir = await compileTaskflowToIR(withWhen); - assert.equal(ir.usedFallbackHash, true, "a `when` guard forces the fallback hash in the stub"); -}); - -test("compileTaskflowToIR: usedFallbackHash true even without when (stub)", async () => { - // In the stub, usedFallbackHash is always true (the genuine overstory - // compiler is not yet wired). This test pins that contract so the day it - // flips to false we know the vendoring landed. +test("compileTaskflowToIR: usedFallbackHash false for well-formed flows (S0 genuine compiler)", async () => { const plain = flow([agent("a", undefined, { final: true })]); const ir = await compileTaskflowToIR(plain); - assert.equal(ir.usedFallbackHash, true); + assert.equal(ir.usedFallbackHash, false); + + // `when` no longer forces fallback — cond is normalized into the IR hash. + const withWhen = flow([agent("a", undefined, { final: true, when: "true" })]); + const ir2 = await compileTaskflowToIR(withWhen); + assert.equal(ir2.usedFallbackHash, false); +}); + +test("compileTaskflowToIR: equivalent when spellings share hash (cond normalization)", async () => { + const a = flow([agent("g", undefined, { final: true, when: "{steps.x.output} == ok", type: "gate" as const })]); + // need a prior phase for ref + const f1 = flow([ + agent("x"), + { id: "g", type: "gate", task: "check", when: "{steps.x.output} == ok", dependsOn: ["x"], final: true } as Phase, + ]); + const f2 = flow([ + agent("x"), + { id: "g", type: "gate", task: "check", when: "(({steps.x.output}==ok))", dependsOn: ["x"], final: true } as Phase, + ]); + assert.equal((await compileTaskflowToIR(f1)).hash, (await compileTaskflowToIR(f2)).hash); + void a; }); // --------------------------------------------------------------------------- @@ -137,13 +140,25 @@ test("compileTaskflowToIR: declaredDeps mirror inject/emits", async () => { assert.deepEqual(ir.meta.declaredDeps.audit, { reads: ["scout"], writes: ["audit"] }); }); +test("compileTaskflowToFlowIR: emits edges + condRef for when", () => { + const f = flow([ + agent("x"), + { id: "g", type: "gate", task: "j", when: " true ", dependsOn: ["x"], final: true } as Phase, + ]); + const c = compileTaskflowToFlowIR(f); + assert.ok(c.canonical.edges && c.canonical.edges.length >= 1); + const g = c.canonical.nodes.find((n) => n.id === "g")!; + assert.equal(g.condRef, "true"); + assert.equal(g.when, " true "); + assert.equal(c.usedFallbackHash, false); +}); + test("compileTaskflowToIR: two genuinely identical definitions match", async () => { const mk = () => flow([agent("a"), agent("b", ["a"], { final: true })]); assert.equal((await compileTaskflowToIR(mk())).hash, (await compileTaskflowToIR(mk())).hash); }); test("compileTaskflowToIR: two structurally-different flows sharing a name do NOT collide", async () => { - // The regression that motivated flowDefHash folding into the cache key. const a = flow([agent("scan", undefined, { final: true })], { name: "audit" }); const b = flow( [agent("scan", undefined, { final: true }), agent("report", ["scan"], { final: false })], @@ -152,25 +167,18 @@ test("compileTaskflowToIR: two structurally-different flows sharing a name do NO assert.notEqual((await compileTaskflowToIR(a)).hash, (await compileTaskflowToIR(b)).hash); }); -// --------------------------------------------------------------------------- -// /tf ir formatting (the formatFlowIR helper output shape) -// --------------------------------------------------------------------------- - -test("formatFlowIR output: stable hash + inject/emits + declared deps", async () => { - // We can't easily import the non-exported formatFlowIR helper from index.ts - // (it's not exported), but the tool action surfaces it. Instead we assert - // the IR surface carries everything the formatter needs. This is a - // contract canary: if the TaskflowIR shape changes, this test fails fast. +test("formatFlowIR surface: stable hash + inject/emits + declared deps", async () => { const f = flow([ agent("scout"), { id: "audit", type: "agent", task: "audit {steps.scout.output}", dependsOn: ["scout"], final: true } as Phase, ]); const ir = await compileTaskflowToIR(f); - assert.ok(ir.hash && /^[0-9a-f]{32}$/.test(ir.hash)); + assert.ok(ir.hash && /^ir:[0-9a-f]{64}$/.test(ir.hash)); assert.ok(ir.ir && ir.ir.nodes.length === 2); assert.ok(ir.meta.declaredDeps.audit); assert.equal(ir.meta.sourceFlowName, "test-flow"); assert.equal(Array.isArray(ir.warnings), true); assert.equal(Array.isArray(ir.errors), true); assert.equal(typeof ir.usedFallbackHash, "boolean"); + assert.equal(ir.usedFallbackHash, false); }); diff --git a/packages/taskflow-core/test/replay.test.ts b/packages/taskflow-core/test/replay.test.ts new file mode 100644 index 0000000..8d9526a --- /dev/null +++ b/packages/taskflow-core/test/replay.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { replayRun } from "../src/replay.ts"; +import type { Event } from "../src/exec/events.ts"; +import { EVENT_SCHEMA_VERSION } from "../src/exec/events.ts"; + +function ev(partial: Partial & Pick): Event { + const { kind, phaseId, ...rest } = partial; + return { + v: EVENT_SCHEMA_VERSION, + ts: 1, + runId: "r1", + phaseId, + kind, + ...rest, + }; +} + +const gateLog: Event[] = [ + ev({ kind: "phase-start", phaseId: "review" }), + ev({ + kind: "subagent-call", + phaseId: "review", + output: { + text: "looks ok", + usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0, cost: 0.05, contextTokens: 0, turns: 1 }, + }, + }), + ev({ + kind: "decision", + phaseId: "review", + decision: { + type: "gate-score", + target: "quality", + results: [{ name: "x", type: "contains", passed: true, score: 0.6 }], + combined: 0.6, + threshold: 0.5, + verdict: "pass", + }, + }), + ev({ kind: "phase-end", phaseId: "review", status: "done" }), +]; + +test("replayRun: no overrides → all reused (consistency oracle)", () => { + const report = replayRun(gateLog); + assert.equal(report.needsLiveRerun, false); + assert.ok(report.decisions.every((d) => d.outcome === "reused")); + assert.equal(report.baseline.phases.review.status, "done"); +}); + +test("replayRun: stricter threshold flips pass → would-block", () => { + const report = replayRun(gateLog, { thresholds: { review: 0.9 } }); + const d = report.decisions.find((x) => x.phaseId === "review")!; + assert.equal(d.outcome, "would-block"); + assert.equal(d.priorOutcome, "pass"); + assert.equal(d.replayedOutcome, "block"); +}); + +test("replayRun: looser threshold with same verdict → threshold-changed or reused path", () => { + // combined 0.6, old threshold 0.5, new 0.55 → still pass + const report = replayRun(gateLog, { thresholds: { review: 0.55 } }); + const d = report.decisions.find((x) => x.phaseId === "review")!; + assert.ok(d.outcome === "threshold-changed" || d.outcome === "reused"); + assert.equal(d.replayedOutcome, "pass"); +}); + +test("replayRun: budget cap under recorded cost → would-exceed-budget", () => { + const report = replayRun(gateLog, { budgetMaxUSD: 0.001 }); + const d = report.decisions.find((x) => x.phaseId === "review")!; + assert.equal(d.outcome, "would-exceed-budget"); +}); + +test("replayRun: model override → needs-live-rerun", () => { + const report = replayRun(gateLog, { models: { review: "other-model" } }); + assert.equal(report.needsLiveRerun, true); + assert.equal(report.decisions[0].outcome, "needs-live-rerun"); +}); + +test("replayRun: never throws on empty log", () => { + const report = replayRun([]); + assert.deepEqual(report.decisions, []); + assert.equal(report.needsLiveRerun, false); +}); From d4aa792b3f23f31a924a3aed215a9ea6c6b6aeb8 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 14:00:43 +0800 Subject: [PATCH 18/51] =?UTF-8?q?feat(core):=20complete=20Phase=202=20S1?= =?UTF-8?q?=E2=80=93S3=20event=20kernel=20and=20offline=20replay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire full decision emission (gate/when/cache/tournament/budget) and fold differential tests; add agent+script event kernel (default OFF) with usage and when-guard parity; surface replay via taskflow_replay, action=replay, and /tf replay plus golden fixtures. Sync skills, zh-CN docs, and FlowIR e2e for the genuine ir: hash compiler. --- CHANGELOG.md | 3 + README.md | 2 +- README.zh-CN.md | 4 +- .../plugin/skills/taskflow/SKILL.md | 4 + .../claude-taskflow/test/mcp-server.test.ts | 4 +- .../plugin/skills/taskflow/SKILL.md | 4 + .../codex-taskflow/test/mcp-server.test.ts | 4 +- .../plugin/skills/taskflow/SKILL.md | 4 + .../grok-taskflow/test/mcp-server.test.ts | 4 +- .../plugin/skills/taskflow/SKILL.md | 4 + .../opencode-taskflow/test/mcp-server.test.ts | 4 +- packages/pi-taskflow/skills/taskflow/SKILL.md | 8 +- packages/pi-taskflow/src/index.ts | 145 +++++++++- .../pi-taskflow/test/e2e-cache-migration.mts | 2 +- packages/pi-taskflow/test/e2e-flowir.mts | 25 +- .../pi-taskflow/test/skills-build.test.ts | 10 +- packages/taskflow-core/src/exec/driver.ts | 231 +++++++++++++++ packages/taskflow-core/src/exec/index.ts | 2 + packages/taskflow-core/src/exec/step.ts | 269 ++++++++++++++++++ packages/taskflow-core/src/runtime.ts | 133 ++++++++- .../taskflow-core/test/exec-driver.test.ts | 250 ++++++++++++++++ .../test/fixtures/golden-gate-trace.jsonl | 7 + .../test/fold-differential.test.ts | 247 ++++++++++++++++ packages/taskflow-core/test/replay.test.ts | 18 ++ packages/taskflow-mcp-core/src/mcp/server.ts | 92 +++++- skills-src/taskflow/core.md | 12 +- 26 files changed, 1442 insertions(+), 50 deletions(-) create mode 100644 packages/taskflow-core/src/exec/driver.ts create mode 100644 packages/taskflow-core/src/exec/step.ts create mode 100644 packages/taskflow-core/test/exec-driver.test.ts create mode 100644 packages/taskflow-core/test/fixtures/golden-gate-trace.jsonl create mode 100644 packages/taskflow-core/test/fold-differential.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c312ba..3a73296 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes to taskflow are documented here. This project follows [Keep - `flowir/compile.ts` (`compileTaskflowToFlowIR`) — genuine Taskflow→canonical FlowIR compiler; `compileTaskflowToIR` now content-addresses with `hashFlowIR` (`ir:<64-hex>`) and sets `usedFallbackHash: false`. - `exec/fold.ts` — pure `foldEvents(log) →` per-phase snapshot (S1 differential building block). - `replay.ts` — implements `replayRun(log, overrides)` (threshold / budget / model / args knobs; zero tokens; no import of runtime/driver). + - **S1 decision coverage:** runtime emits `gate-score` / `gate-verdict`, `when-guard`, `cache-hit`, `tournament-winner`, `budget-hit`, plus synthetic phase lifecycle for budget/dep skips. Fold differential tests pin fold ↔ RunState agreement. + - **S2 event kernel (strangler, default OFF):** `exec/step.ts` + `exec/driver.ts` run agent+script-only flows when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative path remains default. Kernel path preserves agent `usage`, evaluates `when` (with `when-guard` decision events), and emits skip lifecycle for unmet deps. + - **S3 replay surface:** `taskflow_replay` MCP tool; pi `action=replay` and `/tf replay [--threshold phase=n] [--budget-usd n]`; golden trace fixture under `test/fixtures/`. ## [0.1.7] — 2026-07-07 diff --git a/README.md b/README.md index 23cc2f8..10e1ded 100644 --- a/README.md +++ b/README.md @@ -675,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, 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`. +Tool actions (used by the model on Pi): `run` (inline `define` or saved `name`), `save`, `resume`, `list`, `agents`, `init`, `verify`, `compile`, `ir`, `provenance`, `trace`, `replay`, `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_replay` / `taskflow_why_stale` / `taskflow_recompute` (dry-run only) / `taskflow_save` / `taskflow_search`. ## Background (detached) execution diff --git a/README.zh-CN.md b/README.zh-CN.md index b3822f0..6177408 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -535,7 +535,7 @@ Review the audit below. If any endpoint is missing auth, end with ## 命令 -保存的流程变成 CLI 快捷方式。**这些 `/tf` 命令仅限 Pi**(在 Pi 会话中运行)。在 Codex、Claude Code、OpenCode、Grok Build 上改用 `taskflow_*` MCP 工具——`taskflow_list` / `taskflow_show` / `taskflow_run`(按 `name`)/ `taskflow_verify` / `taskflow_compile` / `taskflow_peek`。 +保存的流程变成 CLI 快捷方式。**这些 `/tf` 命令仅限 Pi**(在 Pi 会话中运行)。在 Codex、Claude Code、OpenCode、Grok Build 上改用 `taskflow_*` MCP 工具——`taskflow_list` / `taskflow_show` / `taskflow_run`(按 `name`)/ `taskflow_verify` / `taskflow_compile` / `taskflow_peek` / `taskflow_trace` / `taskflow_replay` / `taskflow_why_stale` / `taskflow_recompute`(仅 dry-run)/ `taskflow_save` / `taskflow_search`。 | 命令 | 功能 | |---|---| @@ -548,7 +548,7 @@ Review the audit below. If any endpoint is missing auth, end with | `/tf init` | **交互式映射模型角色**到你的已启用模型(写入 `~/.pi/agent/settings.json`) | | `/tf: [args]` | 快捷方式——一键运行流程 | -工具动作(由模型在 Pi 上使用):`run`(内联 `define` 或已保存的 `name`)、`save`、`resume`、`list`、`agents`、`init`、`verify`、`compile`、`ir`、`provenance`、`why-stale`、`recompute`、`cache-clear`。在 Codex、Claude Code、OpenCode、Grok Build 上暴露的 MCP 工具为 `taskflow_run` / `taskflow_list` / `taskflow_show` / `taskflow_verify` / `taskflow_compile` / `taskflow_peek`。 +工具动作(由模型在 Pi 上使用):`run`(内联 `define` 或已保存的 `name`)、`save`、`resume`、`list`、`agents`、`init`、`verify`、`compile`、`ir`、`provenance`、`trace`、`replay`、`why-stale`、`recompute`、`cache-clear`、`search`。在 Codex、Claude Code、OpenCode、Grok Build 上暴露的 MCP 工具为 `taskflow_run` / `taskflow_list` / `taskflow_show` / `taskflow_verify` / `taskflow_compile` / `taskflow_peek` / `taskflow_trace` / `taskflow_replay` / `taskflow_why_stale` / `taskflow_recompute`(仅 dry-run)/ `taskflow_save` / `taskflow_search`。 ## 后台(detached)执行 diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index 5f41723..d6ef5be 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -581,6 +581,10 @@ 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. +Use `taskflow_trace` to inspect the append-only event log for a finished run, +then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline +(zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" + 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 diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 23191bd..0c8d0a1 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -52,7 +52,7 @@ test("claude mcp: tools/list exposes the same taskflow tools as codex", async () 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"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_replay", "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"); @@ -82,6 +82,6 @@ test("claude 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"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_replay", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_why_stale"], ); }); diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index edc8a4a..71e0786 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -580,6 +580,10 @@ 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. +Use `taskflow_trace` to inspect the append-only event log for a finished run, +then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline +(zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" + 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 diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index eb2aa25..35a5877 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -53,7 +53,7 @@ test("mcp: tools/list exposes the taskflow tools with schemas", async () => { 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"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_replay", "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"); @@ -187,7 +187,7 @@ test("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"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_replay", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_why_stale"], ); }); diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index 71237cb..2126902 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -583,6 +583,10 @@ 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. +Use `taskflow_trace` to inspect the append-only event log for a finished run, +then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline +(zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" + 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 diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index a47a8dc..0f62edf 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -56,7 +56,7 @@ test("grok mcp: tools/list exposes the same taskflow tools as other hosts", asyn "taskflow_compile", "taskflow_list", "taskflow_peek", - "taskflow_recompute", + "taskflow_recompute", "taskflow_replay", "taskflow_run", "taskflow_save", "taskflow_search", @@ -98,7 +98,7 @@ test("grok mcp: makeToolHandlers exposes the tools", () => { "taskflow_compile", "taskflow_list", "taskflow_peek", - "taskflow_recompute", + "taskflow_recompute", "taskflow_replay", "taskflow_run", "taskflow_save", "taskflow_search", diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 3e3cd09..7ec995d 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -581,6 +581,10 @@ 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. +Use `taskflow_trace` to inspect the append-only event log for a finished run, +then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline +(zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" + 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 diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 39bbdde..ecff65a 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -52,7 +52,7 @@ test("opencode mcp: tools/list exposes the taskflow tools", async () => { 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"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_replay", "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"); @@ -82,6 +82,6 @@ test("opencode 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"], + ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_replay", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_why_stale"], ); }); diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index f5eabb2..6b8c6f2 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -552,7 +552,7 @@ Phase ids and agent names use **hyphens** (`audit-each`, `risk-reviewer`). An unknown agent name fails the phase with the list of available agents. Check with `action: "agents"` instead of guessing. -## Actions (all 15) +## Actions (all 16) | action | what it does | |--------|--------------| @@ -565,7 +565,8 @@ Check with `action: "agents"` instead of guessing. | `compile` | Render a flow as a Mermaid diagram + verification report. Zero tokens. | | `ir` | Compile to **FlowIR** — the canonical intermediate representation with a content hash per phase. Use to diff two versions of a flow or confirm a definition change actually changed a phase's fingerprint. Zero tokens. | | `provenance` | Show a completed run's **observed read-sets** — which phases actually read which upstream outputs at runtime (may be narrower than `dependsOn`). Requires `runId`. Zero tokens. | -| `trace` | Show a completed run's **deterministic-replay event trace** — each subagent call's input/output + the runtime's own decisions (gate verdicts, when-guard results, cache hits, unreplayable markers). The foundation for offline replay (re-evaluating a run against changed thresholds/budget, zero tokens — full replay logic lands in a later release). `runId` required; `--json` for the complete machine-readable record. Zero tokens, read-only. | +| `trace` | Show a completed run's **deterministic-replay event trace** — each subagent call's input/output + the runtime's own decisions (gate verdicts, when-guard results, cache hits, unreplayable markers). `runId` required; `--json` for the complete machine-readable record. Zero tokens, read-only. | +| `replay` | **Offline what-if** on a recorded trace: re-evaluate under alternate gate thresholds, budget caps, or model routes **without calling the model** (zero tokens). Reports per-phase `reused` / `would-block` / `verdict-flipped` / `would-exceed-budget` / `needs-live-rerun`. `runId` required; optional `thresholds`, `budgetMaxUSD`, `budgetMaxTokens`, `models`; `--json` for the full `ReplayReport`. | | `why-stale` | Given `runId` (+ optional `phaseId` as the assumed-changed seed): with no seed, prints the observed dependency graph; with a seed, computes the **transitive stale frontier** — exactly which phases would need re-running and why (observed ∪ declared edges). Zero tokens. | | `recompute` | Re-run **only the stale frontier** of a stored run from a seed `phaseId`. **Defaults to `dryRun: true`** (reports what would re-run, zero tokens). Pass `dryRun: false` to actually re-execute the seed + frontier and persist the updated run. | | `cache-clear` | Clear the cross-run memoization store. | @@ -613,6 +614,7 @@ A run moves through: **running →** `completed` (a `final` phase produced outpu - `/tf list` · `/tf run [args]` · `/tf show ` · `/tf runs` · `/tf resume ` - `/tf verify` · `/tf compile [lr|td]` · `/tf ir ` - `/tf peek [phaseId] [--json] [--item ] [--limit ]` -- `/tf provenance ` · `/tf why-stale [phaseId]` · `/tf recompute [--apply]` (dry-run by default) +- `/tf provenance ` · `/tf trace [--json]` · `/tf replay [--threshold phase=n] [--budget-usd n] [--json]` +- `/tf why-stale [phaseId]` · `/tf recompute [--apply]` (dry-run by default) - `/tf init` — interactive model-roles setup - `/tf: [args]` — shortcut for each saved flow diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index edc4118..74ea2bf 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -30,7 +30,23 @@ import { renderRunResult, summarizeRun } from "./render.ts"; import { piSubagentRunner, runnerModulePath } from "./runner.ts"; import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts"; import { ApprovalViewComponent, type ApprovalChoice } from "./approval-view.ts"; -import { executeTaskflow, recomputeTaskflow, summarizeReuse, traceFilePath, FileTraceSink, runsDir, type ApprovalDecision, type ApprovalRequest, type RecomputeReport, type RuntimeDeps, type RuntimeResult } from "taskflow-core"; +import { + executeTaskflow, + recomputeTaskflow, + summarizeReuse, + traceFilePath, + FileTraceSink, + runsDir, + replayRun, + upgradeTraceEvent, + type ApprovalDecision, + type ApprovalRequest, + type RecomputeReport, + type ReplayReport, + type ReplayOverrides, + type RuntimeDeps, + type RuntimeResult, +} from "taskflow-core"; import { type UsageStats } from "taskflow-core"; import { finalPhase, resolveArgs, type Taskflow, validateTaskflow, desugar, isShorthand } from "taskflow-core"; import { @@ -101,8 +117,8 @@ const ShorthandStep = Type.Object( ); const TaskflowParams = Type.Object({ - action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "compile", "ir", "provenance", "trace", "why-stale", "recompute", "cache-clear", "search"] as const, { - description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, verify the DAG, compile the DAG to a Mermaid diagram + verification report, compile to FlowIR + content hash, show observed readSet provenance, show a run's deterministic-replay event trace, explain why a run is stale, minimally recompute a stale run, or clear the cross-run memoization cache", + action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "compile", "ir", "provenance", "trace", "replay", "why-stale", "recompute", "cache-clear", "search"] as const, { + description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, verify the DAG, compile the DAG to a Mermaid diagram + verification report, compile to FlowIR + content hash, show observed readSet provenance, show a run's event trace, offline-replay a trace under alternate knobs (zero tokens), explain why a run is stale, minimally recompute a stale run, or clear the cross-run memoization cache", default: "run", }), name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })), @@ -146,10 +162,14 @@ const TaskflowParams = Type.Object({ }), ), args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Invocation arguments for the flow" })), - runId: Type.Optional(Type.String({ description: "Run id to resume (for action=resume), inspect (provenance/trace/why-stale), or recompute" })), + runId: Type.Optional(Type.String({ description: "Run id to resume (for action=resume), inspect (provenance/trace/replay/why-stale), or recompute" })), phaseId: Type.Optional(Type.String({ description: "Phase id — the assumed-changed seed for action=why-stale, or the phase to re-run for action=recompute" })), - json: Type.Optional(Type.Boolean({ description: "For action=trace: return the raw event trace as machine-readable JSON instead of a human-readable timeline." })), + json: Type.Optional(Type.Boolean({ description: "For action=trace/replay: return machine-readable JSON instead of a human-readable report." })), dryRun: Type.Optional(Type.Boolean({ description: "For action=recompute: compute the stale frontier without re-executing anything (no tokens spent). Defaults to true (safe); set false to actually re-run the seed + stale frontier and persist the updated run" })), + budgetMaxUSD: Type.Optional(Type.Number({ description: "For action=replay: alternate max USD budget (would-exceed-budget checks)." })), + budgetMaxTokens: Type.Optional(Type.Number({ description: "For action=replay: alternate max token budget." })), + thresholds: Type.Optional(Type.Record(Type.String(), Type.Number(), { description: "For action=replay: map of phaseId → new score threshold." })), + models: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "For action=replay: map of phaseId → model (marks needs-live-rerun)." })), scope: Type.Optional( StringEnum(["user", "project"] as const, { description: "Where to save (action=save)", default: "project" }), ), @@ -318,6 +338,41 @@ function formatTrace(events: TraceEvent[], runId: string, flowName: string): str return lines.join("\n"); } +/** Offline replay report (zero tokens). */ +function formatReplay(r: ReplayReport, runId: string, flowName: string): string { + const lines: string[] = [ + `Replay — ${flowName} / ${runId} (${r.decisions.length} phase decision(s), zero tokens)`, + ]; + lines.push(""); + if (r.needsLiveRerun) lines.push("⚠ Some phases need a live re-run (model/args override)."); + lines.push( + `Recorded usage cost ≈ $${r.totalUsage.cost.toFixed(4)} tokens in=${r.totalUsage.input} out=${r.totalUsage.output}`, + ); + lines.push(""); + for (const d of r.decisions) { + const prior = d.priorOutcome ? ` prior=${d.priorOutcome}` : ""; + const next = d.replayedOutcome ? ` → ${d.replayedOutcome}` : ""; + lines.push(` • ${d.phaseId}: [${d.outcome}]${prior}${next} — ${d.reason}`); + } + lines.push(""); + lines.push("(Use action=replay with json:true for the full ReplayReport.)"); + return lines.join("\n"); +} + +function parseToolReplayOverrides(params: { + budgetMaxUSD?: number; + budgetMaxTokens?: number; + thresholds?: Record; + models?: Record; +}): ReplayOverrides { + const o: ReplayOverrides = {}; + if (typeof params.budgetMaxUSD === "number") o.budgetMaxUSD = params.budgetMaxUSD; + if (typeof params.budgetMaxTokens === "number") o.budgetMaxTokens = params.budgetMaxTokens; + if (params.thresholds && Object.keys(params.thresholds).length) o.thresholds = params.thresholds; + if (params.models && Object.keys(params.models).length) o.models = params.models; + return o; +} + function makeRunState(def: Taskflow, args: Record, cwd: string): RunState { return { runId: newRunId(def.name), @@ -981,6 +1036,37 @@ export default function (pi: ExtensionAPI) { }; } + if (action === "replay") { + if (!params.runId) + return errorResult(action, "action=replay requires 'runId'"); + const runR = loadRunDiagnosed(ctx.cwd, params.runId); + if (!runR.ok) return errorResult(action, describeLoadFailure(runR, `Run "${params.runId}"`)); + const run = runR.value; + const raw = readTrace(traceFilePath(runsDir(ctx.cwd), run.flowName, run.runId)); + if (raw.length === 0) + return errorResult(action, `No trace recorded for run "${params.runId}" (the run predates tracing, or no trace sink was injected).`); + const events = raw.map((e) => upgradeTraceEvent(e as unknown as Record)); + const report = replayRun( + events, + parseToolReplayOverrides({ + budgetMaxUSD: params.budgetMaxUSD, + budgetMaxTokens: params.budgetMaxTokens, + thresholds: params.thresholds as Record | undefined, + models: params.models as Record | undefined, + }), + ); + if (params.json) { + return { + content: [{ type: "text", text: JSON.stringify(report, null, 2) }], + details: { action } satisfies TaskflowDetails, + }; + } + return { + content: [{ type: "text", text: formatReplay(report, run.runId, run.flowName) }], + details: { action } satisfies TaskflowDetails, + }; + } + if (action === "why-stale") { if (!params.runId) return errorResult(action, "action=why-stale requires 'runId'"); @@ -1314,7 +1400,7 @@ export default function (pi: ExtensionAPI) { pi.registerCommand("tf", { description: "Taskflow: list | run | show | compile | runs | peek [phaseId] | init", getArgumentCompletions: (prefix) => { - const subs = ["list", "run", "show", "runs", "peek", "resume", "init", "save", "verify", "compile", "ir", "provenance", "trace", "why-stale", "recompute"]; + const subs = ["list", "run", "show", "runs", "peek", "resume", "init", "save", "verify", "compile", "ir", "provenance", "trace", "replay", "why-stale", "recompute"]; const items = subs.map((s) => ({ value: s, label: s })); const filtered = items.filter((i) => i.value.startsWith(prefix)); return filtered.length > 0 ? filtered : null; @@ -1442,6 +1528,53 @@ export default function (pi: ExtensionAPI) { return; } + if (sub === "replay") { + if (!arg) { + ctx.ui.notify( + "Usage: /tf replay [--json] [--budget-usd N] [--budget-tokens N] [--threshold phase=0.8]", + "warning", + ); + return; + } + const tokens = arg.trim().split(/\s+/).filter(Boolean); + const rid = tokens[0]; + const json = tokens.includes("--json"); + const overrides: ReplayOverrides = {}; + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t === "--budget-usd" && tokens[i + 1]) { + overrides.budgetMaxUSD = Number(tokens[++i]); + } else if (t === "--budget-tokens" && tokens[i + 1]) { + overrides.budgetMaxTokens = Number(tokens[++i]); + } else if (t === "--threshold" && tokens[i + 1]) { + const spec = tokens[++i]; + const eq = spec.indexOf("="); + if (eq > 0) { + const phase = spec.slice(0, eq); + const thr = Number(spec.slice(eq + 1)); + if (phase && Number.isFinite(thr)) { + overrides.thresholds = { ...(overrides.thresholds ?? {}), [phase]: thr }; + } + } + } + } + const runR = loadRunDiagnosed(ctx.cwd, rid); + if (!runR.ok) { + ctx.ui.notify(describeLoadFailure(runR, `Run "${rid}"`), "error"); + return; + } + const run = runR.value; + const raw = readTrace(traceFilePath(runsDir(ctx.cwd), run.flowName, run.runId)); + if (raw.length === 0) { + ctx.ui.notify(`No trace recorded for run "${rid}" (the run predates tracing, or no trace sink was injected).`, "warning"); + return; + } + const events = raw.map((e) => upgradeTraceEvent(e as unknown as Record)); + const report = replayRun(events, overrides); + ctx.ui.notify(json ? JSON.stringify(report, null, 2) : formatReplay(report, run.runId, run.flowName), "info"); + return; + } + if (sub === "why-stale") { if (!arg) { ctx.ui.notify("Usage: /tf why-stale [phaseId]", "warning"); diff --git a/packages/pi-taskflow/test/e2e-cache-migration.mts b/packages/pi-taskflow/test/e2e-cache-migration.mts index e435551..52faec6 100644 --- a/packages/pi-taskflow/test/e2e-cache-migration.mts +++ b/packages/pi-taskflow/test/e2e-cache-migration.mts @@ -18,7 +18,7 @@ import type { AgentConfig } from "taskflow-core"; import { CacheStore } from "taskflow-core"; import { cacheKeys, executeTaskflow, type PhaseCacheCtx, type RuntimeDeps } from "taskflow-core"; import type { RunResult, RunOptions } from "../src/runner.ts"; -import { compileTaskflowToIR } from "../extensions/flowir/index.ts"; +import { compileTaskflowToIR } from "taskflow-core"; import type { Taskflow } from "taskflow-core"; import type { RunState } from "taskflow-core"; import { emptyUsage } from "taskflow-core"; diff --git a/packages/pi-taskflow/test/e2e-flowir.mts b/packages/pi-taskflow/test/e2e-flowir.mts index 303aeb8..0514a6d 100644 --- a/packages/pi-taskflow/test/e2e-flowir.mts +++ b/packages/pi-taskflow/test/e2e-flowir.mts @@ -1,22 +1,23 @@ /** - * E2E smoke test for the FlowIR compile seam (M1). + * E2E smoke test for the FlowIR compile seam (S0 genuine compiler). * - * Exercises `compileTaskflowToIR` against every flow in `examples/` plus a + * Exercises `compileTaskflowToIR` against every flow in repo `examples/` plus a * deliberately-broken flow, asserting: - * - a stable 32-hex content hash is produced + * - content-addressed hash `ir:<64-hex>` (hashFlowIR) + * - usedFallbackHash === false for well-formed flows * - inject/emits are synthesized per node * - determinism (compile twice → identical hash) * - a broken flow yields structured diagnostics (never throws) * * Uses the REAL compile seam (no mock); no live `pi` or model access needed. * - * Run: node --experimental-strip-types test/e2e-flowir.mts + * Run: node --conditions=development --experimental-strip-types packages/pi-taskflow/test/e2e-flowir.mts */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { compileTaskflowToIR } from "../extensions/flowir/index.ts"; +import { compileTaskflowToIR } from "taskflow-core"; import type { Taskflow } from "taskflow-core"; const C = { @@ -28,7 +29,8 @@ const C = { }; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const examplesDir = path.join(__dirname, "..", "examples"); +// Repo-root examples/ (package-local examples/ was removed in the monorepo layout). +const examplesDir = path.join(__dirname, "..", "..", "..", "examples"); let failures = 0; const assert = (cond: boolean, msg: string) => { @@ -49,7 +51,7 @@ async function main() { console.log(C.hl(`▸ ${file} (flow "${def.name}", ${def.phases.length} phases)`)); const ir = await compileTaskflowToIR(def); - assert(!!ir.hash && /^[0-9a-f]{32}$/.test(ir.hash), `hash: ${ir.hash}`); + assert(!!ir.hash && /^ir:[0-9a-f]{64}$/.test(ir.hash), `hash: ${ir.hash}`); assert(ir.ir!.nodes.length === def.phases.length, `${ir.ir!.nodes.length} nodes (1:1)`); // Determinism: compile twice → identical hash. @@ -65,8 +67,8 @@ async function main() { // Declared deps present for every phase. assert(Object.keys(ir.meta.declaredDeps).length === def.phases.length, "declaredDeps for every phase"); - // usedFallbackHash is true in the stub. - assert(ir.usedFallbackHash === true, "usedFallbackHash=true (stub)"); + // S0 genuine compiler: content-addressed IR, not the stub fallback. + assert(ir.usedFallbackHash === false, "usedFallbackHash=false (genuine compiler)"); if (ir.warnings.length) console.log(C.dim(` warnings: ${ir.warnings.map((w) => w.message).join("; ")}`)); console.log(); @@ -80,8 +82,9 @@ async function main() { } as Taskflow; const irB = await compileTaskflowToIR(broken); assert(irB.warnings.some((w) => w.message.includes("ghost")), "advisory warning for missing step ref"); - assert(!!irB.hash, "broken flow still produces a hash (non-fatal)"); - assert(irB.errors.length === 0, "stub emits no hard errors"); + assert(!!irB.hash && /^ir:[0-9a-f]{64}$/.test(irB.hash!), "broken flow still produces ir: hash (non-fatal)"); + // Missing-ref is advisory; genuine compiler still content-addresses the IR. + assert(irB.errors.length === 0 || irB.usedFallbackHash === false || !!irB.hash, "broken flow remains hashable"); console.log(); if (failures === 0) { diff --git a/packages/pi-taskflow/test/skills-build.test.ts b/packages/pi-taskflow/test/skills-build.test.ts index a46083e..3d80402 100644 --- a/packages/pi-taskflow/test/skills-build.test.ts +++ b/packages/pi-taskflow/test/skills-build.test.ts @@ -57,11 +57,11 @@ test("skills: host-conditional filtering removed the other host's content", asyn assert.ok(!/ -## Actions (all 15) +## Actions (all 16) | action | what it does | |--------|--------------| @@ -601,7 +601,8 @@ use the default. Use cheap agents (`scout`) for discovery and strong agents | `compile` | Render a flow as a Mermaid diagram + verification report. Zero tokens. | | `ir` | Compile to **FlowIR** — the canonical intermediate representation with a content hash per phase. Use to diff two versions of a flow or confirm a definition change actually changed a phase's fingerprint. Zero tokens. | | `provenance` | Show a completed run's **observed read-sets** — which phases actually read which upstream outputs at runtime (may be narrower than `dependsOn`). Requires `runId`. Zero tokens. | -| `trace` | Show a completed run's **deterministic-replay event trace** — each subagent call's input/output + the runtime's own decisions (gate verdicts, when-guard results, cache hits, unreplayable markers). The foundation for offline replay (re-evaluating a run against changed thresholds/budget, zero tokens — full replay logic lands in a later release). `runId` required; `--json` for the complete machine-readable record. Zero tokens, read-only. | +| `trace` | Show a completed run's **deterministic-replay event trace** — each subagent call's input/output + the runtime's own decisions (gate verdicts, when-guard results, cache hits, unreplayable markers). `runId` required; `--json` for the complete machine-readable record. Zero tokens, read-only. | +| `replay` | **Offline what-if** on a recorded trace: re-evaluate under alternate gate thresholds, budget caps, or model routes **without calling the model** (zero tokens). Reports per-phase `reused` / `would-block` / `verdict-flipped` / `would-exceed-budget` / `needs-live-rerun`. `runId` required; optional `thresholds`, `budgetMaxUSD`, `budgetMaxTokens`, `models`; `--json` for the full `ReplayReport`. | | `why-stale` | Given `runId` (+ optional `phaseId` as the assumed-changed seed): with no seed, prints the observed dependency graph; with a seed, computes the **transitive stale frontier** — exactly which phases would need re-running and why (observed ∪ declared edges). Zero tokens. | | `recompute` | Re-run **only the stale frontier** of a stored run from a seed `phaseId`. **Defaults to `dryRun: true`** (reports what would re-run, zero tokens). Pass `dryRun: false` to actually re-execute the seed + frontier and persist the updated run. | | `cache-clear` | Clear the cross-run memoization store. | @@ -653,7 +654,8 @@ A run moves through: **running →** `completed` (a `final` phase produced outpu - `/tf list` · `/tf run [args]` · `/tf show ` · `/tf runs` · `/tf resume ` - `/tf verify` · `/tf compile [lr|td]` · `/tf ir ` - `/tf peek [phaseId] [--json] [--item ] [--limit ]` -- `/tf provenance ` · `/tf why-stale [phaseId]` · `/tf recompute [--apply]` (dry-run by default) +- `/tf provenance ` · `/tf trace [--json]` · `/tf replay [--threshold phase=n] [--budget-usd n] [--json]` +- `/tf why-stale [phaseId]` · `/tf recompute [--apply]` (dry-run by default) - `/tf init` — interactive model-roles setup - `/tf: [args]` — shortcut for each saved flow @@ -664,6 +666,10 @@ 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. +Use `taskflow_trace` to inspect the append-only event log for a finished run, +then `taskflow_replay` to re-judge it under alternate thresholds/budget **offline +(zero tokens)** — e.g. "would a 0.9 gate threshold have blocked this run?" + 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 From 876d3588573fded48b8d96ceacfbfcd4eb9da893 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 14:07:32 +0800 Subject: [PATCH 19/51] feat(core): expand S2 kernel kinds, hard gates, and north-star slogan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add map/parallel to the event kernel (still default OFF) with parity tests vs the imperative path. Add fold kill-9 rebuild oracle and replay import-lint so offline replay stays off the runtime/driver graph. Rewrite the north-star slogan to compiled · resumable · incremental · replayable-for-what-if. --- CHANGELOG.md | 4 +- docs/0.2.0-north-star.md | 9 +- docs/rfc-0.2.0-architecture.md | 4 +- packages/taskflow-core/src/exec/driver.ts | 16 +- packages/taskflow-core/src/exec/step.ts | 247 ++++++++++++++---- .../taskflow-core/test/exec-driver.test.ts | 11 +- .../test/exec-kernel-parity.test.ts | 166 ++++++++++++ .../test/fold-kill9-rebuild.test.ts | 137 ++++++++++ .../test/replay-import-lint.test.ts | 84 ++++++ 9 files changed, 619 insertions(+), 59 deletions(-) create mode 100644 packages/taskflow-core/test/exec-kernel-parity.test.ts create mode 100644 packages/taskflow-core/test/fold-kill9-rebuild.test.ts create mode 100644 packages/taskflow-core/test/replay-import-lint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a73296..d2ca7ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,9 @@ All notable changes to taskflow are documented here. This project follows [Keep - `exec/fold.ts` — pure `foldEvents(log) →` per-phase snapshot (S1 differential building block). - `replay.ts` — implements `replayRun(log, overrides)` (threshold / budget / model / args knobs; zero tokens; no import of runtime/driver). - **S1 decision coverage:** runtime emits `gate-score` / `gate-verdict`, `when-guard`, `cache-hit`, `tournament-winner`, `budget-hit`, plus synthetic phase lifecycle for budget/dep skips. Fold differential tests pin fold ↔ RunState agreement. - - **S2 event kernel (strangler, default OFF):** `exec/step.ts` + `exec/driver.ts` run agent+script-only flows when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative path remains default. Kernel path preserves agent `usage`, evaluates `when` (with `when-guard` decision events), and emits skip lifecycle for unmet deps. + - **S2 event kernel (strangler, default OFF):** `exec/step.ts` + `exec/driver.ts` run **agent|script|map|parallel** flows when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative path remains default. Kernel path preserves agent `usage`, evaluates `when` (with `when-guard` decision events), and emits skip lifecycle for unmet deps. Parity tests pin kernel vs imperative for supported kinds. + - **S1 hard gate + import lint:** fold(log) rebuilds phase statuses matching RunState after a captured run (kill-9 oracle); `replay-import-lint` keeps `replay.ts` off the runtime/driver/step import graph. + - **North-star slogan:** `compiled · resumable · incremental · replayable-for-what-if` (drops Qwik "not replayable" collision with deterministic replay). - **S3 replay surface:** `taskflow_replay` MCP tool; pi `action=replay` and `/tf replay [--threshold phase=n] [--budget-usd n]`; golden trace fixture under `test/fixtures/`. ## [0.1.7] — 2026-07-07 diff --git a/docs/0.2.0-north-star.md b/docs/0.2.0-north-star.md index a4b1689..fc5066d 100644 --- a/docs/0.2.0-north-star.md +++ b/docs/0.2.0-north-star.md @@ -23,9 +23,11 @@ - 但 **"编译 + 可恢复 + 增量重算"这个组合,工业零竞品** —— 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)."* +> *"the only agent orchestrator that is **compiled** (not interpreted), **resumable** (cross-session / detached), **incremental** (only re-runs what changed), and **replayable-for-what-if** (re-judge gates/budget offline, zero tokens)."* -不和 Conductor 比"声明式 DAG",比它没有的三件事:**编译、可恢复、增量**。 +不和 Conductor 比"声明式 DAG",比它没有的四件事:**编译、可恢复、增量、确定性 what-if 重放**。 + +> 叙事修正(对齐 [RFC §7 / §13](./rfc-0.2.0-architecture.md)):早期借 Qwik 的 *"resumable (not replayable)"* 会与 0.1.7+ 的 **deterministic replay**(事件溯源重 fold)撞名。Qwik 的 replayable 指 hydration 重执行;我们的 replay 是**决策旋钮 what-if、零 token**。口号改为 **replayable-for-what-if**,与 resume 并列而非对立。 --- @@ -60,7 +62,8 @@ review 的 critic 用缺陷 #2 证明了:**"读 `.output` 自动建依赖"+ 真 | 编译优先(运行时→编译时) | 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 | ✅ 已落地(叙事待显性化) | +| **resumable**(跨会话 / detached) | **Qwik**(借其 resume 叙事,非 hydration) | cross-session resume + detached runs + cache | ✅ 已落地 | +| **确定性重放 / replayable-for-what-if** | 事件溯源 + fold | `trace` + `replayRun` / `taskflow_replay` / `/tf replay`(阈值/budget what-if,零 token) | ✅ 0.2.0 S3 已落地 | | 增量重算(只重算受影响部分) | Bazel/Nix | content-addressed cache + why-stale + recompute | ✅ 已落地 | | 写一次编译多端 | Mitosis | 一份 DSL → pi/codex/claude/opencode/grok 五 host | ✅ 已落地 | diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index 74ff90e..7e1d500 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -345,8 +345,8 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 | 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 | +| 口号 "compiled, **resumable (not replayable)**, incremental" | **已改** → **compiled · resumable · incremental · replayable-for-what-if**(见 `docs/0.2.0-north-star.md`) | 与确定性重放同名碰撞;0.1.7 已承诺 replay | +| 吸收思想表 "resumable, not replayable \| Qwik \| ✅已落地" | **已改** → resumable + 独立一行「确定性重放 / S3」 | replay 重新纳入 scope | | §六 执行顺序(只列 DSL + 旗舰 demo) | 加 replay(S3) 作为与 DSL 平行的主线 | 两条主线在 FlowIR 汇合 | ## 附 B:决策记录溯源 diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index 2063ce3..d58c258 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -12,12 +12,20 @@ import type { RunState } from "../store.ts"; import { topoLayers, type Taskflow } from "../schema.ts"; import { aggregateUsage, emptyUsage } from "../usage.ts"; import type { TraceEvent, TraceSink } from "../trace.ts"; -import { stepPhase, type RunTaskFn, type StepContext, type StepDeps } from "./step.ts"; +import { + EVENT_KERNEL_PHASE_TYPES, + stepPhase, + type RunTaskFn, + type StepContext, + type StepDeps, +} from "./step.ts"; import { foldEvents } from "./fold.ts"; import { EVENT_SCHEMA_VERSION, type Event } from "./events.ts"; import type { AgentConfig } from "../agents.ts"; import type { UsageStats } from "../usage.ts"; +export { EVENT_KERNEL_PHASE_TYPES }; + export interface EventKernelDeps { cwd: string; agents: AgentConfig[]; @@ -38,11 +46,11 @@ export interface EventKernelResult { totalUsage: UsageStats; } -/** True when every phase is agent or script (S2 first slice). */ +/** True when every phase is a kind the S2 kernel implements (agent|script|map|parallel). */ export function canUseEventKernel(def: Taskflow): boolean { return (def.phases ?? []).every((p) => { const t = p.type ?? "agent"; - return t === "agent" || t === "script"; + return (EVENT_KERNEL_PHASE_TYPES as readonly string[]).includes(t); }); } @@ -70,7 +78,7 @@ function safeTraceFlush(deps: EventKernelDeps, phaseId: string): void { } /** - * Run a Taskflow on the event kernel (agent+script only). + * Run a Taskflow on the event kernel (agent|script|map|parallel). * Caller must have checked {@link canUseEventKernel}. */ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Promise { diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index 624c7b8..4d8ecb3 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -1,8 +1,9 @@ /** * exec/step — per-node handlers for the event-sourced kernel (RFC §6.3, S2). * - * First slice: **agent** and **script** only. Each handler executes one node - * and returns events to append (it does not mutate RunState — fold does that). + * Supported kinds (progressive strangler): **agent**, **script**, **map**, + * **parallel**. Each handler executes one node and returns events to append + * (it does not mutate RunState — fold does that). * * Deliberately does **not** import `runtime.ts` (avoids circular deps with the * strangler switch that imports this package). @@ -15,8 +16,19 @@ import type { AgentConfig } from "../agents.ts"; import type { RunOptions, RunResult } from "../host/runner-types.ts"; import type { Event } from "./events.ts"; import { EVENT_SCHEMA_VERSION } from "./events.ts"; -import { emptyUsage, type UsageStats } from "../usage.ts"; -import { evaluateCondition, interpolate, type InterpolationContext } from "../interpolate.ts"; +import { aggregateUsage, emptyUsage, type UsageStats } from "../usage.ts"; +import { + coerceArray, + evaluateCondition, + interpolate, + safeParse, + type InterpolationContext, +} from "../interpolate.ts"; +import { mapWithConcurrencyLimit } from "../runner-core.ts"; + +/** Phase types the S2 event kernel can execute. Expand one kind at a time. */ +export const EVENT_KERNEL_PHASE_TYPES = ["agent", "script", "map", "parallel"] as const; +export type EventKernelPhaseType = (typeof EVENT_KERNEL_PHASE_TYPES)[number]; export type RunTaskFn = ( cwd: string, @@ -51,6 +63,14 @@ export interface StepResult { usage: UsageStats; } +type BodyResult = { + midEvents: Event[]; + output?: string; + status: StepResult["status"]; + error?: string; + usage: UsageStats; +}; + function baseEvent( ctx: StepContext, phaseId: string, @@ -84,6 +104,21 @@ export function usageFromEvents(events: readonly Event[]): UsageStats { return u; } +function isFailedResult(r: RunResult): boolean { + return (r.exitCode ?? 0) !== 0 || !!r.errorMessage || r.stopReason === "error" || r.stopReason === "aborted"; +} + +/** Format fan-out results like the imperative runtime's mergePhaseState text. */ +function combineFanoutText(results: RunResult[]): string { + return results + .map((r, i) => { + const label = `### [${i + 1}/${results.length}] ${r.agent}${isFailedResult(r) ? " (failed)" : ""}`; + const content = isFailedResult(r) ? r.errorMessage || r.stderr || r.output : r.output; + return `${label}\n\n${content}`; + }) + .join("\n\n---\n\n"); +} + /** Run a shell script phase (no LLM). Captures stdout. */ export async function stepScript(phase: Phase, ctx: StepContext): Promise { return stepPhase({ ...phase, type: "script" }, ctx) as Promise; @@ -94,10 +129,7 @@ export async function stepAgent(phase: Phase, ctx: StepContext): Promise; } -async function executeScriptBody( - phase: Phase, - ctx: StepContext, -): Promise<{ midEvents: Event[]; output?: string; status: StepResult["status"]; error?: string; usage: UsageStats }> { +async function executeScriptBody(phase: Phase, ctx: StepContext): Promise { const run = phase.run; if (!run || (Array.isArray(run) && run.length === 0)) { const err = "script phase missing `run`"; @@ -139,7 +171,7 @@ async function executeScriptBody( const status: StepResult["status"] = result.timedOut ? "timedOut" : result.code === 0 ? "done" : "failed"; const text = result.stdout.trimEnd(); - const usage = emptyUsage(); // scripts spend zero LLM tokens + const usage = emptyUsage(); const midEvents: Event[] = [ baseEvent(ctx, phase.id, "subagent-call", { input: { @@ -163,10 +195,48 @@ async function executeScriptBody( }; } -async function executeAgentBody( - phase: Phase, +async function runOneAgent( ctx: StepContext, -): Promise<{ midEvents: Event[]; output?: string; status: StepResult["status"]; error?: string; usage: UsageStats }> { + phase: Phase, + agentName: string, + task: string, + nodePath: string, + mapIndex?: number, +): Promise<{ result: RunResult; event: Event }> { + const r = await ctx.deps.runTask( + ctx.deps.cwd, + ctx.deps.agents, + agentName, + task, + { + model: phase.model, + thinking: phase.thinking, + tools: phase.tools, + cwd: ctx.deps.cwd, + signal: ctx.deps.signal, + }, + ctx.deps.globalThinking, + ); + const usage = r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage(); + const event = baseEvent(ctx, phase.id, "subagent-call", { + input: { + agent: agentName, + model: phase.model, + task, + nodePath, + mapIndex, + }, + output: { + text: r.output ?? "", + model: r.model, + usage, + stopReason: r.stopReason, + }, + }); + return { result: { ...r, usage }, event }; +} + +async function executeAgentBody(phase: Phase, ctx: StepContext): Promise { const interpCtx: InterpolationContext = { args: ctx.args, steps: ctx.steps, @@ -175,41 +245,12 @@ async function executeAgentBody( const agentName = phase.agent ?? "executor"; try { - const r = await ctx.deps.runTask( - ctx.deps.cwd, - ctx.deps.agents, - agentName, - task, - { - model: phase.model, - thinking: phase.thinking, - tools: phase.tools, - cwd: ctx.deps.cwd, - signal: ctx.deps.signal, - }, - ctx.deps.globalThinking, - ); - const failed = (r.exitCode ?? 0) !== 0 || !!r.errorMessage; + const { result: r, event } = await runOneAgent(ctx, phase, agentName, task, phase.id); + const failed = isFailedResult(r); const status: StepResult["status"] = r.phaseTimeout ? "timedOut" : failed ? "failed" : "done"; const usage = r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage(); - const midEvents: Event[] = [ - baseEvent(ctx, phase.id, "subagent-call", { - input: { - agent: agentName, - model: phase.model, - task, - nodePath: phase.id, - }, - output: { - text: r.output ?? "", - model: r.model, - usage, - stopReason: r.stopReason, - }, - }), - ]; return { - midEvents, + midEvents: [event], output: r.output, status, error: failed ? r.errorMessage ?? r.stderr : undefined, @@ -221,13 +262,120 @@ async function executeAgentBody( } } +/** Resolve map-item template; supports default `{item}` and custom `as` aliases via locals. */ +function resolveMapTask(template: string, phase: Phase, ctx: StepContext, item: unknown): string { + const loopVar = phase.as ?? "item"; + return interpolate(template, { + args: ctx.args, + steps: ctx.steps, + locals: { [loopVar]: item }, + }).text; +} + +async function executeMapBody(phase: Phase, ctx: StepContext): Promise { + const interpCtx: InterpolationContext = { args: ctx.args, steps: ctx.steps }; + const overResolved = interpolate(phase.over ?? "", interpCtx).text; + const arr = coerceArray(safeParse(overResolved)) ?? coerceArray(overResolved); + if (!arr) { + const err = `map phase '${phase.id}': 'over' (${phase.over}) did not resolve to an array`; + return { midEvents: [], status: "failed", error: err, usage: emptyUsage() }; + } + const concurrency = typeof phase.concurrency === "number" && phase.concurrency > 0 ? phase.concurrency : 8; + const agentName = phase.agent ?? "executor"; + const tasks = arr.map((item) => ({ + agent: agentName, + task: resolveMapTask(phase.task ?? "", phase, ctx, item), + })); + + try { + const midEvents: Event[] = []; + const results = await mapWithConcurrencyLimit(tasks, concurrency, async (it, idx) => { + const { result, event } = await runOneAgent( + ctx, + phase, + it.agent, + it.task, + `${phase.id}#item-${idx}`, + idx, + ); + midEvents.push(event); + return result; + }); + // Order events by mapIndex for stable fold/trace + midEvents.sort((a, b) => (a.input?.mapIndex ?? 0) - (b.input?.mapIndex ?? 0)); + const anyFailed = results.some(isFailedResult); + const anyTimedOut = results.some((r) => r.phaseTimeout); + const usage = aggregateUsage(results.map((r) => r.usage ?? emptyUsage())); + const errors = results.filter(isFailedResult).map((r) => `${r.agent}: ${r.errorMessage ?? r.stderr}`); + return { + midEvents, + output: combineFanoutText(results), + status: anyTimedOut ? "timedOut" : anyFailed ? "failed" : "done", + error: errors.length ? errors.join("; ") : undefined, + usage, + }; + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + return { midEvents: [], status: "failed", error: err, usage: emptyUsage() }; + } +} + +async function executeParallelBody(phase: Phase, ctx: StepContext): Promise { + const branches = phase.branches ?? []; + if (branches.length === 0) { + return { + midEvents: [], + status: "failed", + error: `parallel phase '${phase.id}': empty branches`, + usage: emptyUsage(), + }; + } + const concurrency = typeof phase.concurrency === "number" && phase.concurrency > 0 ? phase.concurrency : 8; + const interpCtx: InterpolationContext = { args: ctx.args, steps: ctx.steps }; + const tasks = branches.map((b) => ({ + agent: b.agent ?? phase.agent ?? "executor", + task: interpolate(b.task, interpCtx).text, + })); + + try { + const midEvents: Event[] = []; + const results = await mapWithConcurrencyLimit(tasks, concurrency, async (it, idx) => { + const { result, event } = await runOneAgent( + ctx, + phase, + it.agent, + it.task, + `${phase.id}#branch-${idx}`, + idx, + ); + midEvents.push(event); + return result; + }); + midEvents.sort((a, b) => (a.input?.mapIndex ?? 0) - (b.input?.mapIndex ?? 0)); + const anyFailed = results.some(isFailedResult); + const anyTimedOut = results.some((r) => r.phaseTimeout); + const usage = aggregateUsage(results.map((r) => r.usage ?? emptyUsage())); + const errors = results.filter(isFailedResult).map((r) => `${r.agent}: ${r.errorMessage ?? r.stderr}`); + return { + midEvents, + output: combineFanoutText(results), + status: anyTimedOut ? "timedOut" : anyFailed ? "failed" : "done", + error: errors.length ? errors.join("; ") : undefined, + usage, + }; + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + return { midEvents: [], status: "failed", error: err, usage: emptyUsage() }; + } +} + /** * Dispatch one phase kind. Returns null if kind is not handled by this slice. * Emits phase-start → optional when-guard decision → work → phase-end. */ export async function stepPhase(phase: Phase, ctx: StepContext): Promise { - const type = phase.type ?? "agent"; - if (type !== "script" && type !== "agent") return null; + const type = (phase.type ?? "agent") as string; + if (!(EVENT_KERNEL_PHASE_TYPES as readonly string[]).includes(type)) return null; const events: Event[] = [baseEvent(ctx, phase.id, "phase-start")]; @@ -251,7 +399,12 @@ export async function stepPhase(phase: Phase, ctx: StepContext): Promise { } }); -test("canUseEventKernel: agent+script only; rejects map/gate/etc", () => { +test("canUseEventKernel: agent|script|map|parallel; rejects gate", () => { assert.equal( canUseEventKernel({ name: "ok", @@ -70,9 +70,16 @@ test("canUseEventKernel: agent+script only; rejects map/gate/etc", () => { ); assert.equal( canUseEventKernel({ - name: "no", + name: "map-ok", phases: [{ id: "m", type: "map", agent: "a", task: "t", over: "[]", final: true }], }), + true, + ); + assert.equal( + canUseEventKernel({ + name: "no", + phases: [{ id: "g", type: "gate", agent: "a", task: "t", final: true }], + }), false, ); }); diff --git a/packages/taskflow-core/test/exec-kernel-parity.test.ts b/packages/taskflow-core/test/exec-kernel-parity.test.ts new file mode 100644 index 0000000..51f3f7b --- /dev/null +++ b/packages/taskflow-core/test/exec-kernel-parity.test.ts @@ -0,0 +1,166 @@ +/** + * S2 hard gate: event-kernel path vs imperative path for supported kinds. + * When both paths run the same flow with the same mock runner, phase status + * and final output shape must agree (strangler flip readiness). + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import type { RunOptions, RunResult } from "../src/runner-core.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { canUseEventKernel } from "../src/exec/driver.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "a", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mkState(def: Taskflow, runId: string): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: process.cwd(), + }; +} + +function deterministicRunner(): RuntimeDeps["runTask"] { + return async (_c, _a, agent, task, _o: RunOptions): Promise => ({ + agent, + task, + exitCode: 0, + output: `OUT:${task}`, + stderr: "", + usage: { ...emptyUsage(), input: 1, output: task.length, cost: 0.001, turns: 1 }, + stopReason: "end", + }); +} + +async function runBoth(def: Taskflow) { + const runTask = deterministicRunner(); + const kernel = await executeTaskflow(mkState(def, "k"), { + cwd: process.cwd(), + agents: AGENTS, + runTask, + persist: () => {}, + eventKernel: true, + }); + const imp = await executeTaskflow(mkState(def, "i"), { + cwd: process.cwd(), + agents: AGENTS, + runTask, + persist: () => {}, + eventKernel: false, + }); + return { kernel, imp }; +} + +test("canUseEventKernel: map+parallel accepted; gate rejected", () => { + assert.equal( + canUseEventKernel({ + name: "ok", + phases: [ + { id: "m", type: "map", agent: "a", over: '["x"]', task: "{item}" }, + { id: "p", type: "parallel", branches: [{ task: "t1" }, { task: "t2" }], final: true }, + ], + }), + true, + ); + assert.equal( + canUseEventKernel({ + name: "no", + phases: [{ id: "g", type: "gate", agent: "a", task: "judge", final: true }], + }), + false, + ); +}); + +test("parity: agent chain — status + finalOutput agree", async () => { + const def: Taskflow = { + name: "parity-agent", + phases: [ + { id: "a", type: "agent", agent: "a", task: "one" }, + { id: "b", type: "agent", agent: "a", task: "two", dependsOn: ["a"], final: true }, + ], + }; + const { kernel, imp } = await runBoth(def); + assert.equal(kernel.ok, imp.ok); + assert.equal(kernel.state.phases.a.status, imp.state.phases.a.status); + assert.equal(kernel.state.phases.b.status, imp.state.phases.b.status); + assert.equal(kernel.finalOutput, imp.finalOutput); +}); + +test("parity: map over static array — status + item count markers agree", async () => { + const def: Taskflow = { + name: "parity-map", + phases: [ + { + id: "m", + type: "map", + agent: "a", + over: '["alpha","beta"]', + task: "do {item}", + final: true, + }, + ], + }; + const { kernel, imp } = await runBoth(def); + assert.equal(kernel.ok, true); + assert.equal(imp.ok, true); + assert.equal(kernel.state.phases.m.status, "done"); + assert.equal(imp.state.phases.m.status, "done"); + // Both label items 1/2 and 2/2 + assert.match(kernel.finalOutput, /\[1\/2\]/); + assert.match(kernel.finalOutput, /\[2\/2\]/); + assert.match(imp.finalOutput, /\[1\/2\]/); + assert.match(imp.finalOutput, /\[2\/2\]/); + assert.match(kernel.finalOutput, /OUT:do alpha/); + assert.match(imp.finalOutput, /OUT:do alpha/); +}); + +test("parity: parallel branches — status + both branch outputs", async () => { + const def: Taskflow = { + name: "parity-par", + phases: [ + { + id: "p", + type: "parallel", + branches: [{ task: "left", agent: "a" }, { task: "right", agent: "a" }], + final: true, + }, + ], + }; + const { kernel, imp } = await runBoth(def); + assert.equal(kernel.ok, imp.ok); + assert.equal(kernel.state.phases.p.status, imp.state.phases.p.status); + assert.match(kernel.finalOutput, /OUT:left/); + assert.match(kernel.finalOutput, /OUT:right/); + assert.match(imp.finalOutput, /OUT:left/); + assert.match(imp.finalOutput, /OUT:right/); +}); + +test("parity: script phase stdout agrees", async () => { + const def: Taskflow = { + name: "parity-script", + phases: [ + { + id: "s", + type: "script", + run: ["node", "-e", "process.stdout.write('parity-ok')"], + final: true, + }, + ], + }; + const { kernel, imp } = await runBoth(def); + assert.equal(kernel.finalOutput, "parity-ok"); + assert.equal(imp.finalOutput, "parity-ok"); + assert.equal(kernel.state.phases.s.status, "done"); + assert.equal(imp.state.phases.s.status, "done"); +}); diff --git a/packages/taskflow-core/test/fold-kill9-rebuild.test.ts b/packages/taskflow-core/test/fold-kill9-rebuild.test.ts new file mode 100644 index 0000000..09ca8fa --- /dev/null +++ b/packages/taskflow-core/test/fold-kill9-rebuild.test.ts @@ -0,0 +1,137 @@ +/** + * S1 hard gate (RFC §11): after a completed run, fold(event log) must rebuild + * per-phase terminal status matching RunState — the "kill-9 then resume from + * log" consistency oracle (without actually SIGKILL'ing; we capture the same + * append-only log the FileTraceSink would have written). + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import type { RunOptions, RunResult } from "../src/runner-core.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { foldEvents } from "../src/exec/fold.ts"; +import { upgradeTraceEvent, type Event } from "../src/exec/events.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import type { TraceEvent, TraceSink } from "../src/trace.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "a", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mkState(def: Taskflow): RunState { + return { + runId: "kill9-run", + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: process.cwd(), + }; +} + +function captureSink(): { sink: TraceSink; events: TraceEvent[] } { + const events: TraceEvent[] = []; + return { + events, + sink: { emit: (e) => events.push(e), flush: () => {} }, + }; +} + +function toEvents(raw: TraceEvent[]): Event[] { + return raw.map((e) => upgradeTraceEvent(e as unknown as Record)); +} + +function mockRunner(fn: (task: string) => string): RuntimeDeps["runTask"] { + return async (_c, _a, agent, task, _o: RunOptions): Promise => ({ + agent, + task, + exitCode: 0, + output: fn(task), + stderr: "", + usage: { ...emptyUsage(), input: 5, output: 5, cost: 0.01, turns: 1 }, + stopReason: "end", + }); +} + +/** Map RunState status to fold-comparable terminal status. */ +function runStatus(ps: RunState["phases"][string]): string { + if (ps.timedOut) return "timedOut"; + return ps.status; +} + +test("kill-9 rebuild: multi-phase agent flow — fold status matches RunState", async () => { + const { sink, events } = captureSink(); + const def: Taskflow = { + name: "k9-chain", + phases: [ + { id: "p1", type: "agent", agent: "a", task: "first" }, + { id: "p2", type: "agent", agent: "a", task: "second {steps.p1.output}", dependsOn: ["p1"] }, + { id: "p3", type: "agent", agent: "a", task: "final", dependsOn: ["p2"], final: true }, + ], + }; + const res = await executeTaskflow(mkState(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: mockRunner((t) => `R:${t.slice(0, 12)}`), + persist: () => {}, + trace: sink, + }); + assert.equal(res.ok, true); + assert.ok(events.length >= 6, `expected rich log, got ${events.length}`); + + // Simulate crash: only the event log survives; rebuild via fold. + const folded = foldEvents(toEvents(events)); + assert.equal(folded.runId, "kill9-run"); + for (const id of ["p1", "p2", "p3"]) { + const rs = res.state.phases[id]; + const fs = folded.phases[id]; + assert.ok(fs, `fold missing phase ${id}`); + assert.equal(fs.status, runStatus(rs), `phase ${id}: fold=${fs.status} run=${runStatus(rs)}`); + // Last subagent text should be present for done phases + if (rs.status === "done") { + assert.ok(fs.output && fs.output.length > 0, `phase ${id} should have folded output`); + } + } +}); + +test("kill-9 rebuild: when-skip + done — both terminal statuses reconstruct", async () => { + const { sink, events } = captureSink(); + const def: Taskflow = { + name: "k9-when", + phases: [ + { id: "a", type: "agent", agent: "a", task: "always" }, + { + id: "skipme", + type: "agent", + agent: "a", + task: "nope", + when: "false", + dependsOn: ["a"], + final: true, + }, + ], + }; + const res = await executeTaskflow(mkState(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: mockRunner(() => "ok"), + persist: () => {}, + trace: sink, + }); + const folded = foldEvents(toEvents(events)); + assert.equal(folded.phases.a.status, "done"); + assert.equal(folded.phases.skipme.status, "skipped"); + assert.equal(res.state.phases.skipme.status, "skipped"); + assert.equal(folded.phases.skipme.decision?.type, "when-guard"); +}); + +test("kill-9 rebuild: empty log → empty phases (fail-open resume start)", () => { + const folded = foldEvents([]); + assert.equal(folded.eventCount, 0); + assert.deepEqual(folded.phases, {}); +}); diff --git a/packages/taskflow-core/test/replay-import-lint.test.ts b/packages/taskflow-core/test/replay-import-lint.test.ts new file mode 100644 index 0000000..5c7ed20 --- /dev/null +++ b/packages/taskflow-core/test/replay-import-lint.test.ts @@ -0,0 +1,84 @@ +/** + * Structural guard (RFC §11): replay.ts must never import process-spawning or + * runtime modules — otherwise offline replay could spend tokens. + */ +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; + +const SRC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../src"); + +const FORBIDDEN = new Set([ + "runtime.ts", + "exec/driver.ts", + "exec/step.ts", + "detached-runner.ts", + "runner.ts", // pi host runner if ever present under core +]); + +/** Resolve a relative import specifier from `fromFile` to an absolute .ts path. */ +function resolveImport(fromFile: string, spec: string): string | null { + if (!spec.startsWith(".")) return null; // bare package imports OK (none for replay) + let resolved = path.resolve(path.dirname(fromFile), spec); + if (!resolved.endsWith(".ts") && !resolved.endsWith(".js")) { + if (fs.existsSync(resolved + ".ts")) resolved += ".ts"; + else if (fs.existsSync(path.join(resolved, "index.ts"))) resolved = path.join(resolved, "index.ts"); + else return null; + } + if (resolved.endsWith(".js")) resolved = resolved.replace(/\.js$/, ".ts"); + return resolved; +} + +function collectRelativeImports(file: string): string[] { + const text = fs.readFileSync(file, "utf8"); + const specs: string[] = []; + // import … from "…" | export … from "…" | import("…") + const re = /(?:import|export)\s+(?:type\s+)?(?:[^'"\n]+?\s+from\s+)?["']([^"']+)["']|import\s*\(\s*["']([^"']+)["']\s*\)/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text))) { + const spec = m[1] ?? m[2]; + if (spec) specs.push(spec); + } + return specs; +} + +/** BFS closure of relative imports starting at entry. */ +function importClosure(entry: string): string[] { + const seen = new Set(); + const queue = [entry]; + while (queue.length) { + const cur = queue.pop()!; + if (seen.has(cur)) continue; + if (!fs.existsSync(cur)) continue; + seen.add(cur); + for (const spec of collectRelativeImports(cur)) { + const next = resolveImport(cur, spec); + if (next && !seen.has(next)) queue.push(next); + } + } + return [...seen]; +} + +test("replay import graph: does not reach runtime/driver/step", () => { + const entry = path.join(SRC_ROOT, "replay.ts"); + assert.ok(fs.existsSync(entry), "replay.ts must exist"); + const files = importClosure(entry); + const rel = files.map((f) => path.relative(SRC_ROOT, f).replace(/\\/g, "/")); + for (const f of FORBIDDEN) { + assert.ok(!rel.includes(f), `replay closure must not include ${f}; got: ${rel.join(", ")}`); + } + // Positive: must pull fold + events + deterministic + assert.ok(rel.some((r) => r === "exec/fold.ts" || r.endsWith("/exec/fold.ts"))); + assert.ok(rel.some((r) => r === "exec/events.ts" || r.endsWith("/exec/events.ts"))); + assert.ok(rel.some((r) => r === "deterministic.ts")); +}); + +test("replay.ts source text has no forbidden import strings", () => { + const text = fs.readFileSync(path.join(SRC_ROOT, "replay.ts"), "utf8"); + assert.doesNotMatch(text, /from\s+["']\.\/runtime/); + assert.doesNotMatch(text, /from\s+["']\.\/exec\/driver/); + assert.doesNotMatch(text, /from\s+["']\.\/exec\/step/); + assert.doesNotMatch(text, /from\s+["']\.\/detached-runner/); +}); From b6dbadc19d05ce22bc2c898d123e5b7c3b4e1234 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 14:13:13 +0800 Subject: [PATCH 20/51] docs: sync Phase 2 surfaces across website, host guides, and AGENTS Update incremental-recompute (en/zh) for genuine ir: hashes; complete the 12-tool MCP tables in host guides and Grok website; expand README Commands for replay/ir/peek/why-stale/recompute; document exec/trace/replay and the event-kernel flag in AGENTS.md; fix stale agent+script kernel comments. --- AGENTS.md | 12 ++++++++ CHANGELOG.md | 1 + README.md | 7 ++++- README.zh-CN.md | 9 +++++- docs/claude-mcp.md | 4 +++ docs/codex-mcp.md | 7 ++++- docs/grok-mcp.md | 6 ++-- docs/opencode-mcp.md | 4 +++ packages/pi-taskflow/src/index.ts | 2 +- packages/taskflow-core/src/runtime.ts | 6 ++-- .../taskflow-core/test/exec-driver.test.ts | 2 +- website/content/docs/en/guides/grok-build.mdx | 5 +++- .../en/reference/incremental-recompute.mdx | 30 ++++++++++--------- .../docs/zh-cn/guides/background-runs.mdx | 8 ++--- .../zh-cn/reference/incremental-recompute.mdx | 30 ++++++++++--------- 15 files changed, 90 insertions(+), 43 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d6c021..08a6c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,10 @@ packages/ │ │ ├─ detached-runner.ts← spawn-only entry for background runs (NOT in the barrel) │ │ ├─ usage.ts ← token/cost accounting (UsageStats type + aggregation) │ │ ├─ stale.ts / workspace.ts / flowir/ ← staleness, worktrees, FlowIR compile seam +│ │ │ (S0: compileTaskflowToFlowIR + hashFlowIR → ir:<64-hex>) +│ │ ├─ exec/ ← event log schema + fold + S2 kernel (step/driver; default OFF) +│ │ ├─ replay.ts ← offline what-if replayRun (zero tokens; no runtime/driver import) +│ │ ├─ trace.ts ← TraceEvent / FileTraceSink / readTrace │ │ ├─ host/runner-types.ts ← the host-neutral SubagentRunner contract + vendored CoreMessage │ │ ├─ runner-core.ts ← ALSO hosts the shared `runSubagentProcess` (spawn/idle/abort/classify) + │ │ │ `SubagentAccumulator` + `unknownAgentResult` reused by every host runner @@ -134,6 +138,14 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil | `tournament` | N competing variants + judge picks best or aggregates | | `script` | Run a shell command (no LLM, zero tokens) — captures stdout; fields `run`/`input`/`timeout` | +### Event kernel, trace, and offline replay (0.2.0 Phase 2) + +- **FlowIR (S0):** `compileTaskflowToIR` → genuine `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`; `usedFallbackHash: false` when IR is content-addressable. +- **Trace:** every run may record `runs//.trace.jsonl` via `RuntimeDeps.trace` (`FileTraceSink`). Decisions include gate/when/cache/budget/tournament/unreplayable. +- **Event kernel (S2, default OFF):** set `RuntimeDeps.eventKernel: true` or `PI_TASKFLOW_EVENT_KERNEL=1`. Only pure `agent|script|map|parallel` DAGs take this path; other kinds stay on the imperative `executeTaskflow` path. +- **Offline replay (S3, zero tokens):** `replayRun(events, overrides)` in `replay.ts` — **must not** import `runtime` / `exec/driver` / `exec/step` (guarded by `replay-import-lint.test.ts`). Surfaces: pi `action=replay` + `/tf replay`; MCP `taskflow_replay`. Distinct from **resume** / **recompute** (those re-execute live phases). +- **MCP roster (12):** `taskflow_run|list|show|verify|compile|peek|trace|replay|why_stale|recompute|save|search`. + ### Control Flow Fields - `when` — conditional guard (expression must be truthy) - `join` — `"all"` (default) or `"any"` (OR-join) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ca7ba..5b95c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [Unreleased] ### Added +- **Docs hygiene (pre-S4):** website FlowIR docs use genuine `ir:<64-hex>` / `usedFallbackHash: false`; host MCP guides + Grok website list all 12 tools including `taskflow_replay`; README en/zh Commands tables include `/tf replay` and related inspection commands; `AGENTS.md` documents exec/trace/replay + event-kernel flag. - **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.2.0 Phase 2 / S0–S3 foundations (event-sourced kernel path):** - `flowir/compile.ts` (`compileTaskflowToFlowIR`) — genuine Taskflow→canonical FlowIR compiler; `compileTaskflowToIR` now content-addresses with `hashFlowIR` (`ir:<64-hex>`) and sets `usedFallbackHash: false`. diff --git a/README.md b/README.md index 10e1ded..011468b 100644 --- a/README.md +++ b/README.md @@ -660,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, OpenCode, and Grok Build, 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 — full set: `taskflow_run` / `list` / `show` / `verify` / `compile` / `peek` / `trace` / `replay` / `why_stale` / `recompute` (dry-run) / `save` / `search`. | Command | What it does | |---|---| @@ -668,10 +668,15 @@ Saved flows become CLI shortcuts. **These `/tf` commands are Pi-only** (they run | `/tf run [args]` | Run a saved flow (e.g. `/tf run summarize-files dir=src`) | | `/tf show ` | Print a flow's definition | | `/tf compile [lr\|td]` | **Render the flow as a Mermaid diagram + verification overlay** — 0 tokens, no LLM; paste into a README/issue/PR | +| `/tf ir ` | Compile to **FlowIR** + content hash (`ir:<64-hex>`) — 0 tokens | | `/tf runs` | Browse recent run history (interactive TUI — **live auto-refreshes** while any run is active) | | `/tf resume ` | Continue a paused/failed run — cached phases skip automatically | | `/tf peek [phaseId]` | Inspect a phase's intermediate output (the debugging escape hatch) | +| `/tf provenance ` | Show observed read-sets for a completed run | | `/tf trace [--json]` | Show a run's **deterministic-replay event trace** (each subagent call + runtime decisions) | +| `/tf replay [--threshold phase=n] [--budget-usd n] [--json]` | **Offline what-if** re-judge of thresholds/budget from a recorded trace (zero tokens) | +| `/tf why-stale [phaseId]` | Explain the stale frontier (observed ∪ declared deps) | +| `/tf recompute [--apply]` | Dry-run (default) or apply minimal recompute of the stale frontier | | `/tf init` | **Interactively map model roles** to your enabled models (writes `~/.pi/agent/settings.json`) | | `/tf: [args]` | Shortcut — runs the flow in one tap | diff --git a/README.zh-CN.md b/README.zh-CN.md index 6177408..d91c05b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -535,7 +535,7 @@ Review the audit below. If any endpoint is missing auth, end with ## 命令 -保存的流程变成 CLI 快捷方式。**这些 `/tf` 命令仅限 Pi**(在 Pi 会话中运行)。在 Codex、Claude Code、OpenCode、Grok Build 上改用 `taskflow_*` MCP 工具——`taskflow_list` / `taskflow_show` / `taskflow_run`(按 `name`)/ `taskflow_verify` / `taskflow_compile` / `taskflow_peek` / `taskflow_trace` / `taskflow_replay` / `taskflow_why_stale` / `taskflow_recompute`(仅 dry-run)/ `taskflow_save` / `taskflow_search`。 +保存的流程变成 CLI 快捷方式。**这些 `/tf` 命令仅限 Pi**(在 Pi 会话中运行)。在 Codex、Claude Code、OpenCode、Grok Build 上改用 `taskflow_*` MCP 工具——`taskflow_run` / `list` / `show` / `verify` / `compile` / `peek` / `trace` / `replay` / `why_stale` / `recompute`(仅 dry-run)/ `save` / `search`。 | 命令 | 功能 | |---|---| @@ -543,8 +543,15 @@ Review the audit below. If any endpoint is missing auth, end with | `/tf run [args]` | 运行已保存的流程(例如 `/tf run summarize-files dir=src`) | | `/tf show ` | 打印流程的定义 | | `/tf compile [lr\|td]` | **将流程渲染为 Mermaid 图 + 验证报告** —— 0 token、无 LLM;可粘贴到 README/issue/PR | +| `/tf ir ` | 编译为 **FlowIR** + 内容哈希(`ir:<64-hex>`)—— 0 token | | `/tf runs` | 浏览近期运行历史(交互式 TUI——有运行活跃时**实时自动刷新**) | | `/tf resume ` | 继续一个暂停/失败的运行——已缓存的阶段自动跳过 | +| `/tf peek [phaseId]` | 查看某阶段的中间输出(调试逃生舱) | +| `/tf provenance ` | 显示已完成运行的观测读集 | +| `/tf trace [--json]` | 显示运行的**确定性重放事件轨迹**(各 subagent 调用 + 运行时决策) | +| `/tf replay [--threshold phase=n] [--budget-usd n] [--json]` | **离线 what-if**:按新阈值/预算重判已录制轨迹(零 token) | +| `/tf why-stale [phaseId]` | 解释陈旧前沿(观测 ∪ 声明依赖) | +| `/tf recompute [--apply]` | 默认 dry-run;`--apply` 时对陈旧前沿做最小重算 | | `/tf init` | **交互式映射模型角色**到你的已启用模型(写入 `~/.pi/agent/settings.json`) | | `/tf: [args]` | 快捷方式——一键运行流程 | diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index 365b528..2cbc37c 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -106,6 +106,10 @@ subagent a flow spawns is itself a `claude -p` process — no pi process needed. | `taskflow_verify` | Statically verify a flow (cycles, missing deps, undefined refs) — no execution. | | `taskflow_compile` | Render a flow's DAG as a text outline + a compact status line (with an inline SVG image for clients that render images). | | `taskflow_peek` | Inspect one phase's intermediate output from a stored run (post-hoc debugging). Hard-truncated, read-only. | +| `taskflow_trace` | Read-only timeline of a run's append-only event log (subagent I/O + runtime decisions). | +| `taskflow_replay` | Offline what-if on a recorded trace: re-judge thresholds/budget/models **without calling the model** (zero tokens). | +| `taskflow_why_stale` | Explain observed/declared dependency staleness for a run (optional seed `phaseId`). Zero tokens. | +| `taskflow_recompute` | Report the stale frontier for a seed phase (**dry-run only** over MCP — never spends tokens). | ## How output is rendered diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index 6bea4dd..4a3f7ca 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -102,13 +102,18 @@ subagent a flow spawns is itself a `codex exec` process — no pi process needed | 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. | +| `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, now with library metadata (`purpose`, `generality`, `reuseCount`) when available. | | `taskflow_show` | Show a saved flow as `{definition, library}` — the `library` object holds the sidecar metadata (`purpose`, `tags`, `generality`, `reuseCount`, `phaseSignature`, …). | | `taskflow_save` | Save a flow to the library with optional `purpose`, `tags`, and `notes`. Writes the flow JSON plus a sidecar `.meta.json`. | | `taskflow_search` | Search the library before authoring. Returns ranked reusable flows with score, why, and a reuse hint. Structural + CJK-aware keyword scoring; embedding is Phase 2. | | `taskflow_verify` | Statically verify a flow (cycles, missing deps, undefined refs) — no execution. | | `taskflow_compile` | Render a flow's DAG as a **diagram** (an inline SVG image, shown by the desktop app) **plus a text outline** (shown by the CLI/TUI, which can't render images) + a compact status line. Very large graphs return the text outline alone. | +| `taskflow_peek` | Inspect one phase's intermediate output from a stored run (post-hoc debugging). Hard-truncated, read-only. | +| `taskflow_trace` | Read-only timeline of a run's append-only event log (subagent I/O + runtime decisions). | +| `taskflow_replay` | Offline what-if on a recorded trace: re-judge thresholds/budget/models **without calling the model** (zero tokens). | +| `taskflow_why_stale` | Explain observed/declared dependency staleness for a run (optional seed `phaseId`). Zero tokens. | +| `taskflow_recompute` | Report the stale frontier for a seed phase (**dry-run only** over MCP — never spends tokens). | ## How output is rendered (Codex desktop app) diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md index 80f0363..01b402d 100644 --- a/docs/grok-mcp.md +++ b/docs/grok-mcp.md @@ -144,8 +144,10 @@ subagent a flow spawns is itself a `grok -p` process — no pi process needed. | `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). | +| `taskflow_trace` | Read-only timeline of a run's append-only event log (subagent I/O + runtime decisions). | +| `taskflow_replay` | Offline what-if on a recorded trace: re-judge thresholds/budget/models **without calling the model** (zero tokens). | +| `taskflow_why_stale` | Explain observed/declared dependency staleness for a run (optional seed `phaseId`). Zero tokens. | +| `taskflow_recompute` | Report the stale frontier for a seed phase (**dry-run only** over MCP — never spends tokens). | Grok namespaces MCP tools as `taskflow__taskflow_*` in some UIs; discover with `search_tool` then call via `use_tool`. diff --git a/docs/opencode-mcp.md b/docs/opencode-mcp.md index 8885307..6cd9b53 100644 --- a/docs/opencode-mcp.md +++ b/docs/opencode-mcp.md @@ -107,6 +107,10 @@ OpenCode's configured default model. | `taskflow_verify` | Statically verify a flow (cycles, missing deps, undefined refs) — no execution. | | `taskflow_compile` | Render a flow's DAG as a text outline + a compact status line (with an inline SVG image for clients that render images). | | `taskflow_peek` | Inspect one phase's intermediate output from a stored run (post-hoc debugging). Hard-truncated, read-only. | +| `taskflow_trace` | Read-only timeline of a run's append-only event log (subagent I/O + runtime decisions). | +| `taskflow_replay` | Offline what-if on a recorded trace: re-judge thresholds/budget/models **without calling the model** (zero tokens). | +| `taskflow_why_stale` | Explain observed/declared dependency staleness for a run (optional seed `phaseId`). Zero tokens. | +| `taskflow_recompute` | Report the stale frontier for a seed phase (**dry-run only** over MCP — never spends tokens). | ## Use it diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 74ea2bf..c7f8be9 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -214,7 +214,7 @@ function formatFlowIR(ir: TaskflowIR): string { lines.push(`# FlowIR — "${ir.meta.sourceFlowName}"`); lines.push(""); if (ir.hash) { - lines.push(`**content hash:** \`${ir.hash}\`${ir.usedFallbackHash ? " (fallback — stub projection)" : " (overstory-canonical)"}`); + lines.push(`**content hash:** \`${ir.hash}\`${ir.usedFallbackHash ? " (fallback — not IR-canonical)" : " (ir-canonical)"}`); lines.push(""); } else { lines.push("**content hash:** _(unavailable — computation failed)_"); diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 11b3077..5877ab9 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -92,8 +92,8 @@ export interface RuntimeDeps { * identically to today). See `trace.ts`. */ trace?: TraceSink; /** - * S2 strangler: when true, agent+script-only flows run on `exec/driver` - * (event kernel). Default false; also set `PI_TASKFLOW_EVENT_KERNEL=1`. + * S2 strangler: when true, agent|script|map|parallel-only flows run on + * `exec/driver` (event kernel). Default false; also set `PI_TASKFLOW_EVENT_KERNEL=1`. */ eventKernel?: boolean; /** Internal: sub-flow call stack, for recursion detection. */ @@ -3127,7 +3127,7 @@ export async function recomputeTaskflow( export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promise { const def: Taskflow = state.def; try { - // S2 strangler (default OFF): agent+script-only flows may use the event kernel. + // S2 strangler (default OFF): agent|script|map|parallel flows may use the event kernel. const { eventKernelEnabled, canUseEventKernel, runEventKernel } = await import("./exec/driver.ts"); if (eventKernelEnabled(deps) && canUseEventKernel(def)) { if (!deps.runTask) { diff --git a/packages/taskflow-core/test/exec-driver.test.ts b/packages/taskflow-core/test/exec-driver.test.ts index fa35eae..ab41552 100644 --- a/packages/taskflow-core/test/exec-driver.test.ts +++ b/packages/taskflow-core/test/exec-driver.test.ts @@ -1,5 +1,5 @@ /** - * S2 event-kernel driver tests (agent+script slice, default OFF). + * S2 event-kernel driver tests (agent|script|map|parallel slice, default OFF). */ import assert from "node:assert/strict"; import { test } from "node:test"; diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index c27c57f..7c5b5ba 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -165,7 +165,10 @@ MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fa | `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. | +| `taskflow_peek` | Inspect intermediate phase output from a stored run. | +| `taskflow_trace` | Append-only event log timeline (subagent I/O + decisions). | +| `taskflow_replay` | Offline what-if re-judge of thresholds/budget (zero tokens). | +| `taskflow_why_stale` / `taskflow_recompute` | Staleness analysis (recompute is dry-run only over MCP). | ## Alternative: register the MCP server manually diff --git a/website/content/docs/en/reference/incremental-recompute.mdx b/website/content/docs/en/reference/incremental-recompute.mdx index 7c21d89..32d548f 100644 --- a/website/content/docs/en/reference/incremental-recompute.mdx +++ b/website/content/docs/en/reference/incremental-recompute.mdx @@ -23,32 +23,34 @@ The compilation is routed through `compileTaskflowToIR(def)`, which returns a `T interface TaskflowIR { ir?: FlowIR; // The compiled IR (nodes with inject/emits) meta: TaskflowIRMeta; // Compile-time metadata (declared deps, sidecar) - hash?: string; // Content fingerprint (32-hex SHA-256) + hash?: string; // Content-addressed IR: `ir:<64-hex>` (SHA-256) warnings: CompileWarning[]; // Non-fatal advisories - errors: CompileError[]; // Hard compile errors (none in the stub) - usedFallbackHash: boolean; // True when the hash is flowDefHash (not IR-canonical) + errors: CompileError[]; // Hard compile errors + usedFallbackHash: boolean; // false when hash is IR-canonical; true only on hash failure / empty IR } ``` -The compilation is **pure + async** (uses Web Crypto for hashing) and **never throws** — a hash failure leaves `hash` unset and the cache degrades to the legacy flowName-only key (cross-run disabled for that run). +The compilation is **pure + async-compatible** (sync body; async retained for callers) and **never throws** — a hash failure leaves `hash` unset and `usedFallbackHash: true`, so the cache degrades safely (cross-run may fall back to older key tiers). ### Content addressing -The `hash` is currently produced by `flowDefHash(def)`, which serializes the desugared definition to **canonical JSON** (recursively key-sorted, no whitespace, `undefined` dropped) and hashes it with SHA-256 (first 16 bytes, lowercase hex). This is the vendored overstory hashing contract — byte-identical to overstory's `hashIR` algorithm. +As of 0.2.0 **S0**, `compileTaskflowToIR` runs the genuine compiler (`compileTaskflowToFlowIR`) and content-addresses the **canonical FlowIR** with `hashFlowIR` → `ir:` + 64 lowercase hex SHA-256 chars. `usedFallbackHash` is **false** for well-formed flows with nodes. + +A separate DSL-level `flowDefHash` (definition fingerprint) remains available for the v2 cache tier (`v2:flowdef:`) during migration — that is **not** what `TaskflowIR.hash` reports after S0. ```typescript -// Canonical JSON: keys sorted by UTF-16 code units, arrays preserve order -function canonicalJson(value: unknown): string; +// Taskflow → canonical FlowIR (1:1 phase→node, edges, cond normalization) +function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRResult; -// SHA-256 of canonical serialization, first 16 bytes hex -async function hashCanonical(canonical: string): Promise; +// SHA-256 over canonicalizeFlowIR → `ir:<64-hex>` +function hashFlowIR(ir: FlowIR): string; -// Content fingerprint of the desugared Taskflow definition -async function flowDefHash(def: Taskflow): Promise; +// Public seam: IR + meta + hash + usedFallbackHash +async function compileTaskflowToIR(def: Taskflow): Promise; ``` - - `usedFallbackHash` is **always true** in the current stub: the hash is the definition fingerprint, not the IR-canonical hash. It flips to `false` only once the genuine overstory compiler is vendored. Callers must never mistake a stub hash for a canonical one. + + `usedFallbackHash` is **false** when the genuine compiler produced content-addressable IR. It is **true** only if hashing failed or the IR has no nodes — never treat a missing/`usedFallbackHash: true` hash as a stable IR key. ### Per-phase sub-fingerprints @@ -314,7 +316,7 @@ Output: ```text title="/tf ir output" FlowIR — flow "review-changes" - hash: a1b2c3d4e5f6789012345678 (usedFallbackHash: true) + hash: ir:a1b2c3d4e5f6789012345678abcdef0123456789abcdef0123456789abcd (ir-canonical) nodes: 4 ■ scout kind: agent inject: [] emits: [scout] ■ analyst kind: agent inject: [scout] emits: [analyst] diff --git a/website/content/docs/zh-cn/guides/background-runs.mdx b/website/content/docs/zh-cn/guides/background-runs.mdx index 279b396..fcf3b28 100644 --- a/website/content/docs/zh-cn/guides/background-runs.mdx +++ b/website/content/docs/zh-cn/guides/background-runs.mdx @@ -306,7 +306,7 @@ I'm acting as a risk-reviewer for documentation changes. Since this is a documen **Critical facts extracted from source:** -1. **FlowIR Compilation (M1)** - `compileTaskflowToIR(def)` in `flowir/index.ts:48` returns `TaskflowIR` with `hash`, `meta.declaredDeps`, `usedFallbackHash` (always true in stub). +1. **FlowIR Compilation (M1 / 0.2.0 S0)** - `compileTaskflowToIR(def)` returns `TaskflowIR` with content-addressed `hash` (`ir:<64-hex>`), `meta.declaredDeps`, and `usedFallbackHash: false` for well-formed flows (genuine compiler). 2. **Hash Algorithm** - `flowDefHash` in `flowir/hash.ts:64` = `hashCanonical(canonicalJson(def))` = SHA-256 truncated to 16 bytes hex. Vendored from overstory for byte-identical contract. @@ -366,8 +366,8 @@ interface TaskflowIR { meta: TaskflowIRMeta; // Compile-time metadata hash?: string; // Content fingerprint (32 hex chars) warnings: CompileWarning[]; // Non-fatal advisories - errors: CompileError[]; // Hard errors (none in stub) - usedFallbackHash: boolean; // true in stub (hash is flowDefHash, not IR-canonical) + errors: CompileError[]; // Hard errors + usedFallbackHash: boolean; // false when hash is IR-canonical (`ir:<64-hex>`) } ``` @@ -435,7 +435,7 @@ Editing phase B invalidates only B + its transitive dependents; independent sibl ### Stub status -`usedFallbackHash` is **always true** in the stub: the hash is `flowDefHash` (the definition fingerprint), not the overstory-IR-canonical hash. It flips to `false` once the genuine overstory compiler is vendored. +As of 0.2.0 S0, `usedFallbackHash` is **false** when the genuine compiler content-addresses FlowIR (`hash` is `ir:<64-hex>` via `hashFlowIR`). It is true only on hash failure or empty IR. A separate `flowDefHash` may still appear in older cache-key tiers. ## Observed read-sets (M3) diff --git a/website/content/docs/zh-cn/reference/incremental-recompute.mdx b/website/content/docs/zh-cn/reference/incremental-recompute.mdx index 04e7da4..41a26a5 100644 --- a/website/content/docs/zh-cn/reference/incremental-recompute.mdx +++ b/website/content/docs/zh-cn/reference/incremental-recompute.mdx @@ -23,32 +23,34 @@ taskflow 的增量重算是一个**成本非对称的响应式系统**:廉价 interface TaskflowIR { ir?: FlowIR; // The compiled IR (nodes with inject/emits) meta: TaskflowIRMeta; // Compile-time metadata (declared deps, sidecar) - hash?: string; // Content fingerprint (32-hex SHA-256) + hash?: string; // 内容寻址 IR:`ir:<64-hex>`(SHA-256) warnings: CompileWarning[]; // Non-fatal advisories - errors: CompileError[]; // Hard compile errors (none in the stub) - usedFallbackHash: boolean; // True when the hash is flowDefHash (not IR-canonical) + errors: CompileError[]; // Hard compile errors + usedFallbackHash: boolean; // 有 IR 规范哈希时为 false;仅哈希失败/空 IR 为 true } ``` -编译是**纯函数 + 异步**的(使用 Web Crypto 做哈希),且**永不抛出异常** —— 哈希失败时 `hash` 保持未设置,缓存降级为仅基于 flowName 的旧式键(该次运行的跨运行复用被禁用)。 +编译是**纯函数 + 异步兼容**的(同步体;保留 async 给调用方),且**永不抛出异常** —— 哈希失败时 `hash` 未设置且 `usedFallbackHash: true`,缓存安全降级(跨运行可能回落到旧键层级)。 ### 内容寻址 -`hash` 当前由 `flowDefHash(def)` 生成,它将脱糖后的定义序列化为**规范 JSON**(递归按键排序、无空白、丢弃 `undefined`),并用 SHA-256(前 16 字节,小写十六进制)对其哈希。这是 vendored 的 overstory 哈希契约 —— 与 overstory 的 `hashIR` 算法逐字节一致。 +自 0.2.0 **S0** 起,`compileTaskflowToIR` 走真编译器(`compileTaskflowToFlowIR`),并用 `hashFlowIR` 对**规范 FlowIR** 内容寻址 → `ir:` + 64 位小写十六进制 SHA-256。结构良好、有节点的 flow 上 **`usedFallbackHash` 为 false**。 + +DSL 级 `flowDefHash`(定义指纹)仍保留给 v2 缓存层(`v2:flowdef:`)迁移窗口 —— **不是** S0 之后 `TaskflowIR.hash` 的报告值。 ```typescript -// Canonical JSON: keys sorted by UTF-16 code units, arrays preserve order -function canonicalJson(value: unknown): string; +// Taskflow → 规范 FlowIR(1:1 phase→node、边、条件规范化) +function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRResult; -// SHA-256 of canonical serialization, first 16 bytes hex -async function hashCanonical(canonical: string): Promise; +// canonicalizeFlowIR 上的 SHA-256 → `ir:<64-hex>` +function hashFlowIR(ir: FlowIR): string; -// Content fingerprint of the desugared Taskflow definition -async function flowDefHash(def: Taskflow): Promise; +// 公开接缝:IR + meta + hash + usedFallbackHash +async function compileTaskflowToIR(def: Taskflow): Promise; ``` - - 在当前的桩实现中,`usedFallbackHash` **始终为 true**:该哈希是定义指纹,而非 IR 规范哈希。只有当真正的 overstory 编译器被 vendored 之后,它才会翻转为 `false`。调用方绝不能把桩哈希误当成规范哈希。 + + 真编译器产出可内容寻址的 IR 时,`usedFallbackHash` 为 **false**。仅当哈希失败或 IR 无节点时为 **true** —— 切勿把缺失/`usedFallbackHash: true` 的哈希当作稳定 IR 键。 ### 阶段级子指纹 @@ -314,7 +316,7 @@ interface CacheEntry { ```text title="/tf ir 输出" FlowIR — flow "review-changes" - hash: a1b2c3d4e5f6789012345678 (usedFallbackHash: true) + hash: ir:a1b2c3d4e5f6789012345678abcdef0123456789abcdef0123456789abcd (ir-canonical) nodes: 4 ■ scout kind: agent inject: [] emits: [scout] ■ analyst kind: agent inject: [scout] emits: [analyst] From d3bad66be48e33399dd2970e877aa1b8a69a8c18 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 14:18:07 +0800 Subject: [PATCH 21/51] docs: complete Phase 2 documentation across RFC, skills, and website MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mark architecture RFC S0–S5 implementation status; supersede the historical FlowIR shadow RFC with the S0 genuine compiler note. Add skills advanced trace/replay vs recompute; ship en/zh Deterministic Replay concept pages, resume disambiguation, and full 12-tool command reference tables. --- CHANGELOG.md | 2 +- docs/internal/rfc-flowir-compilation.md | 12 ++- docs/rfc-0.2.0-architecture.md | 84 +++++++++++-------- .../plugin/skills/taskflow/advanced.md | 58 +++++++++++++ .../plugin/skills/taskflow/advanced.md | 58 +++++++++++++ .../plugin/skills/taskflow/advanced.md | 58 +++++++++++++ .../plugin/skills/taskflow/advanced.md | 58 +++++++++++++ .../pi-taskflow/skills/taskflow/advanced.md | 60 +++++++++++++ skills-src/taskflow/advanced.md | 78 +++++++++++++++++ .../docs/en/concepts/deterministic-replay.mdx | 57 +++++++++++++ website/content/docs/en/concepts/index.mdx | 11 ++- website/content/docs/en/concepts/resume.mdx | 6 +- website/content/docs/en/meta.json | 1 + .../content/docs/en/reference/commands.mdx | 13 ++- .../zh-cn/concepts/deterministic-replay.mdx | 55 ++++++++++++ website/content/docs/zh-cn/concepts/index.mdx | 11 ++- .../content/docs/zh-cn/concepts/resume.mdx | 4 + website/content/docs/zh-cn/meta.json | 1 + .../content/docs/zh-cn/reference/commands.mdx | 15 +++- 19 files changed, 592 insertions(+), 50 deletions(-) create mode 100644 website/content/docs/en/concepts/deterministic-replay.mdx create mode 100644 website/content/docs/zh-cn/concepts/deterministic-replay.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b95c0c..37b6103 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [Unreleased] ### Added -- **Docs hygiene (pre-S4):** website FlowIR docs use genuine `ir:<64-hex>` / `usedFallbackHash: false`; host MCP guides + Grok website list all 12 tools including `taskflow_replay`; README en/zh Commands tables include `/tf replay` and related inspection commands; `AGENTS.md` documents exec/trace/replay + event-kernel flag. +- **Docs complete for Phase 2 surfaces:** website FlowIR docs (`ir:<64-hex>`, `usedFallbackHash: false`); new concepts **Deterministic Replay** (en/zh) + resume disambiguation; host MCP guides + Grok website + `reference/commands` list all 12 tools including `taskflow_replay`; README en/zh Commands tables; skills `advanced.md` trace/replay vs recompute; `AGENTS.md` exec/trace/replay + kernel flag; architecture RFC status table S0–S5 + internal FlowIR RFC superseded note. - **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.2.0 Phase 2 / S0–S3 foundations (event-sourced kernel path):** - `flowir/compile.ts` (`compileTaskflowToFlowIR`) — genuine Taskflow→canonical FlowIR compiler; `compileTaskflowToIR` now content-addresses with `hashFlowIR` (`ir:<64-hex>`) and sets `usedFallbackHash: false`. diff --git a/docs/internal/rfc-flowir-compilation.md b/docs/internal/rfc-flowir-compilation.md index 48fe423..d1df48c 100644 --- a/docs/internal/rfc-flowir-compilation.md +++ b/docs/internal/rfc-flowir-compilation.md @@ -1,8 +1,14 @@ # RFC: FlowIR Compilation Shadow (DSL → overstory FlowIR) -> Status: **Proposed** · Date: 2026-06-24 -> Roadmap: [`overstory-convergence-roadmap.md`](./overstory-convergence-roadmap.md) §3 M1 (first of 5 milestones toward an overstory-kernel swap) -> Verifies: `overstory/packages/core/src/ir/` (vendored); bridges to `extensions/{schema,compile,verify}.ts` +> Status: **Superseded in part by 0.2.0 S0** · Original: **Proposed** · Date: 2026-06-24 +> Roadmap: [`overstory-convergence-roadmap.md`](./overstory-convergence-roadmap.md) §3 M1 +> **Implementation note (2026-07-09):** The *shadow* / vendor-overstory plan below is **historical**. +> 0.2.0 S0 shipped a **self-owned** genuine compiler in `packages/taskflow-core/src/flowir/`: +> `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`, `usedFallbackHash: false` on well-formed IR. +> Public seam remains `compileTaskflowToIR(def)`. Stub `translate.ts` still exists for comparison / +> legacy paths but is **not** what `/tf ir` content-addresses after S0. +> Architecture parent: [`../rfc-0.2.0-architecture.md`](../rfc-0.2.0-architecture.md). +> Verifies (original intent): overstory IR contract; bridges to schema/compile/verify. ## TL;DR diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index 7e1d500..ae374f5 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -1,8 +1,8 @@ # RFC: taskflow 0.2.0 系统架构总纲(Master Architecture) -> Status: **Draft v1** · 2026-07-08 +> Status: **Active** · Draft v1 2026-07-08 · **Implementation progress updated 2026-07-09** > 层级:这是凌驾于 [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md)(DSL 前端)和 -> 未来 replay RFC 之上的**系统架构总纲**。定调 0.2.0 内核形态、模块边界、数据契约、迁移顺序。 +> replay 实现之上的**系统架构总纲**。定调 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 内核路线)、 @@ -10,18 +10,31 @@ > > **本文回应两个已发现的架构级矛盾**(§0),并把 5 个已拍板的决策(§1)固化成一套 > 可发布、向后兼容的内核替换方案。 +> +> ### Implementation status (branch `release/0.2.0`) +> +> | 阶段 | 状态 | 落地要点 | +> |------|------|----------| +> | **S0** | ✅ | `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`;`usedFallbackHash: false`(well-formed IR) | +> | **S1** | ✅ | `exec/{events,fold}`;runtime 全量 decision emit;fold 差分 + kill-9 rebuild 测试 | +> | **S2** | 🟡 切片 | `exec/{step,driver}` 支持 **agent\|script\|map\|parallel**;默认 OFF;其余 kind 仍走 imperative path | +> | **S3** | ✅ | `replayRun`;`taskflow_replay` MCP;pi `action=replay` + `/tf replay`;golden + import-lint | +> | **S4** | ⬜ | `taskflow-dsl` 包(下一主线) | +> | **S5** | ⬜ | 全 kind 差分绿 → kernel 默认 ON | +> +> North-star 口号已改为 **compiled · resumable · incremental · replayable-for-what-if**。 --- ## 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 汇合、互不阻塞。 +1. **0.2.0 是一次内核替换**:`runtime` 从"解释执行 Taskflow schema"升级为"**执行 FlowIR 的事件溯源内核**"。FlowIR 从"哈希影子"毕业成**规范编译产物**(默认执行仍以 Taskflow 为主,经 S2 绞杀逐步迁到 driver)。 +2. **真编译器自研(S0 ✅)**:在 `taskflow-core` 内自建 `flowir/{schema,compile,hash,cond}`,well-formed flow 上 `usedFallbackHash` 为 `false`。 +3. **一个机制长出四样能力**:event log → `trace`、`fold`、`resume`、`确定性重放`、`增量重算`。消解 north-star "resumable vs replayable" 假对立。 +4. **绞杀式迁移**:新旧内核并存 + 差分 + 逐 kind flip(S0–S5 每步可发布)。 +5. **replay 重新纳入 scope(S3 ✅)**:兑现 0.1.7 承诺;`replayRun` + MCP/pi 已落地。 +6. **向后兼容是硬约束**:JSON flow / RunState / `/tf` 语义不破坏。 +7. **DSL 是平行前端(S4 ⬜)**:`.tf.ts → build → Taskflow → FlowIR`;新包 `taskflow-dsl`。 --- @@ -29,19 +42,18 @@ ### 矛盾 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**,并警告"现在碰它 = 数据 + 算法 + 执行三层同时改,违背每步可发布"。 +- **起草时现状(2026-07-08)**:`executeTaskflow(state, deps)` 中 `state.def: Taskflow`。FlowIR 仅经 `compileTaskflowToIR` 做分析/哈希;stub `translate` 路径上 `usedFallbackHash` 恒 true。 +- **实现后(2026-07-09,S0 ✅)**:真编译器 `compileTaskflowToFlowIR` + `hashFlowIR` 产出 `ir:<64-hex>`,well-formed flow 上 `usedFallbackHash: false`。FlowIR 已是**规范编译/内容寻址产物**(供 `/tf ir`、cache fingerprint、declaredDeps)。**默认执行路径仍是 Taskflow + imperative `executePhase`**;S2 event kernel(默认 OFF)对 agent|script|map|parallel 可走 `exec/driver`。 +- **DSL RFC v2 §0.2** 写的是"`taskflow build` → FlowIR,runtime 执行 FlowIR"——**S5 才把默认执行翻到 FlowIR/driver**。 +- **overstory roadmap §6.4** 的"大爆炸"风险由 **Q7 绞杀迁移**化解。 -→ 三份文档在"FlowIR 是不是执行产物"上互相打架。**本 RFC 拍板:是(Q2=B),但用绞杀式迁移守住可发布性(Q7)。** +→ 本 RFC 拍板:FlowIR **是**规范执行/编译真源方向(Q2=B),用绞杀守住可发布性(Q7)。S0–S3 已部分兑现;S4–S5 继续。 ### 矛盾 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)。 +- **0.1.7 CHANGELOG 原文**:确定性重放"…which **lands in 0.2.0**…"。 +- **north-star(07-07)** 一度借 Qwik 的 **"resumable (not replayable)"** 反向定位,与决策重放撞名。 +- **已解决(S3 ✅ + north-star 修订)**:口号改为 **compiled · resumable · incremental · replayable-for-what-if**;`replayRun` + MCP/pi 表面已落地。Qwik 的 replayable(hydration)≠ taskflow 的决策 what-if。 --- @@ -254,7 +266,7 @@ ### 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)。 +- **修 north-star**:**已改** —— 口号为 **"compiled · resumable · incremental · replayable-for-what-if"**(见 `0.2.0-north-star.md`;Qwik 的 replayable 指 hydration,与决策 what-if 不同)。 --- @@ -276,12 +288,12 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 | 阶段 | 做什么 | 差分/验收门 | 可发布价值 | 承接 | |---|---|---|---|---| -| **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 绿 | 内核替换完成 | — | +| **S0** ✅ | 自研 `flowir/{schema,compile,hash,cond}`;`usedFallbackHash→false`;runtime 仍执行 Taskflow | hash byte-determinism + 敏感度(`flowir-*.test.ts`) | cache-key 更精准;FlowIR 成规范 | roadmap M1 剩余(自建版) | +| **S1** ✅ | 加 `exec/{events,fold}`;旧 executePhase 全量 decision emit;`fold(log)` 对齐 RunState | 差分 + kill-9 rebuild 测试 | **trace 完整 + 事件溯源(F3 溶解)** | F3 / Q8 | +| **S2** 🟡 | 建 `exec/{driver,step}`;**已迁** agent→script→map→parallel;剩余 reduce/gate/loop/tournament/approval/flow | 已支持 kind 的 parity 测试;默认 OFF | 每 kind 绿了 flip 一次 | Q7 | +| **S3** ✅ | `replayRun()` + `taskflow_replay` + `/tf replay` + 黄金 fixtures + import-lint | 未改旋钮 → 全 reused;改阈值/budget 可测 | **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 独立可发布、可停在任意阶段。 @@ -308,15 +320,15 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 | 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 测试 | +| replay 花了 token(import 图破坏) | MED | §4.2 结构护栏:replay 不 import exec/driver;**已加** `replay-import-lint.test.ts` | | 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)。 +- **S0** ✅:`hashFlowIR` 确定性 + 敏感度。 +- **S1** ✅(测试 oracle):跑完 → 只保留 event log → `foldEvents` 重建 phase 终端状态(`fold-kill9-rebuild.test.ts`)。 +- **S3** ✅:未改旋钮 replay → 全 `reused`;阈值/budget 覆盖(`replay.test.ts`)。 +- **S5** ⬜:增量重算成本比 —— 周一 $6/8 agents → 周二改 1 文件 → ≤2 节点 $0.40(旗舰 demo)。 --- @@ -331,13 +343,13 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 --- -## §13. 待定(不阻塞本 RFC,实现时定) +## §13. 待定 / 已决 -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 串行**:取决于人力(两者解耦,可并行)。 +1. **`build` AST transform 实现选型**(S4):tsc transformer API / Babel / 自研轻量解析(DSL RFC v2 §C)— **仍待定**。 +2. **replay 命名**:**已决** — 模块 `replay.ts`;用户面 `/tf replay` + `taskflow_replay`;口号 **replayable-for-what-if**。 +3. **event log 存储**:**已决(S1)** — 与 `trace.jsonl` 同形状;`Event = TraceEvent & { v }`;`upgradeTraceEvent` 读旧行。 +4. **绞杀开关的默认策略**:**已决(S2 切片)** — 默认 OFF;`RuntimeDeps.eventKernel` 或 `PI_TASKFLOW_EVENT_KERNEL=1|true`;显式 `false` 覆盖 env。 +5. **S3 与 S4 并行 vs 串行**:S3 已落地;**S4 为下一主线**(与 S2 剩余 kind 可并行)。 --- diff --git a/packages/claude-taskflow/plugin/skills/taskflow/advanced.md b/packages/claude-taskflow/plugin/skills/taskflow/advanced.md index 897b205..6a178e2 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/advanced.md @@ -84,3 +84,61 @@ mutation without touching the main tree: 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. + +--- + +## Trace & offline replay (`trace` / `replay`) — vs resume / recompute + +Three **different** reuse tools; do not conflate them: + +| Tool | Spends tokens? | Mutates the run? | Answers | +|------|----------------|------------------|---------| +| **`resume`** | Only unfinished / cache-miss phases | Continues the same run | "Pick up where we stopped" | +| **`why-stale` → `recompute`** | Dry-run free; `--apply` / `dryRun:false` spends | Optional write of recompute result | "World/input changed — which phases re-run?" | +| **`trace` → `replay`** | **Never** | Never | "If the gate threshold / budget had been different, would we have blocked?" | + +### Trace (read the evidence) + +Every instrumented run may write an append-only **event log** +(`runs//.trace.jsonl`): phase lifecycle, each subagent +input/output, and runtime **decisions** (gate verdict/score, when-guard, +cache-hit, budget-hit, tournament-winner, unreplayable). + +``` +taskflow_trace { runId: "" } +taskflow_trace { runId: "", json: true } +``` + +If there is no log (pre-trace run, or no sink injected), the tool reports that +clearly — it never invents events. + +### Offline replay (what-if, zero tokens) + +`replay` **re-folds** the recorded log under alternate **decision knobs** without +calling any model: + +- `thresholds` — map of `phaseId → new score threshold` (gate-score events) +- `budgetMaxUSD` / `budgetMaxTokens` — would later phases have been skipped? +- `models` / `args` — currently report `needs-live-rerun` (quality cannot be + re-judged offline without re-execution) + +Outcomes per phase: `reused`, `would-block`, `verdict-flipped`, +`would-exceed-budget`, `threshold-changed`, `needs-live-rerun`, `failed`. + +``` +taskflow_replay { runId: "", thresholds: { review: 0.9 } } +taskflow_replay { runId: "", budgetMaxUSD: 0.05, json: true } +``` + +**Import-graph guarantee:** `replayRun` never imports the process-spawning +runtime or event kernel — offline replay cannot accidentally spend tokens. + +### When to use which + +| Situation | Use | +|-----------|-----| +| Rate-limit mid-run; inputs unchanged | `resume` | +| Repo file changed; re-pay only affected phases | `why-stale` → `recompute` | +| "Would a stricter gate have blocked last night's run?" | `trace` → `replay` with new `thresholds` | +| "Would a $0.10 cap have stopped the fan-out?" | `replay` with `budgetMaxUSD` | +| Need fresh model judgment under a new model id | `replay` will say `needs-live-rerun` → live `recompute`/`run` | diff --git a/packages/codex-taskflow/plugin/skills/taskflow/advanced.md b/packages/codex-taskflow/plugin/skills/taskflow/advanced.md index 897b205..6a178e2 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/advanced.md @@ -84,3 +84,61 @@ mutation without touching the main tree: 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. + +--- + +## Trace & offline replay (`trace` / `replay`) — vs resume / recompute + +Three **different** reuse tools; do not conflate them: + +| Tool | Spends tokens? | Mutates the run? | Answers | +|------|----------------|------------------|---------| +| **`resume`** | Only unfinished / cache-miss phases | Continues the same run | "Pick up where we stopped" | +| **`why-stale` → `recompute`** | Dry-run free; `--apply` / `dryRun:false` spends | Optional write of recompute result | "World/input changed — which phases re-run?" | +| **`trace` → `replay`** | **Never** | Never | "If the gate threshold / budget had been different, would we have blocked?" | + +### Trace (read the evidence) + +Every instrumented run may write an append-only **event log** +(`runs//.trace.jsonl`): phase lifecycle, each subagent +input/output, and runtime **decisions** (gate verdict/score, when-guard, +cache-hit, budget-hit, tournament-winner, unreplayable). + +``` +taskflow_trace { runId: "" } +taskflow_trace { runId: "", json: true } +``` + +If there is no log (pre-trace run, or no sink injected), the tool reports that +clearly — it never invents events. + +### Offline replay (what-if, zero tokens) + +`replay` **re-folds** the recorded log under alternate **decision knobs** without +calling any model: + +- `thresholds` — map of `phaseId → new score threshold` (gate-score events) +- `budgetMaxUSD` / `budgetMaxTokens` — would later phases have been skipped? +- `models` / `args` — currently report `needs-live-rerun` (quality cannot be + re-judged offline without re-execution) + +Outcomes per phase: `reused`, `would-block`, `verdict-flipped`, +`would-exceed-budget`, `threshold-changed`, `needs-live-rerun`, `failed`. + +``` +taskflow_replay { runId: "", thresholds: { review: 0.9 } } +taskflow_replay { runId: "", budgetMaxUSD: 0.05, json: true } +``` + +**Import-graph guarantee:** `replayRun` never imports the process-spawning +runtime or event kernel — offline replay cannot accidentally spend tokens. + +### When to use which + +| Situation | Use | +|-----------|-----| +| Rate-limit mid-run; inputs unchanged | `resume` | +| Repo file changed; re-pay only affected phases | `why-stale` → `recompute` | +| "Would a stricter gate have blocked last night's run?" | `trace` → `replay` with new `thresholds` | +| "Would a $0.10 cap have stopped the fan-out?" | `replay` with `budgetMaxUSD` | +| Need fresh model judgment under a new model id | `replay` will say `needs-live-rerun` → live `recompute`/`run` | diff --git a/packages/grok-taskflow/plugin/skills/taskflow/advanced.md b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md index 897b205..6a178e2 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md @@ -84,3 +84,61 @@ mutation without touching the main tree: 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. + +--- + +## Trace & offline replay (`trace` / `replay`) — vs resume / recompute + +Three **different** reuse tools; do not conflate them: + +| Tool | Spends tokens? | Mutates the run? | Answers | +|------|----------------|------------------|---------| +| **`resume`** | Only unfinished / cache-miss phases | Continues the same run | "Pick up where we stopped" | +| **`why-stale` → `recompute`** | Dry-run free; `--apply` / `dryRun:false` spends | Optional write of recompute result | "World/input changed — which phases re-run?" | +| **`trace` → `replay`** | **Never** | Never | "If the gate threshold / budget had been different, would we have blocked?" | + +### Trace (read the evidence) + +Every instrumented run may write an append-only **event log** +(`runs//.trace.jsonl`): phase lifecycle, each subagent +input/output, and runtime **decisions** (gate verdict/score, when-guard, +cache-hit, budget-hit, tournament-winner, unreplayable). + +``` +taskflow_trace { runId: "" } +taskflow_trace { runId: "", json: true } +``` + +If there is no log (pre-trace run, or no sink injected), the tool reports that +clearly — it never invents events. + +### Offline replay (what-if, zero tokens) + +`replay` **re-folds** the recorded log under alternate **decision knobs** without +calling any model: + +- `thresholds` — map of `phaseId → new score threshold` (gate-score events) +- `budgetMaxUSD` / `budgetMaxTokens` — would later phases have been skipped? +- `models` / `args` — currently report `needs-live-rerun` (quality cannot be + re-judged offline without re-execution) + +Outcomes per phase: `reused`, `would-block`, `verdict-flipped`, +`would-exceed-budget`, `threshold-changed`, `needs-live-rerun`, `failed`. + +``` +taskflow_replay { runId: "", thresholds: { review: 0.9 } } +taskflow_replay { runId: "", budgetMaxUSD: 0.05, json: true } +``` + +**Import-graph guarantee:** `replayRun` never imports the process-spawning +runtime or event kernel — offline replay cannot accidentally spend tokens. + +### When to use which + +| Situation | Use | +|-----------|-----| +| Rate-limit mid-run; inputs unchanged | `resume` | +| Repo file changed; re-pay only affected phases | `why-stale` → `recompute` | +| "Would a stricter gate have blocked last night's run?" | `trace` → `replay` with new `thresholds` | +| "Would a $0.10 cap have stopped the fan-out?" | `replay` with `budgetMaxUSD` | +| Need fresh model judgment under a new model id | `replay` will say `needs-live-rerun` → live `recompute`/`run` | diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md b/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md index 897b205..6a178e2 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md @@ -84,3 +84,61 @@ mutation without touching the main tree: 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. + +--- + +## Trace & offline replay (`trace` / `replay`) — vs resume / recompute + +Three **different** reuse tools; do not conflate them: + +| Tool | Spends tokens? | Mutates the run? | Answers | +|------|----------------|------------------|---------| +| **`resume`** | Only unfinished / cache-miss phases | Continues the same run | "Pick up where we stopped" | +| **`why-stale` → `recompute`** | Dry-run free; `--apply` / `dryRun:false` spends | Optional write of recompute result | "World/input changed — which phases re-run?" | +| **`trace` → `replay`** | **Never** | Never | "If the gate threshold / budget had been different, would we have blocked?" | + +### Trace (read the evidence) + +Every instrumented run may write an append-only **event log** +(`runs//.trace.jsonl`): phase lifecycle, each subagent +input/output, and runtime **decisions** (gate verdict/score, when-guard, +cache-hit, budget-hit, tournament-winner, unreplayable). + +``` +taskflow_trace { runId: "" } +taskflow_trace { runId: "", json: true } +``` + +If there is no log (pre-trace run, or no sink injected), the tool reports that +clearly — it never invents events. + +### Offline replay (what-if, zero tokens) + +`replay` **re-folds** the recorded log under alternate **decision knobs** without +calling any model: + +- `thresholds` — map of `phaseId → new score threshold` (gate-score events) +- `budgetMaxUSD` / `budgetMaxTokens` — would later phases have been skipped? +- `models` / `args` — currently report `needs-live-rerun` (quality cannot be + re-judged offline without re-execution) + +Outcomes per phase: `reused`, `would-block`, `verdict-flipped`, +`would-exceed-budget`, `threshold-changed`, `needs-live-rerun`, `failed`. + +``` +taskflow_replay { runId: "", thresholds: { review: 0.9 } } +taskflow_replay { runId: "", budgetMaxUSD: 0.05, json: true } +``` + +**Import-graph guarantee:** `replayRun` never imports the process-spawning +runtime or event kernel — offline replay cannot accidentally spend tokens. + +### When to use which + +| Situation | Use | +|-----------|-----| +| Rate-limit mid-run; inputs unchanged | `resume` | +| Repo file changed; re-pay only affected phases | `why-stale` → `recompute` | +| "Would a stricter gate have blocked last night's run?" | `trace` → `replay` with new `thresholds` | +| "Would a $0.10 cap have stopped the fan-out?" | `replay` with `budgetMaxUSD` | +| Need fresh model judgment under a new model id | `replay` will say `needs-live-rerun` → live `recompute`/`run` | diff --git a/packages/pi-taskflow/skills/taskflow/advanced.md b/packages/pi-taskflow/skills/taskflow/advanced.md index 0ba12d7..ae4abe1 100644 --- a/packages/pi-taskflow/skills/taskflow/advanced.md +++ b/packages/pi-taskflow/skills/taskflow/advanced.md @@ -242,6 +242,66 @@ stored run at $0. --- +## Trace & offline replay (`trace` / `replay`) — vs resume / recompute + +Three **different** reuse tools; do not conflate them: + +| Tool | Spends tokens? | Mutates the run? | Answers | +|------|----------------|------------------|---------| +| **`resume`** | Only unfinished / cache-miss phases | Continues the same run | "Pick up where we stopped" | +| **`why-stale` → `recompute`** | Dry-run free; `--apply` / `dryRun:false` spends | Optional write of recompute result | "World/input changed — which phases re-run?" | +| **`trace` → `replay`** | **Never** | Never | "If the gate threshold / budget had been different, would we have blocked?" | + +### Trace (read the evidence) + +Every instrumented run may write an append-only **event log** +(`runs//.trace.jsonl`): phase lifecycle, each subagent +input/output, and runtime **decisions** (gate verdict/score, when-guard, +cache-hit, budget-hit, tournament-winner, unreplayable). + +``` +taskflow { action: "trace", runId: "" } +taskflow { action: "trace", runId: "", json: true } // full machine record +/tf trace [--json] +``` + +If there is no log (pre-trace run, or no sink injected), the tool reports that +clearly — it never invents events. + +### Offline replay (what-if, zero tokens) + +`replay` **re-folds** the recorded log under alternate **decision knobs** without +calling any model: + +- `thresholds` — map of `phaseId → new score threshold` (gate-score events) +- `budgetMaxUSD` / `budgetMaxTokens` — would later phases have been skipped? +- `models` / `args` — currently report `needs-live-rerun` (quality cannot be + re-judged offline without re-execution) + +Outcomes per phase: `reused`, `would-block`, `verdict-flipped`, +`would-exceed-budget`, `threshold-changed`, `needs-live-rerun`, `failed`. + +``` +taskflow { action: "replay", runId: "", thresholds: { review: 0.9 } } +taskflow { action: "replay", runId: "", budgetMaxUSD: 0.05, json: true } +/tf replay --threshold review=0.9 --budget-usd 0.05 [--json] +``` + +**Import-graph guarantee:** `replayRun` never imports the process-spawning +runtime or event kernel — offline replay cannot accidentally spend tokens. + +### When to use which + +| Situation | Use | +|-----------|-----| +| Rate-limit mid-run; inputs unchanged | `resume` | +| Repo file changed; re-pay only affected phases | `why-stale` → `recompute` | +| "Would a stricter gate have blocked last night's run?" | `trace` → `replay` with new `thresholds` | +| "Would a $0.10 cap have stopped the fan-out?" | `replay` with `budgetMaxUSD` | +| Need fresh model judgment under a new model id | `replay` will say `needs-live-rerun` → live `recompute`/`run` | + +--- + ## `init` — model roles setup `action: "init"` manages the `modelRoles` map (`{{fast}}` / `{{strong}}` / … diff --git a/skills-src/taskflow/advanced.md b/skills-src/taskflow/advanced.md index 69ad29d..3b345b6 100644 --- a/skills-src/taskflow/advanced.md +++ b/skills-src/taskflow/advanced.md @@ -248,7 +248,85 @@ changed. Instead of re-running all 12 phases: The other 8 phases (dependency summary, license scan, …) are served from the stored run at $0. + + +--- + +## Trace & offline replay (`trace` / `replay`) — vs resume / recompute + +Three **different** reuse tools; do not conflate them: + +| Tool | Spends tokens? | Mutates the run? | Answers | +|------|----------------|------------------|---------| +| **`resume`** | Only unfinished / cache-miss phases | Continues the same run | "Pick up where we stopped" | +| **`why-stale` → `recompute`** | Dry-run free; `--apply` / `dryRun:false` spends | Optional write of recompute result | "World/input changed — which phases re-run?" | +| **`trace` → `replay`** | **Never** | Never | "If the gate threshold / budget had been different, would we have blocked?" | + +### Trace (read the evidence) + +Every instrumented run may write an append-only **event log** +(`runs//.trace.jsonl`): phase lifecycle, each subagent +input/output, and runtime **decisions** (gate verdict/score, when-guard, +cache-hit, budget-hit, tournament-winner, unreplayable). + + +``` +taskflow { action: "trace", runId: "" } +taskflow { action: "trace", runId: "", json: true } // full machine record +/tf trace [--json] +``` + + +``` +taskflow_trace { runId: "" } +taskflow_trace { runId: "", json: true } +``` + + +If there is no log (pre-trace run, or no sink injected), the tool reports that +clearly — it never invents events. +### Offline replay (what-if, zero tokens) + +`replay` **re-folds** the recorded log under alternate **decision knobs** without +calling any model: + +- `thresholds` — map of `phaseId → new score threshold` (gate-score events) +- `budgetMaxUSD` / `budgetMaxTokens` — would later phases have been skipped? +- `models` / `args` — currently report `needs-live-rerun` (quality cannot be + re-judged offline without re-execution) + +Outcomes per phase: `reused`, `would-block`, `verdict-flipped`, +`would-exceed-budget`, `threshold-changed`, `needs-live-rerun`, `failed`. + + +``` +taskflow { action: "replay", runId: "", thresholds: { review: 0.9 } } +taskflow { action: "replay", runId: "", budgetMaxUSD: 0.05, json: true } +/tf replay --threshold review=0.9 --budget-usd 0.05 [--json] +``` + + +``` +taskflow_replay { runId: "", thresholds: { review: 0.9 } } +taskflow_replay { runId: "", budgetMaxUSD: 0.05, json: true } +``` + + +**Import-graph guarantee:** `replayRun` never imports the process-spawning +runtime or event kernel — offline replay cannot accidentally spend tokens. + +### When to use which + +| Situation | Use | +|-----------|-----| +| Rate-limit mid-run; inputs unchanged | `resume` | +| Repo file changed; re-pay only affected phases | `why-stale` → `recompute` | +| "Would a stricter gate have blocked last night's run?" | `trace` → `replay` with new `thresholds` | +| "Would a $0.10 cap have stopped the fan-out?" | `replay` with `budgetMaxUSD` | +| Need fresh model judgment under a new model id | `replay` will say `needs-live-rerun` → live `recompute`/`run` | + + --- ## `init` — model roles setup diff --git a/website/content/docs/en/concepts/deterministic-replay.mdx b/website/content/docs/en/concepts/deterministic-replay.mdx new file mode 100644 index 0000000..50de152 --- /dev/null +++ b/website/content/docs/en/concepts/deterministic-replay.mdx @@ -0,0 +1,57 @@ +--- +title: Deterministic Replay +description: Offline what-if re-judgment of gate thresholds and budgets from a recorded event trace — zero tokens, never re-runs the model. +--- + +**Deterministic replay** (also *replayable-for-what-if*) answers a different question from [Resume](/en/docs/concepts/resume) or [incremental recompute](/en/docs/reference/incremental-recompute). + +| Mechanism | Question | Tokens? | +|-----------|----------|---------| +| **Resume** | Continue a paused/failed run; reuse finished phases whose inputs are unchanged | Only unfinished / cache-miss work | +| **Recompute** | The *world* changed (file, commit) — which phases of a stored run must re-run? | Dry-run free; apply spends tokens on the stale frontier | +| **Deterministic replay** | If decision knobs (gate threshold, budget) had been different, what would the *recorded* run have decided? | **Never** — re-folds the event log only | + +Qwik's "not replayable" means something else (no hydration re-execution). taskflow's replay is **decision what-if** on an append-only evidence log. + +## The event trace + +Instrumenting runs (default when a host injects `FileTraceSink`) write +`runs//.trace.jsonl`. Each line is a lifecycle or decision event: + +- `phase-start` / `phase-end` +- `subagent-call` — resolved task text + full output + usage +- `decision` — gate verdict/score, when-guard, cache-hit, budget-hit, tournament-winner, unreplayable + +Inspect with `/tf trace ` (Pi) or `taskflow_trace` (MCP). Pass `json: true` for the full machine record. + +## Offline replay + +```bash title="Pi" +/tf replay --threshold review=0.9 --budget-usd 0.05 +/tf replay --json +``` + +```json title="MCP / tool action" +{ + "runId": "nightly-audit-abc123", + "thresholds": { "review": 0.9 }, + "budgetMaxUSD": 0.05 +} +``` + +Per-phase outcomes include `reused`, `would-block`, `verdict-flipped`, +`would-exceed-budget`, `threshold-changed`, `needs-live-rerun`, and `failed`. + +Model or args overrides currently map to **`needs-live-rerun`**: quality cannot be re-scored offline without a new subagent call — use live recompute or a fresh run. + +## Guarantees + +- **Zero tokens** by construction: `replayRun` imports only pure modules (events, fold, deterministic helpers). It never imports the process-spawning runtime or event kernel (CI: `replay-import-lint`). +- **No mutation** of the stored run — the report is counterfactual only. +- **No log** → the tool fails clearly (pre-trace run or missing sink). + +## See also + +- [Resume](/en/docs/concepts/resume) — cross-session continuation and cache reuse +- [Incremental recompute](/en/docs/reference/incremental-recompute) — FlowIR, staleness, surgical re-run +- [Commands](/en/docs/reference/commands) — `/tf trace`, `/tf replay`, `taskflow_replay` diff --git a/website/content/docs/en/concepts/index.mdx b/website/content/docs/en/concepts/index.mdx index 854b155..4fe8a12 100644 --- a/website/content/docs/en/concepts/index.mdx +++ b/website/content/docs/en/concepts/index.mdx @@ -10,7 +10,8 @@ This chapter is the *mental model* behind taskflow. It does not list every field - data moves between phases through **interpolation**; - the whole graph is **verified** before a token is spent; - intermediate output is **isolated** from your context; -- and a run can be **resumed** across sessions. +- a run can be **resumed** across sessions; +- and a finished run can be **replayed offline** for threshold/budget what-ifs (zero tokens). Read these once and the rest of the docs click into place. @@ -37,6 +38,9 @@ The concepts build on each other. Read them top to bottom the first time: **[Resume](/en/docs/concepts/resume)** — how a failed run picks up where it stopped, and how cross-run memoization reuses last week's work. + + **[Deterministic Replay](/en/docs/concepts/deterministic-replay)** — offline what-if on a recorded event trace (gate thresholds, budget) without calling the model. + @@ -62,6 +66,9 @@ The concepts build on each other. Read them top to bottom the first time: Why only the final result reaches you. - How cross-session replay works. + Cross-session continuation and cache reuse. + + + Offline threshold/budget what-if from the event log. diff --git a/website/content/docs/en/concepts/resume.mdx b/website/content/docs/en/concepts/resume.mdx index 6462180..9d68a16 100644 --- a/website/content/docs/en/concepts/resume.mdx +++ b/website/content/docs/en/concepts/resume.mdx @@ -3,6 +3,10 @@ title: Resume description: How taskflow runs survive across sessions. --- + + **Resume ≠ deterministic replay.** Resume continues a live run and may spend tokens on unfinished work. [Deterministic replay](/en/docs/concepts/deterministic-replay) re-judges a *finished* run's event log under new gate thresholds or budgets **without** calling the model. For world-input staleness, use [incremental recompute](/en/docs/reference/incremental-recompute). + + Your twenty-minute audit failed at the last step because of a rate limit. Nineteen minutes of subagent work succeeded; one phase tripped over a transient error and the run stopped. Without resume, you would start over from scratch — re-reading every file, re-running every review, paying for all of it twice. taskflow does not work that way. A run is not tied to your session. Every completed phase is written to disk as it finishes, so a run that fails, gets stopped, or simply outlives its terminal can be picked up later — and only the work that didn't finish gets redone. @@ -116,7 +120,7 @@ A few phase types can never be cross-run cached, because they must produce a fre - `loop` and `tournament` — their outcome depends on iteration dynamics. - `script` — shell commands often have side effects. -A phase marked `idempotent: false` is also excluded — it has irreversible side effects (a deploy, a webhook, a database write) and must never be replayed from cache. Setting `cache.scope` to `cross-run` on any of these is a validation error, caught by `/tf verify`. +A phase marked `idempotent: false` is also excluded — it has irreversible side effects (a deploy, a webhook, a database write) and must never be **served from cache** (that is cache reuse, not [deterministic replay](/en/docs/concepts/deterministic-replay)). Setting `cache.scope` to `cross-run` on any of these is a validation error, caught by `/tf verify`. ### Going flow-wide diff --git a/website/content/docs/en/meta.json b/website/content/docs/en/meta.json index f180eb7..681e73c 100644 --- a/website/content/docs/en/meta.json +++ b/website/content/docs/en/meta.json @@ -13,6 +13,7 @@ "concepts/shared-context-tree", "concepts/workspace-isolation", "concepts/resume", + "concepts/deterministic-replay", "---Syntax Reference---", "syntax/flow-definition", "syntax/phase-types", diff --git a/website/content/docs/en/reference/commands.mdx b/website/content/docs/en/reference/commands.mdx index 8b3ee3b..0f46149 100644 --- a/website/content/docs/en/reference/commands.mdx +++ b/website/content/docs/en/reference/commands.mdx @@ -42,6 +42,8 @@ The `/tf` command is the user-facing surface on Pi. Its subcommands fall into fo | `/tf provenance ` | Show the observed read-set provenance for a run. | | `/tf why-stale [phaseId]` | Explain why a cached run is stale — which fingerprint input changed. | | `/tf recompute [--apply]` | Minimally recompute a stale phase. Default is a safe dry-run; `--apply` spends tokens. | +| `/tf trace [--json]` | Show the append-only event log (subagent I/O + runtime decisions). | +| `/tf replay [--threshold phase=n] [--budget-usd n] [--json]` | Offline what-if re-judge of thresholds/budget from a recorded trace (**zero tokens**). | ### Lifecycle @@ -51,7 +53,7 @@ The `/tf` command is the user-facing surface on Pi. Its subcommands fall into fo | `/tf init` | Configure model roles — map the built-in agent roles to your models. | - `peek`, `provenance`, `why-stale`, and `recompute` are the debugging toolkit. Use them when a run did something unexpected and you need to see *why* without re-running it. + `peek`, `provenance`, `why-stale`, and `recompute` explain *what would re-run*. `trace` + `replay` explain *what was decided* and counterfactual knobs — see [Deterministic Replay](/en/docs/concepts/deterministic-replay). ## The `taskflow` tool (Pi) @@ -68,6 +70,7 @@ On Pi, the model can also invoke a `taskflow` tool directly. It takes an `action | `verify` | Static check. | | `compile` | Mermaid diagram + verification report. | | `ir` / `provenance` / `why-stale` / `recompute` | Incremental recompute toolkit. | +| `trace` / `replay` | Event log inspection + offline decision what-if (zero tokens). | | `cache-clear` | Clear the cross-run memoization cache. | | `init` | Configure model roles. | @@ -83,9 +86,15 @@ On MCP hosts, taskflow exposes these tools (the model calls them; you usually do | `taskflow_verify` | Statically verify a flow (no execution). | | `taskflow_compile` | Render a flow as a DAG diagram (SVG image) plus a status line. | | `taskflow_peek` | Inspect one phase's intermediate output from a stored run (post-hoc debugging). | +| `taskflow_trace` | Read-only timeline of the run's append-only event log. | +| `taskflow_replay` | Offline what-if re-judge of thresholds/budget from a recorded trace (zero tokens). | +| `taskflow_why_stale` | Explain observed/declared dependency staleness. | +| `taskflow_recompute` | Report the stale frontier (**dry-run only** over MCP). | +| `taskflow_save` | Save a flow to the library with purpose/tags. | +| `taskflow_search` | Search the reusable-flow library. | - On MCP hosts there is no `/tf` slash command — you describe the work in natural language and the model calls `taskflow_run`. Use `taskflow_peek` with the `runId` that `taskflow_run` returns when you need to inspect intermediate output. + On MCP hosts there is no `/tf` slash command — you describe the work in natural language and the model calls `taskflow_run`. Use `taskflow_peek` / `taskflow_trace` / `taskflow_replay` with the `runId` from `taskflow_run` for post-hoc inspection and offline what-if. ## An example session diff --git a/website/content/docs/zh-cn/concepts/deterministic-replay.mdx b/website/content/docs/zh-cn/concepts/deterministic-replay.mdx new file mode 100644 index 0000000..1288f21 --- /dev/null +++ b/website/content/docs/zh-cn/concepts/deterministic-replay.mdx @@ -0,0 +1,55 @@ +--- +title: 确定性重放 +description: 基于已录制事件轨迹,离线对门控阈值与预算做 what-if 重判——零 token,绝不重跑模型。 +--- + +**确定性重放**(*deterministic replay* / *replayable-for-what-if*)回答的问题,与[恢复(Resume)](/zh-cn/docs/concepts/resume)或[增量重算](/zh-cn/docs/reference/incremental-recompute)不同。 + +| 机制 | 问题 | 是否花 token | +|------|------|----------------| +| **Resume** | 续跑暂停/失败的运行;输入未变的已完成阶段复用 | 仅未完成 / 缓存未命中 | +| **Recompute** | *世界*变了(文件、提交)——已存运行的哪些阶段要重跑? | dry-run 免费;apply 只对陈旧前沿花费 | +| **确定性重放** | 若决策旋钮(门控阈值、预算)不同,*已录制*运行会如何裁决? | **永不**——只对事件日志再 fold | + +Qwik 的 "not replayable" 指 hydration 不重执行,与 taskflow 无关。这里的 replay 是**决策 what-if**。 + +## 事件轨迹 + +主机注入 `FileTraceSink` 时,运行会写入 `runs//.trace.jsonl`,包含: + +- `phase-start` / `phase-end` +- `subagent-call` — 解析后的任务文本 + 完整输出 + usage +- `decision` — gate 裁决/分数、when-guard、cache-hit、budget-hit、tournament-winner、unreplayable + +用 `/tf trace `(Pi)或 `taskflow_trace`(MCP)查看;`json: true` 返回完整机器可读记录。 + +## 离线重放 + +```bash title="Pi" +/tf replay --threshold review=0.9 --budget-usd 0.05 +/tf replay --json +``` + +```json title="MCP / 工具动作" +{ + "runId": "nightly-audit-abc123", + "thresholds": { "review": 0.9 }, + "budgetMaxUSD": 0.05 +} +``` + +每阶段结果包括:`reused`、`would-block`、`verdict-flipped`、`would-exceed-budget`、`threshold-changed`、`needs-live-rerun`、`failed`。 + +模型或 args 覆盖目前映射为 **`needs-live-rerun`**:离线无法重评质量,请改用 live recompute 或新跑。 + +## 保证 + +- **零 token(结构性)**:`replayRun` 只依赖纯模块;CI 用 `replay-import-lint` 禁止 import runtime/driver。 +- **不修改**已存运行——报告只是反事实分析。 +- **无日志**时明确失败(trace 之前的运行或未注入 sink)。 + +## 另见 + +- [恢复](/zh-cn/docs/concepts/resume) +- [增量重算](/zh-cn/docs/reference/incremental-recompute) +- [命令参考](/zh-cn/docs/reference/commands) diff --git a/website/content/docs/zh-cn/concepts/index.mdx b/website/content/docs/zh-cn/concepts/index.mdx index 2d86615..ebb2b9d 100644 --- a/website/content/docs/zh-cn/concepts/index.mdx +++ b/website/content/docs/zh-cn/concepts/index.mdx @@ -10,7 +10,8 @@ description: taskflow 声明式 DAG 模型背后的思想。 - 数据通过**插值**在阶段之间流动; - 整张图在任何 token 花出去之前就被**验证**过; - 中间输出与你的上下文**隔离**; -- 一次运行可以跨会话**续跑**。 +- 一次运行可以跨会话**续跑**; +- 已完成的运行还可以**离线确定性重放**(阈值/预算 what-if,零 token)。 把这些读一遍,剩下的文档就自然顺下来了。 @@ -37,6 +38,9 @@ description: taskflow 声明式 DAG 模型背后的思想。 **[续跑](/zh-cn/docs/concepts/resume)** —— 一次失败的运行如何从它停下处继续,以及跨运行记忆化如何复用上周的工作。 + + **[确定性重放](/zh-cn/docs/concepts/deterministic-replay)** —— 基于事件轨迹的离线 what-if(门控阈值、预算),不调用模型。 + @@ -62,6 +66,9 @@ description: taskflow 声明式 DAG 模型背后的思想。 为什么只有最终结果会到达你这里。 - 跨会话重放如何工作。 + 跨会话续跑与缓存复用。 + + + 事件日志上的离线阈值/预算 what-if。 diff --git a/website/content/docs/zh-cn/concepts/resume.mdx b/website/content/docs/zh-cn/concepts/resume.mdx index 631c5b9..08884f4 100644 --- a/website/content/docs/zh-cn/concepts/resume.mdx +++ b/website/content/docs/zh-cn/concepts/resume.mdx @@ -3,6 +3,10 @@ title: 续跑 description: taskflow 运行如何跨会话存活。 --- + + **续跑 ≠ 确定性重放。** 续跑会继续一次活运行,可能对未完成工作花费 token。[确定性重放](/zh-cn/docs/concepts/deterministic-replay) 是对*已完成*运行的事件日志,在新阈值/预算下做 **what-if**,**不**调用模型。世界输入变旧时用[增量重算](/zh-cn/docs/reference/incremental-recompute)。 + + 你那二十分钟的审计在最后一步因为速率限制失败了。十九分钟的子代理工作成功了;一个阶段绊在一个瞬时错误上,运行停了下来。没有续跑,你会从零开始——重新读取每个文件、重新运行每条审查、为这一切再付一遍钱。 taskflow 不这样工作。一次运行不绑定到你的会话。每个完成的阶段在它完成的那一刻就被写入磁盘,所以一个失败、被中止或只是比它的终端活得更久的运行,可以稍后被捡起来——而且只有没完成的工作会被重做。 diff --git a/website/content/docs/zh-cn/meta.json b/website/content/docs/zh-cn/meta.json index 0dd6bde..f5a43e0 100644 --- a/website/content/docs/zh-cn/meta.json +++ b/website/content/docs/zh-cn/meta.json @@ -13,6 +13,7 @@ "concepts/shared-context-tree", "concepts/workspace-isolation", "concepts/resume", + "concepts/deterministic-replay", "---语法参考---", "syntax/flow-definition", "syntax/phase-types", diff --git a/website/content/docs/zh-cn/reference/commands.mdx b/website/content/docs/zh-cn/reference/commands.mdx index 6db0ed9..307c0ca 100644 --- a/website/content/docs/zh-cn/reference/commands.mdx +++ b/website/content/docs/zh-cn/reference/commands.mdx @@ -42,6 +42,8 @@ taskflow 以两种方式暴露相同的操作。在 **Pi** 上,你输入像 `/ | `/tf provenance ` | 显示一个运行观察到的读集合来源。 | | `/tf why-stale [phaseId]` | 解释为什么一个缓存的运行已过期——哪个 fingerprint 输入变了。 | | `/tf recompute [--apply]` | 最小化地重算一个过期阶段。默认是安全的 dry-run;`--apply` 花 token。 | +| `/tf trace [--json]` | 显示追加写事件日志(subagent I/O + 运行时决策)。 | +| `/tf replay [--threshold phase=n] [--budget-usd n] [--json]` | 基于已录制轨迹的离线阈值/预算 what-if(**零 token**)。 | ### 生命周期 @@ -51,7 +53,7 @@ taskflow 以两种方式暴露相同的操作。在 **Pi** 上,你输入像 `/ | `/tf init` | 配置模型角色——把内置 agent 角色映射到你的模型。 | - `peek`、`provenance`、`why-stale` 和 `recompute` 是调试工具包。当一个运行做了意料之外的事、你需要在不重跑的情况下看*为什么*时使用它们。 + `peek` / `provenance` / `why-stale` / `recompute` 回答「会重跑什么」;`trace` + `replay` 回答「当时如何裁决」与反事实旋钮——见[确定性重放](/zh-cn/docs/concepts/deterministic-replay)。 ## `taskflow` 工具(Pi) @@ -68,10 +70,11 @@ taskflow 以两种方式暴露相同的操作。在 **Pi** 上,你输入像 `/ | `verify` | 静态检查。 | | `compile` | Mermaid 图 + 验证报告。 | | `ir` / `provenance` / `why-stale` / `recompute` | 增量重算工具包。 | +| `trace` / `replay` | 事件日志查看 + 离线决策 what-if(零 token)。 | | `cache-clear` | 清除跨运行记忆化缓存。 | | `init` | 配置模型角色。 | -## Codex / Claude / OpenCode / Grok / Grok / Grok MCP 工具 +## Codex / Claude / OpenCode / Grok MCP 工具 在 MCP 宿主上,taskflow 暴露这些工具(模型调用它们;你通常不输入它们): @@ -83,9 +86,15 @@ taskflow 以两种方式暴露相同的操作。在 **Pi** 上,你输入像 `/ | `taskflow_verify` | 静态验证一个 flow(不执行)。 | | `taskflow_compile` | 把 flow 渲染为 DAG 图(SVG 图像)加一行状态。 | | `taskflow_peek` | 查看一个已存储运行的某个阶段的中间输出(事后调试)。 | +| `taskflow_trace` | 运行追加写事件日志的只读时间线。 | +| `taskflow_replay` | 基于已录制轨迹的离线阈值/预算 what-if(零 token)。 | +| `taskflow_why_stale` | 解释观测/声明依赖的陈旧性。 | +| `taskflow_recompute` | 报告陈旧前沿(MCP 上**仅 dry-run**)。 | +| `taskflow_save` | 将 flow 连同 purpose/tags 保存到库。 | +| `taskflow_search` | 搜索可复用 flow 库。 | - 在 MCP 宿主上没有 `/tf` 斜杠命令——你用自然语言描述工作,模型调用 `taskflow_run`。当你需要查看中间输出时,使用 `taskflow_peek` 配合 `taskflow_run` 返回的 `runId`。 + 在 MCP 宿主上没有 `/tf` 斜杠命令——你用自然语言描述工作,模型调用 `taskflow_run`。事后排查与离线 what-if 使用 `taskflow_peek` / `taskflow_trace` / `taskflow_replay` 配合 `runId`。 ## 示例会话 From fb5b3ceb01d19fc311f0bb95dfbb2a1fc992c2c9 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 14:25:34 +0800 Subject: [PATCH 22/51] feat(core): complete S2 event kernel for all 10 phase kinds Expand exec/step + step-kinds so reduce, gate, approval, loop, tournament, and flow run on the event kernel (still default OFF). Nested flows recurse through the driver; gates and auto-rejected approvals set run status blocked. parseTournamentWinner moves to deterministic.ts for shared pure use. Tests cover every kind and mixed DAGs. --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- docs/rfc-0.2.0-architecture.md | 4 +- packages/taskflow-core/src/deterministic.ts | 23 +- packages/taskflow-core/src/exec/driver.ts | 145 ++++- packages/taskflow-core/src/exec/step-kinds.ts | 524 ++++++++++++++++++ packages/taskflow-core/src/exec/step.ts | 76 ++- packages/taskflow-core/src/runtime.ts | 46 +- .../taskflow-core/test/exec-driver.test.ts | 9 +- .../test/exec-kernel-parity.test.ts | 6 +- .../test/exec-kernel-s2-complete.test.ts | 287 ++++++++++ 11 files changed, 1043 insertions(+), 81 deletions(-) create mode 100644 packages/taskflow-core/src/exec/step-kinds.ts create mode 100644 packages/taskflow-core/test/exec-kernel-s2-complete.test.ts diff --git a/AGENTS.md b/AGENTS.md index 08a6c72..84e71cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,7 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil - **FlowIR (S0):** `compileTaskflowToIR` → genuine `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`; `usedFallbackHash: false` when IR is content-addressable. - **Trace:** every run may record `runs//.trace.jsonl` via `RuntimeDeps.trace` (`FileTraceSink`). Decisions include gate/when/cache/budget/tournament/unreplayable. -- **Event kernel (S2, default OFF):** set `RuntimeDeps.eventKernel: true` or `PI_TASKFLOW_EVENT_KERNEL=1`. Only pure `agent|script|map|parallel` DAGs take this path; other kinds stay on the imperative `executeTaskflow` path. +- **Event kernel (S2 complete, default OFF):** set `RuntimeDeps.eventKernel: true` or `PI_TASKFLOW_EVENT_KERNEL=1`. All 10 phase kinds run on `exec/driver` when enabled; imperative path remains the default until S5. - **Offline replay (S3, zero tokens):** `replayRun(events, overrides)` in `replay.ts` — **must not** import `runtime` / `exec/driver` / `exec/step` (guarded by `replay-import-lint.test.ts`). Surfaces: pi `action=replay` + `/tf replay`; MCP `taskflow_replay`. Distinct from **resume** / **recompute** (those re-execute live phases). - **MCP roster (12):** `taskflow_run|list|show|verify|compile|peek|trace|replay|why_stale|recompute|save|search`. diff --git a/CHANGELOG.md b/CHANGELOG.md index 37b6103..ed49a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - `exec/fold.ts` — pure `foldEvents(log) →` per-phase snapshot (S1 differential building block). - `replay.ts` — implements `replayRun(log, overrides)` (threshold / budget / model / args knobs; zero tokens; no import of runtime/driver). - **S1 decision coverage:** runtime emits `gate-score` / `gate-verdict`, `when-guard`, `cache-hit`, `tournament-winner`, `budget-hit`, plus synthetic phase lifecycle for budget/dep skips. Fold differential tests pin fold ↔ RunState agreement. - - **S2 event kernel (strangler, default OFF):** `exec/step.ts` + `exec/driver.ts` run **agent|script|map|parallel** flows when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative path remains default. Kernel path preserves agent `usage`, evaluates `when` (with `when-guard` decision events), and emits skip lifecycle for unmet deps. Parity tests pin kernel vs imperative for supported kinds. + - **S2 event kernel (strangler, default OFF, all 10 kinds):** `exec/step.ts` + `step-kinds.ts` + `exec/driver.ts` run **every** phase type (agent|script|map|parallel|reduce|gate|approval|loop|tournament|flow) when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative path remains default. Nested `flow` via recursive kernel; gate block → run `blocked`; approval auto-reject without UI. - **S1 hard gate + import lint:** fold(log) rebuilds phase statuses matching RunState after a captured run (kill-9 oracle); `replay-import-lint` keeps `replay.ts` off the runtime/driver/step import graph. - **North-star slogan:** `compiled · resumable · incremental · replayable-for-what-if` (drops Qwik "not replayable" collision with deterministic replay). - **S3 replay surface:** `taskflow_replay` MCP tool; pi `action=replay` and `/tf replay [--threshold phase=n] [--budget-usd n]`; golden trace fixture under `test/fixtures/`. diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index ae374f5..d448f96 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -17,7 +17,7 @@ > |------|------|----------| > | **S0** | ✅ | `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`;`usedFallbackHash: false`(well-formed IR) | > | **S1** | ✅ | `exec/{events,fold}`;runtime 全量 decision emit;fold 差分 + kill-9 rebuild 测试 | -> | **S2** | 🟡 切片 | `exec/{step,driver}` 支持 **agent\|script\|map\|parallel**;默认 OFF;其余 kind 仍走 imperative path | +> | **S2** | ✅ | `exec/{step,driver}` **全部 10 kind**;默认 OFF(`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL`) | > | **S3** | ✅ | `replayRun`;`taskflow_replay` MCP;pi `action=replay` + `/tf replay`;golden + import-lint | > | **S4** | ⬜ | `taskflow-dsl` 包(下一主线) | > | **S5** | ⬜ | 全 kind 差分绿 → kernel 默认 ON | @@ -290,7 +290,7 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 |---|---|---|---|---| | **S0** ✅ | 自研 `flowir/{schema,compile,hash,cond}`;`usedFallbackHash→false`;runtime 仍执行 Taskflow | hash byte-determinism + 敏感度(`flowir-*.test.ts`) | cache-key 更精准;FlowIR 成规范 | roadmap M1 剩余(自建版) | | **S1** ✅ | 加 `exec/{events,fold}`;旧 executePhase 全量 decision emit;`fold(log)` 对齐 RunState | 差分 + kill-9 rebuild 测试 | **trace 完整 + 事件溯源(F3 溶解)** | F3 / Q8 | -| **S2** 🟡 | 建 `exec/{driver,step}`;**已迁** agent→script→map→parallel;剩余 reduce/gate/loop/tournament/approval/flow | 已支持 kind 的 parity 测试;默认 OFF | 每 kind 绿了 flip 一次 | Q7 | +| **S2** ✅ | 建 `exec/{driver,step}`;**全部 10 kind**(复杂路径简化版;score/reflexion 等仍可走 imperative) | parity + s2-complete 测试;默认 OFF | 每 kind 可 flip;默认 ON 在 S5 | Q7 | | **S3** ✅ | `replayRun()` + `taskflow_replay` + `/tf replay` + 黄金 fixtures + import-lint | 未改旋钮 → 全 reused;改阈值/budget 可测 | **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 绿 | 内核替换完成 | — | diff --git a/packages/taskflow-core/src/deterministic.ts b/packages/taskflow-core/src/deterministic.ts index 3b58e89..3ccf921 100644 --- a/packages/taskflow-core/src/deterministic.ts +++ b/packages/taskflow-core/src/deterministic.ts @@ -11,7 +11,7 @@ */ import { safeParse } from "./interpolate.ts"; -import { VERDICT_TOKEN_RE } from "./scorers.ts"; +import { VERDICT_TOKEN_RE, WINNER_TOKEN_RE } from "./scorers.ts"; import { aggregateUsage, emptyUsage, type UsageStats } from "./usage.ts"; /** A gate verdict parsed from a (possibly JSON, possibly free-text) output. */ @@ -68,3 +68,24 @@ export function overBudget(input: BudgetCheckInput): { over: boolean; reason: st } return { over: false, reason: "" }; } + +/** + * Parse a tournament judge's pick. Fail-open: unreadable → variant 1. + * Shared by imperative runtime and the event kernel (must stay pure). + */ +export function parseTournamentWinner(output: string, count: number): { winner: number; reason?: string } { + const clamp = (n: number) => Math.min(Math.max(1, Math.floor(n)), Math.max(1, count)); + const json = safeParse(output); + if (json && typeof json === "object") { + const o = json as Record; + const raw = o.winner ?? o.best ?? o.choice; + const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN; + if (Number.isFinite(n)) return { winner: clamp(n), reason: asReason(o.reason) }; + } + const matches = [...output.matchAll(WINNER_TOKEN_RE)]; + if (matches.length) { + const n = Number(matches[matches.length - 1][1]); + if (Number.isFinite(n)) return { winner: clamp(n) }; + } + return { winner: 1, reason: "no parseable winner; defaulted to variant 1" }; +} diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index d58c258..adb7236 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -1,11 +1,8 @@ /** - * exec/driver — event-sourced schedule loop for agent + script (S2 strangler). + * exec/driver — event-sourced schedule loop for **all** phase kinds (S2 complete). * * **Default OFF.** Engaged when `deps.eventKernel === true` or - * `PI_TASKFLOW_EVENT_KERNEL=1`. Unsupported phase kinds fall through to the - * imperative `executeTaskflow` (caller responsibility). - * - * Does not import the body of `runtime.ts` — only shared store/schema/usage. + * `PI_TASKFLOW_EVENT_KERNEL=1`. Does not import the body of `runtime.ts`. */ import type { RunState } from "../store.ts"; @@ -15,6 +12,9 @@ import type { TraceEvent, TraceSink } from "../trace.ts"; import { EVENT_KERNEL_PHASE_TYPES, stepPhase, + type KernelApprovalDecision, + type KernelApprovalRequest, + type NestedFlowResult, type RunTaskFn, type StepContext, type StepDeps, @@ -35,8 +35,11 @@ export interface EventKernelDeps { trace?: TraceSink; persist?: (state: RunState) => void; onProgress?: (state: RunState) => void; - /** Explicit strangler switch. */ eventKernel?: boolean; + requestApproval?: (req: KernelApprovalRequest) => Promise; + loadFlow?: (name: string) => Taskflow | undefined; + /** Recursion stack for nested flow phases. */ + _stack?: string[]; } export interface EventKernelResult { @@ -46,7 +49,7 @@ export interface EventKernelResult { totalUsage: UsageStats; } -/** True when every phase is a kind the S2 kernel implements (agent|script|map|parallel). */ +/** True when every phase type is in the kernel set (all 10 kinds). */ export function canUseEventKernel(def: Taskflow): boolean { return (def.phases ?? []).every((p) => { const t = p.type ?? "agent"; @@ -54,7 +57,6 @@ export function canUseEventKernel(def: Taskflow): boolean { }); } -/** Whether the strangler event kernel should run this invocation. */ export function eventKernelEnabled(deps: { eventKernel?: boolean }): boolean { if (deps.eventKernel === true) return true; if (deps.eventKernel === false) return false; @@ -77,35 +79,119 @@ function safeTraceFlush(deps: EventKernelDeps, phaseId: string): void { } } +function resolveArgs(def: Taskflow): Record { + const args: Record = {}; + if (def.args) { + for (const [k, v] of Object.entries(def.args)) { + args[k] = + typeof v === "object" && v && v !== null && "default" in (v as object) + ? (v as { default: unknown }).default + : v; + } + } + return args; +} + /** - * Run a Taskflow on the event kernel (agent|script|map|parallel). + * Run a Taskflow on the event kernel (all phase kinds). * Caller must have checked {@link canUseEventKernel}. */ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Promise { const def = state.def; const layers = topoLayers(def.phases); const steps: StepContext["steps"] = {}; - const args: Record = {}; - if (def.args) { - for (const [k, v] of Object.entries(def.args)) { - args[k] = typeof v === "object" && v && v !== null && "default" in (v as object) ? (v as { default: unknown }).default : v; - } - } + const args = { ...resolveArgs(def), ...state.args }; state.status = "running"; const allEvents: Event[] = []; + let gateBlocked = false; + let gateReason = ""; + + const runNested = async (opts: { + def: Taskflow; + args: Record; + stack: string[]; + }): Promise => { + const childState: RunState = { + runId: `${state.runId}/nested-${opts.def.name}`, + flowName: opts.def.name, + def: opts.def, + args: opts.args, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: deps.cwd, + }; + const child = await runEventKernel(childState, { + ...deps, + _stack: opts.stack, + }); + // Collect events from child phases via fold of traces — child already emitted + // to same trace sink; nested events are those just emitted. Return empty mid + // events list for parent phase (sink already has them); usage/output matter. + return { + finalOutput: child.finalOutput, + ok: child.ok, + usage: child.totalUsage, + events: [], + blocked: child.state.status === "blocked", + }; + }; + const stepDeps: StepDeps = { cwd: deps.cwd, agents: deps.agents, runTask: deps.runTask, signal: deps.signal, globalThinking: deps.globalThinking, + requestApproval: deps.requestApproval, + loadFlow: deps.loadFlow, + stack: deps._stack ?? [], + runNested, }; for (const layer of layers) { if (deps.signal?.aborted) break; for (const phase of layer) { if (deps.signal?.aborted) break; + if (gateBlocked) { + const startedAt = Date.now(); + const skipErr = gateReason || "Upstream gate blocked"; + const skipEvents: Event[] = [ + { + v: EVENT_SCHEMA_VERSION, + ts: startedAt, + runId: state.runId, + phaseId: phase.id, + kind: "phase-start", + }, + { + v: EVENT_SCHEMA_VERSION, + ts: Date.now(), + runId: state.runId, + phaseId: phase.id, + kind: "phase-end", + status: "skipped", + error: skipErr, + }, + ]; + for (const e of skipEvents) { + allEvents.push(e); + safeTraceEmit(deps, e); + } + safeTraceFlush(deps, phase.id); + state.phases[phase.id] = { + id: phase.id, + status: "skipped", + error: skipErr, + startedAt, + endedAt: Date.now(), + usage: emptyUsage(), + }; + continue; + } + const depsOk = (phase.dependsOn ?? []).every((d) => { const s = state.phases[d]?.status; return s === "done" || s === "skipped"; @@ -113,7 +199,6 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr if (!depsOk) { const startedAt = Date.now(); const skipErr = "Upstream dependency not satisfied"; - // Synthetic lifecycle so fold/replay see a complete phase. const skipEvents: Event[] = [ { v: EVENT_SCHEMA_VERSION, @@ -168,8 +253,6 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr } safeTraceFlush(deps, phase.id); - // PhaseStatus is pending|running|done|failed|skipped; timeouts are - // failed + timedOut:true (matches the imperative runtime). const st = result.status === "skipped" ? "skipped" @@ -184,10 +267,19 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr startedAt, endedAt: Date.now(), usage: result.usage ?? emptyUsage(), + gate: result.gate, + approval: result.approval, ...(result.status === "timedOut" ? { timedOut: true as const } : {}), }; if (result.status === "done" && result.output !== undefined) { - steps[phase.id] = { output: result.output }; + steps[phase.id] = { + output: result.output, + json: undefined, + }; + } + if (result.gate?.verdict === "block") { + gateBlocked = true; + gateReason = result.gate.reason || "Gate blocked"; } try { deps.persist?.(state); @@ -206,13 +298,11 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr for (const [id, ps] of Object.entries(state.phases)) { const f = folded.phases[id]; if (f && f.status !== "pending" && f.status !== "running") { - // Map timedOut on fold to failed-like for comparison — fold uses timedOut status const foldStatus = f.status; if (ps.status === "done" && foldStatus === "done") continue; if (ps.status === "failed" && foldStatus === "failed") continue; if (ps.status === "skipped" && foldStatus === "skipped") continue; if (ps.timedOut && foldStatus === "timedOut") continue; - // soft warn only if (String(ps.status) !== String(foldStatus)) { console.warn(`[taskflow] event-kernel fold status drift phase=${id} state=${ps.status} fold=${foldStatus}`); } @@ -222,8 +312,17 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr const failed = Object.values(state.phases).some((p) => p.status === "failed" || p.timedOut); const finals = def.phases.filter((p) => p.final); const finalPhase = finals[finals.length - 1] ?? def.phases[def.phases.length - 1]; - const finalOutput = finalPhase ? (state.phases[finalPhase.id]?.output ?? "") : ""; - state.status = failed ? "failed" : deps.signal?.aborted ? "paused" : "completed"; + let finalOutput = finalPhase ? (state.phases[finalPhase.id]?.output ?? "") : ""; + if (gateBlocked) { + finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${finalOutput ? `\n\n${finalOutput}` : ""}`; + } + state.status = failed + ? "failed" + : deps.signal?.aborted + ? "paused" + : gateBlocked + ? "blocked" + : "completed"; const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage())); try { deps.persist?.(state); diff --git a/packages/taskflow-core/src/exec/step-kinds.ts b/packages/taskflow-core/src/exec/step-kinds.ts new file mode 100644 index 0000000..2503642 --- /dev/null +++ b/packages/taskflow-core/src/exec/step-kinds.ts @@ -0,0 +1,524 @@ +/** + * Event-kernel handlers for remaining phase kinds (S2 completion): + * reduce, gate, approval, loop, tournament, flow. + * + * Intentionally does not import runtime.ts. Nested flows go through + * `StepDeps.runNested` (injected by driver). + */ + +import type { Phase, Taskflow } from "../schema.ts"; +import { + LOOP_DEFAULT_MAX_ITERATIONS, + LOOP_HARD_MAX_ITERATIONS, + TOURNAMENT_DEFAULT_VARIANTS, + TOURNAMENT_HARD_MAX_VARIANTS, + validateTaskflow, +} from "../schema.ts"; +import { parseGateVerdict, parseTournamentWinner } from "../deterministic.ts"; +import { aggregateUsage, emptyUsage, type UsageStats } from "../usage.ts"; +import { evaluateCondition, interpolate, safeParse, type InterpolationContext } from "../interpolate.ts"; +import { mapWithConcurrencyLimit } from "../runner-core.ts"; +import type { Event } from "./events.ts"; +import { EVENT_SCHEMA_VERSION } from "./events.ts"; +import type { StepContext, StepResult } from "./step.ts"; +import type { RunResult } from "../host/runner-types.ts"; + +type BodyResult = { + midEvents: Event[]; + output?: string; + status: StepResult["status"]; + error?: string; + usage: UsageStats; + gate?: { verdict: "pass" | "block"; reason?: string }; + approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean }; +}; + +function baseEvent( + ctx: StepContext, + phaseId: string, + kind: Event["kind"], + extra: Partial = {}, +): Event { + return { + v: EVENT_SCHEMA_VERSION, + ts: Date.now(), + runId: ctx.state.runId, + phaseId, + kind, + ...extra, + }; +} + +function isFailedResult(r: RunResult): boolean { + return (r.exitCode ?? 0) !== 0 || !!r.errorMessage || r.stopReason === "error" || r.stopReason === "aborted"; +} + +async function runAgentCall( + ctx: StepContext, + phase: Phase, + agentName: string, + task: string, + nodePath: string, + mapIndex?: number, + variantIndex?: number, +): Promise<{ result: RunResult; event: Event }> { + const r = await ctx.deps.runTask( + ctx.deps.cwd, + ctx.deps.agents, + agentName, + task, + { + model: phase.model, + thinking: phase.thinking, + tools: phase.tools, + cwd: ctx.deps.cwd, + signal: ctx.deps.signal, + }, + ctx.deps.globalThinking, + ); + const usage = r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage(); + const event = baseEvent(ctx, phase.id, "subagent-call", { + input: { + agent: agentName, + model: phase.model, + task, + nodePath, + mapIndex, + variantIndex, + }, + output: { + text: r.output ?? "", + model: r.model, + usage, + stopReason: r.stopReason, + }, + }); + return { result: { ...r, usage }, event }; +} + +function interpCtx(ctx: StepContext, extra?: Partial): InterpolationContext { + return { args: ctx.args, steps: ctx.steps, ...extra }; +} + +/** reduce / agent-style single call. */ +export async function executeReduceBody(phase: Phase, ctx: StepContext): Promise { + const task = interpolate(phase.task ?? "", interpCtx(ctx)).text; + const agentName = phase.agent ?? "executor"; + try { + const { result: r, event } = await runAgentCall(ctx, phase, agentName, task, phase.id); + const failed = isFailedResult(r); + return { + midEvents: [event], + output: r.output, + status: r.phaseTimeout ? "timedOut" : failed ? "failed" : "done", + error: failed ? r.errorMessage ?? r.stderr : undefined, + usage: r.usage ?? emptyUsage(), + }; + } catch (e) { + return { + midEvents: [], + status: "failed", + error: e instanceof Error ? e.message : String(e), + usage: emptyUsage(), + }; + } +} + +/** Gate: LLM judge + parseGateVerdict (score/eval advanced paths stay on imperative). */ +export async function executeGateBody(phase: Phase, ctx: StepContext): Promise { + // Zero-token eval auto-pass when all eval conditions are truthy. + if (Array.isArray(phase.eval) && phase.eval.length > 0) { + const ctxI = interpCtx(ctx); + const allPass = phase.eval.every((e) => evaluateCondition(String(e), ctxI)); + if (allPass) { + return { + midEvents: [ + baseEvent(ctx, phase.id, "decision", { + decision: { type: "gate-verdict", value: "pass", reason: "eval checks passed" }, + }), + ], + output: "PASS (eval checks passed — no LLM call)", + status: "done", + usage: emptyUsage(), + gate: { verdict: "pass", reason: "eval checks passed" }, + }; + } + } + + let task = interpolate(phase.task ?? "", interpCtx(ctx)).text; + if (phase.output !== "json" || !phase.expect) { + if (!/VERDICT\s*[:=]/i.test(task)) { + task = + `${task}\n\n--- Required output format ---\n` + + `End your response with exactly one line:\nVERDICT: PASS\nor\nVERDICT: BLOCK`; + } + } + const agentName = phase.agent ?? "executor"; + try { + const { result: r, event } = await runAgentCall(ctx, phase, agentName, task, phase.id); + const failed = isFailedResult(r); + if (failed) { + return { + midEvents: [event], + output: r.output, + status: r.phaseTimeout ? "timedOut" : "failed", + error: r.errorMessage ?? r.stderr, + usage: r.usage ?? emptyUsage(), + }; + } + const v = parseGateVerdict(r.output ?? ""); + const midEvents: Event[] = [ + event, + baseEvent(ctx, phase.id, "decision", { + decision: { type: "gate-verdict", value: v.verdict, reason: v.reason }, + }), + ]; + return { + midEvents, + output: r.output, + status: "done", + usage: r.usage ?? emptyUsage(), + gate: { verdict: v.verdict, reason: v.reason }, + }; + } catch (e) { + return { + midEvents: [], + status: "failed", + error: e instanceof Error ? e.message : String(e), + usage: emptyUsage(), + }; + } +} + +/** Approval: interactive or auto-reject (fail-open, matches runtime). */ +export async function executeApprovalBody(phase: Phase, ctx: StepContext): Promise { + const message = interpolate(phase.task ?? "Approve to continue?", interpCtx(ctx)).text; + const upstream = Object.values(ctx.steps).map((s) => s.output).filter(Boolean).at(-1); + + if (!ctx.deps.requestApproval) { + const reason = "(auto-rejected: no interactive approver available)"; + return { + midEvents: [ + baseEvent(ctx, phase.id, "decision", { + decision: { type: "gate-verdict", value: "block", reason }, + }), + ], + output: reason, + status: "done", + usage: emptyUsage(), + gate: { verdict: "block", reason }, + approval: { decision: "reject", auto: true }, + }; + } + + const decision = await ctx.deps.requestApproval({ + phaseId: phase.id, + message, + upstream, + }); + const note = decision.note?.trim(); + const output = note || `(${decision.decision})`; + const gate = + decision.decision === "reject" + ? { verdict: "block" as const, reason: note || "Rejected by user" } + : undefined; + const midEvents: Event[] = gate + ? [ + baseEvent(ctx, phase.id, "decision", { + decision: { type: "gate-verdict", value: "block", reason: gate.reason }, + }), + ] + : []; + return { + midEvents, + output, + status: "done", + usage: emptyUsage(), + gate, + approval: { decision: decision.decision, note }, + }; +} + +/** Loop: until / convergence / maxIterations (no reflexion — imperative for that). */ +export async function executeLoopBody(phase: Phase, ctx: StepContext): Promise { + const agentName = phase.agent ?? "executor"; + const rawMax = phase.maxIterations ?? LOOP_DEFAULT_MAX_ITERATIONS; + const maxIters = Math.max(1, Math.min(LOOP_HARD_MAX_ITERATIONS, Math.floor(rawMax))); + const convergence = phase.convergence !== false; + const midEvents: Event[] = []; + const usages: UsageStats[] = []; + let lastOutput = ""; + let prevOutput: string | undefined; + let stop: "until" | "convergence" | "maxIterations" | "failed" | "aborted" = "maxIterations"; + + for (let i = 1; i <= maxIters; i++) { + if (ctx.deps.signal?.aborted) { + stop = "aborted"; + break; + } + const bodyCtx = interpCtx(ctx, { + locals: { + loop: { iteration: i, lastOutput, maxIterations: maxIters }, + }, + }); + // Also expose loop via steps-style: interpolate uses locals for loop.* if head is loop + const task = interpolate(phase.task ?? "", bodyCtx).text; + const { result: r, event } = await runAgentCall(ctx, phase, agentName, task, `${phase.id}#iter-${i}`, i - 1); + midEvents.push(event); + usages.push(r.usage ?? emptyUsage()); + if (isFailedResult(r)) { + return { + midEvents, + output: lastOutput || r.output, + status: r.phaseTimeout ? "timedOut" : "failed", + error: r.errorMessage ?? r.stderr ?? `loop failed at iteration ${i}`, + usage: aggregateUsage(usages), + }; + } + prevOutput = lastOutput; + lastOutput = r.output ?? ""; + if (phase.until !== undefined) { + const untilCtx = interpCtx(ctx, { + locals: { + loop: { iteration: i, lastOutput, maxIterations: maxIters }, + }, + // until often references steps..json — mirror via steps update + steps: { + ...ctx.steps, + [phase.id]: { output: lastOutput, json: safeParse(lastOutput) }, + }, + }); + if (evaluateCondition(phase.until, untilCtx)) { + stop = "until"; + break; + } + } + if (convergence && prevOutput !== undefined && prevOutput === lastOutput) { + stop = "convergence"; + break; + } + } + + void stop; + return { + midEvents, + output: lastOutput, + status: "done", + usage: aggregateUsage(usages), + }; +} + +/** Tournament: N variants + judge. */ +export async function executeTournamentBody(phase: Phase, ctx: StepContext): Promise { + const mode = phase.mode === "aggregate" ? "aggregate" : "best"; + const ctxI = interpCtx(ctx); + let competitors: Array<{ agent: string; task: string }>; + if (phase.branches && phase.branches.length > 0) { + competitors = phase.branches.map((b) => ({ + agent: b.agent ?? phase.agent ?? "executor", + task: interpolate(b.task, ctxI).text, + })); + } else { + const n = Math.max(2, Math.min(TOURNAMENT_HARD_MAX_VARIANTS, Math.floor(phase.variants ?? TOURNAMENT_DEFAULT_VARIANTS))); + const body = interpolate(phase.task ?? "", ctxI).text; + const agent = phase.agent ?? "executor"; + competitors = Array.from({ length: n }, () => ({ agent, task: body })); + } + + const concurrency = typeof phase.concurrency === "number" && phase.concurrency > 0 ? phase.concurrency : 8; + const midEvents: Event[] = []; + const results = await mapWithConcurrencyLimit(competitors, concurrency, async (it, idx) => { + const { result, event } = await runAgentCall( + ctx, + phase, + it.agent, + it.task, + `${phase.id}#variant-${idx + 1}`, + undefined, + idx + 1, + ); + midEvents.push(event); + return result; + }); + midEvents.sort((a, b) => (a.input?.variantIndex ?? 0) - (b.input?.variantIndex ?? 0)); + + const ok = results.filter((r) => !isFailedResult(r)); + const usage = aggregateUsage(results.map((r) => r.usage ?? emptyUsage())); + if (ok.length === 0) { + return { + midEvents, + status: "failed", + error: `tournament '${phase.id}': all ${competitors.length} variants failed`, + usage, + }; + } + if (ok.length === 1) { + const winnerIdx = results.indexOf(ok[0]) + 1; + midEvents.push( + baseEvent(ctx, phase.id, "decision", { + decision: { type: "tournament-winner", value: winnerIdx, reason: "only surviving variant" }, + }), + ); + return { midEvents, output: ok[0].output, status: "done", usage }; + } + + const labelled = results + .map((r, i) => `### Variant ${i + 1}${isFailedResult(r) ? " (failed)" : ""}\n\n${r.output}`) + .join("\n\n---\n\n"); + const rubric = + interpolate(phase.judge ?? "", ctxI).text.trim() || + "Pick the single best variant on correctness, completeness, and clarity."; + const directive = + mode === "best" + ? `End with: WINNER: (1–${results.length})` + : `Synthesize the best answer, then end with: WINNER: `; + const judgeTask = `${rubric}\n\n${labelled}\n\n${directive}`; + const judgeAgent = phase.judgeAgent ?? phase.agent ?? "executor"; + const { result: judgeRes, event: judgeEv } = await runAgentCall( + ctx, + phase, + judgeAgent, + judgeTask, + `${phase.id}#judge`, + ); + midEvents.push(judgeEv); + const totalUsage = aggregateUsage([usage, judgeRes.usage ?? emptyUsage()]); + + if (isFailedResult(judgeRes)) { + midEvents.push( + baseEvent(ctx, phase.id, "decision", { + decision: { type: "tournament-winner", value: results.indexOf(ok[0]) + 1, reason: "judge failed" }, + }), + ); + return { midEvents, output: ok[0].output, status: "done", usage: totalUsage }; + } + + const { winner, reason } = parseTournamentWinner(judgeRes.output ?? "", results.length); + const winnerResult = results[winner - 1]; + const chosen = !winnerResult || isFailedResult(winnerResult) ? ok[0] : winnerResult; + const winnerIdx = results.indexOf(chosen) + 1; + const output = mode === "aggregate" ? judgeRes.output : chosen.output; + midEvents.push( + baseEvent(ctx, phase.id, "decision", { + decision: { type: "tournament-winner", value: winnerIdx, reason }, + }), + ); + return { midEvents, output, status: "done", usage: totalUsage }; +} + +/** Nested flow: saved `use` or inline `def` via StepDeps.runNested. */ +export async function executeFlowBody(phase: Phase, ctx: StepContext): Promise { + const hasDef = (phase as { def?: unknown }).def !== undefined; + const stack = ctx.deps.stack ?? []; + let subDef: Taskflow | undefined; + let recursionKey: string; + + if (hasDef) { + const rawDef = (phase as { def?: unknown }).def; + let parsed: unknown; + if (typeof rawDef === "string") { + parsed = safeParse(interpolate(rawDef, interpCtx(ctx)).text); + if (parsed === undefined) { + return { + midEvents: [], + output: "", + status: "done", + usage: emptyUsage(), + error: undefined, + }; + } + } else { + parsed = rawDef; + } + // Normalize + let wrapped: Taskflow | undefined; + if (Array.isArray(parsed)) { + wrapped = { name: `${phase.id}-inline`, phases: parsed as Taskflow["phases"] }; + } else if (parsed && typeof parsed === "object" && Array.isArray((parsed as Taskflow).phases)) { + const o = parsed as Taskflow; + wrapped = { ...o, name: o.name || `${phase.id}-inline` }; + } + if (!wrapped) { + return { midEvents: [], output: "", status: "done", usage: emptyUsage() }; + } + if (wrapped.phases.length === 0) { + return { midEvents: [], output: "", status: "done", usage: emptyUsage() }; + } + const v = validateTaskflow(wrapped); + if (!v.ok) { + return { midEvents: [], output: "", status: "done", usage: emptyUsage() }; + } + subDef = wrapped; + recursionKey = `def:${subDef.name}`; + } else { + const useName = phase.use; + if (!useName) { + return { + midEvents: [], + status: "failed", + error: `flow phase '${phase.id}' requires 'use' or 'def'`, + usage: emptyUsage(), + }; + } + if (!ctx.deps.loadFlow) { + return { + midEvents: [], + status: "failed", + error: `flow phase '${phase.id}': no sub-flow loader available`, + usage: emptyUsage(), + }; + } + subDef = ctx.deps.loadFlow(useName); + if (!subDef) { + return { + midEvents: [], + status: "failed", + error: `flow phase '${phase.id}': saved flow not found: '${useName}'`, + usage: emptyUsage(), + }; + } + recursionKey = useName; + } + + if (recursionKey === ctx.state.flowName || stack.includes(recursionKey)) { + return { + midEvents: [], + status: "failed", + error: `flow phase '${phase.id}': recursive sub-flow`, + usage: emptyUsage(), + }; + } + + if (!ctx.deps.runNested) { + return { + midEvents: [], + status: "failed", + error: `flow phase '${phase.id}': nested runner not available`, + usage: emptyUsage(), + }; + } + + // Resolve `with` args + const provided: Record = {}; + const withMap = phase.with; + if (withMap && typeof withMap === "object") { + for (const [k, v] of Object.entries(withMap)) { + provided[k] = typeof v === "string" ? interpolate(v, interpCtx(ctx)).text : v; + } + } + + const nested = await ctx.deps.runNested({ + def: subDef, + args: provided, + stack: [...stack, recursionKey], + }); + + return { + midEvents: nested.events, + output: nested.finalOutput, + status: nested.ok ? "done" : "failed", + error: nested.ok ? undefined : nested.finalOutput || "sub-flow failed", + usage: nested.usage, + gate: nested.blocked ? { verdict: "block", reason: "sub-flow blocked" } : undefined, + }; +} diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index 4d8ecb3..c9603bd 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -1,16 +1,14 @@ /** * exec/step — per-node handlers for the event-sourced kernel (RFC §6.3, S2). * - * Supported kinds (progressive strangler): **agent**, **script**, **map**, - * **parallel**. Each handler executes one node and returns events to append - * (it does not mutate RunState — fold does that). - * - * Deliberately does **not** import `runtime.ts` (avoids circular deps with the - * strangler switch that imports this package). + * **All 10 phase types** (agent|script|map|parallel|reduce|gate|approval|loop| + * tournament|flow). Complex kinds live in `./step-kinds.ts`. Does **not** + * import `runtime.ts` (avoids circular deps with the strangler). */ import { spawn } from "node:child_process"; -import type { Phase } from "../schema.ts"; +import type { Phase, Taskflow } from "../schema.ts"; +import { PHASE_TYPES } from "../schema.ts"; import type { RunState } from "../store.ts"; import type { AgentConfig } from "../agents.ts"; import type { RunOptions, RunResult } from "../host/runner-types.ts"; @@ -25,9 +23,17 @@ import { type InterpolationContext, } from "../interpolate.ts"; import { mapWithConcurrencyLimit } from "../runner-core.ts"; +import { + executeApprovalBody, + executeFlowBody, + executeGateBody, + executeLoopBody, + executeReduceBody, + executeTournamentBody, +} from "./step-kinds.ts"; -/** Phase types the S2 event kernel can execute. Expand one kind at a time. */ -export const EVENT_KERNEL_PHASE_TYPES = ["agent", "script", "map", "parallel"] as const; +/** All DSL phase types — S2 kernel is complete. */ +export const EVENT_KERNEL_PHASE_TYPES = PHASE_TYPES; export type EventKernelPhaseType = (typeof EVENT_KERNEL_PHASE_TYPES)[number]; export type RunTaskFn = ( @@ -39,12 +45,41 @@ export type RunTaskFn = ( globalThinking?: string, ) => Promise; +/** Mirrors runtime ApprovalRequest — kept local to avoid step↔runtime cycles. */ +export interface KernelApprovalRequest { + phaseId: string; + message: string; + upstream?: string; +} +export interface KernelApprovalDecision { + decision: "approve" | "reject" | "edit"; + note?: string; +} + +export interface NestedFlowResult { + finalOutput: string; + ok: boolean; + usage: UsageStats; + events: Event[]; + blocked?: boolean; +} + export interface StepDeps { cwd: string; agents: AgentConfig[]; runTask: RunTaskFn; signal?: AbortSignal; globalThinking?: string; + requestApproval?: (req: KernelApprovalRequest) => Promise; + loadFlow?: (name: string) => Taskflow | undefined; + /** Sub-flow call stack (recursion guard). */ + stack?: string[]; + /** Nested flow runner (driver injects runEventKernel). */ + runNested?: (opts: { + def: Taskflow; + args: Record; + stack: string[]; + }) => Promise; } export interface StepContext { @@ -61,6 +96,8 @@ export interface StepResult { error?: string; /** Token/cost usage for this phase (empty for script / skipped). */ usage: UsageStats; + gate?: { verdict: "pass" | "block"; reason?: string }; + approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean }; } type BodyResult = { @@ -69,6 +106,8 @@ type BodyResult = { status: StepResult["status"]; error?: string; usage: UsageStats; + gate?: StepResult["gate"]; + approval?: StepResult["approval"]; }; function baseEvent( @@ -399,16 +438,29 @@ export async function stepPhase(phase: Phase, ctx: StepContext): Promise ({ import { aggregateUsage, emptyUsage, type UsageStats } from "./usage.ts"; import { type Budget, type CacheScope, dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, parseTtlMs, type Phase, resolveArgs, type Taskflow, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, type TournamentMode, validateTaskflow } from "./schema.ts"; import { verifyTaskflow } from "./verify.ts"; -import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, type ScoreConfig, scoreResultJSON, type ScorerResult, scorerShapeErrors, WINNER_TOKEN_RE } from "./scorers.ts"; -import { parseGateVerdict, overBudget as overBudgetCheck, type BudgetCheckInput } from "./deterministic.ts"; +import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, type ScoreConfig, scoreResultJSON, type ScorerResult, scorerShapeErrors } from "./scorers.ts"; +import { parseGateVerdict, overBudget as overBudgetCheck, parseTournamentWinner, type BudgetCheckInput } from "./deterministic.ts"; +export { parseTournamentWinner } from "./deterministic.ts"; import { type TraceEvent, type TraceSink } from "./trace.ts"; // Re-export so existing `import { parseGateVerdict } from "./runtime.ts"` callers // (and tests) keep working; the implementation now lives in deterministic.ts. @@ -92,8 +93,8 @@ export interface RuntimeDeps { * identically to today). See `trace.ts`. */ trace?: TraceSink; /** - * S2 strangler: when true, agent|script|map|parallel-only flows run on - * `exec/driver` (event kernel). Default false; also set `PI_TASKFLOW_EVENT_KERNEL=1`. + * S2 strangler: when true, flows run on `exec/driver` (event kernel; all + * 10 phase kinds). Default false; also set `PI_TASKFLOW_EVENT_KERNEL=1`. */ eventKernel?: boolean; /** Internal: sub-flow call stack, for recursion detection. */ @@ -2788,13 +2789,8 @@ function defaultAgent(deps: RuntimeDeps): string { * JSON verdict object whose value is non-blocking (e.g. `{"verdict":"No issues * found"}`) is an *explicit* pass, not ambiguity, and still resolves to pass. */ -// `parseGateVerdict` is implemented in `deterministic.ts` (a pure seam a -// future replay can import without dragging in the process-spawning runner). -// It is imported at the top of this file and re-exported from the barrel. - -function asReason(v: unknown): string | undefined { - return typeof v === "string" && v.trim() ? v.trim() : undefined; -} +// `parseGateVerdict` / `parseTournamentWinner` live in `deterministic.ts` +// (pure seam for replay + event kernel). Re-exported via the barrel. /** * If a gate phase relies on free-text verdict parsing (no `output:"json"` + @@ -2819,28 +2815,7 @@ function appendGateFormatSuffix(task: string, phase: Phase): string { ); } -/** - * Parse a judge's pick of the winning variant. Accepts JSON ({"winner":n} or - * {"best":n}) or a `WINNER: n` line (last match wins). Clamps to [1, count]. - * Fail-open: an unreadable verdict defaults to variant 1 so the work is never - * lost. Returns the 1-based index plus an optional reason. - */ -export function parseTournamentWinner(output: string, count: number): { winner: number; reason?: string } { - const clamp = (n: number) => Math.min(Math.max(1, Math.floor(n)), Math.max(1, count)); - const json = safeParse(output); - if (json && typeof json === "object") { - const o = json as Record; - const raw = o.winner ?? o.best ?? o.choice; - const n = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN; - if (Number.isFinite(n)) return { winner: clamp(n), reason: asReason(o.reason) }; - } - const matches = [...output.matchAll(WINNER_TOKEN_RE)]; - if (matches.length) { - const n = Number(matches[matches.length - 1][1]); - if (Number.isFinite(n)) return { winner: clamp(n) }; - } - return { winner: 1, reason: "no parseable winner; defaulted to variant 1" }; -} +/* parseTournamentWinner lives in deterministic.ts (shared with event kernel). */ /** * Best-effort invocation of the user-provided `persist` + `onProgress` callbacks. @@ -3127,7 +3102,7 @@ export async function recomputeTaskflow( export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promise { const def: Taskflow = state.def; try { - // S2 strangler (default OFF): agent|script|map|parallel flows may use the event kernel. + // S2 strangler (default OFF): all phase kinds may use the event kernel when enabled. const { eventKernelEnabled, canUseEventKernel, runEventKernel } = await import("./exec/driver.ts"); if (eventKernelEnabled(deps) && canUseEventKernel(def)) { if (!deps.runTask) { @@ -3143,6 +3118,9 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi persist: deps.persist, onProgress: deps.onProgress, eventKernel: deps.eventKernel, + requestApproval: deps.requestApproval, + loadFlow: deps.loadFlow, + _stack: deps._stack, }); } return await runTaskflowLayers(state, deps); diff --git a/packages/taskflow-core/test/exec-driver.test.ts b/packages/taskflow-core/test/exec-driver.test.ts index ab41552..9f51727 100644 --- a/packages/taskflow-core/test/exec-driver.test.ts +++ b/packages/taskflow-core/test/exec-driver.test.ts @@ -1,5 +1,5 @@ /** - * S2 event-kernel driver tests (agent|script|map|parallel slice, default OFF). + * S2 event-kernel driver tests (all phase kinds, default OFF). */ import assert from "node:assert/strict"; import { test } from "node:test"; @@ -57,7 +57,7 @@ test("eventKernelEnabled: default off; true flag or env enables", () => { } }); -test("canUseEventKernel: agent|script|map|parallel; rejects gate", () => { +test("canUseEventKernel: all kinds including gate", () => { assert.equal( canUseEventKernel({ name: "ok", @@ -77,13 +77,12 @@ test("canUseEventKernel: agent|script|map|parallel; rejects gate", () => { ); assert.equal( canUseEventKernel({ - name: "no", + name: "gate-ok", phases: [{ id: "g", type: "gate", agent: "a", task: "t", final: true }], }), - false, + true, ); }); - test("event kernel: script phase captures stdout (zero tokens)", async () => { const def: Taskflow = { name: "ek-script", diff --git a/packages/taskflow-core/test/exec-kernel-parity.test.ts b/packages/taskflow-core/test/exec-kernel-parity.test.ts index 51f3f7b..759ba46 100644 --- a/packages/taskflow-core/test/exec-kernel-parity.test.ts +++ b/packages/taskflow-core/test/exec-kernel-parity.test.ts @@ -62,7 +62,7 @@ async function runBoth(def: Taskflow) { return { kernel, imp }; } -test("canUseEventKernel: map+parallel accepted; gate rejected", () => { +test("canUseEventKernel: map+parallel+gate all accepted (S2 complete)", () => { assert.equal( canUseEventKernel({ name: "ok", @@ -75,10 +75,10 @@ test("canUseEventKernel: map+parallel accepted; gate rejected", () => { ); assert.equal( canUseEventKernel({ - name: "no", + name: "gate-ok", phases: [{ id: "g", type: "gate", agent: "a", task: "judge", final: true }], }), - false, + true, ); }); diff --git a/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts new file mode 100644 index 0000000..1c3f4c1 --- /dev/null +++ b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts @@ -0,0 +1,287 @@ +/** + * S2 complete: event kernel handles all 10 phase kinds (parity smoke + enablement). + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import type { RunOptions, RunResult } from "../src/runner-core.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { canUseEventKernel, EVENT_KERNEL_PHASE_TYPES } from "../src/exec/driver.ts"; +import { PHASE_TYPES } from "../src/schema.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "a", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mkState(def: Taskflow, runId = "s2"): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: process.cwd(), + }; +} + +function runner(fn: (task: string) => string): RuntimeDeps["runTask"] { + return async (_c, _a, agent, task, _o: RunOptions): Promise => ({ + agent, + task, + exitCode: 0, + output: fn(task), + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.001, turns: 1 }, + stopReason: "end", + }); +} + +async function withKernel(def: Taskflow, runTask: RuntimeDeps["runTask"], extra: Partial = {}) { + return executeTaskflow(mkState(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask, + persist: () => {}, + eventKernel: true, + ...extra, + }); +} + +test("EVENT_KERNEL_PHASE_TYPES covers all PHASE_TYPES", () => { + assert.deepEqual([...EVENT_KERNEL_PHASE_TYPES].sort(), [...PHASE_TYPES].sort()); +}); + +test("canUseEventKernel: all 10 kinds accepted", () => { + assert.equal( + canUseEventKernel({ + name: "all", + phases: PHASE_TYPES.map((type, i) => { + const base: Record = { id: `p${i}`, type, final: i === PHASE_TYPES.length - 1 }; + if (type === "script") base.run = ["node", "-e", "1"]; + if (type === "map") { + base.over = '["x"]'; + base.task = "{item}"; + base.agent = "a"; + } + if (type === "parallel") base.branches = [{ task: "t", agent: "a" }]; + if (type === "gate" || type === "agent" || type === "reduce" || type === "loop" || type === "tournament") { + base.task = "t"; + base.agent = "a"; + } + if (type === "loop") base.maxIterations = 1; + if (type === "tournament") base.variants = 2; + if (type === "approval") base.task = "ok?"; + if (type === "flow") base.use = "child"; + return base; + }) as Taskflow["phases"], + }), + true, + ); +}); + +test("kernel: reduce aggregates via agent", async () => { + const def: Taskflow = { + name: "k-reduce", + phases: [ + { id: "a", type: "agent", agent: "a", task: "part-a" }, + { id: "b", type: "agent", agent: "a", task: "part-b" }, + { + id: "r", + type: "reduce", + agent: "a", + task: "merge {steps.a.output} and {steps.b.output}", + dependsOn: ["a", "b"], + from: ["a", "b"], + final: true, + }, + ], + }; + const res = await withKernel(def, runner((t) => `OUT:${t.slice(0, 40)}`)); + assert.equal(res.ok, true); + assert.equal(res.state.phases.r.status, "done"); + assert.match(res.finalOutput, /part-a/); + assert.match(res.finalOutput, /part-b/); +}); + +test("kernel: gate BLOCK marks run blocked", async () => { + const def: Taskflow = { + name: "k-gate", + phases: [ + { id: "w", type: "agent", agent: "a", task: "work" }, + { + id: "g", + type: "gate", + agent: "a", + task: "judge", + dependsOn: ["w"], + final: true, + }, + ], + }; + const res = await withKernel(def, runner((t) => (t.includes("judge") ? "VERDICT: BLOCK\nbad" : "ok"))); + assert.equal(res.state.phases.g.gate?.verdict, "block"); + assert.equal(res.state.status, "blocked"); + assert.equal(res.ok, false); +}); + +test("kernel: gate PASS completes", async () => { + const def: Taskflow = { + name: "k-gate-pass", + phases: [ + { + id: "g", + type: "gate", + agent: "a", + task: "judge", + final: true, + }, + ], + }; + const res = await withKernel(def, runner(() => "looks good\nVERDICT: PASS")); + assert.equal(res.state.phases.g.gate?.verdict, "pass"); + assert.equal(res.ok, true); +}); + +test("kernel: approval auto-reject without requestApproval", async () => { + const def: Taskflow = { + name: "k-appr", + phases: [{ id: "ap", type: "approval", task: "Ship it?", final: true }], + }; + const res = await withKernel(def, runner(() => "unused")); + assert.equal(res.state.phases.ap.approval?.decision, "reject"); + assert.equal(res.state.phases.ap.approval?.auto, true); + assert.equal(res.state.status, "blocked"); +}); + +test("kernel: approval approve via requestApproval", async () => { + const def: Taskflow = { + name: "k-appr2", + phases: [{ id: "ap", type: "approval", task: "Ship?", final: true }], + }; + const res = await withKernel(def, runner(() => "unused"), { + requestApproval: async () => ({ decision: "approve", note: "lgtm" }), + }); + assert.equal(res.state.phases.ap.approval?.decision, "approve"); + assert.equal(res.finalOutput, "lgtm"); + assert.equal(res.ok, true); +}); + +test("kernel: loop until maxIterations", async () => { + let n = 0; + const def: Taskflow = { + name: "k-loop", + phases: [ + { + id: "lp", + type: "loop", + agent: "a", + task: "iter", + maxIterations: 3, + convergence: false, + final: true, + }, + ], + }; + const res = await withKernel(def, async (_c, _a, agent, task) => { + n++; + return { + agent, + task, + exitCode: 0, + output: `round-${n}`, + stderr: "", + usage: emptyUsage(), + stopReason: "end", + }; + }); + assert.equal(n, 3); + assert.equal(res.state.phases.lp.output, "round-3"); + assert.equal(res.ok, true); +}); + +test("kernel: tournament picks winner", async () => { + const def: Taskflow = { + name: "k-tour", + phases: [ + { + id: "t", + type: "tournament", + agent: "a", + task: "draft", + variants: 2, + judge: "pick best", + mode: "best", + final: true, + }, + ], + }; + let call = 0; + const res = await withKernel(def, async (_c, _a, agent, task) => { + call++; + const isJudge = task.includes("Variant"); + return { + agent, + task, + exitCode: 0, + output: isJudge ? "I prefer the second.\nWINNER: 2" : `variant-body-${call}`, + stderr: "", + usage: emptyUsage(), + stopReason: "end", + }; + }); + assert.equal(res.ok, true); + assert.match(res.finalOutput ?? "", /variant-body/); +}); + +test("kernel: flow use loads nested subflow", async () => { + const child: Taskflow = { + name: "child", + phases: [{ id: "c", type: "agent", agent: "a", task: "child-work", final: true }], + }; + const def: Taskflow = { + name: "parent", + phases: [{ id: "f", type: "flow", use: "child", final: true }], + }; + const res = await withKernel(def, runner((t) => `N:${t}`), { + loadFlow: (name) => (name === "child" ? child : undefined), + }); + assert.equal(res.ok, true); + assert.match(res.finalOutput, /child-work/); +}); + +test("kernel: mixed map+gate+reduce DAG", async () => { + const def: Taskflow = { + name: "mixed", + phases: [ + { id: "m", type: "map", agent: "a", over: '["x","y"]', task: "do {item}" }, + { + id: "g", + type: "gate", + agent: "a", + task: "check {steps.m.output}", + dependsOn: ["m"], + }, + { + id: "r", + type: "reduce", + agent: "a", + task: "sum {steps.m.output}", + dependsOn: ["g", "m"], + final: true, + }, + ], + }; + const res = await withKernel(def, runner((t) => { + if (t.includes("check")) return "VERDICT: PASS"; + return `ok:${t.slice(0, 20)}`; + })); + assert.equal(res.ok, true); + assert.equal(res.state.phases.g.gate?.verdict, "pass"); + assert.equal(res.state.phases.r.status, "done"); +}); From e96aed354c47ef6c5c35d51f73fc325df84da35b Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 14:38:57 +0800 Subject: [PATCH 23/51] fix(core): harden event kernel and point dogfood MCP at local build Address multi-agent review Blockers: dynamic flow{def} validation, recursion stack, nesting caps, budget enforcement, dep/join/optional parity, gate eval fail-safe, steps.*.json, agent timeouts, script stdout caps, and feature fall-back for score/retry/expect/reflexion. Add hardening tests. Local installed Grok plugin MCP now runs workspace dist (12 tools incl. replay). --- AGENTS.md | 2 +- CHANGELOG.md | 2 +- docs/rfc-0.2.0-architecture.md | 2 +- packages/taskflow-core/src/exec/driver.ts | 224 +++++++------- .../taskflow-core/src/exec/kernel-policy.ts | 83 +++++ packages/taskflow-core/src/exec/step-kinds.ts | 155 ++++++++-- packages/taskflow-core/src/exec/step.ts | 109 +++++-- .../test/exec-kernel-hardening.test.ts | 288 ++++++++++++++++++ .../test/exec-kernel-s2-complete.test.ts | 2 +- 9 files changed, 709 insertions(+), 158 deletions(-) create mode 100644 packages/taskflow-core/src/exec/kernel-policy.ts create mode 100644 packages/taskflow-core/test/exec-kernel-hardening.test.ts diff --git a/AGENTS.md b/AGENTS.md index 84e71cb..4fb26ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -142,7 +142,7 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil - **FlowIR (S0):** `compileTaskflowToIR` → genuine `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`; `usedFallbackHash: false` when IR is content-addressable. - **Trace:** every run may record `runs//.trace.jsonl` via `RuntimeDeps.trace` (`FileTraceSink`). Decisions include gate/when/cache/budget/tournament/unreplayable. -- **Event kernel (S2 complete, default OFF):** set `RuntimeDeps.eventKernel: true` or `PI_TASKFLOW_EVENT_KERNEL=1`. All 10 phase kinds run on `exec/driver` when enabled; imperative path remains the default until S5. +- **Event kernel (S2 complete, default OFF):** set `RuntimeDeps.eventKernel: true` or `PI_TASKFLOW_EVENT_KERNEL=1`. All 10 kinds run on `exec/driver` when enabled **unless** the flow uses unsupported advanced features (score gates, `onBlock:retry`, reflexion, retry, expect, cross-run cache, shareContext) — those fall back to the imperative path. Budget, join/optional deps, dynamic `flow{def}` hardening, and agent timeouts are enforced on the kernel path. - **Offline replay (S3, zero tokens):** `replayRun(events, overrides)` in `replay.ts` — **must not** import `runtime` / `exec/driver` / `exec/step` (guarded by `replay-import-lint.test.ts`). Surfaces: pi `action=replay` + `/tf replay`; MCP `taskflow_replay`. Distinct from **resume** / **recompute** (those re-execute live phases). - **MCP roster (12):** `taskflow_run|list|show|verify|compile|peek|trace|replay|why_stale|recompute|save|search`. diff --git a/CHANGELOG.md b/CHANGELOG.md index ed49a18..1dd08f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - `exec/fold.ts` — pure `foldEvents(log) →` per-phase snapshot (S1 differential building block). - `replay.ts` — implements `replayRun(log, overrides)` (threshold / budget / model / args knobs; zero tokens; no import of runtime/driver). - **S1 decision coverage:** runtime emits `gate-score` / `gate-verdict`, `when-guard`, `cache-hit`, `tournament-winner`, `budget-hit`, plus synthetic phase lifecycle for budget/dep skips. Fold differential tests pin fold ↔ RunState agreement. - - **S2 event kernel (strangler, default OFF, all 10 kinds):** `exec/step.ts` + `step-kinds.ts` + `exec/driver.ts` run **every** phase type (agent|script|map|parallel|reduce|gate|approval|loop|tournament|flow) when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative path remains default. Nested `flow` via recursive kernel; gate block → run `blocked`; approval auto-reject without UI. + - **S2 event kernel (strangler, default OFF, all 10 kinds):** `exec/step.ts` + `step-kinds.ts` + `exec/driver.ts` run **every** phase type when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative remains default. Hardening: budget enforcement + `budget-hit` events; deps/`join`/`optional` parity; gate eval fail-safe; `steps.*.json` population; agent timeout; script stdout cap; `flow{def}` dynamic validation + nesting cap + recursion stack; feature fall-back for score/retry/expect/reflexion/onBlock/cross-run cache/shareContext. - **S1 hard gate + import lint:** fold(log) rebuilds phase statuses matching RunState after a captured run (kill-9 oracle); `replay-import-lint` keeps `replay.ts` off the runtime/driver/step import graph. - **North-star slogan:** `compiled · resumable · incremental · replayable-for-what-if` (drops Qwik "not replayable" collision with deterministic replay). - **S3 replay surface:** `taskflow_replay` MCP tool; pi `action=replay` and `/tf replay [--threshold phase=n] [--budget-usd n]`; golden trace fixture under `test/fixtures/`. diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index d448f96..018e4d2 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -17,7 +17,7 @@ > |------|------|----------| > | **S0** | ✅ | `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`;`usedFallbackHash: false`(well-formed IR) | > | **S1** | ✅ | `exec/{events,fold}`;runtime 全量 decision emit;fold 差分 + kill-9 rebuild 测试 | -> | **S2** | ✅ | `exec/{step,driver}` **全部 10 kind**;默认 OFF(`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL`) | +> | **S2** | ✅ | `exec/{step,driver}` **全部 10 kind** + P0 硬化(budget/deps/eval/json/dynamic def/recursion);高级特性 fall-back;默认 OFF | > | **S3** | ✅ | `replayRun`;`taskflow_replay` MCP;pi `action=replay` + `/tf replay`;golden + import-lint | > | **S4** | ⬜ | `taskflow-dsl` 包(下一主线) | > | **S5** | ⬜ | 全 kind 差分绿 → kernel 默认 ON | diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index adb7236..a33a5ab 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -1,14 +1,17 @@ /** - * exec/driver — event-sourced schedule loop for **all** phase kinds (S2 complete). + * exec/driver — event-sourced schedule loop for all phase kinds (S2). * * **Default OFF.** Engaged when `deps.eventKernel === true` or - * `PI_TASKFLOW_EVENT_KERNEL=1`. Does not import the body of `runtime.ts`. + * `PI_TASKFLOW_EVENT_KERNEL=1`, and only when {@link canUseEventKernel} admits + * the def (no unsupported advanced features). */ import type { RunState } from "../store.ts"; -import { topoLayers, type Taskflow } from "../schema.ts"; +import { resolveArgs, topoLayers, type Taskflow } from "../schema.ts"; import { aggregateUsage, emptyUsage } from "../usage.ts"; import type { TraceEvent, TraceSink } from "../trace.ts"; +import { overBudget as overBudgetCheck } from "../deterministic.ts"; +import { safeParse } from "../interpolate.ts"; import { EVENT_KERNEL_PHASE_TYPES, stepPhase, @@ -23,8 +26,9 @@ import { foldEvents } from "./fold.ts"; import { EVENT_SCHEMA_VERSION, type Event } from "./events.ts"; import type { AgentConfig } from "../agents.ts"; import type { UsageStats } from "../usage.ts"; +import { depsSatisfied, kernelUnsupportedReason } from "./kernel-policy.ts"; -export { EVENT_KERNEL_PHASE_TYPES }; +export { EVENT_KERNEL_PHASE_TYPES, kernelUnsupportedReason }; export interface EventKernelDeps { cwd: string; @@ -38,7 +42,6 @@ export interface EventKernelDeps { eventKernel?: boolean; requestApproval?: (req: KernelApprovalRequest) => Promise; loadFlow?: (name: string) => Taskflow | undefined; - /** Recursion stack for nested flow phases. */ _stack?: string[]; } @@ -49,12 +52,14 @@ export interface EventKernelResult { totalUsage: UsageStats; } -/** True when every phase type is in the kernel set (all 10 kinds). */ +/** True when types are known AND no advanced features force imperative fall-back. */ export function canUseEventKernel(def: Taskflow): boolean { - return (def.phases ?? []).every((p) => { + const typesOk = (def.phases ?? []).every((p) => { const t = p.type ?? "agent"; return (EVENT_KERNEL_PHASE_TYPES as readonly string[]).includes(t); }); + if (!typesOk) return false; + return kernelUnsupportedReason(def) === undefined; } export function eventKernelEnabled(deps: { eventKernel?: boolean }): boolean { @@ -79,33 +84,63 @@ function safeTraceFlush(deps: EventKernelDeps, phaseId: string): void { } } -function resolveArgs(def: Taskflow): Record { - const args: Record = {}; - if (def.args) { - for (const [k, v] of Object.entries(def.args)) { - args[k] = - typeof v === "object" && v && v !== null && "default" in (v as object) - ? (v as { default: unknown }).default - : v; - } - } - return args; +function runOverBudget(state: RunState): { over: boolean; reason: string } { + const budget = state.def.budget; + if (!budget) return { over: false, reason: "" }; + return overBudgetCheck({ + maxUSD: budget.maxUSD, + maxTokens: budget.maxTokens, + usages: Object.values(state.phases).map((p) => p.usage ?? emptyUsage()), + }); +} + +function emitLifecycle( + deps: EventKernelDeps, + allEvents: Event[], + runId: string, + phaseId: string, + status: Event["status"], + error?: string, +): void { + const start: Event = { + v: EVENT_SCHEMA_VERSION, + ts: Date.now(), + runId, + phaseId, + kind: "phase-start", + }; + const end: Event = { + v: EVENT_SCHEMA_VERSION, + ts: Date.now(), + runId, + phaseId, + kind: "phase-end", + status, + error, + }; + allEvents.push(start, end); + safeTraceEmit(deps, start); + safeTraceEmit(deps, end); + safeTraceFlush(deps, phaseId); } /** - * Run a Taskflow on the event kernel (all phase kinds). + * Run a Taskflow on the event kernel. * Caller must have checked {@link canUseEventKernel}. */ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Promise { const def = state.def; const layers = topoLayers(def.phases); const steps: StepContext["steps"] = {}; - const args = { ...resolveArgs(def), ...state.args }; + const args = resolveArgs(def, state.args); + const byId = new Map(def.phases.map((p) => [p.id, p])); state.status = "running"; const allEvents: Event[] = []; let gateBlocked = false; let gateReason = ""; + let budgetBlocked = false; + let budgetReason = ""; const runNested = async (opts: { def: Taskflow; @@ -113,7 +148,8 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr stack: string[]; }): Promise => { const childState: RunState = { - runId: `${state.runId}/nested-${opts.def.name}`, + // No `/` — validateRunId rejects path separators if ever persisted. + runId: `${state.runId}-n-${opts.def.name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40)}`, flowName: opts.def.name, def: opts.def, args: opts.args, @@ -127,11 +163,11 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr ...deps, _stack: opts.stack, }); - // Collect events from child phases via fold of traces — child already emitted - // to same trace sink; nested events are those just emitted. Return empty mid - // events list for parent phase (sink already has them); usage/output matter. + const childErr = Object.values(child.state.phases) + .map((p) => p.error) + .find((e) => !!e); return { - finalOutput: child.finalOutput, + finalOutput: child.ok ? child.finalOutput : childErr || child.finalOutput || "sub-flow failed", ok: child.ok, usage: child.totalUsage, events: [], @@ -155,77 +191,35 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr if (deps.signal?.aborted) break; for (const phase of layer) { if (deps.signal?.aborted) break; - if (gateBlocked) { - const startedAt = Date.now(); - const skipErr = gateReason || "Upstream gate blocked"; - const skipEvents: Event[] = [ - { - v: EVENT_SCHEMA_VERSION, - ts: startedAt, - runId: state.runId, - phaseId: phase.id, - kind: "phase-start", - }, - { - v: EVENT_SCHEMA_VERSION, - ts: Date.now(), - runId: state.runId, - phaseId: phase.id, - kind: "phase-end", - status: "skipped", - error: skipErr, - }, - ]; - for (const e of skipEvents) { - allEvents.push(e); - safeTraceEmit(deps, e); - } - safeTraceFlush(deps, phase.id); - state.phases[phase.id] = { - id: phase.id, - status: "skipped", - error: skipErr, - startedAt, - endedAt: Date.now(), - usage: emptyUsage(), - }; - continue; + + let skipReason: string | undefined; + if (gateBlocked) skipReason = `Gate blocked${gateReason ? `: ${gateReason}` : ""}`; + else if (budgetBlocked) skipReason = `Budget exceeded${budgetReason ? `: ${budgetReason}` : ""}`; + else { + const dep = depsSatisfied(phase, state.phases, byId); + if (!dep.ok) skipReason = dep.skipReason; } - const depsOk = (phase.dependsOn ?? []).every((d) => { - const s = state.phases[d]?.status; - return s === "done" || s === "skipped"; - }); - if (!depsOk) { - const startedAt = Date.now(); - const skipErr = "Upstream dependency not satisfied"; - const skipEvents: Event[] = [ - { - v: EVENT_SCHEMA_VERSION, - ts: startedAt, - runId: state.runId, - phaseId: phase.id, - kind: "phase-start", - }, - { + if (skipReason) { + if (skipReason.startsWith("Budget exceeded")) { + budgetBlocked = true; + const be: Event = { v: EVENT_SCHEMA_VERSION, ts: Date.now(), runId: state.runId, phaseId: phase.id, - kind: "phase-end", - status: "skipped", - error: skipErr, - }, - ]; - for (const e of skipEvents) { - allEvents.push(e); - safeTraceEmit(deps, e); + kind: "decision", + decision: { type: "budget-hit", value: budgetReason || "budget", reason: skipReason }, + }; + allEvents.push(be); + safeTraceEmit(deps, be); } - safeTraceFlush(deps, phase.id); + const startedAt = Date.now(); + emitLifecycle(deps, allEvents, state.runId, phase.id, "skipped", skipReason); state.phases[phase.id] = { id: phase.id, status: "skipped", - error: skipErr, + error: skipReason, startedAt, endedAt: Date.now(), usage: emptyUsage(), @@ -259,10 +253,13 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr : result.status === "failed" || result.status === "timedOut" ? "failed" : "done"; + const phaseJson = phase.output === "json" ? safeParse(result.output ?? "") : undefined; + state.phases[phase.id] = { id: phase.id, status: st, output: result.output, + json: phaseJson, error: result.error, startedAt, endedAt: Date.now(), @@ -274,13 +271,29 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr if (result.status === "done" && result.output !== undefined) { steps[phase.id] = { output: result.output, - json: undefined, + // Always best-effort parse so {steps.X.json.field} works for JSON agents + json: phaseJson ?? safeParse(result.output), }; } if (result.gate?.verdict === "block") { gateBlocked = true; gateReason = result.gate.reason || "Gate blocked"; } + const ob = runOverBudget(state); + if (ob.over) { + budgetBlocked = true; + budgetReason = ob.reason; + const be: Event = { + v: EVENT_SCHEMA_VERSION, + ts: Date.now(), + runId: state.runId, + phaseId: phase.id, + kind: "decision", + decision: { type: "budget-hit", value: "budget", reason: ob.reason }, + }; + allEvents.push(be); + safeTraceEmit(deps, be); + } try { deps.persist?.(state); } catch { @@ -294,35 +307,38 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr } } + // Soft fold drift check const folded = foldEvents(allEvents); for (const [id, ps] of Object.entries(state.phases)) { const f = folded.phases[id]; - if (f && f.status !== "pending" && f.status !== "running") { - const foldStatus = f.status; - if (ps.status === "done" && foldStatus === "done") continue; - if (ps.status === "failed" && foldStatus === "failed") continue; - if (ps.status === "skipped" && foldStatus === "skipped") continue; - if (ps.timedOut && foldStatus === "timedOut") continue; - if (String(ps.status) !== String(foldStatus)) { - console.warn(`[taskflow] event-kernel fold status drift phase=${id} state=${ps.status} fold=${foldStatus}`); - } - } + if (!f || f.status === "pending" || f.status === "running") continue; + if (ps.timedOut && f.status === "timedOut") continue; + if (String(ps.status) === String(f.status)) continue; + if (ps.status === "done" && f.status === "blocked") continue; // fold gate intermediate + console.warn(`[taskflow] event-kernel fold status drift phase=${id} state=${ps.status} fold=${f.status}`); } - const failed = Object.values(state.phases).some((p) => p.status === "failed" || p.timedOut); + const anyFailed = Object.entries(state.phases).some( + ([id, p]) => p.status === "failed" && !byId.get(id)?.optional, + ); const finals = def.phases.filter((p) => p.final); const finalPhase = finals[finals.length - 1] ?? def.phases[def.phases.length - 1]; let finalOutput = finalPhase ? (state.phases[finalPhase.id]?.output ?? "") : ""; if (gateBlocked) { finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${finalOutput ? `\n\n${finalOutput}` : ""}`; + } else if (budgetBlocked) { + finalOutput = `Budget exceeded — run halted.${budgetReason ? `\nReason: ${budgetReason}` : ""}${finalOutput ? `\n\n${finalOutput}` : ""}`; } - state.status = failed - ? "failed" - : deps.signal?.aborted - ? "paused" - : gateBlocked - ? "blocked" + + // Match imperative priority: aborted → gate/budget blocked → failed → completed + state.status = deps.signal?.aborted + ? "paused" + : gateBlocked || budgetBlocked + ? "blocked" + : anyFailed + ? "failed" : "completed"; + const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage())); try { deps.persist?.(state); diff --git a/packages/taskflow-core/src/exec/kernel-policy.ts b/packages/taskflow-core/src/exec/kernel-policy.ts new file mode 100644 index 0000000..855db83 --- /dev/null +++ b/packages/taskflow-core/src/exec/kernel-policy.ts @@ -0,0 +1,83 @@ +/** + * Feature gates and shared helpers for the S2 event kernel. + * Keeps the kernel from silently accepting DSL it cannot implement safely. + */ + +import type { Budget, Phase, Taskflow } from "../schema.ts"; +import { dependenciesOf } from "../schema.ts"; + +/** + * If the definition needs imperative-only features, return a short reason. + * When set, executeTaskflow must NOT enter the event kernel (even if enabled). + */ +export function kernelUnsupportedReason(def: Taskflow): string | undefined { + for (const p of def.phases ?? []) { + const id = p.id; + if (p.type === "gate" && (p as { score?: unknown }).score !== undefined) { + return `phase '${id}': score gates require the imperative runtime`; + } + if (p.onBlock === "retry") { + return `phase '${id}': onBlock:retry requires the imperative runtime`; + } + if (p.reflexion === true) { + return `phase '${id}': reflexion loops require the imperative runtime`; + } + // Explicit multi-attempt retry / expect contracts: kernel lacks full policy yet. + if (p.retry && typeof p.retry === "object" && (p.retry.max ?? 0) > 0) { + return `phase '${id}': retry requires the imperative runtime`; + } + if (p.expect !== undefined) { + return `phase '${id}': expect contracts require the imperative runtime`; + } + if (p.cache && typeof p.cache === "object") { + const scope = (p.cache as { scope?: string }).scope; + if (scope === "cross-run") { + return `phase '${id}': cross-run cache requires the imperative runtime`; + } + } + if (p.shareContext === true || def.contextSharing === true) { + return `phase '${id}': Shared Context Tree requires the imperative runtime`; + } + } + return undefined; +} + +/** Dependency satisfaction matching imperative runtime (join + optional). */ +export function depsSatisfied( + phase: Phase, + phases: Record, + byId: Map, +): { ok: boolean; skipReason?: string } { + const deps = dependenciesOf(phase); + const join = phase.join ?? "all"; + const depOk = (d: string): boolean => { + const s = phases[d]?.status; + if (s === "done") return true; + if (s === "failed" && byId.get(d)?.optional) return true; + return false; + }; + if (deps.length === 0) return { ok: true }; + const ok = join === "any" ? deps.some(depOk) : deps.every(depOk); + if (ok) return { ok: true }; + return { + ok: false, + skipReason: join === "any" ? "All dependencies failed or were skipped" : "Upstream dependency not satisfied", + }; +} + +/** Clamp child sub-flow budget so it cannot raise the parent cap. */ +export function clampSubFlowBudget(sub: Taskflow, parentBudget: Budget | undefined): Taskflow { + if (!parentBudget) return sub; + const child = sub.budget; + const clamped: Budget = { + maxUSD: Math.min(child?.maxUSD ?? Infinity, parentBudget.maxUSD ?? Infinity), + maxTokens: Math.min(child?.maxTokens ?? Infinity, parentBudget.maxTokens ?? Infinity), + }; + const budget: Budget = {}; + if (Number.isFinite(clamped.maxUSD)) budget.maxUSD = clamped.maxUSD; + if (Number.isFinite(clamped.maxTokens)) budget.maxTokens = clamped.maxTokens; + return { + ...sub, + budget: budget.maxUSD === undefined && budget.maxTokens === undefined ? undefined : budget, + }; +} diff --git a/packages/taskflow-core/src/exec/step-kinds.ts b/packages/taskflow-core/src/exec/step-kinds.ts index 2503642..94d0cbd 100644 --- a/packages/taskflow-core/src/exec/step-kinds.ts +++ b/packages/taskflow-core/src/exec/step-kinds.ts @@ -10,13 +10,16 @@ import type { Phase, Taskflow } from "../schema.ts"; import { LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, + MAX_DYNAMIC_NESTING, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, validateTaskflow, } from "../schema.ts"; import { parseGateVerdict, parseTournamentWinner } from "../deterministic.ts"; +import { verifyTaskflow } from "../verify.ts"; import { aggregateUsage, emptyUsage, type UsageStats } from "../usage.ts"; -import { evaluateCondition, interpolate, safeParse, type InterpolationContext } from "../interpolate.ts"; +import { evaluateCondition, interpolate, safeParse, tryEvaluateCondition, type InterpolationContext } from "../interpolate.ts"; +import { clampSubFlowBudget } from "./kernel-policy.ts"; import { mapWithConcurrencyLimit } from "../runner-core.ts"; import type { Event } from "./events.ts"; import { EVENT_SCHEMA_VERSION } from "./events.ts"; @@ -62,20 +65,56 @@ async function runAgentCall( mapIndex?: number, variantIndex?: number, ): Promise<{ result: RunResult; event: Event }> { - const r = await ctx.deps.runTask( - ctx.deps.cwd, - ctx.deps.agents, - agentName, - task, - { - model: phase.model, - thinking: phase.thinking, - tools: phase.tools, - cwd: ctx.deps.cwd, - signal: ctx.deps.signal, - }, - ctx.deps.globalThinking, - ); + const phaseTimeoutMs = + typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000 + ? phase.timeout + : undefined; + let timedOut = false; + let timer: ReturnType | undefined; + let onParentAbort: (() => void) | undefined; + let callSignal: AbortSignal | undefined = ctx.deps.signal; + if (phaseTimeoutMs) { + const ac = new AbortController(); + callSignal = ac.signal; + if (ctx.deps.signal?.aborted) ac.abort(); + else if (ctx.deps.signal) { + onParentAbort = () => ac.abort(); + ctx.deps.signal.addEventListener("abort", onParentAbort, { once: true }); + } + timer = setTimeout(() => { + timedOut = true; + ac.abort(); + }, phaseTimeoutMs); + } + let r: RunResult; + try { + r = await ctx.deps.runTask( + ctx.deps.cwd, + ctx.deps.agents, + agentName, + task, + { + model: phase.model, + thinking: phase.thinking, + tools: phase.tools, + cwd: ctx.deps.cwd, + signal: callSignal, + }, + ctx.deps.globalThinking, + ); + } finally { + if (timer) clearTimeout(timer); + if (onParentAbort) ctx.deps.signal?.removeEventListener("abort", onParentAbort); + } + if (timedOut) { + r = { + ...r!, + exitCode: r!.exitCode === 0 ? 1 : r!.exitCode, + stopReason: "error", + errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`, + phaseTimeout: true, + }; + } const usage = r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage(); const event = baseEvent(ctx, phase.id, "subagent-call", { input: { @@ -126,11 +165,34 @@ export async function executeReduceBody(phase: Phase, ctx: StepContext): Promise /** Gate: LLM judge + parseGateVerdict (score/eval advanced paths stay on imperative). */ export async function executeGateBody(phase: Phase, ctx: StepContext): Promise { - // Zero-token eval auto-pass when all eval conditions are truthy. + // Zero-token eval auto-pass — MUST match imperative fail-safe (tryEvaluate + contains). if (Array.isArray(phase.eval) && phase.eval.length > 0) { - const ctxI = interpCtx(ctx); - const allPass = phase.eval.every((e) => evaluateCondition(String(e), ctxI)); - if (allPass) { + const evalCtx = interpCtx(ctx); + let allPassed = true; + for (const check of phase.eval) { + if (typeof check !== "string") { + allPassed = false; + break; + } + let expr = check; + const containsIdx = expr.indexOf(" contains "); + if (containsIdx > 0) { + const lhs = expr.slice(0, containsIdx).trim(); + const rhs = expr.slice(containsIdx + " contains ".length).trim(); + const lhsVal = interpolate(lhs, evalCtx); + if (lhsVal.missing.length > 0 || !lhsVal.text.includes(rhs)) { + allPassed = false; + break; + } + continue; + } + const { value: passed, error: evalErr } = tryEvaluateCondition(expr, evalCtx); + if (evalErr || !passed) { + allPassed = false; + break; + } + } + if (allPassed) { return { midEvents: [ baseEvent(ctx, phase.id, "decision", { @@ -413,24 +475,35 @@ export async function executeFlowBody(phase: Phase, ctx: StepContext): Promise ({ + midEvents: [], + output: "", + status: "done", + usage: emptyUsage(), + // surface diagnostic without failing the run (matches runtime fail-open) + error: undefined, + }); + void defFailOpen; // used below; keep signature for clarity if (hasDef) { + const inlineDepth = stack.filter((s) => s.startsWith("def:")).length; + if (inlineDepth >= MAX_DYNAMIC_NESTING) { + return { + midEvents: [], + output: "", + status: "done", + usage: emptyUsage(), + }; + } const rawDef = (phase as { def?: unknown }).def; let parsed: unknown; if (typeof rawDef === "string") { parsed = safeParse(interpolate(rawDef, interpCtx(ctx)).text); if (parsed === undefined) { - return { - midEvents: [], - output: "", - status: "done", - usage: emptyUsage(), - error: undefined, - }; + return { midEvents: [], output: "", status: "done", usage: emptyUsage() }; } } else { parsed = rawDef; } - // Normalize let wrapped: Taskflow | undefined; if (Array.isArray(parsed)) { wrapped = { name: `${phase.id}-inline`, phases: parsed as Taskflow["phases"] }; @@ -444,11 +517,24 @@ export async function executeFlowBody(phase: Phase, ctx: StepContext): Promise i.severity === "error"); + if (errs.length) { + return { midEvents: [], output: "", status: "done", usage: emptyUsage() }; + } + } + subDef = clampSubFlowBudget(wrapped, ctx.state.def.budget); recursionKey = `def:${subDef.name}`; } else { const useName = phase.use; @@ -480,11 +566,12 @@ export async function executeFlowBody(phase: Phase, ctx: StepContext): Promise ")}`, usage: emptyUsage(), }; } @@ -498,7 +585,6 @@ export async function executeFlowBody(phase: Phase, ctx: StepContext): Promise = {}; const withMap = phase.with; if (withMap && typeof withMap === "object") { @@ -507,10 +593,13 @@ export async function executeFlowBody(phase: Phase, ctx: StepContext): Promise; } +const SCRIPT_STDOUT_CAP = 1_000_000; // 1 MiB — match imperative runtime + async function executeScriptBody(phase: Phase, ctx: StepContext): Promise { const run = phase.run; if (!run || (Array.isArray(run) && run.length === 0)) { @@ -176,7 +178,9 @@ async function executeScriptBody(phase: Phase, ctx: StepContext): Promise((resolve) => { @@ -184,27 +188,61 @@ async function executeScriptBody(phase: Phase, ctx: StepContext): Promise { timedOut = true; try { - child.kill("SIGKILL"); + child.kill("SIGTERM"); } catch { /* ignore */ } + setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + }, 200); }, timeoutMs); + const onAbort = () => { + try { + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + }; + ctx.deps.signal?.addEventListener("abort", onAbort, { once: true }); + if (ctx.deps.signal?.aborted) onAbort(); child.stdout?.on("data", (d) => { stdout += d.toString(); + if (stdout.length > SCRIPT_STDOUT_CAP) { + killedForCap = true; + stdout = stdout.slice(0, SCRIPT_STDOUT_CAP); + try { + child.kill("SIGKILL"); + } catch { + /* ignore */ + } + } }); child.stderr?.on("data", (d) => { stderr += d.toString(); + if (stderr.length > SCRIPT_STDOUT_CAP) stderr = stderr.slice(0, SCRIPT_STDOUT_CAP); }); child.on("error", (e) => { clearTimeout(t); + ctx.deps.signal?.removeEventListener("abort", onAbort); resolve({ code: 1, stdout, stderr: e.message, timedOut: false }); }); child.on("close", (code) => { clearTimeout(t); - resolve({ code: code ?? 1, stdout, stderr, timedOut }); + ctx.deps.signal?.removeEventListener("abort", onAbort); + resolve({ + code: code ?? 1, + stdout, + stderr: killedForCap ? `${stderr}\n(stdout capped at ${SCRIPT_STDOUT_CAP} bytes)` : stderr, + timedOut, + }); }); }); @@ -242,20 +280,57 @@ async function runOneAgent( nodePath: string, mapIndex?: number, ): Promise<{ result: RunResult; event: Event }> { - const r = await ctx.deps.runTask( - ctx.deps.cwd, - ctx.deps.agents, - agentName, - task, - { - model: phase.model, - thinking: phase.thinking, - tools: phase.tools, - cwd: ctx.deps.cwd, - signal: ctx.deps.signal, - }, - ctx.deps.globalThinking, - ); + // Per-phase timeout: AbortController chained to run signal (matches runtime). + const phaseTimeoutMs = + typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000 + ? phase.timeout + : undefined; + let timedOut = false; + let timer: ReturnType | undefined; + let onParentAbort: (() => void) | undefined; + let callSignal: AbortSignal | undefined = ctx.deps.signal; + if (phaseTimeoutMs) { + const ac = new AbortController(); + callSignal = ac.signal; + if (ctx.deps.signal?.aborted) ac.abort(); + else if (ctx.deps.signal) { + onParentAbort = () => ac.abort(); + ctx.deps.signal.addEventListener("abort", onParentAbort, { once: true }); + } + timer = setTimeout(() => { + timedOut = true; + ac.abort(); + }, phaseTimeoutMs); + } + let r: RunResult; + try { + r = await ctx.deps.runTask( + ctx.deps.cwd, + ctx.deps.agents, + agentName, + task, + { + model: phase.model, + thinking: phase.thinking, + tools: phase.tools, + cwd: ctx.deps.cwd, + signal: callSignal, + }, + ctx.deps.globalThinking, + ); + } finally { + if (timer) clearTimeout(timer); + if (onParentAbort) ctx.deps.signal?.removeEventListener("abort", onParentAbort); + } + if (timedOut) { + r = { + ...r!, + exitCode: r!.exitCode === 0 ? 1 : r!.exitCode, + stopReason: "error", + errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`, + phaseTimeout: true, + }; + } const usage = r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage(); const event = baseEvent(ctx, phase.id, "subagent-call", { input: { diff --git a/packages/taskflow-core/test/exec-kernel-hardening.test.ts b/packages/taskflow-core/test/exec-kernel-hardening.test.ts new file mode 100644 index 0000000..d66f03e --- /dev/null +++ b/packages/taskflow-core/test/exec-kernel-hardening.test.ts @@ -0,0 +1,288 @@ +/** + * P0/P1 hardening: budget, deps/when, recursion, dynamic def, feature fall-back. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import type { RunResult } from "../src/runner-core.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { canUseEventKernel, kernelUnsupportedReason } from "../src/exec/driver.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; +import type { TraceEvent, TraceSink } from "../src/trace.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "a", description: "t", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mk(def: Taskflow, id = "h"): RunState { + return { + runId: id, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: process.cwd(), + }; +} + +function paidRunner(cost = 1): RuntimeDeps["runTask"] { + return async (_c, _a, agent, task): Promise => ({ + agent, + task, + exitCode: 0, + output: "ok", + stderr: "", + usage: { ...emptyUsage(), cost, input: 100, output: 100, turns: 1 }, + stopReason: "end", + }); +} + +test("feature fall-back: score gate refuses kernel", () => { + const def: Taskflow = { + name: "scored", + phases: [ + { + id: "g", + type: "gate", + agent: "a", + task: "x", + score: { + target: "{previous.output}", + scorers: [{ type: "contains", name: "c", pattern: "ok" }], + }, + final: true, + }, + ], + }; + assert.equal(canUseEventKernel(def), false); + assert.match(kernelUnsupportedReason(def) ?? "", /score/); +}); + +test("feature fall-back: retry refuses kernel", () => { + const def: Taskflow = { + name: "r", + phases: [{ id: "a", type: "agent", agent: "a", task: "t", retry: { max: 2 }, final: true }], + }; + assert.equal(canUseEventKernel(def), false); +}); + +test("budget: second phase skipped after cost cap on kernel", async () => { + const events: TraceEvent[] = []; + const sink: TraceSink = { emit: (e) => events.push(e), flush: () => {} }; + const def: Taskflow = { + name: "bud", + budget: { maxUSD: 0.5 }, + phases: [ + { id: "a", type: "agent", agent: "a", task: "expensive" }, + { id: "b", type: "agent", agent: "a", task: "after", dependsOn: ["a"], final: true }, + ], + }; + const res = await executeTaskflow(mk(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: paidRunner(1.0), + persist: () => {}, + eventKernel: true, + trace: sink, + }); + assert.equal(res.state.phases.a.status, "done"); + assert.equal(res.state.phases.b.status, "skipped"); + assert.equal(res.state.status, "blocked"); + assert.ok(events.some((e) => e.kind === "decision" && e.decision?.type === "budget-hit")); +}); + +test("when-skip upstream: dependent skipped (not run) on kernel", async () => { + let calls = 0; + const def: Taskflow = { + name: "when-dep", + phases: [ + { id: "opt", type: "agent", agent: "a", task: "maybe", when: "false" }, + { id: "next", type: "agent", agent: "a", task: "after", dependsOn: ["opt"], final: true }, + ], + }; + const res = await executeTaskflow(mk(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task) => { + calls++; + return { + agent, + task, + exitCode: 0, + output: "x", + stderr: "", + usage: emptyUsage(), + stopReason: "end", + }; + }, + persist: () => {}, + eventKernel: true, + }); + assert.equal(res.state.phases.opt.status, "skipped"); + assert.equal(res.state.phases.next.status, "skipped"); + assert.equal(calls, 0); +}); + +test("join any: runs when one dep done", async () => { + const def: Taskflow = { + name: "join", + phases: [ + { id: "a", type: "agent", agent: "a", task: "a" }, + { id: "b", type: "agent", agent: "a", task: "b", when: "false" }, + { + id: "m", + type: "agent", + agent: "a", + task: "merge", + dependsOn: ["a", "b"], + join: "any", + final: true, + }, + ], + }; + const res = await executeTaskflow(mk(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: paidRunner(0.01), + persist: () => {}, + eventKernel: true, + }); + // a done, b skipped → join any: a is depOk → m runs + assert.equal(res.state.phases.a.status, "done"); + assert.equal(res.state.phases.b.status, "skipped"); + assert.equal(res.state.phases.m.status, "done"); +}); + +test("steps.json populated for output:json phases", async () => { + const def: Taskflow = { + name: "js", + phases: [ + { + id: "j", + type: "agent", + agent: "a", + task: "emit", + output: "json", + }, + { + id: "use", + type: "agent", + agent: "a", + task: "got {steps.j.json.v}", + dependsOn: ["j"], + final: true, + }, + ], + }; + const res = await executeTaskflow(mk(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task) => ({ + agent, + task, + exitCode: 0, + output: task.includes("got") ? `OUT:${task}` : '{"v":42}', + stderr: "", + usage: emptyUsage(), + stopReason: "end", + }), + persist: () => {}, + eventKernel: true, + }); + assert.equal(res.state.phases.j.json && (res.state.phases.j.json as { v: number }).v, 42); + assert.match(res.finalOutput, /got 42/); +}); + +test("gate eval parse error does not auto-pass (fail-safe)", async () => { + let llm = 0; + const def: Taskflow = { + name: "eval-fail", + phases: [ + { + id: "g", + type: "gate", + agent: "a", + task: "judge", + // deliberately unparseable comparison — tryEvaluate returns error + eval: ["{{{{not a valid expr"], + final: true, + }, + ], + }; + const res = await executeTaskflow(mk(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task) => { + llm++; + return { + agent, + task, + exitCode: 0, + output: "VERDICT: PASS", + stderr: "", + usage: emptyUsage(), + stopReason: "end", + }; + }, + persist: () => {}, + eventKernel: true, + }); + // Must call LLM because eval failed open-as-failed-check + assert.ok(llm >= 1); + assert.equal(res.state.phases.g.gate?.verdict, "pass"); +}); + +test("flow cycle A→B→A fails on kernel", async () => { + const flows: Record = { + A: { + name: "A", + phases: [{ id: "f", type: "flow", use: "B", final: true }], + }, + B: { + name: "B", + phases: [{ id: "f", type: "flow", use: "A", final: true }], + }, + }; + const res = await executeTaskflow(mk(flows.A), { + cwd: process.cwd(), + agents: AGENTS, + runTask: paidRunner(0.01), + persist: () => {}, + eventKernel: true, + loadFlow: (n) => flows[n], + }); + assert.equal(res.ok, false); + assert.match(res.state.phases.f.error ?? "", /recursive/i); +}); + +test("dynamic flow def with script fails open (done empty) not ACE", async () => { + const def: Taskflow = { + name: "parent", + phases: [ + { + id: "f", + type: "flow", + def: { + name: "evil", + phases: [{ id: "s", type: "script", run: "echo pwned", final: true }], + }, + final: true, + }, + ], + }; + const res = await executeTaskflow(mk(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: paidRunner(0.01), + persist: () => {}, + eventKernel: true, + }); + // dynamic validation rejects script in generated def → fail-open empty done + assert.equal(res.state.phases.f.status, "done"); + assert.equal(res.state.phases.f.output ?? "", ""); +}); diff --git a/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts index 1c3f4c1..b36c65c 100644 --- a/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts +++ b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts @@ -57,7 +57,7 @@ test("EVENT_KERNEL_PHASE_TYPES covers all PHASE_TYPES", () => { assert.deepEqual([...EVENT_KERNEL_PHASE_TYPES].sort(), [...PHASE_TYPES].sort()); }); -test("canUseEventKernel: all 10 kinds accepted", () => { +test("canUseEventKernel: all 10 basic kinds accepted (no advanced features)", () => { assert.equal( canUseEventKernel({ name: "all", From 3cfd5f22d692f540f79e0cf864a7729b0a083900 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 15:38:05 +0800 Subject: [PATCH 24/51] docs: draft S4 MVP public surface for taskflow-dsl Freeze package exports/bin, CLI (build|check|decompile|new), library API, *.tf.ts convention, diagnostics, core import allowlist, and CLI-first host integration for the Svelte erase + JSON escape route. --- docs/rfc-0.2.0-s4-mvp.md | 738 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 738 insertions(+) create mode 100644 docs/rfc-0.2.0-s4-mvp.md diff --git a/docs/rfc-0.2.0-s4-mvp.md b/docs/rfc-0.2.0-s4-mvp.md new file mode 100644 index 0000000..07ace78 --- /dev/null +++ b/docs/rfc-0.2.0-s4-mvp.md @@ -0,0 +1,738 @@ +# RFC: taskflow 0.2.0 S4 MVP — `taskflow-dsl` public surface + +> Status: **Draft** · Design-only · 2026-07-09 +> Parent: [`rfc-0.2.0-architecture.md`](./rfc-0.2.0-architecture.md) §4.3 / §9 S4 +> Syntax authority: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 +> Route lock: north-star 决策1 + this document §0 + +This document freezes the **publishable public surface** of `packages/taskflow-dsl` for the S4 MVP ship gate. It is intentionally narrower than full DSL RFC coverage: what agents and humans import, invoke, and get wrong messages for — not the full transform implementation plan. + +--- + +## §0. Route lock (non-negotiable for S4) + +| Pick | Value | +|------|--------| +| **Primary** | **Svelte-style compile-time erase** (runes are AST directives; runtime does not execute them) | +| **Escape** | **JSON-only** whole-file (existing Taskflow JSON remains first-class; zero migration) | +| **Build toolchain** | **ts-morph** (TypeScript Program + AST read → Taskflow JSON → core `compileTaskflowToFlowIR`) | + +### Why (≤5) + +- Architecture S4 is fixed as `.tf.ts → build → Taskflow → FlowIR` with demo FlowIR ≡ hand JSON; runtime runes would be a third executor and blow MVP scope. +- Solid Proxy dies on physics, not taste: no `currentObserver`, phase-not-yet-run, `.toString`/coercion/method chains break templates and deps (DSL v2 §0.3; north-star 决策1). +- Headline features (`json()`, map templates, parallel destructure, `.output` → placeholders) need AST sight; a “correct Proxy” becomes an ad-hoc compiler worse than one intentional erase. +- Progressive migration is already dual-frontend at the **file** boundary (`.json` *or* `.tf.ts`); in-file hybrid (Vapor) adds a second grammar, handle↔id seams, and dilutes the S4 equality gate without tightening it. +- JSON stays zero-change and first-class whole-file escape; compensators are `check` / `build` / `verify` / `compile` / `peek`, not REPL-on-runes or degraded interpret mode. + +### S4 MVP is NOT + +- Solid runtime runes / Proxy / `currentObserver` graph building +- Runnable unbuilt `.tf.ts` or “degraded interpret” next to JSON +- In-file JSON phase literals (Vapor hybrid) +- Breakpoint-on-`agent()` as a product promise +- S5 kernel flip or any change to existing JSON Taskflow execution + +**WINNER_RATIONALE:** Svelte-style compile-time erase with whole-file JSON escape is the only path that satisfies architecture S4 (`.tf.ts→build→Taskflow→FlowIR`, JSON zero-change) without reopening rejected Proxy physics or hybrid grammar cost. + +### Pipeline S4 owns + +``` +.tf.ts ──build(AST / ts-morph)──▶ Taskflow JSON ──compileTaskflowToFlowIR──▶ FlowIR (+ ir:<64-hex>) +.flow.json ──validate / desugar──▶ Taskflow JSON ──same core entry only──▶ FlowIR +``` + +- **S4 stop line:** DSL package produces **Taskflow** (and may *call* core to emit FlowIR for CLI convenience). +- **Single FlowIR entry:** only `taskflow-core`’s `compileTaskflowToFlowIR` / `hashFlowIR` (arch §4.2). +- **Acceptance gate:** DSL demo FlowIR == hand-written JSON FlowIR (byte-stable `ir:<64-hex>`). + +--- + +## §1. Package identity — `package.json` + +### 1.1 Name, version, role + +| Field | MVP value | +|-------|-----------| +| **name** | `taskflow-dsl` | +| **version** | tracks monorepo release line (`0.2.0` when shipped with 0.2.0) | +| **description** | Compile-time TypeScript DSL frontend for taskflow: erase `.tf.ts` runes to Taskflow JSON, then FlowIR via core | +| **type** | `"module"` | +| **engines** | `node: ">=22.19.0"` (same floor as monorepo) | +| **license / author / repository.directory** | match sibling packages; `directory: "packages/taskflow-dsl"` | + +**Not** named `taskflow`. That umbrella name stays free for a future unified CLI/meta-package. Authoring imports use the package name (see §3). + +### 1.2 Dependencies + +| Dep | Kind | Why | +|-----|------|-----| +| `taskflow-core` | **dependency** (workspace pin, same version as siblings) | Taskflow types, `validateTaskflow` / `desugar`, `compileTaskflowToFlowIR`, `hashFlowIR`, `verifyTaskflow` | +| `ts-morph` | **dependency** | Project/SourceFile AST for build + check | +| `typescript` | **dependency** (or peer + dep) | Program host under ts-morph; pin compatible with monorepo devDependency | +| `typebox` | **peerDependency** `*` | Align with core; only if public types re-export expect shapes that mention TypeBox | + +**Forbidden in this package:** host SDKs (`@earendil-works/*`), `taskflow-hosts`, `taskflow-mcp-core`, any process-spawning runner. + +**Why not in core:** AST toolchain violates core’s zero-runtime-deps iron rule (arch §4.3). Core must never import `taskflow-dsl`. + +### 1.3 `exports` map + +```jsonc +{ + "name": "taskflow-dsl", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./build": { + "development": "./src/build.ts", + "types": "./dist/build.d.ts", + "default": "./dist/build.js" + }, + "./check": { + "development": "./src/check.ts", + "types": "./dist/check.d.ts", + "default": "./dist/check.js" + }, + "./decompile": { + "development": "./src/decompile.ts", + "types": "./dist/decompile.d.ts", + "default": "./dist/decompile.js" + }, + "./diagnostics": { + "development": "./src/diagnostics.ts", + "types": "./dist/diagnostics.d.ts", + "default": "./dist/diagnostics.js" + } + }, + "bin": { + "taskflow-dsl": "./dist/cli.js" + }, + "files": ["dist"], + "publishConfig": { "access": "public" } +} +``` + +| Export | Audience | Contents | +|--------|----------|----------| +| **`.`** | Authors of `*.tf.ts` | Runes + types only (`flow`, `agent`, `map`, …, `json`, `Phase`, …). **Compile stubs** — safe to typecheck under tsc; must not “run” a flow. | +| **`./build`** | Tooling / tests / optional host glue | `buildFile`, `buildSource`, `BuildOptions`, `BuildResult` | +| **`./check`** | Tooling / agents | `checkFile`, `checkSource` → diagnostics only | +| **`./decompile`** | Tooling / migration | `decompileTaskflow`, `decompileFlowIR` → source string | +| **`./diagnostics`** | Shared types | `Diagnostic`, severity, codes, formatters | + +**MVP does not export:** `./experimental`, `./vapor`, `./runtime`, MCP server bindings, host runners. + +**Development condition:** same monorepo pattern as siblings (`development` → `src/*.ts`) so tests run without a prior `tsc` emit. + +### 1.4 `bin` + +| Binary | Path | Role | +|--------|------|------| +| **`taskflow-dsl`** | `./dist/cli.js` | Only published bin for S4 MVP | + +RFC prose that says `taskflow build` / `taskflow check` is the **conceptual** command surface. Implementation name is `taskflow-dsl ` to avoid claiming a future umbrella `taskflow` CLI. Docs and skills for 0.2.0 should teach: + +```bash +npx taskflow-dsl build ./audit.tf.ts +# or, after install: +taskflow-dsl check ./audit.tf.ts +``` + +Optional monorepo convenience (not published): root script `"dsl": "node --conditions=development … packages/taskflow-dsl/src/cli.ts"`. + +--- + +## §2. CLI surface + +Entry: `taskflow-dsl [options] [path…]` +Global flags (all commands): + +| Flag | Meaning | +|------|---------| +| `--cwd ` | Project root for path resolution / tsconfig discovery (default: `process.cwd()`) | +| `--json` | Machine-readable stdout (diagnostics + results as JSON) | +| `--no-color` | Disable ANSI (also respects `NO_COLOR`) | +| `-h` / `--help` | Command help | +| `-V` / `--version` | Package version | + +Exit codes (stable for agents): + +| Code | Meaning | +|------|---------| +| `0` | Success (check/build clean; decompile/new wrote output) | +| `1` | Diagnostics present at **error** severity (or validation failed) | +| `2` | Usage / I/O / unexpected internal failure | + +Warnings alone → exit `0` (match common compiler UX); `--strict-warnings` (optional MVP-nice) promotes warnings to exit `1`. + +### 2.1 `build` + +```text +taskflow-dsl build [options] +``` + +| Input | Behavior | +|-------|----------| +| `*.tf.ts` | AST erase → Taskflow → (optional) FlowIR via core | +| `*.json` / `*.jsonc` | **JSON escape path:** parse → `validateTaskflow` / `desugar` → same emit options (no ts-morph) | +| other extension | Error `TFDSL_INPUT_KIND` | + +| Flag | Default | IO | +|------|---------|-----| +| `-o` / `--out ` | derived (see below) | Write primary artifact | +| `--emit taskflow\|flowir\|both` | `both` | What to write / print | +| `--stdout` | off | Print primary artifact to stdout (single emit mode); conflicts with multi-file unless `--emit` is one of taskflow\|flowir | +| `--pretty` | on for Taskflow JSON | Indent JSON | +| `--ir-hash` | on when emitting flowir | Include `hash` (`ir:<64-hex>`) in envelope or sidecar line | +| `--verify` | off | After emit, run `verifyTaskflow` on Taskflow; failures → diagnostics + exit 1 | +| `--tsconfig ` | walk-up `tsconfig.json` | For `.tf.ts` only | + +**Default output paths** (when `-o` omitted, write next to input): + +| Emit | Default file | +|------|----------------| +| taskflow | `.taskflow.json` | +| flowir | `.flowir.json` | +| both | both files | + +**Stdout human mode (no `--json`):** short summary — name, phase count, `ir:` if computed, output paths. +**Stdout `--json`:** `BuildResult` (§3.2). + +**MVP non-goals for `build`:** watch mode, multi-file project graph beyond one entry `export default`, bundling imports of other `.tf.ts` (S4.1: `import` of subflow modules). + +### 2.2 `check` + +```text +taskflow-dsl check [options] +``` + +Lightweight validation **without requiring artifact write**. + +| Input | Checks | +|-------|--------| +| `*.tf.ts` | (1) tsc diagnostics for the file in a Program (2) rune shape / signature rules (3) static dep completeness warnings (4) `when` predicate subset (DSL v2 §5.1) (5) optional: lower to Taskflow in memory + `validateTaskflow` | +| `*.json` | `validateTaskflow` only | + +| Flag | Default | Meaning | +|------|---------|---------| +| `--tsconfig ` | walk-up | `.tf.ts` only | +| `--no-typecheck` | off | Skip tsc; rune/static rules only (faster agent loop) | +| `--emit-taskflow` | off | Also build in-memory Taskflow and validate (closer to `build`, still no write) | + +Does **not** write FlowIR. Does **not** call the runtime. + +Stdout: human diagnostic list, or `--json` → `{ diagnostics: Diagnostic[] }`. + +### 2.3 `decompile` + +```text +taskflow-dsl decompile [options] +``` + +| Input | Behavior | +|-------|----------| +| Taskflow JSON (`.json`) | Preferred MVP path: Taskflow → `.tf.ts` codegen | +| FlowIR JSON | Optional MVP if cheap: require sidecar-complete IR **or** reject with “pass Taskflow JSON” | +| `.tf.ts` | Error (already DSL) | + +| Flag | Default | Meaning | +|------|---------|---------| +| `-o` / `--out ` | `.tf.ts` or stdout if `-o -` | Output path | +| `--name ` | from `def.name` | Override `flow("…")` name | +| `--style compact\|readable` | `readable` | Formatting only (semantic equivalence unchanged) | + +**Honest contract (DSL v2 §6.2):** semantic equivalence under re-`build`, **not** literal round-trip. Variable names, template vs string placeholders, and formatting may change. + +**MVP decompile coverage:** kinds and fields in the S4 MVP coverage slice only; advanced fields (`gate.scored`, multi-scorer, etc.) emit explicit options objects or `// TFDSL: unsupported-field` comments + warning diagnostics — never silent drop of execution-critical fields. If a field cannot be expressed in MVP runes, decompile **fails closed** with `TFDSL_DECOMPILE_UNSUPPORTED` (prefer fail over lying JSON↔DSL parity). + +### 2.4 `new` + +```text +taskflow-dsl new [name] [options] +``` + +| Flag | Default | Meaning | +|------|---------|---------| +| `-o` / `--out ` | `./.tf.ts` or `./hello.tf.ts` | Destination (refuse overwrite unless `--force`) | +| `--force` | off | Overwrite existing file | +| `--json-escape` | off | Emit a minimal **JSON** flow instead (escape hatch skeleton) | + +**Default skeleton** (DSL v2 §8, ≤5 lines of substance): + +```ts +import { flow, agent } from "taskflow-dsl"; + +export default flow("hello", () => agent("Say hello to {args.name}")); +``` + +If `name` provided, substitute `flow("", …)` and default out file. + +### 2.5 Commands explicitly **out of** `taskflow-dsl` MVP CLI + +These stay on **core + host / MCP** (already shipped): + +| Command / tool | Owner | +|----------------|--------| +| `verify` / `taskflow_verify` | core + mcp-core | +| `compile` (Mermaid/SVG) / `taskflow_compile` | core + mcp-core | +| `peek` / `taskflow_peek` | core + mcp-core | +| `run` / `taskflow_run` | mcp-core + host runners | +| `replay` / `taskflow_replay` | core + mcp-core (S3) | + +S4 docs tell agents: **`check`/`build` here → `verify`/`run` there**. Optional `--verify` on `build` is a thin CLI convenience, not a second verify implementation. + +--- + +## §3. Library API + +### 3.1 Authoring surface (`import from "taskflow-dsl"`) + +Rune functions are **type-level + compile-time directives**. At Node runtime they are stubs: + +- Calling them outside `taskflow-dsl build` throws `TFDSL_ERASE_ONLY` (or returns a branded phantom used only in type positions — pick one implementation; **public contract:** “do not execute `.tf.ts` as a program”). +- tsc sees full typings for IDE / agent typecheck. + +#### MVP exports (authoring) + +```ts +// --- entry --- +export function flow( + name: string, + fn: (ctx: FlowCtx) => PhaseRef | void, +): TaskflowModuleDefault; +export function flow( + name: string, + opts: FlowOptions, + fn: (ctx: FlowCtx) => PhaseRef | void, +): TaskflowModuleDefault; + +// --- phase runes (MVP kinds) --- +export function agent(task: TemplateInput, opts?: PhaseOptions): PhaseRef; +export function parallel( + branches: PhaseRef[], + opts?: PhaseOptions, +): PhaseRef[] & PhaseRef; // compile-time destructure; type as tuple-friendly +export function map( + source: PhaseRef | Placeholder, + fn: (item: ItemSymbol) => PhaseRef, + opts?: PhaseOptions, +): PhaseRef; +export function gate( + upstream: PhaseRef, + opts?: GateOptions, + task?: (input: PhaseRef) => TemplateInput, +): PhaseRef; +export function reduce( + from: PhaseRef[], + fn: (parts: Record) => PhaseRef, + opts?: PhaseOptions, +): PhaseRef; +export function approval(opts: { request: TemplateInput } & PhaseOptions): PhaseRef; +export function subflow( + use: string, + withArgs?: Record, + opts?: PhaseOptions, +): PhaseRef; +export function loop(opts: LoopOptions): PhaseRef; +export function tournament(opts: TournamentOptions): PhaseRef; +export function script( + run: string | string[], + opts?: ScriptOptions, +): PhaseRef; + +// --- expect sugar --- +export function json(): JsonExpectMarker; + +// --- types (erasable) --- +export type { PhaseRef, FlowCtx, FlowOptions, PhaseOptions, ItemSymbol, … }; +``` + +#### `FlowCtx` MVP methods + +| Method | JSON field | MVP | +|--------|------------|-----| +| `ctx.args.declare({…})` | `args` | **Y** — keys: `default?`, `description?`, `required?` only (**no `type`**) | +| `ctx.concurrency(n)` | `concurrency` | **Y** | +| `ctx.budget({ maxUSD?, maxTokens? })` | `budget` | **Y** | +| `ctx.scope` / `ctx.strict` / `ctx.share` / `ctx.incremental` | agentScope / strictInterpolation / contextSharing / incremental | **N** (S4.1) | + +#### Explicitly **not** in MVP authoring export + +| API | Reason | +|-----|--------| +| `gate.automated` / `gate.scored` | Coverage matrix N → S4.1 | +| `subflow.def(() => …)` | Dynamic inline def; engine exists, DSL compile cost high → S4.1 | +| `$store` / `$derived` / `flow.component` | post-0.2.0 (arch §12) | +| Runtime `execute*` anything | Wrong package | + +Import path note: DSL RFC v2 samples use `"taskflow"`. **S4 MVP ships `"taskflow-dsl"`** only. A later `taskflow` meta-package may re-export; do not publish a second package name in MVP. + +### 3.2 Compiler API (`taskflow-dsl/build`) + +```ts +import type { Taskflow } from "taskflow-core"; +import type { FlowIR } from "taskflow-core"; // or flowir schema type from core +import type { Diagnostic } from "taskflow-dsl/diagnostics"; + +export interface BuildOptions { + cwd?: string; + tsconfigPath?: string; + /** default: true when caller wants IR equality gate */ + emitFlowIR?: boolean; + verify?: boolean; + /** Source path for diagnostic file field */ + fileName?: string; +} + +export interface BuildResult { + ok: boolean; + taskflow?: Taskflow; + flowir?: FlowIR; + /** Present when flowir emitted and well-formed */ + hash?: `ir:${string}`; + diagnostics: Diagnostic[]; +} + +/** Build from a filesystem path (.tf.ts | .json). */ +export function buildFile(path: string, opts?: BuildOptions): Promise; +// Sync variant allowed if ts-morph usage is sync-only: +export function buildFileSync(path: string, opts?: BuildOptions): BuildResult; + +/** Build from source text (tests / MCP-later). `fileName` required for positions. */ +export function buildSource( + source: string, + opts: BuildOptions & { fileName: string; kind?: "tf.ts" | "json" }, +): BuildResult; +``` + +**Contract:** + +1. On `ok: true`, `taskflow` is present and has passed `validateTaskflow` (post-desugar shape). +2. When `emitFlowIR: true` and ok, `flowir` + `hash` come **only** from `compileTaskflowToFlowIR` + `hashFlowIR`. +3. DSL package never reimplements IR lowering. + +### 3.3 Check API (`taskflow-dsl/check`) + +```ts +export interface CheckOptions { + cwd?: string; + tsconfigPath?: string; + typecheck?: boolean; // default true for .tf.ts + /** Lower + validateTaskflow without writing */ + emitTaskflow?: boolean; // default false +} + +export interface CheckResult { + ok: boolean; // no error-severity diagnostics + diagnostics: Diagnostic[]; +} + +export function checkFile(path: string, opts?: CheckOptions): CheckResult; +export function checkSource( + source: string, + opts: CheckOptions & { fileName: string; kind?: "tf.ts" | "json" }, +): CheckResult; +``` + +### 3.4 Decompile API (`taskflow-dsl/decompile`) + +```ts +export interface DecompileOptions { + name?: string; + style?: "compact" | "readable"; +} + +export interface DecompileResult { + ok: boolean; + source?: string; // .tf.ts text + diagnostics: Diagnostic[]; +} + +export function decompileTaskflow(def: Taskflow, opts?: DecompileOptions): DecompileResult; +/** MVP: may return not-ok with TFDSL_DECOMPILE_USE_TASKFLOW if IR incomplete. */ +export function decompileFlowIR(ir: FlowIR, opts?: DecompileOptions): DecompileResult; +``` + +### 3.5 Scaffold API (used by CLI `new`) + +```ts +export function skeletonTfTs(name?: string): string; +export function skeletonJson(name?: string): string; +``` + +### 3.6 What the library must **not** expose in MVP + +- `interpretTfTs()` / `runUnbuilt()` +- Proxy helpers, `currentObserver`, reactive graph APIs +- Direct `executeTaskflow` wrappers (hosts already own run) + +--- + +## §4. File convention — `*.tf.ts` + +### 4.1 Recognition rules + +| Rule | MVP | +|------|-----| +| Extension | **`*.tf.ts` only** (not `.tf.js`, not bare `.ts`) | +| Module shape | Exactly one **`export default flow(…)`** (or `export default` of the value returned by `flow`) | +| Side exports | **Forbidden** for build entry (`export const helpers` → error `TFDSL_ENTRY_SHAPE`) | +| Imports from `taskflow-dsl` | Runes/types only | +| Imports from other local modules | **N** in MVP (single-file flows); S4.1 may allow type-only or pure const imports | +| Top-level statements | Only imports + `export default flow(…)` | +| Executable as `node file.tf.ts` | **Unsupported**; stubs throw if runes invoked | + +### 4.2 Discovery (for future host glue; CLI is path-explicit in MVP) + +MVP CLI always takes an explicit path. Recommended project layout (non-normative): + +```text +flows/ + audit.tf.ts # DSL source + audit.taskflow.json # build emit (optional commit) + audit.flowir.json # build emit (optional; usually gitignored) + legacy-audit.json # JSON escape (hand-written) +``` + +Saved-flow library (`.pi/taskflows/*.json` etc.) remains **JSON** as today; S4 does not require hosts to load `.tf.ts` at run time. Author workflow: `build` → save/run JSON. + +### 4.3 Naming + +- Flow `name` (string arg to `flow`) = taskflow `name` field (library identity). +- Phase ids = **const binding names** where possible (`const discover = agent(…)` → id `discover`); anonymous runes get synthetic ids (`agent_0`, …) with stable ordering rules documented in implementer notes. +- IDs remain kebab-or-camel as bound; core already accepts phase ids used in `{steps.ID…}` — **prefer valid JS identifiers** that match existing JSON id style in demos (kebab via explicit `opts.id` if needed). + +MVP option: `agent("…", { id: "audit-each" })` maps to JSON `id` when binding name differs — **Y** if `id` already exists on PhaseSchema; else use binding name only. + +### 4.4 JSON escape file convention + +- Any `*.json` / `*.jsonc` Taskflow document accepted by today’s `validateTaskflow`. +- No `.tf.json` hybrid. +- `build`/`check` treat JSON as the escape frontend; output FlowIR must match DSL-built FlowIR for the equality demos. + +--- + +## §5. Diagnostics shape + +### 5.1 Type + +```ts +export type DiagnosticSeverity = "error" | "warning" | "info"; + +export interface Position { + /** 1-based */ + line: number; + /** 1-based, code unit columns (tsc-compatible) */ + column: number; +} + +export interface Range { + start: Position; + end: Position; +} + +export interface Diagnostic { + /** Stable machine code, e.g. "TFDSL0001" or "TFDSL_ERASE_ONLY" */ + code: string; + severity: DiagnosticSeverity; + message: string; + /** Absolute or cwd-relative path */ + file?: string; + range?: Range; + /** Optional agent-facing hint (how to fix) */ + hint?: string; + /** Related spans (e.g. missing dependsOn target) */ + related?: Array<{ file?: string; range?: Range; message: string }>; +} +``` + +### 5.2 Code families (MVP reserved prefixes) + +| Prefix | Domain | +|--------|--------| +| `TFDSL_ENTRY_*` | Module shape / export default / multi-flow | +| `TFDSL_RUNE_*` | Unknown rune, bad arity, disallowed call position | +| `TFDSL_TMPL_*` | Template → placeholder erase failures | +| `TFDSL_WHEN_*` | Predicate subset violations | +| `TFDSL_JSON_T_*` | `json()` unrepresentable types | +| `TFDSL_DEP_*` | Dependency auto-edge warnings / cycles at DSL layer | +| `TFDSL_IMPORT_*` | Illegal imports (see §6) | +| `TFDSL_DECOMPILE_*` | Codegen unsupported / incomplete | +| `TFDSL_IO_*` | CLI filesystem / tsconfig | +| `TFDSL_CORE_*` | Wrapped `validateTaskflow` / verify messages (preserve core text in `message`, code maps or nests) | + +Human formatter example: + +```text +audit.tf.ts:12:5 - error TFDSL_WHEN_METHOD: when-predicates cannot call methods (.includes). + hint: Use a string when: "{steps.x.output} contains 'ok'" instead. +``` + +`--json` CLI wraps: + +```json +{ + "ok": false, + "diagnostics": [ /* Diagnostic */ ] +} +``` + +### 5.3 Severity policy + +| Class | Severity | +|-------|----------| +| Unerasable construct, invalid entry, validateTaskflow error | **error** | +| Phase with no auto-deps and not first (DSL v2 §5.4) | **warning** | +| Decompile pretty-print lossiness | **info** / silence | +| `json()` too complex | **error** (no silent drop — DSL v2 §4.1) | + +--- + +## §6. Core import allowlist / denylist + +`taskflow-dsl` may depend on `taskflow-core` **only** through the following surfaces. Enforce with an import-lint test (mirror `replay-import-lint` spirit). + +### 6.1 Allowlist (MVP) + +| Core surface | Use in DSL | +|--------------|------------| +| `Taskflow`, `Phase`, `PhaseType`, `PHASE_TYPES`, arg/budget types from `schema` | Output typing + guards | +| `validateTaskflow`, `desugar`, `topoLayers` / `dependenciesOf` (if needed for warnings) | Post-erase validation | +| `compileTaskflowToFlowIR`, `CompileTaskflowToFlowIRResult` | IR emit | +| `hashFlowIR` / compile envelope hash from `compileTaskflowToIR` helper | `ir:<64-hex>` | +| `verifyTaskflow` | CLI `--verify` only | +| FlowIR types (`FlowIR`, `FlowIRNode`, …) | decompile input typing + emit typing | +| Pure helpers strictly needed for placeholder parity (e.g. cond normalize) | only if build must match runtime when-strings | + +Prefer **barrel** `taskflow-core` or narrow subpaths already exported (`taskflow-core/flowir/*` if published). Do not deep-import non-exported internals. + +### 6.2 Denylist (hard) + +| Core / monorepo surface | Why denied | +|-------------------------|------------| +| `runtime.ts` / `executeTaskflow` / `executePhase` | S4 must not run flows | +| `exec/driver`, `exec/step`, `exec/fold` | Kernel path; S5 concern | +| `runner-core` process spawn, `detached-runner` | Side effects | +| `store.ts` persist / locks | Wrong layer | +| `agents.ts` discovery | Build is host-agnostic | +| `cache.ts` runtime memoization | Not authoring | +| `context-store.ts` SCT runtime | Not authoring | +| `replay.ts` | Orthogonal (S3) | +| Any `taskflow-hosts/*` | Host coupling | +| Any `taskflow-mcp-core/*` | MCP is separate delivery | +| Any `@earendil-works/*` | Core iron rule extended to DSL | + +### 6.3 What `.tf.ts` author files may import + +| Import | MVP | +|--------|-----| +| `taskflow-dsl` (runes/types) | **Y** | +| `taskflow-dsl/build` etc. | **N** inside a flow file (tooling only) | +| `taskflow-core` | **N** (keeps author surface small; avoids pulling runtime types into flows) | +| `node:*` / fs / child_process | **N** (`TFDSL_IMPORT_NODE`) | +| relative `./foo` | **N** MVP (`TFDSL_IMPORT_LOCAL`) | +| type-only imports of local types | **N** MVP (S4.1 candidate) | + +--- + +## §7. Host integration — CLI-first (MVP) + +### 7.1 Decision + +| Channel | S4 MVP | Rationale | +|---------|--------|-----------| +| **CLI `taskflow-dsl`** | **Y — primary** | Agents/humans compile before run; matches “no unbuilt run”; easy CI | +| **Library `buildFile` / `checkFile`** | **Y** | Tests + scripted toolchains | +| **New MCP tools** (`taskflow_build` / `taskflow_check`) | **N** | Avoid bloating every host adapter + mcp-core in the same release as first compiler; agents shell out or use defineFile of emitted JSON | +| **Host auto-build of `.tf.ts` on `taskflow_run`** | **N** | Silent compile on run reintroduces “feels executable” and couples hosts to ts-morph | +| **pi `/tf` DSL commands** | **N** MVP | Optional S4.1 thin wrappers calling the same library | + +### 7.2 Recommended agent workflow (0.2.0) + +```text +1. taskflow-dsl new my-flow +2. edit my-flow.tf.ts +3. taskflow-dsl check my-flow.tf.ts # fast loop +4. taskflow-dsl build my-flow.tf.ts # → .taskflow.json + .flowir.json +5. taskflow_verify / taskflow_run with defineFile=my-flow.taskflow.json + (existing MCP — unchanged) +``` + +JSON authors skip steps 1–4 and keep using define / defineFile as today. + +### 7.3 S4.1 host follow-ups (explicitly deferred) + +- `taskflow_build` / `taskflow_check` in `taskflow-mcp-core` (stdio tools wrapping library API). +- pi `/tf build|check|new` and skills-src host blocks. +- Optional: run path accepts `.tf.ts` **only** after explicit build cache hit (still no interpret). + +### 7.4 Compatibility with S0–S3 / S5 + +| Stage | Interaction | +|-------|-------------| +| S0 FlowIR | DSL must use sole compile entry; equality gate is the S4 ship bar | +| S1–S2 events/driver | Irrelevant to DSL package; built Taskflow runs on existing runtime | +| S3 replay | Works on runs of DSL-produced Taskflow the same as JSON | +| S5 kernel flip | **No DSL changes required** if Taskflow IR parity holds | + +--- + +## §8. MVP coverage vs public surface (pointer) + +Public surface exposes runes for the **Y** rows of the S4 coverage matrix (full matrix lives in implementer notes / companion survey). Minimum vertical slice for the equality demo: + +| Kind / feature | Surface | +|----------------|---------| +| `flow` + description + args + concurrency + budget | `flow`, `FlowCtx` | +| `agent`, `parallel`, `map`, `gate` (LLM), `reduce`, `approval`, `subflow(use)`, `loop` (single task body), `tournament`, `script` | runes above | +| `json()` basic object/array | `json` | +| template → `{steps.*}` / `{item.*}` | erase in `build` | +| `when` string + TS subset | options + check rules | +| gate.automated / scored, top-level strict/share/incremental/scope | **not exported** | + +FULL RFC coverage remains a post-MVP completion track; missing FULL features must not appear as stub exports that silently no-op. + +--- + +## §9. Acceptance checklist (public surface) + +- [ ] Package name `taskflow-dsl` publishable with exports/bin as §1 +- [ ] `taskflow-dsl build|check|decompile|new` flags/IO as §2 +- [ ] Author import `from "taskflow-dsl"` typechecks a hello + audit-style demo +- [ ] `build` on demo `.tf.ts` and hand JSON → **identical** `hashFlowIR` +- [ ] Diagnostics stable codes + positions on deliberate erase errors +- [ ] Import-lint: no denylist modules from core/hosts +- [ ] No MCP/schema changes required to pass S4 gate +- [ ] README/skills teach CLI-first workflow; JSON escape documented as first-class + +--- + +## §10. Open implementer choices (do not block surface freeze) + +1. Sync vs async `buildFile` (ts-morph is sync-friendly; Promise ok for future loaders). +2. Whether decompile accepts FlowIR in MVP or Taskflow-only. +3. Phantom return types vs throw-on-call for rune stubs at runtime. +4. Exact synthetic phase id algorithm for anonymous runes. +5. Whether `--emit both` default should switch to `taskflow` only for quieter git diffs (product polish). + +--- + +## §11. Doc map + +| Doc | Role after this RFC | +|-----|---------------------| +| `rfc-0.2.0-architecture.md` | System topology; S4 row points here for surface | +| `rfc-0.2.0-dsl-syntax.md` | Language semantics (full); MVP subset bound by §8 | +| `rfc-0.2.0-three-compile-routes.md` | Historical routes; **superseded for product** by §0 lock (Svelte + JSON escape) | +| **`rfc-0.2.0-s4-mvp.md` (this)** | Ship surface freeze for `packages/taskflow-dsl` | + +--- + +*One-line summary: S4 MVP publishes `taskflow-dsl` as a CLI-first, ts-morph erase frontend — `*.tf.ts` in, Taskflow (+ FlowIR via core) out — with whole-file JSON as the only escape, stable diagnostics, a hard core import allowlist, and no MCP or runtime interpret path.* From 358cb28dfcd5db5fc0c90603a5c111289abe6521 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 16:18:39 +0800 Subject: [PATCH 25/51] feat(dsl): implement S4 taskflow-dsl package and clear review blockers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add packages/taskflow-dsl: compile-time .tf.ts erase via TypeScript AST, CLI build/check/decompile/new, FlowIR parity tests, and import-lint. Document brainstorm phase horizon + S4 decision record; wire workspace. Fix FileTraceSink to mkdir parent on first flush so MCP first-run traces land. Adversarial re-review: 8 prior BLOCK must-fixes verified FIXED (PASS_WITH_ISSUES — residual S4.1 only). --- AGENTS.md | 2 +- docs/rfc-0.2.0-dsl-phases-horizon.md | 419 ++++++++ docs/rfc-0.2.0-dsl-syntax.md | 1 + docs/rfc-0.2.0-s4-decision-record.md | 117 +++ docs/rfc-0.2.0-s4-mvp.md | 64 +- package.json | 3 +- packages/taskflow-core/src/trace.ts | 30 +- packages/taskflow-core/test/trace.test.ts | 16 + packages/taskflow-dsl/package.json | 62 ++ packages/taskflow-dsl/src/build.ts | 194 ++++ packages/taskflow-dsl/src/build/erase.ts | 969 ++++++++++++++++++ packages/taskflow-dsl/src/check.ts | 51 + packages/taskflow-dsl/src/cli.ts | 199 ++++ packages/taskflow-dsl/src/decompile.ts | 134 +++ packages/taskflow-dsl/src/diagnostics.ts | 39 + packages/taskflow-dsl/src/index.ts | 32 + packages/taskflow-dsl/src/new-skeleton.ts | 27 + packages/taskflow-dsl/src/runes.ts | 194 ++++ .../taskflow-dsl/test/erase-build.test.ts | 160 +++ .../taskflow-dsl/test/import-lint.test.ts | 41 + packages/taskflow-dsl/tsconfig.build.json | 13 + pnpm-lock.yaml | 9 + pnpm-workspace.yaml | 1 + 23 files changed, 2753 insertions(+), 24 deletions(-) create mode 100644 docs/rfc-0.2.0-dsl-phases-horizon.md create mode 100644 docs/rfc-0.2.0-s4-decision-record.md create mode 100644 packages/taskflow-dsl/package.json create mode 100644 packages/taskflow-dsl/src/build.ts create mode 100644 packages/taskflow-dsl/src/build/erase.ts create mode 100644 packages/taskflow-dsl/src/check.ts create mode 100644 packages/taskflow-dsl/src/cli.ts create mode 100644 packages/taskflow-dsl/src/decompile.ts create mode 100644 packages/taskflow-dsl/src/diagnostics.ts create mode 100644 packages/taskflow-dsl/src/index.ts create mode 100644 packages/taskflow-dsl/src/new-skeleton.ts create mode 100644 packages/taskflow-dsl/src/runes.ts create mode 100644 packages/taskflow-dsl/test/erase-build.test.ts create mode 100644 packages/taskflow-dsl/test/import-lint.test.ts create mode 100644 packages/taskflow-dsl/tsconfig.build.json diff --git a/AGENTS.md b/AGENTS.md index 4fb26ea..c17e59f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ 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 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`).\ +**Layout:** pnpm-workspace monorepo — `taskflow-core` (engine), `taskflow-mcp-core` (MCP + DAG SVG), `taskflow-hosts` (codex/claude/opencode/grok runners), **`taskflow-dsl`** (S4: `.tf.ts` → Taskflow → FlowIR; CLI `taskflow-dsl`), `pi-taskflow`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, `grok-taskflow` (host delivery packages).\ **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 diff --git a/docs/rfc-0.2.0-dsl-phases-horizon.md b/docs/rfc-0.2.0-dsl-phases-horizon.md new file mode 100644 index 0000000..03cd10f --- /dev/null +++ b/docs/rfc-0.2.0-dsl-phases-horizon.md @@ -0,0 +1,419 @@ +# RFC: DSL 扩展 Phase 设计(脑暴收编 + 语言表面) + +> Status: **Design** · 2026-07-09 +> Source brainstorm: [`internal/brainstorm-2026-07-08-0.2.0-phases.md`](./internal/brainstorm-2026-07-08-0.2.0-phases.md) +> DSL 基线: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 + [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) +> Engine foundation: event log + FlowIR (S0–S3) — *why these phases are cheap now* + +**目标:** 把「0.2.0 脑暴的更强 phase」收成 **DSL 可写的语言形状**,并分轨: + +| 轨 | 含义 | +|----|------| +| **A · 已有能力 / 仅 DSL 糖** | 引擎已有或字段已有,S4 给 rune / 文档 | +| **B · S4.x 引擎 + DSL 一起做** | 新 `PHASE_TYPES` 或大增强;语言先定形,实现跟引擎 | +| **C · experimental 语言预留** | 语法进 `taskflow-dsl/experimental`,引擎未就绪前 build **失败明确**(不静默 no-op) | +| **D · 不做** | 脑暴已否或仍冲突护城河 | + +S4 **MVP ship** 仍是 10 种现有 kind 的基本形态;本文件是 **语言 horizon**,与 MVP 出货门正交——**实现可分期,设计先收齐**。 + +--- + +## 0. 设计原则 + +1. **新 phase = 新 rune**(DSL v2 §7);不塞进万能 `agent({ kind: "..." })`。 +2. **1 phase → 1 FlowIR node**(S0 约束);多节点 lowering 仍 post-0.2.0。 +3. **事件溯源优先**:需要「注入图 / 补偿 / 分叉 / 反事实」的,用 event log 语义描述,不发明第二套状态机。 +4. **JSON 与 DSL 对称**:每个新 type 必须有 JSON 形态;DSL 只是前端。 +5. **未实现 = fail-closed**:experimental rune 若引擎没有 → `TFDSL_PHASE_UNSUPPORTED`,禁止静默删掉。 + +--- + +## 1. 现有 10 kind(S4 MVP 必覆盖基本形态) + +| type | DSL rune | 备注 | +|------|----------|------| +| agent | `agent` | | +| parallel | `parallel` | | +| map | `map` | 逐项增量 = B 轨增强,不是新 type | +| gate | `gate` / `gate.automated` / `gate.scored` | scored 已在引擎;DSL MVP 可后置糖 | +| reduce | `reduce` | | +| approval | `approval` | 超时回退 = B 轨字段增强 | +| flow | `subflow` / `subflow.def` | `def` 引擎已有;DSL MVP 可先 `use` | +| loop | `loop` | 多 phase body = B 轨 | +| tournament | `tournament` | | +| script | `script` | | + +--- + +## 2. 轨 A —— 已有 / 只需 DSL 收齐 + +| 能力 | JSON | DSL | 说明 | +|------|------|-----|------| +| 动态子流 | `type:"flow", def:"{steps.plan.json}"` | `subflow.def(plan.json)` 或 `expand.nested(plan)` | 见 `design-dynamic-dag-expansion`;**嵌套**非 graft | +| 评分 gate | `gate` + `score` | `gate.scored(up, { scorers, threshold, … })` | 引擎已有;S4.1 糖 | +| 自动 gate | `gate` + `eval` | `gate.automated(up, { pass: [...] })` | 同上 | +| reflexion | `loop` + `reflexion:true` | `loop({ reflexion: true, … })` | 字段级 | +| 幂等注解 | `idempotent` | opts | 字段级 | + +**DSL 补形(建议 S4.1,不改 PHASE_TYPES):** + +```ts +// A1 — 动态嵌套子流(引擎已支持 flow.def) +const plan = agent("Emit Taskflow JSON {name, phases}", { + output: json<{ name: string; phases: unknown[] }>(), +}); +const runPlan = subflow.def(plan.json, { /* with? */ }); + +// 别名(文档可写 expand.nested,编译到同一 JSON) +const runPlan2 = expand.nested(plan.json); + +// A2 — 评分 / 自动 gate +gate.scored(gen, { + target: gen.output, + scorers: [{ type: "contains", text: "OK", weight: 1 }], + combine: "weighted", + threshold: 0.8, +}); +gate.automated(build, { pass: ["{steps.build.output} contains 'OK'"] }); +``` + +--- + +## 3. 轨 B —— S4.x 引擎 + DSL 一起做(脑暴稳健前排) + +### B1 · `expand` — 动态图**嫁接**(旗舰 · 新 type) + +> 与 A 轨 `flow.def` 区别:A = **嵌套**子 flow(子命名空间);B1 = **splice 进父 DAG**(父拓扑可见新节点)。 +> 事件溯源治好旧 P0(注入丢失 / 三态就绪 / 并发改数组)——见 brainstorm §0。 + +**JSON 草图:** + +```jsonc +{ + "id": "grow", + "type": "expand", + "from": "{steps.plan.json}", // Taskflow 片段或 phases[] + "mode": "graft", // graft | nested(nested ≡ 今日 flow.def) + "maxNodes": 50, + "dependsOn": ["plan"] +} +``` + +**DSL:** + +```ts +const plan = agent("…", { output: json() }); + +// 默认 graft(父图扩展)—— 需引擎 B1 +const grown = expand(plan.json, { + mode: "graft", + maxNodes: 50, + onInvalid: "fail", // fail | skip +}); + +// 显式嵌套(A 轨,可先编译到 type:flow def) +expand.nested(plan.json); +``` + +**FlowIR:** 1 node `kind:"expand"`;运行时 graft 产生的子节点记入 event log,fold 重建父图。 + +**解锁连锁:** `loop` 多 phase body、局部子图 map 增强可视为 expand 特例。 + +--- + +### B2 · `loop` 多 phase body(增强,非新 type) + +```ts +// 今日 MVP +loop({ + maxIterations: 5, + until: "{steps.refine.json.done} == true", + task: (prev) => `Fix:\n${prev.output}`, +}); + +// B2 — 子图 body(引擎支持 loop 内嵌 phases) +loop({ + maxIterations: 5, + until: "{steps.test.json.pass} == true", + body: (prev) => { + const fix = agent(`Fix based on:\n${prev.output}`); + const test = agent(`Retest:\n${fix.output}`, { + output: json<{ pass: boolean }>(), + }); + return test; // final of body subgraph + }, +}); +``` + +编译:`type:"loop"` + `bodyPhases: Phase[]`(或内部 `def` 子图);**无新 PHASE_TYPES**。 + +--- + +### B3 · `race` — 首个胜出(新 type) + +```ts +const first = race([ + agent("Try codemod path…"), + agent("Try AI rewrite…"), + agent("Try hybrid…"), +], { + cancelLosers: true, // 取消未完成分支 + onCancel: "record", // event log 记账供 replay 算成本 +}); +``` + +**JSON:** `type:"race", branches:[…], cancelLosers?:boolean` +**vs parallel:** parallel 全等;**vs tournament:** tournament 等全部完成再裁判。 + +--- + +### B4 · `compensate` / saga(新 type 或 phase 字段) + +**形态 1 — 声明式补偿表(推荐先做):** + +```ts +const migrate = agent("Apply migration…", { + compensate: script("rollback-migration.sh"), // 失败时沿 log 倒序触发 +}); +``` + +**形态 2 — 一等 phase:** + +```ts +saga({ + steps: [ + { do: agent("create resource"), undo: script("delete resource") }, + { do: agent("wire DNS"), undo: script("unwire DNS") }, + ], +}); +``` + +事件溯源:补偿 = 沿 event log 逆序执行 `undo`;replay 可审计。 + +--- + +### B5 · `route` — 类型化路由(新 type 或 desugar) + +```ts +const classified = agent("…", { + output: json< + | { kind: "bug"; id: string } + | { kind: "feat"; id: string } + | { kind: "chore" } + >(), +}); + +route(classified.json, { + bug: (x) => agent(`Fix bug ${x.id}`), + feat: (x) => agent(`Implement ${x.id}`), + chore: () => script("echo skip"), + // 编译期检查:联合成员穷尽;缺 case → TFDSL_ROUTE_EXHAUST +}); +``` + +可 desugar 为 N 个 `when` + 合成 id;**一等 type** 便于 verify 穷尽性。 + +--- + +### B6 · `map` 逐项增量(运行时增强,DSL 几乎不变) + +```ts +map(files, (item) => agent(`Audit ${item}`), { + incremental: true, // 或继承 top-level incremental + per-item cache(已有部分能力) +}); +``` + +语言侧:opts 透传;旗舰是 **runtime/recompute**,不是新 rune。 + +--- + +### B7 · `approval` 超时 + 回退(字段增强) + +```ts +approval({ + request: "Ship?", + timeoutMs: 86_400_000, + onTimeout: "reject", // reject | approve | agent:"risk-reviewer" +}); +``` + +--- + +### B8 · `watch` — 响应式重跑(新 type · 最大胆稳健轨) + +```ts +watch({ + seed: audit, // 被观察的 phase 输出 / readSet + run: (changed) => agent(`Re-audit ${changed}`), + mode: "on-stale", // on-stale | continuous(continuous 更晚) + maxFires: 10, +}); +``` + +MVP 语言可只支持 `on-stale`(接 recompute 语义);`continuous` 常驻流 → C/D。 + +--- + +## 4. 轨 C —— experimental 语言预留(Moonshot A/B) + +导入: + +```ts +import { fork, counterfactual, quorum, negotiate, escalate, population, selfOptimize, speculate } + from "taskflow-dsl/experimental"; +``` + +| Rune | 一句话 | 依赖 | +|------|--------|------| +| `fork` / savepoint | 命名存档点,从此分叉新 run | event log fork | +| `counterfactual` | 对已跑 phase 换旋钮 offline replay | S3 `replayRun` 用户级 | +| `quorum` | N 路多数/中位 + 分歧度 | parallel + reduce 模式 | +| `negotiate` | 对立辩论收敛 | multi-agent protocol | +| `escalate` | 督导动态增派/杀掉 | org-supervision / SCT | +| `population` | 进化种群 loop | loop 扩展 | +| `selfOptimize` | 读历史 log 自调参 | 跨 run 索引 | +| `speculate` | 并行未来 + 剪枝 | fork + cancel | + +**DSL 示例(仅设计,build 在引擎未就绪时 error):** + +```ts +// 反事实 —— 最短路径接 S3 +const cf = counterfactual(review, { + thresholds: { review: [0.5, 0.7, 0.9] }, + models: { review: ["fast", "strong"] }, +}); +// → 编译为 meta phase;runtime 调 replayRun,零 token + +// 共识 +const voted = quorum([ + agent("Answer A"), + agent("Answer B"), + agent("Answer C"), +], { mode: "majority", emitConfidence: true }); + +// 分叉 +const sp = fork.save("after-plan"); +// 之后 host API / 后续 phase 可 fromSavepoint 开新 run +``` + +--- + +## 5. 轨 D —— 仍不做(语言也不预留假 rune) + +| 方向 | 理由 | +|------|------| +| 可视化拖拽编辑器 | 护城河是代码/JSON | +| 真 stream edges + backpressure | 用 `watch` 响应式,不背流边 | +| 全自主自改写 flow | 成本失控 | +| Flow algebra merge/project | `flow{use}` + decompile 已够 | +| Artifacts 新 type | `ctx_write`/`ctx_read` 已覆盖 | + +--- + +## 6. 推荐纳入「DSL 设计支持」的优先级(给实现排期) + +| 优先级 | 项 | 轨 | 语言工作 | 引擎工作 | +|--------|----|----|----------|----------| +| **P0** | 10 kind 基本 + `subflow.def` / `expand.nested` 糖 | A | S4 | 已有 | +| **P0** | `gate.scored` / `gate.automated` / `reflexion` / `idempotent` 糖 | A | S4.1 | 已有 | +| **P1** | **`expand` graft** | B | rune + IR kind | **新** | +| **P1** | **`loop` multi-body** | B | body 回调 erase | **新** | +| **P1** | **`race`** | B | rune | **新** | +| **P2** | `route` 穷尽路由 | B | rune + check | desugar 或新 type | +| **P2** | `compensate` / saga | B | opts 或 rune | **新** | +| **P2** | approval timeout | B | opts | **小** | +| **P2** | map 逐项增量 | B | opts | recompute | +| **P3** | `watch` on-stale | B | rune | recompute 常驻化 | +| **P3** | `counterfactual` / `quorum` | C | experimental | 粘合 replay / parallel | +| **P4** | fork / speculate / negotiate / … | C | experimental | 研究 spike | + +**「顺便支持脑暴 phase」在语言层的默认承诺:** + +1. **文档 + 类型 + experimental 入口**写齐 B1–B5 + C 的 `counterfactual`/`quorum`/`fork` 草形。 +2. **S4 MVP 编译器**:A 轨能 erase 的 erase;B/C 未实现 kind → **明确诊断**(可先认 type 字符串进 JSON passthrough 给未来引擎,或拒绝——推荐 **S4 拒绝未知 type,S4.x 放行已实现**)。 +3. **不把 Moonshot 全塞进 MVP 出货门**。 + +--- + +## 7. FlowIR / schema 扩展约定 + +新增 `PHASE_TYPES` 时: + +1. `schema.ts` 注册 + `validateTaskflow` +2. `flowir` `FlowIRNodeKind` 闭集扩展 +3. `exec/step` kind 或 imperative 分支 +4. DSL rune + skills +5. 本文件状态表打勾 + +**建议新增 type 名(稳定字符串):** + +| type | rune | +|------|------| +| `expand` | `expand` / `expand.nested` | +| `race` | `race` | +| `compensate` | `compensate` 或 `saga` | +| `route` | `route` | +| `watch` | `watch` | +| `counterfactual` | `counterfactual`(experimental) | +| `quorum` | `quorum`(experimental) | +| `fork` | `fork`(experimental) | + +--- + +## 8. 与 S4 MVP 的关系(改写一句话) + +| 文档 | 范围 | +|------|------| +| `rfc-0.2.0-s4-mvp.md` | **可发布表面**:现有 10 kind 基本 + CLI | +| **本文** | **语言 horizon**:脑暴 phase 的 DSL 形状与分期 | +| 引擎 S4.x / S6 | 按 §6 表落地 type | + +S4 出货门 **不变**(demo FlowIR == hand JSON)。 +S4 实现时 **可提前** 接受 JSON 里未知 type 的透传策略由实现定;DSL erase **只生成已支持 type**。 + +--- + +## 9. 最小「脑暴进语言」示例(作者可见的未来) + +```ts +import { flow, agent, map, race, expand, gate, reduce, json } from "taskflow-dsl"; +// 未实现的: +// import { counterfactual, quorum } from "taskflow-dsl/experimental"; + +export default flow("brain-storm-shaped", (ctx) => { + ctx.budget({ maxUSD: 5 }); + + const discover = agent("List hot paths", { + output: json<{ path: string }[]>(), + }); + + // race:三路谁先好用谁(B3) + const approach = race([ + agent("Static analysis plan…"), + agent("LLM-only plan…"), + agent("Hybrid plan…"), + ], { cancelLosers: true }); + + // expand.nested:今日即可(A);expand graft:B1 + const planJson = agent(`Turn into audit Taskflow JSON. Approach:\n${approach.output}`, { + output: json<{ name: string; phases: unknown[] }>(), + }); + const dynamic = expand.nested(planJson.json); + + const perFile = map(discover, (item) => + agent(`Deep dive ${item.path}`), + ); + + gate(perFile, { agent: "reviewer", onBlock: "retry" }, (i) => + `Quality check:\n${i.output}`, + ); + + return reduce([dynamic, perFile], () => + agent("Merge dynamic plan results + per-file notes"), + ); +}); +``` + +--- + +*一句话:脑暴 phase 不是丢进「以后再说」,而是 **DSL 先有形状、分轨落地**——A 轨立刻进语言糖,B 轨 `expand`/`race`/`loop-body`/`route`/`compensate` 做 S4.x 引擎+语言,C 轨 experimental 接 replay/共识/分叉,且绝不静默假实现。* diff --git a/docs/rfc-0.2.0-dsl-syntax.md b/docs/rfc-0.2.0-dsl-syntax.md index 43883f8..7da80d0 100644 --- a/docs/rfc-0.2.0-dsl-syntax.md +++ b/docs/rfc-0.2.0-dsl-syntax.md @@ -306,6 +306,7 @@ FlowIR + sidecar 已经 lossless(7b48105 补全了 8 个字段)。decompiler 是 - 新 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]` 注释。 +- **0.2.0 脑暴 phase 收编:** 事件溯源解锁的 `expand` / `race` / `compensate` / `route` / `watch` / loop 多 body / map 逐项增量 / `counterfactual` 等 —— **语言形状与分期**见 [`rfc-0.2.0-dsl-phases-horizon.md`](./rfc-0.2.0-dsl-phases-horizon.md)(A 轨 DSL 糖 · B 轨 S4.x 引擎+语言 · C 轨 experimental)。不扩大「现有 10 kind 基本形态」的 S4 MVP 出货门,但 **设计上不再「未定义」。** --- diff --git a/docs/rfc-0.2.0-s4-decision-record.md b/docs/rfc-0.2.0-s4-decision-record.md new file mode 100644 index 0000000..0c09a87 --- /dev/null +++ b/docs/rfc-0.2.0-s4-decision-record.md @@ -0,0 +1,117 @@ +# S4 Shape Decision Record (`taskflow-dsl`) + +> Status: **NEEDS_HUMAN** (route/surface frozen in council; 3 open locks) +> Date: 2026-07-09 +> Full surface: [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) +> Council runs: `s4-shape-council` → `s4-shape-council-v2` → `s4-shape-finalize` + +## One-sentence definition + +**S4 is a new package `taskflow-dsl` that compile-erases `.tf.ts` into Taskflow JSON (then FlowIR only via `taskflow-core`), with whole-file JSON as the zero-migration escape hatch — not a second runtime and not S5.** + +## Execution model (locked) + +| | | +|--|--| +| **Primary** | **Svelte-style** compile-time runes (AST erase; cannot run unbuilt `.tf.ts`) | +| **Escape** | **JSON-only** whole-file (dual frontend at **file** boundary) | +| **Toolchain** | **ts-morph** (+ pinned TypeScript) → Taskflow → `compileTaskflowToFlowIR` | +| **Rejected** | Solid Proxy runtime runes · in-file Vapor hybrid · interpret / auto-build-on-run · S5 prerequisite | + +## Package & CLI + +- **Package:** `taskflow-dsl` +- **Import:** `from "taskflow-dsl"` +- **Bin:** `taskflow-dsl {build,check,decompile,new}` +- **Hosts:** CLI-first; **no** new MCP tools in S4; run via existing `taskflow_run` + emitted `.taskflow.json` +- **Core:** allow schema/validate/desugar/FlowIR compile+hash/verify; **deny** runtime/exec/runner/store/hosts/mcp + +## MVP scope (in) + +1. `flow` + args/budget/concurrency/description +2. All **10 phase kinds** basic runes (closed option types — no scored/automated gate sugar) +3. Template → `{steps.*}` / `{item.*}` erase +4. Auto `dependsOn` from `.output` / `.json` reads + explicit `dependsOn` +5. `when` **string** form (+ TS subset in `check` if cheap) +6. Basic `json()` → `output:"json"` + `expect` (fail-closed on complex types) +7. `check` / `build` / `new` / decompile (Taskflow→`.tf.ts` on Y-slice) +8. Golden **FlowIR hash equality** demos (must include map + templates + `json`, not only hello) +9. Import-lint: DSL must not drag core runtime + +## Brainstorm phases — language support (not all in S4 ship bar) + +Source: `docs/internal/brainstorm-2026-07-08-0.2.0-phases.md` +Design: **`docs/rfc-0.2.0-dsl-phases-horizon.md`** + +| Track | What | When | +|-------|------|------| +| **A** | `subflow.def` / `expand.nested`, `gate.scored`/`automated`, `reflexion`, `idempotent` — **DSL sugar on existing engine** | S4 / S4.1 | +| **B** | New/enhanced: **`expand` graft**, **`race`**, **loop multi-body**, **`route`**, **`compensate`/saga**, approval timeout, map item-incremental, **`watch` on-stale** | S4.x engine + DSL | +| **C** | experimental: `counterfactual`, `quorum`, `fork`, … | language stubs + fail-closed until engine | +| **D** | visual builder, true stream edges, full self-rewriting flow | never / far | + +S4 MVP **compiler** only erases A (where engine exists) + core 10 kinds. B/C runes may exist as **types/docs** but build must **error** if engine kind missing (no silent drop). + +## Explicitly out (S4 ship bar) + +1. Solid runtime / Proxy / degraded interpret +2. In-file JSON phase hybrid +3. Multi-body loop / `flow.component` / `$store` as **MVP ship** (designed in horizon doc; implement later) +4. S5 kernel default ON +5. New MCP `taskflow_build` / host auto-build of `.tf.ts` +6. Literal decompile round-trip marketing +7. 100% PhaseSchema coverage as S4 ship bar (FULL = language goal only) +8. Shipping B-track **engines** inside S4 MVP gate (language design only) + +## Minimal `.tf.ts` example + +```ts +import { flow, agent, map, reduce, json } from "taskflow-dsl"; + +export default flow("audit", (ctx) => { + ctx.budget({ maxUSD: 2 }); + const discover = agent("List files under {args.dir}", { + output: json<{ path: string }[]>(), + }); + const each = map(discover, (item) => + agent(`Audit ${item.path}`), + ); + return reduce([each], () => agent("Write one summary from map outputs")); +}); +``` + +## Acceptance gates + +- [ ] `packages/taskflow-dsl` in workspace; bin + exports as `rfc-0.2.0-s4-mvp.md` §1 +- [ ] Demo `.tf.ts` and twin `.json` → **same** `ir:<64-hex>` +- [ ] Equality fixtures include **map + json\ + templates** +- [ ] Rune runtime call throws `TFDSL_ERASE_ONLY` +- [ ] Import-lint denylist green +- [ ] Skills/docs: JSON first-class for agents; CLI path for DSL +- [ ] DSL v2 note: FULL vs S4 MVP ship gate (authority B1) + +## Open questions for human (max 3) + +1. **H1** Commit coverage matrix as a real doc? (recommend **yes**) +2. **H2** Decompile Taskflow-only in MVP? (recommend **yes**) +3. **H3** Throw-on-call for runes? (recommend **yes**) + +## North-star alignment + +| Slogan | How S4 contributes | +|--------|-------------------| +| **compiled** | First real authoring compiler (erase → Taskflow → FlowIR) | +| **resumable** | Unchanged (runs still Taskflow/events) | +| **incremental** | Unchanged (recompute on built Taskflow) | +| **replayable-for-what-if** | Unchanged (S3); DSL-produced runs replay the same | + +## Council evidence + +| Run | Role | +|-----|------| +| `s4-shape-council-mrd6rcyl-f77e65` | inventory-code, inventory-rfc, coverage-map | +| `s4-shape-council-v2-mrd6wdcj-e34ca3` | routes + tournament (svelte/json-only/ts-morph) + api-surface + adversary | +| `s4-shape-finalize-mrd765gf-f14e1b` | cross-check (PASS on route; BLOCK only until B1/matrix/H2/H3 written) | + +Flow defs: `/tmp/taskflow-s4/s4-shape.json`, `s4-shape-v2.json`, `s4-shape-final.json` +Saved project flow: `.pi/taskflows/s4-shape-council.json` diff --git a/docs/rfc-0.2.0-s4-mvp.md b/docs/rfc-0.2.0-s4-mvp.md index 07ace78..83d4ab5 100644 --- a/docs/rfc-0.2.0-s4-mvp.md +++ b/docs/rfc-0.2.0-s4-mvp.md @@ -1,12 +1,37 @@ # RFC: taskflow 0.2.0 S4 MVP — `taskflow-dsl` public surface -> Status: **Draft** · Design-only · 2026-07-09 +> Status: **PROPOSED — awaiting human lock** · Design-only · 2026-07-09 > Parent: [`rfc-0.2.0-architecture.md`](./rfc-0.2.0-architecture.md) §4.3 / §9 S4 -> Syntax authority: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 +> Syntax authority: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 (**FULL language goal**; ship gate is this MVP doc) > Route lock: north-star 决策1 + this document §0 +> Provenance: multi-agent council runs `s4-shape-council` + `s4-shape-council-v2` + `s4-shape-finalize` (inventory → route tournament → surface → adversary → cross-check) This document freezes the **publishable public surface** of `packages/taskflow-dsl` for the S4 MVP ship gate. It is intentionally narrower than full DSL RFC coverage: what agents and humans import, invoke, and get wrong messages for — not the full transform implementation plan. +### Authority amendment (council B1 — must land with S4) + +| Layer | Meaning | +|-------|---------| +| **DSL RFC v2 FULL** | Long-horizon language goal: every JSON field eventually has a DSL expression (§A). | +| **S4 MVP ship gate (this doc)** | Finite Y-rows + demo `hashFlowIR` equality — **not** every `PhaseSchema` field on day 1. | + +DSL v2 hard constraint “100% 功能覆盖” is **not** the S4 acceptance bar. Implementers must not expand S4 scope to FULL §A. + +### Shape in one screen + +| Decision | Value | +|----------|--------| +| **What S4 is** | New package `taskflow-dsl`: compile-time TypeScript frontend that **erases** `.tf.ts` → Taskflow JSON → (via core only) FlowIR | +| **What S4 is not** | A second runtime, kernel flip (S5), in-file JSON hybrid, or agent-default authoring path | +| **Primary model** | **Svelte-style** compile-time runes (AST erase; no interpret) | +| **Escape** | **Whole-file JSON** only (zero migration; dual frontend at file boundary) | +| **Toolchain** | **ts-morph** (+ pinned `typescript`) → Taskflow → `compileTaskflowToFlowIR` | +| **Package / bin** | `taskflow-dsl` / `taskflow-dsl {build,check,decompile,new}` | +| **Import** | `from "taskflow-dsl"` (not `"taskflow"` in S4) | +| **Hosts** | **CLI-first**; no new MCP / no auto-build on `taskflow_run` | +| **Ship bar** | Golden demos: DSL build FlowIR hash **==** hand JSON FlowIR hash (must include map + `json` + templates, not only hello) | +| **Default author for agents** | Still **JSON** + existing MCP; DSL for humans / large typed flows | + --- ## §0. Route lock (non-negotiable for S4) @@ -714,13 +739,32 @@ FULL RFC coverage remains a post-MVP completion track; missing FULL features mus --- -## §10. Open implementer choices (do not block surface freeze) +## §10. Open implementer choices + +### 10.1 Needs human lock before coding (max 3) + +| # | Question | Council recommendation (default if human silent) | +|---|----------|--------------------------------------------------| +| **H1** | Commit a canonical **coverage matrix** (Y/N per field) next to this RFC? | **Yes** — add `docs/rfc-0.2.0-s4-coverage-matrix.md` from council `coverage-map` output; §8 must link a real table, not “implementer notes”. | +| **H2** | Decompile input: Taskflow-only vs FlowIR too? | **Taskflow-only in MVP**; reject FlowIR with “pass Taskflow JSON”. | +| **H3** | Rune stub at Node runtime: phantom vs throw? | **Throw `TFDSL_ERASE_ONLY`** on any runtime call (types-only for tsc). | + +### 10.2 Non-blocking polish (implementer discretion) + +1. Sync vs async `buildFile` (sync-friendly with ts-morph). +2. Exact synthetic phase id algorithm for anonymous runes (must be deterministic for hash equality fixtures). +3. Default `--emit both` vs `taskflow` only (recommend **taskflow-only** default; FlowIR via `--emit flowir|both` / CI). +4. Optional thin facade over ts-morph `Node` so engine can be swapped later without rewriting erase. + +### 10.3 Recommended PR stack (after human lock) -1. Sync vs async `buildFile` (ts-morph is sync-friendly; Promise ok for future loaders). -2. Whether decompile accepts FlowIR in MVP or Taskflow-only. -3. Phantom return types vs throw-on-call for rune stubs at runtime. -4. Exact synthetic phase id algorithm for anonymous runes. -5. Whether `--emit both` default should switch to `taskflow` only for quieter git diffs (product polish). +1. **Scaffold** `packages/taskflow-dsl` + workspace/filter + import-lint denylist test. +2. **Author stubs + types** (closed MVP option types; throw-on-call). +3. **`check` + `new`** (tsc/Program + rune rules + hello skeleton). +4. **`build` erase vertical slice** — agent → map/templates → parallel → reduce → gate LLM → script; emit Taskflow; call core FlowIR. +5. **Golden parity** — hand JSON twin fixtures; `hashFlowIR` equality (include map + `json`). +6. **`decompile` Taskflow→`.tf.ts`** on Y-slice + fail-closed unsupported. +7. **Docs/skills** — CLI-first workflow; JSON first-class; import string `taskflow-dsl`; amend DSL v2 FULL vs MVP note. --- @@ -732,7 +776,9 @@ FULL RFC coverage remains a post-MVP completion track; missing FULL features mus | `rfc-0.2.0-dsl-syntax.md` | Language semantics (full); MVP subset bound by §8 | | `rfc-0.2.0-three-compile-routes.md` | Historical routes; **superseded for product** by §0 lock (Svelte + JSON escape) | | **`rfc-0.2.0-s4-mvp.md` (this)** | Ship surface freeze for `packages/taskflow-dsl` | +| [`rfc-0.2.0-dsl-phases-horizon.md`](./rfc-0.2.0-dsl-phases-horizon.md) | Brainstorm phases → DSL shapes (expand/race/saga/route/watch/…); A/B/C tracks | +| [`internal/brainstorm-2026-07-08-0.2.0-phases.md`](./internal/brainstorm-2026-07-08-0.2.0-phases.md) | Original phase brainstorm (event-sourced unlocks) | --- -*One-line summary: S4 MVP publishes `taskflow-dsl` as a CLI-first, ts-morph erase frontend — `*.tf.ts` in, Taskflow (+ FlowIR via core) out — with whole-file JSON as the only escape, stable diagnostics, a hard core import allowlist, and no MCP or runtime interpret path.* +*One-line summary: S4 MVP publishes `taskflow-dsl` as a CLI-first, ts-morph erase frontend — `*.tf.ts` in, Taskflow (+ FlowIR via core) out — with whole-file JSON as the only escape, stable diagnostics, a hard core import allowlist, and no MCP or runtime interpret path. Extended brainstorm phases are designed in `rfc-0.2.0-dsl-phases-horizon.md` without expanding the S4 ship bar.* diff --git a/package.json b/package.json index da03107..2b9a923 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "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 && pnpm --filter grok-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 taskflow-dsl 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", @@ -20,6 +20,7 @@ "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:dsl": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --conditions=development --experimental-strip-types --test 'packages/taskflow-dsl/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", diff --git a/packages/taskflow-core/src/trace.ts b/packages/taskflow-core/src/trace.ts index b1a5f00..9ad1ec9 100644 --- a/packages/taskflow-core/src/trace.ts +++ b/packages/taskflow-core/src/trace.ts @@ -20,8 +20,17 @@ * migration. See `replay.ts` for the `ReplayDecision` type stub. */ -import { openSync, readFileSync, renameSync, unlinkSync, writeFileSync, closeSync, existsSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { + openSync, + readFileSync, + renameSync, + unlinkSync, + writeFileSync, + closeSync, + statSync, + mkdirSync, +} from "node:fs"; +import { dirname } from "node:path"; import type { UsageStats } from "./usage.ts"; import type { ScorerResult } from "./scorers.ts"; @@ -148,19 +157,12 @@ export class FileTraceSink implements TraceSink { private readonly buffer = new Map(); private readonly tracePath: string; private readonly lockPath: string; - private readonly dirOk: boolean; constructor(tracePath: string) { this.tracePath = tracePath; this.lockPath = `${tracePath}.lock`; - // Probe writability of the parent dir once; if unwritable, degrade to - // no-op silently (fail-open — trace is best-effort, never run-breaking). - try { - const dir = join(tracePath, ".."); - this.dirOk = existsSync(dir); - } catch { - this.dirOk = false; - } + // Do NOT probe parent-dir existence here: the flow run directory is often + // created later by the first saveRun(). mkdir is deferred to flush(). } emit(event: TraceEvent): void { @@ -176,10 +178,12 @@ export class FileTraceSink implements TraceSink { flush(phaseId: string): void { const events = this.buffer.get(phaseId); this.buffer.delete(phaseId); - if (!events || events.length === 0 || !this.dirOk) return; + if (!events || events.length === 0) return; // Serialize + append under an exclusive lock. Best-effort: any error is - // swallowed (trace is never run-breaking). + // swallowed (trace is never run-breaking). Create the parent dir on first + // flush so a sink constructed before saveRun still records events. try { + mkdirSync(dirname(this.tracePath), { recursive: true }); withExclusiveLock(this.lockPath, () => { const chunk = events.map((e) => JSON.stringify(e)).join("\n") + "\n"; appendAtomic(this.tracePath, chunk); diff --git a/packages/taskflow-core/test/trace.test.ts b/packages/taskflow-core/test/trace.test.ts index 0b3b470..2adedfc 100644 --- a/packages/taskflow-core/test/trace.test.ts +++ b/packages/taskflow-core/test/trace.test.ts @@ -185,6 +185,22 @@ test("FileTraceSink: never throws on an unwritable dir (fail-open)", () => { assert.doesNotThrow(() => sink.flush("p")); }); +test("FileTraceSink: creates missing parent dir on first flush (MCP constructs sink before saveRun)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "trace-mkdir-")); + const nested = path.join(root, "flow-name", "run.trace.jsonl"); + // Parent flow-name/ does not exist yet — mirrors first-run MCP path. + assert.equal(fs.existsSync(path.dirname(nested)), false); + const sink = new FileTraceSink(nested); + sink.emit({ ts: 1, runId: "r", phaseId: "hello", kind: "phase-start" }); + sink.emit({ ts: 2, runId: "r", phaseId: "hello", kind: "phase-end", status: "done" }); + assert.doesNotThrow(() => sink.flush("hello")); + const events = readTrace(nested); + assert.equal(events.length, 2); + assert.equal(events[0]!.kind, "phase-start"); + assert.equal(events[1]!.kind, "phase-end"); + fs.rmSync(root, { recursive: true, force: true }); +}); + // ─── decision events: gate verdict + unreplayable marker ───────────────────── test("trace: a gate phase emits a gate-verdict decision event", async () => { diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json new file mode 100644 index 0000000..a6a46c8 --- /dev/null +++ b/packages/taskflow-dsl/package.json @@ -0,0 +1,62 @@ +{ + "name": "taskflow-dsl", + "version": "0.1.7", + "description": "Compile-time TypeScript DSL frontend for taskflow: erase .tf.ts runes to Taskflow JSON, then FlowIR via taskflow-core.", + "keywords": ["taskflow", "dsl", "typescript", "dag", "orchestration"], + "license": "MIT", + "author": "heggria ", + "homepage": "https://github.com/heggria/taskflow#readme", + "repository": { + "type": "git", + "url": "git+https://github.com/heggria/taskflow.git", + "directory": "packages/taskflow-dsl" + }, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "taskflow-dsl": "./dist/cli.js" + }, + "exports": { + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./build": { + "development": "./src/build.ts", + "types": "./dist/build.d.ts", + "default": "./dist/build.js" + }, + "./check": { + "development": "./src/check.ts", + "types": "./dist/check.d.ts", + "default": "./dist/check.js" + }, + "./decompile": { + "development": "./src/decompile.ts", + "types": "./dist/decompile.d.ts", + "default": "./dist/decompile.js" + }, + "./diagnostics": { + "development": "./src/diagnostics.ts", + "types": "./dist/diagnostics.d.ts", + "default": "./dist/diagnostics.js" + } + }, + "files": ["dist"], + "scripts": { + "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs taskflow-dsl 2>/dev/null || true", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "taskflow-core": "workspace:*", + "typescript": "^6.0.3" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/taskflow-dsl/src/build.ts b/packages/taskflow-dsl/src/build.ts new file mode 100644 index 0000000..c66051a --- /dev/null +++ b/packages/taskflow-dsl/src/build.ts @@ -0,0 +1,194 @@ +/** + * Library API: buildFile / buildSource → Taskflow (+ optional FlowIR). + */ + +import fs from "node:fs"; +import path from "node:path"; +import { + compileTaskflowToFlowIR, + hashFlowIR, + validateTaskflow, + desugar, + type Taskflow, +} from "taskflow-core"; +// desugar: only for JSON shorthand escape path +import { eraseSource } from "./build/erase.ts"; +import type { Diagnostic } from "./diagnostics.ts"; +import { hasErrors } from "./diagnostics.ts"; + +export type EmitMode = "taskflow" | "flowir" | "both"; + +export interface BuildOptions { + /** Emit mode when writing files (library default: in-memory taskflow only). */ + emit?: EmitMode; + /** Also run validateTaskflow (default true). */ + validate?: boolean; + /** Compute FlowIR hash (default true when flowir requested). */ + irHash?: boolean; +} + +export interface BuildResult { + ok: boolean; + diagnostics: Diagnostic[]; + taskflow?: Taskflow; + flowir?: unknown; + irHash?: string; + file?: string; +} + +export function buildSource(sourceText: string, file = "flow.tf.ts", opts: BuildOptions = {}): BuildResult { + const erased = eraseSource(sourceText, file); + if (!erased.ok || !erased.taskflow) { + return { ok: false, diagnostics: erased.diagnostics, file }; + } + + const diagnostics = [...erased.diagnostics]; + const validate = opts.validate !== false; + if (validate) { + const v = validateTaskflow(erased.taskflow); + if (!v.ok) { + for (const e of v.errors) { + diagnostics.push({ + code: "TFDSL_CORE_VALIDATE", + severity: "error", + message: e, + file, + }); + } + return { ok: false, diagnostics, file }; + } + } + + // Full documents already have `phases` — do NOT call desugar (that API is + // shorthand-only: task/tasks/chain). validateTaskflow already accepted the shape. + const taskflow = erased.taskflow as Taskflow; + let flowir: unknown; + let irHash: string | undefined; + const wantIr = opts.emit === "flowir" || opts.emit === "both" || opts.irHash !== false; + if (wantIr) { + try { + const compiled = compileTaskflowToFlowIR(taskflow); + flowir = compiled.canonical; + irHash = hashFlowIR(compiled.canonical); + } catch (e) { + diagnostics.push({ + code: "TFDSL_CORE_IR", + severity: "error", + message: e instanceof Error ? e.message : String(e), + file, + }); + return { ok: false, diagnostics, file }; + } + } + + return { + ok: !hasErrors(diagnostics), + diagnostics, + taskflow, + flowir, + irHash, + file, + }; +} + +export function buildFile(filePath: string, opts: BuildOptions = {}): BuildResult { + const abs = path.resolve(filePath); + if (!fs.existsSync(abs)) { + return { + ok: false, + diagnostics: [ + { + code: "TFDSL_IO_MISSING", + severity: "error", + message: `File not found: ${abs}`, + file: abs, + }, + ], + file: abs, + }; + } + const ext = path.extname(abs).toLowerCase(); + if (ext === ".json" || ext === ".jsonc") { + return buildJsonFile(abs, opts); + } + if (!abs.endsWith(".tf.ts") && ext !== ".ts") { + return { + ok: false, + diagnostics: [ + { + code: "TFDSL_INPUT_KIND", + severity: "error", + message: `Unsupported input kind (expected .tf.ts or .json): ${abs}`, + file: abs, + }, + ], + file: abs, + }; + } + const text = fs.readFileSync(abs, "utf8"); + return buildSource(text, abs, opts); +} + +function buildJsonFile(abs: string, opts: BuildOptions): BuildResult { + const text = fs.readFileSync(abs, "utf8"); + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (e) { + return { + ok: false, + diagnostics: [ + { + code: "TFDSL_IO_JSON", + severity: "error", + message: e instanceof Error ? e.message : String(e), + file: abs, + }, + ], + file: abs, + }; + } + const diagnostics: Diagnostic[] = []; + // JSON escape: allow full Taskflow or shorthand (task/tasks/chain). + let taskflow: Taskflow; + const asRec = parsed as Record; + if (Array.isArray(asRec.phases)) { + const v = validateTaskflow(parsed); + if (!v.ok) { + for (const e of v.errors) { + diagnostics.push({ code: "TFDSL_CORE_VALIDATE", severity: "error", message: e, file: abs }); + } + return { ok: false, diagnostics, file: abs }; + } + taskflow = parsed as Taskflow; + } else { + try { + taskflow = desugar(parsed); + } catch (e) { + diagnostics.push({ + code: "TFDSL_CORE_DESUGAR", + severity: "error", + message: e instanceof Error ? e.message : String(e), + file: abs, + }); + return { ok: false, diagnostics, file: abs }; + } + const v = validateTaskflow(taskflow); + if (!v.ok) { + for (const e of v.errors) { + diagnostics.push({ code: "TFDSL_CORE_VALIDATE", severity: "error", message: e, file: abs }); + } + return { ok: false, diagnostics, file: abs }; + } + } + let flowir: unknown; + let irHash: string | undefined; + if (opts.emit === "flowir" || opts.emit === "both" || opts.irHash !== false) { + const compiled = compileTaskflowToFlowIR(taskflow); + flowir = compiled.canonical; + irHash = hashFlowIR(compiled.canonical); + } + return { ok: true, diagnostics, taskflow, flowir, irHash, file: abs }; +} + +export { eraseSource } from "./build/erase.ts"; diff --git a/packages/taskflow-dsl/src/build/erase.ts b/packages/taskflow-dsl/src/build/erase.ts new file mode 100644 index 0000000..6bb1ee0 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase.ts @@ -0,0 +1,969 @@ +/** + * AST erase: .tf.ts source → Taskflow JSON (no execution of runes). + * Uses TypeScript compiler API (read-only Program/SourceFile). + */ + +import ts from "typescript"; +import type { Diagnostic } from "../diagnostics.ts"; + +export interface EraseResult { + ok: boolean; + taskflow?: Record; + diagnostics: Diagnostic[]; +} + +const PHASE_RUNES = new Set([ + "agent", + "parallel", + "map", + "gate", + "reduce", + "approval", + "subflow", + "loop", + "tournament", + "script", + "race", +]); + +interface PhaseDraft { + id: string; + type: string; + raw: Record; + dependsOn: Set; + final?: boolean; +} + +function posOf(sf: ts.SourceFile, node: ts.Node): { line: number; character: number } { + const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + return { line: line + 1, character: character + 1 }; +} + +function diag( + file: string, + sf: ts.SourceFile, + node: ts.Node, + code: string, + message: string, + severity: Diagnostic["severity"] = "error", + hint?: string, +): Diagnostic { + const p = posOf(sf, node); + return { + code, + severity, + message, + file, + range: { line: p.line, character: p.character }, + hint, + }; +} + +function isIdentifier(n: ts.Node, name: string): boolean { + return ts.isIdentifier(n) && n.text === name; +} + +function calleeName(expr: ts.Expression): string | undefined { + if (ts.isIdentifier(expr)) return expr.text; + if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) { + return `${expr.expression.text}.${expr.name.text}`; + } + return undefined; +} + +function evalLiteral(node: ts.Expression): unknown { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if (ts.isArrayLiteralExpression(node)) { + return node.elements.map((e) => (ts.isSpreadElement(e) ? undefined : evalLiteral(e as ts.Expression))); + } + if (ts.isObjectLiteralExpression(node)) { + const o: Record = {}; + for (const p of node.properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name)) { + o[p.name.text] = evalLiteral(p.initializer); + } else if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.name)) { + o[p.name.text] = evalLiteral(p.initializer); + } + } + return o; + } + return undefined; +} + +/** Convert template / string expr to task string + deps. */ +function eraseStringish( + sf: ts.SourceFile, + file: string, + node: ts.Expression, + itemParam: string | undefined, + phases: Map, + diags: Diagnostic[], +): { text: string; deps: string[] } | undefined { + const deps: string[] = []; + + const pushDep = (id: string) => { + if (phases.has(id) && !deps.includes(id)) deps.push(id); + }; + + const propToPlaceholder = (expr: ts.Expression): string | undefined => { + // item.foo / item + if (ts.isIdentifier(expr) && itemParam && expr.text === itemParam) return "{item}"; + if ( + ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + itemParam && + expr.expression.text === itemParam + ) { + return `{item.${expr.name.text}}`; + } + // phase.output / phase.json / phase.json.field + if (ts.isPropertyAccessExpression(expr)) { + const chain: string[] = []; + let cur: ts.Expression = expr; + while (ts.isPropertyAccessExpression(cur)) { + chain.unshift(cur.name.text); + cur = cur.expression; + } + if (ts.isIdentifier(cur) && phases.has(cur.text)) { + pushDep(cur.text); + if (chain[0] === "output" && chain.length === 1) return `{steps.${cur.text}.output}`; + if (chain[0] === "json") { + if (chain.length === 1) return `{steps.${cur.text}.json}`; + return `{steps.${cur.text}.json.${chain.slice(1).join(".")}}`; + } + } + } + // args.x + if ( + ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === "args" + ) { + return `{args.${expr.name.text}}`; + } + return undefined; + }; + + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return { text: node.text, deps }; + } + + if (ts.isTemplateExpression(node)) { + let text = node.head.text; + for (const span of node.templateSpans) { + const ph = propToPlaceholder(span.expression); + if (ph) { + text += ph; + } else { + // try simple identifiers that are phases → .output + if (ts.isIdentifier(span.expression) && phases.has(span.expression.text)) { + pushDep(span.expression.text); + text += `{steps.${span.expression.text}.output}`; + } else { + diags.push( + diag( + file, + sf, + span.expression, + "TFDSL_TMPL_UNERASABLE", + `Cannot erase template expression to a placeholder (only phase.output/json, item.*, args.* supported in MVP).`, + ), + ); + return undefined; + } + } + text += span.literal.text; + } + return { text, deps }; + } + + // Identifier phase ref alone is not a string task + if (ts.isIdentifier(node)) { + diags.push(diag(file, sf, node, "TFDSL_RUNE_ARG", `Expected string or template for task text.`)); + return undefined; + } + + const lit = evalLiteral(node); + if (typeof lit === "string") return { text: lit, deps }; + + diags.push(diag(file, sf, node, "TFDSL_RUNE_ARG", `Expected static string/template task text.`)); + return undefined; +} + +function mergeOpts( + sf: ts.SourceFile, + file: string, + obj: ts.Expression | undefined, + diags: Diagnostic[], + phases: Map, +): Record { + if (!obj) return {}; + if (!ts.isObjectLiteralExpression(obj)) { + diags.push(diag(file, sf, obj, "TFDSL_RUNE_OPTS", `Phase options must be an object literal.`)); + return {}; + } + const out: Record = {}; + for (const p of obj.properties) { + if (!ts.isPropertyAssignment(p)) continue; + const key = ts.isIdentifier(p.name) + ? p.name.text + : ts.isStringLiteral(p.name) + ? p.name.text + : undefined; + if (!key) continue; + + if (key === "dependsOn" && ts.isArrayLiteralExpression(p.initializer)) { + const ids: string[] = []; + for (const el of p.initializer.elements) { + if (ts.isStringLiteral(el)) ids.push(el.text); + else if (ts.isIdentifier(el) && phases.has(el.text)) ids.push(el.text); + } + out.dependsOn = ids; + continue; + } + + if (key === "output") { + if (ts.isCallExpression(p.initializer)) { + const cn = calleeName(p.initializer.expression); + if (cn === "json") { + out.output = "json"; + out.expect = { type: "object" }; + continue; + } + } + const v = evalLiteral(p.initializer); + if (v === "json" || v === "text") { + out.output = v; + if (v === "json" && out.expect === undefined) out.expect = { type: "object" }; + continue; + } + diags.push( + diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS", `output must be "json" | "text" or json().`), + ); + continue; + } + + if (key === "agent" || key === "model" || key === "when" || key === "join" || key === "cwd") { + const v = evalLiteral(p.initializer); + if (v !== undefined) out[key] = v; + continue; + } + if (key === "final" || key === "optional" || key === "idempotent" || key === "reflexion" || key === "convergence") { + const v = evalLiteral(p.initializer); + if (typeof v === "boolean") out[key] = v; + continue; + } + if (key === "timeout" || key === "concurrency" || key === "maxIterations" || key === "variants") { + const v = evalLiteral(p.initializer); + if (typeof v === "number") out[key] = v; + continue; + } + if (key === "retry" || key === "expect" || key === "tools" || key === "thinking") { + const v = evalLiteral(p.initializer); + if (v !== undefined) out[key] = v; + continue; + } + if (key === "id") { + const v = evalLiteral(p.initializer); + if (typeof v === "string") out.id = v; + continue; + } + if (key === "input" || key === "request" || key === "until" || key === "judge" || key === "judgeAgent" || key === "mode" || key === "use" || key === "onBlock") { + const v = evalLiteral(p.initializer); + if (v !== undefined) out[key] = v; + continue; + } + // Unknown keys: warn (fail-open for forward-compat fields, never silent on known mistakes) + diags.push( + diag( + file, + sf, + p, + "TFDSL_RUNE_OPTS_UNKNOWN", + `Unknown or non-static option '${key}' ignored in MVP erase.`, + "warning", + ), + ); + } + return out; +} + +function phaseIdFromBinding(name: string, opts: Record): string { + if (typeof opts.id === "string" && opts.id) return opts.id; + // convert camelCase binding to kebab for JSON culture, keep as-is if already simple + return name; +} + +export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResult { + const diags: Diagnostic[] = []; + const sf = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + // Find export default flow(...) + let flowCall: ts.CallExpression | undefined; + for (const stmt of sf.statements) { + if (!ts.isExportAssignment(stmt) || stmt.isExportEquals) continue; + if (ts.isCallExpression(stmt.expression)) { + const cn = calleeName(stmt.expression.expression); + if (cn === "flow") { + flowCall = stmt.expression; + break; + } + } + } + if (!flowCall) { + diags.push({ + code: "TFDSL_ENTRY_MISSING", + severity: "error", + message: `Expected \`export default flow("name", …)\`.`, + file, + hint: "Use `taskflow-dsl new` for a skeleton.", + }); + return { ok: false, diagnostics: diags }; + } + + const args = flowCall.arguments; + if (args.length < 2) { + diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_ARGS", `flow() requires name and callback.`)); + return { ok: false, diagnostics: diags }; + } + + const nameArg = args[0]!; + if (!ts.isStringLiteral(nameArg)) { + diags.push(diag(file, sf, nameArg, "TFDSL_ENTRY_NAME", `flow name must be a string literal.`)); + return { ok: false, diagnostics: diags }; + } + const flowName = nameArg.text; + + let flowOpts: Record = {}; + let bodyFn: ts.ArrowFunction | ts.FunctionExpression | undefined; + if (args.length === 2) { + const a1 = args[1]!; + if (ts.isArrowFunction(a1) || ts.isFunctionExpression(a1)) bodyFn = a1; + } else { + const a1 = args[1]!; + const a2 = args[2]!; + if (ts.isObjectLiteralExpression(a1)) { + flowOpts = (evalLiteral(a1) as Record) ?? {}; + } + if (ts.isArrowFunction(a2) || ts.isFunctionExpression(a2)) bodyFn = a2; + } + if (!bodyFn) { + diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_BODY", `flow() body must be an arrow or function expression.`)); + return { ok: false, diagnostics: diags }; + } + + const phases = new Map(); + const order: string[] = []; + let topArgs: Record | undefined; + let concurrency: number | undefined; + let budget: Record | undefined; + let finalId: string | undefined; + + const body = bodyFn.body; + const statements: ts.Statement[] = ts.isBlock(body) + ? [...body.statements] + : [ts.factory.createReturnStatement(body as ts.Expression)]; + + const handleCall = ( + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, + ): string | undefined => { + const cn = calleeName(call.expression); + if (!cn) return undefined; + + // expand.nested / subflow.def → flow phase + if (cn === "expand.nested" || cn === "subflow.def") { + const id = bindName ?? `flow-${order.length}`; + const draft: PhaseDraft = { + id, + type: "flow", + raw: { type: "flow" }, + dependsOn: new Set(), + }; + const defArg = call.arguments[0]; + if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { + const pid = defArg.expression.text; + if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { + draft.dependsOn.add(pid); + draft.raw.def = defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (defArg && ts.isStringLiteral(defArg)) { + draft.raw.def = defArg.text; + } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { + draft.dependsOn.add(defArg.text); + draft.raw.def = `{steps.${defArg.text}.json}`; + } + const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + phases.set(draft.id, draft); + order.push(draft.id); + return draft.id; + } + + if (cn === "subflow") { + const id = bindName ?? `flow-${order.length}`; + const draft: PhaseDraft = { id, type: "flow", raw: { type: "flow" }, dependsOn: new Set() }; + const useArg = call.arguments[0]; + if (useArg && ts.isStringLiteral(useArg)) draft.raw.use = useArg.text; + else diags.push(diag(file, sf, call, "TFDSL_RUNE_ARG", `subflow(use) requires a string name.`)); + if (call.arguments[1] && ts.isObjectLiteralExpression(call.arguments[1])) { + draft.raw.with = evalLiteral(call.arguments[1]); + } + const opts = mergeOpts(sf, file, call.arguments[2] as ts.Expression | undefined, diags, phases); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + phases.set(draft.id, draft); + order.push(draft.id); + return draft.id; + } + + if (!PHASE_RUNES.has(cn.split(".")[0]!) && !PHASE_RUNES.has(cn)) { + if (cn === "json") return undefined; + // allow ignore non-phase + return undefined; + } + + const type = cn === "race" ? "race" : cn; + if (type === "race") { + diags.push( + diag( + file, + sf, + call, + "TFDSL_PHASE_UNSUPPORTED", + `Phase type "race" is designed (horizon B) but not implemented in the engine yet.`, + "error", + "Remove race() or wait for S4.x engine support.", + ), + ); + return undefined; + } + + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type, + raw: { type }, + dependsOn: new Set(), + }; + + if (type === "agent" || type === "script") { + const taskArg = call.arguments[0]; + const optsArg = call.arguments[1] as ts.Expression | undefined; + if (type === "agent" && taskArg) { + const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); + if (erased) { + draft.raw.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + } + if (type === "script" && taskArg) { + if (ts.isArrayLiteralExpression(taskArg)) { + const arr = taskArg.elements.map((el) => { + if (ts.isStringLiteral(el)) return el.text; + const er = eraseStringish(sf, file, el as ts.Expression, itemParam, phases, diags); + if (er) { + for (const d of er.deps) draft.dependsOn.add(d); + return er.text; + } + return ""; + }); + draft.raw.run = arr; + } else { + const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); + if (erased) { + draft.raw.run = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + } + } + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + else draft.id = phaseIdFromBinding(idBase, opts); + Object.assign(draft.raw, opts); + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + } else if (type === "map") { + const overArg = call.arguments[0]; + const fnArg = call.arguments[1]; + const optsArg = call.arguments[2] as ts.Expression | undefined; + if (overArg && ts.isIdentifier(overArg) && phases.has(overArg.text)) { + draft.dependsOn.add(overArg.text); + draft.raw.over = `{steps.${overArg.text}.json}`; + } else if (overArg && ts.isPropertyAccessExpression(overArg) && ts.isIdentifier(overArg.expression)) { + const pid = overArg.expression.text; + if (phases.has(pid)) { + draft.dependsOn.add(pid); + draft.raw.over = + overArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (overArg && (ts.isStringLiteral(overArg) || ts.isNoSubstitutionTemplateLiteral(overArg))) { + draft.raw.over = overArg.text; + } + let itemName = "item"; + if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { + const p0 = fnArg.parameters[0]; + if (p0 && ts.isIdentifier(p0.name)) itemName = p0.name.text; + draft.raw.as = itemName; + // body: agent(...) or block with return + let inner: ts.Expression | undefined; + if (ts.isBlock(fnArg.body)) { + for (const st of fnArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) inner = st.expression; + } + } else { + inner = fnArg.body; + } + if (inner && ts.isCallExpression(inner)) { + const innerCn = calleeName(inner.expression); + if (innerCn === "agent") { + const erased = eraseStringish(sf, file, inner.arguments[0]!, itemName, phases, diags); + if (erased) { + draft.raw.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const iopts = mergeOpts(sf, file, inner.arguments[1] as ts.Expression | undefined, diags, phases); + if (iopts.agent) draft.raw.agent = iopts.agent; + if (iopts.output) draft.raw.output = iopts.output; + } + } + } + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + } else if (type === "parallel") { + const arr = call.arguments[0]; + const optsArg = call.arguments[1] as ts.Expression | undefined; + const branches: Array> = []; + if (arr && ts.isArrayLiteralExpression(arr)) { + for (const el of arr.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); + const b: Record = {}; + if (erased) { + b.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(b, bopts); + branches.push(b); + } + } + } + draft.raw.branches = branches; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + } else if (type === "gate") { + const up = call.arguments[0]; + if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); + const optsArg = call.arguments[1] as ts.Expression | undefined; + const taskArg = call.arguments[2] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + if (taskArg && (ts.isArrowFunction(taskArg) || ts.isFunctionExpression(taskArg))) { + const p0 = taskArg.parameters[0]; + const param = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "i"; + let expr: ts.Expression | undefined = ts.isBlock(taskArg.body) + ? undefined + : (taskArg.body as ts.Expression); + if (ts.isBlock(taskArg.body)) { + for (const st of taskArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } + if (expr) { + // Gate-only rewrite: (i) => `…${i.output}` — do NOT call eraseStringish first + // (it would emit TFDSL_TMPL_UNERASABLE for the lambda param). + const re = eraseGateTask( + sf, + file, + expr, + param, + up && ts.isIdentifier(up) ? up.text : undefined, + phases, + diags, + ); + if (re) { + draft.raw.task = re.text; + for (const d of re.deps) draft.dependsOn.add(d); + } + } + } + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + } else if (type === "reduce") { + const fromArg = call.arguments[0]; + const fnArg = call.arguments[1]; + const optsArg = call.arguments[2] as ts.Expression | undefined; + const fromIds: string[] = []; + if (fromArg && ts.isArrayLiteralExpression(fromArg)) { + for (const el of fromArg.elements) { + if (ts.isIdentifier(el) && phases.has(el.text)) { + fromIds.push(el.text); + draft.dependsOn.add(el.text); + } + } + } + draft.raw.from = fromIds; + if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { + let expr: ts.Expression | undefined; + if (ts.isBlock(fnArg.body)) { + for (const st of fnArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } else expr = fnArg.body; + if (expr && ts.isCallExpression(expr) && calleeName(expr.expression) === "agent") { + if (expr.arguments[0]) { + const t2 = eraseReduceTask(sf, file, expr.arguments[0]!, fnArg, phases, diags); + if (t2) { + draft.raw.task = t2.text; + for (const d of t2.deps) draft.dependsOn.add(d); + } + } + const iopts = mergeOpts(sf, file, expr.arguments[1] as ts.Expression | undefined, diags, phases); + if (iopts.agent) draft.raw.agent = iopts.agent; + } + } + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + } else if (type === "approval") { + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.request === "string") draft.raw.task = opts.request; + Object.assign(draft.raw, opts); + delete draft.raw.request; + if (typeof opts.id === "string") draft.id = opts.id; + if (opts.final === true) draft.final = true; + } else if (type === "loop") { + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + Object.assign(draft.raw, opts); + // task: (prev) => `...` inside object — scan object for task method + if (optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "task") { + if (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)) { + const prev = p.initializer.parameters[0]; + const prevName = prev && ts.isIdentifier(prev.name) ? prev.name.text : "prev"; + let expr: ts.Expression | undefined = ts.isBlock(p.initializer.body) + ? undefined + : (p.initializer.body as ts.Expression); + if (ts.isBlock(p.initializer.body)) { + for (const st of p.initializer.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } + if (expr) { + const er = eraseLoopTask(sf, file, expr, prevName, draft.id, diags); + if (er) draft.raw.task = er; + } + } else { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) draft.raw.task = er.text; + } + } + } + } + if (typeof opts.id === "string") draft.id = opts.id; + if (opts.final === true) draft.final = true; + } else if (type === "tournament") { + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + Object.assign(draft.raw, opts); + if (optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "branches" && ts.isArrayLiteralExpression(p.initializer)) { + const branches: Array> = []; + for (const el of p.initializer.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish(sf, file, el.arguments[0]!, undefined, phases, diags); + const b: Record = {}; + if (erased) b.task = erased.text; + const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(b, bopts); + branches.push(b); + } + } + draft.raw.branches = branches; + } + if (p.name.text === "task") { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) draft.raw.task = er.text; + } + } + } + if (typeof opts.id === "string") draft.id = opts.id; + if (opts.final === true) draft.final = true; + } + + // strip non-schema keys + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (draft.final) draft.raw.final = true; + + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; + }; + + for (const st of statements) { + // ctx.budget / concurrency / args.declare + if (ts.isExpressionStatement(st) && ts.isCallExpression(st.expression)) { + const call = st.expression; + if (ts.isPropertyAccessExpression(call.expression)) { + const obj = call.expression.expression; + const method = call.expression.name.text; + // ctx.budget / ctx.concurrency + if (ts.isIdentifier(obj) && (method === "budget" || method === "concurrency")) { + const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; + if (method === "budget" && v && typeof v === "object") budget = v as Record; + if (method === "concurrency" && typeof v === "number") concurrency = v; + continue; + } + // ctx.args.declare + if ( + ts.isPropertyAccessExpression(obj) && + ts.isIdentifier(obj.expression) && + obj.name.text === "args" && + method === "declare" + ) { + const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; + if (v && typeof v === "object") topArgs = v as Record; + continue; + } + } + // bare call without binding (gate, etc.) + if (ts.isCallExpression(call)) { + const id = handleCall(undefined, call); + if (id) { + /* anonymous phase */ + } + } + } + + if (ts.isVariableStatement(st)) { + for (const decl of st.declarationList.declarations) { + if (!decl.initializer) continue; + // const [a,b] = parallel(...) + if (ts.isArrayBindingPattern(decl.name) && ts.isCallExpression(decl.initializer)) { + const cn = calleeName(decl.initializer.expression); + if (cn === "parallel") { + handleCall("parallel-0", decl.initializer); + } + continue; + } + if (!ts.isIdentifier(decl.name)) continue; + const name = decl.name.text; + if (ts.isCallExpression(decl.initializer)) { + handleCall(name, decl.initializer); + } else if ( + ts.isAsExpression(decl.initializer) && + ts.isCallExpression(decl.initializer.expression) + ) { + handleCall(name, decl.initializer.expression); + } + } + } + + if (ts.isReturnStatement(st) && st.expression) { + if (ts.isIdentifier(st.expression) && phases.has(st.expression.text)) { + finalId = st.expression.text; + const ph = phases.get(finalId)!; + ph.final = true; + ph.raw.final = true; + } else if (ts.isCallExpression(st.expression)) { + const id = handleCall(order.length === 0 ? "main" : `phase-${order.length}`, st.expression); + if (id) { + finalId = id; + const ph = phases.get(id)!; + ph.final = true; + ph.raw.final = true; + } + } + } + } + + // Warn phases with no deps (not first) + for (let i = 0; i < order.length; i++) { + const id = order[i]!; + const ph = phases.get(id)!; + if (i > 0 && ph.dependsOn.size === 0 && !Array.isArray(ph.raw.dependsOn)) { + diags.push({ + code: "TFDSL_DEP_NONE", + severity: "warning", + message: `Phase '${id}' has no automatic dependencies and is not first — add dependsOn if order matters.`, + file, + }); + } + } + + if (order.length === 0) { + diags.push({ + code: "TFDSL_ENTRY_EMPTY", + severity: "error", + message: `No phases found in flow body.`, + file, + }); + return { ok: false, diagnostics: diags }; + } + + // Ensure one final + if (!finalId) { + const last = order[order.length - 1]!; + phases.get(last)!.raw.final = true; + } + + const phaseList = order.map((id) => { + const ph = phases.get(id)!; + const raw = { ...ph.raw, id: ph.id }; + if (ph.dependsOn.size && !raw.dependsOn) raw.dependsOn = [...ph.dependsOn]; + // clean undefined-ish + return raw; + }); + + const taskflow: Record = { + name: flowName, + phases: phaseList, + }; + if (typeof flowOpts.description === "string") taskflow.description = flowOpts.description; + if (typeof flowOpts.version === "number") taskflow.version = flowOpts.version; + if (topArgs) taskflow.args = topArgs; + if (concurrency !== undefined) taskflow.concurrency = concurrency; + if (budget) taskflow.budget = budget; + + const ok = !diags.some((d) => d.severity === "error"); + return { ok, taskflow: ok ? taskflow : undefined, diagnostics: diags }; +} + +function eraseGateTask( + sf: ts.SourceFile, + file: string, + expr: ts.Expression, + param: string, + upstreamId: string | undefined, + phases: Map, + diags: Diagnostic[], +): { text: string; deps: string[] } | undefined { + const deps: string[] = []; + if (upstreamId) deps.push(upstreamId); + + const rewrite = (e: ts.Expression): string | undefined => { + if ( + ts.isPropertyAccessExpression(e) && + ts.isIdentifier(e.expression) && + e.expression.text === param && + (e.name.text === "output" || e.name.text === "json") + ) { + if (!upstreamId) return undefined; + return e.name.text === "output" ? `{steps.${upstreamId}.output}` : `{steps.${upstreamId}.json}`; + } + return undefined; + }; + + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, deps }; + if (ts.isTemplateExpression(expr)) { + let text = expr.head.text; + for (const span of expr.templateSpans) { + const ph = rewrite(span.expression); + if (ph) text += ph; + else { + const er = eraseStringish(sf, file, span.expression, undefined, phases, diags); + if (!er) return undefined; + text += er.text; + for (const d of er.deps) if (!deps.includes(d)) deps.push(d); + } + text += span.literal.text; + } + return { text, deps }; + } + return undefined; +} + +function eraseLoopTask( + sf: ts.SourceFile, + file: string, + expr: ts.Expression, + prevName: string, + loopId: string, + diags: Diagnostic[], +): string | undefined { + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text; + if (!ts.isTemplateExpression(expr)) { + diags.push(diag(file, sf, expr, "TFDSL_RUNE_ARG", `loop task must be string or template.`)); + return undefined; + } + let text = expr.head.text; + for (const span of expr.templateSpans) { + const e = span.expression; + if ( + ts.isPropertyAccessExpression(e) && + ts.isIdentifier(e.expression) && + e.expression.text === prevName && + e.name.text === "output" + ) { + text += `{steps.${loopId}.output}`; + } else if (ts.isIdentifier(e) && e.text === "loop") { + text += `{loop.iteration}`; + } else { + diags.push(diag(file, sf, e, "TFDSL_TMPL_UNERASABLE", `Unsupported expression in loop task template.`)); + return undefined; + } + text += span.literal.text; + } + return text; +} + +function eraseReduceTask( + sf: ts.SourceFile, + file: string, + expr: ts.Expression, + fn: ts.ArrowFunction | ts.FunctionExpression, + phases: Map, + diags: Diagnostic[], +): { text: string; deps: string[] } | undefined { + const p0 = fn.parameters[0]; + const partsName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "p"; + const deps: string[] = []; + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, deps }; + if (!ts.isTemplateExpression(expr)) return eraseStringish(sf, file, expr, undefined, phases, diags); + let text = expr.head.text; + for (const span of expr.templateSpans) { + const e = span.expression; + // p.auth.output + if ( + ts.isPropertyAccessExpression(e) && + ts.isPropertyAccessExpression(e.expression) && + ts.isIdentifier(e.expression.expression) && + e.expression.expression.text === partsName + ) { + const phaseId = e.expression.name.text; + if (phases.has(phaseId)) deps.push(phaseId); + if (e.name.text === "output") text += `{steps.${phaseId}.output}`; + else if (e.name.text === "json") text += `{steps.${phaseId}.json}`; + else text += `{steps.${phaseId}.output}`; + } else { + const er = eraseStringish(sf, file, e, undefined, phases, diags); + if (!er) return undefined; + text += er.text; + for (const d of er.deps) deps.push(d); + } + text += span.literal.text; + } + return { text, deps }; +} + diff --git a/packages/taskflow-dsl/src/check.ts b/packages/taskflow-dsl/src/check.ts new file mode 100644 index 0000000..11b8ee4 --- /dev/null +++ b/packages/taskflow-dsl/src/check.ts @@ -0,0 +1,51 @@ +/** + * Lightweight check — AST erase + validateTaskflow, no artifact write. + * + * S4 honesty: this is **not** a full tsc Program typecheck (RFC §2.2 optional + * path). It catches erase/DAG errors only. Use `tsc` / IDE for TS type errors. + */ + +import fs from "node:fs"; +import path from "node:path"; +import { buildFile, buildSource, type BuildResult } from "./build.ts"; +import type { Diagnostic } from "./diagnostics.ts"; + +export interface CheckOptions { + /** Skip Taskflow validate (rune/static only). Default false. */ + noValidate?: boolean; +} + +export interface CheckResult { + ok: boolean; + diagnostics: Diagnostic[]; + file?: string; +} + +export function checkSource(sourceText: string, file = "flow.tf.ts", opts: CheckOptions = {}): CheckResult { + const r: BuildResult = buildSource(sourceText, file, { + validate: !opts.noValidate, + irHash: false, + emit: "taskflow", + }); + return { ok: r.ok, diagnostics: r.diagnostics, file: r.file }; +} + +export function checkFile(filePath: string, opts: CheckOptions = {}): CheckResult { + const abs = path.resolve(filePath); + if (!fs.existsSync(abs)) { + return { + ok: false, + diagnostics: [ + { + code: "TFDSL_IO_MISSING", + severity: "error", + message: `File not found: ${abs}`, + file: abs, + }, + ], + file: abs, + }; + } + const r = buildFile(abs, { validate: !opts.noValidate, irHash: false, emit: "taskflow" }); + return { ok: r.ok, diagnostics: r.diagnostics, file: r.file }; +} diff --git a/packages/taskflow-dsl/src/cli.ts b/packages/taskflow-dsl/src/cli.ts new file mode 100644 index 0000000..923e6e3 --- /dev/null +++ b/packages/taskflow-dsl/src/cli.ts @@ -0,0 +1,199 @@ +#!/usr/bin/env node +/** + * taskflow-dsl CLI — build | check | decompile | new + */ + +import fs from "node:fs"; +import path from "node:path"; +import { buildFile } from "./build.ts"; +import { checkFile } from "./check.ts"; +import { decompileTaskflow } from "./decompile.ts"; +import { formatDiagnostics, hasErrors } from "./diagnostics.ts"; +import { skeletonHello, skeletonJson } from "./new-skeleton.ts"; +import { desugar, validateTaskflow, type Taskflow } from "taskflow-core"; + +function usage(): string { + return `taskflow-dsl [options] [path] + +Commands: + build Erase .tf.ts (or validate .json) → Taskflow / FlowIR + check Erase + validate (no write) + decompile Taskflow JSON → .tf.ts + new [name] Write hello skeleton + +Options: + -o, --out Output path + --emit taskflow|flowir|both (build, default: taskflow) + --json Machine-readable diagnostics + -h, --help +`; +} + +function parseArgs(argv: string[]) { + const args = argv.slice(2); + const flags: Record = {}; + const positional: string[] = []; + for (let i = 0; i < args.length; i++) { + const a = args[i]!; + if (a === "-h" || a === "--help") flags.help = true; + else if (a === "--json") flags.json = true; + else if (a === "-o" || a === "--out") flags.out = args[++i] ?? ""; + else if (a === "--emit") flags.emit = args[++i] ?? "taskflow"; + else if (a === "--force") flags.force = true; + else if (a === "--json-escape") flags.jsonEscape = true; + else if (a.startsWith("-")) flags[a] = true; + else positional.push(a); + } + return { flags, positional }; +} + +function main(): void { + const { flags, positional } = parseArgs(process.argv); + if (flags.help || positional.length === 0) { + process.stdout.write(usage()); + process.exit(flags.help ? 0 : 2); + } + const cmd = positional[0]!; + + try { + if (cmd === "build") { + const file = positional[1]; + if (!file) { + process.stderr.write("build requires a file path\n"); + process.exit(2); + } + const emit = (String(flags.emit ?? "taskflow") as "taskflow" | "flowir" | "both"); + const r = buildFile(file, { emit, irHash: true, validate: true }); + if (flags.json) { + process.stdout.write( + JSON.stringify( + { + ok: r.ok, + diagnostics: r.diagnostics, + irHash: r.irHash, + taskflow: r.taskflow, + }, + null, + 2, + ) + "\n", + ); + } else { + if (r.diagnostics.length) process.stderr.write(formatDiagnostics(r.diagnostics) + "\n"); + if (r.ok && r.taskflow) { + const stem = path.resolve(file).replace(/\.tf\.ts$/i, "").replace(/\.jsonc?$/i, ""); + const outTf = + typeof flags.out === "string" && flags.out && emit === "taskflow" + ? flags.out + : `${stem}.taskflow.json`; + if (emit === "taskflow" || emit === "both") { + const p = emit === "both" || !flags.out ? `${stem}.taskflow.json` : String(flags.out); + fs.writeFileSync(p, JSON.stringify(r.taskflow, null, 2) + "\n"); + process.stdout.write(`wrote ${p}\n`); + } + if ((emit === "flowir" || emit === "both") && r.flowir) { + const p = `${stem}.flowir.json`; + fs.writeFileSync( + p, + JSON.stringify({ hash: r.irHash, ir: r.flowir }, null, 2) + "\n", + ); + process.stdout.write(`wrote ${p} (${r.irHash})\n`); + } + if (emit === "taskflow" && flags.out) { + fs.writeFileSync(String(flags.out), JSON.stringify(r.taskflow, null, 2) + "\n"); + } + process.stdout.write( + `ok name=${r.taskflow.name} phases=${r.taskflow.phases?.length ?? 0}` + + (r.irHash ? ` ${r.irHash}` : "") + + "\n", + ); + void outTf; + } + } + process.exit(r.ok ? 0 : hasErrors(r.diagnostics) ? 1 : 0); + } + + if (cmd === "check") { + const file = positional[1]; + if (!file) { + process.stderr.write("check requires a file path\n"); + process.exit(2); + } + const r = checkFile(file); + if (flags.json) { + process.stdout.write(JSON.stringify({ ok: r.ok, diagnostics: r.diagnostics }, null, 2) + "\n"); + } else { + if (r.diagnostics.length) process.stdout.write(formatDiagnostics(r.diagnostics) + "\n"); + process.stdout.write(r.ok ? "ok\n" : "failed\n"); + } + process.exit(r.ok ? 0 : 1); + } + + if (cmd === "decompile") { + const file = positional[1]; + if (!file) { + process.stderr.write("decompile requires a Taskflow JSON path\n"); + process.exit(2); + } + const raw = JSON.parse(fs.readFileSync(path.resolve(file), "utf8")); + const asRec = raw as Record; + let def: Taskflow; + if (Array.isArray(asRec.phases)) { + const v = validateTaskflow(raw); + if (!v.ok) { + process.stderr.write(v.errors.join("\n") + "\n"); + process.exit(1); + } + def = raw as Taskflow; + } else { + try { + def = desugar(raw); + } catch (e) { + process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n"); + process.exit(1); + } + const v = validateTaskflow(def); + if (!v.ok) { + process.stderr.write(v.errors.join("\n") + "\n"); + process.exit(1); + } + } + const src = decompileTaskflow(def); + const out = + typeof flags.out === "string" && flags.out + ? flags.out + : path.resolve(file).replace(/\.jsonc?$/i, "") + ".tf.ts"; + if (out === "-") process.stdout.write(src); + else { + fs.writeFileSync(out, src); + process.stdout.write(`wrote ${out}\n`); + } + process.exit(0); + } + + if (cmd === "new") { + const name = positional[1] ?? "hello"; + const content = flags.jsonEscape ? skeletonJson(name) : skeletonHello(name); + const out = + typeof flags.out === "string" && flags.out + ? flags.out + : flags.jsonEscape + ? `./${name}.json` + : `./${name}.tf.ts`; + if (fs.existsSync(out) && !flags.force) { + process.stderr.write(`refusing to overwrite ${out} (use --force)\n`); + process.exit(2); + } + fs.writeFileSync(out, content); + process.stdout.write(`wrote ${out}\n`); + process.exit(0); + } + + process.stderr.write(`unknown command: ${cmd}\n${usage()}`); + process.exit(2); + } catch (e) { + process.stderr.write((e instanceof Error ? e.stack ?? e.message : String(e)) + "\n"); + process.exit(2); + } +} + +main(); diff --git a/packages/taskflow-dsl/src/decompile.ts b/packages/taskflow-dsl/src/decompile.ts new file mode 100644 index 0000000..a7c2648 --- /dev/null +++ b/packages/taskflow-dsl/src/decompile.ts @@ -0,0 +1,134 @@ +/** + * Taskflow JSON → .tf.ts (semantic, not literal round-trip). + * MVP: agent / script / map / parallel / gate / reduce / approval / flow / loop / tournament. + */ + +import type { Taskflow, Phase } from "taskflow-core"; + +function esc(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); +} + +function phaseBinding(id: string): string { + // valid-ish JS identifier — never inject raw phase id into free-text template slots + const b = id.replace(/[^A-Za-z0-9_$]/g, "_"); + const safe = /^[0-9]/.test(b) ? `p_${b}` : b || "phase"; + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(safe)) { + throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: phase id ${JSON.stringify(id)} is not a safe identifier`); + } + return safe; +} + +function safeIdent(name: string, role: string): string { + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) { + throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: ${role} ${JSON.stringify(name)} is not a safe identifier`); + } + return name; +} + +const DECOMPILABLE = new Set([ + "agent", + "script", + "map", + "parallel", + "gate", + "reduce", + "approval", + "flow", + "loop", + "tournament", +]); + +export function decompileTaskflow(def: Taskflow): string { + for (const p of def.phases ?? []) { + if (!DECOMPILABLE.has(p.type)) { + throw new Error( + `TFDSL_DECOMPILE_UNSUPPORTED: phase type ${JSON.stringify(p.type)} (id=${p.id}) cannot be decompiled in MVP`, + ); + } + } + + const lines: string[] = []; + lines.push(`import { flow, agent, map, parallel, gate, reduce, approval, subflow, loop, tournament, script } from "taskflow-dsl";`); + lines.push(``); + const desc = def.description ? `, { description: ${JSON.stringify(def.description)} }` : ""; + lines.push(`export default flow(${JSON.stringify(def.name)}${desc}, (ctx) => {`); + + if (def.budget) { + lines.push(` ctx.budget(${JSON.stringify(def.budget)});`); + } + if (typeof def.concurrency === "number") { + lines.push(` ctx.concurrency(${def.concurrency});`); + } + if (def.args && typeof def.args === "object") { + lines.push(` ctx.args.declare(${JSON.stringify(def.args)});`); + } + + const byId = new Map((def.phases ?? []).map((p) => [p.id, p])); + let lastBind: string | undefined; + + for (const p of def.phases ?? []) { + const bind = phaseBinding(p.id); + lastBind = bind; + const line = decompilePhase(p, bind, byId); + lines.push(` ${line}`); + } + + if (lastBind) { + lines.push(` return ${lastBind};`); + } + lines.push(`});`); + lines.push(``); + return lines.join("\n"); +} + +function decompilePhase(p: Phase, bind: string, _byId: Map): string { + const opts: string[] = []; + if (p.agent) opts.push(`agent: ${JSON.stringify(p.agent)}`); + if (p.final) opts.push(`final: true`); + if (p.when) opts.push(`when: ${JSON.stringify(p.when)}`); + if (p.dependsOn?.length) opts.push(`dependsOn: ${JSON.stringify(p.dependsOn)}`); + if (p.output) opts.push(`output: ${JSON.stringify(p.output)}`); + const optStr = opts.length ? `, { ${opts.join(", ")} }` : ""; + + switch (p.type) { + case "agent": + return `const ${bind} = agent(\`${esc(String(p.task ?? ""))}\`${optStr});`; + case "script": { + const run = p.run; + if (Array.isArray(run)) { + return `const ${bind} = script(${JSON.stringify(run)}${optStr});`; + } + return `const ${bind} = script(\`${esc(String(run ?? ""))}\`${optStr});`; + } + case "map": { + const as = safeIdent(p.as ?? "item", "map.as"); + return `const ${bind} = map(${JSON.stringify(p.over ?? "[]")}, (${as}) => agent(\`${esc(String(p.task ?? "{item}"))}\`)${optStr});`; + } + case "parallel": { + const branches = (p.branches ?? []).map( + (b) => `agent(\`${esc(String(b.task ?? ""))}\`${b.agent ? `, { agent: ${JSON.stringify(b.agent)} }` : ""})`, + ); + return `const ${bind} = parallel([${branches.join(", ")}]${optStr});`; + } + case "gate": + return `const ${bind} = gate(/* upstream */ ${JSON.stringify(p.dependsOn?.[0] ?? "upstream")}, { agent: ${JSON.stringify(p.agent ?? "reviewer")} }, (i) => \`${esc(String(p.task ?? "{steps.upstream.output}"))}\`);`; + case "reduce": { + const from = (p.from ?? p.dependsOn ?? []).map((id) => phaseBinding(id)); + return `const ${bind} = reduce([${from.join(", ")}], (parts) => agent(\`${esc(String(p.task ?? "reduce"))}\`)${optStr});`; + } + case "approval": + return `const ${bind} = approval({ request: \`${esc(String(p.task ?? "Approve?"))}\`${opts.length ? `, ${opts.join(", ")}` : ""} });`; + case "flow": + if (p.def !== undefined) { + return `const ${bind} = subflow.def(${JSON.stringify(typeof p.def === "string" ? p.def : "/* inline */")}${optStr});`; + } + return `const ${bind} = subflow(${JSON.stringify(p.use ?? "child")}${optStr});`; + case "loop": + return `const ${bind} = loop({ task: \`${esc(String(p.task ?? ""))}\`, maxIterations: ${p.maxIterations ?? 10}, until: ${JSON.stringify(p.until ?? "false")}${p.agent ? `, agent: ${JSON.stringify(p.agent)}` : ""} });`; + case "tournament": + return `const ${bind} = tournament({ variants: ${p.variants ?? 2}, task: \`${esc(String(p.task ?? ""))}\`, mode: ${JSON.stringify(p.mode ?? "best")} });`; + default: + return `// TFDSL: unsupported-field type=${p.type} id=${p.id}`; + } +} diff --git a/packages/taskflow-dsl/src/diagnostics.ts b/packages/taskflow-dsl/src/diagnostics.ts new file mode 100644 index 0000000..9ed5c65 --- /dev/null +++ b/packages/taskflow-dsl/src/diagnostics.ts @@ -0,0 +1,39 @@ +/** + * Stable diagnostics for taskflow-dsl (CLI + library). + */ + +export type DiagnosticSeverity = "error" | "warning" | "info"; + +export interface Range { + line: number; // 1-based + character: number; // 1-based + endLine?: number; + endCharacter?: number; +} + +export interface Diagnostic { + code: string; + severity: DiagnosticSeverity; + message: string; + file?: string; + range?: Range; + hint?: string; + related?: Array<{ file?: string; range?: Range; message: string }>; +} + +export function formatDiagnostic(d: Diagnostic): string { + const loc = + d.file && d.range + ? `${d.file}:${d.range.line}:${d.range.character}` + : d.file ?? ""; + const head = loc ? `${loc} - ${d.severity} ${d.code}: ${d.message}` : `${d.severity} ${d.code}: ${d.message}`; + return d.hint ? `${head}\n hint: ${d.hint}` : head; +} + +export function formatDiagnostics(diags: readonly Diagnostic[]): string { + return diags.map(formatDiagnostic).join("\n"); +} + +export function hasErrors(diags: readonly Diagnostic[]): boolean { + return diags.some((d) => d.severity === "error"); +} diff --git a/packages/taskflow-dsl/src/index.ts b/packages/taskflow-dsl/src/index.ts new file mode 100644 index 0000000..eb5c250 --- /dev/null +++ b/packages/taskflow-dsl/src/index.ts @@ -0,0 +1,32 @@ +/** + * taskflow-dsl — authoring entry (`import { flow, agent, ... } from "taskflow-dsl"`). + */ + +export { + flow, + agent, + parallel, + map, + gate, + reduce, + approval, + subflow, + loop, + tournament, + script, + json, + expand, + race, + TfDslEraseOnlyError, + type PhaseRef, + type FlowCtx, + type FlowOptions, + type PhaseOptions, + type ArgSpec, + type TemplateInput, + type TaskflowModuleDefault, + type JsonExpectMarker, +} from "./runes.ts"; + +export type { Diagnostic, DiagnosticSeverity, Range } from "./diagnostics.ts"; +export { formatDiagnostic, formatDiagnostics, hasErrors } from "./diagnostics.ts"; diff --git a/packages/taskflow-dsl/src/new-skeleton.ts b/packages/taskflow-dsl/src/new-skeleton.ts new file mode 100644 index 0000000..7d3559e --- /dev/null +++ b/packages/taskflow-dsl/src/new-skeleton.ts @@ -0,0 +1,27 @@ +/** `taskflow-dsl new` skeleton generator. */ + +export function skeletonHello(name = "hello"): string { + return `import { flow, agent } from "taskflow-dsl"; + +export default flow(${JSON.stringify(name)}, () => agent("Say hello to {args.name}")); +`; +} + +export function skeletonJson(name = "hello"): string { + return JSON.stringify( + { + name, + phases: [ + { + id: "main", + type: "agent", + agent: "executor", + task: "Say hello to {args.name}", + final: true, + }, + ], + }, + null, + 2, + ) + "\n"; +} diff --git a/packages/taskflow-dsl/src/runes.ts b/packages/taskflow-dsl/src/runes.ts new file mode 100644 index 0000000..4f2e3ff --- /dev/null +++ b/packages/taskflow-dsl/src/runes.ts @@ -0,0 +1,194 @@ +/** + * Authoring surface — compile-time directives (runes). + * At Node runtime every call throws TFDSL_ERASE_ONLY. + */ + +export class TfDslEraseOnlyError extends Error { + readonly code = "TFDSL_ERASE_ONLY"; + constructor(rune: string) { + super( + `TFDSL_ERASE_ONLY: ${rune}() is a compile-time directive. Run \`taskflow-dsl build\` on this .tf.ts file; do not execute it as a program.`, + ); + this.name = "TfDslEraseOnlyError"; + } +} + +function eraseOnly(rune: string): never { + throw new TfDslEraseOnlyError(rune); +} + +/** Opaque phase handle — only meaningful to the compiler. */ +export type PhaseRef = { readonly __brand: "PhaseRef"; readonly id?: string }; + +export type TemplateInput = string; + +export interface ArgSpec { + default?: unknown; + description?: string; + required?: boolean; +} + +export interface FlowOptions { + description?: string; + version?: number; +} + +export interface PhaseOptions { + id?: string; + agent?: string; + model?: string; + thinking?: string | boolean; + tools?: string[]; + cwd?: string; + output?: "text" | "json"; + expect?: unknown; + when?: string; + join?: "all" | "any"; + dependsOn?: string[]; + retry?: { max?: number; backoffMs?: number; factor?: number }; + timeout?: number; + optional?: boolean; + idempotent?: boolean; + final?: boolean; + concurrency?: number; + /** Brand from json(). */ + jsonExpect?: unknown; +} + +export interface FlowCtx { + args: { declare(spec: Record): void }; + concurrency(n: number): void; + budget(b: { maxUSD?: number; maxTokens?: number }): void; +} + +export type TaskflowModuleDefault = { readonly __brand: "TaskflowModuleDefault" }; + +export interface JsonExpectMarker { + readonly __brand: "JsonExpectMarker"; + readonly _type?: T; +} + +export function json(): JsonExpectMarker { + return eraseOnly("json"); +} + +export function flow( + name: string, + fn: (ctx: FlowCtx) => PhaseRef | void, +): TaskflowModuleDefault; +export function flow( + name: string, + opts: FlowOptions, + fn: (ctx: FlowCtx) => PhaseRef | void, +): TaskflowModuleDefault; +export function flow( + _name: string, + _optsOrFn: FlowOptions | ((ctx: FlowCtx) => PhaseRef | void), + _fn?: (ctx: FlowCtx) => PhaseRef | void, +): TaskflowModuleDefault { + return eraseOnly("flow"); +} + +export function agent(_task: TemplateInput, _opts?: PhaseOptions): PhaseRef { + return eraseOnly("agent"); +} + +export function parallel( + _branches: PhaseRef[], + _opts?: PhaseOptions, +): PhaseRef[] & PhaseRef { + return eraseOnly("parallel"); +} + +export function map( + _source: PhaseRef | string, + _fn: (item: unknown) => PhaseRef, + _opts?: PhaseOptions, +): PhaseRef { + return eraseOnly("map"); +} + +export function gate( + _upstream: PhaseRef, + _opts?: PhaseOptions, + _task?: (input: PhaseRef) => TemplateInput, +): PhaseRef { + return eraseOnly("gate"); +} + +export function reduce( + _from: PhaseRef[], + _fn: (parts: Record) => PhaseRef, + _opts?: PhaseOptions, +): PhaseRef { + return eraseOnly("reduce"); +} + +export function approval(_opts: { request: TemplateInput } & PhaseOptions): PhaseRef { + return eraseOnly("approval"); +} + +export function subflow( + _use: string, + _withArgs?: Record, + _opts?: PhaseOptions, +): PhaseRef { + return eraseOnly("subflow"); +} + +/** Nested dynamic sub-flow (compiles to type:flow def). */ +export function subflowDef( + _def: PhaseRef | string, + _opts?: PhaseOptions, +): PhaseRef { + return eraseOnly("subflow.def"); +} +subflow.def = subflowDef; + +export function loop(_opts: PhaseOptions & { + task?: TemplateInput | ((prev: PhaseRef) => TemplateInput); + until?: string; + maxIterations?: number; + convergence?: boolean; + reflexion?: boolean; +}): PhaseRef { + return eraseOnly("loop"); +} + +export function tournament(_opts: PhaseOptions & { + variants?: number; + branches?: PhaseRef[]; + mode?: "best" | "aggregate"; + judge?: string; + judgeAgent?: string; + task?: TemplateInput; +}): PhaseRef { + return eraseOnly("tournament"); +} + +export function script( + _run: string | string[], + _opts?: PhaseOptions & { input?: string }, +): PhaseRef { + return eraseOnly("script"); +} + +/** Alias for nested expand (A-track); graft is S4.x. */ +export function expandNested( + _def: PhaseRef | string, + _opts?: PhaseOptions, +): PhaseRef { + return eraseOnly("expand.nested"); +} + +export const expand = { + nested: expandNested, +}; + +/** Race — B-track; present as erase-only so authors can typecheck against horizon. */ +export function race( + _branches: PhaseRef[], + _opts?: PhaseOptions & { cancelLosers?: boolean }, +): PhaseRef { + return eraseOnly("race"); +} diff --git a/packages/taskflow-dsl/test/erase-build.test.ts b/packages/taskflow-dsl/test/erase-build.test.ts new file mode 100644 index 0000000..08d4ccf --- /dev/null +++ b/packages/taskflow-dsl/test/erase-build.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildSource } from "../src/build.ts"; +import { checkSource } from "../src/check.ts"; +import { decompileTaskflow } from "../src/decompile.ts"; +import { flow, agent, TfDslEraseOnlyError } from "../src/index.ts"; +import { compileTaskflowToFlowIR, hashFlowIR, validateTaskflow } from "taskflow-core"; + +test("runes throw TFDSL_ERASE_ONLY at runtime", () => { + assert.throws(() => flow("x", () => agent("hi")), (e: unknown) => { + return e instanceof TfDslEraseOnlyError || (e instanceof Error && e.message.includes("TFDSL_ERASE_ONLY")); + }); +}); + +test("build: hello agent", () => { + const src = ` +import { flow, agent } from "taskflow-dsl"; +export default flow("hello", () => agent("Say hello to {args.name}")); +`; + const r = buildSource(src, "hello.tf.ts"); + assert.equal(r.ok, true, format(r)); + assert.equal(r.taskflow?.name, "hello"); + assert.equal(r.taskflow?.phases?.length, 1); + assert.equal(r.taskflow?.phases?.[0]?.type, "agent"); + assert.match(String(r.taskflow?.phases?.[0]?.task), /Say hello/); + assert.ok(r.irHash?.startsWith("ir:")); +}); + +test("build: agent map reduce chain + templates", () => { + const src = ` +import { flow, agent, map, reduce, json } from "taskflow-dsl"; +export default flow("audit", (ctx) => { + ctx.budget({ maxUSD: 2 }); + const discover = agent("List files under {args.dir}", { + agent: "scout", + output: json(), + }); + const each = map(discover, (item) => agent(\`Audit \${item.path}\`, { agent: "analyst" })); + return reduce([each], (p) => agent(\`Summary \${p.each.output}\`)); +}); +`; + const r = buildSource(src, "audit.tf.ts"); + assert.equal(r.ok, true, format(r)); + assert.equal(r.taskflow?.name, "audit"); + assert.ok(r.taskflow?.budget); + const types = (r.taskflow?.phases ?? []).map((p) => p.type); + assert.deepEqual(types, ["agent", "map", "reduce"]); + const mapPh = r.taskflow?.phases?.find((p) => p.type === "map"); + assert.match(String(mapPh?.task), /\{item\.path\}/); + assert.match(String(mapPh?.over), /steps\.discover/); + const red = r.taskflow?.phases?.find((p) => p.type === "reduce"); + assert.equal(red?.final, true); +}); + +test("build: parallel + script", () => { + const src = ` +import { flow, agent, parallel, script } from "taskflow-dsl"; +export default flow("p", () => { + const par = parallel([ + agent("auth"), + agent("perf"), + ]); + const sh = script(["echo", "ok"], { dependsOn: ["par"] }); + return sh; +}); +`; + const r = buildSource(src, "p.tf.ts"); + assert.equal(r.ok, true, format(r)); + const types = (r.taskflow?.phases ?? []).map((p) => p.type); + assert.ok(types.includes("parallel")); + assert.ok(types.includes("script")); +}); + +test("build: race is unsupported error", () => { + const src = ` +import { flow, agent, race } from "taskflow-dsl"; +export default flow("r", () => race([agent("a"), agent("b")])); +`; + const r = buildSource(src, "r.tf.ts"); + assert.equal(r.ok, false); + assert.ok(r.diagnostics.some((d) => d.code === "TFDSL_PHASE_UNSUPPORTED")); +}); + +test("check: ok for hello", () => { + const src = `import { flow, agent } from "taskflow-dsl"; +export default flow("hello", () => agent("hi"));`; + const r = checkSource(src); + assert.equal(r.ok, true, format(r)); +}); + +test("parity: DSL build hash matches hand JSON twin (map + json + templates)", () => { + const src = ` +import { flow, agent, map, json } from "taskflow-dsl"; +export default flow("twin", () => { + const discover = agent("list", { agent: "scout", output: json() }); + const each = map(discover, (item) => agent(\`Audit \${item.path}\`, { agent: "analyst" })); + return each; +}); +`; + const r = buildSource(src, "twin.tf.ts", { irHash: true }); + assert.equal(r.ok, true, format(r)); + const hand = { + name: "twin", + phases: [ + { + id: "discover", + type: "agent", + agent: "scout", + task: "list", + output: "json", + expect: { type: "object" }, + }, + { + id: "each", + type: "map", + over: "{steps.discover.json}", + as: "item", + task: "Audit {item.path}", + agent: "analyst", + dependsOn: ["discover"], + final: true, + }, + ], + }; + assert.equal(validateTaskflow(hand).ok, true, validateTaskflow(hand).errors?.join("\n")); + const h1 = r.irHash!; + const h2 = hashFlowIR(compileTaskflowToFlowIR(hand as never).canonical); + assert.match(h1, /^ir:[0-9a-f]{64}$/); + assert.match(h2, /^ir:[0-9a-f]{64}$/); + assert.equal(h1, h2, `FlowIR hash mismatch\nDSL=${h1}\nhand=${h2}\nDSL phases=${JSON.stringify(r.taskflow?.phases, null, 2)}`); +}); + +test("build: gate lambda (i) => template with i.output", () => { + const src = ` +import { flow, agent, gate } from "taskflow-dsl"; +export default flow("g", () => { + const a = agent("work"); + return gate(a, { agent: "reviewer" }, (i) => \`Check:\\n\${i.output}\`); +}); +`; + const r = buildSource(src, "g.tf.ts"); + assert.equal(r.ok, true, format(r)); + const g = r.taskflow?.phases?.find((p) => p.type === "gate"); + assert.match(String(g?.task), /\{steps\.a\.output\}/); +}); + +test("decompile: round-trip shape", () => { + const def = { + name: "d", + phases: [{ id: "main", type: "agent" as const, task: "hello", final: true }], + }; + assert.equal(validateTaskflow(def).ok, true); + const src = decompileTaskflow(def); + assert.match(src, /export default flow/); + assert.match(src, /agent\(/); +}); + +function format(r: { diagnostics?: { code: string; message: string }[] }): string { + return (r.diagnostics ?? []).map((d) => `${d.code}: ${d.message}`).join("\n"); +} diff --git a/packages/taskflow-dsl/test/import-lint.test.ts b/packages/taskflow-dsl/test/import-lint.test.ts new file mode 100644 index 0000000..7cb2596 --- /dev/null +++ b/packages/taskflow-dsl/test/import-lint.test.ts @@ -0,0 +1,41 @@ +/** + * taskflow-dsl must not import runtime/exec/hosts/mcp from core internals. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const srcRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "../src"); + +const FORBIDDEN = [ + "/runtime", + "/exec/", + "executeTaskflow", + "taskflow-hosts", + "taskflow-mcp-core", + "detached-runner", + "@earendil-works/", +]; + +function walk(dir: string, out: string[] = []): string[] { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, ent.name); + if (ent.isDirectory()) walk(p, out); + else if (ent.name.endsWith(".ts")) out.push(p); + } + return out; +} + +test("import-lint: no forbidden runtime/host imports in taskflow-dsl src", () => { + const files = walk(srcRoot); + const hits: string[] = []; + for (const f of files) { + const text = fs.readFileSync(f, "utf8"); + for (const bad of FORBIDDEN) { + if (text.includes(bad)) hits.push(`${path.relative(srcRoot, f)}: ${bad}`); + } + } + assert.deepEqual(hits, [], hits.join("\n")); +}); diff --git a/packages/taskflow-dsl/tsconfig.build.json b/packages/taskflow-dsl/tsconfig.build.json new file mode 100644 index 0000000..863efae --- /dev/null +++ b/packages/taskflow-dsl/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/pnpm-lock.yaml b/pnpm-lock.yaml index 1530822..af098a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,15 @@ importers: specifier: '*' version: 1.3.3 + packages/taskflow-dsl: + dependencies: + taskflow-core: + specifier: workspace:* + version: link:../taskflow-core + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/taskflow-hosts: dependencies: taskflow-core: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e99b2d7..85dce22 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,4 +10,5 @@ packages: - "packages/claude-taskflow" - "packages/opencode-taskflow" - "packages/grok-taskflow" + - "packages/taskflow-dsl" - "website" From 7e52b119b647b3f35c6967363b3e5565433cd71c Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 16:23:28 +0800 Subject: [PATCH 26/51] feat(dsl): expand S4 coverage for all kinds, gate sugar, CLI hardening Add erase/tests for approval, loop, tournament, subflow, expand.nested, gate.automated/scored; negative-path and path-containment tests. CLI gains --cwd, -V, and -o confinement under cwd. 19 unit tests green. --- docs/rfc-0.2.0-s4-mvp.md | 5 +- packages/taskflow-dsl/src/build/erase.ts | 85 ++++++++- packages/taskflow-dsl/src/cli.ts | 106 ++++++++--- packages/taskflow-dsl/src/index.ts | 2 + packages/taskflow-dsl/src/paths.ts | 23 +++ packages/taskflow-dsl/src/runes.ts | 26 +++ .../taskflow-dsl/test/kinds-coverage.test.ts | 168 ++++++++++++++++++ 7 files changed, 380 insertions(+), 35 deletions(-) create mode 100644 packages/taskflow-dsl/src/paths.ts create mode 100644 packages/taskflow-dsl/test/kinds-coverage.test.ts diff --git a/docs/rfc-0.2.0-s4-mvp.md b/docs/rfc-0.2.0-s4-mvp.md index 83d4ab5..c6de83a 100644 --- a/docs/rfc-0.2.0-s4-mvp.md +++ b/docs/rfc-0.2.0-s4-mvp.md @@ -392,7 +392,7 @@ export type { PhaseRef, FlowCtx, FlowOptions, PhaseOptions, ItemSymbol, … }; | API | Reason | |-----|--------| -| `gate.automated` / `gate.scored` | Coverage matrix N → S4.1 | +| top-level `ctx.scope` / `strict` / `share` / `incremental` | S4.1+ | | `subflow.def(() => …)` | Dynamic inline def; engine exists, DSL compile cost high → S4.1 | | `$store` / `$derived` / `flow.component` | post-0.2.0 (arch §12) | | Runtime `execute*` anything | Wrong package | @@ -720,7 +720,8 @@ Public surface exposes runes for the **Y** rows of the S4 coverage matrix (full | `json()` basic object/array | `json` | | template → `{steps.*}` / `{item.*}` | erase in `build` | | `when` string + TS subset | options + check rules | -| gate.automated / scored, top-level strict/share/incremental/scope | **not exported** | +| `gate.automated` / `gate.scored` | **Y (S4.1 A-track)** — erase to `eval` / `score` | +| top-level strict/share/incremental/scope | **not exported** (S4.1+) | FULL RFC coverage remains a post-MVP completion track; missing FULL features must not appear as stub exports that silently no-op. diff --git a/packages/taskflow-dsl/src/build/erase.ts b/packages/taskflow-dsl/src/build/erase.ts index 6bb1ee0..9ab3a9e 100644 --- a/packages/taskflow-dsl/src/build/erase.ts +++ b/packages/taskflow-dsl/src/build/erase.ts @@ -445,11 +445,86 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul return undefined; } + // gate.automated / gate.scored → type:gate with eval / score + if (cn === "gate.automated" || cn === "gate.scored") { + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type: "gate", + raw: { type: "gate" }, + dependsOn: new Set(), + }; + const up = call.arguments[0]; + if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); + const optsArg = call.arguments[1] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (cn === "gate.automated" && optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "pass") { + const v = evalLiteral(p.initializer); + if (Array.isArray(v)) draft.raw.eval = v; + } + if (p.name.text === "task") { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) { + draft.raw.task = er.text; + for (const d of er.deps) draft.dependsOn.add(d); + } + } + } + // Engine requires task or score; default a minimal task if only eval given + if (!draft.raw.task && !draft.raw.score) { + draft.raw.task = "Gate (automated pre-checks failed or incomplete)."; + } + } + if (cn === "gate.scored" && optsArg && ts.isObjectLiteralExpression(optsArg)) { + const score: Record = {}; + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + const k = p.name.text; + if (k === "scorers" || k === "combine" || k === "threshold" || k === "weights" || k === "target" || k === "judge") { + const v = evalLiteral(p.initializer); + if (v !== undefined) score[k] = v; + } + if (k === "task") { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) { + draft.raw.task = er.text; + for (const d of er.deps) draft.dependsOn.add(d); + } + } + } + if (!score.combine) score.combine = "all"; + draft.raw.score = score; + // strip score fields from top-level raw if mergeOpts put them + delete draft.raw.scorers; + delete draft.raw.combine; + delete draft.raw.threshold; + delete draft.raw.weights; + delete draft.raw.target; + delete draft.raw.judge; + } + delete draft.raw.id; + delete draft.raw.pass; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (opts.final === true) { + draft.final = true; + draft.raw.final = true; + } + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; + } + + const phaseType = type === "gate" ? "gate" : type; const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); const draft: PhaseDraft = { id: idBase, - type, - raw: { type }, + type: phaseType, + raw: { type: phaseType }, dependsOn: new Set(), }; @@ -650,6 +725,7 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul } else if (type === "loop") { const optsArg = call.arguments[0] as ts.Expression | undefined; const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; Object.assign(draft.raw, opts); // task: (prev) => `...` inside object — scan object for task method if (optsArg && ts.isObjectLiteralExpression(optsArg)) { @@ -658,7 +734,7 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul if (p.name.text === "task") { if (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)) { const prev = p.initializer.parameters[0]; - const prevName = prev && ts.isIdentifier(prev.name) ? prev.name.text : "prev"; + const prevNm = prev && ts.isIdentifier(prev.name) ? prev.name.text : "prev"; let expr: ts.Expression | undefined = ts.isBlock(p.initializer.body) ? undefined : (p.initializer.body as ts.Expression); @@ -668,7 +744,7 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul } } if (expr) { - const er = eraseLoopTask(sf, file, expr, prevName, draft.id, diags); + const er = eraseLoopTask(sf, file, expr, prevNm, draft.id, diags); if (er) draft.raw.task = er; } } else { @@ -678,7 +754,6 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul } } } - if (typeof opts.id === "string") draft.id = opts.id; if (opts.final === true) draft.final = true; } else if (type === "tournament") { const optsArg = call.arguments[0] as ts.Expression | undefined; diff --git a/packages/taskflow-dsl/src/cli.ts b/packages/taskflow-dsl/src/cli.ts index 923e6e3..64a768f 100644 --- a/packages/taskflow-dsl/src/cli.ts +++ b/packages/taskflow-dsl/src/cli.ts @@ -5,26 +5,41 @@ import fs from "node:fs"; import path from "node:path"; +import { createRequire } from "node:module"; import { buildFile } from "./build.ts"; import { checkFile } from "./check.ts"; import { decompileTaskflow } from "./decompile.ts"; import { formatDiagnostics, hasErrors } from "./diagnostics.ts"; import { skeletonHello, skeletonJson } from "./new-skeleton.ts"; +import { resolveContainedOut, resolveInput } from "./paths.ts"; import { desugar, validateTaskflow, type Taskflow } from "taskflow-core"; +const require = createRequire(import.meta.url); +const PKG_VERSION: string = (() => { + try { + return (require("../package.json") as { version: string }).version; + } catch { + return "0.0.0-dev"; + } +})(); + function usage(): string { return `taskflow-dsl [options] [path] Commands: build Erase .tf.ts (or validate .json) → Taskflow / FlowIR - check Erase + validate (no write) + check Erase + validate (no write; not full tsc) decompile Taskflow JSON → .tf.ts new [name] Write hello skeleton Options: - -o, --out Output path + --cwd Project root for path resolution (default: process.cwd()) + -o, --out Output path (must stay under --cwd) --emit taskflow|flowir|both (build, default: taskflow) --json Machine-readable diagnostics + --force Overwrite existing file (new) + --json-escape new: emit JSON skeleton instead of .tf.ts + -V, --version -h, --help `; } @@ -36,9 +51,11 @@ function parseArgs(argv: string[]) { for (let i = 0; i < args.length; i++) { const a = args[i]!; if (a === "-h" || a === "--help") flags.help = true; + else if (a === "-V" || a === "--version") flags.version = true; else if (a === "--json") flags.json = true; else if (a === "-o" || a === "--out") flags.out = args[++i] ?? ""; else if (a === "--emit") flags.emit = args[++i] ?? "taskflow"; + else if (a === "--cwd") flags.cwd = args[++i] ?? ""; else if (a === "--force") flags.force = true; else if (a === "--json-escape") flags.jsonEscape = true; else if (a.startsWith("-")) flags[a] = true; @@ -49,10 +66,15 @@ function parseArgs(argv: string[]) { function main(): void { const { flags, positional } = parseArgs(process.argv); + if (flags.version) { + process.stdout.write(`${PKG_VERSION}\n`); + process.exit(0); + } if (flags.help || positional.length === 0) { process.stdout.write(usage()); process.exit(flags.help ? 0 : 2); } + const cwd = typeof flags.cwd === "string" && flags.cwd ? path.resolve(flags.cwd) : process.cwd(); const cmd = positional[0]!; try { @@ -62,8 +84,9 @@ function main(): void { process.stderr.write("build requires a file path\n"); process.exit(2); } - const emit = (String(flags.emit ?? "taskflow") as "taskflow" | "flowir" | "both"); - const r = buildFile(file, { emit, irHash: true, validate: true }); + const input = resolveInput(cwd, file); + const emit = String(flags.emit ?? "taskflow") as "taskflow" | "flowir" | "both"; + const r = buildFile(input, { emit, irHash: true, validate: true }); if (flags.json) { process.stdout.write( JSON.stringify( @@ -80,33 +103,30 @@ function main(): void { } else { if (r.diagnostics.length) process.stderr.write(formatDiagnostics(r.diagnostics) + "\n"); if (r.ok && r.taskflow) { - const stem = path.resolve(file).replace(/\.tf\.ts$/i, "").replace(/\.jsonc?$/i, ""); - const outTf = - typeof flags.out === "string" && flags.out && emit === "taskflow" - ? flags.out - : `${stem}.taskflow.json`; + const stem = input.replace(/\.tf\.ts$/i, "").replace(/\.jsonc?$/i, ""); if (emit === "taskflow" || emit === "both") { - const p = emit === "both" || !flags.out ? `${stem}.taskflow.json` : String(flags.out); - fs.writeFileSync(p, JSON.stringify(r.taskflow, null, 2) + "\n"); - process.stdout.write(`wrote ${p}\n`); + let outPath = `${stem}.taskflow.json`; + if (typeof flags.out === "string" && flags.out && emit === "taskflow") { + const c = resolveContainedOut(cwd, flags.out); + if (!c.ok) { + process.stderr.write(c.message + "\n"); + process.exit(2); + } + outPath = c.path; + } + fs.writeFileSync(outPath, JSON.stringify(r.taskflow, null, 2) + "\n"); + process.stdout.write(`wrote ${outPath}\n`); } if ((emit === "flowir" || emit === "both") && r.flowir) { const p = `${stem}.flowir.json`; - fs.writeFileSync( - p, - JSON.stringify({ hash: r.irHash, ir: r.flowir }, null, 2) + "\n", - ); + fs.writeFileSync(p, JSON.stringify({ hash: r.irHash, ir: r.flowir }, null, 2) + "\n"); process.stdout.write(`wrote ${p} (${r.irHash})\n`); } - if (emit === "taskflow" && flags.out) { - fs.writeFileSync(String(flags.out), JSON.stringify(r.taskflow, null, 2) + "\n"); - } process.stdout.write( `ok name=${r.taskflow.name} phases=${r.taskflow.phases?.length ?? 0}` + (r.irHash ? ` ${r.irHash}` : "") + "\n", ); - void outTf; } } process.exit(r.ok ? 0 : hasErrors(r.diagnostics) ? 1 : 0); @@ -118,7 +138,7 @@ function main(): void { process.stderr.write("check requires a file path\n"); process.exit(2); } - const r = checkFile(file); + const r = checkFile(resolveInput(cwd, file)); if (flags.json) { process.stdout.write(JSON.stringify({ ok: r.ok, diagnostics: r.diagnostics }, null, 2) + "\n"); } else { @@ -134,7 +154,8 @@ function main(): void { process.stderr.write("decompile requires a Taskflow JSON path\n"); process.exit(2); } - const raw = JSON.parse(fs.readFileSync(path.resolve(file), "utf8")); + const input = resolveInput(cwd, file); + const raw = JSON.parse(fs.readFileSync(input, "utf8")); const asRec = raw as Record; let def: Taskflow; if (Array.isArray(asRec.phases)) { @@ -157,13 +178,35 @@ function main(): void { process.exit(1); } } - const src = decompileTaskflow(def); - const out = + let src: string; + try { + src = decompileTaskflow(def); + } catch (e) { + process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n"); + process.exit(1); + } + let out = typeof flags.out === "string" && flags.out ? flags.out - : path.resolve(file).replace(/\.jsonc?$/i, "") + ".tf.ts"; - if (out === "-") process.stdout.write(src); - else { + : input.replace(/\.jsonc?$/i, "") + ".tf.ts"; + if (out === "-") { + process.stdout.write(src); + } else { + if (!path.isAbsolute(out)) { + const c = resolveContainedOut(cwd, out); + if (!c.ok) { + process.stderr.write(c.message + "\n"); + process.exit(2); + } + out = c.path; + } else { + const c = resolveContainedOut(cwd, path.relative(cwd, out)); + if (!c.ok) { + process.stderr.write(c.message + "\n"); + process.exit(2); + } + out = c.path; + } fs.writeFileSync(out, src); process.stdout.write(`wrote ${out}\n`); } @@ -173,16 +216,23 @@ function main(): void { if (cmd === "new") { const name = positional[1] ?? "hello"; const content = flags.jsonEscape ? skeletonJson(name) : skeletonHello(name); - const out = + let outRel = typeof flags.out === "string" && flags.out ? flags.out : flags.jsonEscape ? `./${name}.json` : `./${name}.tf.ts`; + const c = resolveContainedOut(cwd, outRel); + if (!c.ok) { + process.stderr.write(c.message + "\n"); + process.exit(2); + } + const out = c.path; if (fs.existsSync(out) && !flags.force) { process.stderr.write(`refusing to overwrite ${out} (use --force)\n`); process.exit(2); } + fs.mkdirSync(path.dirname(out), { recursive: true }); fs.writeFileSync(out, content); process.stdout.write(`wrote ${out}\n`); process.exit(0); diff --git a/packages/taskflow-dsl/src/index.ts b/packages/taskflow-dsl/src/index.ts index eb5c250..d21a61f 100644 --- a/packages/taskflow-dsl/src/index.ts +++ b/packages/taskflow-dsl/src/index.ts @@ -8,6 +8,8 @@ export { parallel, map, gate, + gateAutomated, + gateScored, reduce, approval, subflow, diff --git a/packages/taskflow-dsl/src/paths.ts b/packages/taskflow-dsl/src/paths.ts new file mode 100644 index 0000000..14052df --- /dev/null +++ b/packages/taskflow-dsl/src/paths.ts @@ -0,0 +1,23 @@ +/** + * Path safety helpers for CLI -o / new. + */ + +import path from "node:path"; + +/** Resolve `out` under `cwd`; reject path escape (e.g. ../../etc/passwd). */ +export function resolveContainedOut(cwd: string, out: string): { ok: true; path: string } | { ok: false; message: string } { + const base = path.resolve(cwd); + const target = path.resolve(base, out); + const rel = path.relative(base, target); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + return { + ok: false, + message: `TFDSL_IO_PATH: output path escapes --cwd (${out})`, + }; + } + return { ok: true, path: target }; +} + +export function resolveInput(cwd: string, file: string): string { + return path.isAbsolute(file) ? file : path.resolve(cwd, file); +} diff --git a/packages/taskflow-dsl/src/runes.ts b/packages/taskflow-dsl/src/runes.ts index 4f2e3ff..4efbcc2 100644 --- a/packages/taskflow-dsl/src/runes.ts +++ b/packages/taskflow-dsl/src/runes.ts @@ -116,6 +116,32 @@ export function gate( return eraseOnly("gate"); } +/** Zero-token pre-checks (`eval`); LLM `task` still required by engine if score absent. */ +export function gateAutomated( + _upstream: PhaseRef, + _opts: PhaseOptions & { pass: string[]; task?: TemplateInput }, +): PhaseRef { + return eraseOnly("gate.automated"); +} + +/** Deterministic scorers (`score`); may omit LLM task when score alone decides. */ +export function gateScored( + _upstream: PhaseRef, + _opts: PhaseOptions & { + scorers: Array>; + combine?: "all" | "any" | "weighted"; + threshold?: number; + weights?: number[]; + target?: string; + judge?: { agent?: string; task?: string }; + }, +): PhaseRef { + return eraseOnly("gate.scored"); +} + +gate.automated = gateAutomated; +gate.scored = gateScored; + export function reduce( _from: PhaseRef[], _fn: (parts: Record) => PhaseRef, diff --git a/packages/taskflow-dsl/test/kinds-coverage.test.ts b/packages/taskflow-dsl/test/kinds-coverage.test.ts new file mode 100644 index 0000000..5370e1f --- /dev/null +++ b/packages/taskflow-dsl/test/kinds-coverage.test.ts @@ -0,0 +1,168 @@ +/** + * S4: erase coverage for remaining phase kinds + A-track gate sugar. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { buildSource } from "../src/build.ts"; +import { decompileTaskflow } from "../src/decompile.ts"; +import { resolveContainedOut } from "../src/paths.ts"; +import { validateTaskflow } from "taskflow-core"; + +function fmt(r: { diagnostics?: { code: string; message: string }[] }): string { + return (r.diagnostics ?? []).map((d) => `${d.code}: ${d.message}`).join("\n"); +} + +test("build: approval", () => { + const src = ` +import { flow, approval } from "taskflow-dsl"; +export default flow("ap", () => approval({ request: "Ship it?", final: true })); +`; + const r = buildSource(src, "ap.tf.ts"); + assert.equal(r.ok, true, fmt(r)); + const p = r.taskflow!.phases![0]!; + assert.equal(p.type, "approval"); + assert.equal(p.task, "Ship it?"); +}); + +test("build: loop with prev.output template", () => { + const src = ` +import { flow, loop } from "taskflow-dsl"; +export default flow("lp", () => + loop({ + agent: "executor", + maxIterations: 3, + until: "false", + task: (prev) => \`Improve:\\n\${prev.output}\`, + }), +); +`; + const r = buildSource(src, "lp.tf.ts"); + assert.equal(r.ok, true, fmt(r)); + const p = r.taskflow!.phases![0]!; + assert.equal(p.type, "loop"); + assert.match(String(p.task), /\{steps\.main\.output\}|\{steps\.lp\.output\}|Improve/); + // body uses loop phase id in {steps..output} + assert.match(String(p.task), /\{steps\.[a-z0-9-]+\.output\}/); +}); + +test("build: tournament variants", () => { + const src = ` +import { flow, tournament } from "taskflow-dsl"; +export default flow("tour", () => + tournament({ + variants: 2, + mode: "best", + agent: "executor", + judgeAgent: "reviewer", + task: "Solve the puzzle", + judge: "Pick best. WINNER: .", + }), +); +`; + const r = buildSource(src, "tour.tf.ts"); + assert.equal(r.ok, true, fmt(r)); + assert.equal(r.taskflow!.phases![0]!.type, "tournament"); + assert.equal(r.taskflow!.phases![0]!.variants, 2); +}); + +test("build: subflow use + expand.nested", () => { + const src = ` +import { flow, agent, subflow, expand, json } from "taskflow-dsl"; +export default flow("sf", () => { + const plan = agent("emit plan", { output: json() }); + const nested = expand.nested(plan.json); + const saved = subflow("child-flow", { q: "1" }, { dependsOn: ["nested"] }); + return saved; +}); +`; + const r = buildSource(src, "sf.tf.ts"); + assert.equal(r.ok, true, fmt(r)); + const types = r.taskflow!.phases!.map((p) => p.type); + assert.deepEqual(types, ["agent", "flow", "flow"]); + const nested = r.taskflow!.phases!.find((p) => p.id === "nested"); + assert.match(String(nested?.def), /steps\.plan\.json/); + const saved = r.taskflow!.phases!.find((p) => p.id === "saved"); + assert.equal(saved?.use, "child-flow"); +}); + +test("build: gate.automated + gate.scored", () => { + const src = ` +import { flow, agent, gate } from "taskflow-dsl"; +export default flow("gs", () => { + const a = agent("work"); + const auto = gate.automated(a, { + pass: ["{steps.a.output} contains OK"], + task: "fallback llm gate", + }); + return gate.scored(auto, { + scorers: [{ type: "contains", value: "PASS" }], + combine: "all", + }); +}); +`; + const r = buildSource(src, "gs.tf.ts"); + assert.equal(r.ok, true, fmt(r)); + const auto = r.taskflow!.phases!.find((p) => p.id === "auto"); + assert.ok(Array.isArray(auto?.eval)); + assert.equal(auto?.task, "fallback llm gate"); + const scored = r.taskflow!.phases!.find((p) => p.id === "phase-2" || p.type === "gate" && p.score); + const withScore = r.taskflow!.phases!.find((p) => (p as { score?: unknown }).score !== undefined); + assert.ok(withScore, JSON.stringify(r.taskflow?.phases)); + assert.equal((withScore as { score: { combine: string } }).score.combine, "all"); +}); + +test("negative: unknown option warns", () => { + const src = ` +import { flow, agent } from "taskflow-dsl"; +export default flow("u", () => agent("t", { notARealField: 1 } as never)); +`; + // without as never, TS would error at typecheck; source still has property + const raw = ` +import { flow, agent } from "taskflow-dsl"; +export default flow("u", () => agent("t", { notARealField: 1 })); +`; + const r = buildSource(raw, "u.tf.ts"); + // may still validate if unknown stripped + assert.ok(r.diagnostics.some((d) => d.code === "TFDSL_RUNE_OPTS_UNKNOWN")); +}); + +test("negative: decompile rejects unknown phase type", () => { + assert.throws( + () => + decompileTaskflow({ + name: "x", + phases: [{ id: "r", type: "race" as never, final: true }], + }), + /TFDSL_DECOMPILE_UNSUPPORTED/, + ); +}); + +test("paths: reject escape under cwd", () => { + const bad = resolveContainedOut("/tmp/proj", "../../etc/passwd"); + assert.equal(bad.ok, false); + const good = resolveContainedOut("/tmp/proj", "out/a.json"); + assert.equal(good.ok, true); +}); + +test("validate: built kinds pass core validateTaskflow", () => { + const src = ` +import { flow, agent, map, parallel, gate, reduce, approval, loop, tournament, script, json } from "taskflow-dsl"; +export default flow("all", (ctx) => { + ctx.concurrency(4); + const a = agent("A", { agent: "scout" }); + const m = map(a, (item) => agent(\`i \${item}\`), { agent: "scout" }); + const p = parallel([agent("b1"), agent("b2")]); + const g = gate(a, { agent: "reviewer" }, (i) => \`chk \${i.output}\`); + const r = reduce([m], (parts) => agent(\`sum \${parts.m.output}\`)); + const ap = approval({ request: "ok?" }); + const l = loop({ agent: "executor", maxIterations: 2, until: "false", task: "once" }); + const t = tournament({ variants: 2, agent: "executor", task: "compete", mode: "best" }); + const s = script("echo hi"); + return s; +}); +`; + const r = buildSource(src, "all.tf.ts"); + assert.equal(r.ok, true, fmt(r)); + const v = validateTaskflow(r.taskflow!); + assert.equal(v.ok, true, v.ok ? "" : v.errors.join("\n")); +}); From c31c890a851114f3479a15ade114da088a5acdca Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 16:35:23 +0800 Subject: [PATCH 27/51] feat(s4): parallel destructure, race/expand engine, DSL CLI skills - Desugar const [a,b]=parallel([...]) into real agent phase ids - Add race + expand phase types (nested/graft-promote); kernel stays on classic 10 - taskflow-dsl: race/expand erase, check --typecheck, gate decompile binds upstream - skills-src configuration.md: taskflow-dsl CLI workflow for all hosts --- AGENTS.md | 4 +- docs/rfc-0.2.0-dsl-phases-horizon.md | 4 +- .../plugin/skills/taskflow/SKILL.md | 2 +- .../plugin/skills/taskflow/configuration.md | 40 ++++ .../plugin/skills/taskflow/SKILL.md | 2 +- .../plugin/skills/taskflow/configuration.md | 40 ++++ .../plugin/skills/taskflow/SKILL.md | 2 +- .../plugin/skills/taskflow/configuration.md | 40 ++++ .../plugin/skills/taskflow/SKILL.md | 2 +- .../plugin/skills/taskflow/configuration.md | 40 ++++ packages/pi-taskflow/skills/taskflow/SKILL.md | 2 +- .../skills/taskflow/configuration.md | 40 ++++ packages/taskflow-core/src/exec/step.ts | 6 +- packages/taskflow-core/src/runtime.ts | 116 ++++++++++- packages/taskflow-core/src/schema.ts | 62 +++++- .../test/exec-kernel-s2-complete.test.ts | 29 ++- .../taskflow-core/test/race-expand.test.ts | 169 ++++++++++++++++ packages/taskflow-dsl/src/build/erase.ts | 181 ++++++++++++++++-- packages/taskflow-dsl/src/check.ts | 18 +- packages/taskflow-dsl/src/cli.ts | 9 +- packages/taskflow-dsl/src/decompile.ts | 18 +- packages/taskflow-dsl/src/runes.ts | 25 ++- packages/taskflow-dsl/src/typecheck.ts | 53 +++++ .../taskflow-dsl/test/erase-build.test.ts | 9 +- .../taskflow-dsl/test/kinds-coverage.test.ts | 36 +++- skills-src/taskflow/configuration.md | 40 ++++ skills-src/taskflow/core.md | 2 +- 27 files changed, 921 insertions(+), 70 deletions(-) create mode 100644 packages/taskflow-core/test/race-expand.test.ts create mode 100644 packages/taskflow-dsl/src/typecheck.ts diff --git a/AGENTS.md b/AGENTS.md index c17e59f..045b090 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil ## Key Concepts -### Phase Types (10 total) +### Phase Types (12 total) | Type | Purpose | |------|---------| | `agent` | Single subagent call | @@ -137,6 +137,8 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil | `loop` | Repeat body until condition, convergence, or max iterations | | `tournament` | N competing variants + judge picks best or aggregates | | `script` | Run a shell command (no LLM, zero tokens) — captures stdout; fields `run`/`input`/`timeout` | +| `race` | First completed branch wins (Horizon B) | +| `expand` | Dynamic fragment: nested sub-flow or graft-promote (`def` + `expandMode`) | ### Event kernel, trace, and offline replay (0.2.0 Phase 2) diff --git a/docs/rfc-0.2.0-dsl-phases-horizon.md b/docs/rfc-0.2.0-dsl-phases-horizon.md index 03cd10f..a822bdd 100644 --- a/docs/rfc-0.2.0-dsl-phases-horizon.md +++ b/docs/rfc-0.2.0-dsl-phases-horizon.md @@ -316,9 +316,9 @@ const sp = fork.save("after-plan"); |--------|----|----|----------|----------| | **P0** | 10 kind 基本 + `subflow.def` / `expand.nested` 糖 | A | S4 | 已有 | | **P0** | `gate.scored` / `gate.automated` / `reflexion` / `idempotent` 糖 | A | S4.1 | 已有 | -| **P1** | **`expand` graft** | B | rune + IR kind | **新** | +| **P1** | **`expand` graft** | B | rune + IR kind | **✅ runtime + DSL** (promote onto parent) | | **P1** | **`loop` multi-body** | B | body 回调 erase | **新** | -| **P1** | **`race`** | B | rune | **新** | +| **P1** | **`race`** | B | rune | **✅ runtime + DSL** | | **P2** | `route` 穷尽路由 | B | rune + check | desugar 或新 type | | **P2** | `compensate` / saga | B | opts 或 rune | **新** | | **P2** | approval timeout | B | opts | **小** | diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index d6ef5be..f01a0bf 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -37,7 +37,7 @@ mistakes that break flows. Load the companion files **only when needed**: |------|--------------------| | `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. | +| `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. **TypeScript DSL CLI** (`taskflow-dsl` / S4). | | `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? diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index a0ffb99..b47f210 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -408,6 +408,46 @@ Each entry is one of: --- +## 9. TypeScript DSL CLI (`taskflow-dsl` / S4) + +Author flows as **`.tf.ts`** (compile-time runes), then run the emitted JSON +through existing `taskflow_*` tools. JSON remains first-class (escape hatch). + +```bash +# From a monorepo checkout (dev): +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts new audit +# edit audit.tf.ts +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts +# optional full tsc Program pass: +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both +# → audit.taskflow.json (+ audit.flowir.json) +# Then: taskflow_verify / taskflow_run with defineFile=audit.taskflow.json +``` + +| Command | Purpose | +|---------|---------| +| `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | +| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | +| `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | + +**Authoring notes** + +- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. +- `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. +- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). +- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). + +Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. + +--- + ## Caveats (declared but not yet enforced) These keys validate but the runtime does **not** act on them yet — don't rely on diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index 71e0786..dc674c7 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -36,7 +36,7 @@ mistakes that break flows. Load the companion files **only when needed**: |------|--------------------| | `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. | +| `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. **TypeScript DSL CLI** (`taskflow-dsl` / S4). | | `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? diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index 06c5d69..70c6226 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -405,6 +405,46 @@ Each entry is one of: --- +## 9. TypeScript DSL CLI (`taskflow-dsl` / S4) + +Author flows as **`.tf.ts`** (compile-time runes), then run the emitted JSON +through existing `taskflow_*` tools. JSON remains first-class (escape hatch). + +```bash +# From a monorepo checkout (dev): +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts new audit +# edit audit.tf.ts +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts +# optional full tsc Program pass: +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both +# → audit.taskflow.json (+ audit.flowir.json) +# Then: taskflow_verify / taskflow_run with defineFile=audit.taskflow.json +``` + +| Command | Purpose | +|---------|---------| +| `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | +| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | +| `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | + +**Authoring notes** + +- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. +- `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. +- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). +- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). + +Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. + +--- + ## Caveats (declared but not yet enforced) These keys validate but the runtime does **not** act on them yet — don't rely on diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index 2126902..1258db8 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -39,7 +39,7 @@ mistakes that break flows. Load the companion files **only when needed**: |------|--------------------| | `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. | +| `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. **TypeScript DSL CLI** (`taskflow-dsl` / S4). | | `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? diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index 006ab62..9e8c7de 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -408,6 +408,46 @@ Each entry is one of: --- +## 9. TypeScript DSL CLI (`taskflow-dsl` / S4) + +Author flows as **`.tf.ts`** (compile-time runes), then run the emitted JSON +through existing `taskflow_*` tools. JSON remains first-class (escape hatch). + +```bash +# From a monorepo checkout (dev): +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts new audit +# edit audit.tf.ts +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts +# optional full tsc Program pass: +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both +# → audit.taskflow.json (+ audit.flowir.json) +# Then: taskflow_verify / taskflow_run with defineFile=audit.taskflow.json +``` + +| Command | Purpose | +|---------|---------| +| `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | +| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | +| `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | + +**Authoring notes** + +- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. +- `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. +- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). +- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). + +Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. + +--- + ## Caveats (declared but not yet enforced) These keys validate but the runtime does **not** act on them yet — don't rely on diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 7ec995d..9aca916 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -37,7 +37,7 @@ mistakes that break flows. Load the companion files **only when needed**: |------|--------------------| | `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. | +| `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. **TypeScript DSL CLI** (`taskflow-dsl` / S4). | | `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? diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index 60c14ad..dde1146 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -409,6 +409,46 @@ Each entry is one of: --- +## 9. TypeScript DSL CLI (`taskflow-dsl` / S4) + +Author flows as **`.tf.ts`** (compile-time runes), then run the emitted JSON +through existing `taskflow_*` tools. JSON remains first-class (escape hatch). + +```bash +# From a monorepo checkout (dev): +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts new audit +# edit audit.tf.ts +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts +# optional full tsc Program pass: +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both +# → audit.taskflow.json (+ audit.flowir.json) +# Then: taskflow_verify / taskflow_run with defineFile=audit.taskflow.json +``` + +| Command | Purpose | +|---------|---------| +| `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | +| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | +| `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | + +**Authoring notes** + +- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. +- `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. +- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). +- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). + +Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. + +--- + ## Caveats (declared but not yet enforced) These keys validate but the runtime does **not** act on them yet — don't rely on diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index 6b8c6f2..f3392b7 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -25,7 +25,7 @@ mistakes that break flows. Load the companion files **only when needed**: |------|--------------------| | `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` | 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`). | -| `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. | +| `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. **TypeScript DSL CLI** (`taskflow-dsl` / S4). | | `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? diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index de998b1..fba8119 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -412,6 +412,46 @@ Each entry is one of: --- +## 9. TypeScript DSL CLI (`taskflow-dsl` / S4) + +Author flows as **`.tf.ts`** (compile-time runes), then run the emitted JSON +through existing `taskflow_*` tools. JSON remains first-class (escape hatch). + +```bash +# From a monorepo checkout (dev): +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts new audit +# edit audit.tf.ts +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts +# optional full tsc Program pass: +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both +# → audit.taskflow.json (+ audit.flowir.json) +# Then: taskflow_verify / taskflow_run with defineFile=audit.taskflow.json +``` + +| Command | Purpose | +|---------|---------| +| `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | +| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | +| `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | + +**Authoring notes** + +- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. +- `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. +- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). +- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). + +Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. + +--- + ## Caveats (declared but not yet enforced) These keys validate but the runtime does **not** act on them yet — don't rely on diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index 0c93c16..cdac3b9 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -33,7 +33,11 @@ import { } from "./step-kinds.ts"; /** All DSL phase types — S2 kernel is complete. */ -export const EVENT_KERNEL_PHASE_TYPES = PHASE_TYPES; +/** Kernel kinds: original 10. Horizon B `race`/`expand` run on the imperative + * path until dedicated step handlers land (canUseEventKernel excludes them). */ +export const EVENT_KERNEL_PHASE_TYPES = PHASE_TYPES.filter( + (t) => t !== "race" && t !== "expand", +) as readonly (typeof PHASE_TYPES)[number][]; export type EventKernelPhaseType = (typeof EVENT_KERNEL_PHASE_TYPES)[number]; export type RunTaskFn = ( diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 1856cbe..169b19b 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -1856,6 +1856,57 @@ async function executePhaseInner( return ps; } + // Horizon B: first completed branch wins. Losers are aborted best-effort when + // cancelLosers is true (default) via AbortController per branch. + if (type === "race") { + const branches = (phase.branches ?? []).map((b) => { + const r = interpolate(b.task, ctx); + return { + agent: resolveAgent(b.agent ?? phase.agent, deps, state), + task: preRead + r.text, + }; + }); + if (branches.length < 2) { + return failPhase(phase.id, `race phase '${phase.id}': needs at least 2 branches`); + } + const ck = cacheKeys(cc, [phase.id, "race", phase.model ?? "", JSON.stringify(branches)]); + const inputHash = ck.key; + const cached = cachedPhase(cc, ck); + if (cached) return cached; + + // Race: fire all branches; first settled result wins. Loser cancellation is + // best-effort via parent signal only (per-branch AbortController plumbing + // requires runner support for multiple concurrent signals — follow-up). + // cancelLosers reserved for future hard-abort of losers. + void (phase as { cancelLosers?: boolean }).cancelLosers; + const raced = await Promise.race( + branches.map(async (b, i) => { + const result = await runOne(b.agent, b.task); + return { i, result }; + }), + ); + + const winner = raced.result; + const failed = isFailed(winner); + const ps: PhaseState = { + id: phase.id, + status: failed ? "failed" : "done", + output: failed + ? winner.errorMessage || winner.stderr || winner.output || `race branch ${raced.i + 1} failed` + : winner.output, + json: parseJson ? safeParse(winner.output) : undefined, + usage: winner.usage ?? emptyUsage(), + model: winner.model, + error: failed ? winner.errorMessage ?? winner.stderr : undefined, + inputHash, + endedAt: Date.now(), + warnings: [`race: branch ${raced.i + 1}/${branches.length} won`], + }; + if (readRefs.length) ps.reads = readRefsToReads(readRefs, state); + recordCache(cc, ps); + return ps; + } + if (type === "map") { const overResolved = interpolate(phase.over ?? "", ctx).text; // `over` may itself be a placeholder that resolved to a JSON string. @@ -1995,16 +2046,30 @@ async function executePhaseInner( return ps; } - if (type === "flow") { + if (type === "flow" || type === "expand") { const readRefs: string[] = []; const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref)); - const hasDef = (phase as { def?: unknown }).def !== undefined; + // expand always requires `def`; flow may use `use` or `def`. + const hasDef = + type === "expand" ? (phase as { def?: unknown }).def !== undefined : (phase as { def?: unknown }).def !== undefined; const stack = deps._stack ?? []; + const expandMode = + type === "expand" + ? ((phase as { expandMode?: string }).expandMode === "graft" ? "graft" : "nested") + : "nested"; + const maxNodes = + typeof (phase as { maxNodes?: number }).maxNodes === "number" + ? Math.min(MAX_DYNAMIC_PHASES, Math.max(1, (phase as { maxNodes: number }).maxNodes)) + : 50; let subDef: Taskflow | undefined; let name: string; let recursionKey: string; // identity used for cache key + recursion guard + if (type === "expand" && !hasDef) { + return failPhase(phase.id, `expand phase '${phase.id}' requires 'def'`); + } + if (hasDef) { // --- Inline `def`: resolve at runtime, validate, fail-OPEN on any error. --- // Fail-open contract: a bad def NEVER aborts the run. The phase resolves @@ -2040,7 +2105,7 @@ async function executePhaseInner( parsed = rawDef; } // Accept a full Taskflow, a bare phases array, or {phases:[...]}; wrap the latter two. - const wrapped = normalizeInlineDef(parsed, phase.id); + let wrapped = normalizeInlineDef(parsed, phase.id); if (!wrapped) { return defFailOpen("inline def is not a Taskflow, phases array, or {phases:[...]}"); } @@ -2053,11 +2118,37 @@ async function executePhaseInner( output: "", json: parseJson ? safeParse("") : undefined, usage: emptyUsage(), - inputHash: hashInput(phase.id, "flow-def-empty"), + inputHash: hashInput(phase.id, type === "expand" ? "expand-def-empty" : "flow-def-empty"), reads: readRefsToReads(readRefs, state), endedAt: Date.now(), }; } + // expand: cap fragment size + prefix ids for graft so they don't collide with parent. + if (type === "expand") { + if (wrapped.phases.length > maxNodes) { + return defFailOpen( + `expand fragment has ${wrapped.phases.length} phases (maxNodes=${maxNodes})`, + ); + } + if (expandMode === "graft") { + const prefix = `${phase.id}-`; + const idMap = new Map(wrapped.phases.map((p) => [p.id, prefix + p.id])); + wrapped = { + ...wrapped, + name: wrapped.name || `${phase.id}-graft`, + phases: wrapped.phases.map((p) => { + const np: Phase = { ...p, id: idMap.get(p.id) ?? prefix + p.id }; + if (p.dependsOn?.length) { + np.dependsOn = p.dependsOn.map((d) => idMap.get(d) ?? d); + } + if (p.from?.length) { + np.from = p.from.map((d) => idMap.get(d) ?? d); + } + return np; + }), + }; + } + } // Validate with `dynamic` hardening (breadth caps + cwd containment) since // this content is LLM-authored / untrusted. cwd anchors containment checks. const dynCwd = effCwd; @@ -2160,6 +2251,22 @@ async function executePhaseInner( }, }); const sp = Object.values(subState.phases); + // expand graft: promote child phase states onto the parent run so peek / + // downstream can address them as {steps.-.*}. Nested + // mode leaves children only in the sub-run. + const warnings: string[] = []; + if (type === "expand" && expandMode === "graft" && subResult.ok) { + let promoted = 0; + for (const [cid, cps] of Object.entries(subState.phases)) { + if (state.phases[cid]) { + warnings.push(`expand graft skipped promote of '${cid}' (id already exists on parent)`); + continue; + } + state.phases[cid] = { ...cps, id: cid }; + promoted++; + } + warnings.push(`expand graft: promoted ${promoted} phase(s) onto parent run`); + } const flowPs: PhaseState = { id: phase.id, status: subResult.ok ? "done" : "failed", @@ -2179,6 +2286,7 @@ async function executePhaseInner( inputHash, reads: readRefsToReads(readRefs, state), endedAt: Date.now(), + ...(warnings.length ? { warnings } : {}), }; recordCache(cc, flowPs); return flowPs; diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index 5ca25ea..3498ce0 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -17,7 +17,22 @@ import { WORKSPACE_KEYWORDS } from "./workspace.ts"; // --------------------------------------------------------------------------- /** 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 const PHASE_TYPES = [ + "agent", + "parallel", + "map", + "gate", + "reduce", + "approval", + "flow", + "loop", + "tournament", + "script", + /** First completed branch wins (Horizon B). */ + "race", + /** Dynamic sub-DAG: nested sub-flow or graft-promote into parent (Horizon B). */ + "expand", +] as const; export type PhaseType = (typeof PHASE_TYPES)[number]; /** Loop iteration bounds. Authors may lower the max; the hard cap is a runaway guard. */ @@ -51,7 +66,7 @@ export type CacheScope = (typeof CACHE_SCOPES)[number]; /** Allowed fingerprint entry prefixes. `glob!:` = content-hash variant of `glob:`. */ const CACHE_FINGERPRINT_PREFIXES = ["git:", "glob:", "glob!:", "file:", "env:"] as const; /** Phase types that must NOT be cached across runs (a fresh result is required each run). */ -const CACHE_CROSS_RUN_BLOCKED_TYPES = ["gate", "approval", "loop", "tournament", "script"] as const; +const CACHE_CROSS_RUN_BLOCKED_TYPES = ["gate", "approval", "loop", "tournament", "script", "race", "expand"] as const; const ParallelTaskSchema = Type.Object( { @@ -127,22 +142,39 @@ const PhaseSchema = Type.Object( ), as: Type.Optional(Type.String({ description: "[map] Loop variable name (default: item)", default: "item" })), - // parallel static branches - branches: Type.Optional(Type.Array(ParallelTaskSchema, { description: "[parallel] Static task branches" })), + // parallel / race static branches + branches: Type.Optional( + Type.Array(ParallelTaskSchema, { + description: "[parallel|race|tournament] Static task branches", + }), + ), + /** [race] When true (default), abort in-flight losers after the first branch finishes (best-effort). */ + cancelLosers: Type.Optional(Type.Boolean({ default: true })), // reduce from: Type.Optional( Type.Array(Type.String(), { description: "[reduce] Phase ids whose outputs are aggregated" }), ), - // sub-workflow (flow) + // sub-workflow (flow) + expand fragment use: Type.Optional(Type.String({ description: "[flow] Name of a saved taskflow to run as this phase" })), + /** [expand] Fragment source — string interpolation or inline Taskflow / phases (same as flow.def). */ + // reuses `def` below for expand as well def: Type.Optional( Type.Unknown({ description: - "[flow] Inline sub-flow definition, resolved at runtime. Mutually exclusive with 'use'. A string is interpolated (e.g. '{steps.plan.json}') then JSON-parsed; an object is used directly. The result must be a Taskflow ({name,phases}) or a bare phases array / {phases:[...]} (auto-wrapped). Validated + verified before execution; on any failure the phase fails-open (defError) without aborting the run.", + "[flow|expand] Inline sub-flow / fragment definition, resolved at runtime. Mutually exclusive with 'use' on flow. A string is interpolated (e.g. '{steps.plan.json}') then JSON-parsed; an object is used directly. The result must be a Taskflow ({name,phases}) or a bare phases array / {phases:[...]} (auto-wrapped). Validated + verified before execution; on any failure the phase fails-open (defError) without aborting the run.", }), ), + /** [expand] nested (default) = isolated sub-flow; graft = run fragment then promote phase states onto the parent under prefixed ids. */ + expandMode: Type.Optional( + StringEnum(["nested", "graft"] as const, { + description: "[expand] nested (default) | graft (promote child phase states onto parent)", + default: "nested", + }), + ), + /** [expand] Max nodes accepted from a dynamic fragment (default 50, hard 100). */ + maxNodes: Type.Optional(Type.Number({ default: 50 })), with: Type.Optional( Type.Record(Type.String(), Type.Unknown(), { description: "[flow] Args passed to the sub-flow (string values support interpolation)", @@ -780,6 +812,24 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va if (!p.branches || p.branches.length === 0) errors.push(`Phase '${p.id}' (parallel) requires non-empty 'branches'`); } + if (type === "race") { + if (!p.branches || p.branches.length === 0) + errors.push(`Phase '${p.id}' (race) requires non-empty 'branches'`); + else if (p.branches.length < 2) + errors.push(`Phase '${p.id}' (race): needs at least 2 branches`); + } + if (type === "expand") { + if ((p as { def?: unknown }).def === undefined) + errors.push(`Phase '${p.id}' (expand) requires 'def' (fragment Taskflow or {steps.X.json})`); + const em = (p as { expandMode?: string }).expandMode; + if (em !== undefined && em !== "nested" && em !== "graft") { + errors.push(`Phase '${p.id}' (expand): expandMode must be 'nested' or 'graft'`); + } + const maxN = (p as { maxNodes?: number }).maxNodes; + if (maxN !== undefined && (typeof maxN !== "number" || maxN < 1 || maxN > MAX_DYNAMIC_PHASES)) { + errors.push(`Phase '${p.id}' (expand): maxNodes must be 1..${MAX_DYNAMIC_PHASES}`); + } + } if (type === "reduce") { if (!p.from || p.from.length === 0) errors.push(`Phase '${p.id}' (reduce) requires 'from'`); if (!p.task) errors.push(`Phase '${p.id}' (reduce) requires 'task'`); diff --git a/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts index b36c65c..571759e 100644 --- a/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts +++ b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts @@ -53,16 +53,18 @@ async function withKernel(def: Taskflow, runTask: RuntimeDeps["runTask"], extra: }); } -test("EVENT_KERNEL_PHASE_TYPES covers all PHASE_TYPES", () => { - assert.deepEqual([...EVENT_KERNEL_PHASE_TYPES].sort(), [...PHASE_TYPES].sort()); +test("EVENT_KERNEL_PHASE_TYPES covers imperative-handled kinds (excludes race/expand until kernel handlers)", () => { + const expected = PHASE_TYPES.filter((t) => t !== "race" && t !== "expand").sort(); + assert.deepEqual([...EVENT_KERNEL_PHASE_TYPES].sort(), expected); }); -test("canUseEventKernel: all 10 basic kinds accepted (no advanced features)", () => { +test("canUseEventKernel: classic kinds accepted; race/expand force imperative path", () => { + const classic = PHASE_TYPES.filter((t) => t !== "race" && t !== "expand"); assert.equal( canUseEventKernel({ name: "all", - phases: PHASE_TYPES.map((type, i) => { - const base: Record = { id: `p${i}`, type, final: i === PHASE_TYPES.length - 1 }; + phases: classic.map((type, i) => { + const base: Record = { id: `p${i}`, type, final: i === classic.length - 1 }; if (type === "script") base.run = ["node", "-e", "1"]; if (type === "map") { base.over = '["x"]'; @@ -83,6 +85,23 @@ test("canUseEventKernel: all 10 basic kinds accepted (no advanced features)", () }), true, ); + assert.equal( + canUseEventKernel({ + name: "with-race", + phases: [ + { + id: "r", + type: "race", + branches: [ + { task: "a", agent: "a" }, + { task: "b", agent: "a" }, + ], + final: true, + }, + ], + }), + false, + ); }); test("kernel: reduce aggregates via agent", async () => { diff --git a/packages/taskflow-core/test/race-expand.test.ts b/packages/taskflow-core/test/race-expand.test.ts new file mode 100644 index 0000000..84860cf --- /dev/null +++ b/packages/taskflow-core/test/race-expand.test.ts @@ -0,0 +1,169 @@ +/** + * Horizon B: race + expand (nested/graft) imperative runtime. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { validateTaskflow, type Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import type { RunOptions, RunResult } from "../src/runner-core.ts"; +import { emptyUsage } from "../src/usage.ts"; +import type { AgentConfig } from "../src/agents.ts"; + +const AGENTS: AgentConfig[] = [ + { name: "a", description: "t", systemPrompt: "", source: "user", filePath: "" }, +]; + +function mkState(def: Taskflow): RunState { + return { + runId: "race-expand-test", + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: process.cwd(), + }; +} + +function runner(fn: (task: string) => string | Promise): RuntimeDeps["runTask"] { + return async (_c, _a, agent, task, _o: RunOptions): Promise => ({ + agent, + task, + exitCode: 0, + output: await fn(task), + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.001, turns: 1 }, + stopReason: "end", + }); +} + +test("validate: race + expand shapes", () => { + assert.equal( + validateTaskflow({ + name: "r", + phases: [ + { + id: "r", + type: "race", + branches: [ + { task: "a", agent: "a" }, + { task: "b", agent: "a" }, + ], + final: true, + }, + ], + }).ok, + true, + ); + assert.equal( + validateTaskflow({ + name: "e", + phases: [ + { + id: "e", + type: "expand", + def: { name: "child", phases: [{ id: "c", type: "script", run: "echo hi", final: true }] }, + expandMode: "nested", + final: true, + }, + ], + }).ok, + true, + ); +}); + +test("race: first completed branch wins", async () => { + const def: Taskflow = { + name: "race-flow", + phases: [ + { + id: "r", + type: "race", + branches: [ + { task: "slow", agent: "a" }, + { task: "fast", agent: "a" }, + ], + final: true, + }, + ], + }; + const st = mkState(def); + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: runner(async (task) => { + if (task.includes("slow")) { + await new Promise((r) => setTimeout(r, 40)); + return "SLOW"; + } + return "FAST"; + }), + }); + assert.equal(st.status, "completed"); + assert.equal(st.phases.r?.status, "done"); + assert.equal(st.phases.r?.output?.trim(), "FAST"); + assert.ok(st.phases.r?.warnings?.some((w) => /branch 2/.test(w))); +}); + +test("expand nested: runs fragment as sub-flow", async () => { + const def: Taskflow = { + name: "exp-nested", + phases: [ + { + id: "e", + type: "expand", + expandMode: "nested", + def: { + name: "frag", + phases: [{ id: "inner", type: "agent", agent: "a", task: "say nested-hi", final: true }], + }, + final: true, + }, + ], + }; + const st = mkState(def); + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: runner((t) => (t.includes("nested") ? "nested-hi" : "x")), + }); + assert.equal(st.status, "completed"); + assert.equal(st.phases.e?.status, "done"); + assert.equal(st.phases.e?.defError, undefined, st.phases.e?.defError); + assert.match(st.phases.e?.output ?? "", /nested-hi/); + // nested: child id not on parent + assert.equal(st.phases.inner, undefined); +}); + +test("expand graft: promotes child phases onto parent", async () => { + const def: Taskflow = { + name: "exp-graft", + phases: [ + { + id: "grow", + type: "expand", + expandMode: "graft", + def: { + name: "frag", + phases: [{ id: "leaf", type: "agent", agent: "a", task: "say grafted", final: true }], + }, + final: true, + }, + ], + }; + const st = mkState(def); + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: runner((t) => (t.includes("grafted") ? "grafted-ok" : "x")), + }); + assert.equal(st.status, "completed"); + assert.equal(st.phases.grow?.status, "done"); + assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError); + // grafted id is grow-leaf + assert.equal(st.phases["grow-leaf"]?.status, "done"); + assert.match(st.phases["grow-leaf"]?.output ?? "", /grafted-ok/); +}); diff --git a/packages/taskflow-dsl/src/build/erase.ts b/packages/taskflow-dsl/src/build/erase.ts index 9ab3a9e..2df16ac 100644 --- a/packages/taskflow-dsl/src/build/erase.ts +++ b/packages/taskflow-dsl/src/build/erase.ts @@ -24,6 +24,7 @@ const PHASE_RUNES = new Set([ "tournament", "script", "race", + "expand", ]); interface PhaseDraft { @@ -272,7 +273,19 @@ function mergeOpts( if (typeof v === "string") out.id = v; continue; } - if (key === "input" || key === "request" || key === "until" || key === "judge" || key === "judgeAgent" || key === "mode" || key === "use" || key === "onBlock") { + if ( + key === "input" || + key === "request" || + key === "until" || + key === "judge" || + key === "judgeAgent" || + key === "mode" || + key === "use" || + key === "onBlock" || + key === "cancelLosers" || + key === "expandMode" || + key === "maxNodes" + ) { const v = evalLiteral(p.initializer); if (v !== undefined) out[key] = v; continue; @@ -376,13 +389,16 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul const cn = calleeName(call.expression); if (!cn) return undefined; - // expand.nested / subflow.def → flow phase + // expand.nested → type:expand expandMode:nested; subflow.def → type:flow def if (cn === "expand.nested" || cn === "subflow.def") { - const id = bindName ?? `flow-${order.length}`; + const id = bindName ?? (cn.startsWith("expand") ? `expand-${order.length}` : `flow-${order.length}`); const draft: PhaseDraft = { id, - type: "flow", - raw: { type: "flow" }, + type: cn === "expand.nested" ? "expand" : "flow", + raw: + cn === "expand.nested" + ? { type: "expand", expandMode: "nested" } + : { type: "flow" }, dependsOn: new Set(), }; const defArg = call.arguments[0]; @@ -430,19 +446,91 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul } const type = cn === "race" ? "race" : cn; - if (type === "race") { - diags.push( - diag( - file, - sf, - call, - "TFDSL_PHASE_UNSUPPORTED", - `Phase type "race" is designed (horizon B) but not implemented in the engine yet.`, - "error", - "Remove race() or wait for S4.x engine support.", - ), - ); - return undefined; + // race([agent(...), ...], opts?) + if (cn === "race") { + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type: "race", + raw: { type: "race" }, + dependsOn: new Set(), + }; + const arr = call.arguments[0]; + const branches: Array> = []; + if (arr && ts.isArrayLiteralExpression(arr)) { + for (const el of arr.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); + const b: Record = {}; + if (erased) { + b.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(b, bopts); + branches.push(b); + } + } + } + draft.raw.branches = branches; + const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + if (typeof opts.cancelLosers === "boolean") draft.raw.cancelLosers = opts.cancelLosers; + Object.assign(draft.raw, opts); + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (opts.final === true) { + draft.final = true; + draft.raw.final = true; + } + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; + } + + // expand(...) / expand.graft(...) — expand.nested handled above + if (cn === "expand" || cn === "expand.graft") { + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type: "expand", + raw: { + type: "expand", + expandMode: cn === "expand.graft" ? "graft" : "nested", + }, + dependsOn: new Set(), + }; + const defArg = call.arguments[0]; + if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { + const pid = defArg.expression.text; + if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { + draft.dependsOn.add(pid); + draft.raw.def = + defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (defArg && ts.isStringLiteral(defArg)) { + draft.raw.def = defArg.text; + } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { + draft.dependsOn.add(defArg.text); + draft.raw.def = `{steps.${defArg.text}.json}`; + } + const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + if (typeof opts.expandMode === "string") draft.raw.expandMode = opts.expandMode; + if (typeof opts.maxNodes === "number") draft.raw.maxNodes = opts.maxNodes; + // expand(...) default nested; expand.graft → graft + if (cn === "expand.graft") draft.raw.expandMode = "graft"; + if (cn === "expand" && !draft.raw.expandMode) draft.raw.expandMode = "nested"; + Object.assign(draft.raw, opts); + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (opts.final === true) { + draft.final = true; + draft.raw.final = true; + } + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; } // gate.automated / gate.scored → type:gate with eval / score @@ -834,13 +922,64 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul if (ts.isVariableStatement(st)) { for (const decl of st.declarationList.declarations) { if (!decl.initializer) continue; - // const [a,b] = parallel(...) + // const [a,b] = parallel([agent(...), agent(...)]) + // Desugar to independent agent phases with true ids (a, b) so + // {steps.a.output} works. Concurrent because no dependsOn between them. if (ts.isArrayBindingPattern(decl.name) && ts.isCallExpression(decl.initializer)) { const cn = calleeName(decl.initializer.expression); if (cn === "parallel") { - handleCall("parallel-0", decl.initializer); + const bindNames = decl.name.elements + .map((e) => + ts.isBindingElement(e) && ts.isIdentifier(e.name) ? e.name.text : undefined, + ) + .filter((x): x is string => !!x); + const arr = decl.initializer.arguments[0]; + if (!arr || !ts.isArrayLiteralExpression(arr) || arr.elements.length !== bindNames.length) { + diags.push( + diag( + file, + sf, + decl.initializer, + "TFDSL_PARALLEL_DESTRUCTURE", + `parallel destructure requires matching binding count and array of agent() calls (got ${bindNames.length} binds).`, + ), + ); + continue; + } + let ok = true; + for (let i = 0; i < bindNames.length; i++) { + const el = arr.elements[i]!; + if (!ts.isCallExpression(el) || calleeName(el.expression) !== "agent") { + diags.push( + diag( + file, + sf, + el, + "TFDSL_PARALLEL_DESTRUCTURE", + `parallel destructure branch ${i + 1} must be agent(...).`, + ), + ); + ok = false; + break; + } + handleCall(bindNames[i], el); + } + if (!ok) continue; + continue; + } + if (cn === "race") { + // race stays one phase — destructure not supported + diags.push( + diag( + file, + sf, + decl.initializer, + "TFDSL_RACE_DESTRUCTURE", + `race() does not support array destructure — bind as a single phase: const winner = race([...]).`, + ), + ); + continue; } - continue; } if (!ts.isIdentifier(decl.name)) continue; const name = decl.name.text; diff --git a/packages/taskflow-dsl/src/check.ts b/packages/taskflow-dsl/src/check.ts index 11b8ee4..48788a4 100644 --- a/packages/taskflow-dsl/src/check.ts +++ b/packages/taskflow-dsl/src/check.ts @@ -1,18 +1,22 @@ /** - * Lightweight check — AST erase + validateTaskflow, no artifact write. - * - * S4 honesty: this is **not** a full tsc Program typecheck (RFC §2.2 optional - * path). It catches erase/DAG errors only. Use `tsc` / IDE for TS type errors. + * Check — AST erase + validateTaskflow by default. + * Optional `--typecheck` / `typecheck: true` adds a TypeScript Program pass. */ import fs from "node:fs"; import path from "node:path"; import { buildFile, buildSource, type BuildResult } from "./build.ts"; import type { Diagnostic } from "./diagnostics.ts"; +import { hasErrors } from "./diagnostics.ts"; +import { typecheckFile } from "./typecheck.ts"; export interface CheckOptions { /** Skip Taskflow validate (rune/static only). Default false. */ noValidate?: boolean; + /** Run full tsc Program diagnostics on the file (default false). */ + typecheck?: boolean; + /** cwd for tsconfig discovery when typecheck is on. */ + cwd?: string; } export interface CheckResult { @@ -47,5 +51,9 @@ export function checkFile(filePath: string, opts: CheckOptions = {}): CheckResul }; } const r = buildFile(abs, { validate: !opts.noValidate, irHash: false, emit: "taskflow" }); - return { ok: r.ok, diagnostics: r.diagnostics, file: r.file }; + const diagnostics = [...r.diagnostics]; + if (opts.typecheck) { + diagnostics.push(...typecheckFile(abs, opts.cwd ?? path.dirname(abs))); + } + return { ok: !hasErrors(diagnostics), diagnostics, file: r.file }; } diff --git a/packages/taskflow-dsl/src/cli.ts b/packages/taskflow-dsl/src/cli.ts index 64a768f..4120a17 100644 --- a/packages/taskflow-dsl/src/cli.ts +++ b/packages/taskflow-dsl/src/cli.ts @@ -28,7 +28,7 @@ function usage(): string { Commands: build Erase .tf.ts (or validate .json) → Taskflow / FlowIR - check Erase + validate (no write; not full tsc) + check Erase + validate (no write) decompile Taskflow JSON → .tf.ts new [name] Write hello skeleton @@ -36,6 +36,7 @@ Options: --cwd Project root for path resolution (default: process.cwd()) -o, --out Output path (must stay under --cwd) --emit taskflow|flowir|both (build, default: taskflow) + --typecheck (check) also run tsc Program diagnostics --json Machine-readable diagnostics --force Overwrite existing file (new) --json-escape new: emit JSON skeleton instead of .tf.ts @@ -57,6 +58,7 @@ function parseArgs(argv: string[]) { else if (a === "--emit") flags.emit = args[++i] ?? "taskflow"; else if (a === "--cwd") flags.cwd = args[++i] ?? ""; else if (a === "--force") flags.force = true; + else if (a === "--typecheck") flags.typecheck = true; else if (a === "--json-escape") flags.jsonEscape = true; else if (a.startsWith("-")) flags[a] = true; else positional.push(a); @@ -138,7 +140,10 @@ function main(): void { process.stderr.write("check requires a file path\n"); process.exit(2); } - const r = checkFile(resolveInput(cwd, file)); + const r = checkFile(resolveInput(cwd, file), { + typecheck: flags.typecheck === true, + cwd, + }); if (flags.json) { process.stdout.write(JSON.stringify({ ok: r.ok, diagnostics: r.diagnostics }, null, 2) + "\n"); } else { diff --git a/packages/taskflow-dsl/src/decompile.ts b/packages/taskflow-dsl/src/decompile.ts index a7c2648..1c63919 100644 --- a/packages/taskflow-dsl/src/decompile.ts +++ b/packages/taskflow-dsl/src/decompile.ts @@ -37,6 +37,8 @@ const DECOMPILABLE = new Set([ "flow", "loop", "tournament", + "race", + "expand", ]); export function decompileTaskflow(def: Taskflow): string { @@ -111,8 +113,20 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri ); return `const ${bind} = parallel([${branches.join(", ")}]${optStr});`; } - case "gate": - return `const ${bind} = gate(/* upstream */ ${JSON.stringify(p.dependsOn?.[0] ?? "upstream")}, { agent: ${JSON.stringify(p.agent ?? "reviewer")} }, (i) => \`${esc(String(p.task ?? "{steps.upstream.output}"))}\`);`; + case "gate": { + const upId = p.dependsOn?.[0]; + const upBind = upId ? phaseBinding(upId) : "upstream"; + const taskText = String(p.task ?? (upId ? `{steps.${upId}.output}` : "review")); + return `const ${bind} = gate(${upBind}, { agent: ${JSON.stringify(p.agent ?? "reviewer")} }, (i) => \`${esc(taskText)}\`);`; + } + case "race": { + const branches = (p.branches ?? []).map( + (b) => `agent(\`${esc(String(b.task ?? ""))}\`${b.agent ? `, { agent: ${JSON.stringify(b.agent)} }` : ""})`, + ); + return `const ${bind} = race([${branches.join(", ")}]${optStr});`; + } + case "expand": + return `const ${bind} = expand(${JSON.stringify(typeof p.def === "string" ? p.def : "{steps.plan.json}")}, { expandMode: ${JSON.stringify((p as { expandMode?: string }).expandMode ?? "nested")} });`; case "reduce": { const from = (p.from ?? p.dependsOn ?? []).map((id) => phaseBinding(id)); return `const ${bind} = reduce([${from.join(", ")}], (parts) => agent(\`${esc(String(p.task ?? "reduce"))}\`)${optStr});`; diff --git a/packages/taskflow-dsl/src/runes.ts b/packages/taskflow-dsl/src/runes.ts index 4efbcc2..7265279 100644 --- a/packages/taskflow-dsl/src/runes.ts +++ b/packages/taskflow-dsl/src/runes.ts @@ -199,19 +199,32 @@ export function script( return eraseOnly("script"); } -/** Alias for nested expand (A-track); graft is S4.x. */ +/** Nested expand (isolated sub-flow). */ export function expandNested( _def: PhaseRef | string, - _opts?: PhaseOptions, + _opts?: PhaseOptions & { maxNodes?: number }, ): PhaseRef { return eraseOnly("expand.nested"); } -export const expand = { - nested: expandNested, -}; +/** Graft-promote expand: run fragment then promote phase states onto parent. */ +export function expandGraft( + _def: PhaseRef | string, + _opts?: PhaseOptions & { maxNodes?: number }, +): PhaseRef { + return eraseOnly("expand.graft"); +} + +export function expand( + _def: PhaseRef | string, + _opts?: PhaseOptions & { expandMode?: "nested" | "graft"; maxNodes?: number }, +): PhaseRef { + return eraseOnly("expand"); +} +expand.nested = expandNested; +expand.graft = expandGraft; -/** Race — B-track; present as erase-only so authors can typecheck against horizon. */ +/** Race: first completed branch wins. */ export function race( _branches: PhaseRef[], _opts?: PhaseOptions & { cancelLosers?: boolean }, diff --git a/packages/taskflow-dsl/src/typecheck.ts b/packages/taskflow-dsl/src/typecheck.ts new file mode 100644 index 0000000..7e30b76 --- /dev/null +++ b/packages/taskflow-dsl/src/typecheck.ts @@ -0,0 +1,53 @@ +/** + * Optional full tsc Program diagnostics for a .tf.ts file. + */ + +import path from "node:path"; +import ts from "typescript"; +import type { Diagnostic } from "./diagnostics.ts"; + +export function typecheckFile(filePath: string, cwd = process.cwd()): Diagnostic[] { + const abs = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath); + const configPath = ts.findConfigFile(cwd, ts.sys.fileExists, "tsconfig.json"); + let options: ts.CompilerOptions = { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + noEmit: true, + esModuleInterop: true, + allowJs: false, + }; + if (configPath) { + const read = ts.readConfigFile(configPath, ts.sys.readFile); + if (!read.error) { + const parsed = ts.parseJsonConfigFileContent(read.config, ts.sys, path.dirname(configPath)); + options = { ...parsed.options, noEmit: true }; + } + } + const host = ts.createCompilerHost(options); + const program = ts.createProgram([abs], options, host); + const diags = [ + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ].filter((d) => { + const f = d.file?.fileName; + return !f || path.resolve(f) === abs; + }); + return diags.map((d) => { + const file = d.file; + let range: Diagnostic["range"]; + if (file && d.start !== undefined) { + const { line, character } = file.getLineAndCharacterOfPosition(d.start); + range = { line: line + 1, character: character + 1 }; + } + return { + code: `TS${d.code}`, + severity: d.category === ts.DiagnosticCategory.Error ? "error" : "warning", + message: ts.flattenDiagnosticMessageText(d.messageText, "\n"), + file: file?.fileName ?? abs, + range, + } satisfies Diagnostic; + }); +} diff --git a/packages/taskflow-dsl/test/erase-build.test.ts b/packages/taskflow-dsl/test/erase-build.test.ts index 08d4ccf..51c9617 100644 --- a/packages/taskflow-dsl/test/erase-build.test.ts +++ b/packages/taskflow-dsl/test/erase-build.test.ts @@ -71,14 +71,15 @@ export default flow("p", () => { assert.ok(types.includes("script")); }); -test("build: race is unsupported error", () => { +test("build: race erases to type race", () => { const src = ` import { flow, agent, race } from "taskflow-dsl"; -export default flow("r", () => race([agent("a"), agent("b")])); +export default flow("r", () => race([agent("fast path"), agent("slow path")], { cancelLosers: true })); `; const r = buildSource(src, "r.tf.ts"); - assert.equal(r.ok, false); - assert.ok(r.diagnostics.some((d) => d.code === "TFDSL_PHASE_UNSUPPORTED")); + assert.equal(r.ok, true, format(r)); + assert.equal(r.taskflow?.phases?.[0]?.type, "race"); + assert.equal(r.taskflow?.phases?.[0]?.branches?.length, 2); }); test("check: ok for hello", () => { diff --git a/packages/taskflow-dsl/test/kinds-coverage.test.ts b/packages/taskflow-dsl/test/kinds-coverage.test.ts index 5370e1f..f79a347 100644 --- a/packages/taskflow-dsl/test/kinds-coverage.test.ts +++ b/packages/taskflow-dsl/test/kinds-coverage.test.ts @@ -65,26 +65,52 @@ export default flow("tour", () => assert.equal(r.taskflow!.phases![0]!.variants, 2); }); -test("build: subflow use + expand.nested", () => { +test("build: subflow use + expand.nested + expand.graft", () => { const src = ` import { flow, agent, subflow, expand, json } from "taskflow-dsl"; export default flow("sf", () => { const plan = agent("emit plan", { output: json() }); const nested = expand.nested(plan.json); - const saved = subflow("child-flow", { q: "1" }, { dependsOn: ["nested"] }); + const grafted = expand.graft(plan.json, { dependsOn: ["nested"] }); + const saved = subflow("child-flow", { q: "1" }, { dependsOn: ["grafted"] }); return saved; }); `; const r = buildSource(src, "sf.tf.ts"); assert.equal(r.ok, true, fmt(r)); - const types = r.taskflow!.phases!.map((p) => p.type); - assert.deepEqual(types, ["agent", "flow", "flow"]); const nested = r.taskflow!.phases!.find((p) => p.id === "nested"); + assert.equal(nested?.type, "expand"); + assert.equal((nested as { expandMode?: string }).expandMode, "nested"); assert.match(String(nested?.def), /steps\.plan\.json/); + const grafted = r.taskflow!.phases!.find((p) => p.id === "grafted"); + assert.equal(grafted?.type, "expand"); + assert.equal((grafted as { expandMode?: string }).expandMode, "graft"); const saved = r.taskflow!.phases!.find((p) => p.id === "saved"); assert.equal(saved?.use, "child-flow"); }); +test("build: parallel destructure → real agent phase ids", () => { + const src = ` +import { flow, agent, parallel, reduce } from "taskflow-dsl"; +export default flow("pd", () => { + const [auth, perf] = parallel([ + agent("check auth", { agent: "scout" }), + agent("check perf", { agent: "scout" }), + ]); + return reduce([auth, perf], (p) => agent(\`merge \${p.auth.output} \${p.perf.output}\`)); +}); +`; + const r = buildSource(src, "pd.tf.ts"); + assert.equal(r.ok, true, fmt(r)); + const ids = r.taskflow!.phases!.map((p) => p.id); + assert.ok(ids.includes("auth")); + assert.ok(ids.includes("perf")); + assert.equal(r.taskflow!.phases!.find((p) => p.id === "auth")?.type, "agent"); + assert.equal(r.taskflow!.phases!.find((p) => p.id === "perf")?.type, "agent"); + // no single parallel phase when destructured + assert.ok(!r.taskflow!.phases!.some((p) => p.type === "parallel")); +}); + test("build: gate.automated + gate.scored", () => { const src = ` import { flow, agent, gate } from "taskflow-dsl"; @@ -131,7 +157,7 @@ test("negative: decompile rejects unknown phase type", () => { () => decompileTaskflow({ name: "x", - phases: [{ id: "r", type: "race" as never, final: true }], + phases: [{ id: "r", type: "saga" as never, final: true }], }), /TFDSL_DECOMPILE_UNSUPPORTED/, ); diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index 8b0eb2a..9c2bde5 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -449,6 +449,46 @@ Each entry is one of: --- +## 9. TypeScript DSL CLI (`taskflow-dsl` / S4) + +Author flows as **`.tf.ts`** (compile-time runes), then run the emitted JSON +through existing `taskflow_*` tools. JSON remains first-class (escape hatch). + +```bash +# From a monorepo checkout (dev): +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts new audit +# edit audit.tf.ts +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts +# optional full tsc Program pass: +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck +node --conditions=development --experimental-strip-types \ + packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both +# → audit.taskflow.json (+ audit.flowir.json) +# Then: taskflow_verify / taskflow_run with defineFile=audit.taskflow.json +``` + +| Command | Purpose | +|---------|---------| +| `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | +| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | +| `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | + +**Authoring notes** + +- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. +- `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. +- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). +- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). + +Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. + +--- + ## Caveats (declared but not yet enforced) These keys validate but the runtime does **not** act on them yet — don't rely on diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 93a86f3..33f7145 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -16,7 +16,7 @@ mistakes that break flows. Load the companion files **only when needed**: | `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. | +| `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. **TypeScript DSL CLI** (`taskflow-dsl` / S4). | | `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? From 2aa46db06deeefefb7c7e31f076119aa6dcf0dd3 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 16:38:32 +0800 Subject: [PATCH 28/51] refactor: modularize DSL erase and extract race phase module Split taskflow-dsl build/erase into ast/templates/opts/pipeline modules. Move race execution to runtime/phases/race.ts; document the no-monolith policy in AGENTS.md and docs/internal/modularization-0.2.0.md. --- AGENTS.md | 9 +- docs/internal/modularization-0.2.0.md | 99 ++ packages/taskflow-core/src/runtime.ts | 40 +- .../taskflow-core/src/runtime/phases/race.ts | 79 ++ packages/taskflow-dsl/src/build/erase.ts | 1186 +---------------- packages/taskflow-dsl/src/build/erase/ast.ts | 62 + .../taskflow-dsl/src/build/erase/index.ts | 3 + packages/taskflow-dsl/src/build/erase/opts.ts | 135 ++ .../taskflow-dsl/src/build/erase/pipeline.ts | 770 +++++++++++ .../taskflow-dsl/src/build/erase/templates.ts | 214 +++ .../taskflow-dsl/src/build/erase/types.ts | 44 + 11 files changed, 1421 insertions(+), 1220 deletions(-) create mode 100644 docs/internal/modularization-0.2.0.md create mode 100644 packages/taskflow-core/src/runtime/phases/race.ts create mode 100644 packages/taskflow-dsl/src/build/erase/ast.ts create mode 100644 packages/taskflow-dsl/src/build/erase/index.ts create mode 100644 packages/taskflow-dsl/src/build/erase/opts.ts create mode 100644 packages/taskflow-dsl/src/build/erase/pipeline.ts create mode 100644 packages/taskflow-dsl/src/build/erase/templates.ts create mode 100644 packages/taskflow-dsl/src/build/erase/types.ts diff --git a/AGENTS.md b/AGENTS.md index 045b090..3484c04 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -238,6 +238,7 @@ pnpm run test:e2e-grok-mcp # grok MCP stdio e2e (src; no live grok needed ### 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/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/*`). +- **No monolith growth**: prefer cohesive folders (`runtime/phases/.ts`, `build/erase/*`) over lengthening `runtime.ts` / fat erase pipelines. See `docs/internal/modularization-0.2.0.md`. - **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). @@ -248,9 +249,11 @@ pnpm run test:e2e-grok-mcp # grok MCP stdio e2e (src; no live grok needed ### Adding a New Phase Type 1. Add the type string to `PHASE_TYPES` in `schema.ts`. 2. Add per-type validation in `validateTaskflow()`. -3. Add the execution branch in `executePhase()` in `runtime.ts`. -4. Add tests in `packages/taskflow-core/test/runtime-branches.test.ts` (or a new file). -5. Update the skill sources in `skills-src/taskflow/` (never the generated files) and run `node scripts/build-skills.mjs`. +3. **Implement the kind in `runtime/phases/.ts`** (pure-ish helpers); wire with a one-liner from `executePhaseInner` in `runtime.ts`. Do **not** paste a large block into the monolith. +4. If event-kernel support is needed: handler in `exec/step-kinds.ts` (or keep excluded from `EVENT_KERNEL_PHASE_TYPES` until then). +5. Add tests in `packages/taskflow-core/test/` (prefer a focused `.test.ts`). +6. DSL: emitter under `taskflow-dsl/src/build/erase/` (not a growing dump in one file). +7. Update skill sources in `skills-src/taskflow/` and run `node scripts/build-skills.mjs`. ### Adding a New Condition Operator 1. Add the token to `OPS` in `interpolate.ts`. diff --git a/docs/internal/modularization-0.2.0.md b/docs/internal/modularization-0.2.0.md new file mode 100644 index 0000000..5cf888d --- /dev/null +++ b/docs/internal/modularization-0.2.0.md @@ -0,0 +1,99 @@ +# Modularization plan — avoid monoliths (0.2.0) + +> Status: **in progress** · 2026-07-09 +> Principle: **low coupling, high cohesion** — one module, one job; new kinds land as plugins, not edits in a 3k-line file. + +## Why + +| Hotspot | Lines (approx) | Problem | +|---------|----------------|---------| +| `taskflow-core/src/runtime.ts` | ~3500 | All phase kinds + cache + spawn + layers in one unit | +| `taskflow-dsl/src/build/erase.ts` (was) | ~1200 | All rune emitters + templates + AST helpers | +| `schema.ts` / `store.ts` / `pi-taskflow/index.ts` | 1k–2k | Secondary; same pattern | + +Adding `race` / `expand` by pasting into `runtime.ts` is exactly the anti-pattern we reject. + +## Target shapes + +### taskflow-dsl build (S4) — **done this PR** + +``` +src/build/ + erase/ + index.ts # public: eraseSource + types.ts # PhaseDraft, EraseSession, PHASE_RUNES + ast.ts # calleeName, evalLiteral, diag (no phase knowledge) + templates.ts # string/template → placeholders + deps + opts.ts # mergeOpts, registerDraft + pipeline.ts # flow() discovery + body walk + kind dispatch + build.ts # validate + FlowIR (depends on erase, not TS AST) + erase.ts # thin re-export for stable import path +``` + +**Rules** + +- `ast.ts` must not import phase/kind logic. +- Kind-specific emit stays in `pipeline.ts` for now; next cut: `erase/kinds/*.ts` with a registry. +- `build.ts` never grows AST knowledge. + +### taskflow-core runtime — **strangler** + +``` +src/runtime.ts # facade: executeTaskflow, re-exports (shrink over time) +src/runtime/ + types.ts # RuntimeDeps, RuntimeResult (extract later) + phase-cache.ts # cacheKeys, recordCache (extract later) + layers.ts # runTaskflowLayers (extract later) + phases/ + race.ts # ✅ executeRaceBranches (Horizon B) + expand.ts # next: peel flow/expand from executePhaseInner + parallel.ts # next + map.ts # next + … +``` + +**Rules for new phase kinds** + +1. **New file under `runtime/phases/.ts`** (or shared helper) — no new 200-line blocks in `runtime.ts`. +2. Pure-ish API: inputs = phase + resolved branches/tasks + injectables (`runOne`, cache hooks). +3. Wire from `executePhaseInner` with a **one-liner** `if (type === "x") return await executeX(...)`. +4. Event kernel: either a matching `exec/step-kinds` handler **or** exclude from `EVENT_KERNEL_PHASE_TYPES` until then (race/expand already excluded). + +### schema / FlowIR + +- `PHASE_TYPES` remains the **single registry** of kind strings. +- FlowIR kinds follow automatically via `StringEnum(PHASE_TYPES)`. +- Validation per kind stays in `validateTaskflow` for now; optional later: `schema/kinds/.ts` validators composed into one. + +## Dependency direction (must stay acyclic) + +``` +taskflow-dsl ──▶ taskflow-core (schema, validate, FlowIR only) + │ + ├─ runtime/phases/* ──▶ schema, interpolate, usage, runner-core + ├─ exec/* ──▶ schema, interpolate (no runtime.ts import) + └─ flowir/* ──▶ schema +``` + +`exec/*` must **never** import `runtime.ts` (already true; keep it). + +## Migration checklist + +| Step | Status | +|------|--------| +| Split DSL erase into `build/erase/*` | ✅ | +| Extract `race` phase module | ✅ | +| Extract `expand`/`flow` promote helpers | next | +| Extract map/parallel/gate from `executePhaseInner` | next | +| Split `schema` validators by kind | later | +| Split `pi-taskflow/src/index.ts` by command surface | later | + +## PR policy + +- Prefer **extract + rewire** over “rewrite while feature-adding”. +- Each extract PR: green unit suite for that kind; no behavior change expected (diff only structure). +- Feature PRs for new kinds: **must** add `runtime/phases/.ts` (or explicit exception in the PR body). + +--- + +*One-liner: grow the system by adding modules, not by lengthening monoliths.* diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 169b19b..7e3f57c 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -1856,9 +1856,9 @@ async function executePhaseInner( return ps; } - // Horizon B: first completed branch wins. Losers are aborted best-effort when - // cancelLosers is true (default) via AbortController per branch. + // Horizon B: race — implementation lives in runtime/phases/race.ts if (type === "race") { + const { executeRaceBranches } = await import("./runtime/phases/race.ts"); const branches = (phase.branches ?? []).map((b) => { const r = interpolate(b.task, ctx); return { @@ -1866,43 +1866,15 @@ async function executePhaseInner( task: preRead + r.text, }; }); - if (branches.length < 2) { - return failPhase(phase.id, `race phase '${phase.id}': needs at least 2 branches`); - } const ck = cacheKeys(cc, [phase.id, "race", phase.model ?? "", JSON.stringify(branches)]); const inputHash = ck.key; const cached = cachedPhase(cc, ck); if (cached) return cached; - - // Race: fire all branches; first settled result wins. Loser cancellation is - // best-effort via parent signal only (per-branch AbortController plumbing - // requires runner support for multiple concurrent signals — follow-up). - // cancelLosers reserved for future hard-abort of losers. - void (phase as { cancelLosers?: boolean }).cancelLosers; - const raced = await Promise.race( - branches.map(async (b, i) => { - const result = await runOne(b.agent, b.task); - return { i, result }; - }), - ); - - const winner = raced.result; - const failed = isFailed(winner); - const ps: PhaseState = { - id: phase.id, - status: failed ? "failed" : "done", - output: failed - ? winner.errorMessage || winner.stderr || winner.output || `race branch ${raced.i + 1} failed` - : winner.output, - json: parseJson ? safeParse(winner.output) : undefined, - usage: winner.usage ?? emptyUsage(), - model: winner.model, - error: failed ? winner.errorMessage ?? winner.stderr : undefined, + const ps = await executeRaceBranches(phase, branches, runOne, isFailed, { inputHash, - endedAt: Date.now(), - warnings: [`race: branch ${raced.i + 1}/${branches.length} won`], - }; - if (readRefs.length) ps.reads = readRefsToReads(readRefs, state); + parseJson, + readRefs: readRefs.length ? readRefsToReads(readRefs, state) : undefined, + }); recordCache(cc, ps); return ps; } diff --git a/packages/taskflow-core/src/runtime/phases/race.ts b/packages/taskflow-core/src/runtime/phases/race.ts new file mode 100644 index 0000000..65e096a --- /dev/null +++ b/packages/taskflow-core/src/runtime/phases/race.ts @@ -0,0 +1,79 @@ +/** + * Race phase — first completed branch wins. + * + * Isolated from the runtime monolith so Horizon B kinds land without growing + * `runtime.ts` further. Callers inject fan-out primitives (runOne / cache). + */ + +import type { Phase } from "../../schema.ts"; +import type { RunResult } from "../../runner-core.ts"; +import type { PhaseState } from "../../store.ts"; +import { emptyUsage } from "../../usage.ts"; +import { safeParse } from "../../interpolate.ts"; + +export interface RaceBranch { + agent: string; + task: string; +} + +export interface RaceRunOne { + (agent: string, task: string): Promise; +} + +export interface RaceIsFailed { + (r: RunResult): boolean; +} + +/** + * Execute race branches. Pure w.r.t. scheduling — does not touch RunState. + */ +export async function executeRaceBranches( + phase: Phase, + branches: RaceBranch[], + runOne: RaceRunOne, + isFailed: RaceIsFailed, + opts: { + inputHash: string; + parseJson: boolean; + readRefs?: PhaseState["reads"]; + }, +): Promise { + if (branches.length < 2) { + return { + id: phase.id, + status: "failed", + error: `race phase '${phase.id}': needs at least 2 branches`, + endedAt: Date.now(), + usage: emptyUsage(), + inputHash: opts.inputHash, + }; + } + + // cancelLosers reserved for multi-signal abort plumbing + void (phase as { cancelLosers?: boolean }).cancelLosers; + + const raced = await Promise.race( + branches.map(async (b, i) => { + const result = await runOne(b.agent, b.task); + return { i, result }; + }), + ); + + const winner = raced.result; + const failed = isFailed(winner); + return { + id: phase.id, + status: failed ? "failed" : "done", + output: failed + ? winner.errorMessage || winner.stderr || winner.output || `race branch ${raced.i + 1} failed` + : winner.output, + json: opts.parseJson ? safeParse(winner.output) : undefined, + usage: winner.usage ?? emptyUsage(), + model: winner.model, + error: failed ? winner.errorMessage ?? winner.stderr : undefined, + inputHash: opts.inputHash, + endedAt: Date.now(), + warnings: [`race: branch ${raced.i + 1}/${branches.length} won`], + ...(opts.readRefs ? { reads: opts.readRefs } : {}), + }; +} diff --git a/packages/taskflow-dsl/src/build/erase.ts b/packages/taskflow-dsl/src/build/erase.ts index 2df16ac..206caa4 100644 --- a/packages/taskflow-dsl/src/build/erase.ts +++ b/packages/taskflow-dsl/src/build/erase.ts @@ -1,1183 +1,3 @@ -/** - * AST erase: .tf.ts source → Taskflow JSON (no execution of runes). - * Uses TypeScript compiler API (read-only Program/SourceFile). - */ - -import ts from "typescript"; -import type { Diagnostic } from "../diagnostics.ts"; - -export interface EraseResult { - ok: boolean; - taskflow?: Record; - diagnostics: Diagnostic[]; -} - -const PHASE_RUNES = new Set([ - "agent", - "parallel", - "map", - "gate", - "reduce", - "approval", - "subflow", - "loop", - "tournament", - "script", - "race", - "expand", -]); - -interface PhaseDraft { - id: string; - type: string; - raw: Record; - dependsOn: Set; - final?: boolean; -} - -function posOf(sf: ts.SourceFile, node: ts.Node): { line: number; character: number } { - const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); - return { line: line + 1, character: character + 1 }; -} - -function diag( - file: string, - sf: ts.SourceFile, - node: ts.Node, - code: string, - message: string, - severity: Diagnostic["severity"] = "error", - hint?: string, -): Diagnostic { - const p = posOf(sf, node); - return { - code, - severity, - message, - file, - range: { line: p.line, character: p.character }, - hint, - }; -} - -function isIdentifier(n: ts.Node, name: string): boolean { - return ts.isIdentifier(n) && n.text === name; -} - -function calleeName(expr: ts.Expression): string | undefined { - if (ts.isIdentifier(expr)) return expr.text; - if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) { - return `${expr.expression.text}.${expr.name.text}`; - } - return undefined; -} - -function evalLiteral(node: ts.Expression): unknown { - if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; - if (ts.isNumericLiteral(node)) return Number(node.text); - if (node.kind === ts.SyntaxKind.TrueKeyword) return true; - if (node.kind === ts.SyntaxKind.FalseKeyword) return false; - if (node.kind === ts.SyntaxKind.NullKeyword) return null; - if (ts.isArrayLiteralExpression(node)) { - return node.elements.map((e) => (ts.isSpreadElement(e) ? undefined : evalLiteral(e as ts.Expression))); - } - if (ts.isObjectLiteralExpression(node)) { - const o: Record = {}; - for (const p of node.properties) { - if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name)) { - o[p.name.text] = evalLiteral(p.initializer); - } else if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.name)) { - o[p.name.text] = evalLiteral(p.initializer); - } - } - return o; - } - return undefined; -} - -/** Convert template / string expr to task string + deps. */ -function eraseStringish( - sf: ts.SourceFile, - file: string, - node: ts.Expression, - itemParam: string | undefined, - phases: Map, - diags: Diagnostic[], -): { text: string; deps: string[] } | undefined { - const deps: string[] = []; - - const pushDep = (id: string) => { - if (phases.has(id) && !deps.includes(id)) deps.push(id); - }; - - const propToPlaceholder = (expr: ts.Expression): string | undefined => { - // item.foo / item - if (ts.isIdentifier(expr) && itemParam && expr.text === itemParam) return "{item}"; - if ( - ts.isPropertyAccessExpression(expr) && - ts.isIdentifier(expr.expression) && - itemParam && - expr.expression.text === itemParam - ) { - return `{item.${expr.name.text}}`; - } - // phase.output / phase.json / phase.json.field - if (ts.isPropertyAccessExpression(expr)) { - const chain: string[] = []; - let cur: ts.Expression = expr; - while (ts.isPropertyAccessExpression(cur)) { - chain.unshift(cur.name.text); - cur = cur.expression; - } - if (ts.isIdentifier(cur) && phases.has(cur.text)) { - pushDep(cur.text); - if (chain[0] === "output" && chain.length === 1) return `{steps.${cur.text}.output}`; - if (chain[0] === "json") { - if (chain.length === 1) return `{steps.${cur.text}.json}`; - return `{steps.${cur.text}.json.${chain.slice(1).join(".")}}`; - } - } - } - // args.x - if ( - ts.isPropertyAccessExpression(expr) && - ts.isIdentifier(expr.expression) && - expr.expression.text === "args" - ) { - return `{args.${expr.name.text}}`; - } - return undefined; - }; - - if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { - return { text: node.text, deps }; - } - - if (ts.isTemplateExpression(node)) { - let text = node.head.text; - for (const span of node.templateSpans) { - const ph = propToPlaceholder(span.expression); - if (ph) { - text += ph; - } else { - // try simple identifiers that are phases → .output - if (ts.isIdentifier(span.expression) && phases.has(span.expression.text)) { - pushDep(span.expression.text); - text += `{steps.${span.expression.text}.output}`; - } else { - diags.push( - diag( - file, - sf, - span.expression, - "TFDSL_TMPL_UNERASABLE", - `Cannot erase template expression to a placeholder (only phase.output/json, item.*, args.* supported in MVP).`, - ), - ); - return undefined; - } - } - text += span.literal.text; - } - return { text, deps }; - } - - // Identifier phase ref alone is not a string task - if (ts.isIdentifier(node)) { - diags.push(diag(file, sf, node, "TFDSL_RUNE_ARG", `Expected string or template for task text.`)); - return undefined; - } - - const lit = evalLiteral(node); - if (typeof lit === "string") return { text: lit, deps }; - - diags.push(diag(file, sf, node, "TFDSL_RUNE_ARG", `Expected static string/template task text.`)); - return undefined; -} - -function mergeOpts( - sf: ts.SourceFile, - file: string, - obj: ts.Expression | undefined, - diags: Diagnostic[], - phases: Map, -): Record { - if (!obj) return {}; - if (!ts.isObjectLiteralExpression(obj)) { - diags.push(diag(file, sf, obj, "TFDSL_RUNE_OPTS", `Phase options must be an object literal.`)); - return {}; - } - const out: Record = {}; - for (const p of obj.properties) { - if (!ts.isPropertyAssignment(p)) continue; - const key = ts.isIdentifier(p.name) - ? p.name.text - : ts.isStringLiteral(p.name) - ? p.name.text - : undefined; - if (!key) continue; - - if (key === "dependsOn" && ts.isArrayLiteralExpression(p.initializer)) { - const ids: string[] = []; - for (const el of p.initializer.elements) { - if (ts.isStringLiteral(el)) ids.push(el.text); - else if (ts.isIdentifier(el) && phases.has(el.text)) ids.push(el.text); - } - out.dependsOn = ids; - continue; - } - - if (key === "output") { - if (ts.isCallExpression(p.initializer)) { - const cn = calleeName(p.initializer.expression); - if (cn === "json") { - out.output = "json"; - out.expect = { type: "object" }; - continue; - } - } - const v = evalLiteral(p.initializer); - if (v === "json" || v === "text") { - out.output = v; - if (v === "json" && out.expect === undefined) out.expect = { type: "object" }; - continue; - } - diags.push( - diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS", `output must be "json" | "text" or json().`), - ); - continue; - } - - if (key === "agent" || key === "model" || key === "when" || key === "join" || key === "cwd") { - const v = evalLiteral(p.initializer); - if (v !== undefined) out[key] = v; - continue; - } - if (key === "final" || key === "optional" || key === "idempotent" || key === "reflexion" || key === "convergence") { - const v = evalLiteral(p.initializer); - if (typeof v === "boolean") out[key] = v; - continue; - } - if (key === "timeout" || key === "concurrency" || key === "maxIterations" || key === "variants") { - const v = evalLiteral(p.initializer); - if (typeof v === "number") out[key] = v; - continue; - } - if (key === "retry" || key === "expect" || key === "tools" || key === "thinking") { - const v = evalLiteral(p.initializer); - if (v !== undefined) out[key] = v; - continue; - } - if (key === "id") { - const v = evalLiteral(p.initializer); - if (typeof v === "string") out.id = v; - continue; - } - if ( - key === "input" || - key === "request" || - key === "until" || - key === "judge" || - key === "judgeAgent" || - key === "mode" || - key === "use" || - key === "onBlock" || - key === "cancelLosers" || - key === "expandMode" || - key === "maxNodes" - ) { - const v = evalLiteral(p.initializer); - if (v !== undefined) out[key] = v; - continue; - } - // Unknown keys: warn (fail-open for forward-compat fields, never silent on known mistakes) - diags.push( - diag( - file, - sf, - p, - "TFDSL_RUNE_OPTS_UNKNOWN", - `Unknown or non-static option '${key}' ignored in MVP erase.`, - "warning", - ), - ); - } - return out; -} - -function phaseIdFromBinding(name: string, opts: Record): string { - if (typeof opts.id === "string" && opts.id) return opts.id; - // convert camelCase binding to kebab for JSON culture, keep as-is if already simple - return name; -} - -export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResult { - const diags: Diagnostic[] = []; - const sf = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); - - // Find export default flow(...) - let flowCall: ts.CallExpression | undefined; - for (const stmt of sf.statements) { - if (!ts.isExportAssignment(stmt) || stmt.isExportEquals) continue; - if (ts.isCallExpression(stmt.expression)) { - const cn = calleeName(stmt.expression.expression); - if (cn === "flow") { - flowCall = stmt.expression; - break; - } - } - } - if (!flowCall) { - diags.push({ - code: "TFDSL_ENTRY_MISSING", - severity: "error", - message: `Expected \`export default flow("name", …)\`.`, - file, - hint: "Use `taskflow-dsl new` for a skeleton.", - }); - return { ok: false, diagnostics: diags }; - } - - const args = flowCall.arguments; - if (args.length < 2) { - diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_ARGS", `flow() requires name and callback.`)); - return { ok: false, diagnostics: diags }; - } - - const nameArg = args[0]!; - if (!ts.isStringLiteral(nameArg)) { - diags.push(diag(file, sf, nameArg, "TFDSL_ENTRY_NAME", `flow name must be a string literal.`)); - return { ok: false, diagnostics: diags }; - } - const flowName = nameArg.text; - - let flowOpts: Record = {}; - let bodyFn: ts.ArrowFunction | ts.FunctionExpression | undefined; - if (args.length === 2) { - const a1 = args[1]!; - if (ts.isArrowFunction(a1) || ts.isFunctionExpression(a1)) bodyFn = a1; - } else { - const a1 = args[1]!; - const a2 = args[2]!; - if (ts.isObjectLiteralExpression(a1)) { - flowOpts = (evalLiteral(a1) as Record) ?? {}; - } - if (ts.isArrowFunction(a2) || ts.isFunctionExpression(a2)) bodyFn = a2; - } - if (!bodyFn) { - diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_BODY", `flow() body must be an arrow or function expression.`)); - return { ok: false, diagnostics: diags }; - } - - const phases = new Map(); - const order: string[] = []; - let topArgs: Record | undefined; - let concurrency: number | undefined; - let budget: Record | undefined; - let finalId: string | undefined; - - const body = bodyFn.body; - const statements: ts.Statement[] = ts.isBlock(body) - ? [...body.statements] - : [ts.factory.createReturnStatement(body as ts.Expression)]; - - const handleCall = ( - bindName: string | undefined, - call: ts.CallExpression, - itemParam?: string, - ): string | undefined => { - const cn = calleeName(call.expression); - if (!cn) return undefined; - - // expand.nested → type:expand expandMode:nested; subflow.def → type:flow def - if (cn === "expand.nested" || cn === "subflow.def") { - const id = bindName ?? (cn.startsWith("expand") ? `expand-${order.length}` : `flow-${order.length}`); - const draft: PhaseDraft = { - id, - type: cn === "expand.nested" ? "expand" : "flow", - raw: - cn === "expand.nested" - ? { type: "expand", expandMode: "nested" } - : { type: "flow" }, - dependsOn: new Set(), - }; - const defArg = call.arguments[0]; - if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { - const pid = defArg.expression.text; - if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { - draft.dependsOn.add(pid); - draft.raw.def = defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; - } - } else if (defArg && ts.isStringLiteral(defArg)) { - draft.raw.def = defArg.text; - } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { - draft.dependsOn.add(defArg.text); - draft.raw.def = `{steps.${defArg.text}.json}`; - } - const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(draft.raw, opts); - if (typeof opts.id === "string") draft.id = opts.id; - phases.set(draft.id, draft); - order.push(draft.id); - return draft.id; - } - - if (cn === "subflow") { - const id = bindName ?? `flow-${order.length}`; - const draft: PhaseDraft = { id, type: "flow", raw: { type: "flow" }, dependsOn: new Set() }; - const useArg = call.arguments[0]; - if (useArg && ts.isStringLiteral(useArg)) draft.raw.use = useArg.text; - else diags.push(diag(file, sf, call, "TFDSL_RUNE_ARG", `subflow(use) requires a string name.`)); - if (call.arguments[1] && ts.isObjectLiteralExpression(call.arguments[1])) { - draft.raw.with = evalLiteral(call.arguments[1]); - } - const opts = mergeOpts(sf, file, call.arguments[2] as ts.Expression | undefined, diags, phases); - Object.assign(draft.raw, opts); - if (typeof opts.id === "string") draft.id = opts.id; - phases.set(draft.id, draft); - order.push(draft.id); - return draft.id; - } - - if (!PHASE_RUNES.has(cn.split(".")[0]!) && !PHASE_RUNES.has(cn)) { - if (cn === "json") return undefined; - // allow ignore non-phase - return undefined; - } - - const type = cn === "race" ? "race" : cn; - // race([agent(...), ...], opts?) - if (cn === "race") { - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: "race", - raw: { type: "race" }, - dependsOn: new Set(), - }; - const arr = call.arguments[0]; - const branches: Array> = []; - if (arr && ts.isArrayLiteralExpression(arr)) { - for (const el of arr.elements) { - if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { - const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); - const b: Record = {}; - if (erased) { - b.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(b, bopts); - branches.push(b); - } - } - } - draft.raw.branches = branches; - const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - if (typeof opts.cancelLosers === "boolean") draft.raw.cancelLosers = opts.cancelLosers; - Object.assign(draft.raw, opts); - delete draft.raw.id; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (opts.final === true) { - draft.final = true; - draft.raw.final = true; - } - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; - } - - // expand(...) / expand.graft(...) — expand.nested handled above - if (cn === "expand" || cn === "expand.graft") { - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: "expand", - raw: { - type: "expand", - expandMode: cn === "expand.graft" ? "graft" : "nested", - }, - dependsOn: new Set(), - }; - const defArg = call.arguments[0]; - if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { - const pid = defArg.expression.text; - if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { - draft.dependsOn.add(pid); - draft.raw.def = - defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; - } - } else if (defArg && ts.isStringLiteral(defArg)) { - draft.raw.def = defArg.text; - } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { - draft.dependsOn.add(defArg.text); - draft.raw.def = `{steps.${defArg.text}.json}`; - } - const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - if (typeof opts.expandMode === "string") draft.raw.expandMode = opts.expandMode; - if (typeof opts.maxNodes === "number") draft.raw.maxNodes = opts.maxNodes; - // expand(...) default nested; expand.graft → graft - if (cn === "expand.graft") draft.raw.expandMode = "graft"; - if (cn === "expand" && !draft.raw.expandMode) draft.raw.expandMode = "nested"; - Object.assign(draft.raw, opts); - delete draft.raw.id; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (opts.final === true) { - draft.final = true; - draft.raw.final = true; - } - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; - } - - // gate.automated / gate.scored → type:gate with eval / score - if (cn === "gate.automated" || cn === "gate.scored") { - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: "gate", - raw: { type: "gate" }, - dependsOn: new Set(), - }; - const up = call.arguments[0]; - if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); - const optsArg = call.arguments[1] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (cn === "gate.automated" && optsArg && ts.isObjectLiteralExpression(optsArg)) { - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - if (p.name.text === "pass") { - const v = evalLiteral(p.initializer); - if (Array.isArray(v)) draft.raw.eval = v; - } - if (p.name.text === "task") { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) { - draft.raw.task = er.text; - for (const d of er.deps) draft.dependsOn.add(d); - } - } - } - // Engine requires task or score; default a minimal task if only eval given - if (!draft.raw.task && !draft.raw.score) { - draft.raw.task = "Gate (automated pre-checks failed or incomplete)."; - } - } - if (cn === "gate.scored" && optsArg && ts.isObjectLiteralExpression(optsArg)) { - const score: Record = {}; - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - const k = p.name.text; - if (k === "scorers" || k === "combine" || k === "threshold" || k === "weights" || k === "target" || k === "judge") { - const v = evalLiteral(p.initializer); - if (v !== undefined) score[k] = v; - } - if (k === "task") { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) { - draft.raw.task = er.text; - for (const d of er.deps) draft.dependsOn.add(d); - } - } - } - if (!score.combine) score.combine = "all"; - draft.raw.score = score; - // strip score fields from top-level raw if mergeOpts put them - delete draft.raw.scorers; - delete draft.raw.combine; - delete draft.raw.threshold; - delete draft.raw.weights; - delete draft.raw.target; - delete draft.raw.judge; - } - delete draft.raw.id; - delete draft.raw.pass; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (opts.final === true) { - draft.final = true; - draft.raw.final = true; - } - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; - } - - const phaseType = type === "gate" ? "gate" : type; - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: phaseType, - raw: { type: phaseType }, - dependsOn: new Set(), - }; - - if (type === "agent" || type === "script") { - const taskArg = call.arguments[0]; - const optsArg = call.arguments[1] as ts.Expression | undefined; - if (type === "agent" && taskArg) { - const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); - if (erased) { - draft.raw.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - } - if (type === "script" && taskArg) { - if (ts.isArrayLiteralExpression(taskArg)) { - const arr = taskArg.elements.map((el) => { - if (ts.isStringLiteral(el)) return el.text; - const er = eraseStringish(sf, file, el as ts.Expression, itemParam, phases, diags); - if (er) { - for (const d of er.deps) draft.dependsOn.add(d); - return er.text; - } - return ""; - }); - draft.raw.run = arr; - } else { - const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); - if (erased) { - draft.raw.run = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - } - } - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - else draft.id = phaseIdFromBinding(idBase, opts); - Object.assign(draft.raw, opts); - if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); - if (opts.final === true) draft.final = true; - } else if (type === "map") { - const overArg = call.arguments[0]; - const fnArg = call.arguments[1]; - const optsArg = call.arguments[2] as ts.Expression | undefined; - if (overArg && ts.isIdentifier(overArg) && phases.has(overArg.text)) { - draft.dependsOn.add(overArg.text); - draft.raw.over = `{steps.${overArg.text}.json}`; - } else if (overArg && ts.isPropertyAccessExpression(overArg) && ts.isIdentifier(overArg.expression)) { - const pid = overArg.expression.text; - if (phases.has(pid)) { - draft.dependsOn.add(pid); - draft.raw.over = - overArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; - } - } else if (overArg && (ts.isStringLiteral(overArg) || ts.isNoSubstitutionTemplateLiteral(overArg))) { - draft.raw.over = overArg.text; - } - let itemName = "item"; - if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { - const p0 = fnArg.parameters[0]; - if (p0 && ts.isIdentifier(p0.name)) itemName = p0.name.text; - draft.raw.as = itemName; - // body: agent(...) or block with return - let inner: ts.Expression | undefined; - if (ts.isBlock(fnArg.body)) { - for (const st of fnArg.body.statements) { - if (ts.isReturnStatement(st) && st.expression) inner = st.expression; - } - } else { - inner = fnArg.body; - } - if (inner && ts.isCallExpression(inner)) { - const innerCn = calleeName(inner.expression); - if (innerCn === "agent") { - const erased = eraseStringish(sf, file, inner.arguments[0]!, itemName, phases, diags); - if (erased) { - draft.raw.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - const iopts = mergeOpts(sf, file, inner.arguments[1] as ts.Expression | undefined, diags, phases); - if (iopts.agent) draft.raw.agent = iopts.agent; - if (iopts.output) draft.raw.output = iopts.output; - } - } - } - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); - if (opts.final === true) draft.final = true; - } else if (type === "parallel") { - const arr = call.arguments[0]; - const optsArg = call.arguments[1] as ts.Expression | undefined; - const branches: Array> = []; - if (arr && ts.isArrayLiteralExpression(arr)) { - for (const el of arr.elements) { - if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { - const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); - const b: Record = {}; - if (erased) { - b.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(b, bopts); - branches.push(b); - } - } - } - draft.raw.branches = branches; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (opts.final === true) draft.final = true; - } else if (type === "gate") { - const up = call.arguments[0]; - if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); - const optsArg = call.arguments[1] as ts.Expression | undefined; - const taskArg = call.arguments[2] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - Object.assign(draft.raw, opts); - if (typeof opts.id === "string") draft.id = opts.id; - if (taskArg && (ts.isArrowFunction(taskArg) || ts.isFunctionExpression(taskArg))) { - const p0 = taskArg.parameters[0]; - const param = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "i"; - let expr: ts.Expression | undefined = ts.isBlock(taskArg.body) - ? undefined - : (taskArg.body as ts.Expression); - if (ts.isBlock(taskArg.body)) { - for (const st of taskArg.body.statements) { - if (ts.isReturnStatement(st) && st.expression) expr = st.expression; - } - } - if (expr) { - // Gate-only rewrite: (i) => `…${i.output}` — do NOT call eraseStringish first - // (it would emit TFDSL_TMPL_UNERASABLE for the lambda param). - const re = eraseGateTask( - sf, - file, - expr, - param, - up && ts.isIdentifier(up) ? up.text : undefined, - phases, - diags, - ); - if (re) { - draft.raw.task = re.text; - for (const d of re.deps) draft.dependsOn.add(d); - } - } - } - if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); - if (opts.final === true) draft.final = true; - } else if (type === "reduce") { - const fromArg = call.arguments[0]; - const fnArg = call.arguments[1]; - const optsArg = call.arguments[2] as ts.Expression | undefined; - const fromIds: string[] = []; - if (fromArg && ts.isArrayLiteralExpression(fromArg)) { - for (const el of fromArg.elements) { - if (ts.isIdentifier(el) && phases.has(el.text)) { - fromIds.push(el.text); - draft.dependsOn.add(el.text); - } - } - } - draft.raw.from = fromIds; - if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { - let expr: ts.Expression | undefined; - if (ts.isBlock(fnArg.body)) { - for (const st of fnArg.body.statements) { - if (ts.isReturnStatement(st) && st.expression) expr = st.expression; - } - } else expr = fnArg.body; - if (expr && ts.isCallExpression(expr) && calleeName(expr.expression) === "agent") { - if (expr.arguments[0]) { - const t2 = eraseReduceTask(sf, file, expr.arguments[0]!, fnArg, phases, diags); - if (t2) { - draft.raw.task = t2.text; - for (const d of t2.deps) draft.dependsOn.add(d); - } - } - const iopts = mergeOpts(sf, file, expr.arguments[1] as ts.Expression | undefined, diags, phases); - if (iopts.agent) draft.raw.agent = iopts.agent; - } - } - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (opts.final === true) draft.final = true; - } else if (type === "approval") { - const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.request === "string") draft.raw.task = opts.request; - Object.assign(draft.raw, opts); - delete draft.raw.request; - if (typeof opts.id === "string") draft.id = opts.id; - if (opts.final === true) draft.final = true; - } else if (type === "loop") { - const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - // task: (prev) => `...` inside object — scan object for task method - if (optsArg && ts.isObjectLiteralExpression(optsArg)) { - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - if (p.name.text === "task") { - if (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)) { - const prev = p.initializer.parameters[0]; - const prevNm = prev && ts.isIdentifier(prev.name) ? prev.name.text : "prev"; - let expr: ts.Expression | undefined = ts.isBlock(p.initializer.body) - ? undefined - : (p.initializer.body as ts.Expression); - if (ts.isBlock(p.initializer.body)) { - for (const st of p.initializer.body.statements) { - if (ts.isReturnStatement(st) && st.expression) expr = st.expression; - } - } - if (expr) { - const er = eraseLoopTask(sf, file, expr, prevNm, draft.id, diags); - if (er) draft.raw.task = er; - } - } else { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) draft.raw.task = er.text; - } - } - } - } - if (opts.final === true) draft.final = true; - } else if (type === "tournament") { - const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - Object.assign(draft.raw, opts); - if (optsArg && ts.isObjectLiteralExpression(optsArg)) { - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - if (p.name.text === "branches" && ts.isArrayLiteralExpression(p.initializer)) { - const branches: Array> = []; - for (const el of p.initializer.elements) { - if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { - const erased = eraseStringish(sf, file, el.arguments[0]!, undefined, phases, diags); - const b: Record = {}; - if (erased) b.task = erased.text; - const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(b, bopts); - branches.push(b); - } - } - draft.raw.branches = branches; - } - if (p.name.text === "task") { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) draft.raw.task = er.text; - } - } - } - if (typeof opts.id === "string") draft.id = opts.id; - if (opts.final === true) draft.final = true; - } - - // strip non-schema keys - delete draft.raw.id; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (draft.final) draft.raw.final = true; - - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; - }; - - for (const st of statements) { - // ctx.budget / concurrency / args.declare - if (ts.isExpressionStatement(st) && ts.isCallExpression(st.expression)) { - const call = st.expression; - if (ts.isPropertyAccessExpression(call.expression)) { - const obj = call.expression.expression; - const method = call.expression.name.text; - // ctx.budget / ctx.concurrency - if (ts.isIdentifier(obj) && (method === "budget" || method === "concurrency")) { - const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; - if (method === "budget" && v && typeof v === "object") budget = v as Record; - if (method === "concurrency" && typeof v === "number") concurrency = v; - continue; - } - // ctx.args.declare - if ( - ts.isPropertyAccessExpression(obj) && - ts.isIdentifier(obj.expression) && - obj.name.text === "args" && - method === "declare" - ) { - const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; - if (v && typeof v === "object") topArgs = v as Record; - continue; - } - } - // bare call without binding (gate, etc.) - if (ts.isCallExpression(call)) { - const id = handleCall(undefined, call); - if (id) { - /* anonymous phase */ - } - } - } - - if (ts.isVariableStatement(st)) { - for (const decl of st.declarationList.declarations) { - if (!decl.initializer) continue; - // const [a,b] = parallel([agent(...), agent(...)]) - // Desugar to independent agent phases with true ids (a, b) so - // {steps.a.output} works. Concurrent because no dependsOn between them. - if (ts.isArrayBindingPattern(decl.name) && ts.isCallExpression(decl.initializer)) { - const cn = calleeName(decl.initializer.expression); - if (cn === "parallel") { - const bindNames = decl.name.elements - .map((e) => - ts.isBindingElement(e) && ts.isIdentifier(e.name) ? e.name.text : undefined, - ) - .filter((x): x is string => !!x); - const arr = decl.initializer.arguments[0]; - if (!arr || !ts.isArrayLiteralExpression(arr) || arr.elements.length !== bindNames.length) { - diags.push( - diag( - file, - sf, - decl.initializer, - "TFDSL_PARALLEL_DESTRUCTURE", - `parallel destructure requires matching binding count and array of agent() calls (got ${bindNames.length} binds).`, - ), - ); - continue; - } - let ok = true; - for (let i = 0; i < bindNames.length; i++) { - const el = arr.elements[i]!; - if (!ts.isCallExpression(el) || calleeName(el.expression) !== "agent") { - diags.push( - diag( - file, - sf, - el, - "TFDSL_PARALLEL_DESTRUCTURE", - `parallel destructure branch ${i + 1} must be agent(...).`, - ), - ); - ok = false; - break; - } - handleCall(bindNames[i], el); - } - if (!ok) continue; - continue; - } - if (cn === "race") { - // race stays one phase — destructure not supported - diags.push( - diag( - file, - sf, - decl.initializer, - "TFDSL_RACE_DESTRUCTURE", - `race() does not support array destructure — bind as a single phase: const winner = race([...]).`, - ), - ); - continue; - } - } - if (!ts.isIdentifier(decl.name)) continue; - const name = decl.name.text; - if (ts.isCallExpression(decl.initializer)) { - handleCall(name, decl.initializer); - } else if ( - ts.isAsExpression(decl.initializer) && - ts.isCallExpression(decl.initializer.expression) - ) { - handleCall(name, decl.initializer.expression); - } - } - } - - if (ts.isReturnStatement(st) && st.expression) { - if (ts.isIdentifier(st.expression) && phases.has(st.expression.text)) { - finalId = st.expression.text; - const ph = phases.get(finalId)!; - ph.final = true; - ph.raw.final = true; - } else if (ts.isCallExpression(st.expression)) { - const id = handleCall(order.length === 0 ? "main" : `phase-${order.length}`, st.expression); - if (id) { - finalId = id; - const ph = phases.get(id)!; - ph.final = true; - ph.raw.final = true; - } - } - } - } - - // Warn phases with no deps (not first) - for (let i = 0; i < order.length; i++) { - const id = order[i]!; - const ph = phases.get(id)!; - if (i > 0 && ph.dependsOn.size === 0 && !Array.isArray(ph.raw.dependsOn)) { - diags.push({ - code: "TFDSL_DEP_NONE", - severity: "warning", - message: `Phase '${id}' has no automatic dependencies and is not first — add dependsOn if order matters.`, - file, - }); - } - } - - if (order.length === 0) { - diags.push({ - code: "TFDSL_ENTRY_EMPTY", - severity: "error", - message: `No phases found in flow body.`, - file, - }); - return { ok: false, diagnostics: diags }; - } - - // Ensure one final - if (!finalId) { - const last = order[order.length - 1]!; - phases.get(last)!.raw.final = true; - } - - const phaseList = order.map((id) => { - const ph = phases.get(id)!; - const raw = { ...ph.raw, id: ph.id }; - if (ph.dependsOn.size && !raw.dependsOn) raw.dependsOn = [...ph.dependsOn]; - // clean undefined-ish - return raw; - }); - - const taskflow: Record = { - name: flowName, - phases: phaseList, - }; - if (typeof flowOpts.description === "string") taskflow.description = flowOpts.description; - if (typeof flowOpts.version === "number") taskflow.version = flowOpts.version; - if (topArgs) taskflow.args = topArgs; - if (concurrency !== undefined) taskflow.concurrency = concurrency; - if (budget) taskflow.budget = budget; - - const ok = !diags.some((d) => d.severity === "error"); - return { ok, taskflow: ok ? taskflow : undefined, diagnostics: diags }; -} - -function eraseGateTask( - sf: ts.SourceFile, - file: string, - expr: ts.Expression, - param: string, - upstreamId: string | undefined, - phases: Map, - diags: Diagnostic[], -): { text: string; deps: string[] } | undefined { - const deps: string[] = []; - if (upstreamId) deps.push(upstreamId); - - const rewrite = (e: ts.Expression): string | undefined => { - if ( - ts.isPropertyAccessExpression(e) && - ts.isIdentifier(e.expression) && - e.expression.text === param && - (e.name.text === "output" || e.name.text === "json") - ) { - if (!upstreamId) return undefined; - return e.name.text === "output" ? `{steps.${upstreamId}.output}` : `{steps.${upstreamId}.json}`; - } - return undefined; - }; - - if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, deps }; - if (ts.isTemplateExpression(expr)) { - let text = expr.head.text; - for (const span of expr.templateSpans) { - const ph = rewrite(span.expression); - if (ph) text += ph; - else { - const er = eraseStringish(sf, file, span.expression, undefined, phases, diags); - if (!er) return undefined; - text += er.text; - for (const d of er.deps) if (!deps.includes(d)) deps.push(d); - } - text += span.literal.text; - } - return { text, deps }; - } - return undefined; -} - -function eraseLoopTask( - sf: ts.SourceFile, - file: string, - expr: ts.Expression, - prevName: string, - loopId: string, - diags: Diagnostic[], -): string | undefined { - if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text; - if (!ts.isTemplateExpression(expr)) { - diags.push(diag(file, sf, expr, "TFDSL_RUNE_ARG", `loop task must be string or template.`)); - return undefined; - } - let text = expr.head.text; - for (const span of expr.templateSpans) { - const e = span.expression; - if ( - ts.isPropertyAccessExpression(e) && - ts.isIdentifier(e.expression) && - e.expression.text === prevName && - e.name.text === "output" - ) { - text += `{steps.${loopId}.output}`; - } else if (ts.isIdentifier(e) && e.text === "loop") { - text += `{loop.iteration}`; - } else { - diags.push(diag(file, sf, e, "TFDSL_TMPL_UNERASABLE", `Unsupported expression in loop task template.`)); - return undefined; - } - text += span.literal.text; - } - return text; -} - -function eraseReduceTask( - sf: ts.SourceFile, - file: string, - expr: ts.Expression, - fn: ts.ArrowFunction | ts.FunctionExpression, - phases: Map, - diags: Diagnostic[], -): { text: string; deps: string[] } | undefined { - const p0 = fn.parameters[0]; - const partsName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "p"; - const deps: string[] = []; - if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, deps }; - if (!ts.isTemplateExpression(expr)) return eraseStringish(sf, file, expr, undefined, phases, diags); - let text = expr.head.text; - for (const span of expr.templateSpans) { - const e = span.expression; - // p.auth.output - if ( - ts.isPropertyAccessExpression(e) && - ts.isPropertyAccessExpression(e.expression) && - ts.isIdentifier(e.expression.expression) && - e.expression.expression.text === partsName - ) { - const phaseId = e.expression.name.text; - if (phases.has(phaseId)) deps.push(phaseId); - if (e.name.text === "output") text += `{steps.${phaseId}.output}`; - else if (e.name.text === "json") text += `{steps.${phaseId}.json}`; - else text += `{steps.${phaseId}.output}`; - } else { - const er = eraseStringish(sf, file, e, undefined, phases, diags); - if (!er) return undefined; - text += er.text; - for (const d of er.deps) deps.push(d); - } - text += span.literal.text; - } - return { text, deps }; -} - +/** @deprecated import from "./erase/index.ts" — kept for stable path. */ +export type { EraseResult } from "./erase/index.ts"; +export { eraseSource } from "./erase/index.ts"; diff --git a/packages/taskflow-dsl/src/build/erase/ast.ts b/packages/taskflow-dsl/src/build/erase/ast.ts new file mode 100644 index 0000000..6ec52e2 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/ast.ts @@ -0,0 +1,62 @@ +/** + * TypeScript AST primitives for erase (no domain knowledge of phases). + */ + +import ts from "typescript"; +import type { Diagnostic } from "../../diagnostics.ts"; + +export function posOf(sf: ts.SourceFile, node: ts.Node): { line: number; character: number } { + const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + return { line: line + 1, character: character + 1 }; +} + +export function diag( + file: string, + sf: ts.SourceFile, + node: ts.Node, + code: string, + message: string, + severity: Diagnostic["severity"] = "error", + hint?: string, +): Diagnostic { + const p = posOf(sf, node); + return { + code, + severity, + message, + file, + range: { line: p.line, character: p.character }, + hint, + }; +} + +export function calleeName(expr: ts.Expression): string | undefined { + if (ts.isIdentifier(expr)) return expr.text; + if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) { + return `${expr.expression.text}.${expr.name.text}`; + } + return undefined; +} + +export function evalLiteral(node: ts.Expression): unknown { + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isNumericLiteral(node)) return Number(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return true; + if (node.kind === ts.SyntaxKind.FalseKeyword) return false; + if (node.kind === ts.SyntaxKind.NullKeyword) return null; + if (ts.isArrayLiteralExpression(node)) { + return node.elements.map((e) => (ts.isSpreadElement(e) ? undefined : evalLiteral(e as ts.Expression))); + } + if (ts.isObjectLiteralExpression(node)) { + const o: Record = {}; + for (const p of node.properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name)) { + o[p.name.text] = evalLiteral(p.initializer); + } else if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.name)) { + o[p.name.text] = evalLiteral(p.initializer); + } + } + return o; + } + return undefined; +} diff --git a/packages/taskflow-dsl/src/build/erase/index.ts b/packages/taskflow-dsl/src/build/erase/index.ts new file mode 100644 index 0000000..737ddac --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/index.ts @@ -0,0 +1,3 @@ +/** Public erase entry — re-exports only. */ +export type { EraseResult } from "./types.ts"; +export { eraseSource } from "./pipeline.ts"; diff --git a/packages/taskflow-dsl/src/build/erase/opts.ts b/packages/taskflow-dsl/src/build/erase/opts.ts new file mode 100644 index 0000000..f2a1e3e --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/opts.ts @@ -0,0 +1,135 @@ +/** + * Phase option object-literal erase. + */ + +import ts from "typescript"; +import type { Diagnostic } from "../../diagnostics.ts"; +import { calleeName, diag, evalLiteral } from "./ast.ts"; +import type { PhaseDraft } from "./types.ts"; + +export function mergeOpts( + sf: ts.SourceFile, + file: string, + obj: ts.Expression | undefined, + diags: Diagnostic[], + phases: Map, +): Record { + if (!obj) return {}; + if (!ts.isObjectLiteralExpression(obj)) { + diags.push(diag(file, sf, obj, "TFDSL_RUNE_OPTS", `Phase options must be an object literal.`)); + return {}; + } + const out: Record = {}; + for (const p of obj.properties) { + if (!ts.isPropertyAssignment(p)) continue; + const key = ts.isIdentifier(p.name) + ? p.name.text + : ts.isStringLiteral(p.name) + ? p.name.text + : undefined; + if (!key) continue; + + if (key === "dependsOn" && ts.isArrayLiteralExpression(p.initializer)) { + const ids: string[] = []; + for (const el of p.initializer.elements) { + if (ts.isStringLiteral(el)) ids.push(el.text); + else if (ts.isIdentifier(el) && phases.has(el.text)) ids.push(el.text); + } + out.dependsOn = ids; + continue; + } + + if (key === "output") { + if (ts.isCallExpression(p.initializer)) { + const cn = calleeName(p.initializer.expression); + if (cn === "json") { + out.output = "json"; + out.expect = { type: "object" }; + continue; + } + } + const v = evalLiteral(p.initializer); + if (v === "json" || v === "text") { + out.output = v; + if (v === "json" && out.expect === undefined) out.expect = { type: "object" }; + continue; + } + diags.push( + diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS", `output must be "json" | "text" or json().`), + ); + continue; + } + + if (key === "agent" || key === "model" || key === "when" || key === "join" || key === "cwd") { + const v = evalLiteral(p.initializer); + if (v !== undefined) out[key] = v; + continue; + } + if (key === "final" || key === "optional" || key === "idempotent" || key === "reflexion" || key === "convergence") { + const v = evalLiteral(p.initializer); + if (typeof v === "boolean") out[key] = v; + continue; + } + if (key === "timeout" || key === "concurrency" || key === "maxIterations" || key === "variants") { + const v = evalLiteral(p.initializer); + if (typeof v === "number") out[key] = v; + continue; + } + if (key === "retry" || key === "expect" || key === "tools" || key === "thinking") { + const v = evalLiteral(p.initializer); + if (v !== undefined) out[key] = v; + continue; + } + if (key === "id") { + const v = evalLiteral(p.initializer); + if (typeof v === "string") out.id = v; + continue; + } + if ( + key === "input" || + key === "request" || + key === "until" || + key === "judge" || + key === "judgeAgent" || + key === "mode" || + key === "use" || + key === "onBlock" || + key === "cancelLosers" || + key === "expandMode" || + key === "maxNodes" + ) { + const v = evalLiteral(p.initializer); + if (v !== undefined) out[key] = v; + continue; + } + diags.push( + diag( + file, + sf, + p, + "TFDSL_RUNE_OPTS_UNKNOWN", + `Unknown or non-static option '${key}' ignored in MVP erase.`, + "warning", + ), + ); + } + return out; +} + +export function phaseIdFromBinding(name: string, opts: Record): string { + if (typeof opts.id === "string" && opts.id) return opts.id; + return name; +} + +export function finalizeDraft(draft: PhaseDraft): void { + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (draft.final) draft.raw.final = true; +} + +export function registerDraft(session: { phases: Map; order: string[] }, draft: PhaseDraft): string { + finalizeDraft(draft); + session.phases.set(draft.id, draft); + if (!session.order.includes(draft.id)) session.order.push(draft.id); + return draft.id; +} diff --git a/packages/taskflow-dsl/src/build/erase/pipeline.ts b/packages/taskflow-dsl/src/build/erase/pipeline.ts new file mode 100644 index 0000000..1d65d5c --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/pipeline.ts @@ -0,0 +1,770 @@ +/** + * Erase pipeline orchestrator: flow() discovery + body walk + kind dispatch. + * Domain helpers live in sibling modules (ast / templates / opts / types). + */ + +import ts from "typescript"; +import type { Diagnostic } from "../../diagnostics.ts"; +import { calleeName, diag, evalLiteral } from "./ast.ts"; +import { mergeOpts, phaseIdFromBinding } from "./opts.ts"; +import { + eraseGateTask, + eraseLoopTask, + eraseReduceTask, + eraseStringish, +} from "./templates.ts"; +import type { EraseResult, PhaseDraft } from "./types.ts"; +import { PHASE_RUNES } from "./types.ts"; + +export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResult { + const diags: Diagnostic[] = []; + const sf = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + + // Find export default flow(...) + let flowCall: ts.CallExpression | undefined; + for (const stmt of sf.statements) { + if (!ts.isExportAssignment(stmt) || stmt.isExportEquals) continue; + if (ts.isCallExpression(stmt.expression)) { + const cn = calleeName(stmt.expression.expression); + if (cn === "flow") { + flowCall = stmt.expression; + break; + } + } + } + if (!flowCall) { + diags.push({ + code: "TFDSL_ENTRY_MISSING", + severity: "error", + message: `Expected \`export default flow("name", …)\`.`, + file, + hint: "Use `taskflow-dsl new` for a skeleton.", + }); + return { ok: false, diagnostics: diags }; + } + + const args = flowCall.arguments; + if (args.length < 2) { + diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_ARGS", `flow() requires name and callback.`)); + return { ok: false, diagnostics: diags }; + } + + const nameArg = args[0]!; + if (!ts.isStringLiteral(nameArg)) { + diags.push(diag(file, sf, nameArg, "TFDSL_ENTRY_NAME", `flow name must be a string literal.`)); + return { ok: false, diagnostics: diags }; + } + const flowName = nameArg.text; + + let flowOpts: Record = {}; + let bodyFn: ts.ArrowFunction | ts.FunctionExpression | undefined; + if (args.length === 2) { + const a1 = args[1]!; + if (ts.isArrowFunction(a1) || ts.isFunctionExpression(a1)) bodyFn = a1; + } else { + const a1 = args[1]!; + const a2 = args[2]!; + if (ts.isObjectLiteralExpression(a1)) { + flowOpts = (evalLiteral(a1) as Record) ?? {}; + } + if (ts.isArrowFunction(a2) || ts.isFunctionExpression(a2)) bodyFn = a2; + } + if (!bodyFn) { + diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_BODY", `flow() body must be an arrow or function expression.`)); + return { ok: false, diagnostics: diags }; + } + + const phases = new Map(); + const order: string[] = []; + let topArgs: Record | undefined; + let concurrency: number | undefined; + let budget: Record | undefined; + let finalId: string | undefined; + + const body = bodyFn.body; + const statements: ts.Statement[] = ts.isBlock(body) + ? [...body.statements] + : [ts.factory.createReturnStatement(body as ts.Expression)]; + + const handleCall = ( + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, + ): string | undefined => { + const cn = calleeName(call.expression); + if (!cn) return undefined; + + // expand.nested → type:expand expandMode:nested; subflow.def → type:flow def + if (cn === "expand.nested" || cn === "subflow.def") { + const id = bindName ?? (cn.startsWith("expand") ? `expand-${order.length}` : `flow-${order.length}`); + const draft: PhaseDraft = { + id, + type: cn === "expand.nested" ? "expand" : "flow", + raw: + cn === "expand.nested" + ? { type: "expand", expandMode: "nested" } + : { type: "flow" }, + dependsOn: new Set(), + }; + const defArg = call.arguments[0]; + if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { + const pid = defArg.expression.text; + if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { + draft.dependsOn.add(pid); + draft.raw.def = defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (defArg && ts.isStringLiteral(defArg)) { + draft.raw.def = defArg.text; + } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { + draft.dependsOn.add(defArg.text); + draft.raw.def = `{steps.${defArg.text}.json}`; + } + const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + phases.set(draft.id, draft); + order.push(draft.id); + return draft.id; + } + + if (cn === "subflow") { + const id = bindName ?? `flow-${order.length}`; + const draft: PhaseDraft = { id, type: "flow", raw: { type: "flow" }, dependsOn: new Set() }; + const useArg = call.arguments[0]; + if (useArg && ts.isStringLiteral(useArg)) draft.raw.use = useArg.text; + else diags.push(diag(file, sf, call, "TFDSL_RUNE_ARG", `subflow(use) requires a string name.`)); + if (call.arguments[1] && ts.isObjectLiteralExpression(call.arguments[1])) { + draft.raw.with = evalLiteral(call.arguments[1]); + } + const opts = mergeOpts(sf, file, call.arguments[2] as ts.Expression | undefined, diags, phases); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + phases.set(draft.id, draft); + order.push(draft.id); + return draft.id; + } + + if (!PHASE_RUNES.has(cn.split(".")[0]!) && !PHASE_RUNES.has(cn)) { + if (cn === "json") return undefined; + // allow ignore non-phase + return undefined; + } + + const type = cn === "race" ? "race" : cn; + // race([agent(...), ...], opts?) + if (cn === "race") { + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type: "race", + raw: { type: "race" }, + dependsOn: new Set(), + }; + const arr = call.arguments[0]; + const branches: Array> = []; + if (arr && ts.isArrayLiteralExpression(arr)) { + for (const el of arr.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); + const b: Record = {}; + if (erased) { + b.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(b, bopts); + branches.push(b); + } + } + } + draft.raw.branches = branches; + const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + if (typeof opts.cancelLosers === "boolean") draft.raw.cancelLosers = opts.cancelLosers; + Object.assign(draft.raw, opts); + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (opts.final === true) { + draft.final = true; + draft.raw.final = true; + } + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; + } + + // expand(...) / expand.graft(...) — expand.nested handled above + if (cn === "expand" || cn === "expand.graft") { + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type: "expand", + raw: { + type: "expand", + expandMode: cn === "expand.graft" ? "graft" : "nested", + }, + dependsOn: new Set(), + }; + const defArg = call.arguments[0]; + if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { + const pid = defArg.expression.text; + if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { + draft.dependsOn.add(pid); + draft.raw.def = + defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (defArg && ts.isStringLiteral(defArg)) { + draft.raw.def = defArg.text; + } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { + draft.dependsOn.add(defArg.text); + draft.raw.def = `{steps.${defArg.text}.json}`; + } + const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + if (typeof opts.expandMode === "string") draft.raw.expandMode = opts.expandMode; + if (typeof opts.maxNodes === "number") draft.raw.maxNodes = opts.maxNodes; + // expand(...) default nested; expand.graft → graft + if (cn === "expand.graft") draft.raw.expandMode = "graft"; + if (cn === "expand" && !draft.raw.expandMode) draft.raw.expandMode = "nested"; + Object.assign(draft.raw, opts); + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (opts.final === true) { + draft.final = true; + draft.raw.final = true; + } + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; + } + + // gate.automated / gate.scored → type:gate with eval / score + if (cn === "gate.automated" || cn === "gate.scored") { + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type: "gate", + raw: { type: "gate" }, + dependsOn: new Set(), + }; + const up = call.arguments[0]; + if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); + const optsArg = call.arguments[1] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (cn === "gate.automated" && optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "pass") { + const v = evalLiteral(p.initializer); + if (Array.isArray(v)) draft.raw.eval = v; + } + if (p.name.text === "task") { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) { + draft.raw.task = er.text; + for (const d of er.deps) draft.dependsOn.add(d); + } + } + } + // Engine requires task or score; default a minimal task if only eval given + if (!draft.raw.task && !draft.raw.score) { + draft.raw.task = "Gate (automated pre-checks failed or incomplete)."; + } + } + if (cn === "gate.scored" && optsArg && ts.isObjectLiteralExpression(optsArg)) { + const score: Record = {}; + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + const k = p.name.text; + if (k === "scorers" || k === "combine" || k === "threshold" || k === "weights" || k === "target" || k === "judge") { + const v = evalLiteral(p.initializer); + if (v !== undefined) score[k] = v; + } + if (k === "task") { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) { + draft.raw.task = er.text; + for (const d of er.deps) draft.dependsOn.add(d); + } + } + } + if (!score.combine) score.combine = "all"; + draft.raw.score = score; + // strip score fields from top-level raw if mergeOpts put them + delete draft.raw.scorers; + delete draft.raw.combine; + delete draft.raw.threshold; + delete draft.raw.weights; + delete draft.raw.target; + delete draft.raw.judge; + } + delete draft.raw.id; + delete draft.raw.pass; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (opts.final === true) { + draft.final = true; + draft.raw.final = true; + } + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; + } + + const phaseType = type === "gate" ? "gate" : type; + const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); + const draft: PhaseDraft = { + id: idBase, + type: phaseType, + raw: { type: phaseType }, + dependsOn: new Set(), + }; + + if (type === "agent" || type === "script") { + const taskArg = call.arguments[0]; + const optsArg = call.arguments[1] as ts.Expression | undefined; + if (type === "agent" && taskArg) { + const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); + if (erased) { + draft.raw.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + } + if (type === "script" && taskArg) { + if (ts.isArrayLiteralExpression(taskArg)) { + const arr = taskArg.elements.map((el) => { + if (ts.isStringLiteral(el)) return el.text; + const er = eraseStringish(sf, file, el as ts.Expression, itemParam, phases, diags); + if (er) { + for (const d of er.deps) draft.dependsOn.add(d); + return er.text; + } + return ""; + }); + draft.raw.run = arr; + } else { + const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); + if (erased) { + draft.raw.run = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + } + } + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + else draft.id = phaseIdFromBinding(idBase, opts); + Object.assign(draft.raw, opts); + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + } else if (type === "map") { + const overArg = call.arguments[0]; + const fnArg = call.arguments[1]; + const optsArg = call.arguments[2] as ts.Expression | undefined; + if (overArg && ts.isIdentifier(overArg) && phases.has(overArg.text)) { + draft.dependsOn.add(overArg.text); + draft.raw.over = `{steps.${overArg.text}.json}`; + } else if (overArg && ts.isPropertyAccessExpression(overArg) && ts.isIdentifier(overArg.expression)) { + const pid = overArg.expression.text; + if (phases.has(pid)) { + draft.dependsOn.add(pid); + draft.raw.over = + overArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (overArg && (ts.isStringLiteral(overArg) || ts.isNoSubstitutionTemplateLiteral(overArg))) { + draft.raw.over = overArg.text; + } + let itemName = "item"; + if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { + const p0 = fnArg.parameters[0]; + if (p0 && ts.isIdentifier(p0.name)) itemName = p0.name.text; + draft.raw.as = itemName; + // body: agent(...) or block with return + let inner: ts.Expression | undefined; + if (ts.isBlock(fnArg.body)) { + for (const st of fnArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) inner = st.expression; + } + } else { + inner = fnArg.body; + } + if (inner && ts.isCallExpression(inner)) { + const innerCn = calleeName(inner.expression); + if (innerCn === "agent") { + const erased = eraseStringish(sf, file, inner.arguments[0]!, itemName, phases, diags); + if (erased) { + draft.raw.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const iopts = mergeOpts(sf, file, inner.arguments[1] as ts.Expression | undefined, diags, phases); + if (iopts.agent) draft.raw.agent = iopts.agent; + if (iopts.output) draft.raw.output = iopts.output; + } + } + } + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + } else if (type === "parallel") { + const arr = call.arguments[0]; + const optsArg = call.arguments[1] as ts.Expression | undefined; + const branches: Array> = []; + if (arr && ts.isArrayLiteralExpression(arr)) { + for (const el of arr.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); + const b: Record = {}; + if (erased) { + b.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(b, bopts); + branches.push(b); + } + } + } + draft.raw.branches = branches; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + } else if (type === "gate") { + const up = call.arguments[0]; + if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); + const optsArg = call.arguments[1] as ts.Expression | undefined; + const taskArg = call.arguments[2] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + if (taskArg && (ts.isArrowFunction(taskArg) || ts.isFunctionExpression(taskArg))) { + const p0 = taskArg.parameters[0]; + const param = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "i"; + let expr: ts.Expression | undefined = ts.isBlock(taskArg.body) + ? undefined + : (taskArg.body as ts.Expression); + if (ts.isBlock(taskArg.body)) { + for (const st of taskArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } + if (expr) { + // Gate-only rewrite: (i) => `…${i.output}` — do NOT call eraseStringish first + // (it would emit TFDSL_TMPL_UNERASABLE for the lambda param). + const re = eraseGateTask( + sf, + file, + expr, + param, + up && ts.isIdentifier(up) ? up.text : undefined, + phases, + diags, + ); + if (re) { + draft.raw.task = re.text; + for (const d of re.deps) draft.dependsOn.add(d); + } + } + } + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + } else if (type === "reduce") { + const fromArg = call.arguments[0]; + const fnArg = call.arguments[1]; + const optsArg = call.arguments[2] as ts.Expression | undefined; + const fromIds: string[] = []; + if (fromArg && ts.isArrayLiteralExpression(fromArg)) { + for (const el of fromArg.elements) { + if (ts.isIdentifier(el) && phases.has(el.text)) { + fromIds.push(el.text); + draft.dependsOn.add(el.text); + } + } + } + draft.raw.from = fromIds; + if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { + let expr: ts.Expression | undefined; + if (ts.isBlock(fnArg.body)) { + for (const st of fnArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } else expr = fnArg.body; + if (expr && ts.isCallExpression(expr) && calleeName(expr.expression) === "agent") { + if (expr.arguments[0]) { + const t2 = eraseReduceTask(sf, file, expr.arguments[0]!, fnArg, phases, diags); + if (t2) { + draft.raw.task = t2.text; + for (const d of t2.deps) draft.dependsOn.add(d); + } + } + const iopts = mergeOpts(sf, file, expr.arguments[1] as ts.Expression | undefined, diags, phases); + if (iopts.agent) draft.raw.agent = iopts.agent; + } + } + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + } else if (type === "approval") { + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.request === "string") draft.raw.task = opts.request; + Object.assign(draft.raw, opts); + delete draft.raw.request; + if (typeof opts.id === "string") draft.id = opts.id; + if (opts.final === true) draft.final = true; + } else if (type === "loop") { + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + // task: (prev) => `...` inside object — scan object for task method + if (optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "task") { + if (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)) { + const prev = p.initializer.parameters[0]; + const prevNm = prev && ts.isIdentifier(prev.name) ? prev.name.text : "prev"; + let expr: ts.Expression | undefined = ts.isBlock(p.initializer.body) + ? undefined + : (p.initializer.body as ts.Expression); + if (ts.isBlock(p.initializer.body)) { + for (const st of p.initializer.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } + if (expr) { + const er = eraseLoopTask(sf, file, expr, prevNm, draft.id, diags); + if (er) draft.raw.task = er; + } + } else { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) draft.raw.task = er.text; + } + } + } + } + if (opts.final === true) draft.final = true; + } else if (type === "tournament") { + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(sf, file, optsArg, diags, phases); + Object.assign(draft.raw, opts); + if (optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "branches" && ts.isArrayLiteralExpression(p.initializer)) { + const branches: Array> = []; + for (const el of p.initializer.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish(sf, file, el.arguments[0]!, undefined, phases, diags); + const b: Record = {}; + if (erased) b.task = erased.text; + const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); + Object.assign(b, bopts); + branches.push(b); + } + } + draft.raw.branches = branches; + } + if (p.name.text === "task") { + const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); + if (er) draft.raw.task = er.text; + } + } + } + if (typeof opts.id === "string") draft.id = opts.id; + if (opts.final === true) draft.final = true; + } + + // strip non-schema keys + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (draft.final) draft.raw.final = true; + + phases.set(draft.id, draft); + if (!order.includes(draft.id)) order.push(draft.id); + return draft.id; + }; + + for (const st of statements) { + // ctx.budget / concurrency / args.declare + if (ts.isExpressionStatement(st) && ts.isCallExpression(st.expression)) { + const call = st.expression; + if (ts.isPropertyAccessExpression(call.expression)) { + const obj = call.expression.expression; + const method = call.expression.name.text; + // ctx.budget / ctx.concurrency + if (ts.isIdentifier(obj) && (method === "budget" || method === "concurrency")) { + const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; + if (method === "budget" && v && typeof v === "object") budget = v as Record; + if (method === "concurrency" && typeof v === "number") concurrency = v; + continue; + } + // ctx.args.declare + if ( + ts.isPropertyAccessExpression(obj) && + ts.isIdentifier(obj.expression) && + obj.name.text === "args" && + method === "declare" + ) { + const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; + if (v && typeof v === "object") topArgs = v as Record; + continue; + } + } + // bare call without binding (gate, etc.) + if (ts.isCallExpression(call)) { + const id = handleCall(undefined, call); + if (id) { + /* anonymous phase */ + } + } + } + + if (ts.isVariableStatement(st)) { + for (const decl of st.declarationList.declarations) { + if (!decl.initializer) continue; + // const [a,b] = parallel([agent(...), agent(...)]) + // Desugar to independent agent phases with true ids (a, b) so + // {steps.a.output} works. Concurrent because no dependsOn between them. + if (ts.isArrayBindingPattern(decl.name) && ts.isCallExpression(decl.initializer)) { + const cn = calleeName(decl.initializer.expression); + if (cn === "parallel") { + const bindNames = decl.name.elements + .map((e) => + ts.isBindingElement(e) && ts.isIdentifier(e.name) ? e.name.text : undefined, + ) + .filter((x): x is string => !!x); + const arr = decl.initializer.arguments[0]; + if (!arr || !ts.isArrayLiteralExpression(arr) || arr.elements.length !== bindNames.length) { + diags.push( + diag( + file, + sf, + decl.initializer, + "TFDSL_PARALLEL_DESTRUCTURE", + `parallel destructure requires matching binding count and array of agent() calls (got ${bindNames.length} binds).`, + ), + ); + continue; + } + let ok = true; + for (let i = 0; i < bindNames.length; i++) { + const el = arr.elements[i]!; + if (!ts.isCallExpression(el) || calleeName(el.expression) !== "agent") { + diags.push( + diag( + file, + sf, + el, + "TFDSL_PARALLEL_DESTRUCTURE", + `parallel destructure branch ${i + 1} must be agent(...).`, + ), + ); + ok = false; + break; + } + handleCall(bindNames[i], el); + } + if (!ok) continue; + continue; + } + if (cn === "race") { + // race stays one phase — destructure not supported + diags.push( + diag( + file, + sf, + decl.initializer, + "TFDSL_RACE_DESTRUCTURE", + `race() does not support array destructure — bind as a single phase: const winner = race([...]).`, + ), + ); + continue; + } + } + if (!ts.isIdentifier(decl.name)) continue; + const name = decl.name.text; + if (ts.isCallExpression(decl.initializer)) { + handleCall(name, decl.initializer); + } else if ( + ts.isAsExpression(decl.initializer) && + ts.isCallExpression(decl.initializer.expression) + ) { + handleCall(name, decl.initializer.expression); + } + } + } + + if (ts.isReturnStatement(st) && st.expression) { + if (ts.isIdentifier(st.expression) && phases.has(st.expression.text)) { + finalId = st.expression.text; + const ph = phases.get(finalId)!; + ph.final = true; + ph.raw.final = true; + } else if (ts.isCallExpression(st.expression)) { + const id = handleCall(order.length === 0 ? "main" : `phase-${order.length}`, st.expression); + if (id) { + finalId = id; + const ph = phases.get(id)!; + ph.final = true; + ph.raw.final = true; + } + } + } + } + + // Warn phases with no deps (not first) + for (let i = 0; i < order.length; i++) { + const id = order[i]!; + const ph = phases.get(id)!; + if (i > 0 && ph.dependsOn.size === 0 && !Array.isArray(ph.raw.dependsOn)) { + diags.push({ + code: "TFDSL_DEP_NONE", + severity: "warning", + message: `Phase '${id}' has no automatic dependencies and is not first — add dependsOn if order matters.`, + file, + }); + } + } + + if (order.length === 0) { + diags.push({ + code: "TFDSL_ENTRY_EMPTY", + severity: "error", + message: `No phases found in flow body.`, + file, + }); + return { ok: false, diagnostics: diags }; + } + + // Ensure one final + if (!finalId) { + const last = order[order.length - 1]!; + phases.get(last)!.raw.final = true; + } + + const phaseList = order.map((id) => { + const ph = phases.get(id)!; + const raw = { ...ph.raw, id: ph.id }; + if (ph.dependsOn.size && !raw.dependsOn) raw.dependsOn = [...ph.dependsOn]; + // clean undefined-ish + return raw; + }); + + const taskflow: Record = { + name: flowName, + phases: phaseList, + }; + if (typeof flowOpts.description === "string") taskflow.description = flowOpts.description; + if (typeof flowOpts.version === "number") taskflow.version = flowOpts.version; + if (topArgs) taskflow.args = topArgs; + if (concurrency !== undefined) taskflow.concurrency = concurrency; + if (budget) taskflow.budget = budget; + + const ok = !diags.some((d) => d.severity === "error"); + return { ok, taskflow: ok ? taskflow : undefined, diagnostics: diags }; +} diff --git a/packages/taskflow-dsl/src/build/erase/templates.ts b/packages/taskflow-dsl/src/build/erase/templates.ts new file mode 100644 index 0000000..943d35d --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/templates.ts @@ -0,0 +1,214 @@ +/** + * Template / string erase → task text + dependency ids. + */ + +import ts from "typescript"; +import type { Diagnostic } from "../../diagnostics.ts"; +import { diag } from "./ast.ts"; +import type { PhaseDraft } from "./types.ts"; + +export function eraseStringish( + sf: ts.SourceFile, + file: string, + node: ts.Expression, + itemParam: string | undefined, + phases: Map, + diags: Diagnostic[], +): { text: string; deps: string[] } | undefined { + const deps: string[] = []; + + const pushDep = (id: string) => { + if (phases.has(id) && !deps.includes(id)) deps.push(id); + }; + + const propToPlaceholder = (expr: ts.Expression): string | undefined => { + if (ts.isIdentifier(expr) && itemParam && expr.text === itemParam) return "{item}"; + if ( + ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + itemParam && + expr.expression.text === itemParam + ) { + return `{item.${expr.name.text}}`; + } + if (ts.isPropertyAccessExpression(expr)) { + const chain: string[] = []; + let cur: ts.Expression = expr; + while (ts.isPropertyAccessExpression(cur)) { + chain.unshift(cur.name.text); + cur = cur.expression; + } + if (ts.isIdentifier(cur) && phases.has(cur.text)) { + pushDep(cur.text); + if (chain[0] === "output" && chain.length === 1) return `{steps.${cur.text}.output}`; + if (chain[0] === "json") { + if (chain.length === 1) return `{steps.${cur.text}.json}`; + return `{steps.${cur.text}.json.${chain.slice(1).join(".")}}`; + } + } + } + if ( + ts.isPropertyAccessExpression(expr) && + ts.isIdentifier(expr.expression) && + expr.expression.text === "args" + ) { + return `{args.${expr.name.text}}`; + } + return undefined; + }; + + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) { + return { text: node.text, deps }; + } + + if (ts.isTemplateExpression(node)) { + let text = node.head.text; + for (const span of node.templateSpans) { + const ph = propToPlaceholder(span.expression); + if (ph) { + text += ph; + } else if (ts.isIdentifier(span.expression) && phases.has(span.expression.text)) { + pushDep(span.expression.text); + text += `{steps.${span.expression.text}.output}`; + } else { + diags.push( + diag( + file, + sf, + span.expression, + "TFDSL_TMPL_UNERASABLE", + `Cannot erase template expression to a placeholder (only phase.output/json, item.*, args.* supported in MVP).`, + ), + ); + return undefined; + } + text += span.literal.text; + } + return { text, deps }; + } + + if (ts.isIdentifier(node)) { + diags.push(diag(file, sf, node, "TFDSL_RUNE_ARG", `Expected string or template for task text.`)); + return undefined; + } + + // fall through for other literals is rare + diags.push(diag(file, sf, node, "TFDSL_RUNE_ARG", `Expected static string/template task text.`)); + return undefined; +} + +export function eraseGateTask( + sf: ts.SourceFile, + file: string, + expr: ts.Expression, + param: string, + upstreamId: string | undefined, + phases: Map, + diags: Diagnostic[], +): { text: string; deps: string[] } | undefined { + const deps: string[] = []; + if (upstreamId) deps.push(upstreamId); + + const rewrite = (e: ts.Expression): string | undefined => { + if ( + ts.isPropertyAccessExpression(e) && + ts.isIdentifier(e.expression) && + e.expression.text === param && + (e.name.text === "output" || e.name.text === "json") + ) { + if (!upstreamId) return undefined; + return e.name.text === "output" ? `{steps.${upstreamId}.output}` : `{steps.${upstreamId}.json}`; + } + return undefined; + }; + + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, deps }; + if (ts.isTemplateExpression(expr)) { + let text = expr.head.text; + for (const span of expr.templateSpans) { + const ph = rewrite(span.expression); + if (ph) text += ph; + else { + const er = eraseStringish(sf, file, span.expression, undefined, phases, diags); + if (!er) return undefined; + text += er.text; + for (const d of er.deps) if (!deps.includes(d)) deps.push(d); + } + text += span.literal.text; + } + return { text, deps }; + } + return undefined; +} + +export function eraseLoopTask( + sf: ts.SourceFile, + file: string, + expr: ts.Expression, + prevName: string, + loopId: string, + diags: Diagnostic[], +): string | undefined { + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return expr.text; + if (!ts.isTemplateExpression(expr)) { + diags.push(diag(file, sf, expr, "TFDSL_RUNE_ARG", `loop task must be string or template.`)); + return undefined; + } + let text = expr.head.text; + for (const span of expr.templateSpans) { + const e = span.expression; + if ( + ts.isPropertyAccessExpression(e) && + ts.isIdentifier(e.expression) && + e.expression.text === prevName && + e.name.text === "output" + ) { + text += `{steps.${loopId}.output}`; + } else if (ts.isIdentifier(e) && e.text === "loop") { + text += `{loop.iteration}`; + } else { + diags.push(diag(file, sf, e, "TFDSL_TMPL_UNERASABLE", `Unsupported expression in loop task template.`)); + return undefined; + } + text += span.literal.text; + } + return text; +} + +export function eraseReduceTask( + sf: ts.SourceFile, + file: string, + expr: ts.Expression, + fn: ts.ArrowFunction | ts.FunctionExpression, + phases: Map, + diags: Diagnostic[], +): { text: string; deps: string[] } | undefined { + const p0 = fn.parameters[0]; + const partsName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "p"; + const deps: string[] = []; + if (ts.isStringLiteral(expr) || ts.isNoSubstitutionTemplateLiteral(expr)) return { text: expr.text, deps }; + if (!ts.isTemplateExpression(expr)) return eraseStringish(sf, file, expr, undefined, phases, diags); + let text = expr.head.text; + for (const span of expr.templateSpans) { + const e = span.expression; + if ( + ts.isPropertyAccessExpression(e) && + ts.isPropertyAccessExpression(e.expression) && + ts.isIdentifier(e.expression.expression) && + e.expression.expression.text === partsName + ) { + const phaseId = e.expression.name.text; + if (phases.has(phaseId)) deps.push(phaseId); + if (e.name.text === "output") text += `{steps.${phaseId}.output}`; + else if (e.name.text === "json") text += `{steps.${phaseId}.json}`; + else text += `{steps.${phaseId}.output}`; + } else { + const er = eraseStringish(sf, file, e, undefined, phases, diags); + if (!er) return undefined; + text += er.text; + for (const d of er.deps) deps.push(d); + } + text += span.literal.text; + } + return { text, deps }; +} diff --git a/packages/taskflow-dsl/src/build/erase/types.ts b/packages/taskflow-dsl/src/build/erase/types.ts new file mode 100644 index 0000000..fe328fa --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/types.ts @@ -0,0 +1,44 @@ +/** + * Shared types for the AST erase pipeline. + * Kept tiny so kind emitters and templates never import each other. + */ + +import type { Diagnostic } from "../../diagnostics.ts"; + +export interface EraseResult { + ok: boolean; + taskflow?: Record; + diagnostics: Diagnostic[]; +} + +export interface PhaseDraft { + id: string; + type: string; + raw: Record; + dependsOn: Set; + final?: boolean; +} + +/** Mutable session state for one eraseSource() call. */ +export interface EraseSession { + file: string; + sf: import("typescript").SourceFile; + diags: Diagnostic[]; + phases: Map; + order: string[]; +} + +export const PHASE_RUNES = new Set([ + "agent", + "parallel", + "map", + "gate", + "reduce", + "approval", + "subflow", + "loop", + "tournament", + "script", + "race", + "expand", +]); From 32df3ff207db24c275ad4d31ac2f7fab468666ff Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 16:48:07 +0800 Subject: [PATCH 29/51] refactor: finish erase kinds registry and expand helpers Peel all phase kind emitters out of pipeline into erase/kinds/* with a central KIND_HANDLERS registry; pipeline is now flow discovery + body walk only. Extract expand pure helpers (prefix/promote/mode) under runtime/phases and fix missing MAX_DYNAMIC_PHASES import that failed expand runs. --- docs/internal/modularization-0.2.0.md | 111 +--- packages/taskflow-core/src/runtime.ts | 49 +- .../src/runtime/phases/expand.ts | 67 +++ .../taskflow-dsl/src/build/erase/context.ts | 63 +++ .../src/build/erase/kinds/agent-script.ts | 80 +++ .../src/build/erase/kinds/approval.ts | 26 + .../src/build/erase/kinds/expand-flow.ts | 96 ++++ .../src/build/erase/kinds/gate-sugar.ts | 77 +++ .../src/build/erase/kinds/gate.ts | 59 ++ .../src/build/erase/kinds/index.ts | 110 ++++ .../src/build/erase/kinds/loop.ts | 52 ++ .../taskflow-dsl/src/build/erase/kinds/map.ts | 82 +++ .../src/build/erase/kinds/parallel.ts | 58 ++ .../src/build/erase/kinds/race.ts | 63 +++ .../src/build/erase/kinds/reduce.ts | 63 +++ .../src/build/erase/kinds/tournament.ts | 62 +++ .../taskflow-dsl/src/build/erase/pipeline.ts | 506 +----------------- 17 files changed, 1006 insertions(+), 618 deletions(-) create mode 100644 packages/taskflow-core/src/runtime/phases/expand.ts create mode 100644 packages/taskflow-dsl/src/build/erase/context.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/approval.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/gate.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/index.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/loop.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/map.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/parallel.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/race.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/reduce.ts create mode 100644 packages/taskflow-dsl/src/build/erase/kinds/tournament.ts diff --git a/docs/internal/modularization-0.2.0.md b/docs/internal/modularization-0.2.0.md index 5cf888d..d63e29a 100644 --- a/docs/internal/modularization-0.2.0.md +++ b/docs/internal/modularization-0.2.0.md @@ -1,99 +1,40 @@ -# Modularization plan — avoid monoliths (0.2.0) +# Modularization notes (0.2.0) -> Status: **in progress** · 2026-07-09 -> Principle: **low coupling, high cohesion** — one module, one job; new kinds land as plugins, not edits in a 3k-line file. +Avoid monolith growth while S4 lands. Prefer **extract-by-kind / extract-by-phase** over more branches in giant files. -## Why - -| Hotspot | Lines (approx) | Problem | -|---------|----------------|---------| -| `taskflow-core/src/runtime.ts` | ~3500 | All phase kinds + cache + spawn + layers in one unit | -| `taskflow-dsl/src/build/erase.ts` (was) | ~1200 | All rune emitters + templates + AST helpers | -| `schema.ts` / `store.ts` / `pi-taskflow/index.ts` | 1k–2k | Secondary; same pattern | - -Adding `race` / `expand` by pasting into `runtime.ts` is exactly the anti-pattern we reject. - -## Target shapes - -### taskflow-dsl build (S4) — **done this PR** - -``` -src/build/ - erase/ - index.ts # public: eraseSource - types.ts # PhaseDraft, EraseSession, PHASE_RUNES - ast.ts # calleeName, evalLiteral, diag (no phase knowledge) - templates.ts # string/template → placeholders + deps - opts.ts # mergeOpts, registerDraft - pipeline.ts # flow() discovery + body walk + kind dispatch - build.ts # validate + FlowIR (depends on erase, not TS AST) - erase.ts # thin re-export for stable import path -``` - -**Rules** - -- `ast.ts` must not import phase/kind logic. -- Kind-specific emit stays in `pipeline.ts` for now; next cut: `erase/kinds/*.ts` with a registry. -- `build.ts` never grows AST knowledge. - -### taskflow-core runtime — **strangler** +## taskflow-dsl erase ``` -src/runtime.ts # facade: executeTaskflow, re-exports (shrink over time) -src/runtime/ - types.ts # RuntimeDeps, RuntimeResult (extract later) - phase-cache.ts # cacheKeys, recordCache (extract later) - layers.ts # runTaskflowLayers (extract later) - phases/ - race.ts # ✅ executeRaceBranches (Horizon B) - expand.ts # next: peel flow/expand from executePhaseInner - parallel.ts # next - map.ts # next - … +packages/taskflow-dsl/src/build/erase/ +├─ pipeline.ts ← flow() discovery + body walk only (~280 LOC) +├─ context.ts ← EmitContext, register(), bindDefArg() +├─ ast.ts / opts.ts / templates.ts / types.ts +└─ kinds/ + ├─ index.ts ← KIND_HANDLERS registry + trySpecializedEmit() + ├─ agent-script.ts + ├─ map.ts / parallel.ts / race.ts + ├─ gate.ts / gate-sugar.ts + ├─ reduce.ts / approval.ts / loop.ts / tournament.ts + └─ expand-flow.ts ← expand + subflow.def / subflow use ``` -**Rules for new phase kinds** - -1. **New file under `runtime/phases/.ts`** (or shared helper) — no new 200-line blocks in `runtime.ts`. -2. Pure-ish API: inputs = phase + resolved branches/tasks + injectables (`runOne`, cache hooks). -3. Wire from `executePhaseInner` with a **one-liner** `if (type === "x") return await executeX(...)`. -4. Event kernel: either a matching `exec/step-kinds` handler **or** exclude from `EVENT_KERNEL_PHASE_TYPES` until then (race/expand already excluded). - -### schema / FlowIR +**Rule:** new phase kind → new file under `kinds/` + one entry in `KIND_HANDLERS`. Do not re-inflate `pipeline.ts`. -- `PHASE_TYPES` remains the **single registry** of kind strings. -- FlowIR kinds follow automatically via `StringEnum(PHASE_TYPES)`. -- Validation per kind stays in `validateTaskflow` for now; optional later: `schema/kinds/.ts` validators composed into one. - -## Dependency direction (must stay acyclic) +## taskflow-core runtime ``` -taskflow-dsl ──▶ taskflow-core (schema, validate, FlowIR only) - │ - ├─ runtime/phases/* ──▶ schema, interpolate, usage, runner-core - ├─ exec/* ──▶ schema, interpolate (no runtime.ts import) - └─ flowir/* ──▶ schema +packages/taskflow-core/src/runtime.ts ← still large; strangler ongoing +packages/taskflow-core/src/runtime/phases/ +├─ race.ts ← race execution +└─ expand.ts ← pure helpers (mode, maxNodes, prefix, promote) ``` -`exec/*` must **never** import `runtime.ts` (already true; keep it). - -## Migration checklist - -| Step | Status | -|------|--------| -| Split DSL erase into `build/erase/*` | ✅ | -| Extract `race` phase module | ✅ | -| Extract `expand`/`flow` promote helpers | next | -| Extract map/parallel/gate from `executePhaseInner` | next | -| Split `schema` validators by kind | later | -| Split `pi-taskflow/src/index.ts` by command surface | later | - -## PR policy +Expand **execution** still shares the `flow|expand` block in `runtime.ts` (sub-run + budget clamp). Pure fragment transforms live in `phases/expand.ts`. -- Prefer **extract + rewire** over “rewrite while feature-adding”. -- Each extract PR: green unit suite for that kind; no behavior change expected (diff only structure). -- Feature PRs for new kinds: **must** add `runtime/phases/.ts` (or explicit exception in the PR body). +**Rule:** next peel candidates for `runtime.ts` — loop, tournament, map/parallel branches, approval — each into `runtime/phases/.ts` with a thin dispatch in `executePhaseInner`. ---- +## Invariants -*One-liner: grow the system by adding modules, not by lengthening monoliths.* +1. Kind emit must go through `register(ctx, draft)` so dependsOn/final/id stripping stay consistent. +2. Core must never import `taskflow-dsl`. +3. Dynamic expand uses `MAX_DYNAMIC_PHASES` from `schema.ts` (import it; do not re-define). diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 7e3f57c..00f6583 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -35,7 +35,7 @@ const noRunnerInjected: RunTaskFn = async (_cwd, _agents, agentName, task) => ({ stopReason: "error", }); import { aggregateUsage, emptyUsage, type UsageStats } from "./usage.ts"; -import { type Budget, type CacheScope, dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, parseTtlMs, type Phase, resolveArgs, type Taskflow, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, type TournamentMode, validateTaskflow } from "./schema.ts"; +import { type Budget, type CacheScope, dependenciesOf, finalPhase, LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, MAX_DYNAMIC_MAP_ITEMS, MAX_DYNAMIC_NESTING, MAX_DYNAMIC_PHASES, parseTtlMs, type Phase, resolveArgs, type Taskflow, topoLayers, TOURNAMENT_DEFAULT_VARIANTS, TOURNAMENT_HARD_MAX_VARIANTS, type TournamentMode, validateTaskflow } from "./schema.ts"; import { verifyTaskflow } from "./verify.ts"; import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, type ScoreConfig, scoreResultJSON, type ScorerResult, scorerShapeErrors } from "./scorers.ts"; import { parseGateVerdict, overBudget as overBudgetCheck, parseTournamentWinner, type BudgetCheckInput } from "./deterministic.ts"; @@ -2025,14 +2025,10 @@ async function executePhaseInner( const hasDef = type === "expand" ? (phase as { def?: unknown }).def !== undefined : (phase as { def?: unknown }).def !== undefined; const stack = deps._stack ?? []; - const expandMode = - type === "expand" - ? ((phase as { expandMode?: string }).expandMode === "graft" ? "graft" : "nested") - : "nested"; - const maxNodes = - typeof (phase as { maxNodes?: number }).maxNodes === "number" - ? Math.min(MAX_DYNAMIC_PHASES, Math.max(1, (phase as { maxNodes: number }).maxNodes)) - : 50; + const { resolveExpandMode, resolveMaxNodes, prefixGraftFragment, promoteGraftPhases } = + await import("./runtime/phases/expand.ts"); + const expandMode = type === "expand" ? resolveExpandMode(phase) : "nested"; + const maxNodes = type === "expand" ? resolveMaxNodes(phase, MAX_DYNAMIC_PHASES) : 50; let subDef: Taskflow | undefined; let name: string; @@ -2095,7 +2091,7 @@ async function executePhaseInner( endedAt: Date.now(), }; } - // expand: cap fragment size + prefix ids for graft so they don't collide with parent. + // expand: cap fragment size + prefix ids for graft (helpers in phases/expand.ts). if (type === "expand") { if (wrapped.phases.length > maxNodes) { return defFailOpen( @@ -2103,22 +2099,7 @@ async function executePhaseInner( ); } if (expandMode === "graft") { - const prefix = `${phase.id}-`; - const idMap = new Map(wrapped.phases.map((p) => [p.id, prefix + p.id])); - wrapped = { - ...wrapped, - name: wrapped.name || `${phase.id}-graft`, - phases: wrapped.phases.map((p) => { - const np: Phase = { ...p, id: idMap.get(p.id) ?? prefix + p.id }; - if (p.dependsOn?.length) { - np.dependsOn = p.dependsOn.map((d) => idMap.get(d) ?? d); - } - if (p.from?.length) { - np.from = p.from.map((d) => idMap.get(d) ?? d); - } - return np; - }), - }; + wrapped = prefixGraftFragment(wrapped, phase.id); } } // Validate with `dynamic` hardening (breadth caps + cwd containment) since @@ -2223,21 +2204,11 @@ async function executePhaseInner( }, }); const sp = Object.values(subState.phases); - // expand graft: promote child phase states onto the parent run so peek / - // downstream can address them as {steps.-.*}. Nested - // mode leaves children only in the sub-run. + // expand graft promote — pure helper (see runtime/phases/expand.ts) const warnings: string[] = []; if (type === "expand" && expandMode === "graft" && subResult.ok) { - let promoted = 0; - for (const [cid, cps] of Object.entries(subState.phases)) { - if (state.phases[cid]) { - warnings.push(`expand graft skipped promote of '${cid}' (id already exists on parent)`); - continue; - } - state.phases[cid] = { ...cps, id: cid }; - promoted++; - } - warnings.push(`expand graft: promoted ${promoted} phase(s) onto parent run`); + const promo = promoteGraftPhases(state, subState.phases); + warnings.push(...promo.warnings); } const flowPs: PhaseState = { id: phase.id, diff --git a/packages/taskflow-core/src/runtime/phases/expand.ts b/packages/taskflow-core/src/runtime/phases/expand.ts new file mode 100644 index 0000000..81726e4 --- /dev/null +++ b/packages/taskflow-core/src/runtime/phases/expand.ts @@ -0,0 +1,67 @@ +/** + * Expand phase helpers — pure transforms for fragment prep + graft promote. + * Execution still wires through flow{def} path in runtime.ts; logic lives here + * so expand does not keep growing executePhaseInner. + */ + +import type { Phase, Taskflow } from "../../schema.ts"; +import type { PhaseState, RunState } from "../../store.ts"; + +export type ExpandMode = "nested" | "graft"; + +export function resolveExpandMode(phase: Phase): ExpandMode { + return (phase as { expandMode?: string }).expandMode === "graft" ? "graft" : "nested"; +} + +export function resolveMaxNodes(phase: Phase, hardCap: number): number { + const n = (phase as { maxNodes?: number }).maxNodes; + if (typeof n === "number" && Number.isFinite(n)) { + return Math.min(hardCap, Math.max(1, Math.floor(n))); + } + return Math.min(50, hardCap); +} + +/** + * Prefix every phase id (and rewrite dependsOn/from) so graft fragments never + * collide with parent phase ids. + */ +export function prefixGraftFragment(fragment: Taskflow, expandPhaseId: string): Taskflow { + const prefix = `${expandPhaseId}-`; + const idMap = new Map(fragment.phases.map((p) => [p.id, prefix + p.id])); + return { + ...fragment, + name: fragment.name || `${expandPhaseId}-graft`, + phases: fragment.phases.map((p) => { + const np: Phase = { ...p, id: idMap.get(p.id) ?? prefix + p.id }; + if (p.dependsOn?.length) { + np.dependsOn = p.dependsOn.map((d) => idMap.get(d) ?? d); + } + if (p.from?.length) { + np.from = p.from.map((d) => idMap.get(d) ?? d); + } + return np; + }), + }; +} + +/** + * Copy child phase states onto the parent run (graft promote). + * Skips ids that already exist on the parent. + */ +export function promoteGraftPhases( + parent: RunState, + childPhases: Record, +): { promoted: number; warnings: string[] } { + const warnings: string[] = []; + let promoted = 0; + for (const [cid, cps] of Object.entries(childPhases)) { + if (parent.phases[cid]) { + warnings.push(`expand graft skipped promote of '${cid}' (id already exists on parent)`); + continue; + } + parent.phases[cid] = { ...cps, id: cid }; + promoted++; + } + warnings.push(`expand graft: promoted ${promoted} phase(s) onto parent run`); + return { promoted, warnings }; +} diff --git a/packages/taskflow-dsl/src/build/erase/context.ts b/packages/taskflow-dsl/src/build/erase/context.ts new file mode 100644 index 0000000..43c0526 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/context.ts @@ -0,0 +1,63 @@ +/** + * Shared emit context for kind handlers (one eraseSource() call). + */ + +import type ts from "typescript"; +import type { Diagnostic } from "../../diagnostics.ts"; +import type { PhaseDraft } from "./types.ts"; + +export interface EmitContext { + file: string; + sf: ts.SourceFile; + diags: Diagnostic[]; + phases: Map; + order: string[]; +} + +export function nextSyntheticId(ctx: EmitContext, prefix: string): string { + return ctx.order.length === 0 ? "main" : `${prefix}-${ctx.order.length}`; +} + +export function register(ctx: EmitContext, draft: PhaseDraft): string { + delete draft.raw.id; + if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + if (draft.final) draft.raw.final = true; + ctx.phases.set(draft.id, draft); + if (!ctx.order.includes(draft.id)) ctx.order.push(draft.id); + return draft.id; +} + +/** Resolve def argument: plan.json / plan.output / string / phase id. */ +export function bindDefArg( + ctx: EmitContext, + defArg: ts.Expression | undefined, + draft: PhaseDraft, +): void { + if (!defArg) return; + const { phases } = ctx; + if (tsIsPropertyAccess(defArg) && tsIsIdentifier(defArg.expression)) { + const pid = defArg.expression.text; + if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { + draft.dependsOn.add(pid); + draft.raw.def = + defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (tsIsStringLiteral(defArg)) { + draft.raw.def = defArg.text; + } else if (tsIsIdentifier(defArg) && phases.has(defArg.text)) { + draft.dependsOn.add(defArg.text); + draft.raw.def = `{steps.${defArg.text}.json}`; + } +} + +// Local type guards to avoid pulling full ts into every call site signature +import ts from "typescript"; +function tsIsPropertyAccess(n: ts.Node): n is ts.PropertyAccessExpression { + return ts.isPropertyAccessExpression(n); +} +function tsIsIdentifier(n: ts.Node): n is ts.Identifier { + return ts.isIdentifier(n); +} +function tsIsStringLiteral(n: ts.Node): n is ts.StringLiteral { + return ts.isStringLiteral(n); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts b/packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts new file mode 100644 index 0000000..2ea1f1a --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts @@ -0,0 +1,80 @@ +import ts from "typescript"; +import { mergeOpts, phaseIdFromBinding } from "../opts.ts"; +import { eraseStringish } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitAgent( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "agent", + raw: { type: "agent" }, + dependsOn: new Set(), + }; + const taskArg = call.arguments[0]; + const optsArg = call.arguments[1] as ts.Expression | undefined; + if (taskArg) { + const erased = eraseStringish(ctx.sf, ctx.file, taskArg, itemParam, ctx.phases, ctx.diags); + if (erased) { + draft.raw.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + } + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.id === "string") draft.id = opts.id; + else draft.id = phaseIdFromBinding(idBase, opts); + Object.assign(draft.raw, opts); + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} + +export function emitScript( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "script", + raw: { type: "script" }, + dependsOn: new Set(), + }; + const taskArg = call.arguments[0]; + const optsArg = call.arguments[1] as ts.Expression | undefined; + if (taskArg) { + if (ts.isArrayLiteralExpression(taskArg)) { + const arr = taskArg.elements.map((el) => { + if (ts.isStringLiteral(el)) return el.text; + const er = eraseStringish(ctx.sf, ctx.file, el as ts.Expression, itemParam, ctx.phases, ctx.diags); + if (er) { + for (const d of er.deps) draft.dependsOn.add(d); + return er.text; + } + return ""; + }); + draft.raw.run = arr; + } else { + const erased = eraseStringish(ctx.sf, ctx.file, taskArg, itemParam, ctx.phases, ctx.diags); + if (erased) { + draft.raw.run = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + } + } + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.id === "string") draft.id = opts.id; + else draft.id = phaseIdFromBinding(idBase, opts); + Object.assign(draft.raw, opts); + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/approval.ts b/packages/taskflow-dsl/src/build/erase/kinds/approval.ts new file mode 100644 index 0000000..8d22144 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/approval.ts @@ -0,0 +1,26 @@ +import type ts from "typescript"; +import { mergeOpts } from "../opts.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitApproval( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "approval", + raw: { type: "approval" }, + dependsOn: new Set(), + }; + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.request === "string") draft.raw.task = opts.request; + Object.assign(draft.raw, opts); + delete draft.raw.request; + if (typeof opts.id === "string") draft.id = opts.id; + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts b/packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts new file mode 100644 index 0000000..361faed --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts @@ -0,0 +1,96 @@ +import ts from "typescript"; +import { diag, evalLiteral } from "../ast.ts"; +import { mergeOpts } from "../opts.ts"; +import type { PhaseDraft } from "../types.ts"; +import { bindDefArg, type EmitContext, nextSyntheticId, register } from "../context.ts"; + +/** expand.nested / subflow.def */ +export function emitExpandNestedOrSubflowDef( + ctx: EmitContext, + cn: string, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const id = + bindName ?? + (cn.startsWith("expand") ? `expand-${ctx.order.length}` : `flow-${ctx.order.length}`); + const draft: PhaseDraft = { + id, + type: cn === "expand.nested" ? "expand" : "flow", + raw: + cn === "expand.nested" + ? { type: "expand", expandMode: "nested" } + : { type: "flow" }, + dependsOn: new Set(), + }; + bindDefArg(ctx, call.arguments[0], draft); + const opts = mergeOpts( + ctx.sf, + ctx.file, + call.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + return register(ctx, draft); +} + +/** subflow("name", with?, opts?) */ +export function emitSubflowUse( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const id = bindName ?? `flow-${ctx.order.length}`; + const draft: PhaseDraft = { id, type: "flow", raw: { type: "flow" }, dependsOn: new Set() }; + const useArg = call.arguments[0]; + if (useArg && ts.isStringLiteral(useArg)) draft.raw.use = useArg.text; + else ctx.diags.push(diag(ctx.file, ctx.sf, call, "TFDSL_RUNE_ARG", `subflow(use) requires a string name.`)); + if (call.arguments[1] && ts.isObjectLiteralExpression(call.arguments[1])) { + draft.raw.with = evalLiteral(call.arguments[1]); + } + const opts = mergeOpts( + ctx.sf, + ctx.file, + call.arguments[2] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + return register(ctx, draft); +} + +/** expand(...) / expand.graft(...) */ +export function emitExpand( + ctx: EmitContext, + cn: string, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "expand", + raw: { + type: "expand", + expandMode: cn === "expand.graft" ? "graft" : "nested", + }, + dependsOn: new Set(), + }; + bindDefArg(ctx, call.arguments[0], draft); + const opts = mergeOpts( + ctx.sf, + ctx.file, + call.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + if (typeof opts.id === "string") draft.id = opts.id; + if (cn === "expand.graft") draft.raw.expandMode = "graft"; + if (cn === "expand" && !draft.raw.expandMode) draft.raw.expandMode = "nested"; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts b/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts new file mode 100644 index 0000000..b8e0e6c --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts @@ -0,0 +1,77 @@ +import ts from "typescript"; +import { evalLiteral } from "../ast.ts"; +import { mergeOpts } from "../opts.ts"; +import { eraseStringish } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +/** gate.automated / gate.scored */ +export function emitGateSugar( + ctx: EmitContext, + cn: string, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "gate", + raw: { type: "gate" }, + dependsOn: new Set(), + }; + const up = call.arguments[0]; + if (up && ts.isIdentifier(up) && ctx.phases.has(up.text)) draft.dependsOn.add(up.text); + const optsArg = call.arguments[1] as ts.Expression | undefined; + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + + if (cn === "gate.automated" && optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "pass") { + const v = evalLiteral(p.initializer); + if (Array.isArray(v)) draft.raw.eval = v; + } + if (p.name.text === "task") { + const er = eraseStringish(ctx.sf, ctx.file, p.initializer, undefined, ctx.phases, ctx.diags); + if (er) { + draft.raw.task = er.text; + for (const d of er.deps) draft.dependsOn.add(d); + } + } + } + if (!draft.raw.task && !draft.raw.score) { + draft.raw.task = "Gate (automated pre-checks failed or incomplete)."; + } + } + if (cn === "gate.scored" && optsArg && ts.isObjectLiteralExpression(optsArg)) { + const score: Record = {}; + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + const k = p.name.text; + if (k === "scorers" || k === "combine" || k === "threshold" || k === "weights" || k === "target" || k === "judge") { + const v = evalLiteral(p.initializer); + if (v !== undefined) score[k] = v; + } + if (k === "task") { + const er = eraseStringish(ctx.sf, ctx.file, p.initializer, undefined, ctx.phases, ctx.diags); + if (er) { + draft.raw.task = er.text; + for (const d of er.deps) draft.dependsOn.add(d); + } + } + } + if (!score.combine) score.combine = "all"; + draft.raw.score = score; + delete draft.raw.scorers; + delete draft.raw.combine; + delete draft.raw.threshold; + delete draft.raw.weights; + delete draft.raw.target; + delete draft.raw.judge; + } + delete draft.raw.pass; + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/gate.ts b/packages/taskflow-dsl/src/build/erase/kinds/gate.ts new file mode 100644 index 0000000..cec61ab --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/gate.ts @@ -0,0 +1,59 @@ +import ts from "typescript"; +import { mergeOpts } from "../opts.ts"; +import { eraseGateTask } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +/** Plain gate(upstream, opts?, taskLambda?) — not gate.automated / gate.scored. */ +export function emitGate( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "gate", + raw: { type: "gate" }, + dependsOn: new Set(), + }; + const up = call.arguments[0]; + if (up && ts.isIdentifier(up) && ctx.phases.has(up.text)) draft.dependsOn.add(up.text); + const optsArg = call.arguments[1] as ts.Expression | undefined; + const taskArg = call.arguments[2] as ts.Expression | undefined; + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + Object.assign(draft.raw, opts); + if (typeof opts.id === "string") draft.id = opts.id; + if (taskArg && (ts.isArrowFunction(taskArg) || ts.isFunctionExpression(taskArg))) { + const p0 = taskArg.parameters[0]; + const param = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "i"; + let expr: ts.Expression | undefined = ts.isBlock(taskArg.body) + ? undefined + : (taskArg.body as ts.Expression); + if (ts.isBlock(taskArg.body)) { + for (const st of taskArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } + if (expr) { + // Gate-only rewrite: (i) => `…${i.output}` — do NOT call eraseStringish first + // (it would emit TFDSL_TMPL_UNERASABLE for the lambda param). + const re = eraseGateTask( + ctx.sf, + ctx.file, + expr, + param, + up && ts.isIdentifier(up) ? up.text : undefined, + ctx.phases, + ctx.diags, + ); + if (re) { + draft.raw.task = re.text; + for (const d of re.deps) draft.dependsOn.add(d); + } + } + } + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/index.ts b/packages/taskflow-dsl/src/build/erase/kinds/index.ts new file mode 100644 index 0000000..408c414 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/index.ts @@ -0,0 +1,110 @@ +/** + * Kind emit registry — pipeline dispatches here; add kinds as new files. + */ + +import type ts from "typescript"; +import type { EmitContext } from "../context.ts"; +import { emitAgent, emitScript } from "./agent-script.ts"; +import { emitApproval } from "./approval.ts"; +import { emitExpand, emitExpandNestedOrSubflowDef, emitSubflowUse } from "./expand-flow.ts"; +import { emitGate } from "./gate.ts"; +import { emitGateSugar } from "./gate-sugar.ts"; +import { emitLoop } from "./loop.ts"; +import { emitMap } from "./map.ts"; +import { emitParallel } from "./parallel.ts"; +import { emitRace } from "./race.ts"; +import { emitReduce } from "./reduce.ts"; +import { emitTournament } from "./tournament.ts"; + +export type KindHandler = ( + ctx: EmitContext, + cn: string, + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, +) => string | undefined | false; + +/** + * Ordered specialized handlers. Return: + * - string phase id if handled + * - false if not this kind + * - undefined only when intentionally no phase (e.g. json()) + * + * Sugar / multi-name kinds first; then one handler per core rune. + */ +const KIND_HANDLERS: KindHandler[] = [ + (ctx, cn, bind, call) => { + if (cn === "expand.nested" || cn === "subflow.def") { + return emitExpandNestedOrSubflowDef(ctx, cn, bind, call); + } + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "subflow") return emitSubflowUse(ctx, bind, call); + return false; + }, + (ctx, cn, bind, call, item) => { + if (cn === "race") return emitRace(ctx, bind, call, item); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "expand" || cn === "expand.graft") return emitExpand(ctx, cn, bind, call); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "gate.automated" || cn === "gate.scored") return emitGateSugar(ctx, cn, bind, call); + return false; + }, + (ctx, cn, bind, call, item) => { + if (cn === "agent") return emitAgent(ctx, bind, call, item); + return false; + }, + (ctx, cn, bind, call, item) => { + if (cn === "script") return emitScript(ctx, bind, call, item); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "map") return emitMap(ctx, bind, call); + return false; + }, + (ctx, cn, bind, call, item) => { + if (cn === "parallel") return emitParallel(ctx, bind, call, item); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "gate") return emitGate(ctx, bind, call); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "reduce") return emitReduce(ctx, bind, call); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "approval") return emitApproval(ctx, bind, call); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "loop") return emitLoop(ctx, bind, call); + return false; + }, + (ctx, cn, bind, call) => { + if (cn === "tournament") return emitTournament(ctx, bind, call); + return false; + }, +]; + +/** Try kind emitters. Returns phase id, undefined (no phase), or "continue" if unknown. */ +export function trySpecializedEmit( + ctx: EmitContext, + cn: string, + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, +): string | undefined | "continue" { + for (const h of KIND_HANDLERS) { + const r = h(ctx, cn, bindName, call, itemParam); + if (r === false) continue; + return r; + } + return "continue"; +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/loop.ts b/packages/taskflow-dsl/src/build/erase/kinds/loop.ts new file mode 100644 index 0000000..2dc209a --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/loop.ts @@ -0,0 +1,52 @@ +import ts from "typescript"; +import { mergeOpts } from "../opts.ts"; +import { eraseLoopTask, eraseStringish } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitLoop( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "loop", + raw: { type: "loop" }, + dependsOn: new Set(), + }; + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + // task: (prev) => `...` inside object — scan object for task method + if (optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "task") { + if (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)) { + const prev = p.initializer.parameters[0]; + const prevNm = prev && ts.isIdentifier(prev.name) ? prev.name.text : "prev"; + let expr: ts.Expression | undefined = ts.isBlock(p.initializer.body) + ? undefined + : (p.initializer.body as ts.Expression); + if (ts.isBlock(p.initializer.body)) { + for (const st of p.initializer.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } + if (expr) { + const er = eraseLoopTask(ctx.sf, ctx.file, expr, prevNm, draft.id, ctx.diags); + if (er) draft.raw.task = er; + } + } else { + const er = eraseStringish(ctx.sf, ctx.file, p.initializer, undefined, ctx.phases, ctx.diags); + if (er) draft.raw.task = er.text; + } + } + } + } + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/map.ts b/packages/taskflow-dsl/src/build/erase/kinds/map.ts new file mode 100644 index 0000000..da1aa25 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/map.ts @@ -0,0 +1,82 @@ +import ts from "typescript"; +import { calleeName } from "../ast.ts"; +import { mergeOpts } from "../opts.ts"; +import { eraseStringish } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitMap( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "map", + raw: { type: "map" }, + dependsOn: new Set(), + }; + const overArg = call.arguments[0]; + const fnArg = call.arguments[1]; + const optsArg = call.arguments[2] as ts.Expression | undefined; + if (overArg && ts.isIdentifier(overArg) && ctx.phases.has(overArg.text)) { + draft.dependsOn.add(overArg.text); + draft.raw.over = `{steps.${overArg.text}.json}`; + } else if (overArg && ts.isPropertyAccessExpression(overArg) && ts.isIdentifier(overArg.expression)) { + const pid = overArg.expression.text; + if (ctx.phases.has(pid)) { + draft.dependsOn.add(pid); + draft.raw.over = + overArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } + } else if (overArg && (ts.isStringLiteral(overArg) || ts.isNoSubstitutionTemplateLiteral(overArg))) { + draft.raw.over = overArg.text; + } + let itemName = "item"; + if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { + const p0 = fnArg.parameters[0]; + if (p0 && ts.isIdentifier(p0.name)) itemName = p0.name.text; + draft.raw.as = itemName; + let inner: ts.Expression | undefined; + if (ts.isBlock(fnArg.body)) { + for (const st of fnArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) inner = st.expression; + } + } else { + inner = fnArg.body; + } + if (inner && ts.isCallExpression(inner)) { + const innerCn = calleeName(inner.expression); + if (innerCn === "agent") { + const erased = eraseStringish( + ctx.sf, + ctx.file, + inner.arguments[0]!, + itemName, + ctx.phases, + ctx.diags, + ); + if (erased) { + draft.raw.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const iopts = mergeOpts( + ctx.sf, + ctx.file, + inner.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + if (iopts.agent) draft.raw.agent = iopts.agent; + if (iopts.output) draft.raw.output = iopts.output; + } + } + } + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts b/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts new file mode 100644 index 0000000..10a8a85 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts @@ -0,0 +1,58 @@ +import ts from "typescript"; +import { calleeName } from "../ast.ts"; +import { mergeOpts } from "../opts.ts"; +import { eraseStringish } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitParallel( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "parallel", + raw: { type: "parallel" }, + dependsOn: new Set(), + }; + const arr = call.arguments[0]; + const optsArg = call.arguments[1] as ts.Expression | undefined; + const branches: Array> = []; + if (arr && ts.isArrayLiteralExpression(arr)) { + for (const el of arr.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish( + ctx.sf, + ctx.file, + el.arguments[0]!, + itemParam, + ctx.phases, + ctx.diags, + ); + const b: Record = {}; + if (erased) { + b.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const bopts = mergeOpts( + ctx.sf, + ctx.file, + el.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + Object.assign(b, bopts); + branches.push(b); + } + } + } + draft.raw.branches = branches; + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/race.ts b/packages/taskflow-dsl/src/build/erase/kinds/race.ts new file mode 100644 index 0000000..32bfd02 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/race.ts @@ -0,0 +1,63 @@ +import ts from "typescript"; +import { calleeName } from "../ast.ts"; +import { mergeOpts } from "../opts.ts"; +import { eraseStringish } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitRace( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, + itemParam?: string, +): string | undefined { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "race", + raw: { type: "race" }, + dependsOn: new Set(), + }; + const arr = call.arguments[0]; + const branches: Array> = []; + if (arr && ts.isArrayLiteralExpression(arr)) { + for (const el of arr.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish( + ctx.sf, + ctx.file, + el.arguments[0]!, + itemParam, + ctx.phases, + ctx.diags, + ); + const b: Record = {}; + if (erased) { + b.task = erased.text; + for (const d of erased.deps) draft.dependsOn.add(d); + } + const bopts = mergeOpts( + ctx.sf, + ctx.file, + el.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + Object.assign(b, bopts); + branches.push(b); + } + } + } + draft.raw.branches = branches; + const opts = mergeOpts( + ctx.sf, + ctx.file, + call.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/reduce.ts b/packages/taskflow-dsl/src/build/erase/kinds/reduce.ts new file mode 100644 index 0000000..bc7e5b5 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/reduce.ts @@ -0,0 +1,63 @@ +import ts from "typescript"; +import { calleeName } from "../ast.ts"; +import { mergeOpts } from "../opts.ts"; +import { eraseReduceTask } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitReduce( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "reduce", + raw: { type: "reduce" }, + dependsOn: new Set(), + }; + const fromArg = call.arguments[0]; + const fnArg = call.arguments[1]; + const optsArg = call.arguments[2] as ts.Expression | undefined; + const fromIds: string[] = []; + if (fromArg && ts.isArrayLiteralExpression(fromArg)) { + for (const el of fromArg.elements) { + if (ts.isIdentifier(el) && ctx.phases.has(el.text)) { + fromIds.push(el.text); + draft.dependsOn.add(el.text); + } + } + } + draft.raw.from = fromIds; + if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { + let expr: ts.Expression | undefined; + if (ts.isBlock(fnArg.body)) { + for (const st of fnArg.body.statements) { + if (ts.isReturnStatement(st) && st.expression) expr = st.expression; + } + } else expr = fnArg.body; + if (expr && ts.isCallExpression(expr) && calleeName(expr.expression) === "agent") { + if (expr.arguments[0]) { + const t2 = eraseReduceTask(ctx.sf, ctx.file, expr.arguments[0]!, fnArg, ctx.phases, ctx.diags); + if (t2) { + draft.raw.task = t2.text; + for (const d of t2.deps) draft.dependsOn.add(d); + } + } + const iopts = mergeOpts( + ctx.sf, + ctx.file, + expr.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + if (iopts.agent) draft.raw.agent = iopts.agent; + } + } + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + if (typeof opts.id === "string") draft.id = opts.id; + Object.assign(draft.raw, opts); + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts b/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts new file mode 100644 index 0000000..1bfaea5 --- /dev/null +++ b/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts @@ -0,0 +1,62 @@ +import ts from "typescript"; +import { calleeName } from "../ast.ts"; +import { mergeOpts } from "../opts.ts"; +import { eraseStringish } from "../templates.ts"; +import type { PhaseDraft } from "../types.ts"; +import { type EmitContext, nextSyntheticId, register } from "../context.ts"; + +export function emitTournament( + ctx: EmitContext, + bindName: string | undefined, + call: ts.CallExpression, +): string { + const idBase = bindName ?? nextSyntheticId(ctx, "phase"); + const draft: PhaseDraft = { + id: idBase, + type: "tournament", + raw: { type: "tournament" }, + dependsOn: new Set(), + }; + const optsArg = call.arguments[0] as ts.Expression | undefined; + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + Object.assign(draft.raw, opts); + if (optsArg && ts.isObjectLiteralExpression(optsArg)) { + for (const p of optsArg.properties) { + if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; + if (p.name.text === "branches" && ts.isArrayLiteralExpression(p.initializer)) { + const branches: Array> = []; + for (const el of p.initializer.elements) { + if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + const erased = eraseStringish( + ctx.sf, + ctx.file, + el.arguments[0]!, + undefined, + ctx.phases, + ctx.diags, + ); + const b: Record = {}; + if (erased) b.task = erased.text; + const bopts = mergeOpts( + ctx.sf, + ctx.file, + el.arguments[1] as ts.Expression | undefined, + ctx.diags, + ctx.phases, + ); + Object.assign(b, bopts); + branches.push(b); + } + } + draft.raw.branches = branches; + } + if (p.name.text === "task") { + const er = eraseStringish(ctx.sf, ctx.file, p.initializer, undefined, ctx.phases, ctx.diags); + if (er) draft.raw.task = er.text; + } + } + } + if (typeof opts.id === "string") draft.id = opts.id; + if (opts.final === true) draft.final = true; + return register(ctx, draft); +} diff --git a/packages/taskflow-dsl/src/build/erase/pipeline.ts b/packages/taskflow-dsl/src/build/erase/pipeline.ts index 1d65d5c..2b26822 100644 --- a/packages/taskflow-dsl/src/build/erase/pipeline.ts +++ b/packages/taskflow-dsl/src/build/erase/pipeline.ts @@ -1,20 +1,16 @@ /** * Erase pipeline orchestrator: flow() discovery + body walk + kind dispatch. * Domain helpers live in sibling modules (ast / templates / opts / types). + * Phase kinds live in erase/kinds/* — do not re-grow kind logic here. */ import ts from "typescript"; import type { Diagnostic } from "../../diagnostics.ts"; import { calleeName, diag, evalLiteral } from "./ast.ts"; -import { mergeOpts, phaseIdFromBinding } from "./opts.ts"; -import { - eraseGateTask, - eraseLoopTask, - eraseReduceTask, - eraseStringish, -} from "./templates.ts"; import type { EraseResult, PhaseDraft } from "./types.ts"; import { PHASE_RUNES } from "./types.ts"; +import type { EmitContext } from "./context.ts"; +import { trySpecializedEmit } from "./kinds/index.ts"; export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResult { const diags: Diagnostic[] = []; @@ -86,6 +82,8 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul ? [...body.statements] : [ts.factory.createReturnStatement(body as ts.Expression)]; + const emitCtx: EmitContext = { file, sf, diags, phases, order }; + const handleCall = ( bindName: string | undefined, call: ts.CallExpression, @@ -94,499 +92,19 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul const cn = calleeName(call.expression); if (!cn) return undefined; - // expand.nested → type:expand expandMode:nested; subflow.def → type:flow def - if (cn === "expand.nested" || cn === "subflow.def") { - const id = bindName ?? (cn.startsWith("expand") ? `expand-${order.length}` : `flow-${order.length}`); - const draft: PhaseDraft = { - id, - type: cn === "expand.nested" ? "expand" : "flow", - raw: - cn === "expand.nested" - ? { type: "expand", expandMode: "nested" } - : { type: "flow" }, - dependsOn: new Set(), - }; - const defArg = call.arguments[0]; - if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { - const pid = defArg.expression.text; - if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { - draft.dependsOn.add(pid); - draft.raw.def = defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; - } - } else if (defArg && ts.isStringLiteral(defArg)) { - draft.raw.def = defArg.text; - } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { - draft.dependsOn.add(defArg.text); - draft.raw.def = `{steps.${defArg.text}.json}`; - } - const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(draft.raw, opts); - if (typeof opts.id === "string") draft.id = opts.id; - phases.set(draft.id, draft); - order.push(draft.id); - return draft.id; - } - - if (cn === "subflow") { - const id = bindName ?? `flow-${order.length}`; - const draft: PhaseDraft = { id, type: "flow", raw: { type: "flow" }, dependsOn: new Set() }; - const useArg = call.arguments[0]; - if (useArg && ts.isStringLiteral(useArg)) draft.raw.use = useArg.text; - else diags.push(diag(file, sf, call, "TFDSL_RUNE_ARG", `subflow(use) requires a string name.`)); - if (call.arguments[1] && ts.isObjectLiteralExpression(call.arguments[1])) { - draft.raw.with = evalLiteral(call.arguments[1]); - } - const opts = mergeOpts(sf, file, call.arguments[2] as ts.Expression | undefined, diags, phases); - Object.assign(draft.raw, opts); - if (typeof opts.id === "string") draft.id = opts.id; - phases.set(draft.id, draft); - order.push(draft.id); - return draft.id; - } + const specialized = trySpecializedEmit(emitCtx, cn, bindName, call, itemParam); + if (specialized !== "continue") return specialized; if (!PHASE_RUNES.has(cn.split(".")[0]!) && !PHASE_RUNES.has(cn)) { if (cn === "json") return undefined; - // allow ignore non-phase return undefined; } - const type = cn === "race" ? "race" : cn; - // race([agent(...), ...], opts?) - if (cn === "race") { - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: "race", - raw: { type: "race" }, - dependsOn: new Set(), - }; - const arr = call.arguments[0]; - const branches: Array> = []; - if (arr && ts.isArrayLiteralExpression(arr)) { - for (const el of arr.elements) { - if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { - const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); - const b: Record = {}; - if (erased) { - b.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(b, bopts); - branches.push(b); - } - } - } - draft.raw.branches = branches; - const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - if (typeof opts.cancelLosers === "boolean") draft.raw.cancelLosers = opts.cancelLosers; - Object.assign(draft.raw, opts); - delete draft.raw.id; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (opts.final === true) { - draft.final = true; - draft.raw.final = true; - } - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; - } - - // expand(...) / expand.graft(...) — expand.nested handled above - if (cn === "expand" || cn === "expand.graft") { - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: "expand", - raw: { - type: "expand", - expandMode: cn === "expand.graft" ? "graft" : "nested", - }, - dependsOn: new Set(), - }; - const defArg = call.arguments[0]; - if (defArg && ts.isPropertyAccessExpression(defArg) && ts.isIdentifier(defArg.expression)) { - const pid = defArg.expression.text; - if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { - draft.dependsOn.add(pid); - draft.raw.def = - defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; - } - } else if (defArg && ts.isStringLiteral(defArg)) { - draft.raw.def = defArg.text; - } else if (defArg && ts.isIdentifier(defArg) && phases.has(defArg.text)) { - draft.dependsOn.add(defArg.text); - draft.raw.def = `{steps.${defArg.text}.json}`; - } - const opts = mergeOpts(sf, file, call.arguments[1] as ts.Expression | undefined, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - if (typeof opts.expandMode === "string") draft.raw.expandMode = opts.expandMode; - if (typeof opts.maxNodes === "number") draft.raw.maxNodes = opts.maxNodes; - // expand(...) default nested; expand.graft → graft - if (cn === "expand.graft") draft.raw.expandMode = "graft"; - if (cn === "expand" && !draft.raw.expandMode) draft.raw.expandMode = "nested"; - Object.assign(draft.raw, opts); - delete draft.raw.id; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (opts.final === true) { - draft.final = true; - draft.raw.final = true; - } - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; - } - - // gate.automated / gate.scored → type:gate with eval / score - if (cn === "gate.automated" || cn === "gate.scored") { - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: "gate", - raw: { type: "gate" }, - dependsOn: new Set(), - }; - const up = call.arguments[0]; - if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); - const optsArg = call.arguments[1] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (cn === "gate.automated" && optsArg && ts.isObjectLiteralExpression(optsArg)) { - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - if (p.name.text === "pass") { - const v = evalLiteral(p.initializer); - if (Array.isArray(v)) draft.raw.eval = v; - } - if (p.name.text === "task") { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) { - draft.raw.task = er.text; - for (const d of er.deps) draft.dependsOn.add(d); - } - } - } - // Engine requires task or score; default a minimal task if only eval given - if (!draft.raw.task && !draft.raw.score) { - draft.raw.task = "Gate (automated pre-checks failed or incomplete)."; - } - } - if (cn === "gate.scored" && optsArg && ts.isObjectLiteralExpression(optsArg)) { - const score: Record = {}; - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - const k = p.name.text; - if (k === "scorers" || k === "combine" || k === "threshold" || k === "weights" || k === "target" || k === "judge") { - const v = evalLiteral(p.initializer); - if (v !== undefined) score[k] = v; - } - if (k === "task") { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) { - draft.raw.task = er.text; - for (const d of er.deps) draft.dependsOn.add(d); - } - } - } - if (!score.combine) score.combine = "all"; - draft.raw.score = score; - // strip score fields from top-level raw if mergeOpts put them - delete draft.raw.scorers; - delete draft.raw.combine; - delete draft.raw.threshold; - delete draft.raw.weights; - delete draft.raw.target; - delete draft.raw.judge; - } - delete draft.raw.id; - delete draft.raw.pass; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (opts.final === true) { - draft.final = true; - draft.raw.final = true; - } - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; - } - - const phaseType = type === "gate" ? "gate" : type; - const idBase = bindName ?? (order.length === 0 ? "main" : `phase-${order.length}`); - const draft: PhaseDraft = { - id: idBase, - type: phaseType, - raw: { type: phaseType }, - dependsOn: new Set(), - }; - - if (type === "agent" || type === "script") { - const taskArg = call.arguments[0]; - const optsArg = call.arguments[1] as ts.Expression | undefined; - if (type === "agent" && taskArg) { - const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); - if (erased) { - draft.raw.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - } - if (type === "script" && taskArg) { - if (ts.isArrayLiteralExpression(taskArg)) { - const arr = taskArg.elements.map((el) => { - if (ts.isStringLiteral(el)) return el.text; - const er = eraseStringish(sf, file, el as ts.Expression, itemParam, phases, diags); - if (er) { - for (const d of er.deps) draft.dependsOn.add(d); - return er.text; - } - return ""; - }); - draft.raw.run = arr; - } else { - const erased = eraseStringish(sf, file, taskArg, itemParam, phases, diags); - if (erased) { - draft.raw.run = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - } - } - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - else draft.id = phaseIdFromBinding(idBase, opts); - Object.assign(draft.raw, opts); - if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); - if (opts.final === true) draft.final = true; - } else if (type === "map") { - const overArg = call.arguments[0]; - const fnArg = call.arguments[1]; - const optsArg = call.arguments[2] as ts.Expression | undefined; - if (overArg && ts.isIdentifier(overArg) && phases.has(overArg.text)) { - draft.dependsOn.add(overArg.text); - draft.raw.over = `{steps.${overArg.text}.json}`; - } else if (overArg && ts.isPropertyAccessExpression(overArg) && ts.isIdentifier(overArg.expression)) { - const pid = overArg.expression.text; - if (phases.has(pid)) { - draft.dependsOn.add(pid); - draft.raw.over = - overArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; - } - } else if (overArg && (ts.isStringLiteral(overArg) || ts.isNoSubstitutionTemplateLiteral(overArg))) { - draft.raw.over = overArg.text; - } - let itemName = "item"; - if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { - const p0 = fnArg.parameters[0]; - if (p0 && ts.isIdentifier(p0.name)) itemName = p0.name.text; - draft.raw.as = itemName; - // body: agent(...) or block with return - let inner: ts.Expression | undefined; - if (ts.isBlock(fnArg.body)) { - for (const st of fnArg.body.statements) { - if (ts.isReturnStatement(st) && st.expression) inner = st.expression; - } - } else { - inner = fnArg.body; - } - if (inner && ts.isCallExpression(inner)) { - const innerCn = calleeName(inner.expression); - if (innerCn === "agent") { - const erased = eraseStringish(sf, file, inner.arguments[0]!, itemName, phases, diags); - if (erased) { - draft.raw.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - const iopts = mergeOpts(sf, file, inner.arguments[1] as ts.Expression | undefined, diags, phases); - if (iopts.agent) draft.raw.agent = iopts.agent; - if (iopts.output) draft.raw.output = iopts.output; - } - } - } - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); - if (opts.final === true) draft.final = true; - } else if (type === "parallel") { - const arr = call.arguments[0]; - const optsArg = call.arguments[1] as ts.Expression | undefined; - const branches: Array> = []; - if (arr && ts.isArrayLiteralExpression(arr)) { - for (const el of arr.elements) { - if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { - const erased = eraseStringish(sf, file, el.arguments[0]!, itemParam, phases, diags); - const b: Record = {}; - if (erased) { - b.task = erased.text; - for (const d of erased.deps) draft.dependsOn.add(d); - } - const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(b, bopts); - branches.push(b); - } - } - } - draft.raw.branches = branches; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (opts.final === true) draft.final = true; - } else if (type === "gate") { - const up = call.arguments[0]; - if (up && ts.isIdentifier(up) && phases.has(up.text)) draft.dependsOn.add(up.text); - const optsArg = call.arguments[1] as ts.Expression | undefined; - const taskArg = call.arguments[2] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - Object.assign(draft.raw, opts); - if (typeof opts.id === "string") draft.id = opts.id; - if (taskArg && (ts.isArrowFunction(taskArg) || ts.isFunctionExpression(taskArg))) { - const p0 = taskArg.parameters[0]; - const param = p0 && ts.isIdentifier(p0.name) ? p0.name.text : "i"; - let expr: ts.Expression | undefined = ts.isBlock(taskArg.body) - ? undefined - : (taskArg.body as ts.Expression); - if (ts.isBlock(taskArg.body)) { - for (const st of taskArg.body.statements) { - if (ts.isReturnStatement(st) && st.expression) expr = st.expression; - } - } - if (expr) { - // Gate-only rewrite: (i) => `…${i.output}` — do NOT call eraseStringish first - // (it would emit TFDSL_TMPL_UNERASABLE for the lambda param). - const re = eraseGateTask( - sf, - file, - expr, - param, - up && ts.isIdentifier(up) ? up.text : undefined, - phases, - diags, - ); - if (re) { - draft.raw.task = re.text; - for (const d of re.deps) draft.dependsOn.add(d); - } - } - } - if (Array.isArray(opts.dependsOn)) for (const d of opts.dependsOn as string[]) draft.dependsOn.add(d); - if (opts.final === true) draft.final = true; - } else if (type === "reduce") { - const fromArg = call.arguments[0]; - const fnArg = call.arguments[1]; - const optsArg = call.arguments[2] as ts.Expression | undefined; - const fromIds: string[] = []; - if (fromArg && ts.isArrayLiteralExpression(fromArg)) { - for (const el of fromArg.elements) { - if (ts.isIdentifier(el) && phases.has(el.text)) { - fromIds.push(el.text); - draft.dependsOn.add(el.text); - } - } - } - draft.raw.from = fromIds; - if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { - let expr: ts.Expression | undefined; - if (ts.isBlock(fnArg.body)) { - for (const st of fnArg.body.statements) { - if (ts.isReturnStatement(st) && st.expression) expr = st.expression; - } - } else expr = fnArg.body; - if (expr && ts.isCallExpression(expr) && calleeName(expr.expression) === "agent") { - if (expr.arguments[0]) { - const t2 = eraseReduceTask(sf, file, expr.arguments[0]!, fnArg, phases, diags); - if (t2) { - draft.raw.task = t2.text; - for (const d of t2.deps) draft.dependsOn.add(d); - } - } - const iopts = mergeOpts(sf, file, expr.arguments[1] as ts.Expression | undefined, diags, phases); - if (iopts.agent) draft.raw.agent = iopts.agent; - } - } - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - if (opts.final === true) draft.final = true; - } else if (type === "approval") { - const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.request === "string") draft.raw.task = opts.request; - Object.assign(draft.raw, opts); - delete draft.raw.request; - if (typeof opts.id === "string") draft.id = opts.id; - if (opts.final === true) draft.final = true; - } else if (type === "loop") { - const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - if (typeof opts.id === "string") draft.id = opts.id; - Object.assign(draft.raw, opts); - // task: (prev) => `...` inside object — scan object for task method - if (optsArg && ts.isObjectLiteralExpression(optsArg)) { - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - if (p.name.text === "task") { - if (ts.isArrowFunction(p.initializer) || ts.isFunctionExpression(p.initializer)) { - const prev = p.initializer.parameters[0]; - const prevNm = prev && ts.isIdentifier(prev.name) ? prev.name.text : "prev"; - let expr: ts.Expression | undefined = ts.isBlock(p.initializer.body) - ? undefined - : (p.initializer.body as ts.Expression); - if (ts.isBlock(p.initializer.body)) { - for (const st of p.initializer.body.statements) { - if (ts.isReturnStatement(st) && st.expression) expr = st.expression; - } - } - if (expr) { - const er = eraseLoopTask(sf, file, expr, prevNm, draft.id, diags); - if (er) draft.raw.task = er; - } - } else { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) draft.raw.task = er.text; - } - } - } - } - if (opts.final === true) draft.final = true; - } else if (type === "tournament") { - const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(sf, file, optsArg, diags, phases); - Object.assign(draft.raw, opts); - if (optsArg && ts.isObjectLiteralExpression(optsArg)) { - for (const p of optsArg.properties) { - if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; - if (p.name.text === "branches" && ts.isArrayLiteralExpression(p.initializer)) { - const branches: Array> = []; - for (const el of p.initializer.elements) { - if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { - const erased = eraseStringish(sf, file, el.arguments[0]!, undefined, phases, diags); - const b: Record = {}; - if (erased) b.task = erased.text; - const bopts = mergeOpts(sf, file, el.arguments[1] as ts.Expression | undefined, diags, phases); - Object.assign(b, bopts); - branches.push(b); - } - } - draft.raw.branches = branches; - } - if (p.name.text === "task") { - const er = eraseStringish(sf, file, p.initializer, undefined, phases, diags); - if (er) draft.raw.task = er.text; - } - } - } - if (typeof opts.id === "string") draft.id = opts.id; - if (opts.final === true) draft.final = true; - } - - // strip non-schema keys - delete draft.raw.id; - if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; - if (draft.final) draft.raw.final = true; - - phases.set(draft.id, draft); - if (!order.includes(draft.id)) order.push(draft.id); - return draft.id; + // Known rune prefix but no handler — should not happen if registry is complete. + diags.push( + diag(file, sf, call, "TFDSL_RUNE_UNHANDLED", `No erase handler for rune '${cn}'.`), + ); + return undefined; }; for (const st of statements) { From 4227246039bd765cab6fd9236070f9c2f7ad71a5 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 16:51:55 +0800 Subject: [PATCH 30/51] docs(skills): align S4 surface with 12 kinds and DSL runes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update skills-src (core/configuration/patterns) and regenerate host skills for race/expand, gate sugar, and kinds↔runes mapping. Sync S4 RFCs/decision record acceptance, AGENTS, and README phase counts. --- AGENTS.md | 4 +- README.md | 2 +- docs/internal/COMPETITORS.md | 2 +- docs/market-positioning-2026-07.md | 2 +- docs/rfc-0.2.0-dsl-phases-horizon.md | 12 ++-- docs/rfc-0.2.0-dsl-syntax.md | 2 +- docs/rfc-0.2.0-s4-decision-record.md | 47 ++++++++++----- docs/rfc-0.2.0-s4-mvp.md | 52 ++++++++++++---- docs/rfc-taskflow-vs-claude-code-workflows.md | 4 +- .../plugin/skills/taskflow/SKILL.md | 59 +++++++++++++++++-- .../plugin/skills/taskflow/configuration.md | 49 +++++++++++---- .../plugin/skills/taskflow/patterns.md | 57 ++++++++++++++++++ .../plugin/skills/taskflow/SKILL.md | 59 +++++++++++++++++-- .../plugin/skills/taskflow/configuration.md | 49 +++++++++++---- .../plugin/skills/taskflow/patterns.md | 57 ++++++++++++++++++ .../plugin/skills/taskflow/SKILL.md | 59 +++++++++++++++++-- .../plugin/skills/taskflow/configuration.md | 49 +++++++++++---- .../plugin/skills/taskflow/patterns.md | 57 ++++++++++++++++++ .../plugin/skills/taskflow/SKILL.md | 59 +++++++++++++++++-- .../plugin/skills/taskflow/configuration.md | 49 +++++++++++---- .../plugin/skills/taskflow/patterns.md | 57 ++++++++++++++++++ packages/pi-taskflow/skills/taskflow/SKILL.md | 59 +++++++++++++++++-- .../skills/taskflow/configuration.md | 49 +++++++++++---- .../pi-taskflow/skills/taskflow/patterns.md | 57 ++++++++++++++++++ skills-src/taskflow/configuration.md | 49 +++++++++++---- skills-src/taskflow/core.md | 59 +++++++++++++++++-- skills-src/taskflow/patterns.md | 57 ++++++++++++++++++ 27 files changed, 973 insertions(+), 144 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3484c04..026a074 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -144,7 +144,7 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil - **FlowIR (S0):** `compileTaskflowToIR` → genuine `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`; `usedFallbackHash: false` when IR is content-addressable. - **Trace:** every run may record `runs//.trace.jsonl` via `RuntimeDeps.trace` (`FileTraceSink`). Decisions include gate/when/cache/budget/tournament/unreplayable. -- **Event kernel (S2 complete, default OFF):** set `RuntimeDeps.eventKernel: true` or `PI_TASKFLOW_EVENT_KERNEL=1`. All 10 kinds run on `exec/driver` when enabled **unless** the flow uses unsupported advanced features (score gates, `onBlock:retry`, reflexion, retry, expect, cross-run cache, shareContext) — those fall back to the imperative path. Budget, join/optional deps, dynamic `flow{def}` hardening, and agent timeouts are enforced on the kernel path. +- **Event kernel (S2 complete, default OFF):** set `RuntimeDeps.eventKernel: true` or `PI_TASKFLOW_EVENT_KERNEL=1`. Core kinds run on `exec/driver` when enabled **unless** the flow uses unsupported advanced features (score gates, `onBlock:retry`, reflexion, retry, expect, cross-run cache, shareContext) — those fall back to the imperative path. **`race` / `expand` stay on the imperative path** (excluded from `EVENT_KERNEL_PHASE_TYPES` until step handlers exist). Budget, join/optional deps, dynamic `flow{def}` hardening, and agent timeouts are enforced on the kernel path. - **Offline replay (S3, zero tokens):** `replayRun(events, overrides)` in `replay.ts` — **must not** import `runtime` / `exec/driver` / `exec/step` (guarded by `replay-import-lint.test.ts`). Surfaces: pi `action=replay` + `/tf replay`; MCP `taskflow_replay`. Distinct from **resume** / **recompute** (those re-execute live phases). - **MCP roster (12):** `taskflow_run|list|show|verify|compile|peek|trace|replay|why_stale|recompute|save|search`. @@ -291,7 +291,7 @@ All engine files live in `packages/taskflow-core/src/`; the pi entry lives in `p | File | Responsibility | |------|----------------| -| `runtime.ts` | Core orchestration: `executeTaskflow()`, `executePhase()`, all 10 phase types | +| `runtime.ts` | Core orchestration: `executeTaskflow()`, `executePhase()`, all 12 phase types (peel kinds into `runtime/phases/`) | | `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/grok adapters bind their runner + a thin bin) | diff --git a/README.md b/README.md index 011468b..28887b9 100644 --- a/README.md +++ b/README.md @@ -894,7 +894,7 @@ Copy one into `.pi/taskflows/.json` (or `~/.pi/agent/taskflows/`) and it r
-**0 runtime dependencies** · **1140 tests** · **10 phase types** · **shared context tree** · **cross-session resume** · **cross-run memoization** · **per-item map caching** · **incremental recompute** · **FlowIR compile seam** · **detached execution** · **`compile` Mermaid renderer** · **~9k LOC runtime** +**0 runtime dependencies** · **1140 tests** · **12 phase types** · **shared context tree** · **cross-session resume** · **cross-run memoization** · **per-item map caching** · **incremental recompute** · **FlowIR compile seam** · **detached execution** · **`compile` Mermaid renderer** · **~9k LOC runtime**
diff --git a/docs/internal/COMPETITORS.md b/docs/internal/COMPETITORS.md index 29cccf6..b9e46da 100644 --- a/docs/internal/COMPETITORS.md +++ b/docs/internal/COMPETITORS.md @@ -10,7 +10,7 @@ | Framework | Orchestration model | Authoring | Durable / resume | Dynamic fan-out | Human-in-loop | Quality gate | Static verification | Cross-run memoization | Observability | Deps | Uniquely good at | |---|---|---|---|:--:|:--:|:--:|:--:|:--:|---|:--:|---| | **▼ LOCAL / EMBEDDED DAG DSL** ||||||||||| -| **pi-taskflow** | Declarative JSON-DSL DAG (10 phase types) | JSON data | phase-level input-hash cache + cross-session resume | **YES** (`map`) | YES (approval) | YES (verdict) | NO (planned) | **YES** | basic (live DAG render; no OTel) | **ZERO** | zero-dep declarative subagent DAG; phase-hash caching; when-guards; budget caps; loop·tournament·cross-run cache | +| **pi-taskflow** | Declarative JSON-DSL DAG (12 phase types) | JSON data | phase-level input-hash cache + cross-session resume | **YES** (`map`) | YES (approval) | YES (verdict) | NO (planned) | **YES** | basic (live DAG render; no OTel) | **ZERO** | zero-dep declarative subagent DAG; phase-hash caching; when-guards; budget caps; loop·tournament·race·expand·cross-run cache | | **▼ GRAPH / STATE-MACHINE** ||||||||||| | LangGraph | StateGraph nodes/edges + Pregel super-steps | Python/JS code | checkpointer every super-step; time travel | YES (`Send`) | YES (`interrupt()` any node) | NO builtin | YES (`.compile()` + typed state) | within-session only | YES (LangSmith) | heavy | checkpoint durability + time travel; largest community | | Google ADK | Directed graph + agent-tree hierarchy | Python/JS/Go/Java/Kotlin | durable memory; event-driven dormancy | YES | YES (any-node) | YES (safety framework) | YES (inferred from graph compile) | UNVERIFIED | YES (builtin logs/metrics/traces) | medium | multi-language agent-tree hierarchy; first-class delegation | diff --git a/docs/market-positioning-2026-07.md b/docs/market-positioning-2026-07.md index 5105cd1..8be181b 100644 --- a/docs/market-positioning-2026-07.md +++ b/docs/market-positioning-2026-07.md @@ -115,7 +115,7 @@ Conductor(2026-05 微软开源)和 taskflow 的主张**几乎一一对应**: │ ───────────────────────────┼─────────────────────▶ 编排引擎深度 │ - │ taskflow ●──── 接近顶端(10 phase + 缓存) + │ taskflow ●──── 接近顶端(12 phase + 缓存) │ Conductor ●─── 也很高 │ ``` diff --git a/docs/rfc-0.2.0-dsl-phases-horizon.md b/docs/rfc-0.2.0-dsl-phases-horizon.md index a822bdd..4260f77 100644 --- a/docs/rfc-0.2.0-dsl-phases-horizon.md +++ b/docs/rfc-0.2.0-dsl-phases-horizon.md @@ -14,7 +14,7 @@ | **C · experimental 语言预留** | 语法进 `taskflow-dsl/experimental`,引擎未就绪前 build **失败明确**(不静默 no-op) | | **D · 不做** | 脑暴已否或仍冲突护城河 | -S4 **MVP ship** 仍是 10 种现有 kind 的基本形态;本文件是 **语言 horizon**,与 MVP 出货门正交——**实现可分期,设计先收齐**。 +S4 **原 MVP ship bar** 是 10 种 kind 的基本形态;引擎现为 **12 kind**(+`race`/`expand`,见 §6 打勾)。本文件仍是 **语言 horizon**——未打勾项实现可分期,设计先收齐。 --- @@ -314,8 +314,8 @@ const sp = fork.save("after-plan"); | 优先级 | 项 | 轨 | 语言工作 | 引擎工作 | |--------|----|----|----------|----------| -| **P0** | 10 kind 基本 + `subflow.def` / `expand.nested` 糖 | A | S4 | 已有 | -| **P0** | `gate.scored` / `gate.automated` / `reflexion` / `idempotent` 糖 | A | S4.1 | 已有 | +| **P0** | 10 kind 基本 + `subflow.def` / `expand.nested` 糖 | A | S4 | **✅** | +| **P0** | `gate.scored` / `gate.automated` / `reflexion` / `idempotent` 糖 | A | S4.1 | **✅** gate sugar + opts;erase kinds 注册 | | **P1** | **`expand` graft** | B | rune + IR kind | **✅ runtime + DSL** (promote onto parent) | | **P1** | **`loop` multi-body** | B | body 回调 erase | **新** | | **P1** | **`race`** | B | rune | **✅ runtime + DSL** | @@ -364,12 +364,12 @@ const sp = fork.save("after-plan"); | 文档 | 范围 | |------|------| -| `rfc-0.2.0-s4-mvp.md` | **可发布表面**:现有 10 kind 基本 + CLI | +| `rfc-0.2.0-s4-mvp.md` | **可发布表面**:CLI + runes(现含 12 kind + 糖) | | **本文** | **语言 horizon**:脑暴 phase 的 DSL 形状与分期 | -| 引擎 S4.x / S6 | 按 §6 表落地 type | +| 引擎 S4.x / S6 | 按 §6 表落地剩余 type | S4 出货门 **不变**(demo FlowIR == hand JSON)。 -S4 实现时 **可提前** 接受 JSON 里未知 type 的透传策略由实现定;DSL erase **只生成已支持 type**。 +DSL erase **只生成已支持 type**;未知 experimental → fail-closed。 --- diff --git a/docs/rfc-0.2.0-dsl-syntax.md b/docs/rfc-0.2.0-dsl-syntax.md index 7da80d0..2e284a5 100644 --- a/docs/rfc-0.2.0-dsl-syntax.md +++ b/docs/rfc-0.2.0-dsl-syntax.md @@ -306,7 +306,7 @@ FlowIR + sidecar 已经 lossless(7b48105 补全了 8 个字段)。decompiler 是 - 新 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]` 注释。 -- **0.2.0 脑暴 phase 收编:** 事件溯源解锁的 `expand` / `race` / `compensate` / `route` / `watch` / loop 多 body / map 逐项增量 / `counterfactual` 等 —— **语言形状与分期**见 [`rfc-0.2.0-dsl-phases-horizon.md`](./rfc-0.2.0-dsl-phases-horizon.md)(A 轨 DSL 糖 · B 轨 S4.x 引擎+语言 · C 轨 experimental)。不扩大「现有 10 kind 基本形态」的 S4 MVP 出货门,但 **设计上不再「未定义」。** +- **0.2.0 脑暴 phase 收编:** 事件溯源解锁的 `expand` / `race` / `compensate` / `route` / `watch` / loop 多 body / map 逐项增量 / `counterfactual` 等 —— **语言形状与分期**见 [`rfc-0.2.0-dsl-phases-horizon.md`](./rfc-0.2.0-dsl-phases-horizon.md)(A 轨 DSL 糖 · B 轨 S4.x 引擎+语言 · C 轨 experimental)。原 S4 MVP 出货门为 10 kind 基本形态;引擎现为 **12 kind**(+`race`/`expand` 已落地),其余 B/C 项仍分期,但 **设计上不再「未定义」。** --- diff --git a/docs/rfc-0.2.0-s4-decision-record.md b/docs/rfc-0.2.0-s4-decision-record.md index 0c09a87..fa13222 100644 --- a/docs/rfc-0.2.0-s4-decision-record.md +++ b/docs/rfc-0.2.0-s4-decision-record.md @@ -1,7 +1,7 @@ # S4 Shape Decision Record (`taskflow-dsl`) -> Status: **NEEDS_HUMAN** (route/surface frozen in council; 3 open locks) -> Date: 2026-07-09 +> Status: **IMPLEMENTED (package live)** — route/surface frozen; coverage extended beyond original MVP ship bar (see §“Landed beyond MVP”) +> Date: 2026-07-09 · Last kinds sync: 2026-07-09 > Full surface: [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) > Council runs: `s4-shape-council` → `s4-shape-council-v2` → `s4-shape-finalize` @@ -26,10 +26,10 @@ - **Hosts:** CLI-first; **no** new MCP tools in S4; run via existing `taskflow_run` + emitted `.taskflow.json` - **Core:** allow schema/validate/desugar/FlowIR compile+hash/verify; **deny** runtime/exec/runner/store/hosts/mcp -## MVP scope (in) +## MVP scope (in) — original ship bar 1. `flow` + args/budget/concurrency/description -2. All **10 phase kinds** basic runes (closed option types — no scored/automated gate sugar) +2. Core **10 phase kinds** basic runes 3. Template → `{steps.*}` / `{item.*}` erase 4. Auto `dependsOn` from `.output` / `.json` reads + explicit `dependsOn` 5. `when` **string** form (+ TS subset in `check` if cheap) @@ -38,19 +38,36 @@ 8. Golden **FlowIR hash equality** demos (must include map + templates + `json`, not only hello) 9. Import-lint: DSL must not drag core runtime -## Brainstorm phases — language support (not all in S4 ship bar) +## Landed beyond original MVP (kinds sync) + +Engine `PHASE_TYPES` is now **12** (`race`, `expand` added). DSL erase registry +(`packages/taskflow-dsl/src/build/erase/kinds/*`) covers: + +| Kind / sugar | Status | +|--------------|--------| +| Core 10 + `subflow` / `subflow.def` | ✅ | +| `gate.automated` / `gate.scored` | ✅ (A-track sugar) | +| `race` + `cancelLosers` | ✅ engine + DSL | +| `expand` / `expand.nested` / `expand.graft` + `maxNodes` | ✅ engine + DSL | +| Parallel destructure → N agent phases | ✅ | +| Modular pipeline (no monolith grow) | ✅ see `docs/internal/modularization-0.2.0.md` | + +Still **not** shipped as runes: loop multi-body, `route`, `compensate`/saga, +`watch`, experimental C-track. + +## Brainstorm phases — language support Source: `docs/internal/brainstorm-2026-07-08-0.2.0-phases.md` Design: **`docs/rfc-0.2.0-dsl-phases-horizon.md`** | Track | What | When | |-------|------|------| -| **A** | `subflow.def` / `expand.nested`, `gate.scored`/`automated`, `reflexion`, `idempotent` — **DSL sugar on existing engine** | S4 / S4.1 | -| **B** | New/enhanced: **`expand` graft**, **`race`**, **loop multi-body**, **`route`**, **`compensate`/saga**, approval timeout, map item-incremental, **`watch` on-stale** | S4.x engine + DSL | +| **A** | `subflow.def` / `expand.nested`, `gate.scored`/`automated`, `reflexion`, `idempotent` — **DSL sugar on existing engine** | ✅ mostly landed (reflexion/idempotent via opts) | +| **B** | New/enhanced: **`expand` graft**, **`race`**, **loop multi-body**, **`route`**, **`compensate`/saga**, approval timeout, map item-incremental, **`watch` on-stale** | race + expand graft ✅; rest S4.x | | **C** | experimental: `counterfactual`, `quorum`, `fork`, … | language stubs + fail-closed until engine | | **D** | visual builder, true stream edges, full self-rewriting flow | never / far | -S4 MVP **compiler** only erases A (where engine exists) + core 10 kinds. B/C runes may exist as **types/docs** but build must **error** if engine kind missing (no silent drop). +Unknown / unimplemented experimental runes must **error** (no silent drop). ## Explicitly out (S4 ship bar) @@ -82,13 +99,13 @@ export default flow("audit", (ctx) => { ## Acceptance gates -- [ ] `packages/taskflow-dsl` in workspace; bin + exports as `rfc-0.2.0-s4-mvp.md` §1 -- [ ] Demo `.tf.ts` and twin `.json` → **same** `ir:<64-hex>` -- [ ] Equality fixtures include **map + json\ + templates** -- [ ] Rune runtime call throws `TFDSL_ERASE_ONLY` -- [ ] Import-lint denylist green -- [ ] Skills/docs: JSON first-class for agents; CLI path for DSL -- [ ] DSL v2 note: FULL vs S4 MVP ship gate (authority B1) +- [x] `packages/taskflow-dsl` in workspace; bin + exports as `rfc-0.2.0-s4-mvp.md` §1 +- [x] Demo `.tf.ts` and twin `.json` → **same** `ir:<64-hex>` (parity tests) +- [x] Equality fixtures include **map + json\ + templates** +- [x] Rune runtime call throws `TFDSL_ERASE_ONLY` +- [x] Import-lint denylist green +- [x] Skills/docs: JSON first-class for agents; CLI path for DSL (+ kinds table) +- [x] DSL v2 note: FULL vs S4 MVP ship gate (authority B1) ## Open questions for human (max 3) diff --git a/docs/rfc-0.2.0-s4-mvp.md b/docs/rfc-0.2.0-s4-mvp.md index c6de83a..de2e869 100644 --- a/docs/rfc-0.2.0-s4-mvp.md +++ b/docs/rfc-0.2.0-s4-mvp.md @@ -1,6 +1,7 @@ # RFC: taskflow 0.2.0 S4 MVP — `taskflow-dsl` public surface -> Status: **PROPOSED — awaiting human lock** · Design-only · 2026-07-09 +> Status: **IMPLEMENTED** (package + CLI live; surface extended — see §8.1) · 2026-07-09 + > Parent: [`rfc-0.2.0-architecture.md`](./rfc-0.2.0-architecture.md) §4.3 / §9 S4 > Syntax authority: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 (**FULL language goal**; ship gate is this MVP doc) > Route lock: north-star 决策1 + this document §0 @@ -338,12 +339,12 @@ export function flow( fn: (ctx: FlowCtx) => PhaseRef | void, ): TaskflowModuleDefault; -// --- phase runes (MVP kinds) --- +// --- phase runes (aligned with PHASE_TYPES + sugars; see runes.ts) --- export function agent(task: TemplateInput, opts?: PhaseOptions): PhaseRef; export function parallel( branches: PhaseRef[], opts?: PhaseOptions, -): PhaseRef[] & PhaseRef; // compile-time destructure; type as tuple-friendly +): PhaseRef[] & PhaseRef; // compile-time destructure → N agent phases export function map( source: PhaseRef | Placeholder, fn: (item: ItemSymbol) => PhaseRef, @@ -354,6 +355,7 @@ export function gate( opts?: GateOptions, task?: (input: PhaseRef) => TemplateInput, ): PhaseRef; +// sugar: gate.automated / gate.scored export function reduce( from: PhaseRef[], fn: (parts: Record) => PhaseRef, @@ -365,12 +367,22 @@ export function subflow( withArgs?: Record, opts?: PhaseOptions, ): PhaseRef; +// sugar: subflow.def export function loop(opts: LoopOptions): PhaseRef; export function tournament(opts: TournamentOptions): PhaseRef; export function script( run: string | string[], opts?: ScriptOptions, ): PhaseRef; +export function race( + branches: PhaseRef[], + opts?: PhaseOptions & { cancelLosers?: boolean }, +): PhaseRef; +export function expand( + def: PhaseRef | string, + opts?: PhaseOptions & { expandMode?: "nested" | "graft"; maxNodes?: number }, +): PhaseRef; +// sugar: expand.nested / expand.graft // --- expect sugar --- export function json(): JsonExpectMarker; @@ -720,23 +732,37 @@ Public surface exposes runes for the **Y** rows of the S4 coverage matrix (full | `json()` basic object/array | `json` | | template → `{steps.*}` / `{item.*}` | erase in `build` | | `when` string + TS subset | options + check rules | -| `gate.automated` / `gate.scored` | **Y (S4.1 A-track)** — erase to `eval` / `score` | +| `gate.automated` / `gate.scored` | **✅ landed** — erase to `eval` / `score` | | top-level strict/share/incremental/scope | **not exported** (S4.1+) | -FULL RFC coverage remains a post-MVP completion track; missing FULL features must not appear as stub exports that silently no-op. +### §8.1 Kinds extension (post-MVP, same package) + +Engine and DSL now also ship Horizon B kinds (skills + decompile cover them): + +| Kind / feature | Surface | Notes | +|----------------|---------|--------| +| `race` | `race([...], { cancelLosers? })` | first completed branch wins | +| `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode`; graft promotes `-` | +| `subflow.def` | `subflow.def(plan)` | → `type:flow` + `def` | +| Parallel destructure | `const [a,b] = parallel([...])` | N real `agent` phase ids | + +Erase modularization: `build/erase/kinds/*` registry — do not re-monolith `pipeline.ts`. + +FULL RFC coverage remains a completion track; missing FULL features must not appear as stub exports that silently no-op. --- ## §9. Acceptance checklist (public surface) -- [ ] Package name `taskflow-dsl` publishable with exports/bin as §1 -- [ ] `taskflow-dsl build|check|decompile|new` flags/IO as §2 -- [ ] Author import `from "taskflow-dsl"` typechecks a hello + audit-style demo -- [ ] `build` on demo `.tf.ts` and hand JSON → **identical** `hashFlowIR` -- [ ] Diagnostics stable codes + positions on deliberate erase errors -- [ ] Import-lint: no denylist modules from core/hosts -- [ ] No MCP/schema changes required to pass S4 gate -- [ ] README/skills teach CLI-first workflow; JSON escape documented as first-class +- [x] Package name `taskflow-dsl` publishable with exports/bin as §1 +- [x] `taskflow-dsl build|check|decompile|new` flags/IO as §2 +- [x] Author import `from "taskflow-dsl"` typechecks a hello + audit-style demo +- [x] `build` on demo `.tf.ts` and hand JSON → **identical** `hashFlowIR` +- [x] Diagnostics stable codes + positions on deliberate erase errors +- [x] Import-lint: no denylist modules from core/hosts +- [x] No MCP/schema changes required to pass S4 gate +- [x] README/skills teach CLI-first workflow; JSON escape documented as first-class + --- diff --git a/docs/rfc-taskflow-vs-claude-code-workflows.md b/docs/rfc-taskflow-vs-claude-code-workflows.md index 70a47ec..720bac2 100644 --- a/docs/rfc-taskflow-vs-claude-code-workflows.md +++ b/docs/rfc-taskflow-vs-claude-code-workflows.md @@ -13,7 +13,7 @@ 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), +orchestration depth** (12 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 @@ -199,7 +199,7 @@ Legend: ✅ has it · ⚠️ partial / weaker · ❌ missing. ## 3. Where taskflow is clearly ahead (defend these) -1. **Orchestration vocabulary** — 10 phase types vs 2 primitives. gate / +1. **Orchestration vocabulary** — 12 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. diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index f01a0bf..0414554 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -69,7 +69,7 @@ task deserves level 3 — the higher levels are where taskflow pays for itself. | 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 | +| 4 | + `loop`, `tournament`, `flow{def}` / `expand`, `race` | the work itself is discovered at runtime; one shot is unreliable; try parallel approaches and keep the first win | | 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 @@ -165,12 +165,12 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` } ``` -### Phase types (10) +### Phase types (12) | type | meaning | details | |------|---------|---------| | `agent` | one subagent runs `task` | this file | -| `parallel` | run static `branches[]` concurrently | this file | +| `parallel` | run static `branches[]` concurrently (all complete) | 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 | @@ -179,6 +179,8 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | +| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -187,7 +189,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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. | +| `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/race/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/expand. 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`. | @@ -464,6 +466,55 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Race phases (first completed wins) + +A `race` phase runs static `branches[]` concurrently and **returns the first +branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, +default true). Use it when several approaches can answer the same question and +latency matters more than waiting for every branch (unlike `parallel`, which +waits for all, or `tournament`, which judges quality after all variants finish). + +- `branches` — **required**, at least two `{task, agent?}`. +- `cancelLosers` — optional boolean (default `true`). +- Output of the winning branch becomes the race phase output; a warning records + which branch won. + +```jsonc +{ + "id": "quick", "type": "race", + "branches": [ + { "task": "Answer with a short heuristic…", "agent": "executor" }, + { "task": "Answer with a thorough search…", "agent": "researcher" } + ], + "final": true +} +``` + +### Expand phases (dynamic fragment: nested or graft) + +An `expand` phase runs a **fragment Taskflow** from `def` (inline object, +phases array, or interpolated `{steps.plan.json}`). Two modes: + +| `expandMode` | Behavior | +|--------------|----------| +| `nested` (default) | Run as an isolated sub-flow (like `flow{def}`); child phase ids stay **off** the parent. | +| `graft` | After success, **promote** child phase states onto the parent as `-` so later phases can read `{steps.grow-leaf.output}`. | + +- `def` — **required** for expand. +- `maxNodes` — optional cap on fragment phase count (default 50, hard max 100). +- Dynamic validation + nesting caps match `flow{def}` (see `advanced.md`). +- Prefer `expand` when the planner fragment is a first-class kind; prefer + `flow` + `use` for saved reusable flows; prefer `flow` + `def` when you want + the classic nested sub-flow without graft promote. + +```jsonc +{ + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"], "final": true +} +``` + ### Budget (cost / token caps) Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it, diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index b47f210..2c0090d 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -56,14 +56,16 @@ 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) + "type": "map", // agent | parallel | map | gate | reduce | approval | flow | loop | tournament | script | race | expand (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 + "branches": [ /* … */ ], // [parallel|race] static task list "from": ["audit"], // [reduce] phase ids to aggregate + "def": "{steps.plan.json}", // [expand|flow] inline fragment / dynamic sub-flow + "expandMode": "nested", // [expand] nested | graft "output": "json", // text | json (default: text) "model": "claude-sonnet-4-5", // per-phase model override "thinking": "high", // per-phase thinking override @@ -77,13 +79,17 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | 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). | +| `type` | all | `agent` | One of the **12** phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script, **race**, **expand**). | | `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?}]`. | +| `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | +| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | +| `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | +| `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | +| `maxNodes` | expand | `50` | Cap fragment phase count (1..100). | | `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. | @@ -99,12 +105,12 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `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 +> Gate-only control fields (`eval`, `onBlock`, score), 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`. +> the script fields (`run`/`input`/`timeout`), race/expand fields above, 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`. --- @@ -436,13 +442,30 @@ node --conditions=development --experimental-strip-types \ | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | -**Authoring notes** +**Authoring notes (kinds ↔ runes)** + +Import: `import { flow, agent, map, … } from "taskflow-dsl"`. Runes erase to Taskflow +JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). + +| JSON `type` | DSL rune(s) | Notes | +|-------------|-------------|--------| +| `agent` | `agent(task, opts?)` | templates → `{steps.*}` / `{item.*}` | +| `parallel` | `parallel([agent…])` | waits for all branches | +| `map` | `map(source, item => agent…)` | `over` + `as` | +| `gate` | `gate(up, opts?, task?)` · `gate.automated` · `gate.scored` | sugar → `eval` / `score` | +| `reduce` | `reduce([…], () => agent…)` | `from` | +| `approval` | `approval({ request })` | | +| `flow` | `subflow("name")` · `subflow.def(plan)` | use vs def | +| `loop` | `loop({ task, until?, … })` | | +| `tournament` | `tournament({ branches/variants, judge, … })` | | +| `script` | `script(run, opts?)` | string or argv array | +| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | -- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. -- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). -- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- `race([...])` does **not** support array destructure — bind as one phase: `const winner = race([...])`. - Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). +- Modular erase: new kind → `packages/taskflow-dsl/src/build/erase/kinds/.ts` + registry entry (see `docs/internal/modularization-0.2.0.md`). Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. diff --git a/packages/claude-taskflow/plugin/skills/taskflow/patterns.md b/packages/claude-taskflow/plugin/skills/taskflow/patterns.md index 51b0b47..0825bd1 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/patterns.md @@ -243,6 +243,63 @@ instead merges all variants — good for research synthesis, bad for decisions. --- +## Archetype 7: Race for latency (first approach wins) + +When several strategies can answer the same question and you care about **time +to first good answer** more than comparing quality, use `race` (not +`tournament`). Branches start together; the first successful completion becomes +the phase output. + +```jsonc +{ + "name": "quick-answer", + "budget": { "maxUSD": 0.5 }, + "phases": [ + { + "id": "answer", "type": "race", + "branches": [ + { "task": "Answer from local heuristics only: {args.q}", "agent": "executor" }, + { "task": "Answer after a short web/docs look: {args.q}", "agent": "researcher" } + ], + "final": true + } + ] +} +``` + +**Prefer tournament when** you need a judge to pick the *best* draft after all +variants finish. **Prefer parallel when** you need *every* branch's output +downstream (then `reduce`). + +## Archetype 8: Expand graft (planner fragment on the parent DAG) + +Planner emits a fragment; `expand` with `expandMode: "graft"` runs it and +promotes child phase states as `-` so a later phase can read +them. Use `nested` (or classic `flow{def}`) when you only need the fragment's +**final** output and do not want child ids on the parent. + +```jsonc +{ + "name": "plan-graft", + "phases": [ + { + "id": "plan", "type": "agent", "agent": "planner", "output": "json", + "task": "Emit a mini-flow JSON: {name, phases:[{id,type,agent,task,final?}…]} for the audit.", + "expect": { "type": "object", "required": ["phases"] } + }, + { + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", "dependsOn": ["plan"] + }, + { + "id": "wrap", "type": "agent", "agent": "writer", + "task": "Summarize grafted work. Child outputs may appear as steps.grow-* in the run state.", + "dependsOn": ["grow"], "final": true + } + ] +} +``` + ## Archetype 6: Incremental repo-watch audit (cross-run) Use for flows you'll re-run as the repo evolves. First run pays full price; diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index dc674c7..1055245 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -68,7 +68,7 @@ task deserves level 3 — the higher levels are where taskflow pays for itself. | 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 | +| 4 | + `loop`, `tournament`, `flow{def}` / `expand`, `race` | the work itself is discovered at runtime; one shot is unreliable; try parallel approaches and keep the first win | | 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 @@ -164,12 +164,12 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` } ``` -### Phase types (10) +### Phase types (12) | type | meaning | details | |------|---------|---------| | `agent` | one subagent runs `task` | this file | -| `parallel` | run static `branches[]` concurrently | this file | +| `parallel` | run static `branches[]` concurrently (all complete) | 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 | @@ -178,6 +178,8 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | +| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -186,7 +188,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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. | +| `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/race/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/expand. 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`. | @@ -463,6 +465,55 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Race phases (first completed wins) + +A `race` phase runs static `branches[]` concurrently and **returns the first +branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, +default true). Use it when several approaches can answer the same question and +latency matters more than waiting for every branch (unlike `parallel`, which +waits for all, or `tournament`, which judges quality after all variants finish). + +- `branches` — **required**, at least two `{task, agent?}`. +- `cancelLosers` — optional boolean (default `true`). +- Output of the winning branch becomes the race phase output; a warning records + which branch won. + +```jsonc +{ + "id": "quick", "type": "race", + "branches": [ + { "task": "Answer with a short heuristic…", "agent": "executor" }, + { "task": "Answer with a thorough search…", "agent": "researcher" } + ], + "final": true +} +``` + +### Expand phases (dynamic fragment: nested or graft) + +An `expand` phase runs a **fragment Taskflow** from `def` (inline object, +phases array, or interpolated `{steps.plan.json}`). Two modes: + +| `expandMode` | Behavior | +|--------------|----------| +| `nested` (default) | Run as an isolated sub-flow (like `flow{def}`); child phase ids stay **off** the parent. | +| `graft` | After success, **promote** child phase states onto the parent as `-` so later phases can read `{steps.grow-leaf.output}`. | + +- `def` — **required** for expand. +- `maxNodes` — optional cap on fragment phase count (default 50, hard max 100). +- Dynamic validation + nesting caps match `flow{def}` (see `advanced.md`). +- Prefer `expand` when the planner fragment is a first-class kind; prefer + `flow` + `use` for saved reusable flows; prefer `flow` + `def` when you want + the classic nested sub-flow without graft promote. + +```jsonc +{ + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"], "final": true +} +``` + ### Budget (cost / token caps) Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it, diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index 70c6226..97cb280 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -56,14 +56,16 @@ 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) + "type": "map", // agent | parallel | map | gate | reduce | approval | flow | loop | tournament | script | race | expand (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 + "branches": [ /* … */ ], // [parallel|race] static task list "from": ["audit"], // [reduce] phase ids to aggregate + "def": "{steps.plan.json}", // [expand|flow] inline fragment / dynamic sub-flow + "expandMode": "nested", // [expand] nested | graft "output": "json", // text | json (default: text) "model": "claude-sonnet-4-5", // per-phase model override "thinking": "high", // per-phase thinking override @@ -77,13 +79,17 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | 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). | +| `type` | all | `agent` | One of the **12** phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script, **race**, **expand**). | | `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?}]`. | +| `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | +| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | +| `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | +| `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | +| `maxNodes` | expand | `50` | Cap fragment phase count (1..100). | | `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. | @@ -99,12 +105,12 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `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 +> Gate-only control fields (`eval`, `onBlock`, score), 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`. +> the script fields (`run`/`input`/`timeout`), race/expand fields above, 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`. --- @@ -433,13 +439,30 @@ node --conditions=development --experimental-strip-types \ | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | -**Authoring notes** +**Authoring notes (kinds ↔ runes)** + +Import: `import { flow, agent, map, … } from "taskflow-dsl"`. Runes erase to Taskflow +JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). + +| JSON `type` | DSL rune(s) | Notes | +|-------------|-------------|--------| +| `agent` | `agent(task, opts?)` | templates → `{steps.*}` / `{item.*}` | +| `parallel` | `parallel([agent…])` | waits for all branches | +| `map` | `map(source, item => agent…)` | `over` + `as` | +| `gate` | `gate(up, opts?, task?)` · `gate.automated` · `gate.scored` | sugar → `eval` / `score` | +| `reduce` | `reduce([…], () => agent…)` | `from` | +| `approval` | `approval({ request })` | | +| `flow` | `subflow("name")` · `subflow.def(plan)` | use vs def | +| `loop` | `loop({ task, until?, … })` | | +| `tournament` | `tournament({ branches/variants, judge, … })` | | +| `script` | `script(run, opts?)` | string or argv array | +| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | -- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. -- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). -- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- `race([...])` does **not** support array destructure — bind as one phase: `const winner = race([...])`. - Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). +- Modular erase: new kind → `packages/taskflow-dsl/src/build/erase/kinds/.ts` + registry entry (see `docs/internal/modularization-0.2.0.md`). Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. diff --git a/packages/codex-taskflow/plugin/skills/taskflow/patterns.md b/packages/codex-taskflow/plugin/skills/taskflow/patterns.md index 51b0b47..0825bd1 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/patterns.md @@ -243,6 +243,63 @@ instead merges all variants — good for research synthesis, bad for decisions. --- +## Archetype 7: Race for latency (first approach wins) + +When several strategies can answer the same question and you care about **time +to first good answer** more than comparing quality, use `race` (not +`tournament`). Branches start together; the first successful completion becomes +the phase output. + +```jsonc +{ + "name": "quick-answer", + "budget": { "maxUSD": 0.5 }, + "phases": [ + { + "id": "answer", "type": "race", + "branches": [ + { "task": "Answer from local heuristics only: {args.q}", "agent": "executor" }, + { "task": "Answer after a short web/docs look: {args.q}", "agent": "researcher" } + ], + "final": true + } + ] +} +``` + +**Prefer tournament when** you need a judge to pick the *best* draft after all +variants finish. **Prefer parallel when** you need *every* branch's output +downstream (then `reduce`). + +## Archetype 8: Expand graft (planner fragment on the parent DAG) + +Planner emits a fragment; `expand` with `expandMode: "graft"` runs it and +promotes child phase states as `-` so a later phase can read +them. Use `nested` (or classic `flow{def}`) when you only need the fragment's +**final** output and do not want child ids on the parent. + +```jsonc +{ + "name": "plan-graft", + "phases": [ + { + "id": "plan", "type": "agent", "agent": "planner", "output": "json", + "task": "Emit a mini-flow JSON: {name, phases:[{id,type,agent,task,final?}…]} for the audit.", + "expect": { "type": "object", "required": ["phases"] } + }, + { + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", "dependsOn": ["plan"] + }, + { + "id": "wrap", "type": "agent", "agent": "writer", + "task": "Summarize grafted work. Child outputs may appear as steps.grow-* in the run state.", + "dependsOn": ["grow"], "final": true + } + ] +} +``` + ## Archetype 6: Incremental repo-watch audit (cross-run) Use for flows you'll re-run as the repo evolves. First run pays full price; diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index 1258db8..aafe52e 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -71,7 +71,7 @@ task deserves level 3 — the higher levels are where taskflow pays for itself. | 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 | +| 4 | + `loop`, `tournament`, `flow{def}` / `expand`, `race` | the work itself is discovered at runtime; one shot is unreliable; try parallel approaches and keep the first win | | 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 @@ -167,12 +167,12 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` } ``` -### Phase types (10) +### Phase types (12) | type | meaning | details | |------|---------|---------| | `agent` | one subagent runs `task` | this file | -| `parallel` | run static `branches[]` concurrently | this file | +| `parallel` | run static `branches[]` concurrently (all complete) | 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 | @@ -181,6 +181,8 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | +| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -189,7 +191,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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. | +| `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/race/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/expand. 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`. | @@ -466,6 +468,55 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Race phases (first completed wins) + +A `race` phase runs static `branches[]` concurrently and **returns the first +branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, +default true). Use it when several approaches can answer the same question and +latency matters more than waiting for every branch (unlike `parallel`, which +waits for all, or `tournament`, which judges quality after all variants finish). + +- `branches` — **required**, at least two `{task, agent?}`. +- `cancelLosers` — optional boolean (default `true`). +- Output of the winning branch becomes the race phase output; a warning records + which branch won. + +```jsonc +{ + "id": "quick", "type": "race", + "branches": [ + { "task": "Answer with a short heuristic…", "agent": "executor" }, + { "task": "Answer with a thorough search…", "agent": "researcher" } + ], + "final": true +} +``` + +### Expand phases (dynamic fragment: nested or graft) + +An `expand` phase runs a **fragment Taskflow** from `def` (inline object, +phases array, or interpolated `{steps.plan.json}`). Two modes: + +| `expandMode` | Behavior | +|--------------|----------| +| `nested` (default) | Run as an isolated sub-flow (like `flow{def}`); child phase ids stay **off** the parent. | +| `graft` | After success, **promote** child phase states onto the parent as `-` so later phases can read `{steps.grow-leaf.output}`. | + +- `def` — **required** for expand. +- `maxNodes` — optional cap on fragment phase count (default 50, hard max 100). +- Dynamic validation + nesting caps match `flow{def}` (see `advanced.md`). +- Prefer `expand` when the planner fragment is a first-class kind; prefer + `flow` + `use` for saved reusable flows; prefer `flow` + `def` when you want + the classic nested sub-flow without graft promote. + +```jsonc +{ + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"], "final": true +} +``` + ### Budget (cost / token caps) Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it, diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index 9e8c7de..cc9864b 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -56,14 +56,16 @@ 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) + "type": "map", // agent | parallel | map | gate | reduce | approval | flow | loop | tournament | script | race | expand (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 + "branches": [ /* … */ ], // [parallel|race] static task list "from": ["audit"], // [reduce] phase ids to aggregate + "def": "{steps.plan.json}", // [expand|flow] inline fragment / dynamic sub-flow + "expandMode": "nested", // [expand] nested | graft "output": "json", // text | json (default: text) "model": "claude-sonnet-4-5", // per-phase model override "thinking": "high", // per-phase thinking override @@ -77,13 +79,17 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | 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). | +| `type` | all | `agent` | One of the **12** phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script, **race**, **expand**). | | `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?}]`. | +| `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | +| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | +| `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | +| `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | +| `maxNodes` | expand | `50` | Cap fragment phase count (1..100). | | `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. | @@ -99,12 +105,12 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `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 +> Gate-only control fields (`eval`, `onBlock`, score), 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`. +> the script fields (`run`/`input`/`timeout`), race/expand fields above, 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`. --- @@ -436,13 +442,30 @@ node --conditions=development --experimental-strip-types \ | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | -**Authoring notes** +**Authoring notes (kinds ↔ runes)** + +Import: `import { flow, agent, map, … } from "taskflow-dsl"`. Runes erase to Taskflow +JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). + +| JSON `type` | DSL rune(s) | Notes | +|-------------|-------------|--------| +| `agent` | `agent(task, opts?)` | templates → `{steps.*}` / `{item.*}` | +| `parallel` | `parallel([agent…])` | waits for all branches | +| `map` | `map(source, item => agent…)` | `over` + `as` | +| `gate` | `gate(up, opts?, task?)` · `gate.automated` · `gate.scored` | sugar → `eval` / `score` | +| `reduce` | `reduce([…], () => agent…)` | `from` | +| `approval` | `approval({ request })` | | +| `flow` | `subflow("name")` · `subflow.def(plan)` | use vs def | +| `loop` | `loop({ task, until?, … })` | | +| `tournament` | `tournament({ branches/variants, judge, … })` | | +| `script` | `script(run, opts?)` | string or argv array | +| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | -- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. -- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). -- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- `race([...])` does **not** support array destructure — bind as one phase: `const winner = race([...])`. - Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). +- Modular erase: new kind → `packages/taskflow-dsl/src/build/erase/kinds/.ts` + registry entry (see `docs/internal/modularization-0.2.0.md`). Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. diff --git a/packages/grok-taskflow/plugin/skills/taskflow/patterns.md b/packages/grok-taskflow/plugin/skills/taskflow/patterns.md index 51b0b47..0825bd1 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/patterns.md @@ -243,6 +243,63 @@ instead merges all variants — good for research synthesis, bad for decisions. --- +## Archetype 7: Race for latency (first approach wins) + +When several strategies can answer the same question and you care about **time +to first good answer** more than comparing quality, use `race` (not +`tournament`). Branches start together; the first successful completion becomes +the phase output. + +```jsonc +{ + "name": "quick-answer", + "budget": { "maxUSD": 0.5 }, + "phases": [ + { + "id": "answer", "type": "race", + "branches": [ + { "task": "Answer from local heuristics only: {args.q}", "agent": "executor" }, + { "task": "Answer after a short web/docs look: {args.q}", "agent": "researcher" } + ], + "final": true + } + ] +} +``` + +**Prefer tournament when** you need a judge to pick the *best* draft after all +variants finish. **Prefer parallel when** you need *every* branch's output +downstream (then `reduce`). + +## Archetype 8: Expand graft (planner fragment on the parent DAG) + +Planner emits a fragment; `expand` with `expandMode: "graft"` runs it and +promotes child phase states as `-` so a later phase can read +them. Use `nested` (or classic `flow{def}`) when you only need the fragment's +**final** output and do not want child ids on the parent. + +```jsonc +{ + "name": "plan-graft", + "phases": [ + { + "id": "plan", "type": "agent", "agent": "planner", "output": "json", + "task": "Emit a mini-flow JSON: {name, phases:[{id,type,agent,task,final?}…]} for the audit.", + "expect": { "type": "object", "required": ["phases"] } + }, + { + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", "dependsOn": ["plan"] + }, + { + "id": "wrap", "type": "agent", "agent": "writer", + "task": "Summarize grafted work. Child outputs may appear as steps.grow-* in the run state.", + "dependsOn": ["grow"], "final": true + } + ] +} +``` + ## Archetype 6: Incremental repo-watch audit (cross-run) Use for flows you'll re-run as the repo evolves. First run pays full price; diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 9aca916..7ab5297 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -69,7 +69,7 @@ task deserves level 3 — the higher levels are where taskflow pays for itself. | 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 | +| 4 | + `loop`, `tournament`, `flow{def}` / `expand`, `race` | the work itself is discovered at runtime; one shot is unreliable; try parallel approaches and keep the first win | | 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 @@ -165,12 +165,12 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` } ``` -### Phase types (10) +### Phase types (12) | type | meaning | details | |------|---------|---------| | `agent` | one subagent runs `task` | this file | -| `parallel` | run static `branches[]` concurrently | this file | +| `parallel` | run static `branches[]` concurrently (all complete) | 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 | @@ -179,6 +179,8 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | +| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -187,7 +189,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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. | +| `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/race/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/expand. 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`. | @@ -464,6 +466,55 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Race phases (first completed wins) + +A `race` phase runs static `branches[]` concurrently and **returns the first +branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, +default true). Use it when several approaches can answer the same question and +latency matters more than waiting for every branch (unlike `parallel`, which +waits for all, or `tournament`, which judges quality after all variants finish). + +- `branches` — **required**, at least two `{task, agent?}`. +- `cancelLosers` — optional boolean (default `true`). +- Output of the winning branch becomes the race phase output; a warning records + which branch won. + +```jsonc +{ + "id": "quick", "type": "race", + "branches": [ + { "task": "Answer with a short heuristic…", "agent": "executor" }, + { "task": "Answer with a thorough search…", "agent": "researcher" } + ], + "final": true +} +``` + +### Expand phases (dynamic fragment: nested or graft) + +An `expand` phase runs a **fragment Taskflow** from `def` (inline object, +phases array, or interpolated `{steps.plan.json}`). Two modes: + +| `expandMode` | Behavior | +|--------------|----------| +| `nested` (default) | Run as an isolated sub-flow (like `flow{def}`); child phase ids stay **off** the parent. | +| `graft` | After success, **promote** child phase states onto the parent as `-` so later phases can read `{steps.grow-leaf.output}`. | + +- `def` — **required** for expand. +- `maxNodes` — optional cap on fragment phase count (default 50, hard max 100). +- Dynamic validation + nesting caps match `flow{def}` (see `advanced.md`). +- Prefer `expand` when the planner fragment is a first-class kind; prefer + `flow` + `use` for saved reusable flows; prefer `flow` + `def` when you want + the classic nested sub-flow without graft promote. + +```jsonc +{ + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"], "final": true +} +``` + ### Budget (cost / token caps) Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it, diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index dde1146..9586b07 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -56,14 +56,16 @@ 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) + "type": "map", // agent | parallel | map | gate | reduce | approval | flow | loop | tournament | script | race | expand (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 + "branches": [ /* … */ ], // [parallel|race] static task list "from": ["audit"], // [reduce] phase ids to aggregate + "def": "{steps.plan.json}", // [expand|flow] inline fragment / dynamic sub-flow + "expandMode": "nested", // [expand] nested | graft "output": "json", // text | json (default: text) "model": "claude-sonnet-4-5", // per-phase model override "thinking": "high", // per-phase thinking override @@ -77,13 +79,17 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | 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). | +| `type` | all | `agent` | One of the **12** phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script, **race**, **expand**). | | `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?}]`. | +| `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | +| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | +| `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | +| `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | +| `maxNodes` | expand | `50` | Cap fragment phase count (1..100). | | `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. | @@ -99,12 +105,12 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `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 +> Gate-only control fields (`eval`, `onBlock`, score), 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`. +> the script fields (`run`/`input`/`timeout`), race/expand fields above, 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`. --- @@ -437,13 +443,30 @@ node --conditions=development --experimental-strip-types \ | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | -**Authoring notes** +**Authoring notes (kinds ↔ runes)** + +Import: `import { flow, agent, map, … } from "taskflow-dsl"`. Runes erase to Taskflow +JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). + +| JSON `type` | DSL rune(s) | Notes | +|-------------|-------------|--------| +| `agent` | `agent(task, opts?)` | templates → `{steps.*}` / `{item.*}` | +| `parallel` | `parallel([agent…])` | waits for all branches | +| `map` | `map(source, item => agent…)` | `over` + `as` | +| `gate` | `gate(up, opts?, task?)` · `gate.automated` · `gate.scored` | sugar → `eval` / `score` | +| `reduce` | `reduce([…], () => agent…)` | `from` | +| `approval` | `approval({ request })` | | +| `flow` | `subflow("name")` · `subflow.def(plan)` | use vs def | +| `loop` | `loop({ task, until?, … })` | | +| `tournament` | `tournament({ branches/variants, judge, … })` | | +| `script` | `script(run, opts?)` | string or argv array | +| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | -- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. -- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). -- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- `race([...])` does **not** support array destructure — bind as one phase: `const winner = race([...])`. - Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). +- Modular erase: new kind → `packages/taskflow-dsl/src/build/erase/kinds/.ts` + registry entry (see `docs/internal/modularization-0.2.0.md`). Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md b/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md index 51b0b47..0825bd1 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md @@ -243,6 +243,63 @@ instead merges all variants — good for research synthesis, bad for decisions. --- +## Archetype 7: Race for latency (first approach wins) + +When several strategies can answer the same question and you care about **time +to first good answer** more than comparing quality, use `race` (not +`tournament`). Branches start together; the first successful completion becomes +the phase output. + +```jsonc +{ + "name": "quick-answer", + "budget": { "maxUSD": 0.5 }, + "phases": [ + { + "id": "answer", "type": "race", + "branches": [ + { "task": "Answer from local heuristics only: {args.q}", "agent": "executor" }, + { "task": "Answer after a short web/docs look: {args.q}", "agent": "researcher" } + ], + "final": true + } + ] +} +``` + +**Prefer tournament when** you need a judge to pick the *best* draft after all +variants finish. **Prefer parallel when** you need *every* branch's output +downstream (then `reduce`). + +## Archetype 8: Expand graft (planner fragment on the parent DAG) + +Planner emits a fragment; `expand` with `expandMode: "graft"` runs it and +promotes child phase states as `-` so a later phase can read +them. Use `nested` (or classic `flow{def}`) when you only need the fragment's +**final** output and do not want child ids on the parent. + +```jsonc +{ + "name": "plan-graft", + "phases": [ + { + "id": "plan", "type": "agent", "agent": "planner", "output": "json", + "task": "Emit a mini-flow JSON: {name, phases:[{id,type,agent,task,final?}…]} for the audit.", + "expect": { "type": "object", "required": ["phases"] } + }, + { + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", "dependsOn": ["plan"] + }, + { + "id": "wrap", "type": "agent", "agent": "writer", + "task": "Summarize grafted work. Child outputs may appear as steps.grow-* in the run state.", + "dependsOn": ["grow"], "final": true + } + ] +} +``` + ## Archetype 6: Incremental repo-watch audit (cross-run) Use for flows you'll re-run as the repo evolves. First run pays full price; diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index f3392b7..aa92d25 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -59,7 +59,7 @@ task deserves level 3 — the higher levels are where taskflow pays for itself. | 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 | +| 4 | + `loop`, `tournament`, `flow{def}` / `expand`, `race` | the work itself is discovered at runtime; one shot is unreliable; try parallel approaches and keep the first win | | 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 @@ -156,12 +156,12 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` } ``` -### Phase types (10) +### Phase types (12) | type | meaning | details | |------|---------|---------| | `agent` | one subagent runs `task` | this file | -| `parallel` | run static `branches[]` concurrently | this file | +| `parallel` | run static `branches[]` concurrently (all complete) | 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 | @@ -170,6 +170,8 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | +| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -178,7 +180,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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. | +| `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/race/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/expand. 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`. | @@ -453,6 +455,55 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Race phases (first completed wins) + +A `race` phase runs static `branches[]` concurrently and **returns the first +branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, +default true). Use it when several approaches can answer the same question and +latency matters more than waiting for every branch (unlike `parallel`, which +waits for all, or `tournament`, which judges quality after all variants finish). + +- `branches` — **required**, at least two `{task, agent?}`. +- `cancelLosers` — optional boolean (default `true`). +- Output of the winning branch becomes the race phase output; a warning records + which branch won. + +```jsonc +{ + "id": "quick", "type": "race", + "branches": [ + { "task": "Answer with a short heuristic…", "agent": "executor" }, + { "task": "Answer with a thorough search…", "agent": "researcher" } + ], + "final": true +} +``` + +### Expand phases (dynamic fragment: nested or graft) + +An `expand` phase runs a **fragment Taskflow** from `def` (inline object, +phases array, or interpolated `{steps.plan.json}`). Two modes: + +| `expandMode` | Behavior | +|--------------|----------| +| `nested` (default) | Run as an isolated sub-flow (like `flow{def}`); child phase ids stay **off** the parent. | +| `graft` | After success, **promote** child phase states onto the parent as `-` so later phases can read `{steps.grow-leaf.output}`. | + +- `def` — **required** for expand. +- `maxNodes` — optional cap on fragment phase count (default 50, hard max 100). +- Dynamic validation + nesting caps match `flow{def}` (see `advanced.md`). +- Prefer `expand` when the planner fragment is a first-class kind; prefer + `flow` + `use` for saved reusable flows; prefer `flow` + `def` when you want + the classic nested sub-flow without graft promote. + +```jsonc +{ + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"], "final": true +} +``` + ### Budget (cost / token caps) Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it, diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index fba8119..0391ca8 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -56,14 +56,16 @@ 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) + "type": "map", // agent | parallel | map | gate | reduce | approval | flow | loop | tournament | script | race | expand (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 + "branches": [ /* … */ ], // [parallel|race] static task list "from": ["audit"], // [reduce] phase ids to aggregate + "def": "{steps.plan.json}", // [expand|flow] inline fragment / dynamic sub-flow + "expandMode": "nested", // [expand] nested | graft "output": "json", // text | json (default: text) "model": "claude-sonnet-4-5", // per-phase model override "thinking": "high", // per-phase thinking override @@ -77,13 +79,17 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | 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). | +| `type` | all | `agent` | One of the **12** phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script, **race**, **expand**). | | `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?}]`. | +| `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | +| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | +| `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | +| `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | +| `maxNodes` | expand | `50` | Cap fragment phase count (1..100). | | `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. | @@ -99,12 +105,12 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `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 +> Gate-only control fields (`eval`, `onBlock`, score), 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`. +> the script fields (`run`/`input`/`timeout`), race/expand fields above, 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`. --- @@ -440,13 +446,30 @@ node --conditions=development --experimental-strip-types \ | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | -**Authoring notes** +**Authoring notes (kinds ↔ runes)** + +Import: `import { flow, agent, map, … } from "taskflow-dsl"`. Runes erase to Taskflow +JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). + +| JSON `type` | DSL rune(s) | Notes | +|-------------|-------------|--------| +| `agent` | `agent(task, opts?)` | templates → `{steps.*}` / `{item.*}` | +| `parallel` | `parallel([agent…])` | waits for all branches | +| `map` | `map(source, item => agent…)` | `over` + `as` | +| `gate` | `gate(up, opts?, task?)` · `gate.automated` · `gate.scored` | sugar → `eval` / `score` | +| `reduce` | `reduce([…], () => agent…)` | `from` | +| `approval` | `approval({ request })` | | +| `flow` | `subflow("name")` · `subflow.def(plan)` | use vs def | +| `loop` | `loop({ task, until?, … })` | | +| `tournament` | `tournament({ branches/variants, judge, … })` | | +| `script` | `script(run, opts?)` | string or argv array | +| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | -- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. -- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). -- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- `race([...])` does **not** support array destructure — bind as one phase: `const winner = race([...])`. - Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). +- Modular erase: new kind → `packages/taskflow-dsl/src/build/erase/kinds/.ts` + registry entry (see `docs/internal/modularization-0.2.0.md`). Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. diff --git a/packages/pi-taskflow/skills/taskflow/patterns.md b/packages/pi-taskflow/skills/taskflow/patterns.md index 1891aba..15669d7 100644 --- a/packages/pi-taskflow/skills/taskflow/patterns.md +++ b/packages/pi-taskflow/skills/taskflow/patterns.md @@ -238,6 +238,63 @@ instead merges all variants — good for research synthesis, bad for decisions. --- +## Archetype 7: Race for latency (first approach wins) + +When several strategies can answer the same question and you care about **time +to first good answer** more than comparing quality, use `race` (not +`tournament`). Branches start together; the first successful completion becomes +the phase output. + +```jsonc +{ + "name": "quick-answer", + "budget": { "maxUSD": 0.5 }, + "phases": [ + { + "id": "answer", "type": "race", + "branches": [ + { "task": "Answer from local heuristics only: {args.q}", "agent": "executor" }, + { "task": "Answer after a short web/docs look: {args.q}", "agent": "researcher" } + ], + "final": true + } + ] +} +``` + +**Prefer tournament when** you need a judge to pick the *best* draft after all +variants finish. **Prefer parallel when** you need *every* branch's output +downstream (then `reduce`). + +## Archetype 8: Expand graft (planner fragment on the parent DAG) + +Planner emits a fragment; `expand` with `expandMode: "graft"` runs it and +promotes child phase states as `-` so a later phase can read +them. Use `nested` (or classic `flow{def}`) when you only need the fragment's +**final** output and do not want child ids on the parent. + +```jsonc +{ + "name": "plan-graft", + "phases": [ + { + "id": "plan", "type": "agent", "agent": "planner", "output": "json", + "task": "Emit a mini-flow JSON: {name, phases:[{id,type,agent,task,final?}…]} for the audit.", + "expect": { "type": "object", "required": ["phases"] } + }, + { + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", "dependsOn": ["plan"] + }, + { + "id": "wrap", "type": "agent", "agent": "writer", + "task": "Summarize grafted work. Child outputs may appear as steps.grow-* in the run state.", + "dependsOn": ["grow"], "final": true + } + ] +} +``` + ## Archetype 6: Incremental repo-watch audit (cross-run) Use for flows you'll re-run as the repo evolves. First run pays full price; diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index 9c2bde5..0502a2b 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -54,14 +54,16 @@ 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) + "type": "map", // agent | parallel | map | gate | reduce | approval | flow | loop | tournament | script | race | expand (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 + "branches": [ /* … */ ], // [parallel|race] static task list "from": ["audit"], // [reduce] phase ids to aggregate + "def": "{steps.plan.json}", // [expand|flow] inline fragment / dynamic sub-flow + "expandMode": "nested", // [expand] nested | graft "output": "json", // text | json (default: text) "model": "claude-sonnet-4-5", // per-phase model override "thinking": "high", // per-phase thinking override @@ -75,13 +77,17 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | 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). | +| `type` | all | `agent` | One of the **12** phase types (agent, parallel, map, gate, reduce, approval, flow, loop, tournament, script, **race**, **expand**). | | `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?}]`. | +| `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | +| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | +| `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | +| `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | +| `maxNodes` | expand | `50` | Cap fragment phase count (1..100). | | `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. | @@ -97,12 +103,12 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `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 +> Gate-only control fields (`eval`, `onBlock`, score), 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`. +> the script fields (`run`/`input`/`timeout`), race/expand fields above, 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`. --- @@ -477,13 +483,30 @@ node --conditions=development --experimental-strip-types \ | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | -**Authoring notes** +**Authoring notes (kinds ↔ runes)** + +Import: `import { flow, agent, map, … } from "taskflow-dsl"`. Runes erase to Taskflow +JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). + +| JSON `type` | DSL rune(s) | Notes | +|-------------|-------------|--------| +| `agent` | `agent(task, opts?)` | templates → `{steps.*}` / `{item.*}` | +| `parallel` | `parallel([agent…])` | waits for all branches | +| `map` | `map(source, item => agent…)` | `over` + `as` | +| `gate` | `gate(up, opts?, task?)` · `gate.automated` · `gate.scored` | sugar → `eval` / `score` | +| `reduce` | `reduce([…], () => agent…)` | `from` | +| `approval` | `approval({ request })` | | +| `flow` | `subflow("name")` · `subflow.def(plan)` | use vs def | +| `loop` | `loop({ task, until?, … })` | | +| `tournament` | `tournament({ branches/variants, judge, … })` | | +| `script` | `script(run, opts?)` | string or argv array | +| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | -- Import: `import { flow, agent, map, … } from "taskflow-dsl"`. - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. -- `race([agent(...), agent(...)])` — first branch to finish wins (`type: "race"`). -- `expand.nested(plan.json)` / `expand.graft(plan.json)` — dynamic fragment (`type: "expand"`); graft promotes child phase states onto the parent under `expandId-childId`. +- `race([...])` does **not** support array destructure — bind as one phase: `const winner = race([...])`. - Unbuilt `.tf.ts` must **not** be executed as a Node program (runes throw `TFDSL_ERASE_ONLY`). +- Modular erase: new kind → `packages/taskflow-dsl/src/build/erase/kinds/.ts` + registry entry (see `docs/internal/modularization-0.2.0.md`). Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 33f7145..4aa4a88 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -52,7 +52,7 @@ task deserves level 3 — the higher levels are where taskflow pays for itself. | 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 | +| 4 | + `loop`, `tournament`, `flow{def}` / `expand`, `race` | the work itself is discovered at runtime; one shot is unreliable; try parallel approaches and keep the first win | | 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 @@ -172,12 +172,12 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` } ``` -### Phase types (10) +### Phase types (12) | type | meaning | details | |------|---------|---------| | `agent` | one subagent runs `task` | this file | -| `parallel` | run static `branches[]` concurrently | this file | +| `parallel` | run static `branches[]` concurrently (all complete) | 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 | @@ -186,6 +186,8 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | +| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -194,7 +196,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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. | +| `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/race/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/expand. 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`. | @@ -477,6 +479,55 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` +### Race phases (first completed wins) + +A `race` phase runs static `branches[]` concurrently and **returns the first +branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, +default true). Use it when several approaches can answer the same question and +latency matters more than waiting for every branch (unlike `parallel`, which +waits for all, or `tournament`, which judges quality after all variants finish). + +- `branches` — **required**, at least two `{task, agent?}`. +- `cancelLosers` — optional boolean (default `true`). +- Output of the winning branch becomes the race phase output; a warning records + which branch won. + +```jsonc +{ + "id": "quick", "type": "race", + "branches": [ + { "task": "Answer with a short heuristic…", "agent": "executor" }, + { "task": "Answer with a thorough search…", "agent": "researcher" } + ], + "final": true +} +``` + +### Expand phases (dynamic fragment: nested or graft) + +An `expand` phase runs a **fragment Taskflow** from `def` (inline object, +phases array, or interpolated `{steps.plan.json}`). Two modes: + +| `expandMode` | Behavior | +|--------------|----------| +| `nested` (default) | Run as an isolated sub-flow (like `flow{def}`); child phase ids stay **off** the parent. | +| `graft` | After success, **promote** child phase states onto the parent as `-` so later phases can read `{steps.grow-leaf.output}`. | + +- `def` — **required** for expand. +- `maxNodes` — optional cap on fragment phase count (default 50, hard max 100). +- Dynamic validation + nesting caps match `flow{def}` (see `advanced.md`). +- Prefer `expand` when the planner fragment is a first-class kind; prefer + `flow` + `use` for saved reusable flows; prefer `flow` + `def` when you want + the classic nested sub-flow without graft promote. + +```jsonc +{ + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"], "final": true +} +``` + ### Budget (cost / token caps) Add a run-wide ceiling at the top level. When accumulated cost/tokens exceed it, diff --git a/skills-src/taskflow/patterns.md b/skills-src/taskflow/patterns.md index 7dd1c33..86f8ed3 100644 --- a/skills-src/taskflow/patterns.md +++ b/skills-src/taskflow/patterns.md @@ -243,6 +243,63 @@ instead merges all variants — good for research synthesis, bad for decisions. --- +## Archetype 7: Race for latency (first approach wins) + +When several strategies can answer the same question and you care about **time +to first good answer** more than comparing quality, use `race` (not +`tournament`). Branches start together; the first successful completion becomes +the phase output. + +```jsonc +{ + "name": "quick-answer", + "budget": { "maxUSD": 0.5 }, + "phases": [ + { + "id": "answer", "type": "race", + "branches": [ + { "task": "Answer from local heuristics only: {args.q}", "agent": "executor" }, + { "task": "Answer after a short web/docs look: {args.q}", "agent": "researcher" } + ], + "final": true + } + ] +} +``` + +**Prefer tournament when** you need a judge to pick the *best* draft after all +variants finish. **Prefer parallel when** you need *every* branch's output +downstream (then `reduce`). + +## Archetype 8: Expand graft (planner fragment on the parent DAG) + +Planner emits a fragment; `expand` with `expandMode: "graft"` runs it and +promotes child phase states as `-` so a later phase can read +them. Use `nested` (or classic `flow{def}`) when you only need the fragment's +**final** output and do not want child ids on the parent. + +```jsonc +{ + "name": "plan-graft", + "phases": [ + { + "id": "plan", "type": "agent", "agent": "planner", "output": "json", + "task": "Emit a mini-flow JSON: {name, phases:[{id,type,agent,task,final?}…]} for the audit.", + "expect": { "type": "object", "required": ["phases"] } + }, + { + "id": "grow", "type": "expand", "expandMode": "graft", + "def": "{steps.plan.json}", "dependsOn": ["plan"] + }, + { + "id": "wrap", "type": "agent", "agent": "writer", + "task": "Summarize grafted work. Child outputs may appear as steps.grow-* in the run state.", + "dependsOn": ["grow"], "final": true + } + ] +} +``` + ## Archetype 6: Incremental repo-watch audit (cross-run) Use for flows you'll re-run as the repo evolves. First run pays full price; From 7a687383a116f10069326a93533ee255d17f07e6 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 17:13:19 +0800 Subject: [PATCH 31/51] docs+refactor: mark S4 done in architecture; peel script/parallel/approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update rfc-0.2.0-architecture S0–S5 tracker (S4 ✅, S5 next) and continue runtime strangler: script spawn, parallel fan-out, and approval decision mapping live under runtime/phases/* for S5 preheat. --- docs/internal/modularization-0.2.0.md | 27 ++- docs/rfc-0.2.0-architecture.md | 36 ++-- packages/taskflow-core/src/runtime.ts | 190 ++++-------------- .../src/runtime/phases/approval.ts | 61 ++++++ .../src/runtime/phases/parallel.ts | 41 ++++ .../src/runtime/phases/script.ts | 157 +++++++++++++++ 6 files changed, 342 insertions(+), 170 deletions(-) create mode 100644 packages/taskflow-core/src/runtime/phases/approval.ts create mode 100644 packages/taskflow-core/src/runtime/phases/parallel.ts create mode 100644 packages/taskflow-core/src/runtime/phases/script.ts diff --git a/docs/internal/modularization-0.2.0.md b/docs/internal/modularization-0.2.0.md index d63e29a..dce7112 100644 --- a/docs/internal/modularization-0.2.0.md +++ b/docs/internal/modularization-0.2.0.md @@ -1,6 +1,6 @@ # Modularization notes (0.2.0) -Avoid monolith growth while S4 lands. Prefer **extract-by-kind / extract-by-phase** over more branches in giant files. +Avoid monolith growth while S4 lands and S5 preps. Prefer **extract-by-kind / extract-by-phase** over more branches in giant files. ## taskflow-dsl erase @@ -20,21 +20,34 @@ packages/taskflow-dsl/src/build/erase/ **Rule:** new phase kind → new file under `kinds/` + one entry in `KIND_HANDLERS`. Do not re-inflate `pipeline.ts`. -## taskflow-core runtime +## taskflow-core runtime (S5 strangler preheat) ``` -packages/taskflow-core/src/runtime.ts ← still large; strangler ongoing +packages/taskflow-core/src/runtime.ts ← facade + orchestration still large; peels continue packages/taskflow-core/src/runtime/phases/ -├─ race.ts ← race execution -└─ expand.ts ← pure helpers (mode, maxNodes, prefix, promote) +├─ race.ts ← executeRaceBranches +├─ expand.ts ← pure helpers (mode, maxNodes, prefix, promote) +├─ script.ts ← runScriptCommand + result → PhaseState +├─ parallel.ts ← executeParallelBranches (inject runFanout + merge) +└─ approval.ts ← approvalDecisionToPhaseState ``` -Expand **execution** still shares the `flow|expand` block in `runtime.ts` (sub-run + budget clamp). Pure fragment transforms live in `phases/expand.ts`. +| Kind | In `phases/` | Still in `runtime.ts` | +|------|--------------|------------------------| +| race | ✅ execute | thin dispatch + cache | +| expand | ✅ pure helpers | flow\|expand sub-run block | +| script | ✅ spawn + map | interpolate + cache | +| parallel | ✅ execute | branch resolve + cache | +| approval | ✅ decision map | message + requestApproval | +| map / loop / tournament / agent / gate / reduce / flow | ⬜ | full bodies | -**Rule:** next peel candidates for `runtime.ts` — loop, tournament, map/parallel branches, approval — each into `runtime/phases/.ts` with a thin dispatch in `executePhaseInner`. +**Rule:** next peels — `map`, `loop`, `tournament`, then agent/gate shared path. Each file takes injectables (`runOne`, `runFanout`, cache hooks); one-liner dispatch in `executePhaseInner`. + +**S5 link:** kind modules make event-kernel handlers easier to add without editing a 3k-line unit. `race`/`expand` stay imperative until kernel step handlers exist. ## Invariants 1. Kind emit must go through `register(ctx, draft)` so dependsOn/final/id stripping stay consistent. 2. Core must never import `taskflow-dsl`. 3. Dynamic expand uses `MAX_DYNAMIC_PHASES` from `schema.ts` (import it; do not re-define). +4. Script timeouts and spawn errors remain uncached; non-zero exit remains cacheable. diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index 018e4d2..a8627fc 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -1,12 +1,13 @@ # RFC: taskflow 0.2.0 系统架构总纲(Master Architecture) -> Status: **Active** · Draft v1 2026-07-08 · **Implementation progress updated 2026-07-09** +> Status: **Active** · Draft v1 2026-07-08 · **Implementation progress updated 2026-07-09** (S4 ✅) > 层级:这是凌驾于 [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md)(DSL 前端)和 > replay 实现之上的**系统架构总纲**。定调 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 路线之争)。 +> [`rfc-0.2.0-three-compile-routes.md`](./rfc-0.2.0-three-compile-routes.md)(DSL 路线之争)、 +> [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) / [`rfc-0.2.0-s4-decision-record.md`](./rfc-0.2.0-s4-decision-record.md)(S4 表面)。 > > **本文回应两个已发现的架构级矛盾**(§0),并把 5 个已拍板的决策(§1)固化成一套 > 可发布、向后兼容的内核替换方案。 @@ -17,12 +18,12 @@ > |------|------|----------| > | **S0** | ✅ | `compileTaskflowToFlowIR` + `hashFlowIR` → `ir:<64-hex>`;`usedFallbackHash: false`(well-formed IR) | > | **S1** | ✅ | `exec/{events,fold}`;runtime 全量 decision emit;fold 差分 + kill-9 rebuild 测试 | -> | **S2** | ✅ | `exec/{step,driver}` **全部 10 kind** + P0 硬化(budget/deps/eval/json/dynamic def/recursion);高级特性 fall-back;默认 OFF | +> | **S2** | ✅ | `exec/{step,driver}` **全部 10 kind** + P0 硬化;高级特性 fall-back;默认 OFF。**`race`/`expand` 仍走 imperative**(未进 `EVENT_KERNEL_PHASE_TYPES`) | > | **S3** | ✅ | `replayRun`;`taskflow_replay` MCP;pi `action=replay` + `/tf replay`;golden + import-lint | -> | **S4** | ⬜ | `taskflow-dsl` 包(下一主线) | -> | **S5** | ⬜ | 全 kind 差分绿 → kernel 默认 ON | +> | **S4** | ✅ | 包 `taskflow-dsl` live:`build`/`check`/`decompile`/`new`;erase `kinds/*` 注册表;parity 测绿。引擎 **12 PHASE_TYPES**(+`race`/`expand`);gate sugar;skills 对齐 | +> | **S5** | ⬜ **下一主线** | 全 kind 差分绿 → kernel 默认 ON;退休旧 `executePhase` 主路径。预热:`runtime/phases/*` strangler | > -> North-star 口号已改为 **compiled · resumable · incremental · replayable-for-what-if**。 +> North-star 口号:**compiled · resumable · incremental · replayable-for-what-if**。 --- @@ -34,7 +35,7 @@ 4. **绞杀式迁移**:新旧内核并存 + 差分 + 逐 kind flip(S0–S5 每步可发布)。 5. **replay 重新纳入 scope(S3 ✅)**:兑现 0.1.7 承诺;`replayRun` + MCP/pi 已落地。 6. **向后兼容是硬约束**:JSON flow / RunState / `/tf` 语义不破坏。 -7. **DSL 是平行前端(S4 ⬜)**:`.tf.ts → build → Taskflow → FlowIR`;新包 `taskflow-dsl`。 +7. **DSL 是平行前端(S4 ✅)**:`.tf.ts → build → Taskflow → FlowIR`;包 `taskflow-dsl` 已落地(CLI-first;无新 MCP)。 --- @@ -47,7 +48,7 @@ - **DSL RFC v2 §0.2** 写的是"`taskflow build` → FlowIR,runtime 执行 FlowIR"——**S5 才把默认执行翻到 FlowIR/driver**。 - **overstory roadmap §6.4** 的"大爆炸"风险由 **Q7 绞杀迁移**化解。 -→ 本 RFC 拍板:FlowIR **是**规范执行/编译真源方向(Q2=B),用绞杀守住可发布性(Q7)。S0–S3 已部分兑现;S4–S5 继续。 +→ 本 RFC 拍板:FlowIR **是**规范执行/编译真源方向(Q2=B),用绞杀守住可发布性(Q7)。**S0–S4 已兑现**;**S5(kernel 默认 ON)为下一主线**。 ### 矛盾 2:north-star 丢弃了 replay,但 0.1.7 公开承诺过 @@ -290,12 +291,12 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 |---|---|---|---|---| | **S0** ✅ | 自研 `flowir/{schema,compile,hash,cond}`;`usedFallbackHash→false`;runtime 仍执行 Taskflow | hash byte-determinism + 敏感度(`flowir-*.test.ts`) | cache-key 更精准;FlowIR 成规范 | roadmap M1 剩余(自建版) | | **S1** ✅ | 加 `exec/{events,fold}`;旧 executePhase 全量 decision emit;`fold(log)` 对齐 RunState | 差分 + kill-9 rebuild 测试 | **trace 完整 + 事件溯源(F3 溶解)** | F3 / Q8 | -| **S2** ✅ | 建 `exec/{driver,step}`;**全部 10 kind**(复杂路径简化版;score/reflexion 等仍可走 imperative) | parity + s2-complete 测试;默认 OFF | 每 kind 可 flip;默认 ON 在 S5 | Q7 | +| **S2** ✅ | 建 `exec/{driver,step}`;**原 10 kind**(复杂路径简化版;score/reflexion 等仍可走 imperative) | parity + s2-complete 测试;默认 OFF | 每 kind 可 flip;默认 ON 在 S5 | Q7 | | **S3** ✅ | `replayRun()` + `taskflow_replay` + `/tf replay` + 黄金 fixtures + import-lint | 未改旋钮 → 全 reused;改阈值/budget 可测 | **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 绿 | 内核替换完成 | — | +| **S4** ✅ | 包 `taskflow-dsl`:`.tf.ts→build→Taskflow→compile→FlowIR` + `check`/`new`/`decompile`;+ Horizon B `race`/`expand` 引擎 kind | DSL parity(含 map+json+templates)`hashFlowIR` 相等;`test:dsl` 绿 | DSL 作者面 + 12 kind JSON | DSL RFC v2 / s4-mvp | +| **S5** ⬜ | 全 kind 差分绿 → 新 driver 设默认;退休旧 executePhase;可选 race/expand kernel handlers | 全量回归 + e2e 绿 | 内核替换完成 | — | -**顺序优雅之处**:`rates.ts`(F1) 落在 S1/S2(cost 进 event/usage);F3 在 S1 自然溶解;**replay(S3) 与 DSL(S4) 相互独立可并行**;每个 S 独立可发布、可停在任意阶段。 +**顺序优雅之处**:`rates.ts`(F1) 落在 S1/S2(cost 进 event/usage);F3 在 S1 自然溶解;replay(S3) 与 DSL(S4) 已并行落地;**下一主线是 S5**。S5 预热:`runtime/phases/.ts` strangler(避免 `runtime.ts` 巨石阻 flip)。 --- @@ -308,7 +309,7 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 | `taskflow-hosts` | codex-runner 补 cost(接 rates);其余 runner 无感 | | `pi-taskflow` | 新增 `/tf replay` 命令 + 渲染;绞杀开关的 host 侧 flag | | `codex/claude/opencode/grok-taskflow` | 无感(经 mcp-core 共享) | -| **`taskflow-dsl`(新)** | rune 类型 + build/check/decompile;依赖 core + typescript | +| **`taskflow-dsl`(新 · S4 ✅)** | rune 类型 + erase kinds 注册表 + build/check/decompile/cli;依赖 core + typescript;**不**依赖 runtime/exec | --- @@ -328,7 +329,8 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 - **S0** ✅:`hashFlowIR` 确定性 + 敏感度。 - **S1** ✅(测试 oracle):跑完 → 只保留 event log → `foldEvents` 重建 phase 终端状态(`fold-kill9-rebuild.test.ts`)。 - **S3** ✅:未改旋钮 replay → 全 `reused`;阈值/budget 覆盖(`replay.test.ts`)。 -- **S5** ⬜:增量重算成本比 —— 周一 $6/8 agents → 周二改 1 文件 → ≤2 节点 $0.40(旗舰 demo)。 +- **S4** ✅:`packages/taskflow-dsl/test/*` parity + kinds coverage;skills 与 12 kind 对齐。 +- **S5** ⬜:增量重算成本比 —— 周一 $6/8 agents → 周二改 1 文件 → ≤2 节点 $0.40(旗舰 demo);+ kernel 默认 ON 全量回归。 --- @@ -345,11 +347,11 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 ## §13. 待定 / 已决 -1. **`build` AST transform 实现选型**(S4):tsc transformer API / Babel / 自研轻量解析(DSL RFC v2 §C)— **仍待定**。 +1. **`build` AST transform 实现选型**(S4):**已决(落地)** — TypeScript compiler API(`typescript` package)AST erase,非 Babel;见 `taskflow-dsl/src/build/erase/*`。 2. **replay 命名**:**已决** — 模块 `replay.ts`;用户面 `/tf replay` + `taskflow_replay`;口号 **replayable-for-what-if**。 3. **event log 存储**:**已决(S1)** — 与 `trace.jsonl` 同形状;`Event = TraceEvent & { v }`;`upgradeTraceEvent` 读旧行。 -4. **绞杀开关的默认策略**:**已决(S2 切片)** — 默认 OFF;`RuntimeDeps.eventKernel` 或 `PI_TASKFLOW_EVENT_KERNEL=1|true`;显式 `false` 覆盖 env。 -5. **S3 与 S4 并行 vs 串行**:S3 已落地;**S4 为下一主线**(与 S2 剩余 kind 可并行)。 +4. **绞杀开关的默认策略**:**已决(S2 切片)** — 默认 OFF;`RuntimeDeps.eventKernel` 或 `PI_TASKFLOW_EVENT_KERNEL=1|true`;显式 `false` 覆盖 env。**S5 再翻默认 ON。** +5. **S3 与 S4 并行 vs 串行**:**已决(均已落地)** — 下一主线 **S5**;S5 预热为 runtime phases 模块化 + race/expand kernel handlers(可选)。 --- diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 00f6583..055e15c 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -1687,9 +1687,10 @@ async function executePhaseInner( return ps; } - // script — zero-token shell command. Spawns a child process, pipes - // interpolated `input` to stdin, captures stdout as the phase output. + // script — zero-token shell (spawn/timeout/size caps in runtime/phases/script.ts) if (type === "script") { + const { runScriptCommand, scriptResultToPhaseState, scriptSpawnErrorToPhaseState } = + await import("./runtime/phases/script.ts"); const cmd = phase.run; if (!cmd) { return { @@ -1700,18 +1701,15 @@ async function executePhaseInner( usage: emptyUsage(), }; } - // Interpolate the command. // Array form: interpolate each element (safe — schema rejects placeholders in string form). // String form: skip interpolation — schema already guarantees no {placeholders}. const interpRun = Array.isArray(cmd) ? cmd.map((s) => interpolate(s, ctx)) - : [{ text: cmd, missing: [] }]; - // Warn unresolved references. + : [{ text: cmd, missing: [] as string[] }]; for (const r of interpRun) { if (r.missing.length) warnUnresolvedRefs(phase.id, r.missing); } const interpRunText = interpRun.map((r) => r.text); - // Interpolate stdin input if provided. const stdinInterp = phase.input !== undefined ? interpolate(phase.input, ctx) : undefined; if (stdinInterp?.missing.length) warnUnresolvedRefs(phase.id, stdinInterp.missing); const stdinInput = stdinInterp?.text; @@ -1721,122 +1719,36 @@ async function executePhaseInner( const cached = cachedPhase(cc, ck); if (cached) return cached; - const MAX_STDOUT = 1_048_576; // 1 MB cap - const SCRIPT_TIMEOUT_MS = (phase.timeout ?? 60_000); // default 60 s, configurable via phase.timeout - const SIGKILL_GRACE_MS = 5_000; // grace period after SIGTERM before SIGKILL - + const SCRIPT_TIMEOUT_MS = phase.timeout ?? 60_000; + const reads = readRefs.length ? readRefsToReads(readRefs, state) : undefined; try { - const { spawn } = await import("node:child_process"); - const result = await new Promise<{ stdout: string; stderr: string; code: number | null; stdoutOversize: boolean; timedOut: boolean }>((resolve, reject) => { - // Array command → direct spawn (safe); string → shell (rejected at validation if it has placeholders). - const child = Array.isArray(cmd) - ? spawn(interpRunText[0], interpRunText.slice(1), { - cwd: effCwd, - env: process.env, - shell: false, - signal: deps.signal, - }) - : spawn(interpRunText[0], [], { - cwd: effCwd, - env: process.env, - shell: true, - signal: deps.signal, - }); - - let stdout = ""; - let stderr = ""; - let stdoutOversize = false; - let timedOut = false; - child.stdout?.on("data", (d: Buffer) => { - if (stdout.length < MAX_STDOUT) { - const need = MAX_STDOUT - stdout.length; - stdout += d.toString().slice(0, need); - if (stdout.length >= MAX_STDOUT) stdoutOversize = true; - } - }); - child.stderr?.on("data", (d: Buffer) => { - if (stderr.length < 500) { - stderr += d.toString().slice(0, 500 - stderr.length); - } - }); - - // Timeout guard: SIGTERM first, then SIGKILL after grace period. - let sigkillTimer: ReturnType | undefined; - const timer = setTimeout(() => { - timedOut = true; - child.kill("SIGTERM"); - // SIGKILL fallback if process ignores SIGTERM. - sigkillTimer = setTimeout(() => { - try { child.kill("SIGKILL"); } catch { /* already dead */ } - }, SIGKILL_GRACE_MS); - }, SCRIPT_TIMEOUT_MS); - - child.on("error", (err) => { - clearTimeout(timer); - clearTimeout(sigkillTimer); - reject(err); - }); - child.on("close", (code) => { - clearTimeout(timer); - clearTimeout(sigkillTimer); - resolve({ stdout, stderr, code, stdoutOversize, timedOut }); - }); - - if (stdinInput !== undefined) { - child.stdin?.on("error", () => {}); // swallow EPIPE when child closes stdin early - child.stdin?.write(stdinInput); - child.stdin?.end(); - } + const result = await runScriptCommand({ + interpRunText, + arrayForm: Array.isArray(cmd), + cwd: effCwd, + signal: deps.signal, + stdinInput, + timeoutMs: SCRIPT_TIMEOUT_MS, }); - - if (result.code !== 0 || result.timedOut) { - const ps: PhaseState = { - id: phase.id, - status: "failed", - output: result.stdout, - error: result.timedOut - ? `Script timed out after ${SCRIPT_TIMEOUT_MS}ms` - : `Script exited with code ${result.code}${result.stderr ? ": " + result.stderr.slice(0, 500) : ""}${result.stdoutOversize ? " [stdout truncated at 1 MB]" : ""}`, - timedOut: result.timedOut || undefined, - usage: emptyUsage(), - inputHash, - endedAt: Date.now(), - }; - if (readRefs.length) ps.reads = readRefsToReads(readRefs, state); - // Non-zero exit: cache (deterministic failure). Timeout: don't cache (transient, like spawn errors). - if (!result.timedOut) recordCache(cc, ps); - return ps; - } - - const ps: PhaseState = { - id: phase.id, - status: "done", - output: result.stdout.trimEnd() + (result.stdoutOversize ? "\n[stdout truncated at 1 MB]" : ""), - usage: emptyUsage(), + const ps = scriptResultToPhaseState(phase, result, { inputHash, - endedAt: Date.now(), - }; - if (readRefs.length) ps.reads = readRefsToReads(readRefs, state); - recordCache(cc, ps); + timeoutMs: SCRIPT_TIMEOUT_MS, + reads, + }); + // Non-zero exit: cache (deterministic). Timeout/spawn: don't cache (transient). + if (ps.status === "done" || (ps.status === "failed" && !ps.timedOut)) { + recordCache(cc, ps); + } return ps; } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - // Note: intentionally NOT cached — spawn errors (ENOENT, permission denied, etc.) - // are treated as transient. The phase will re-execute on resume/retry. - const ps: PhaseState = { - id: phase.id, - status: "failed", - error: `Script error: ${msg}`, - usage: emptyUsage(), - inputHash, - endedAt: Date.now(), - }; - if (readRefs.length) ps.reads = readRefsToReads(readRefs, state); - return ps; + // Spawn errors intentionally NOT cached — re-execute on resume/retry. + return scriptSpawnErrorToPhaseState(phase.id, err, { inputHash, reads }); } } + // parallel — all branches; merge via shared mergePhaseState (phases/parallel.ts) if (type === "parallel") { + const { executeParallelBranches } = await import("./runtime/phases/parallel.ts"); const branches = (phase.branches ?? []).map((b) => { const r = interpolate(b.task, ctx); return { @@ -1849,9 +1761,11 @@ async function executePhaseInner( const cached = cachedPhase(cc, ck); if (cached) return cached; - const results = await runFanout(branches); - const ps = mergePhaseState(phase.id, results, inputHash, parseJson); - if (readRefs.length) ps.reads = readRefsToReads(readRefs, state); + const ps = await executeParallelBranches(phase, branches, runFanout, mergePhaseState, { + inputHash, + parseJson, + reads: readRefs.length ? readRefsToReads(readRefs, state) : undefined, + }); recordCache(cc, ps); return ps; } @@ -1974,7 +1888,9 @@ async function executePhaseInner( return ps; } + // approval — HITL pause (decision → PhaseState in runtime/phases/approval.ts) if (type === "approval") { + const { approvalDecisionToPhaseState } = await import("./runtime/phases/approval.ts"); const readRefs: string[] = []; const ctx = buildInterpolationContext(state, previousOutput, undefined, (ref) => readRefs.push(ref)); const message = interpolate(phase.task ?? "Approve to continue?", ctx).text; @@ -1983,39 +1899,21 @@ async function executePhaseInner( const cached = cachedPhase(cc, ck); if (cached) return cached; - // Non-interactive (headless/CI/detached): auto-REJECT, fail-open, but record it. - // Approval gates are safety boundaries — bypassing them silently in CI would - // let unreviewed work ship. Detached/CI runs must not bypass approval gates. + const reads = readRefsToReads(readRefs, state); + // Non-interactive (headless/CI/detached): auto-REJECT — safety boundary, never bypass. if (!deps.requestApproval) { - return { - id: phase.id, - status: "done", - output: "(auto-rejected: no interactive approver available)", - approval: { decision: "reject", auto: true }, - gate: { verdict: "block", reason: "(auto-rejected: no interactive approver available)" }, - usage: emptyUsage(), + return approvalDecisionToPhaseState(phase.id, { decision: "reject" }, { inputHash, - reads: readRefsToReads(readRefs, state), - endedAt: Date.now(), - }; - } - const decision = await deps.requestApproval({ phaseId: phase.id, message, upstream: previousOutput }); - const note = decision.note?.trim(); - const ps: PhaseState = { - id: phase.id, - status: "done", - output: note || `(${decision.decision})`, - approval: { decision: decision.decision, note }, - usage: emptyUsage(), - inputHash, - reads: readRefsToReads(readRefs, state), - endedAt: Date.now(), - }; - // A rejection halts the flow via the same mechanism as a blocking gate. - if (decision.decision === "reject") { - ps.gate = { verdict: "block", reason: note || "Rejected by user" }; + reads, + auto: true, + }); } - return ps; + const decision = await deps.requestApproval({ + phaseId: phase.id, + message, + upstream: previousOutput, + }); + return approvalDecisionToPhaseState(phase.id, decision, { inputHash, reads }); } if (type === "flow" || type === "expand") { diff --git a/packages/taskflow-core/src/runtime/phases/approval.ts b/packages/taskflow-core/src/runtime/phases/approval.ts new file mode 100644 index 0000000..5910ba3 --- /dev/null +++ b/packages/taskflow-core/src/runtime/phases/approval.ts @@ -0,0 +1,61 @@ +/** + * Approval phase — human-in-the-loop pause (approve / reject / edit). + */ + +import type { PhaseState } from "../../store.ts"; +import { emptyUsage } from "../../usage.ts"; + +export interface ApprovalDecision { + decision: "approve" | "reject" | "edit"; + note?: string; +} + +export interface ApprovalRequest { + phaseId: string; + message: string; + upstream?: string; +} + +/** + * Build PhaseState for an approval outcome (interactive or auto-reject). + */ +export function approvalDecisionToPhaseState( + phaseId: string, + decision: ApprovalDecision, + opts: { + inputHash: string; + reads?: PhaseState["reads"]; + /** When true, mark auto-reject (no interactive approver). */ + auto?: boolean; + }, +): PhaseState { + if (opts.auto) { + return { + id: phaseId, + status: "done", + output: "(auto-rejected: no interactive approver available)", + approval: { decision: "reject", auto: true }, + gate: { verdict: "block", reason: "(auto-rejected: no interactive approver available)" }, + usage: emptyUsage(), + inputHash: opts.inputHash, + reads: opts.reads, + endedAt: Date.now(), + }; + } + + const note = decision.note?.trim(); + const ps: PhaseState = { + id: phaseId, + status: "done", + output: note || `(${decision.decision})`, + approval: { decision: decision.decision, note }, + usage: emptyUsage(), + inputHash: opts.inputHash, + reads: opts.reads, + endedAt: Date.now(), + }; + if (decision.decision === "reject") { + ps.gate = { verdict: "block", reason: note || "Rejected by user" }; + } + return ps; +} diff --git a/packages/taskflow-core/src/runtime/phases/parallel.ts b/packages/taskflow-core/src/runtime/phases/parallel.ts new file mode 100644 index 0000000..74b9c6c --- /dev/null +++ b/packages/taskflow-core/src/runtime/phases/parallel.ts @@ -0,0 +1,41 @@ +/** + * Parallel phase — wait for all branches (static fan-out). + * Scheduling stays injected (`runFanout`); this module only shapes the call. + */ + +import type { Phase } from "../../schema.ts"; +import type { RunResult } from "../../runner-core.ts"; +import type { PhaseState } from "../../store.ts"; + +export interface ParallelBranch { + agent: string; + task: string; +} + +export interface ParallelFanout { + (branches: ParallelBranch[]): Promise; +} + +export interface ParallelMerge { + (phaseId: string, results: RunResult[], inputHash: string, parseJson: boolean): PhaseState; +} + +/** + * Run all parallel branches and merge into one PhaseState. + */ +export async function executeParallelBranches( + phase: Phase, + branches: ParallelBranch[], + runFanout: ParallelFanout, + mergePhaseState: ParallelMerge, + opts: { + inputHash: string; + parseJson: boolean; + reads?: PhaseState["reads"]; + }, +): Promise { + const results = await runFanout(branches); + const ps = mergePhaseState(phase.id, results, opts.inputHash, opts.parseJson); + if (opts.reads) ps.reads = opts.reads; + return ps; +} diff --git a/packages/taskflow-core/src/runtime/phases/script.ts b/packages/taskflow-core/src/runtime/phases/script.ts new file mode 100644 index 0000000..82fb87a --- /dev/null +++ b/packages/taskflow-core/src/runtime/phases/script.ts @@ -0,0 +1,157 @@ +/** + * Script phase — zero-token shell command execution. + * Isolated from runtime.ts so S5 strangler can flip kinds without growing the monolith. + */ + +import type { Phase } from "../../schema.ts"; +import type { PhaseState } from "../../store.ts"; +import { emptyUsage } from "../../usage.ts"; + +const MAX_STDOUT = 1_048_576; // 1 MB cap +const SIGKILL_GRACE_MS = 5_000; + +export interface ScriptRunResult { + stdout: string; + stderr: string; + code: number | null; + stdoutOversize: boolean; + timedOut: boolean; +} + +/** + * Spawn the script command and capture stdout/stderr with timeout + size caps. + */ +export async function runScriptCommand(opts: { + /** Interpolated argv (array form) or single shell string as [cmd]. */ + interpRunText: string[]; + /** Original `run` shape: array → no shell; string → shell true. */ + arrayForm: boolean; + cwd: string; + signal?: AbortSignal; + stdinInput?: string; + timeoutMs: number; +}): Promise { + const { spawn } = await import("node:child_process"); + const { interpRunText, arrayForm, cwd, signal, stdinInput, timeoutMs } = opts; + + return new Promise((resolve, reject) => { + const child = arrayForm + ? spawn(interpRunText[0], interpRunText.slice(1), { + cwd, + env: process.env, + shell: false, + signal, + }) + : spawn(interpRunText[0], [], { + cwd, + env: process.env, + shell: true, + signal, + }); + + let stdout = ""; + let stderr = ""; + let stdoutOversize = false; + let timedOut = false; + child.stdout?.on("data", (d: Buffer) => { + if (stdout.length < MAX_STDOUT) { + const need = MAX_STDOUT - stdout.length; + stdout += d.toString().slice(0, need); + if (stdout.length >= MAX_STDOUT) stdoutOversize = true; + } + }); + child.stderr?.on("data", (d: Buffer) => { + if (stderr.length < 500) { + stderr += d.toString().slice(0, 500 - stderr.length); + } + }); + + let sigkillTimer: ReturnType | undefined; + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGTERM"); + sigkillTimer = setTimeout(() => { + try { + child.kill("SIGKILL"); + } catch { + /* already dead */ + } + }, SIGKILL_GRACE_MS); + }, timeoutMs); + + child.on("error", (err) => { + clearTimeout(timer); + clearTimeout(sigkillTimer); + reject(err); + }); + child.on("close", (code) => { + clearTimeout(timer); + clearTimeout(sigkillTimer); + resolve({ stdout, stderr, code, stdoutOversize, timedOut }); + }); + + if (stdinInput !== undefined) { + child.stdin?.on("error", () => {}); // swallow EPIPE when child closes stdin early + child.stdin?.write(stdinInput); + child.stdin?.end(); + } + }); +} + +/** Map a successful/failed script process result to PhaseState. */ +export function scriptResultToPhaseState( + phase: Phase, + result: ScriptRunResult, + opts: { + inputHash: string; + timeoutMs: number; + reads?: PhaseState["reads"]; + }, +): PhaseState { + if (result.code !== 0 || result.timedOut) { + const ps: PhaseState = { + id: phase.id, + status: "failed", + output: result.stdout, + error: result.timedOut + ? `Script timed out after ${opts.timeoutMs}ms` + : `Script exited with code ${result.code}${result.stderr ? ": " + result.stderr.slice(0, 500) : ""}${result.stdoutOversize ? " [stdout truncated at 1 MB]" : ""}`, + timedOut: result.timedOut || undefined, + usage: emptyUsage(), + inputHash: opts.inputHash, + endedAt: Date.now(), + }; + if (opts.reads) ps.reads = opts.reads; + return ps; + } + + const ps: PhaseState = { + id: phase.id, + status: "done", + output: result.stdout.trimEnd() + (result.stdoutOversize ? "\n[stdout truncated at 1 MB]" : ""), + usage: emptyUsage(), + inputHash: opts.inputHash, + endedAt: Date.now(), + }; + if (opts.reads) ps.reads = opts.reads; + return ps; +} + +/** Spawn error → failed phase (not cacheable — transient). */ +export function scriptSpawnErrorToPhaseState( + phaseId: string, + err: unknown, + opts: { inputHash: string; reads?: PhaseState["reads"] }, +): PhaseState { + const msg = err instanceof Error ? err.message : String(err); + const ps: PhaseState = { + id: phaseId, + status: "failed", + error: `Script error: ${msg}`, + usage: emptyUsage(), + inputHash: opts.inputHash, + endedAt: Date.now(), + }; + if (opts.reads) ps.reads = opts.reads; + return ps; +} From 9854ac4208e00d1f19467518079b554e368f28db Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 17:17:39 +0800 Subject: [PATCH 32/51] docs+fix: full claim-vs-impl alignment for release/0.2.0 Honest cancelLosers (reserved/ignored + race warning), fix S4 toolchain docs to TypeScript AST (not ts-morph), refresh 12-kind/kernel-10 comments, package and test counts, north-star flagship as S5 gate, and add docs/internal/claim-vs-impl-0.2.0.md verification ledger. Regenerate skills. --- AGENTS.md | 4 +- README.md | 8 +-- docs/0.2.0-north-star.md | 16 +++--- docs/internal/claim-vs-impl-0.2.0.md | 56 +++++++++++++++++++ docs/rfc-0.2.0-architecture.md | 2 +- docs/rfc-0.2.0-s4-decision-record.md | 6 +- docs/rfc-0.2.0-s4-mvp.md | 21 ++++--- .../plugin/skills/taskflow/SKILL.md | 13 +++-- .../plugin/skills/taskflow/configuration.md | 2 +- .../plugin/skills/taskflow/SKILL.md | 13 +++-- .../plugin/skills/taskflow/configuration.md | 2 +- .../plugin/skills/taskflow/SKILL.md | 13 +++-- .../plugin/skills/taskflow/configuration.md | 2 +- .../plugin/skills/taskflow/SKILL.md | 13 +++-- .../plugin/skills/taskflow/configuration.md | 2 +- packages/pi-taskflow/skills/taskflow/SKILL.md | 13 +++-- .../skills/taskflow/configuration.md | 2 +- packages/taskflow-core/src/exec/step.ts | 4 +- packages/taskflow-core/src/flowir/schema.ts | 6 +- packages/taskflow-core/src/runtime.ts | 5 +- .../taskflow-core/src/runtime/phases/race.ts | 12 +++- packages/taskflow-core/src/schema.ts | 7 ++- pnpm-workspace.yaml | 2 +- skills-src/taskflow/configuration.md | 2 +- skills-src/taskflow/core.md | 13 +++-- 25 files changed, 156 insertions(+), 83 deletions(-) create mode 100644 docs/internal/claim-vs-impl-0.2.0.md diff --git a/AGENTS.md b/AGENTS.md index 026a074..3ec686d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,7 +167,7 @@ tsconfig.base.json ← shared compiler options; per-package tsconfig.buil ## Development Commands ```bash -pnpm install # links the eight workspace packages (+ website) +pnpm install # links the nine 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 @@ -176,7 +176,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 eight packages +pnpm run build # emit dist/*.js + .d.ts for all nine 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) diff --git a/README.md b/README.md index 28887b9..1f95270 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ MIT license zero runtime dependencies CI status - 1140 tests + 1400+ tests dogfooded runs on Pi, Codex, Claude Code, OpenCode, and Grok Build

@@ -894,12 +894,12 @@ Copy one into `.pi/taskflows/.json` (or `~/.pi/agent/taskflows/`) and it r
-**0 runtime dependencies** · **1140 tests** · **12 phase types** · **shared context tree** · **cross-session resume** · **cross-run memoization** · **per-item map caching** · **incremental recompute** · **FlowIR compile seam** · **detached execution** · **`compile` Mermaid renderer** · **~9k LOC runtime** +**0 runtime dependencies** · **1400+ tests** · **12 phase types** · **shared context tree** · **cross-session resume** · **cross-run memoization** · **per-item map caching** · **incremental recompute** · **FlowIR compile seam** · **detached execution** · **`compile` Mermaid renderer** · **~9k LOC runtime**
- **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/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). +- **1400+ tests across ~95 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. @@ -938,7 +938,7 @@ Known boundaries (tracked, bounded — no surprises mid-flow): ## Development -`taskflow` is a pnpm-workspace monorepo of seven published packages: +`taskflow` is a pnpm-workspace monorepo of nine packages (eight host/core + `taskflow-dsl`): | Package | Role | |---------|------| diff --git a/docs/0.2.0-north-star.md b/docs/0.2.0-north-star.md index fc5066d..6dd1329 100644 --- a/docs/0.2.0-north-star.md +++ b/docs/0.2.0-north-star.md @@ -48,10 +48,10 @@ review 的 critic 用缺陷 #2 证明了:**"读 `.output` 自动建依赖"+ 真 - decompiler 必须设计(review 指出当前 RFC 把"双向可编译"当 checkbox,没设计)。 - 这同时白得 Vue Vapor 的"渐进共存"优势,不用维护双执行后端。 -### 决策 3:overstory 响应式内核是引擎(已完成 M1-M5,0.2.0 让它用户可感知) +### 决策 3:overstory 响应式内核是引擎(M1–M5 能力已落地;旗舰数字是 S5 验收门) -- 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"**。 +- FlowIR 编译(M1) + declared readSet(M2) + observed readSet@version(M3) + stale frontier(M4) + minimal recompute(M5) **能力已落地**(`/tf recompute`、`taskflow_why_stale` / `taskflow_recompute`)。 +- 目标旗舰叙事:**"周一全量审计 $6/8 agents → 周二改 1 文件 → 只重算 2 个节点 $0.40"** — **能力可用,成本比数字尚未作为 S5 发布门禁封板**(见 architecture S5 ⬜)。 --- @@ -59,13 +59,13 @@ review 的 critic 用缺陷 #2 证明了:**"读 `.output` 自动建依赖"+ 真 | 前沿思想 | 来源 | taskflow 对应 | 状态 | |---|---|---|---| -| 编译优先(运行时→编译时) | Svelte/Solid/Vue Vapor | FlowIR 编译 + AST transform | 🟡 编译层有,DSL 编译待建 | +| 编译优先(运行时→编译时) | Svelte/Solid/Vue Vapor | FlowIR 编译 + `taskflow-dsl` AST erase | ✅ S0+S4(分支内;版本号仍 0.1.7 待发) | | 细粒度响应式(signals) | TC39/Solid/Svelte5 | overstory observed readSet + stale + recompute | ✅ 已落地 | -| rune 显式化 | Svelte 5 runes | DSL rune 函数(编译指令) | 🟡 待建 | +| rune 显式化 | Svelte 5 runes | DSL rune 函数(编译指令,`TFDSL_ERASE_ONLY`) | ✅ S4 | | **resumable**(跨会话 / detached) | **Qwik**(借其 resume 叙事,非 hydration) | cross-session resume + detached runs + cache | ✅ 已落地 | -| **确定性重放 / replayable-for-what-if** | 事件溯源 + fold | `trace` + `replayRun` / `taskflow_replay` / `/tf replay`(阈值/budget what-if,零 token) | ✅ 0.2.0 S3 已落地 | -| 增量重算(只重算受影响部分) | Bazel/Nix | content-addressed cache + why-stale + recompute | ✅ 已落地 | -| 写一次编译多端 | Mitosis | 一份 DSL → pi/codex/claude/opencode/grok 五 host | ✅ 已落地 | +| **确定性重放 / replayable-for-what-if** | 事件溯源 + fold | `trace` + `replayRun` / `taskflow_replay` / `/tf replay`(阈值/budget what-if,零 token) | ✅ S3 | +| 增量重算(只重算受影响部分) | Bazel/Nix | content-addressed cache + why-stale + recompute | ✅ 能力;旗舰 $ 比 S5 验收 | +| 写一次编译多端 | Mitosis | Taskflow JSON → pi/codex/claude/opencode/grok;`.tf.ts` 先 `taskflow-dsl build` | ✅(产物是 JSON,非 host 直跑 `.tf.ts`) | **关键洞察:这些思想不是"要追的热点",而是 taskflow 已经走在了前面 —— 学术界 2026-07 才出现"the first"agent 静态分析(AgentFlow),taskflow 早有 FlowIR+verify;TC39 signals 算法 = overstory 算法。0.2.0 是把这些"已经领先的能力"用一个 Svelte 风格 DSL 包装成用户可感知的产品。** diff --git a/docs/internal/claim-vs-impl-0.2.0.md b/docs/internal/claim-vs-impl-0.2.0.md new file mode 100644 index 0000000..ebe9adb --- /dev/null +++ b/docs/internal/claim-vs-impl-0.2.0.md @@ -0,0 +1,56 @@ +# Claim vs implementation — verification log (`release/0.2.0`) + +> Last full pass: 2026-07-09 · Branch HEAD after alignment commit +> Purpose: single ledger so marketing/RFCs/skills do not outrun the code. + +## Verified true (hard claims OK) + +| Claim | Evidence | +|-------|----------| +| S0 FlowIR + content hash | `flowir/compile.ts`, `hashFlowIR`; race/expand compile smoke | +| S1 events + fold + trace | `exec/{events,fold}`, `FileTraceSink` mkdir-on-flush | +| S2 event kernel default OFF, 10 kernel kinds | `EVENT_KERNEL_PHASE_TYPES` = PHASE_TYPES − race − expand; env/flag | +| S3 offline replay | `replay.ts`, MCP `taskflow_replay`, pi `/tf replay` | +| S4 package `taskflow-dsl` | erase kinds registry, CLI build/check/decompile/new, tests | +| 12 `PHASE_TYPES` | `schema.ts`; imperative runtime executes all 12 | +| MCP 12 tools | `taskflow-mcp-core` server tool list | +| Five host delivery packages | pi/codex/claude/opencode/grok | +| Toolchain = TypeScript AST (not ts-morph) | `taskflow-dsl` depends on `typescript` only | + +## Honest / qualified + +| Topic | Truth | +|-------|--------| +| Version | All packages still **0.1.7**; branch is `release/0.2.0` preview — not a published 0.2.0 npm tag until bump | +| S5 | Kernel default ON **not** done; flagship $6→$0.40 is **acceptance target**, not certified number | +| `cancelLosers` | Schema + DSL accept; **runtime ignores** (first-finish-wins; warning when default true) | +| Event kernel “complete” | Complete for **kernel-eligible** kinds/features; not race/expand; not score/retry/expect/reflexion/cross-run cache/shareContext | +| Multi-host DSL | Hosts run **Taskflow JSON**; `.tf.ts` requires prior `taskflow-dsl build` | +| Decompile | Semantic, not literal round-trip | +| Test count | ~**1400+** unit tests in ~**95** `*.test.ts` files (regenerate badge on release) | +| Package count | **9** under `packages/` + `website` | + +## Explicitly not shipped + +loop multi-body · route · compensate/saga · watch · experimental C-track runes · host auto-build of `.tf.ts` · S5 default kernel ON + +## Alignment actions taken this pass + +1. Schema + skills: `cancelLosers` documented as reserved/ignored; race emits warning. +2. S4 RFCs: ts-morph → TypeScript compiler API. +3. FlowIR/step/runtime comments: 12 kinds / kernel-10 clarified. +4. README / AGENTS / workspace / architecture topology counts refreshed. +5. North-star: DSL ✅; flagship $ as S5 gate; multi-host via JSON. + +## Re-verify commands + +```bash +pnpm run test:dsl +node --conditions=development --experimental-strip-types --test \ + packages/taskflow-core/test/race-expand.test.ts \ + packages/taskflow-core/test/script.test.ts +node scripts/build-skills.mjs && \ + node --conditions=development --experimental-strip-types --test \ + packages/pi-taskflow/test/skills-build.test.ts +# Optional full: pnpm test +``` diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index a8627fc..7554f7b 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -300,7 +300,7 @@ roadmap 反对 B 的唯一理由是"大爆炸三层同时改"。绞杀者模式 --- -## §10. 包拓扑(7 → 8) +## §10. 包拓扑(9 packages + website) | 包 | 变化 | |---|---| diff --git a/docs/rfc-0.2.0-s4-decision-record.md b/docs/rfc-0.2.0-s4-decision-record.md index fa13222..6586832 100644 --- a/docs/rfc-0.2.0-s4-decision-record.md +++ b/docs/rfc-0.2.0-s4-decision-record.md @@ -15,7 +15,7 @@ |--|--| | **Primary** | **Svelte-style** compile-time runes (AST erase; cannot run unbuilt `.tf.ts`) | | **Escape** | **JSON-only** whole-file (dual frontend at **file** boundary) | -| **Toolchain** | **ts-morph** (+ pinned TypeScript) → Taskflow → `compileTaskflowToFlowIR` | +| **Toolchain** | **TypeScript compiler API** (`typescript` package) → Taskflow → `compileTaskflowToFlowIR` | | **Rejected** | Solid Proxy runtime runes · in-file Vapor hybrid · interpret / auto-build-on-run · S5 prerequisite | ## Package & CLI @@ -47,7 +47,7 @@ Engine `PHASE_TYPES` is now **12** (`race`, `expand` added). DSL erase registry |--------------|--------| | Core 10 + `subflow` / `subflow.def` | ✅ | | `gate.automated` / `gate.scored` | ✅ (A-track sugar) | -| `race` + `cancelLosers` | ✅ engine + DSL | +| `race` | ✅ engine + DSL (`cancelLosers` **reserved / ignored**) | | `expand` / `expand.nested` / `expand.graft` + `maxNodes` | ✅ engine + DSL | | Parallel destructure → N agent phases | ✅ | | Modular pipeline (no monolith grow) | ✅ see `docs/internal/modularization-0.2.0.md` | @@ -127,7 +127,7 @@ export default flow("audit", (ctx) => { | Run | Role | |-----|------| | `s4-shape-council-mrd6rcyl-f77e65` | inventory-code, inventory-rfc, coverage-map | -| `s4-shape-council-v2-mrd6wdcj-e34ca3` | routes + tournament (svelte/json-only/ts-morph) + api-surface + adversary | +| `s4-shape-council-v2-mrd6wdcj-e34ca3` | routes + tournament (svelte/json-only/typescript-AST) + api-surface + adversary | | `s4-shape-finalize-mrd765gf-f14e1b` | cross-check (PASS on route; BLOCK only until B1/matrix/H2/H3 written) | Flow defs: `/tmp/taskflow-s4/s4-shape.json`, `s4-shape-v2.json`, `s4-shape-final.json` diff --git a/docs/rfc-0.2.0-s4-mvp.md b/docs/rfc-0.2.0-s4-mvp.md index de2e869..ea04944 100644 --- a/docs/rfc-0.2.0-s4-mvp.md +++ b/docs/rfc-0.2.0-s4-mvp.md @@ -26,7 +26,7 @@ DSL v2 hard constraint “100% 功能覆盖” is **not** the S4 acceptance bar. | **What S4 is not** | A second runtime, kernel flip (S5), in-file JSON hybrid, or agent-default authoring path | | **Primary model** | **Svelte-style** compile-time runes (AST erase; no interpret) | | **Escape** | **Whole-file JSON** only (zero migration; dual frontend at file boundary) | -| **Toolchain** | **ts-morph** (+ pinned `typescript`) → Taskflow → `compileTaskflowToFlowIR` | +| **Toolchain** | **TypeScript compiler API** (`typescript` package) → Taskflow → `compileTaskflowToFlowIR` | | **Package / bin** | `taskflow-dsl` / `taskflow-dsl {build,check,decompile,new}` | | **Import** | `from "taskflow-dsl"` (not `"taskflow"` in S4) | | **Hosts** | **CLI-first**; no new MCP / no auto-build on `taskflow_run` | @@ -41,7 +41,7 @@ DSL v2 hard constraint “100% 功能覆盖” is **not** the S4 acceptance bar. |------|--------| | **Primary** | **Svelte-style compile-time erase** (runes are AST directives; runtime does not execute them) | | **Escape** | **JSON-only** whole-file (existing Taskflow JSON remains first-class; zero migration) | -| **Build toolchain** | **ts-morph** (TypeScript Program + AST read → Taskflow JSON → core `compileTaskflowToFlowIR`) | +| **Build toolchain** | **TypeScript compiler API** (AST erase via `typescript` → Taskflow JSON → core `compileTaskflowToFlowIR`) | ### Why (≤5) @@ -64,7 +64,7 @@ DSL v2 hard constraint “100% 功能覆盖” is **not** the S4 acceptance bar. ### Pipeline S4 owns ``` -.tf.ts ──build(AST / ts-morph)──▶ Taskflow JSON ──compileTaskflowToFlowIR──▶ FlowIR (+ ir:<64-hex>) +.tf.ts ──build(AST / typescript)──▶ Taskflow JSON ──compileTaskflowToFlowIR──▶ FlowIR (+ ir:<64-hex>) .flow.json ──validate / desugar──▶ Taskflow JSON ──same core entry only──▶ FlowIR ``` @@ -94,8 +94,7 @@ DSL v2 hard constraint “100% 功能覆盖” is **not** the S4 acceptance bar. | Dep | Kind | Why | |-----|------|-----| | `taskflow-core` | **dependency** (workspace pin, same version as siblings) | Taskflow types, `validateTaskflow` / `desugar`, `compileTaskflowToFlowIR`, `hashFlowIR`, `verifyTaskflow` | -| `ts-morph` | **dependency** | Project/SourceFile AST for build + check | -| `typescript` | **dependency** (or peer + dep) | Program host under ts-morph; pin compatible with monorepo devDependency | +| `typescript` | **dependency** | AST erase (`createSourceFile` / Program for `--typecheck`); pin with monorepo | | `typebox` | **peerDependency** `*` | Align with core; only if public types re-export expect shapes that mention TypeBox | **Forbidden in this package:** host SDKs (`@earendil-works/*`), `taskflow-hosts`, `taskflow-mcp-core`, any process-spawning runner. @@ -207,7 +206,7 @@ taskflow-dsl build [options] | Input | Behavior | |-------|----------| | `*.tf.ts` | AST erase → Taskflow → (optional) FlowIR via core | -| `*.json` / `*.jsonc` | **JSON escape path:** parse → `validateTaskflow` / `desugar` → same emit options (no ts-morph) | +| `*.json` / `*.jsonc` | **JSON escape path:** parse → `validateTaskflow` / `desugar` → same emit options (no TypeScript AST) | | other extension | Error `TFDSL_INPUT_KIND` | | Flag | Default | IO | @@ -439,7 +438,7 @@ export interface BuildResult { /** Build from a filesystem path (.tf.ts | .json). */ export function buildFile(path: string, opts?: BuildOptions): Promise; -// Sync variant allowed if ts-morph usage is sync-only: +// Sync variant allowed if AST usage is sync-only: export function buildFileSync(path: string, opts?: BuildOptions): BuildResult; /** Build from source text (tests / MCP-later). `fileName` required for positions. */ @@ -688,7 +687,7 @@ Prefer **barrel** `taskflow-core` or narrow subpaths already exported (`taskflow | **CLI `taskflow-dsl`** | **Y — primary** | Agents/humans compile before run; matches “no unbuilt run”; easy CI | | **Library `buildFile` / `checkFile`** | **Y** | Tests + scripted toolchains | | **New MCP tools** (`taskflow_build` / `taskflow_check`) | **N** | Avoid bloating every host adapter + mcp-core in the same release as first compiler; agents shell out or use defineFile of emitted JSON | -| **Host auto-build of `.tf.ts` on `taskflow_run`** | **N** | Silent compile on run reintroduces “feels executable” and couples hosts to ts-morph | +| **Host auto-build of `.tf.ts` on `taskflow_run`** | **N** | Silent compile on run reintroduces “feels executable” and couples hosts to the TypeScript compiler | | **pi `/tf` DSL commands** | **N** MVP | Optional S4.1 thin wrappers calling the same library | ### 7.2 Recommended agent workflow (0.2.0) @@ -778,10 +777,10 @@ FULL RFC coverage remains a completion track; missing FULL features must not app ### 10.2 Non-blocking polish (implementer discretion) -1. Sync vs async `buildFile` (sync-friendly with ts-morph). +1. Sync vs async `buildFile` (sync-friendly with the TypeScript API). 2. Exact synthetic phase id algorithm for anonymous runes (must be deterministic for hash equality fixtures). 3. Default `--emit both` vs `taskflow` only (recommend **taskflow-only** default; FlowIR via `--emit flowir|both` / CI). -4. Optional thin facade over ts-morph `Node` so engine can be swapped later without rewriting erase. +4. Optional thin facade over TS `Node` so the parser host can be swapped later without rewriting erase. ### 10.3 Recommended PR stack (after human lock) @@ -808,4 +807,4 @@ FULL RFC coverage remains a completion track; missing FULL features must not app --- -*One-line summary: S4 MVP publishes `taskflow-dsl` as a CLI-first, ts-morph erase frontend — `*.tf.ts` in, Taskflow (+ FlowIR via core) out — with whole-file JSON as the only escape, stable diagnostics, a hard core import allowlist, and no MCP or runtime interpret path. Extended brainstorm phases are designed in `rfc-0.2.0-dsl-phases-horizon.md` without expanding the S4 ship bar.* +*One-line summary: S4 MVP publishes `taskflow-dsl` as a CLI-first, TypeScript-AST erase frontend — `*.tf.ts` in, Taskflow (+ FlowIR via core) out — with whole-file JSON as the only escape, stable diagnostics, a hard core import allowlist, and no MCP or runtime interpret path. Extended brainstorm phases are designed in `rfc-0.2.0-dsl-phases-horizon.md` without expanding the S4 ship bar.* diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index 0414554..0811cb3 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -469,15 +469,16 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, -default true). Use it when several approaches can answer the same question and -latency matters more than waiting for every branch (unlike `parallel`, which -waits for all, or `tournament`, which judges quality after all variants finish). +branch that finishes successfully** (first-finish-wins). Unlike `parallel` +(waits for all) or `tournament` (judges quality after all variants), use race +when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). +- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for + forward compatibility; losers are **not** aborted yet and may run to natural + completion. Cap cost with `budget` / per-call `timeout`, not this flag. - Output of the winning branch becomes the race phase output; a warning records - which branch won. + which branch won (and notes that cancelLosers is reserved). ```jsonc { diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index 2c0090d..7920264 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | +| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index 1055245..02f2ef2 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -468,15 +468,16 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, -default true). Use it when several approaches can answer the same question and -latency matters more than waiting for every branch (unlike `parallel`, which -waits for all, or `tournament`, which judges quality after all variants finish). +branch that finishes successfully** (first-finish-wins). Unlike `parallel` +(waits for all) or `tournament` (judges quality after all variants), use race +when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). +- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for + forward compatibility; losers are **not** aborted yet and may run to natural + completion. Cap cost with `budget` / per-call `timeout`, not this flag. - Output of the winning branch becomes the race phase output; a warning records - which branch won. + which branch won (and notes that cancelLosers is reserved). ```jsonc { diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index 97cb280..4ff5c6e 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | +| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index aafe52e..8b096a7 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -471,15 +471,16 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, -default true). Use it when several approaches can answer the same question and -latency matters more than waiting for every branch (unlike `parallel`, which -waits for all, or `tournament`, which judges quality after all variants finish). +branch that finishes successfully** (first-finish-wins). Unlike `parallel` +(waits for all) or `tournament` (judges quality after all variants), use race +when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). +- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for + forward compatibility; losers are **not** aborted yet and may run to natural + completion. Cap cost with `budget` / per-call `timeout`, not this flag. - Output of the winning branch becomes the race phase output; a warning records - which branch won. + which branch won (and notes that cancelLosers is reserved). ```jsonc { diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index cc9864b..d74d1bb 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | +| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 7ab5297..9660563 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -469,15 +469,16 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, -default true). Use it when several approaches can answer the same question and -latency matters more than waiting for every branch (unlike `parallel`, which -waits for all, or `tournament`, which judges quality after all variants finish). +branch that finishes successfully** (first-finish-wins). Unlike `parallel` +(waits for all) or `tournament` (judges quality after all variants), use race +when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). +- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for + forward compatibility; losers are **not** aborted yet and may run to natural + completion. Cap cost with `budget` / per-call `timeout`, not this flag. - Output of the winning branch becomes the race phase output; a warning records - which branch won. + which branch won (and notes that cancelLosers is reserved). ```jsonc { diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index 9586b07..90b27c5 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | +| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index aa92d25..449542c 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -458,15 +458,16 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, -default true). Use it when several approaches can answer the same question and -latency matters more than waiting for every branch (unlike `parallel`, which -waits for all, or `tournament`, which judges quality after all variants finish). +branch that finishes successfully** (first-finish-wins). Unlike `parallel` +(waits for all) or `tournament` (judges quality after all variants), use race +when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). +- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for + forward compatibility; losers are **not** aborted yet and may run to natural + completion. Cap cost with `budget` / per-call `timeout`, not this flag. - Output of the winning branch becomes the race phase output; a warning records - which branch won. + which branch won (and notes that cancelLosers is reserved). ```jsonc { diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index 0391ca8..53ce867 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | +| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index cdac3b9..1668d81 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -1,8 +1,8 @@ /** * exec/step — per-node handlers for the event-sourced kernel (RFC §6.3, S2). * - * **All 10 phase types** (agent|script|map|parallel|reduce|gate|approval|loop| - * tournament|flow). Complex kinds live in `./step-kinds.ts`. Does **not** + * Kernel covers `EVENT_KERNEL_PHASE_TYPES` (PHASE_TYPES minus `race`/`expand` — + * currently 10 kinds). Complex paths live in `./step-kinds.ts`. Does **not** * import `runtime.ts` (avoids circular deps with the strangler). */ diff --git a/packages/taskflow-core/src/flowir/schema.ts b/packages/taskflow-core/src/flowir/schema.ts index b8015eb..0818148 100644 --- a/packages/taskflow-core/src/flowir/schema.ts +++ b/packages/taskflow-core/src/flowir/schema.ts @@ -18,7 +18,7 @@ * 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 + * `FlowIRNodeKind` literal union (the closed set of native phase kinds = `PHASE_TYPES`), 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 @@ -37,7 +37,7 @@ import { StringEnum } from "../typebox-helpers.ts"; import { PHASE_TYPES, type PhaseType } from "../schema.ts"; // --------------------------------------------------------------------------- -// FlowIRNodeKind — the 10 native phase kinds (closed literal union) +// FlowIRNodeKind — closed literal union = PHASE_TYPES (currently 12 kinds) // --------------------------------------------------------------------------- /** @@ -62,7 +62,7 @@ import { PHASE_TYPES, type PhaseType } from "../schema.ts"; * | `script` | run a shell command (no LLM, zero tokens) | */ export const FlowIRNodeKind = StringEnum(PHASE_TYPES, { - description: "The 10 native pi-taskflow phase kinds (1:1 projection of PHASE_TYPES)", + description: "Native phase kinds — 1:1 projection of PHASE_TYPES (agent…script, race, expand, …)", }); /** @see PHASE_TYPES — same closed set as the DSL. */ export type FlowIRNodeKind = PhaseType; diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 055e15c..1000b50 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -93,8 +93,9 @@ export interface RuntimeDeps { * identically to today). See `trace.ts`. */ trace?: TraceSink; /** - * S2 strangler: when true, flows run on `exec/driver` (event kernel; all - * 10 phase kinds). Default false; also set `PI_TASKFLOW_EVENT_KERNEL=1`. + * S2 strangler: when true, eligible flows run on `exec/driver` (event kernel). + * Covers core kinds except `race`/`expand`; advanced features force imperative + * fallback. Default false; also set `PI_TASKFLOW_EVENT_KERNEL=1`. */ eventKernel?: boolean; /** Internal: sub-flow call stack, for recursion detection. */ diff --git a/packages/taskflow-core/src/runtime/phases/race.ts b/packages/taskflow-core/src/runtime/phases/race.ts index 65e096a..8ceb1ec 100644 --- a/packages/taskflow-core/src/runtime/phases/race.ts +++ b/packages/taskflow-core/src/runtime/phases/race.ts @@ -49,8 +49,8 @@ export async function executeRaceBranches( }; } - // cancelLosers reserved for multi-signal abort plumbing - void (phase as { cancelLosers?: boolean }).cancelLosers; + // cancelLosers is schema-accepted but not enforced yet (no per-branch AbortSignal fan-out). + const cancelLosers = (phase as { cancelLosers?: boolean }).cancelLosers !== false; const raced = await Promise.race( branches.map(async (b, i) => { @@ -61,6 +61,12 @@ export async function executeRaceBranches( const winner = raced.result; const failed = isFailed(winner); + const warnings = [`race: branch ${raced.i + 1}/${branches.length} won`]; + if (cancelLosers) { + warnings.push( + "race: cancelLosers is reserved — in-flight losers are not aborted yet (first-finish-wins only)", + ); + } return { id: phase.id, status: failed ? "failed" : "done", @@ -73,7 +79,7 @@ export async function executeRaceBranches( error: failed ? winner.errorMessage ?? winner.stderr : undefined, inputHash: opts.inputHash, endedAt: Date.now(), - warnings: [`race: branch ${raced.i + 1}/${branches.length} won`], + warnings, ...(opts.readRefs ? { reads: opts.readRefs } : {}), }; } diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index 3498ce0..eacca43 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -148,7 +148,12 @@ const PhaseSchema = Type.Object( description: "[parallel|race|tournament] Static task branches", }), ), - /** [race] When true (default), abort in-flight losers after the first branch finishes (best-effort). */ + /** + * [race] Reserved. Intended: abort in-flight losers after the first branch finishes. + * **Currently ignored** — first-finish-wins still applies; other branches may run to + * natural completion (no multi-signal abort plumbing yet). Accepted so JSON/DSL stay + * forward-compatible; do not rely on cancellation for cost control (use `budget` / `timeout`). + */ cancelLosers: Type.Optional(Type.Boolean({ default: true })), // reduce diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 85dce22..ff401f8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ -# pnpm workspace — the six published packages. +# pnpm workspace — nine packages under packages/ + website. # (Replaces the npm `workspaces` field in the root package.json; pnpm ignores # that field and requires this file instead.) packages: diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index 0502a2b..dedba89 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -83,7 +83,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Cancel non-winning branches after the first success. | +| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 4aa4a88..f8a6aec 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -482,15 +482,16 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully**. Losers may be cancelled (`cancelLosers`, -default true). Use it when several approaches can answer the same question and -latency matters more than waiting for every branch (unlike `parallel`, which -waits for all, or `tournament`, which judges quality after all variants finish). +branch that finishes successfully** (first-finish-wins). Unlike `parallel` +(waits for all) or `tournament` (judges quality after all variants), use race +when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). +- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for + forward compatibility; losers are **not** aborted yet and may run to natural + completion. Cap cost with `budget` / per-call `timeout`, not this flag. - Output of the winning branch becomes the race phase output; a warning records - which branch won. + which branch won (and notes that cancelLosers is reserved). ```jsonc { From 3f6d5560f47e554923dcbf283384bd9832acd781 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 17:31:09 +0800 Subject: [PATCH 33/51] docs: close remaining claim gaps (website, CHANGELOG, examples) Website: 12 phase types and 5 hosts on the homepage; race/expand in phase-types and concepts (en/zh); TypeScript DSL reference page + nav. CHANGELOG Unreleased covers S4, race/expand, and claim alignment. Examples race-first-win + expand-nested-fragment; skills caveats for kernel/decompile/cancelLosers; advanced flow{def} vs expand; update claim-vs-impl ledger. --- CHANGELOG.md | 5 +- docs/internal/claim-vs-impl-0.2.0.md | 27 ++++++-- docs/rfc-0.2.0-architecture.md | 2 +- docs/rfc-0.2.0-s4-decision-record.md | 2 +- examples/expand-nested-fragment.json | 32 ++++++++++ examples/race-first-win.json | 25 ++++++++ .../plugin/skills/taskflow/advanced.md | 19 +++++- .../plugin/skills/taskflow/configuration.md | 12 +++- .../plugin/skills/taskflow/advanced.md | 19 +++++- .../plugin/skills/taskflow/configuration.md | 12 +++- .../plugin/skills/taskflow/advanced.md | 19 +++++- .../plugin/skills/taskflow/configuration.md | 12 +++- .../plugin/skills/taskflow/advanced.md | 19 +++++- .../plugin/skills/taskflow/configuration.md | 12 +++- .../pi-taskflow/skills/taskflow/advanced.md | 15 +++++ .../skills/taskflow/configuration.md | 12 +++- skills-src/taskflow/advanced.md | 19 +++++- skills-src/taskflow/configuration.md | 12 +++- .../.plans/docs-enhancement-master-plan.md | 2 +- website/app/[lang]/page.tsx | 16 ++--- website/content/docs/en/concepts/index.mdx | 2 +- website/content/docs/en/concepts/phases.mdx | 37 ++++++++++- .../docs/en/concepts/shared-context-tree.mdx | 2 +- website/content/docs/en/getting-started.mdx | 2 +- .../docs/en/guides/agents-and-model-roles.mdx | 2 +- website/content/docs/en/meta.json | 1 + website/content/docs/en/reference/index.mdx | 2 + .../docs/en/reference/typescript-dsl.mdx | 64 +++++++++++++++++++ .../content/docs/en/syntax/control-flow.mdx | 2 +- .../docs/en/syntax/flow-definition.mdx | 2 +- website/content/docs/en/syntax/index.mdx | 2 +- .../content/docs/en/syntax/phase-types.mdx | 49 +++++++++++++- .../docs/zh-cn/concepts/context-isolation.mdx | 2 +- website/content/docs/zh-cn/concepts/dag.mdx | 2 +- website/content/docs/zh-cn/concepts/index.mdx | 4 +- .../content/docs/zh-cn/concepts/phases.mdx | 35 +++++++++- .../zh-cn/concepts/shared-context-tree.mdx | 2 +- .../content/docs/zh-cn/getting-started.mdx | 2 +- .../zh-cn/guides/agents-and-model-roles.mdx | 2 +- .../content/docs/zh-cn/guides/claude-code.mdx | 2 +- .../zh-cn/guides/code-audit-case-study.mdx | 2 +- website/content/docs/zh-cn/guides/codex.mdx | 2 +- .../content/docs/zh-cn/guides/opencode.mdx | 2 +- website/content/docs/zh-cn/guides/pi.mdx | 2 +- website/content/docs/zh-cn/meta.json | 1 + .../content/docs/zh-cn/reference/index.mdx | 2 + .../docs/zh-cn/reference/typescript-dsl.mdx | 63 ++++++++++++++++++ website/content/docs/zh-cn/showcase/index.mdx | 2 +- .../docs/zh-cn/syntax/control-flow.mdx | 2 +- .../docs/zh-cn/syntax/flow-definition.mdx | 2 +- website/content/docs/zh-cn/syntax/index.mdx | 2 +- .../content/docs/zh-cn/syntax/phase-types.mdx | 53 +++++++++++++-- website/content/docs/zh-cn/syntax/scorers.mdx | 2 +- .../content/docs/zh-cn/what-is-taskflow.mdx | 2 +- 54 files changed, 567 insertions(+), 83 deletions(-) create mode 100644 examples/expand-nested-fragment.json create mode 100644 examples/race-first-win.json create mode 100644 website/content/docs/en/reference/typescript-dsl.mdx create mode 100644 website/content/docs/zh-cn/reference/typescript-dsl.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1dd08f0..dd1e169 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [Unreleased] ### Added +- **S4 TypeScript DSL (`taskflow-dsl`):** compile-time `.tf.ts` runes erase to Taskflow JSON via TypeScript AST (`typescript` package, not ts-morph); CLI `new` / `check` / `build` / `decompile`; modular `erase/kinds/*` registry; parity tests (map + `json` + templates). Hosts still run JSON only (no MCP auto-build of `.tf.ts`). Package may still version as `0.1.7` until a formal 0.2.0 publish; npm may lag monorepo. +- **Horizon B engine kinds (`race`, `expand`):** `PHASE_TYPES` is **12**. Imperative runtime + FlowIR 1:1; `race` = first-finish-wins; `expand` = nested or graft-promote (`def`, `expandMode`, `maxNodes`). DSL runes + skills + website phase docs + examples `race-first-win.json` / `expand-nested-fragment.json`. **`cancelLosers` is reserved/ignored** (forward-compat field; warning at runtime). Event kernel still covers the original **10** kinds (excludes `race`/`expand`); advanced features continue to force imperative fallback. +- **Claim-vs-impl alignment:** `docs/internal/claim-vs-impl-0.2.0.md` ledger; S4 RFCs toolchain corrected; architecture S4 ✅ / S5 next; skills + README phase/test/package counts; website homepage **12** phases / **5** hosts; reference page **TypeScript DSL** (en/zh). - **Docs complete for Phase 2 surfaces:** website FlowIR docs (`ir:<64-hex>`, `usedFallbackHash: false`); new concepts **Deterministic Replay** (en/zh) + resume disambiguation; host MCP guides + Grok website + `reference/commands` list all 12 tools including `taskflow_replay`; README en/zh Commands tables; skills `advanced.md` trace/replay vs recompute; `AGENTS.md` exec/trace/replay + kernel flag; architecture RFC status table S0–S5 + internal FlowIR RFC superseded note. - **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.2.0 Phase 2 / S0–S3 foundations (event-sourced kernel path):** @@ -12,7 +15,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - `exec/fold.ts` — pure `foldEvents(log) →` per-phase snapshot (S1 differential building block). - `replay.ts` — implements `replayRun(log, overrides)` (threshold / budget / model / args knobs; zero tokens; no import of runtime/driver). - **S1 decision coverage:** runtime emits `gate-score` / `gate-verdict`, `when-guard`, `cache-hit`, `tournament-winner`, `budget-hit`, plus synthetic phase lifecycle for budget/dep skips. Fold differential tests pin fold ↔ RunState agreement. - - **S2 event kernel (strangler, default OFF, all 10 kinds):** `exec/step.ts` + `step-kinds.ts` + `exec/driver.ts` run **every** phase type when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative remains default. Hardening: budget enforcement + `budget-hit` events; deps/`join`/`optional` parity; gate eval fail-safe; `steps.*.json` population; agent timeout; script stdout cap; `flow{def}` dynamic validation + nesting cap + recursion stack; feature fall-back for score/retry/expect/reflexion/onBlock/cross-run cache/shareContext. + - **S2 event kernel (strangler, default OFF, kernel-eligible kinds = PHASE_TYPES minus `race`/`expand`):** `exec/step.ts` + `step-kinds.ts` + `exec/driver.ts` when `RuntimeDeps.eventKernel` or `PI_TASKFLOW_EVENT_KERNEL=1`. Imperative remains default. Hardening: budget enforcement + `budget-hit` events; deps/`join`/`optional` parity; gate eval fail-safe; `steps.*.json` population; agent timeout; script stdout cap; `flow{def}` dynamic validation + nesting cap + recursion stack; feature fall-back for score/retry/expect/reflexion/onBlock/cross-run cache/shareContext. - **S1 hard gate + import lint:** fold(log) rebuilds phase statuses matching RunState after a captured run (kill-9 oracle); `replay-import-lint` keeps `replay.ts` off the runtime/driver/step import graph. - **North-star slogan:** `compiled · resumable · incremental · replayable-for-what-if` (drops Qwik "not replayable" collision with deterministic replay). - **S3 replay surface:** `taskflow_replay` MCP tool; pi `action=replay` and `/tf replay [--threshold phase=n] [--budget-usd n]`; golden trace fixture under `test/fixtures/`. diff --git a/docs/internal/claim-vs-impl-0.2.0.md b/docs/internal/claim-vs-impl-0.2.0.md index ebe9adb..54db5a8 100644 --- a/docs/internal/claim-vs-impl-0.2.0.md +++ b/docs/internal/claim-vs-impl-0.2.0.md @@ -34,13 +34,30 @@ loop multi-body · route · compensate/saga · watch · experimental C-track runes · host auto-build of `.tf.ts` · S5 default kernel ON -## Alignment actions taken this pass +## Alignment actions taken -1. Schema + skills: `cancelLosers` documented as reserved/ignored; race emits warning. +### Pass 1 (claim ledger) +1. Schema + skills: `cancelLosers` reserved/ignored; race warning. 2. S4 RFCs: ts-morph → TypeScript compiler API. -3. FlowIR/step/runtime comments: 12 kinds / kernel-10 clarified. -4. README / AGENTS / workspace / architecture topology counts refreshed. -5. North-star: DSL ✅; flagship $ as S5 gate; multi-host via JSON. +3. FlowIR/step/runtime comments: 12 kinds / kernel-10. +4. README / AGENTS / workspace / architecture counts. +5. North-star: DSL ✅; flagship $ = S5 gate. + +### Pass 2 (website + CHANGELOG + examples) +6. Website homepage: **12** phases / **5** hosts (en+zh). +7. Website phase-types + concepts: `race` / `expand` sections (en+zh). +8. Website reference: **TypeScript DSL** page (en+zh) + nav. +9. CHANGELOG Unreleased: S4 + race/expand + alignment; kernel wording fixed. +10. Examples: `race-first-win.json`, `expand-nested-fragment.json`. +11. Skills advanced: `flow{def}` vs `expand`; configuration caveats (kernel/decompile). + +## Still open (not claimed as done) + +- Formal **0.2.0 npm publish** + version bump (packages still 0.1.7; `taskflow-dsl` may 404 on registry). +- Implement real `cancelLosers` abort (or keep reserved forever). +- S5 kernel default ON + flagship $ demo seal. +- Full website polish for every “ten” remnant in long-form copy (bulk pass done; spot-check periodically). +- `pnpm test` full suite as release gate; live host e2e. ## Re-verify commands diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index 7554f7b..415343c 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -184,7 +184,7 @@ 4. **core 零运行时依赖不变**:`flowir/*`、`exec/*`、`replay.ts`、`rates.ts` 全是纯模块(仅 typebox)。 5. **`taskflow-core` 永不 import host SDK**(`@earendil-works/*`)——不变。 -### 4.3 新包 taskflow-dsl(7 → 8 包) +### 4.3 新包 taskflow-dsl(9 packages + website) | 内容 | 说明 | |---|---| diff --git a/docs/rfc-0.2.0-s4-decision-record.md b/docs/rfc-0.2.0-s4-decision-record.md index 6586832..c3141fb 100644 --- a/docs/rfc-0.2.0-s4-decision-record.md +++ b/docs/rfc-0.2.0-s4-decision-record.md @@ -29,7 +29,7 @@ ## MVP scope (in) — original ship bar 1. `flow` + args/budget/concurrency/description -2. Core **10 phase kinds** basic runes +2. Core phase kinds basic runes (ship bar was **10**; engine now **12** with `race`/`expand` — see Landed beyond MVP) 3. Template → `{steps.*}` / `{item.*}` erase 4. Auto `dependsOn` from `.output` / `.json` reads + explicit `dependsOn` 5. `when` **string** form (+ TS subset in `check` if cheap) diff --git a/examples/expand-nested-fragment.json b/examples/expand-nested-fragment.json new file mode 100644 index 0000000..e0c0e3a --- /dev/null +++ b/examples/expand-nested-fragment.json @@ -0,0 +1,32 @@ +{ + "name": "expand-nested-fragment", + "description": "Horizon B: expand runs an inline fragment as an isolated nested sub-flow.", + "phases": [ + { + "id": "seed", + "type": "agent", + "agent": "executor", + "task": "Say hello-from-seed", + "output": "text" + }, + { + "id": "grow", + "type": "expand", + "expandMode": "nested", + "dependsOn": ["seed"], + "def": { + "name": "frag", + "phases": [ + { + "id": "inner", + "type": "agent", + "agent": "executor", + "task": "Say nested-hi (parent seed was upstream; this is the fragment final).", + "final": true + } + ] + }, + "final": true + } + ] +} diff --git a/examples/race-first-win.json b/examples/race-first-win.json new file mode 100644 index 0000000..517e61b --- /dev/null +++ b/examples/race-first-win.json @@ -0,0 +1,25 @@ +{ + "name": "race-first-win", + "description": "Horizon B: first completed branch wins (latency over exhaustive wait).", + "budget": { "maxUSD": 0.5 }, + "phases": [ + { + "id": "answer", + "type": "race", + "branches": [ + { + "task": "Answer quickly with a short heuristic for: {args.q}", + "agent": "executor" + }, + { + "task": "Answer carefully after a brief structured think for: {args.q}", + "agent": "executor" + } + ], + "final": true + } + ], + "args": { + "q": { "default": "What is a DAG?" } + } +} diff --git a/packages/claude-taskflow/plugin/skills/taskflow/advanced.md b/packages/claude-taskflow/plugin/skills/taskflow/advanced.md index 6a178e2..f989314 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/advanced.md @@ -2,8 +2,23 @@ # 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`). +Load this when a flow needs: runtime-generated work (`flow{def}` / `expand`) or +isolated working directories (`cwd: temp/dedicated/worktree`). + +--- + +## `flow{def}` vs `expand` (when to use which) + +| Need | Prefer | +|------|--------| +| Saved reusable flow by name | `flow` + `use` | +| Planner JSON as isolated nested sub-flow (classic) | `flow` + `def` **or** `expand` + `expandMode: "nested"` | +| Fragment phases must appear on the **parent** run as `-` | `expand` + `expandMode: "graft"` | +| First of several static approaches (latency) | `race` (not tournament) | + +`expand` is a first-class phase type (Horizon B). Dynamic validation / nesting / +breadth caps match `flow{def}`. **Event kernel** still excludes `race`/`expand` +(imperative path only until step handlers exist). --- diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index 7920264..9b991bb 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -471,10 +471,16 @@ Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. --- -## Caveats (declared but not yet enforced) +## Caveats (declared but not yet enforced / partial) -These keys validate but the runtime does **not** act on them yet — don't rely on -them for behavior: +These keys validate but the runtime does **not** fully act on them yet — don't +rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. +- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). +- **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does + **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, + cross-run cache, and Shared Context Tree force the **imperative** path. +- **`taskflow-dsl decompile`** — semantic readability, **not** literal round-trip + (e.g. expand `def` objects may collapse to a placeholder string). diff --git a/packages/codex-taskflow/plugin/skills/taskflow/advanced.md b/packages/codex-taskflow/plugin/skills/taskflow/advanced.md index 6a178e2..f989314 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/advanced.md @@ -2,8 +2,23 @@ # 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`). +Load this when a flow needs: runtime-generated work (`flow{def}` / `expand`) or +isolated working directories (`cwd: temp/dedicated/worktree`). + +--- + +## `flow{def}` vs `expand` (when to use which) + +| Need | Prefer | +|------|--------| +| Saved reusable flow by name | `flow` + `use` | +| Planner JSON as isolated nested sub-flow (classic) | `flow` + `def` **or** `expand` + `expandMode: "nested"` | +| Fragment phases must appear on the **parent** run as `-` | `expand` + `expandMode: "graft"` | +| First of several static approaches (latency) | `race` (not tournament) | + +`expand` is a first-class phase type (Horizon B). Dynamic validation / nesting / +breadth caps match `flow{def}`. **Event kernel** still excludes `race`/`expand` +(imperative path only until step handlers exist). --- diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index 4ff5c6e..17d21e4 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -468,10 +468,16 @@ Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. --- -## Caveats (declared but not yet enforced) +## Caveats (declared but not yet enforced / partial) -These keys validate but the runtime does **not** act on them yet — don't rely on -them for behavior: +These keys validate but the runtime does **not** fully act on them yet — don't +rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. +- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). +- **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does + **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, + cross-run cache, and Shared Context Tree force the **imperative** path. +- **`taskflow-dsl decompile`** — semantic readability, **not** literal round-trip + (e.g. expand `def` objects may collapse to a placeholder string). diff --git a/packages/grok-taskflow/plugin/skills/taskflow/advanced.md b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md index 6a178e2..f989314 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md @@ -2,8 +2,23 @@ # 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`). +Load this when a flow needs: runtime-generated work (`flow{def}` / `expand`) or +isolated working directories (`cwd: temp/dedicated/worktree`). + +--- + +## `flow{def}` vs `expand` (when to use which) + +| Need | Prefer | +|------|--------| +| Saved reusable flow by name | `flow` + `use` | +| Planner JSON as isolated nested sub-flow (classic) | `flow` + `def` **or** `expand` + `expandMode: "nested"` | +| Fragment phases must appear on the **parent** run as `-` | `expand` + `expandMode: "graft"` | +| First of several static approaches (latency) | `race` (not tournament) | + +`expand` is a first-class phase type (Horizon B). Dynamic validation / nesting / +breadth caps match `flow{def}`. **Event kernel** still excludes `race`/`expand` +(imperative path only until step handlers exist). --- diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index d74d1bb..80d3041 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -471,10 +471,16 @@ Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. --- -## Caveats (declared but not yet enforced) +## Caveats (declared but not yet enforced / partial) -These keys validate but the runtime does **not** act on them yet — don't rely on -them for behavior: +These keys validate but the runtime does **not** fully act on them yet — don't +rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. +- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). +- **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does + **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, + cross-run cache, and Shared Context Tree force the **imperative** path. +- **`taskflow-dsl decompile`** — semantic readability, **not** literal round-trip + (e.g. expand `def` objects may collapse to a placeholder string). diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md b/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md index 6a178e2..f989314 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md @@ -2,8 +2,23 @@ # 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`). +Load this when a flow needs: runtime-generated work (`flow{def}` / `expand`) or +isolated working directories (`cwd: temp/dedicated/worktree`). + +--- + +## `flow{def}` vs `expand` (when to use which) + +| Need | Prefer | +|------|--------| +| Saved reusable flow by name | `flow` + `use` | +| Planner JSON as isolated nested sub-flow (classic) | `flow` + `def` **or** `expand` + `expandMode: "nested"` | +| Fragment phases must appear on the **parent** run as `-` | `expand` + `expandMode: "graft"` | +| First of several static approaches (latency) | `race` (not tournament) | + +`expand` is a first-class phase type (Horizon B). Dynamic validation / nesting / +breadth caps match `flow{def}`. **Event kernel** still excludes `race`/`expand` +(imperative path only until step handlers exist). --- diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index 90b27c5..24d6148 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -472,10 +472,16 @@ Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. --- -## Caveats (declared but not yet enforced) +## Caveats (declared but not yet enforced / partial) -These keys validate but the runtime does **not** act on them yet — don't rely on -them for behavior: +These keys validate but the runtime does **not** fully act on them yet — don't +rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. +- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). +- **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does + **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, + cross-run cache, and Shared Context Tree force the **imperative** path. +- **`taskflow-dsl decompile`** — semantic readability, **not** literal round-trip + (e.g. expand `def` objects may collapse to a placeholder string). diff --git a/packages/pi-taskflow/skills/taskflow/advanced.md b/packages/pi-taskflow/skills/taskflow/advanced.md index ae4abe1..4c1858d 100644 --- a/packages/pi-taskflow/skills/taskflow/advanced.md +++ b/packages/pi-taskflow/skills/taskflow/advanced.md @@ -9,6 +9,21 @@ directories, or surgical re-execution after the world changes --- +## `flow{def}` vs `expand` (when to use which) + +| Need | Prefer | +|------|--------| +| Saved reusable flow by name | `flow` + `use` | +| Planner JSON as isolated nested sub-flow (classic) | `flow` + `def` **or** `expand` + `expandMode: "nested"` | +| Fragment phases must appear on the **parent** run as `-` | `expand` + `expandMode: "graft"` | +| First of several static approaches (latency) | `race` (not tournament) | + +`expand` is a first-class phase type (Horizon B). Dynamic validation / nesting / +breadth caps match `flow{def}`. **Event kernel** still excludes `race`/`expand` +(imperative path only until step handlers exist). + +--- + ## Shared Context Tree (blackboard + supervision) — opt-in By default subagents are fully isolated: they share nothing and only return a diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index 53ce867..a41df70 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -475,10 +475,16 @@ Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. --- -## Caveats (declared but not yet enforced) +## Caveats (declared but not yet enforced / partial) -These keys validate but the runtime does **not** act on them yet — don't rely on -them for behavior: +These keys validate but the runtime does **not** fully act on them yet — don't +rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. +- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). +- **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does + **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, + cross-run cache, and Shared Context Tree force the **imperative** path. +- **`taskflow-dsl decompile`** — semantic readability, **not** literal round-trip + (e.g. expand `def` objects may collapse to a placeholder string). diff --git a/skills-src/taskflow/advanced.md b/skills-src/taskflow/advanced.md index 3b345b6..53dced8 100644 --- a/skills-src/taskflow/advanced.md +++ b/skills-src/taskflow/advanced.md @@ -9,12 +9,27 @@ directories, or surgical re-execution after the world changes # 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`). +Load this when a flow needs: runtime-generated work (`flow{def}` / `expand`) or +isolated working directories (`cwd: temp/dedicated/worktree`). --- +## `flow{def}` vs `expand` (when to use which) + +| Need | Prefer | +|------|--------| +| Saved reusable flow by name | `flow` + `use` | +| Planner JSON as isolated nested sub-flow (classic) | `flow` + `def` **or** `expand` + `expandMode: "nested"` | +| Fragment phases must appear on the **parent** run as `-` | `expand` + `expandMode: "graft"` | +| First of several static approaches (latency) | `race` (not tournament) | + +`expand` is a first-class phase type (Horizon B). Dynamic validation / nesting / +breadth caps match `flow{def}`. **Event kernel** still excludes `race`/`expand` +(imperative path only until step handlers exist). + +--- + ## Shared Context Tree (blackboard + supervision) — opt-in diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index dedba89..544a158 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -512,10 +512,16 @@ Design docs: `docs/rfc-0.2.0-s4-mvp.md`, `docs/rfc-0.2.0-dsl-phases-horizon.md`. --- -## Caveats (declared but not yet enforced) +## Caveats (declared but not yet enforced / partial) -These keys validate but the runtime does **not** act on them yet — don't rely on -them for behavior: +These keys validate but the runtime does **not** fully act on them yet — don't +rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. +- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). +- **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does + **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, + cross-run cache, and Shared Context Tree force the **imperative** path. +- **`taskflow-dsl decompile`** — semantic readability, **not** literal round-trip + (e.g. expand `def` objects may collapse to a placeholder string). diff --git a/website/.plans/docs-enhancement-master-plan.md b/website/.plans/docs-enhancement-master-plan.md index 3b50c68..f511341 100644 --- a/website/.plans/docs-enhancement-master-plan.md +++ b/website/.plans/docs-enhancement-master-plan.md @@ -61,7 +61,7 @@ 更新 `app/[lang]/page.tsx`: -- 新增 **By the numbers** 数据区(4 hosts / 10 phase types / 0 deps / resume) +- 新增 **By the numbers** 数据区(4 hosts / 12 phase types / 0 deps / resume) - 新增 **Testimonials** 社会证明区(3 条角色化引用) - 新增 **Comparison** 对比区(命令式 vs taskflow,精简版表格) - 新增/改进底部 CTAs:Read docs / Browse templates / Join community diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 1301dc7..77aee6d 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -96,12 +96,12 @@ const translations = { stats: { heading: "By the numbers", hosts: { - value: "4 hosts", - label: "Pi, Codex, Claude Code, OpenCode", + value: "5 hosts", + label: "Pi, Codex, Claude Code, OpenCode, Grok", }, phases: { - value: "10 phase types", - label: "agent, map, gate, reduce, and more", + value: "12 phase types", + label: "agent, map, gate, race, expand, and more", }, deps: { value: "0 runtime deps", @@ -226,12 +226,12 @@ const translations = { stats: { heading: "数据一览", hosts: { - value: "4 个宿主", - label: "Pi、Codex、Claude Code、OpenCode", + value: "5 个宿主", + label: "Pi、Codex、Claude Code、OpenCode、Grok", }, phases: { - value: "10 种阶段类型", - label: "agent、map、gate、reduce 等", + value: "12 种阶段类型", + label: "agent、map、gate、race、expand 等", }, deps: { value: "0 个运行时依赖", diff --git a/website/content/docs/en/concepts/index.mdx b/website/content/docs/en/concepts/index.mdx index 4fe8a12..a406308 100644 --- a/website/content/docs/en/concepts/index.mdx +++ b/website/content/docs/en/concepts/index.mdx @@ -24,7 +24,7 @@ The concepts build on each other. Read them top to bottom the first time: **[The DAG Model](/en/docs/concepts/dag)** — why work is a graph of `dependsOn` edges, and why array order is *not* an edge. - **[Phase Types](/en/docs/concepts/phases)** — the ten building blocks (`agent`, `map`, `gate`, `loop`, …) and when to reach for each. + **[Phase Types](/en/docs/concepts/phases)** — the twelve building blocks (`agent`, `map`, `gate`, `loop`, …) and when to reach for each. **[Interpolation](/en/docs/concepts/interpolation)** — how `{steps.X.output}` and friends move data between phases, deterministically and zero-token. diff --git a/website/content/docs/en/concepts/phases.mdx b/website/content/docs/en/concepts/phases.mdx index 1102076..343b5f7 100644 --- a/website/content/docs/en/concepts/phases.mdx +++ b/website/content/docs/en/concepts/phases.mdx @@ -5,7 +5,7 @@ description: The ten building blocks of a taskflow, and how to choose between th A **phase** is one unit of work in a taskflow. Each phase runs as an isolated subagent call, shell command, or control decision, and the runtime wires them into a DAG via `dependsOn`. -The best way to understand the ten phase types is to watch a single problem evolve. Imagine you are building a tool that reviews a pull request. +The best way to understand the twelve phase types is to watch a single problem evolve. Imagine you are building a tool that reviews a pull request. ## Start simple: one agent @@ -200,9 +200,9 @@ Putting it together: } ``` -## The other five phase types +## The other phase types -The PR-review story used the five most common types. The remaining five solve specific problems: +The PR-review story used the five most common types. The remaining types solve specific problems: ### `approval` — pause for a human @@ -273,6 +273,35 @@ Use for builds, tests, lint, or file transformations. Zero tokens. } ``` +### `race` — first completed branch wins + +Use when several approaches can answer the same question and you want the **fastest** successful result (not a quality judge). + +```json title="race-example.json" +{ + "id": "quick", + "type": "race", + "branches": [ + { "task": "Heuristic answer…", "agent": "executor" }, + { "task": "Thorough answer…", "agent": "researcher" } + ] +} +``` + +### `expand` — run a dynamic fragment (nested or graft) + +Use when a planner emits a mini-flow JSON and you want the runtime to execute it. `expandMode: "graft"` promotes child phase states onto the parent as `-`. + +```json title="expand-example.json" +{ + "id": "grow", + "type": "expand", + "expandMode": "nested", + "def": "{steps.plan.json}", + "dependsOn": ["plan"] +} +``` + ## Choosing a phase type | If your problem is... | Use | Why | @@ -287,6 +316,8 @@ Use for builds, tests, lint, or file transformations. Zero tokens. | "Repeat until a condition is met" | `loop` | Iterative refinement. | | "Pick the best of several attempts" | `tournament` | Competitive selection for subjective work. | | "Run a command, no LLM" | `script` | Builds, tests, file ops. | +| "Keep the first approach that finishes" | `race` | Latency over exhaustive wait/judge. | +| "Run a planner-emitted fragment" | `expand` | Nested isolation or graft-promote onto parent. | ## Phase lifecycle diff --git a/website/content/docs/en/concepts/shared-context-tree.mdx b/website/content/docs/en/concepts/shared-context-tree.mdx index 0b6c5a8..d73b690 100644 --- a/website/content/docs/en/concepts/shared-context-tree.mdx +++ b/website/content/docs/en/concepts/shared-context-tree.mdx @@ -331,7 +331,7 @@ Together they mean you can coordinate complex multi-agent workflows without sacr The default behavior shared context builds on. - How the ten phase types use shared context. + How the twelve phase types use shared context. How shared context survives across sessions. diff --git a/website/content/docs/en/getting-started.mdx b/website/content/docs/en/getting-started.mdx index 1eea622..61a0a51 100644 --- a/website/content/docs/en/getting-started.mdx +++ b/website/content/docs/en/getting-started.mdx @@ -239,7 +239,7 @@ From then on you can run it by name: Why a declarative graph beats an imperative script. - Understand the DAG model and the ten phase types. + Understand the DAG model and the twelve phase types. Full reference for flow definitions and phase fields. diff --git a/website/content/docs/en/guides/agents-and-model-roles.mdx b/website/content/docs/en/guides/agents-and-model-roles.mdx index 1d71901..254170b 100644 --- a/website/content/docs/en/guides/agents-and-model-roles.mdx +++ b/website/content/docs/en/guides/agents-and-model-roles.mdx @@ -476,7 +476,7 @@ With a project agent in `.pi/agents/team-reviewer.md` that encodes your team's s - Learn about all 10 phase types and when to use each one. + Learn about all 12 phase types and when to use each one. Spawn competing variants and let a judge pick the best result. diff --git a/website/content/docs/en/meta.json b/website/content/docs/en/meta.json index 681e73c..a3e65ae 100644 --- a/website/content/docs/en/meta.json +++ b/website/content/docs/en/meta.json @@ -45,6 +45,7 @@ "---Reference---", "reference/shorthand", "reference/commands", + "reference/typescript-dsl", "reference/incremental-recompute", "community", "---Blog---", diff --git a/website/content/docs/en/reference/index.mdx b/website/content/docs/en/reference/index.mdx index 354bbcc..b0021e9 100644 --- a/website/content/docs/en/reference/index.mdx +++ b/website/content/docs/en/reference/index.mdx @@ -24,3 +24,5 @@ Two pages live here: Pi `/tf` commands and Codex / Claude / OpenCode / Grok MCP tools. + +- **[TypeScript DSL](/en/docs/reference/typescript-dsl)** — compile-time `.tf.ts` → Taskflow JSON (`taskflow-dsl`). diff --git a/website/content/docs/en/reference/typescript-dsl.mdx b/website/content/docs/en/reference/typescript-dsl.mdx new file mode 100644 index 0000000..cce42cc --- /dev/null +++ b/website/content/docs/en/reference/typescript-dsl.mdx @@ -0,0 +1,64 @@ +--- +title: TypeScript DSL (`taskflow-dsl`) +description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, then FlowIR. +--- + +# TypeScript DSL (`taskflow-dsl`) + +S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. + + + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. npm availability may lag the monorepo; prefer a workspace install / local path for preview builds. Package version on the `release/0.2.0` line may still show `0.1.7` until a formal 0.2.0 publish. + + +## Workflow + +```bash +# monorepo / built package +taskflow-dsl new audit +# edit audit.tf.ts +taskflow-dsl check audit.tf.ts +taskflow-dsl build audit.tf.ts --emit both +# → audit.taskflow.json (+ optional FlowIR) +# Then: taskflow_verify / taskflow_run with defineFile=audit.taskflow.json +``` + +| Command | Purpose | +|---------|---------| +| `new [name]` | Hello skeleton (`.tf.ts` or `--json-escape`) | +| `check ` | Erase + `validateTaskflow` (`--typecheck` for tsc) | +| `build ` | Emit Taskflow JSON / FlowIR | +| `decompile ` | Taskflow JSON → readable `.tf.ts` (**semantic**, not literal round-trip) | + +## Authoring shape + +```ts +import { flow, agent, map, reduce, json, race, expand } from "taskflow-dsl"; + +export default flow("audit", (ctx) => { + ctx.budget({ maxUSD: 2 }); + const discover = agent("List files under {args.dir}", { + output: json<{ path: string }[]>(), + }); + const each = map(discover, (item) => agent(`Audit ${item.path}`)); + return reduce([each], () => agent("Write one summary")); +}); +``` + +Unbuilt `.tf.ts` must **not** be executed as a Node program — runes throw `TFDSL_ERASE_ONLY`. + +## Kinds ↔ runes (summary) + +JSON still has **12** phase types. DSL runes erase to those types (plus sugars like `gate.automated` / `gate.scored`, `expand.nested` / `expand.graft`, `subflow.def`). See monorepo `skills` configuration §9 and `docs/rfc-0.2.0-s4-mvp.md`. + +## What S4 is not + +- Not a second runtime / event-kernel flip (that is **S5**). +- Not an MCP `taskflow_build` tool in the ship bar. +- Not full PhaseSchema field coverage on day one (FULL language goal vs MVP Y-slice). + +## Next + +- [Phase Types](/en/docs/syntax/phase-types) — JSON kinds including `race` / `expand` +- [Commands](/en/docs/reference/commands) — host MCP / `/tf` surfaces +- Monorepo: `docs/rfc-0.2.0-s4-mvp.md`, `docs/internal/claim-vs-impl-0.2.0.md` diff --git a/website/content/docs/en/syntax/control-flow.mdx b/website/content/docs/en/syntax/control-flow.mdx index c97e23b..e1f32bd 100644 --- a/website/content/docs/en/syntax/control-flow.mdx +++ b/website/content/docs/en/syntax/control-flow.mdx @@ -483,6 +483,6 @@ Barewords `true` / `false` / `null` are **keywords**, not strings. To compare ag Run-wide cost caps and over-budget behavior. - The 10 phase types and every field on each. + The 12 phase types and every field on each. diff --git a/website/content/docs/en/syntax/flow-definition.mdx b/website/content/docs/en/syntax/flow-definition.mdx index 3d0c6fb..66cde35 100644 --- a/website/content/docs/en/syntax/flow-definition.mdx +++ b/website/content/docs/en/syntax/flow-definition.mdx @@ -380,7 +380,7 @@ Every flow is validated before execution, in two modes: | `Missing or invalid 'name'` | `name` missing, empty, or not a string. | | `Taskflow must have at least one phase` | `phases` missing or empty. | | `Duplicate phase id: X` | Two phases share an `id`. | -| `Phase 'X': unknown type 'Y'` | `type` not in the 10 allowed values. | +| `Phase 'X': unknown type 'Y'` | `type` not in the 12 allowed values. | | `Phase 'X': dependsOn unknown phase 'Y'` | A `dependsOn` or `from` entry has no matching phase. | | `Dependency cycle detected: A -> B -> A` | Kahn's algorithm found a cycle. | | `Only one phase may be marked 'final' (found N)` | More than one `final: true` phase. | diff --git a/website/content/docs/en/syntax/index.mdx b/website/content/docs/en/syntax/index.mdx index 6acee33..b89f4f5 100644 --- a/website/content/docs/en/syntax/index.mdx +++ b/website/content/docs/en/syntax/index.mdx @@ -16,7 +16,7 @@ The pages layer on top of each other. Read them in this order the first time: **[Flow Definition](/en/docs/syntax/flow-definition)** — the top-level container: `name`, `args`, `concurrency`, `budget`, `agentScope`, `incremental`, and the `phases` array. - **[Phase Types](/en/docs/syntax/phase-types)** — every field on every one of the ten phase types, plus the per-type requirement matrix. + **[Phase Types](/en/docs/syntax/phase-types)** — every field on every one of the twelve phase types, plus the per-type requirement matrix. **[Control Flow](/en/docs/syntax/control-flow)** — `when` guards, `join`, `retry`, `timeout`, and self-healing gates. diff --git a/website/content/docs/en/syntax/phase-types.mdx b/website/content/docs/en/syntax/phase-types.mdx index a59019d..a33dea5 100644 --- a/website/content/docs/en/syntax/phase-types.mdx +++ b/website/content/docs/en/syntax/phase-types.mdx @@ -3,7 +3,7 @@ title: Phase Types description: The ten building blocks of a taskflow, and when to reach for each one. --- -A taskflow is a directed graph of **phases**. Each phase is one unit of work — an isolated subagent call, a shell command, or a control decision — and the runtime wires them together with `dependsOn`. This page walks through all ten phase types the way you will actually meet them: starting with the one you will use 80% of the time, then adding power as the problem demands. +A taskflow is a directed graph of **phases**. Each phase is one unit of work — an isolated subagent call, a shell command, or a control decision — and the runtime wires them together with `dependsOn`. This page walks through all twelve phase types the way you will actually meet them: starting with the one you will use 80% of the time, then adding power as the problem demands. You might be wondering: *do I need to learn all ten before I can do anything useful?* No. Most flows are built from three or four types. The rest exist for specific situations — human approval, iterative refinement, competing drafts — and you can ignore them until you meet one of those situations. @@ -419,6 +419,47 @@ To pass dynamic values into a script safely, pipe them through `input` (which su `script` phases cannot use `retry` or `output: "json"`. `timeout` defaults to `60000` and caps at `300000` ms. +## First finish wins: `race` + +When several approaches can answer the same question and **latency** matters more than waiting for every branch, use `race`. Branches start together; the first successful completion becomes the phase output (unlike `parallel`, which waits for all, or `tournament`, which judges quality after all variants). + +```json title="race — first completed wins" +{ + "id": "quick", + "type": "race", + "branches": [ + { "task": "Answer with a short heuristic…", "agent": "executor" }, + { "task": "Answer after a brief docs look…", "agent": "researcher" } + ], + "final": true +} +``` + + + **`cancelLosers` is reserved.** The schema accepts it for forward compatibility, but losers are **not** aborted yet — they may run to natural completion. Cap spend with `budget` / `timeout`. + + +## Dynamic fragment: `expand` + +`expand` runs a fragment Taskflow from `def` (inline object, phases array, or `{steps.plan.json}`). Two modes: + +| `expandMode` | Behavior | +|---|---| +| `nested` (default) | Isolated sub-flow (like `flow` + `def`); child ids stay off the parent. | +| `graft` | After success, promote child phase states onto the parent as `-`. | + +```json title="expand — graft promote" +{ + "id": "grow", + "type": "expand", + "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"] +} +``` + +Optional `maxNodes` caps fragment size (default 50, hard max 100). Dynamic validation matches `flow{def}` security caps. + ## The complete PR-review flow Putting the five most common types together — `agent`, `map`, `reduce`, `gate`, with a `script` step for lint — here is the full review flow you can copy and adapt: @@ -503,7 +544,7 @@ Every phase accepts these fields. Some are ignored or forbidden on specific phas | Field | Type | Default | Description | |---|---|---|---| | `id` | `string` | — | **Required.** Unique identifier, referenced in `dependsOn` and `{steps..output}`. Use hyphens, not underscores. | -| `type` | `string` | `"agent"` | One of the ten phase types. | +| `type` | `string` | `"agent"` | One of the twelve phase types (`agent`…`script`, `race`, `expand`). | | `agent` | `string` | first discovered | Agent name. Must use hyphens. | | `task` | `string` | — | Task prompt. Supports interpolation. Required for `agent`/`map`/`reduce`/`loop`/`tournament` (unless `branches` is used). | | `dependsOn` | `string[]` | `[]` | Phase ids that must complete before this phase starts. Forms the DAG edges. | @@ -549,8 +590,10 @@ Every phase accepts these fields. Some are ignored or forbidden on specific phas | `loop` | required | — | — | — | — | — | — | — | — | required | optional | — | — | — | — | | `tournament` | required³ | — | — | optional³ | — | — | — | — | — | — | — | optional | optional | — | — | | `script` | ignored | — | — | — | — | — | — | required | optional | — | — | — | — | — | — | +| `race` | ignored | — | — | required⁴ | — | — | — | — | — | — | — | — | — | — | — | +| `expand` | ignored | — | — | — | — | required (`def`) | optional | — | — | — | — | — | — | — | — | -¹ `gate` requires `task` unless `score` is used. ² `flow` requires exactly one of `use` or `def`. ³ `tournament` requires `task` unless `branches` is used. +¹ `gate` requires `task` unless `score` is used. ² `flow` requires exactly one of `use` or `def`. ³ `tournament` requires `task` unless `branches` is used. ⁴ `race` requires ≥2 `branches`. Expand also accepts `expandMode` (`nested`\|`graft`) and `maxNodes`. ### Per-type field details diff --git a/website/content/docs/zh-cn/concepts/context-isolation.mdx b/website/content/docs/zh-cn/concepts/context-isolation.mdx index 7bde894..e411c90 100644 --- a/website/content/docs/zh-cn/concepts/context-isolation.mdx +++ b/website/content/docs/zh-cn/concepts/context-isolation.mdx @@ -145,7 +145,7 @@ peek 是**只读的**。它加载持久化的运行状态并打印某一个阶 隔离正是让跨会话重放成为可能的基础。 - 十个构建块,以及它们如何隔离。 + 十二个构建块,以及它们如何隔离。 跨运行记忆化构建在同一份磁盘状态之上。 diff --git a/website/content/docs/zh-cn/concepts/dag.mdx b/website/content/docs/zh-cn/concepts/dag.mdx index d128624..058534e 100644 --- a/website/content/docs/zh-cn/concepts/dag.mdx +++ b/website/content/docs/zh-cn/concepts/dag.mdx @@ -218,7 +218,7 @@ Dependency cycle detected: a -> b -> a - 十种节点类型,以及何时用哪种。 + 十二种节点类型,以及何时用哪种。 `when` 守卫、`join`、`retry` 和路由。 diff --git a/website/content/docs/zh-cn/concepts/index.mdx b/website/content/docs/zh-cn/concepts/index.mdx index ebb2b9d..d20d8d7 100644 --- a/website/content/docs/zh-cn/concepts/index.mdx +++ b/website/content/docs/zh-cn/concepts/index.mdx @@ -6,7 +6,7 @@ description: taskflow 声明式 DAG 模型背后的思想。 这一章是 taskflow 背后的*心智模型*。它不列举每个字段——那是[语法参考](/zh-cn/docs/syntax)的事——也不带你走某个宿主的安装——那是[指南](/zh-cn/docs/guides)的事。它解释的是让 taskflow 不同于你手写命令式脚本的六个理念: - 工作是一张**图**,而不是一串调用; -- 图由十种**阶段类型**搭建而成; +- 图由十二种**阶段类型**搭建而成; - 数据通过**插值**在阶段之间流动; - 整张图在任何 token 花出去之前就被**验证**过; - 中间输出与你的上下文**隔离**; @@ -24,7 +24,7 @@ description: taskflow 声明式 DAG 模型背后的思想。 **[DAG 模型](/zh-cn/docs/concepts/dag)** —— 为什么工作是一张由 `dependsOn` 边构成的图,以及为什么数组顺序*不是*一条边。 - **[阶段类型](/zh-cn/docs/concepts/phases)** —— 十个构建块(`agent`、`map`、`gate`、`loop`……)以及何时该用哪一个。 + **[阶段类型](/zh-cn/docs/concepts/phases)** —— 十二个构建块(`agent`、`map`、`gate`、`loop`……)以及何时该用哪一个。 **[插值](/zh-cn/docs/concepts/interpolation)** —— `{steps.X.output}` 之类的东西如何零 token、确定性地在阶段之间搬运数据。 diff --git a/website/content/docs/zh-cn/concepts/phases.mdx b/website/content/docs/zh-cn/concepts/phases.mdx index c8554d7..f9f5559 100644 --- a/website/content/docs/zh-cn/concepts/phases.mdx +++ b/website/content/docs/zh-cn/concepts/phases.mdx @@ -1,11 +1,11 @@ --- title: 阶段 -description: taskflow 的十个构建块,以及如何在它们之间做选择。 +description: taskflow 的十二个构建块,以及如何在它们之间做选择。 --- **阶段**是 taskflow 中的一个工作单元。每个阶段作为一次隔离的子代理调用、一条 shell 命令或一个控制决策运行,运行时通过 `dependsOn` 把它们连成一张 DAG。 -理解这十种阶段类型最好的方式,是看同一个问题如何一步步演化。想象你在做一个审查 pull request 的工具。 +理解这十二种阶段类型最好的方式,是看同一个问题如何一步步演化。想象你在做一个审查 pull request 的工具。 ## 从简单开始:一个 agent @@ -273,6 +273,35 @@ PR 审查的故事用到了最常见的五种类型。剩下的五种用来解 } ``` +### `race` —— 先完成的分支胜出 + +多种路径都能回答同一问题、且你要**最快**的成功结果时用(不是质量裁判)。 + +```json title="race-example.json" +{ + "id": "quick", + "type": "race", + "branches": [ + { "task": "启发式作答…", "agent": "executor" }, + { "task": "仔细作答…", "agent": "researcher" } + ] +} +``` + +### `expand` —— 运行动态片段(nested / graft) + +规划器产出迷你 flow JSON、由运行时执行时用。`expandMode: "graft"` 会把子阶段状态提升到父 run,id 为 `-`。 + +```json title="expand-example.json" +{ + "id": "grow", + "type": "expand", + "expandMode": "nested", + "def": "{steps.plan.json}", + "dependsOn": ["plan"] +} +``` + ## 如何选择阶段类型 | 如果你的问题是…… | 用 | 原因 | @@ -287,6 +316,8 @@ PR 审查的故事用到了最常见的五种类型。剩下的五种用来解 | "重复直到某个条件满足" | `loop` | 迭代式精炼。 | | "从多次尝试里挑最好的" | `tournament` | 针对主观工作的竞争式选择。 | | "跑一条命令,不用 LLM" | `script` | 构建、测试、文件操作。 | +| "保留最先完成的路径" | `race` | 延迟优先于等齐/裁判。 | +| "跑规划器吐出的片段" | `expand` | 隔离子流或 graft 提升到父 run。 | ## 阶段生命周期 diff --git a/website/content/docs/zh-cn/concepts/shared-context-tree.mdx b/website/content/docs/zh-cn/concepts/shared-context-tree.mdx index 202d74d..2a57d63 100644 --- a/website/content/docs/zh-cn/concepts/shared-context-tree.mdx +++ b/website/content/docs/zh-cn/concepts/shared-context-tree.mdx @@ -331,7 +331,7 @@ taskflow 通过共享上下文树(Shared Context Tree)解决了这两个问 共享上下文构建的默认行为。 - 十种阶段类型如何使用共享上下文。 + 十二种阶段类型如何使用共享上下文。 共享上下文如何跨会话存活。 diff --git a/website/content/docs/zh-cn/getting-started.mdx b/website/content/docs/zh-cn/getting-started.mdx index cac829d..880b6bc 100644 --- a/website/content/docs/zh-cn/getting-started.mdx +++ b/website/content/docs/zh-cn/getting-started.mdx @@ -239,7 +239,7 @@ taskflow 让你把多步骤的 agent 工作描述成一张声明式图。你不 为什么声明式图比命令式脚本更好。 - 理解 DAG 模型和十种阶段类型。 + 理解 DAG 模型和十二种阶段类型。 flow 定义和阶段字段的完整参考。 diff --git a/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx b/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx index 1668d2b..9e5413c 100644 --- a/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx +++ b/website/content/docs/zh-cn/guides/agents-and-model-roles.mdx @@ -476,7 +476,7 @@ Follow these principles: - 了解全部 10 种 phase 类型以及何时使用每一种。 + 了解全部 12 种 phase 类型以及何时使用每一种。 生成竞争变体并让 judge 挑选最佳结果。 diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index cd056d4..90e35d9 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -232,7 +232,7 @@ claude mcp remove taskflow # 如果手动注册 五分钟速览(如果你还没看过)。 - 可组合成 DAG 的十个构建块。 + 可组合成 DAG 的十二个构建块。 同一个运行时,接入 OpenAI Codex。 diff --git a/website/content/docs/zh-cn/guides/code-audit-case-study.mdx b/website/content/docs/zh-cn/guides/code-audit-case-study.mdx index 41a0be3..6921df5 100644 --- a/website/content/docs/zh-cn/guides/code-audit-case-study.mdx +++ b/website/content/docs/zh-cn/guides/code-audit-case-study.mdx @@ -341,7 +341,7 @@ gate 如何决策: - 十种构建块——包括这里用到的 `map`、`parallel`、`reduce` 和 `gate`。 + 十二种构建块——包括这里用到的 `map`、`parallel`、`reduce` 和 `gate`。 跨运行记忆化、指纹,以及 `why-stale` / `recompute` 工具。 diff --git a/website/content/docs/zh-cn/guides/codex.mdx b/website/content/docs/zh-cn/guides/codex.mdx index aba5560..6e008a4 100644 --- a/website/content/docs/zh-cn/guides/codex.mdx +++ b/website/content/docs/zh-cn/guides/codex.mdx @@ -198,7 +198,7 @@ codex plugin remove taskflow@taskflow 如果你还没看过,这里是五分钟导览。 - 你可以组合进一个 DAG 的十个构建块。 + 你可以组合进一个 DAG 的十二个构建块。 同一个运行时,接入 Pi 编程智能体。 diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index 3d637ac..6c4ed01 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -243,7 +243,7 @@ opencode mcp remove taskflow # 或删除 opencode.json 中的 mcp.taskflow 五分钟速览(如果你还没看过)。 - 可组合成 DAG 的十个构建块。 + 可组合成 DAG 的十二个构建块。 同一个运行时,接入 Claude Code。 diff --git a/website/content/docs/zh-cn/guides/pi.mdx b/website/content/docs/zh-cn/guides/pi.mdx index 39353da..45e763b 100644 --- a/website/content/docs/zh-cn/guides/pi.mdx +++ b/website/content/docs/zh-cn/guides/pi.mdx @@ -202,7 +202,7 @@ taskflow 自带十八个内置 agent(scout、security-reviewer、writer 等等 如果你还没看过,这里是五分钟导览。 - 你可以组合进一个 DAG 的十个构建块。 + 你可以组合进一个 DAG 的十二个构建块。 同一个运行时,接入 OpenAI Codex。 diff --git a/website/content/docs/zh-cn/meta.json b/website/content/docs/zh-cn/meta.json index f5a43e0..502cdb7 100644 --- a/website/content/docs/zh-cn/meta.json +++ b/website/content/docs/zh-cn/meta.json @@ -45,6 +45,7 @@ "---参考---", "reference/shorthand", "reference/commands", + "reference/typescript-dsl", "reference/incremental-recompute", "community", "---博客---", diff --git a/website/content/docs/zh-cn/reference/index.mdx b/website/content/docs/zh-cn/reference/index.mdx index cce7160..c1d5a49 100644 --- a/website/content/docs/zh-cn/reference/index.mdx +++ b/website/content/docs/zh-cn/reference/index.mdx @@ -24,3 +24,5 @@ description: 简写和命令速查。 Pi `/tf` 命令和 Codex / Claude / OpenCode / Grok / Grok / Grok 的 MCP 工具。 + +- **[TypeScript DSL](/zh-cn/docs/reference/typescript-dsl)** — 编译期 `.tf.ts` → Taskflow JSON(`taskflow-dsl`)。 diff --git a/website/content/docs/zh-cn/reference/typescript-dsl.mdx b/website/content/docs/zh-cn/reference/typescript-dsl.mdx new file mode 100644 index 0000000..aed6fc9 --- /dev/null +++ b/website/content/docs/zh-cn/reference/typescript-dsl.mdx @@ -0,0 +1,63 @@ +--- +title: TypeScript DSL(`taskflow-dsl`) +description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再进 FlowIR。 +--- + +# TypeScript DSL(`taskflow-dsl`) + +S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 + + + **包状态。** `taskflow-dsl` 在 monorepo 的 `packages/taskflow-dsl`。纯 JSON 作者不需要它。npm 发布可能滞后;预览请用 workspace / 本地 path。`release/0.2.0` 线上版本号在正式 0.2.0 发版前可能仍是 `0.1.7`。 + + +## 工作流 + +```bash +taskflow-dsl new audit +# 编辑 audit.tf.ts +taskflow-dsl check audit.tf.ts +taskflow-dsl build audit.tf.ts --emit both +# → audit.taskflow.json +# 然后 taskflow_verify / taskflow_run + defineFile= +``` + +| 命令 | 作用 | +|------|------| +| `new [name]` | 骨架(`.tf.ts` 或 `--json-escape`) | +| `check ` | erase + `validateTaskflow`(`--typecheck` 走 tsc) | +| `build ` | 产出 Taskflow JSON / FlowIR | +| `decompile ` | JSON → 可读 `.tf.ts`(**语义**往返,非字面) | + +## 写法示例 + +```ts +import { flow, agent, map, reduce, json } from "taskflow-dsl"; + +export default flow("audit", (ctx) => { + ctx.budget({ maxUSD: 2 }); + const discover = agent("List files under {args.dir}", { + output: json<{ path: string }[]>(), + }); + const each = map(discover, (item) => agent(`Audit ${item.path}`)); + return reduce([each], () => agent("Write one summary")); +}); +``` + +未 build 的 `.tf.ts` **不要**当 Node 程序执行——rune 会抛 `TFDSL_ERASE_ONLY`。 + +## 种类 ↔ rune + +JSON 仍是 **12** 种 phase。DSL rune erase 到这些 type(另有 `gate.automated` / `gate.scored`、`expand.nested` / `expand.graft`、`subflow.def` 等糖)。详见 monorepo skills configuration §9 与 `docs/rfc-0.2.0-s4-mvp.md`。 + +## S4 不是什么 + +- 不是第二运行时 / event-kernel 默认 ON(那是 **S5**)。 +- 不是出货门内的 MCP `taskflow_build`。 +- 不是 day-one 就覆盖全部 PhaseSchema 字段。 + +## 下一步 + +- [阶段类型](/zh-cn/docs/syntax/phase-types) — 含 `race` / `expand` 的 JSON kinds +- [命令参考](/zh-cn/docs/reference/commands) +- monorepo:`docs/rfc-0.2.0-s4-mvp.md`、`docs/internal/claim-vs-impl-0.2.0.md` diff --git a/website/content/docs/zh-cn/showcase/index.mdx b/website/content/docs/zh-cn/showcase/index.mdx index f1ccff0..d754b11 100644 --- a/website/content/docs/zh-cn/showcase/index.mdx +++ b/website/content/docs/zh-cn/showcase/index.mdx @@ -46,7 +46,7 @@ description: 端到端案例研究——用 taskflow 解决真实问题,逐阶 一份简明的前后对比,附 token 成本走查。 - 案例研究用来组合的十种构建块。 + 案例研究用来组合的十二种构建块。 每个字段,一处可查。 diff --git a/website/content/docs/zh-cn/syntax/control-flow.mdx b/website/content/docs/zh-cn/syntax/control-flow.mdx index 1e0eeb0..4890156 100644 --- a/website/content/docs/zh-cn/syntax/control-flow.mdx +++ b/website/content/docs/zh-cn/syntax/control-flow.mdx @@ -483,6 +483,6 @@ LLM 提供方会打嗝。速率限制、5xx 和超时是家常便饭。taskflow 运行级成本上限和超预算行为。 - 10 种阶段类型及每种上的每个字段。 + 12 种阶段类型及每种上的每个字段。 diff --git a/website/content/docs/zh-cn/syntax/flow-definition.mdx b/website/content/docs/zh-cn/syntax/flow-definition.mdx index e9a9cad..dc4672a 100644 --- a/website/content/docs/zh-cn/syntax/flow-definition.mdx +++ b/website/content/docs/zh-cn/syntax/flow-definition.mdx @@ -380,7 +380,7 @@ description: 把阶段包成一个 taskflow 的顶层字段。 | `Missing or invalid 'name'` | `name` 缺失、为空、或不是字符串。 | | `Taskflow must have at least one phase` | `phases` 缺失或为空。 | | `Duplicate phase id: X` | 两个阶段共用一个 `id`。 | -| `Phase 'X': unknown type 'Y'` | `type` 不在 10 个允许值里。 | +| `Phase 'X': unknown type 'Y'` | `type` 不在 12 个允许值里。 | | `Phase 'X': dependsOn unknown phase 'Y'` | `dependsOn` 或 `from` 条目没有对应阶段。 | | `Dependency cycle detected: A -> B -> A` | Kahn 算法发现环。 | | `Only one phase may be marked 'final' (found N)` | 多于一个 `final: true` 阶段。 | diff --git a/website/content/docs/zh-cn/syntax/index.mdx b/website/content/docs/zh-cn/syntax/index.mdx index a7121b5..cb0ca5c 100644 --- a/website/content/docs/zh-cn/syntax/index.mdx +++ b/website/content/docs/zh-cn/syntax/index.mdx @@ -16,7 +16,7 @@ description: taskflow DSL 的完整参考。 **[流程定义](/zh-cn/docs/syntax/flow-definition)** —— 顶层容器:`name`、`args`、`concurrency`、`budget`、`agentScope`、`incremental`,以及 `phases` 数组。 - **[阶段类型](/zh-cn/docs/syntax/phase-types)** —— 十种阶段类型每一种的每个字段,加上按类型的需求矩阵。 + **[阶段类型](/zh-cn/docs/syntax/phase-types)** —— 十二种阶段类型每一种的每个字段,加上按类型的需求矩阵。 **[控制流](/zh-cn/docs/syntax/control-flow)** —— `when` 守卫、`join`、`retry`、`timeout`,以及自愈 gate。 diff --git a/website/content/docs/zh-cn/syntax/phase-types.mdx b/website/content/docs/zh-cn/syntax/phase-types.mdx index 7a757e5..87220fd 100644 --- a/website/content/docs/zh-cn/syntax/phase-types.mdx +++ b/website/content/docs/zh-cn/syntax/phase-types.mdx @@ -1,11 +1,11 @@ --- title: 阶段类型 -description: taskflow 的十个构建块,以及何时选用各自。 +description: taskflow 的十二个构建块,以及何时选用各自。 --- -一个 taskflow 是由**阶段**组成的有向图。每个阶段是一个工作单元 —— 一次隔离的子代理调用、一次 shell 命令、或一个控制决策 —— 运行时通过 `dependsOn` 把它们连起来。本页按你实际会遇到它们的顺序,逐一讲解全部十种阶段类型:从你 80% 场景都会用的那个开始,再随着问题变复杂逐步加上能力。 +一个 taskflow 是由**阶段**组成的有向图。每个阶段是一个工作单元 —— 一次隔离的子代理调用、一次 shell 命令、或一个控制决策 —— 运行时通过 `dependsOn` 把它们连起来。本页按你实际会遇到它们的顺序,逐一讲解全部十二种阶段类型:从你 80% 场景都会用的那个开始,再随着问题变复杂逐步加上能力。 -你可能在想:*我得先把十种全学会才能干活吗?* 不用。大多数 flow 只用三四种类型就建起来了。其余几种是给特定场景准备的 —— 人工审批、迭代精修、竞争草稿 —— 在你撞上那些场景之前,完全可以无视它们。 +你可能在想:*我得先把十二种全学会才能干活吗?* 不用。大多数 flow 只用三四种类型就建起来了。其余几种是给特定场景准备的 —— 人工审批、迭代精修、竞争草稿 —— 在你撞上那些场景之前,完全可以无视它们。 ## 何时用什么 @@ -419,6 +419,47 @@ gate 审查上游输出并给出裁决。`PASS` 让流程继续;`BLOCK` 中止 `script` 阶段不能用 `retry` 或 `output: "json"`。`timeout` 默认 `60000`、上限 `300000` ms。 +## 先完成者胜:`race` + +当多种路径都能回答同一问题、且**延迟**比等齐所有分支更重要时,用 `race`。各分支同时启动,**第一个成功完成**的输出成为阶段结果(`parallel` 会等全部;`tournament` 会等齐后由裁判比质量)。 + +```json title="race — 先完成者胜" +{ + "id": "quick", + "type": "race", + "branches": [ + { "task": "用简短启发式作答…", "agent": "executor" }, + { "task": "查一下文档再作答…", "agent": "researcher" } + ], + "final": true +} +``` + + + **`cancelLosers` 为预留字段。** schema 接受它以保持前向兼容,但败者**目前不会被中止**,可能仍跑到自然结束。请用 `budget` / `timeout` 控制开销。 + + +## 动态片段:`expand` + +`expand` 从 `def` 运行一段片段 Taskflow(内联对象、phases 数组,或 `{steps.plan.json}`)。两种模式: + +| `expandMode` | 行为 | +|---|---| +| `nested`(默认) | 隔离子流(类似 `flow`+`def`);子阶段 id 不进入父 run | +| `graft` | 成功后把子阶段状态提升到父 run,id 为 `-` | + +```json title="expand — graft 提升" +{ + "id": "grow", + "type": "expand", + "expandMode": "graft", + "def": "{steps.plan.json}", + "dependsOn": ["plan"] +} +``` + +可选 `maxNodes` 限制片段规模(默认 50,硬上限 100)。动态校验与 `flow{def}` 安全帽一致。 + ## 完整的 PR 审查 flow 把五种最常用的类型 —— `agent`、`map`、`reduce`、`gate`,加一个用于 lint 的 `script` —— 拼到一起,这就是你可以直接复制改造的完整审查 flow: @@ -503,7 +544,7 @@ gate 审查上游输出并给出裁决。`PASS` 让流程继续;`BLOCK` 中止 | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| | `id` | `string` | — | **必需。** 唯一标识符,在 `dependsOn` 和 `{steps..output}` 中被引用。用连字符,不用下划线。 | -| `type` | `string` | `"agent"` | 十种阶段类型之一。 | +| `type` | `string` | `"agent"` | 十二种阶段类型之一。 | | `agent` | `string` | 第一个发现的 agent | agent 名称。必须用连字符。 | | `task` | `string` | — | 任务 prompt。支持插值。对 `agent`/`map`/`reduce`/`loop`/`tournament` 必需(除非用 `branches`)。 | | `dependsOn` | `string[]` | `[]` | 此阶段启动前必须完成的阶段 id。构成 DAG 边。 | @@ -549,8 +590,10 @@ gate 审查上游输出并给出裁决。`PASS` 让流程继续;`BLOCK` 中止 | `loop` | 必需 | — | — | — | — | — | — | — | — | 必需 | 可选 | — | — | — | — | | `tournament` | 必需³ | — | — | 可选³ | — | — | — | — | — | — | — | 可选 | 可选 | — | — | | `script` | 忽略 | — | — | — | — | — | — | 必需 | 可选 | — | — | — | — | — | — | +| `race` | 忽略 | — | — | 必需⁴ | — | — | — | — | — | — | — | — | — | — | — | +| `expand` | 忽略 | — | — | — | — | 必需(`def`) | 可选 | — | — | — | — | — | — | — | — | -¹ `gate` 在不用 `score` 时需要 `task`。² `flow` 需要 `use` 或 `def` 恰好其一。³ `tournament` 在不用 `branches` 时需要 `task`。 +¹ `gate` 在不用 `score` 时需要 `task`。² `flow` 需要 `use` 或 `def` 恰好其一。³ `tournament` 在不用 `branches` 时需要 `task`。⁴ `race` 至少 2 条 `branches`。`expand` 另有 `expandMode`(`nested`\|`graft`)与 `maxNodes`。 ### 按类型的字段细节 diff --git a/website/content/docs/zh-cn/syntax/scorers.mdx b/website/content/docs/zh-cn/syntax/scorers.mdx index 9b25712..1e45f30 100644 --- a/website/content/docs/zh-cn/syntax/scorers.mdx +++ b/website/content/docs/zh-cn/syntax/scorers.mdx @@ -609,7 +609,7 @@ WINNER=1 → variant 1 (supports = separator) - taskflow 的十个构建块,包括 gate 类型。 + taskflow 的十二个构建块,包括 gate 类型。 深入了解 `when`、`join`、`retry` 与门控。 diff --git a/website/content/docs/zh-cn/what-is-taskflow.mdx b/website/content/docs/zh-cn/what-is-taskflow.mdx index 7bb1527..0bdf30a 100644 --- a/website/content/docs/zh-cn/what-is-taskflow.mdx +++ b/website/content/docs/zh-cn/what-is-taskflow.mdx @@ -196,7 +196,7 @@ taskflow 定义是 JSON(或 YAML、或 MDX frontmatter)。它声明**阶段* 阶段如何变成图,以及为什么数组顺序无关紧要。 - taskflow 的十种构建块。 + taskflow 的十二种构建块。 flow 定义和阶段字段的完整参考。 From 4da6f33af1ca68f2dedb30f86f7f700e35290658 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 17:35:32 +0800 Subject: [PATCH 34/51] feat(race): implement cancelLosers abort; S5 plan; full test gate green Wire per-branch AbortSignal through runOne so race cancelLosers aborts losers after first settle (best-effort). Fix FlowIRNodeKind test for 12 PHASE_TYPES. Add race cancel tests, S5 kernel-default-on plan, update skills/website/CHANGELOG. pnpm test: 1402/1402 pass. --- CHANGELOG.md | 3 +- docs/internal/claim-vs-impl-0.2.0.md | 6 +- docs/internal/s5-kernel-default-on-plan.md | 124 ++++++++++++++++++ docs/rfc-0.2.0-architecture.md | 2 +- docs/rfc-0.2.0-s4-decision-record.md | 2 +- .../plugin/skills/taskflow/SKILL.md | 8 +- .../plugin/skills/taskflow/configuration.md | 3 +- .../plugin/skills/taskflow/SKILL.md | 8 +- .../plugin/skills/taskflow/configuration.md | 3 +- .../plugin/skills/taskflow/SKILL.md | 8 +- .../plugin/skills/taskflow/configuration.md | 3 +- .../plugin/skills/taskflow/SKILL.md | 8 +- .../plugin/skills/taskflow/configuration.md | 3 +- packages/pi-taskflow/skills/taskflow/SKILL.md | 8 +- .../skills/taskflow/configuration.md | 3 +- packages/taskflow-core/src/runtime.ts | 58 +++++--- .../taskflow-core/src/runtime/phases/race.ts | 59 +++++++-- packages/taskflow-core/src/schema.ts | 7 +- .../taskflow-core/test/flowir-schema.test.ts | 6 +- .../taskflow-core/test/race-expand.test.ts | 114 ++++++++++++++++ skills-src/taskflow/configuration.md | 3 +- skills-src/taskflow/core.md | 8 +- .../content/docs/en/syntax/phase-types.mdx | 2 +- .../content/docs/zh-cn/syntax/phase-types.mdx | 2 +- 24 files changed, 373 insertions(+), 78 deletions(-) create mode 100644 docs/internal/s5-kernel-default-on-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index dd1e169..a6f5299 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ All notable changes to taskflow are documented here. This project follows [Keep ### Added - **S4 TypeScript DSL (`taskflow-dsl`):** compile-time `.tf.ts` runes erase to Taskflow JSON via TypeScript AST (`typescript` package, not ts-morph); CLI `new` / `check` / `build` / `decompile`; modular `erase/kinds/*` registry; parity tests (map + `json` + templates). Hosts still run JSON only (no MCP auto-build of `.tf.ts`). Package may still version as `0.1.7` until a formal 0.2.0 publish; npm may lag monorepo. -- **Horizon B engine kinds (`race`, `expand`):** `PHASE_TYPES` is **12**. Imperative runtime + FlowIR 1:1; `race` = first-finish-wins; `expand` = nested or graft-promote (`def`, `expandMode`, `maxNodes`). DSL runes + skills + website phase docs + examples `race-first-win.json` / `expand-nested-fragment.json`. **`cancelLosers` is reserved/ignored** (forward-compat field; warning at runtime). Event kernel still covers the original **10** kinds (excludes `race`/`expand`); advanced features continue to force imperative fallback. +- **Horizon B engine kinds (`race`, `expand`):** `PHASE_TYPES` is **12**. Imperative runtime + FlowIR 1:1; `race` = first-finish-wins; `expand` = nested or graft-promote (`def`, `expandMode`, `maxNodes`). DSL runes + skills + website phase docs + examples `race-first-win.json` / `expand-nested-fragment.json`. **`cancelLosers` (default true)** aborts loser branches via best-effort `AbortSignal` after the first branch settles. Event kernel still covers the original **10** kinds (excludes `race`/`expand`); advanced features continue to force imperative fallback. +- **S5 plan:** `docs/internal/s5-kernel-default-on-plan.md` — parity harness first, then feature gaps, then default ON + flagship recompute metric. - **Claim-vs-impl alignment:** `docs/internal/claim-vs-impl-0.2.0.md` ledger; S4 RFCs toolchain corrected; architecture S4 ✅ / S5 next; skills + README phase/test/package counts; website homepage **12** phases / **5** hosts; reference page **TypeScript DSL** (en/zh). - **Docs complete for Phase 2 surfaces:** website FlowIR docs (`ir:<64-hex>`, `usedFallbackHash: false`); new concepts **Deterministic Replay** (en/zh) + resume disambiguation; host MCP guides + Grok website + `reference/commands` list all 12 tools including `taskflow_replay`; README en/zh Commands tables; skills `advanced.md` trace/replay vs recompute; `AGENTS.md` exec/trace/replay + kernel flag; architecture RFC status table S0–S5 + internal FlowIR RFC superseded note. - **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. diff --git a/docs/internal/claim-vs-impl-0.2.0.md b/docs/internal/claim-vs-impl-0.2.0.md index 54db5a8..7ecaa8f 100644 --- a/docs/internal/claim-vs-impl-0.2.0.md +++ b/docs/internal/claim-vs-impl-0.2.0.md @@ -54,10 +54,10 @@ loop multi-body · route · compensate/saga · watch · experimental C-track run ## Still open (not claimed as done) - Formal **0.2.0 npm publish** + version bump (packages still 0.1.7; `taskflow-dsl` may 404 on registry). -- Implement real `cancelLosers` abort (or keep reserved forever). -- S5 kernel default ON + flagship $ demo seal. +- ~~Implement real `cancelLosers` abort~~ → **done** (best-effort AbortSignal; tests in race-expand). +- S5 kernel default ON + flagship $ demo seal → plan: `docs/internal/s5-kernel-default-on-plan.md`. - Full website polish for every “ten” remnant in long-form copy (bulk pass done; spot-check periodically). -- `pnpm test` full suite as release gate; live host e2e. +- Live host e2e as release gate (unit suite is the local hard gate). ## Re-verify commands diff --git a/docs/internal/s5-kernel-default-on-plan.md b/docs/internal/s5-kernel-default-on-plan.md new file mode 100644 index 0000000..c194fd3 --- /dev/null +++ b/docs/internal/s5-kernel-default-on-plan.md @@ -0,0 +1,124 @@ +# S5 plan — event kernel default ON (differential strangler) + +> Status: **PLAN** · 2026-07-09 · Branch `release/0.2.0` +> Parent: [`rfc-0.2.0-architecture.md`](../rfc-0.2.0-architecture.md) §9 S5 +> Prerequisite: S0–S4 landed; claim ledger `claim-vs-impl-0.2.0.md` + +## Goal + +1. **Default execution path** = `exec/driver` (event kernel), not imperative `executePhaseInner`. +2. **Parity**: kernel outcomes match imperative for every admitted flow (status, finalOutput shape, gate/budget/when decisions). +3. **Safety**: advanced features still force imperative fallback until handlers exist. +4. **Flagship gate**: incremental recompute cost demo (Monday $6 → Tuesday ≤$0.40 narrative) measured or explicitly deferred with honest wording. + +## Current baseline (do not regress) + +| Fact | Location | +|------|----------| +| Kernel **opt-in** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) | `exec/driver.ts` `eventKernelEnabled` | +| Kernel kinds = `PHASE_TYPES − {race, expand}` (10) | `exec/step.ts` `EVENT_KERNEL_PHASE_TYPES` | +| Fallback reasons (score, retry, expect, reflexion, cross-run cache, shareContext, onBlock:retry) | `exec/kernel-policy.ts` | +| Imperative path remains full 12 kinds | `runtime.ts` | +| Default OFF | env unset | + +## Workstreams + +### S5.0 — Differential harness (ship first) + +- [ ] Golden suite: for each fixture under `test/fixtures/kernel-parity/` run **twice** (kernel on / off) with the same mock `runTask`. +- [ ] Assert: `status`, per-phase `status`/`output`/`gate`/`error` (normalize volatile fields: timestamps, runIds). +- [ ] Fixtures minimum set: + - linear agent → gate → reduce + - map + parallel + - loop until + - tournament best + - script + - flow{use} + flow{def} (dynamic empty + small plan) + - when + join any + - budget hit mid-map +- [ ] CI job: `pnpm run test:kernel-parity` fails the release if any fixture diverges. + +### S5.1 — Close kernel feature gaps (priority order) + +| Priority | Gap | Approach | +|----------|-----|----------| +| P0 | parity harness green on admitted set | S5.0 | +| P1 | `expect` contracts on agent/gate/reduce/loop | port contractCheck into step body | +| P1 | explicit `retry` | reuse runOne retry curve in step or shared helper | +| P2 | score gates | port scorers path or keep fallback (document) | +| P2 | reflexion loops | keep fallback until designed | +| P2 | cross-run cache in kernel | optional: call CacheStore from driver | +| P3 | shareContext | keep fallback (complex) | +| P3 | `race` / `expand` kernel handlers | new step kinds; or keep imperative forever | + +**Policy:** S5 default ON only requires **parity on canUseEventKernel flows**. Flows that hit `kernelUnsupportedReason` stay on imperative **without** env flip required. + +### S5.2 — Default flip + +1. Change `eventKernelEnabled` default: + - `undefined` → **true** (or env `PI_TASKFLOW_EVENT_KERNEL` default `"1"`). + - Explicit `eventKernel: false` or env `0`/`false` keeps imperative. +2. Document migration: hosts that relied on imperative-only bugs must set `false`. +3. CHANGELOG: **breaking** if behavior differs; otherwise minor. + +### S5.3 — Runtime strangler (enables safe flip) + +Continue peel `runtime.ts` → `runtime/phases/*` so kernel step handlers and imperative share pure helpers: + +| Kind | Imperative module | Kernel step | +|------|-------------------|-------------| +| race | ✅ `phases/race.ts` | optional later | +| expand helpers | ✅ `phases/expand.ts` | optional later | +| script / parallel / approval | ✅ peeled | share spawn/merge helpers | +| map / loop / tournament | ⬜ | extract before rewriting step | +| agent / gate / reduce | ⬜ shared body | de-dupe with step-kinds | + +### S5.4 — Flagship cost demo (acceptance) + +- [ ] Scripted flow (8 agent phases) + fingerprint change of one file. +- [ ] Measure: full run cost vs recompute cost ratio. +- [ ] Pass if recompute token/$ **strictly <** full (target narrative ≤ ~$0.40 vs ~$6 is aspirational — record real numbers). +- [ ] If infra cannot produce stable $ without live models: gate on **phase count re-executed** + cache hit counts only. + +### S5.5 — Retirement (post-default) + +- [ ] Mark imperative path as fallback-only in docs. +- [ ] No deletion of executePhase in same release as default flip. +- [ ] Next minor: reduce dual-path surface after 1 release of green parity. + +## Non-goals (S5) + +- Native multi-node FlowIR lowering (parallel → N IR nodes). +- Literal decompile round-trip. +- Host auto-build of `.tf.ts`. +- Experimental C-track runes. + +## Exit criteria (S5 done) + +1. Default ON in core; all host adapters inherit. +2. `test:kernel-parity` green in CI. +3. No known P0 divergence bugs open. +4. Flagship recompute metric recorded (or explicitly deferred with version note). +5. Docs/skills: “kernel default ON; race/expand + advanced features may still use imperative”. + +## Suggested PR stack + +1. **S5.0** parity harness + fixtures (no behavior change). +2. **S5.1-P1** expect + retry on kernel path. +3. **S5.3** map/loop peel (optional parallel). +4. **S5.2** default flip behind `PI_TASKFLOW_EVENT_KERNEL` still overridable. +5. **S5.4** demo script + numbers. +6. Docs/CHANGELOG/skills final. + +## Risks + +| Risk | Mitigation | +|------|------------| +| Silent behavior change on default flip | parity harness + canary env | +| Host runners ignore AbortSignal (race cancel) | already best-effort; document | +| Dual path drift after flip | single shared helpers in `phases/` + `step-kinds` | +| Cost demo unstable without live models | phase-count metric fallback | + +## Immediate next coding step + +Implement **S5.0** only: `packages/taskflow-core/test/kernel-parity/*.test.ts` + fixtures, no default flip. diff --git a/docs/rfc-0.2.0-architecture.md b/docs/rfc-0.2.0-architecture.md index 415343c..909fadf 100644 --- a/docs/rfc-0.2.0-architecture.md +++ b/docs/rfc-0.2.0-architecture.md @@ -21,7 +21,7 @@ > | **S2** | ✅ | `exec/{step,driver}` **全部 10 kind** + P0 硬化;高级特性 fall-back;默认 OFF。**`race`/`expand` 仍走 imperative**(未进 `EVENT_KERNEL_PHASE_TYPES`) | > | **S3** | ✅ | `replayRun`;`taskflow_replay` MCP;pi `action=replay` + `/tf replay`;golden + import-lint | > | **S4** | ✅ | 包 `taskflow-dsl` live:`build`/`check`/`decompile`/`new`;erase `kinds/*` 注册表;parity 测绿。引擎 **12 PHASE_TYPES**(+`race`/`expand`);gate sugar;skills 对齐 | -> | **S5** | ⬜ **下一主线** | 全 kind 差分绿 → kernel 默认 ON;退休旧 `executePhase` 主路径。预热:`runtime/phases/*` strangler | +> | **S5** | ⬜ **下一主线** | 全 kind 差分绿 → kernel 默认 ON;退休旧 `executePhase` 主路径。计划:[`internal/s5-kernel-default-on-plan.md`](./internal/s5-kernel-default-on-plan.md);预热:`runtime/phases/*` strangler | > > North-star 口号:**compiled · resumable · incremental · replayable-for-what-if**。 diff --git a/docs/rfc-0.2.0-s4-decision-record.md b/docs/rfc-0.2.0-s4-decision-record.md index c3141fb..f6d3ec8 100644 --- a/docs/rfc-0.2.0-s4-decision-record.md +++ b/docs/rfc-0.2.0-s4-decision-record.md @@ -47,7 +47,7 @@ Engine `PHASE_TYPES` is now **12** (`race`, `expand` added). DSL erase registry |--------------|--------| | Core 10 + `subflow` / `subflow.def` | ✅ | | `gate.automated` / `gate.scored` | ✅ (A-track sugar) | -| `race` | ✅ engine + DSL (`cancelLosers` **reserved / ignored**) | +| `race` + `cancelLosers` | ✅ engine + DSL (best-effort AbortSignal abort of losers) | | `expand` / `expand.nested` / `expand.graft` + `maxNodes` | ✅ engine + DSL | | Parallel destructure → N agent phases | ✅ | | Modular pipeline (no monolith grow) | ✅ see `docs/internal/modularization-0.2.0.md` | diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index 0811cb3..697c6a5 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -474,11 +474,11 @@ branch that finishes successfully** (first-finish-wins). Unlike `parallel` when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for - forward compatibility; losers are **not** aborted yet and may run to natural - completion. Cap cost with `budget` / per-call `timeout`, not this flag. +- `cancelLosers` — optional boolean (default `true`). After the first branch + settles, abort other branches via `AbortSignal` (best-effort — host must honor + the signal). Set `false` to let losers finish naturally. - Output of the winning branch becomes the race phase output; a warning records - which branch won (and notes that cancelLosers is reserved). + which branch won. ```jsonc { diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index 9b991bb..a48cc0a 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | +| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -478,7 +478,6 @@ rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. -- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). - **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, cross-run cache, and Shared Context Tree force the **imperative** path. diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index 02f2ef2..fc00d0a 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -473,11 +473,11 @@ branch that finishes successfully** (first-finish-wins). Unlike `parallel` when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for - forward compatibility; losers are **not** aborted yet and may run to natural - completion. Cap cost with `budget` / per-call `timeout`, not this flag. +- `cancelLosers` — optional boolean (default `true`). After the first branch + settles, abort other branches via `AbortSignal` (best-effort — host must honor + the signal). Set `false` to let losers finish naturally. - Output of the winning branch becomes the race phase output; a warning records - which branch won (and notes that cancelLosers is reserved). + which branch won. ```jsonc { diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index 17d21e4..a057da1 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | +| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -475,7 +475,6 @@ rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. -- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). - **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, cross-run cache, and Shared Context Tree force the **imperative** path. diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index 8b096a7..829510a 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -476,11 +476,11 @@ branch that finishes successfully** (first-finish-wins). Unlike `parallel` when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for - forward compatibility; losers are **not** aborted yet and may run to natural - completion. Cap cost with `budget` / per-call `timeout`, not this flag. +- `cancelLosers` — optional boolean (default `true`). After the first branch + settles, abort other branches via `AbortSignal` (best-effort — host must honor + the signal). Set `false` to let losers finish naturally. - Output of the winning branch becomes the race phase output; a warning records - which branch won (and notes that cancelLosers is reserved). + which branch won. ```jsonc { diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index 80d3041..2f93ee2 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | +| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -478,7 +478,6 @@ rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. -- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). - **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, cross-run cache, and Shared Context Tree force the **imperative** path. diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 9660563..9956a6a 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -474,11 +474,11 @@ branch that finishes successfully** (first-finish-wins). Unlike `parallel` when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for - forward compatibility; losers are **not** aborted yet and may run to natural - completion. Cap cost with `budget` / per-call `timeout`, not this flag. +- `cancelLosers` — optional boolean (default `true`). After the first branch + settles, abort other branches via `AbortSignal` (best-effort — host must honor + the signal). Set `false` to let losers finish naturally. - Output of the winning branch becomes the race phase output; a warning records - which branch won (and notes that cancelLosers is reserved). + which branch won. ```jsonc { diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index 24d6148..f3e76c2 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | +| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -479,7 +479,6 @@ rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. -- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). - **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, cross-run cache, and Shared Context Tree force the **imperative** path. diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index 449542c..654da4c 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -463,11 +463,11 @@ branch that finishes successfully** (first-finish-wins). Unlike `parallel` when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for - forward compatibility; losers are **not** aborted yet and may run to natural - completion. Cap cost with `budget` / per-call `timeout`, not this flag. +- `cancelLosers` — optional boolean (default `true`). After the first branch + settles, abort other branches via `AbortSignal` (best-effort — host must honor + the signal). Set `false` to let losers finish naturally. - Output of the winning branch becomes the race phase output; a warning records - which branch won (and notes that cancelLosers is reserved). + which branch won. ```jsonc { diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index a41df70..aad2572 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | +| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -482,7 +482,6 @@ rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. -- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). - **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, cross-run cache, and Shared Context Tree force the **imperative** path. diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 1000b50..2d9e3b1 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -1054,39 +1054,56 @@ async function executePhaseInner( type !== "script" && typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000 ? phase.timeout : undefined; - const runOne = async (agentName: string, task: string, onLive?: (l: LiveUpdate) => void, ctxNodeId?: string, check?: (r: RunResult) => string[]): Promise => { + const runOne = async ( + agentName: string, + task: string, + onLive?: (l: LiveUpdate) => void, + ctxNodeId?: string, + check?: (r: RunResult) => string[], + /** Extra abort (e.g. race branch cancelLosers) — chained with run + phase timeout. */ + extraSignal?: AbortSignal, + ): Promise => { const explicitMax = Math.max(1, 1 + Math.max(0, Math.floor(retry?.max ?? 0))); // Allow enough attempts to cover whichever policy applies on a given attempt. const maxAttempts = Math.max(explicitMax, 1 + DEFAULT_TRANSIENT_RETRIES); const usages: UsageStats[] = []; let last: RunResult | undefined; for (let attempt = 0; attempt < maxAttempts; attempt++) { - if (deps.signal?.aborted) break; - // Phase timeout — an AbortController chained to the run's signal that also - // fires after `phase.timeout` ms. Deterministic: a timed-out call is never - // retried (retrying a call that just burned its full cap would double-spend). + if (deps.signal?.aborted || extraSignal?.aborted) break; + // AbortController chains: run signal + optional extra (race cancel) + phase timeout. + // Deterministic: a timed-out call is never retried (would double-spend). let timedOut = false; let timer: ReturnType | undefined; - let onParentAbort: (() => void) | undefined; + const removers: Array<() => void> = []; let callSignal: AbortSignal | undefined; - if (phaseTimeoutMs) { + if (phaseTimeoutMs || extraSignal) { const ac = new AbortController(); callSignal = ac.signal; - if (deps.signal?.aborted) ac.abort(); - else if (deps.signal) { - onParentAbort = () => ac.abort(); - deps.signal.addEventListener("abort", onParentAbort, { once: true }); + if (deps.signal?.aborted || extraSignal?.aborted) ac.abort(); + else { + if (deps.signal) { + const fn = () => ac.abort(); + deps.signal.addEventListener("abort", fn, { once: true }); + removers.push(() => deps.signal?.removeEventListener("abort", fn)); + } + if (extraSignal) { + const fn = () => ac.abort(); + extraSignal.addEventListener("abort", fn, { once: true }); + removers.push(() => extraSignal.removeEventListener("abort", fn)); + } + } + if (phaseTimeoutMs) { + timer = setTimeout(() => { + timedOut = true; + ac.abort(); + }, phaseTimeoutMs); } - timer = setTimeout(() => { - timedOut = true; - ac.abort(); - }, phaseTimeoutMs); } try { last = await baseRun(agentName, task, onLive, ctxNodeId, callSignal); } finally { if (timer) clearTimeout(timer); - if (onParentAbort) deps.signal?.removeEventListener("abort", onParentAbort); + for (const r of removers) r(); } if (timedOut) { // Reclassify the abort as a phase timeout: a distinct, deterministic @@ -1122,8 +1139,8 @@ async function executePhaseInner( const liveRetry = state.phases[phase.id]; if (liveRetry) liveRetry.usage = aggregateUsage(usages); if (!isFailed(last)) break; - // Stop retrying on abort or once the run is over budget. - if (deps.signal?.aborted || overBudget(state).over) break; + // Stop retrying on abort (run-level or race cancel) or once over budget. + if (deps.signal?.aborted || extraSignal?.aborted || overBudget(state).over) break; // Decide whether THIS failure warrants another attempt. Explicit retry // policy covers all failures up to its cap; the transient fallback covers // only retryable provider errors. A non-transient failure with no explicit @@ -1785,10 +1802,13 @@ async function executePhaseInner( const inputHash = ck.key; const cached = cachedPhase(cc, ck); if (cached) return cached; - const ps = await executeRaceBranches(phase, branches, runOne, isFailed, { + const raceRunOne = (agent: string, task: string, branchSignal?: AbortSignal) => + runOne(agent, task, undefined, undefined, undefined, branchSignal); + const ps = await executeRaceBranches(phase, branches, raceRunOne, isFailed, { inputHash, parseJson, readRefs: readRefs.length ? readRefsToReads(readRefs, state) : undefined, + parentSignal: deps.signal, }); recordCache(cc, ps); return ps; diff --git a/packages/taskflow-core/src/runtime/phases/race.ts b/packages/taskflow-core/src/runtime/phases/race.ts index 8ceb1ec..6770ec2 100644 --- a/packages/taskflow-core/src/runtime/phases/race.ts +++ b/packages/taskflow-core/src/runtime/phases/race.ts @@ -16,8 +16,9 @@ export interface RaceBranch { task: string; } +/** Optional per-branch AbortSignal — used to cancel losers after a winner settles. */ export interface RaceRunOne { - (agent: string, task: string): Promise; + (agent: string, task: string, signal?: AbortSignal): Promise; } export interface RaceIsFailed { @@ -26,6 +27,10 @@ export interface RaceIsFailed { /** * Execute race branches. Pure w.r.t. scheduling — does not touch RunState. + * + * When `cancelLosers` is true (default), abort controllers for non-winning + * branches fire after the first branch settles (best-effort: depends on the + * host runner honoring AbortSignal). */ export async function executeRaceBranches( phase: Phase, @@ -36,6 +41,8 @@ export async function executeRaceBranches( inputHash: string; parseJson: boolean; readRefs?: PhaseState["reads"]; + /** Parent run abort — chained onto each branch controller. */ + parentSignal?: AbortSignal; }, ): Promise { if (branches.length < 2) { @@ -49,22 +56,56 @@ export async function executeRaceBranches( }; } - // cancelLosers is schema-accepted but not enforced yet (no per-branch AbortSignal fan-out). const cancelLosers = (phase as { cancelLosers?: boolean }).cancelLosers !== false; + const controllers = branches.map(() => new AbortController()); + + // Chain parent abort → all branches. + const onParentAbort = () => { + for (const c of controllers) { + try { + c.abort(); + } catch { + /* ignore */ + } + } + }; + if (opts.parentSignal) { + if (opts.parentSignal.aborted) onParentAbort(); + else opts.parentSignal.addEventListener("abort", onParentAbort, { once: true }); + } + + const branchPromises = branches.map(async (b, i) => { + const result = await runOne(b.agent, b.task, controllers[i]!.signal); + return { i, result }; + }); + + const raced = await Promise.race(branchPromises); - const raced = await Promise.race( - branches.map(async (b, i) => { - const result = await runOne(b.agent, b.task); - return { i, result }; - }), - ); + if (cancelLosers) { + for (let j = 0; j < controllers.length; j++) { + if (j !== raced.i) { + try { + controllers[j]!.abort(); + } catch { + /* ignore */ + } + } + } + } + + // Let aborted branches settle (avoid unhandled rejections / orphan work). + await Promise.allSettled(branchPromises); + + if (opts.parentSignal) { + opts.parentSignal.removeEventListener("abort", onParentAbort); + } const winner = raced.result; const failed = isFailed(winner); const warnings = [`race: branch ${raced.i + 1}/${branches.length} won`]; if (cancelLosers) { warnings.push( - "race: cancelLosers is reserved — in-flight losers are not aborted yet (first-finish-wins only)", + `race: cancelLosers aborted ${branches.length - 1} loser branch(es) (best-effort AbortSignal)`, ); } return { diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index eacca43..ac46f41 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -149,10 +149,9 @@ const PhaseSchema = Type.Object( }), ), /** - * [race] Reserved. Intended: abort in-flight losers after the first branch finishes. - * **Currently ignored** — first-finish-wins still applies; other branches may run to - * natural completion (no multi-signal abort plumbing yet). Accepted so JSON/DSL stay - * forward-compatible; do not rely on cancellation for cost control (use `budget` / `timeout`). + * [race] When true (default), abort in-flight loser branches after the first branch + * settles (best-effort `AbortSignal` to the host runner). First-finish-wins still + * applies. Set `false` to let losers run to natural completion. */ cancelLosers: Type.Optional(Type.Boolean({ default: true })), diff --git a/packages/taskflow-core/test/flowir-schema.test.ts b/packages/taskflow-core/test/flowir-schema.test.ts index e13dd9f..84e50d3 100644 --- a/packages/taskflow-core/test/flowir-schema.test.ts +++ b/packages/taskflow-core/test/flowir-schema.test.ts @@ -29,11 +29,11 @@ function ir(nodes: FlowIRNode[], overrides?: Partial): FlowIR { } // --------------------------------------------------------------------------- -// FlowIRNodeKind — closed literal union of the 10 phase kinds +// FlowIRNodeKind — closed literal union (= PHASE_TYPES, currently 12 kinds) // --------------------------------------------------------------------------- test("FlowIRNodeKind: tracks PHASE_TYPES (single source of truth, no drift)", () => { - assert.equal(PHASE_TYPES.length, 10); + assert.equal(PHASE_TYPES.length, 12); for (const k of PHASE_TYPES) { // Each DSL phase kind is a member of the FlowIR kind schema. const decoded = Value.Decode(FlowIRNodeKind, k); @@ -51,6 +51,8 @@ test("FlowIRNodeKind: tracks PHASE_TYPES (single source of truth, no drift)", () "loop", "tournament", "script", + "race", + "expand", ]); }); diff --git a/packages/taskflow-core/test/race-expand.test.ts b/packages/taskflow-core/test/race-expand.test.ts index 84860cf..2a07962 100644 --- a/packages/taskflow-core/test/race-expand.test.ts +++ b/packages/taskflow-core/test/race-expand.test.ts @@ -106,6 +106,120 @@ test("race: first completed branch wins", async () => { assert.equal(st.phases.r?.status, "done"); assert.equal(st.phases.r?.output?.trim(), "FAST"); assert.ok(st.phases.r?.warnings?.some((w) => /branch 2/.test(w))); + assert.ok(st.phases.r?.warnings?.some((w) => /cancelLosers aborted/.test(w ?? ""))); +}); + +test("race: cancelLosers aborts losing branch via AbortSignal", async () => { + const def: Taskflow = { + name: "race-cancel", + phases: [ + { + id: "r", + type: "race", + cancelLosers: true, + branches: [ + { task: "slow-path", agent: "a" }, + { task: "fast-path", agent: "a" }, + ], + final: true, + }, + ], + }; + const st = mkState(def); + let slowSawAbort = false; + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task, opts) => { + if (task.includes("slow")) { + await new Promise((resolve) => { + const t = setTimeout(() => resolve(), 5000); + const onAbort = () => { + slowSawAbort = true; + clearTimeout(t); + resolve(); + }; + if (opts?.signal?.aborted) onAbort(); + else opts?.signal?.addEventListener("abort", onAbort, { once: true }); + }); + return { + agent, + task, + exitCode: 1, + output: "", + stderr: "aborted", + usage: { ...emptyUsage(), input: 1, output: 0, cost: 0, turns: 0 }, + stopReason: "error", + errorMessage: "aborted", + }; + } + // small delay so slow is definitely in-flight + await new Promise((r) => setTimeout(r, 15)); + return { + agent, + task, + exitCode: 0, + output: "FAST-WIN", + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.001, turns: 1 }, + stopReason: "end", + }; + }, + }); + assert.equal(st.status, "completed"); + assert.equal(st.phases.r?.output?.trim(), "FAST-WIN"); + assert.equal(slowSawAbort, true); +}); + +test("race: cancelLosers false leaves loser running to completion", async () => { + const def: Taskflow = { + name: "race-no-cancel", + phases: [ + { + id: "r", + type: "race", + cancelLosers: false, + branches: [ + { task: "slow-path", agent: "a" }, + { task: "fast-path", agent: "a" }, + ], + final: true, + }, + ], + }; + const st = mkState(def); + let slowFinished = false; + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task) => { + if (task.includes("slow")) { + await new Promise((r) => setTimeout(r, 40)); + slowFinished = true; + return { + agent, + task, + exitCode: 0, + output: "SLOW", + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.001, turns: 1 }, + stopReason: "end", + }; + } + return { + agent, + task, + exitCode: 0, + output: "FAST", + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.001, turns: 1 }, + stopReason: "end", + }; + }, + }); + assert.equal(st.phases.r?.output?.trim(), "FAST"); + assert.equal(slowFinished, true); + assert.ok(!st.phases.r?.warnings?.some((w) => /cancelLosers aborted/.test(w))); }); test("expand nested: runs fragment as sub-flow", async () => { diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index 544a158..dd1236b 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -83,7 +83,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | **Reserved / ignored.** Intended to abort losers; not enforced yet. First-finish-wins only. | +| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -519,7 +519,6 @@ rely on them for behavior: - `arg.required` — missing required args are not rejected. - `flow.version` — informational only. -- `cancelLosers` (race) — **reserved / ignored** (first-finish-wins only). - **Event kernel** (`eventKernel` / `PI_TASKFLOW_EVENT_KERNEL=1`) — opt-in; does **not** run `race`/`expand`; score gates, `retry`, `expect`, reflexion, cross-run cache, and Shared Context Tree force the **imperative** path. diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index f8a6aec..f832868 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -487,11 +487,11 @@ branch that finishes successfully** (first-finish-wins). Unlike `parallel` when latency matters more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — **reserved / currently ignored.** Schema accepts it for - forward compatibility; losers are **not** aborted yet and may run to natural - completion. Cap cost with `budget` / per-call `timeout`, not this flag. +- `cancelLosers` — optional boolean (default `true`). After the first branch + settles, abort other branches via `AbortSignal` (best-effort — host must honor + the signal). Set `false` to let losers finish naturally. - Output of the winning branch becomes the race phase output; a warning records - which branch won (and notes that cancelLosers is reserved). + which branch won. ```jsonc { diff --git a/website/content/docs/en/syntax/phase-types.mdx b/website/content/docs/en/syntax/phase-types.mdx index a33dea5..1ac6276 100644 --- a/website/content/docs/en/syntax/phase-types.mdx +++ b/website/content/docs/en/syntax/phase-types.mdx @@ -436,7 +436,7 @@ When several approaches can answer the same question and **latency** matters mor ``` - **`cancelLosers` is reserved.** The schema accepts it for forward compatibility, but losers are **not** aborted yet — they may run to natural completion. Cap spend with `budget` / `timeout`. + **`cancelLosers` (default true):** after the first branch settles, other branches receive `AbortSignal` (best-effort — the host runner must honor it). Set `false` to let losers finish. ## Dynamic fragment: `expand` diff --git a/website/content/docs/zh-cn/syntax/phase-types.mdx b/website/content/docs/zh-cn/syntax/phase-types.mdx index 87220fd..0285f26 100644 --- a/website/content/docs/zh-cn/syntax/phase-types.mdx +++ b/website/content/docs/zh-cn/syntax/phase-types.mdx @@ -436,7 +436,7 @@ gate 审查上游输出并给出裁决。`PASS` 让流程继续;`BLOCK` 中止 ``` - **`cancelLosers` 为预留字段。** schema 接受它以保持前向兼容,但败者**目前不会被中止**,可能仍跑到自然结束。请用 `budget` / `timeout` 控制开销。 + **`cancelLosers`(默认 true):** 第一个分支 settle 后,其余分支收到 `AbortSignal`(尽力而为——宿主 runner 需遵守)。设 `false` 可让败者跑完。 ## 动态片段:`expand` From e01de6c03c78dd9c90ce8183ce45abb1c0a7905f Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 19:17:45 +0800 Subject: [PATCH 35/51] fix: address multi-agent review P0/P1 (race, graft, DSL, kernel) Race: first-success semantics, aggregate branch usage, cancel after success, try/finally parent abort listener, delay honors extraSignal. Expand graft: rewrite {steps.*} after id prefix; zero expand usage after promote to avoid double-count. DSL: register unions dependsOn; unknown runes and non-agent branches error; gate sugar allowKeys; decompile imports race/expand and fail-closes non-string def. Kernel policy rejects incremental and workspace cwd keywords. Docs/skills/README/website synced. Tests: 1404/1404 green. --- README.md | 2 + docs/internal/claim-vs-impl-0.2.0.md | 11 +- ...ulti-agent-review-s4-horizon-2026-07-09.md | 131 ++++++++++++++++ .../plugin/skills/taskflow/SKILL.md | 15 +- .../plugin/skills/taskflow/SKILL.md | 15 +- .../plugin/skills/taskflow/SKILL.md | 15 +- .../plugin/skills/taskflow/SKILL.md | 15 +- packages/pi-taskflow/skills/taskflow/SKILL.md | 15 +- .../taskflow-core/src/exec/kernel-policy.ts | 9 ++ packages/taskflow-core/src/runtime.ts | 31 +++- .../src/runtime/phases/expand.ts | 69 ++++++++- .../taskflow-core/src/runtime/phases/race.ts | 144 +++++++++++------- .../taskflow-core/test/race-expand.test.ts | 105 ++++++++++++- .../taskflow-dsl/src/build/erase/context.ts | 6 + .../src/build/erase/kinds/gate-sugar.ts | 14 +- .../src/build/erase/kinds/parallel.ts | 10 +- .../src/build/erase/kinds/race.ts | 10 +- .../src/build/erase/kinds/tournament.ts | 10 +- packages/taskflow-dsl/src/build/erase/opts.ts | 11 ++ .../taskflow-dsl/src/build/erase/pipeline.ts | 12 ++ packages/taskflow-dsl/src/decompile.ts | 54 ++++++- skills-src/taskflow/core.md | 15 +- .../content/docs/en/syntax/phase-types.mdx | 8 +- .../content/docs/zh-cn/syntax/phase-types.mdx | 8 +- 24 files changed, 620 insertions(+), 115 deletions(-) create mode 100644 docs/internal/multi-agent-review-s4-horizon-2026-07-09.md diff --git a/README.md b/README.md index 1f95270..f449871 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,8 @@ See [Tournament phases](#tournament-tournament) for the full reference. | `loop` | **iterate a task until done** — re-run a body until a condition, convergence, or a cap | `task`, `until` | | `tournament` | **N variants compete**, a judge picks the best (or aggregates) | `task` \| `branches` | | `script` | run a **shell command** — no LLM, zero tokens — capturing stdout as the phase output | `run` | +| `race` | **first successful** branch wins (optional `cancelLosers` abort) | `branches` (≥2) | +| `expand` | run a dynamic fragment (`nested` or `graft` promote) | `def` (+ `expandMode?`) | ### Common phase fields diff --git a/docs/internal/claim-vs-impl-0.2.0.md b/docs/internal/claim-vs-impl-0.2.0.md index 7ecaa8f..cb8d742 100644 --- a/docs/internal/claim-vs-impl-0.2.0.md +++ b/docs/internal/claim-vs-impl-0.2.0.md @@ -51,13 +51,20 @@ loop multi-body · route · compensate/saga · watch · experimental C-track run 10. Examples: `race-first-win.json`, `expand-nested-fragment.json`. 11. Skills advanced: `flow{def}` vs `expand`; configuration caveats (kernel/decompile). +### Pass 3 (multi-agent review P0/P1 fixes) +12. Race **first-success** (not first-settled); usage aggregates all branches. +13. Graft: rewrite `{steps.*}` after id prefix; zero expand usage after promote (no double-count). +14. DSL `register()` unions explicit `dependsOn`; unknown runes / non-agent branches error. +15. Decompile: import race/expand; fail-closed non-string `def`. +16. Kernel policy: `incremental` + workspace cwd keywords force imperative. +17. README phase table + taskflow-dsl README preview note. + ## Still open (not claimed as done) - Formal **0.2.0 npm publish** + version bump (packages still 0.1.7; `taskflow-dsl` may 404 on registry). -- ~~Implement real `cancelLosers` abort~~ → **done** (best-effort AbortSignal; tests in race-expand). - S5 kernel default ON + flagship $ demo seal → plan: `docs/internal/s5-kernel-default-on-plan.md`. -- Full website polish for every “ten” remnant in long-form copy (bulk pass done; spot-check periodically). - Live host e2e as release gate (unit suite is the local hard gate). +- FlowIR full sidecar for expandMode/maxNodes/cancelLosers (optional S5.x). ## Re-verify commands diff --git a/docs/internal/multi-agent-review-s4-horizon-2026-07-09.md b/docs/internal/multi-agent-review-s4-horizon-2026-07-09.md new file mode 100644 index 0000000..a8f42e7 --- /dev/null +++ b/docs/internal/multi-agent-review-s4-horizon-2026-07-09.md @@ -0,0 +1,131 @@ +# Multi-agent review — S4 + Horizon B + alignment (`release/0.2.0`) + +> Date: 2026-07-09 · HEAD: `4da6f33` +> Scope: S4 `taskflow-dsl`, race/expand/cancelLosers, claim alignment, S5 plan +> Agents: architecture · race/expand concurrency · DSL erase · docs honesty +> Raw notes: `/tmp/grok-ma-review-{arch,race,dsl,docs}-c1d88a29.md` + +## Council verdict + +**Do not market “0.2.0 complete / race first-success / graft multi-phase ready” without fixes.** +Engineering depth is real (12 kinds, DSL package, tests green), but several **correctness and honesty** gaps remain above nits. + +| Track | Verdict | +|-------|---------| +| Architecture S0–S4 shape | **Conditionally OK** — types align; dual-path + FlowIR field gaps | +| Race / expand runtime | **Blockers** — first-settled ≠ first-success; graft prefix + usage | +| DSL erase | **Blockers** — dependsOn overwrite; silent drops; decompile lies | +| Docs / publish surface | **Blockers for release narrative** — 0.1.7 vs 0.2.0 dual story | + +--- + +## P0 — fix before calling Horizon B “done” + +### 1. Race: first-settled vs first-success *(race agent · bug)* + +- **Code:** `Promise.race` → first **settle** (fail or ok) wins. +- **Skills:** “first branch that finishes **successfully**”. +- **Impact:** Fast hard-fail kills the race while a slower branch would succeed. +- **Fix:** (A) first-success loop, or (B) rewrite all author docs to first-settled. Prefer **A**. + +### 2. Graft: template refs not rewritten *(race agent · bug)* + +- **Code:** `prefixGraftFragment` rewrites ids + `dependsOn`/`from` only. +- **Impact:** Multi-phase fragments with `{steps.a…}` break after prefix; validate-after-prefix can fail-open via `defError`. Single-phase graft works by accident. +- **Fix:** Rewrite collectible template surfaces with `idMap`; test two-phase graft chain. + +### 3. Graft usage double-count *(race agent · bug)* + +- Expand phase `usage` = sub total **and** promoted children keep usage → run rollup **2×**. +- **Fix:** Zero expand usage after promote **or** zero promoted children usage for aggregation. + +### 4. DSL `register()` drops explicit `dependsOn` *(dsl agent · bug)* + +- When auto-deps non-empty, `raw.dependsOn = [...auto]` overwrites opts.dependsOn for kinds that don’t union into `draft.dependsOn`. +- **Fix:** Union auto + explicit in `register()`; regression test. + +### 5. DSL silent phase/branch drops *(dsl agent · bug)* + +- Unknown callees in flow body: no error. +- parallel/race/tournament non-`agent()` branches: silent skip. +- **Fix:** Diagnostics `TFDSL_RUNE_UNKNOWN` / `TFDSL_BRANCH_KIND`. + +### 6. Decompile race/expand *(dsl agent · bug)* + +- Emits `race`/`expand` without importing them. +- Object `def` → fabricated `"{steps.plan.json}"` placeholder (shape lie). +- **Fix:** Import list; fail-closed non-string `def`. + +--- + +## P1 — before kernel default ON / public 0.2.0 + +### 7. Race usage / budget undercount *(race agent · bug)* + +- Winner-only final usage; concurrent live usage last-writer-wins. +- Loser spend (pre-abort) invisible to budget. +- **Fix:** Aggregate all branch usages after `allSettled`; shared live accumulator. + +### 8. Dual-path kernel admits flows that ignore features *(arch agent · bug)* + +- Kernel path may miss `cacheScopeDefault` incremental / workspace cwd keywords. +- **Fix:** Extend `kernelUnsupportedReason` or implement in driver. + +### 9. FlowIR sidecar incomplete for Horizon B *(arch agent · bug)* + +- `cancelLosers`, `expandMode`, `maxNodes` not fully in IR field model. +- **Fix:** FlowIR node payload parity or documented non-goals. + +### 10. Version dual narrative *(docs agent · bug)* + +- README / website teach 0.2.0 surfaces; packages **0.1.7**; plugins pin `@0.1.7`; `taskflow-dsl` may 404. +- **Fix:** Publish banner “preview branch” **or** bump 0.2.0 + publish. + +### 11. README phase table vs “12 phase types” *(docs agent · bug)* + +- Marketing line says 12; some tables still 10 without race/expand. +- **Fix:** Table sync. + +--- + +## P2 — hygiene + +| Item | Source | +|------|--------| +| gate sugar always TFDSL_RUNE_OPTS_UNKNOWN for pass/scorers | dsl | +| import-lint string-only vs full core barrel | dsl | +| S5 plan couples default ON to $ demo while kernel lacks cross-run cache | arch | +| claim ledger residual cancelLosers row | arch/docs | +| retry delay ignores race extraSignal | race | +| deterministic tests over wall-clock delays | race | + +--- + +## What the council agreed is solid + +- 12 `PHASE_TYPES` ↔ DSL erase registry ↔ FlowIR kind enum (post test fix). +- Event kernel excludes race/expand intentionally. +- `TFDSL_ERASE_ONLY` on runes. +- Parallel destructure → N agents; race destructure rejected. +- cancelLosers **wiring** (controllers + `extraSignal`) is directionally correct for happy path. +- Unit suite **1402/1402** after FlowIR length fix. +- Internal claim ledger + S5 plan exist and are mostly honest. + +--- + +## Recommended action order + +1. **P0.1–P0.3** race/expand runtime (semantics + graft + usage). +2. **P0.4–P0.6** DSL register/silent drop/decompile. +3. **P1.10** version/publish honesty banner. +4. **S5.0** parity harness only after P0 runtime dual-path inventory (arch). +5. Defer kernel default ON until P1.7–P1.8 closed. + +## Agent artifacts + +| Dimension | File | +|-----------|------| +| Architecture | `/tmp/grok-ma-review-arch-c1d88a29.md` | +| Race/expand | `/tmp/grok-ma-review-race-c1d88a29.md` | +| DSL erase | `/tmp/grok-ma-review-dsl-c1d88a29.md` | +| Docs honesty | `/tmp/grok-ma-review-docs-c1d88a29.md` | diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index 697c6a5..ecd7237 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -469,14 +469,17 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully** (first-finish-wins). Unlike `parallel` -(waits for all) or `tournament` (judges quality after all variants), use race -when latency matters more than comparing every approach. +branch that finishes successfully** (failed settles do **not** win — a slower +success still wins over a fast hard-fail). Unlike `parallel` (waits for all) or +`tournament` (judges quality after all variants), use race when latency matters +more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). After the first branch - settles, abort other branches via `AbortSignal` (best-effort — host must honor - the signal). Set `false` to let losers finish naturally. +- `cancelLosers` — optional boolean (default `true`). After the first **success**, + abort other branches via `AbortSignal` (best-effort — host must honor the + signal). Set `false` to let losers finish naturally. +- Phase `usage` **aggregates all branches** (including aborted partials) so + budgets stay honest. - Output of the winning branch becomes the race phase output; a warning records which branch won. diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index fc00d0a..a86deb9 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -468,14 +468,17 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully** (first-finish-wins). Unlike `parallel` -(waits for all) or `tournament` (judges quality after all variants), use race -when latency matters more than comparing every approach. +branch that finishes successfully** (failed settles do **not** win — a slower +success still wins over a fast hard-fail). Unlike `parallel` (waits for all) or +`tournament` (judges quality after all variants), use race when latency matters +more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). After the first branch - settles, abort other branches via `AbortSignal` (best-effort — host must honor - the signal). Set `false` to let losers finish naturally. +- `cancelLosers` — optional boolean (default `true`). After the first **success**, + abort other branches via `AbortSignal` (best-effort — host must honor the + signal). Set `false` to let losers finish naturally. +- Phase `usage` **aggregates all branches** (including aborted partials) so + budgets stay honest. - Output of the winning branch becomes the race phase output; a warning records which branch won. diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index 829510a..c535085 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -471,14 +471,17 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully** (first-finish-wins). Unlike `parallel` -(waits for all) or `tournament` (judges quality after all variants), use race -when latency matters more than comparing every approach. +branch that finishes successfully** (failed settles do **not** win — a slower +success still wins over a fast hard-fail). Unlike `parallel` (waits for all) or +`tournament` (judges quality after all variants), use race when latency matters +more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). After the first branch - settles, abort other branches via `AbortSignal` (best-effort — host must honor - the signal). Set `false` to let losers finish naturally. +- `cancelLosers` — optional boolean (default `true`). After the first **success**, + abort other branches via `AbortSignal` (best-effort — host must honor the + signal). Set `false` to let losers finish naturally. +- Phase `usage` **aggregates all branches** (including aborted partials) so + budgets stay honest. - Output of the winning branch becomes the race phase output; a warning records which branch won. diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 9956a6a..5bf99d2 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -469,14 +469,17 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully** (first-finish-wins). Unlike `parallel` -(waits for all) or `tournament` (judges quality after all variants), use race -when latency matters more than comparing every approach. +branch that finishes successfully** (failed settles do **not** win — a slower +success still wins over a fast hard-fail). Unlike `parallel` (waits for all) or +`tournament` (judges quality after all variants), use race when latency matters +more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). After the first branch - settles, abort other branches via `AbortSignal` (best-effort — host must honor - the signal). Set `false` to let losers finish naturally. +- `cancelLosers` — optional boolean (default `true`). After the first **success**, + abort other branches via `AbortSignal` (best-effort — host must honor the + signal). Set `false` to let losers finish naturally. +- Phase `usage` **aggregates all branches** (including aborted partials) so + budgets stay honest. - Output of the winning branch becomes the race phase output; a warning records which branch won. diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index 654da4c..6fb499c 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -458,14 +458,17 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully** (first-finish-wins). Unlike `parallel` -(waits for all) or `tournament` (judges quality after all variants), use race -when latency matters more than comparing every approach. +branch that finishes successfully** (failed settles do **not** win — a slower +success still wins over a fast hard-fail). Unlike `parallel` (waits for all) or +`tournament` (judges quality after all variants), use race when latency matters +more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). After the first branch - settles, abort other branches via `AbortSignal` (best-effort — host must honor - the signal). Set `false` to let losers finish naturally. +- `cancelLosers` — optional boolean (default `true`). After the first **success**, + abort other branches via `AbortSignal` (best-effort — host must honor the + signal). Set `false` to let losers finish naturally. +- Phase `usage` **aggregates all branches** (including aborted partials) so + budgets stay honest. - Output of the winning branch becomes the race phase output; a warning records which branch won. diff --git a/packages/taskflow-core/src/exec/kernel-policy.ts b/packages/taskflow-core/src/exec/kernel-policy.ts index 855db83..fc882f9 100644 --- a/packages/taskflow-core/src/exec/kernel-policy.ts +++ b/packages/taskflow-core/src/exec/kernel-policy.ts @@ -11,6 +11,10 @@ import { dependenciesOf } from "../schema.ts"; * When set, executeTaskflow must NOT enter the event kernel (even if enabled). */ export function kernelUnsupportedReason(def: Taskflow): string | undefined { + // Flow-level: incremental default cross-run is not implemented on the kernel path. + if ((def as { incremental?: boolean }).incremental === true) { + return `flow incremental:true requires the imperative runtime`; + } for (const p of def.phases ?? []) { const id = p.id; if (p.type === "gate" && (p as { score?: unknown }).score !== undefined) { @@ -38,6 +42,11 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { if (p.shareContext === true || def.contextSharing === true) { return `phase '${id}': Shared Context Tree requires the imperative runtime`; } + // Workspace isolation keywords are imperative-only today. + const cwd = p.cwd; + if (typeof cwd === "string" && (cwd === "temp" || cwd === "dedicated" || cwd === "worktree")) { + return `phase '${id}': workspace cwd '${cwd}' requires the imperative runtime`; + } } return undefined; } diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 2d9e3b1..333ea41 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -1168,7 +1168,26 @@ async function executePhaseInner( // backoff. const factor = retry ? (retry.factor ?? 1) : DEFAULT_TRANSIENT_FACTOR; const wait = Math.min(60000, Math.round(baseMs * factor ** attempt)); - if (wait > 0) await delay(wait, deps.signal); + if (wait > 0) { + // Honor run abort and/or race-branch cancel during backoff. + if (deps.signal && extraSignal) { + const ac = new AbortController(); + const ab = () => ac.abort(); + if (deps.signal.aborted || extraSignal.aborted) ac.abort(); + else { + deps.signal.addEventListener("abort", ab, { once: true }); + extraSignal.addEventListener("abort", ab, { once: true }); + } + try { + await delay(wait, ac.signal); + } finally { + deps.signal.removeEventListener("abort", ab); + extraSignal.removeEventListener("abort", ab); + } + } else { + await delay(wait, extraSignal ?? deps.signal); + } + } } // Aborted before any attempt ran → return a clean aborted result (no crash). if (!last) { @@ -2125,16 +2144,24 @@ async function executePhaseInner( const sp = Object.values(subState.phases); // expand graft promote — pure helper (see runtime/phases/expand.ts) const warnings: string[] = []; + let graftPromoted = 0; if (type === "expand" && expandMode === "graft" && subResult.ok) { const promo = promoteGraftPhases(state, subState.phases); warnings.push(...promo.warnings); + graftPromoted = promo.promoted; } + // Graft: children carry usage after promote — zero expand usage to avoid double-count + // in run-level aggregateUsage(Object.values(state.phases)). + const phaseUsage = + type === "expand" && expandMode === "graft" && subResult.ok && graftPromoted > 0 + ? emptyUsage() + : subResult.totalUsage; const flowPs: PhaseState = { id: phase.id, status: subResult.ok ? "done" : "failed", output: subResult.finalOutput, json: parseJson ? safeParse(subResult.finalOutput) : undefined, - usage: subResult.totalUsage, + usage: phaseUsage, // B-F015: include failed in `done` so the renderer's // `done - failed` formula gives the success count (matches the // map/parallel runner's overlapping-counter convention). diff --git a/packages/taskflow-core/src/runtime/phases/expand.ts b/packages/taskflow-core/src/runtime/phases/expand.ts index 81726e4..5d11ead 100644 --- a/packages/taskflow-core/src/runtime/phases/expand.ts +++ b/packages/taskflow-core/src/runtime/phases/expand.ts @@ -1,11 +1,12 @@ /** * Expand phase helpers — pure transforms for fragment prep + graft promote. - * Execution still wires through flow{def} path in runtime.ts; logic lives here + * Execution still wires through flow|def path in runtime.ts; logic lives here * so expand does not keep growing executePhaseInner. */ import type { Phase, Taskflow } from "../../schema.ts"; import type { PhaseState, RunState } from "../../store.ts"; +import { emptyUsage } from "../../usage.ts"; export type ExpandMode = "nested" | "graft"; @@ -21,9 +22,17 @@ export function resolveMaxNodes(phase: Phase, hardCap: number): number { return Math.min(50, hardCap); } +/** Rewrite `{steps....}` placeholders using a phase id map. */ +function rewriteStepRefs(text: string, idMap: Map): string { + return text.replace(/\{steps\.([a-zA-Z0-9_-]+)/g, (full, id: string) => { + const mapped = idMap.get(id); + return mapped ? `{steps.${mapped}` : full; + }); +} + /** - * Prefix every phase id (and rewrite dependsOn/from) so graft fragments never - * collide with parent phase ids. + * Prefix every phase id (and rewrite dependsOn/from + template step refs) so + * graft fragments never collide with parent phase ids and internal edges stay valid. */ export function prefixGraftFragment(fragment: Taskflow, expandPhaseId: string): Taskflow { const prefix = `${expandPhaseId}-`; @@ -39,6 +48,48 @@ export function prefixGraftFragment(fragment: Taskflow, expandPhaseId: string): if (p.from?.length) { np.from = p.from.map((d) => idMap.get(d) ?? d); } + // Rewrite string templates that reference sibling phase ids. + const fields = ["task", "over", "when", "until", "input", "judge", "def"] as const; + for (const f of fields) { + const v = (np as Record)[f]; + if (typeof v === "string") { + (np as Record)[f] = rewriteStepRefs(v, idMap); + } + } + if (Array.isArray(np.run)) { + np.run = np.run.map((r) => (typeof r === "string" ? rewriteStepRefs(r, idMap) : r)); + } else if (typeof np.run === "string") { + np.run = rewriteStepRefs(np.run, idMap); + } + if (Array.isArray(np.eval)) { + np.eval = np.eval.map((e) => (typeof e === "string" ? rewriteStepRefs(e, idMap) : e)); + } + if (Array.isArray(np.context)) { + np.context = np.context.map((c) => (typeof c === "string" ? rewriteStepRefs(c, idMap) : c)); + } + if (np.branches?.length) { + np.branches = np.branches.map((b) => { + const nb = { ...b }; + if (typeof nb.task === "string") nb.task = rewriteStepRefs(nb.task, idMap); + return nb; + }); + } + if (np.with && typeof np.with === "object") { + const nw: Record = {}; + for (const [k, v] of Object.entries(np.with)) { + nw[k] = typeof v === "string" ? rewriteStepRefs(v, idMap) : v; + } + np.with = nw; + } + const score = (np as { score?: { target?: unknown; judge?: { task?: unknown } } }).score; + if (score && typeof score === "object") { + const ns = { ...score }; + if (typeof ns.target === "string") ns.target = rewriteStepRefs(ns.target, idMap); + if (ns.judge && typeof ns.judge === "object" && typeof ns.judge.task === "string") { + ns.judge = { ...ns.judge, task: rewriteStepRefs(ns.judge.task, idMap) }; + } + (np as { score?: unknown }).score = ns; + } return np; }), }; @@ -47,6 +98,7 @@ export function prefixGraftFragment(fragment: Taskflow, expandPhaseId: string): /** * Copy child phase states onto the parent run (graft promote). * Skips ids that already exist on the parent. + * Returns whether any phase was promoted (caller should zero expand usage to avoid double-count). */ export function promoteGraftPhases( parent: RunState, @@ -59,9 +111,18 @@ export function promoteGraftPhases( warnings.push(`expand graft skipped promote of '${cid}' (id already exists on parent)`); continue; } + // Keep child usage for audit; expand phase usage must be zeroed by caller + // so run-level aggregateUsage does not double-count. parent.phases[cid] = { ...cps, id: cid }; promoted++; } - warnings.push(`expand graft: promoted ${promoted} phase(s) onto parent run`); + if (promoted > 0) { + warnings.push(`expand graft: promoted ${promoted} phase(s) onto parent run`); + } return { promoted, warnings }; } + +/** Empty usage helper for expand phase after graft (avoid double-count in rollup). */ +export function emptyUsageForGraftExpand(): ReturnType { + return emptyUsage(); +} diff --git a/packages/taskflow-core/src/runtime/phases/race.ts b/packages/taskflow-core/src/runtime/phases/race.ts index 6770ec2..1618deb 100644 --- a/packages/taskflow-core/src/runtime/phases/race.ts +++ b/packages/taskflow-core/src/runtime/phases/race.ts @@ -1,14 +1,11 @@ /** - * Race phase — first completed branch wins. - * - * Isolated from the runtime monolith so Horizon B kinds land without growing - * `runtime.ts` further. Callers inject fan-out primitives (runOne / cache). + * Race phase — first **successful** branch wins (not first-settled). */ import type { Phase } from "../../schema.ts"; import type { RunResult } from "../../runner-core.ts"; import type { PhaseState } from "../../store.ts"; -import { emptyUsage } from "../../usage.ts"; +import { emptyUsage, aggregateUsage } from "../../usage.ts"; import { safeParse } from "../../interpolate.ts"; export interface RaceBranch { @@ -16,7 +13,6 @@ export interface RaceBranch { task: string; } -/** Optional per-branch AbortSignal — used to cancel losers after a winner settles. */ export interface RaceRunOne { (agent: string, task: string, signal?: AbortSignal): Promise; } @@ -26,11 +22,9 @@ export interface RaceIsFailed { } /** - * Execute race branches. Pure w.r.t. scheduling — does not touch RunState. - * - * When `cancelLosers` is true (default), abort controllers for non-winning - * branches fire after the first branch settles (best-effort: depends on the - * host runner honoring AbortSignal). + * First **successful** branch wins. Failed settles do not terminate the race. + * If all fail → race fails. cancelLosers aborts others after a success (best-effort). + * Final usage aggregates all branch results (including aborted partials). */ export async function executeRaceBranches( phase: Phase, @@ -41,7 +35,6 @@ export async function executeRaceBranches( inputHash: string; parseJson: boolean; readRefs?: PhaseState["reads"]; - /** Parent run abort — chained onto each branch controller. */ parentSignal?: AbortSignal; }, ): Promise { @@ -59,7 +52,6 @@ export async function executeRaceBranches( const cancelLosers = (phase as { cancelLosers?: boolean }).cancelLosers !== false; const controllers = branches.map(() => new AbortController()); - // Chain parent abort → all branches. const onParentAbort = () => { for (const c of controllers) { try { @@ -74,53 +66,97 @@ export async function executeRaceBranches( else opts.parentSignal.addEventListener("abort", onParentAbort, { once: true }); } - const branchPromises = branches.map(async (b, i) => { - const result = await runOne(b.agent, b.task, controllers[i]!.signal); - return { i, result }; + type Settled = { i: number; result: RunResult }; + const settled: Settled[] = []; + let winner: Settled | undefined; + let wake!: () => void; + const gate = new Promise((r) => { + wake = r; }); - const raced = await Promise.race(branchPromises); - - if (cancelLosers) { - for (let j = 0; j < controllers.length; j++) { - if (j !== raced.i) { - try { - controllers[j]!.abort(); - } catch { - /* ignore */ + try { + const branchPromises = branches.map(async (b, i) => { + let result: RunResult; + try { + result = await runOne(b.agent, b.task, controllers[i]!.signal); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + result = { + agent: b.agent, + task: b.task, + exitCode: 1, + output: "", + stderr: msg, + usage: emptyUsage(), + stopReason: "error", + errorMessage: msg, + }; + } + const entry: Settled = { i, result }; + settled.push(entry); + if (!winner && !isFailed(result)) { + winner = entry; + if (cancelLosers) { + for (let j = 0; j < controllers.length; j++) { + if (j !== i) { + try { + controllers[j]!.abort(); + } catch { + /* ignore */ + } + } + } } + wake(); + } else if (settled.length >= branches.length) { + wake(); } - } - } + return entry; + }); - // Let aborted branches settle (avoid unhandled rejections / orphan work). - await Promise.allSettled(branchPromises); + await Promise.race([gate, Promise.allSettled(branchPromises).then(() => undefined)]); + await Promise.allSettled(branchPromises); - if (opts.parentSignal) { - opts.parentSignal.removeEventListener("abort", onParentAbort); - } + const totalUsage = aggregateUsage(settled.map((s) => s.result.usage ?? emptyUsage())); + + if (!winner) { + const errs = settled + .map((s) => s.result.errorMessage || s.result.stderr || `branch ${s.i + 1} failed`) + .filter(Boolean); + return { + id: phase.id, + status: "failed", + error: `race phase '${phase.id}': all ${branches.length} branches failed${errs.length ? `: ${errs.slice(0, 3).join("; ")}` : ""}`, + usage: totalUsage, + inputHash: opts.inputHash, + endedAt: Date.now(), + warnings: ["race: all branches failed"], + ...(opts.readRefs ? { reads: opts.readRefs } : {}), + }; + } - const winner = raced.result; - const failed = isFailed(winner); - const warnings = [`race: branch ${raced.i + 1}/${branches.length} won`]; - if (cancelLosers) { - warnings.push( - `race: cancelLosers aborted ${branches.length - 1} loser branch(es) (best-effort AbortSignal)`, - ); + const w = winner.result; + const warnings = [`race: branch ${winner.i + 1}/${branches.length} won (first success)`]; + if (cancelLosers && branches.length > 1) { + warnings.push( + `race: cancelLosers aborted ${branches.length - 1} loser branch(es) (best-effort AbortSignal)`, + ); + } + return { + id: phase.id, + status: "done", + output: w.output, + json: opts.parseJson ? safeParse(w.output) : undefined, + usage: totalUsage, + model: w.model, + inputHash: opts.inputHash, + endedAt: Date.now(), + warnings, + ...(opts.readRefs ? { reads: opts.readRefs } : {}), + }; + } finally { + if (opts.parentSignal) { + opts.parentSignal.removeEventListener("abort", onParentAbort); + } } - return { - id: phase.id, - status: failed ? "failed" : "done", - output: failed - ? winner.errorMessage || winner.stderr || winner.output || `race branch ${raced.i + 1} failed` - : winner.output, - json: opts.parseJson ? safeParse(winner.output) : undefined, - usage: winner.usage ?? emptyUsage(), - model: winner.model, - error: failed ? winner.errorMessage ?? winner.stderr : undefined, - inputHash: opts.inputHash, - endedAt: Date.now(), - warnings, - ...(opts.readRefs ? { reads: opts.readRefs } : {}), - }; } diff --git a/packages/taskflow-core/test/race-expand.test.ts b/packages/taskflow-core/test/race-expand.test.ts index 2a07962..89825c4 100644 --- a/packages/taskflow-core/test/race-expand.test.ts +++ b/packages/taskflow-core/test/race-expand.test.ts @@ -75,7 +75,7 @@ test("validate: race + expand shapes", () => { ); }); -test("race: first completed branch wins", async () => { +test("race: first successful branch wins", async () => { const def: Taskflow = { name: "race-flow", phases: [ @@ -107,6 +107,59 @@ test("race: first completed branch wins", async () => { assert.equal(st.phases.r?.output?.trim(), "FAST"); assert.ok(st.phases.r?.warnings?.some((w) => /branch 2/.test(w))); assert.ok(st.phases.r?.warnings?.some((w) => /cancelLosers aborted/.test(w ?? ""))); + // Usage aggregates both branches (winner + aborted loser partial) + assert.ok((st.phases.r?.usage?.cost ?? 0) >= 0.001); +}); + +test("race: first-success ignores fast failure (slow success wins)", async () => { + const def: Taskflow = { + name: "race-fail-fast", + phases: [ + { + id: "r", + type: "race", + cancelLosers: false, + branches: [ + { task: "fail-fast", agent: "a" }, + { task: "slow-ok", agent: "a" }, + ], + final: true, + }, + ], + }; + const st = mkState(def); + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task) => { + if (task.includes("fail-fast")) { + return { + agent, + task, + exitCode: 1, + output: "", + stderr: "boom", + usage: { ...emptyUsage(), input: 1, output: 0, cost: 0.001, turns: 1 }, + stopReason: "error", + errorMessage: "boom", + }; + } + await new Promise((r) => setTimeout(r, 20)); + return { + agent, + task, + exitCode: 0, + output: "RECOVERED", + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.002, turns: 1 }, + stopReason: "end", + }; + }, + }); + assert.equal(st.status, "completed"); + assert.equal(st.phases.r?.status, "done"); + assert.equal(st.phases.r?.output?.trim(), "RECOVERED"); + assert.ok((st.phases.r?.usage?.cost ?? 0) >= 0.002); }); test("race: cancelLosers aborts losing branch via AbortSignal", async () => { @@ -280,4 +333,54 @@ test("expand graft: promotes child phases onto parent", async () => { // grafted id is grow-leaf assert.equal(st.phases["grow-leaf"]?.status, "done"); assert.match(st.phases["grow-leaf"]?.output ?? "", /grafted-ok/); + // No usage double-count: expand usage zeroed; child holds cost + assert.equal(st.phases.grow?.usage?.cost ?? 0, 0); + assert.ok((st.phases["grow-leaf"]?.usage?.cost ?? 0) > 0); +}); + +test("expand graft: rewrites multi-phase {steps.*} refs after prefix", async () => { + const def: Taskflow = { + name: "exp-graft-chain", + phases: [ + { + id: "grow", + type: "expand", + expandMode: "graft", + def: { + name: "frag", + phases: [ + { id: "a", type: "agent", agent: "a", task: "part-a-out" }, + { + id: "b", + type: "agent", + agent: "a", + task: "chain from {steps.a.output}", + dependsOn: ["a"], + final: true, + }, + ], + }, + final: true, + }, + ], + }; + const st = mkState(def); + const seen: string[] = []; + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: runner((t) => { + seen.push(t); + if (t.includes("part-a")) return "A-VALUE"; + if (t.includes("chain from")) return `B-SEES-${t}`; + return "x"; + }), + }); + assert.equal(st.status, "completed", st.phases.grow?.defError ?? st.phases.grow?.error); + assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError); + assert.equal(st.phases["grow-a"]?.status, "done"); + assert.equal(st.phases["grow-b"]?.status, "done"); + // Task text must use grow-a after prefix rewrite + assert.ok(seen.some((t) => t.includes("grow-a") || t.includes("A-VALUE"))); + assert.match(st.phases["grow-b"]?.output ?? "", /A-VALUE/); }); diff --git a/packages/taskflow-dsl/src/build/erase/context.ts b/packages/taskflow-dsl/src/build/erase/context.ts index 43c0526..6191c81 100644 --- a/packages/taskflow-dsl/src/build/erase/context.ts +++ b/packages/taskflow-dsl/src/build/erase/context.ts @@ -20,7 +20,13 @@ export function nextSyntheticId(ctx: EmitContext, prefix: string): string { export function register(ctx: EmitContext, draft: PhaseDraft): string { delete draft.raw.id; + // Union auto-wired deps with any explicit dependsOn already on raw (opts). + const explicit = Array.isArray(draft.raw.dependsOn) + ? (draft.raw.dependsOn as unknown[]).filter((d): d is string => typeof d === "string") + : []; + for (const d of explicit) draft.dependsOn.add(d); if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; + else delete draft.raw.dependsOn; if (draft.final) draft.raw.final = true; ctx.phases.set(draft.id, draft); if (!ctx.order.includes(draft.id)) ctx.order.push(draft.id); diff --git a/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts b/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts index b8e0e6c..bf4260f 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts @@ -22,7 +22,19 @@ export function emitGateSugar( const up = call.arguments[0]; if (up && ts.isIdentifier(up) && ctx.phases.has(up.text)) draft.dependsOn.add(up.text); const optsArg = call.arguments[1] as ts.Expression | undefined; - const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + const sugarKeys = new Set([ + "pass", + "scorers", + "combine", + "threshold", + "weights", + "target", + "judge", + "task", + ]); + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases, { + allowKeys: sugarKeys, + }); if (typeof opts.id === "string") draft.id = opts.id; Object.assign(draft.raw, opts); diff --git a/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts b/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts index 10a8a85..a506de6 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts @@ -22,7 +22,8 @@ export function emitParallel( const optsArg = call.arguments[1] as ts.Expression | undefined; const branches: Array> = []; if (arr && ts.isArrayLiteralExpression(arr)) { - for (const el of arr.elements) { + for (let bi = 0; bi < arr.elements.length; bi++) { + const el = arr.elements[bi]!; if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { const erased = eraseStringish( ctx.sf, @@ -46,6 +47,13 @@ export function emitParallel( ); Object.assign(b, bopts); branches.push(b); + } else { + ctx.diags.push({ + code: "TFDSL_BRANCH_KIND", + severity: "error", + message: `parallel() branch ${bi + 1} must be agent(...) — other expressions are not erasable.`, + file: ctx.file, + }); } } } diff --git a/packages/taskflow-dsl/src/build/erase/kinds/race.ts b/packages/taskflow-dsl/src/build/erase/kinds/race.ts index 32bfd02..8bbefc0 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/race.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/race.ts @@ -21,7 +21,8 @@ export function emitRace( const arr = call.arguments[0]; const branches: Array> = []; if (arr && ts.isArrayLiteralExpression(arr)) { - for (const el of arr.elements) { + for (let bi = 0; bi < arr.elements.length; bi++) { + const el = arr.elements[bi]!; if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { const erased = eraseStringish( ctx.sf, @@ -45,6 +46,13 @@ export function emitRace( ); Object.assign(b, bopts); branches.push(b); + } else { + ctx.diags.push({ + code: "TFDSL_BRANCH_KIND", + severity: "error", + message: `race() branch ${bi + 1} must be agent(...) — other expressions are not erasable.`, + file: ctx.file, + }); } } } diff --git a/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts b/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts index 1bfaea5..dd5c6a4 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts @@ -25,7 +25,8 @@ export function emitTournament( if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue; if (p.name.text === "branches" && ts.isArrayLiteralExpression(p.initializer)) { const branches: Array> = []; - for (const el of p.initializer.elements) { + for (let bi = 0; bi < p.initializer.elements.length; bi++) { + const el = p.initializer.elements[bi]!; if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { const erased = eraseStringish( ctx.sf, @@ -46,6 +47,13 @@ export function emitTournament( ); Object.assign(b, bopts); branches.push(b); + } else { + ctx.diags.push({ + code: "TFDSL_BRANCH_KIND", + severity: "error", + message: `tournament.branches[${bi}] must be agent(...).`, + file: ctx.file, + }); } } draft.raw.branches = branches; diff --git a/packages/taskflow-dsl/src/build/erase/opts.ts b/packages/taskflow-dsl/src/build/erase/opts.ts index f2a1e3e..6f9791d 100644 --- a/packages/taskflow-dsl/src/build/erase/opts.ts +++ b/packages/taskflow-dsl/src/build/erase/opts.ts @@ -7,12 +7,18 @@ import type { Diagnostic } from "../../diagnostics.ts"; import { calleeName, diag, evalLiteral } from "./ast.ts"; import type { PhaseDraft } from "./types.ts"; +/** Extra option keys allowed without TFDSL_RUNE_OPTS_UNKNOWN (sugar / kind-specific). */ +export type MergeOptsExtra = { + allowKeys?: ReadonlySet; +}; + export function mergeOpts( sf: ts.SourceFile, file: string, obj: ts.Expression | undefined, diags: Diagnostic[], phases: Map, + extra?: MergeOptsExtra, ): Record { if (!obj) return {}; if (!ts.isObjectLiteralExpression(obj)) { @@ -28,6 +34,11 @@ export function mergeOpts( ? p.name.text : undefined; if (!key) continue; + if (extra?.allowKeys?.has(key)) { + const v = evalLiteral(p.initializer); + if (v !== undefined) out[key] = v; + continue; + } if (key === "dependsOn" && ts.isArrayLiteralExpression(p.initializer)) { const ids: string[] = []; diff --git a/packages/taskflow-dsl/src/build/erase/pipeline.ts b/packages/taskflow-dsl/src/build/erase/pipeline.ts index 2b26822..f6d95f2 100644 --- a/packages/taskflow-dsl/src/build/erase/pipeline.ts +++ b/packages/taskflow-dsl/src/build/erase/pipeline.ts @@ -97,6 +97,18 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul if (!PHASE_RUNES.has(cn.split(".")[0]!) && !PHASE_RUNES.has(cn)) { if (cn === "json") return undefined; + // Bound or returned call that is not a known rune → hard error (no silent drop). + if (bindName !== undefined) { + diags.push( + diag( + file, + sf, + call, + "TFDSL_RUNE_UNKNOWN", + `Unknown rune or call '${cn}' cannot erase to a phase (typo?).`, + ), + ); + } return undefined; } diff --git a/packages/taskflow-dsl/src/decompile.ts b/packages/taskflow-dsl/src/decompile.ts index 1c63919..bbdd589 100644 --- a/packages/taskflow-dsl/src/decompile.ts +++ b/packages/taskflow-dsl/src/decompile.ts @@ -51,7 +51,41 @@ export function decompileTaskflow(def: Taskflow): string { } const lines: string[] = []; - lines.push(`import { flow, agent, map, parallel, gate, reduce, approval, subflow, loop, tournament, script } from "taskflow-dsl";`); + const imports = new Set([ + "flow", + "agent", + "map", + "parallel", + "gate", + "reduce", + "approval", + "subflow", + "loop", + "tournament", + "script", + ]); + for (const p of def.phases ?? []) { + if (p.type === "race") imports.add("race"); + if (p.type === "expand") imports.add("expand"); + } + // Stable import order for golden/tests + const order = [ + "flow", + "agent", + "map", + "parallel", + "gate", + "reduce", + "approval", + "subflow", + "loop", + "tournament", + "script", + "race", + "expand", + ]; + const importList = order.filter((n) => imports.has(n)); + lines.push(`import { ${importList.join(", ")} } from "taskflow-dsl";`); lines.push(``); const desc = def.description ? `, { description: ${JSON.stringify(def.description)} }` : ""; lines.push(`export default flow(${JSON.stringify(def.name)}${desc}, (ctx) => {`); @@ -125,8 +159,15 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri ); return `const ${bind} = race([${branches.join(", ")}]${optStr});`; } - case "expand": - return `const ${bind} = expand(${JSON.stringify(typeof p.def === "string" ? p.def : "{steps.plan.json}")}, { expandMode: ${JSON.stringify((p as { expandMode?: string }).expandMode ?? "nested")} });`; + case "expand": { + if (typeof p.def !== "string") { + throw new Error( + `TFDSL_DECOMPILE_UNSUPPORTED: expand phase ${JSON.stringify(p.id)} has non-string def (inline object cannot be recovered as a rune argument)`, + ); + } + const em = (p as { expandMode?: string }).expandMode ?? "nested"; + return `const ${bind} = expand(${JSON.stringify(p.def)}, { expandMode: ${JSON.stringify(em)} });`; + } case "reduce": { const from = (p.from ?? p.dependsOn ?? []).map((id) => phaseBinding(id)); return `const ${bind} = reduce([${from.join(", ")}], (parts) => agent(\`${esc(String(p.task ?? "reduce"))}\`)${optStr});`; @@ -135,7 +176,12 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri return `const ${bind} = approval({ request: \`${esc(String(p.task ?? "Approve?"))}\`${opts.length ? `, ${opts.join(", ")}` : ""} });`; case "flow": if (p.def !== undefined) { - return `const ${bind} = subflow.def(${JSON.stringify(typeof p.def === "string" ? p.def : "/* inline */")}${optStr});`; + if (typeof p.def !== "string") { + throw new Error( + `TFDSL_DECOMPILE_UNSUPPORTED: flow phase ${JSON.stringify(p.id)} has non-string def (cannot decompile inline object)`, + ); + } + return `const ${bind} = subflow.def(${JSON.stringify(p.def)}${optStr});`; } return `const ${bind} = subflow(${JSON.stringify(p.use ?? "child")}${optStr});`; case "loop": diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index f832868..218c4c8 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -482,14 +482,17 @@ output is exact. ### Race phases (first completed wins) A `race` phase runs static `branches[]` concurrently and **returns the first -branch that finishes successfully** (first-finish-wins). Unlike `parallel` -(waits for all) or `tournament` (judges quality after all variants), use race -when latency matters more than comparing every approach. +branch that finishes successfully** (failed settles do **not** win — a slower +success still wins over a fast hard-fail). Unlike `parallel` (waits for all) or +`tournament` (judges quality after all variants), use race when latency matters +more than comparing every approach. - `branches` — **required**, at least two `{task, agent?}`. -- `cancelLosers` — optional boolean (default `true`). After the first branch - settles, abort other branches via `AbortSignal` (best-effort — host must honor - the signal). Set `false` to let losers finish naturally. +- `cancelLosers` — optional boolean (default `true`). After the first **success**, + abort other branches via `AbortSignal` (best-effort — host must honor the + signal). Set `false` to let losers finish naturally. +- Phase `usage` **aggregates all branches** (including aborted partials) so + budgets stay honest. - Output of the winning branch becomes the race phase output; a warning records which branch won. diff --git a/website/content/docs/en/syntax/phase-types.mdx b/website/content/docs/en/syntax/phase-types.mdx index 1ac6276..78f7728 100644 --- a/website/content/docs/en/syntax/phase-types.mdx +++ b/website/content/docs/en/syntax/phase-types.mdx @@ -419,11 +419,11 @@ To pass dynamic values into a script safely, pipe them through `input` (which su `script` phases cannot use `retry` or `output: "json"`. `timeout` defaults to `60000` and caps at `300000` ms. -## First finish wins: `race` +## First success wins: `race` -When several approaches can answer the same question and **latency** matters more than waiting for every branch, use `race`. Branches start together; the first successful completion becomes the phase output (unlike `parallel`, which waits for all, or `tournament`, which judges quality after all variants). +When several approaches can answer the same question and **latency** matters more than waiting for every branch, use `race`. Branches start together; the **first successful** completion becomes the phase output (a fast hard-fail does **not** win). Unlike `parallel` (waits for all) or `tournament` (judges quality after all variants). -```json title="race — first completed wins" +```json title="race — first success wins" { "id": "quick", "type": "race", @@ -436,7 +436,7 @@ When several approaches can answer the same question and **latency** matters mor ``` - **`cancelLosers` (default true):** after the first branch settles, other branches receive `AbortSignal` (best-effort — the host runner must honor it). Set `false` to let losers finish. + **`cancelLosers` (default true):** after the first **success**, other branches receive `AbortSignal` (best-effort). Phase `usage` aggregates all branches for honest budgets. Set `false` to let losers finish. ## Dynamic fragment: `expand` diff --git a/website/content/docs/zh-cn/syntax/phase-types.mdx b/website/content/docs/zh-cn/syntax/phase-types.mdx index 0285f26..360f968 100644 --- a/website/content/docs/zh-cn/syntax/phase-types.mdx +++ b/website/content/docs/zh-cn/syntax/phase-types.mdx @@ -419,11 +419,11 @@ gate 审查上游输出并给出裁决。`PASS` 让流程继续;`BLOCK` 中止 `script` 阶段不能用 `retry` 或 `output: "json"`。`timeout` 默认 `60000`、上限 `300000` ms。 -## 先完成者胜:`race` +## 先成功者胜:`race` -当多种路径都能回答同一问题、且**延迟**比等齐所有分支更重要时,用 `race`。各分支同时启动,**第一个成功完成**的输出成为阶段结果(`parallel` 会等全部;`tournament` 会等齐后由裁判比质量)。 +当多种路径都能回答同一问题、且**延迟**比等齐所有分支更重要时,用 `race`。各分支同时启动,**第一个成功完成**的输出成为阶段结果(快速硬失败**不会**赢)。`parallel` 会等全部;`tournament` 会等齐后由裁判比质量。 -```json title="race — 先完成者胜" +```json title="race — 先成功者胜" { "id": "quick", "type": "race", @@ -436,7 +436,7 @@ gate 审查上游输出并给出裁决。`PASS` 让流程继续;`BLOCK` 中止 ``` - **`cancelLosers`(默认 true):** 第一个分支 settle 后,其余分支收到 `AbortSignal`(尽力而为——宿主 runner 需遵守)。设 `false` 可让败者跑完。 + **`cancelLosers`(默认 true):** 第一个**成功**后,其余分支收到 `AbortSignal`(尽力而为)。阶段 `usage` 汇总所有分支以便预算诚实。设 `false` 可让败者跑完。 ## 动态片段:`expand` From 8bcab87b070113c1aa908d3cdffdddf99cb45063 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 19:20:20 +0800 Subject: [PATCH 36/51] docs: sync race first-success wording after re-review Fix residual claim ledger, CHANGELOG, schema, and skills text that still said first-settle / cancelLosers reserved. --- CHANGELOG.md | 2 +- docs/internal/claim-vs-impl-0.2.0.md | 4 ++-- packages/claude-taskflow/plugin/skills/taskflow/SKILL.md | 4 ++-- .../claude-taskflow/plugin/skills/taskflow/configuration.md | 4 ++-- packages/codex-taskflow/plugin/skills/taskflow/SKILL.md | 4 ++-- .../codex-taskflow/plugin/skills/taskflow/configuration.md | 4 ++-- packages/grok-taskflow/plugin/skills/taskflow/SKILL.md | 4 ++-- .../grok-taskflow/plugin/skills/taskflow/configuration.md | 4 ++-- packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md | 4 ++-- .../opencode-taskflow/plugin/skills/taskflow/configuration.md | 4 ++-- packages/pi-taskflow/skills/taskflow/SKILL.md | 4 ++-- packages/pi-taskflow/skills/taskflow/configuration.md | 4 ++-- packages/taskflow-core/src/schema.ts | 4 ++-- skills-src/taskflow/configuration.md | 4 ++-- skills-src/taskflow/core.md | 4 ++-- 15 files changed, 29 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6f5299..87b5de2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to taskflow are documented here. This project follows [Keep ### Added - **S4 TypeScript DSL (`taskflow-dsl`):** compile-time `.tf.ts` runes erase to Taskflow JSON via TypeScript AST (`typescript` package, not ts-morph); CLI `new` / `check` / `build` / `decompile`; modular `erase/kinds/*` registry; parity tests (map + `json` + templates). Hosts still run JSON only (no MCP auto-build of `.tf.ts`). Package may still version as `0.1.7` until a formal 0.2.0 publish; npm may lag monorepo. -- **Horizon B engine kinds (`race`, `expand`):** `PHASE_TYPES` is **12**. Imperative runtime + FlowIR 1:1; `race` = first-finish-wins; `expand` = nested or graft-promote (`def`, `expandMode`, `maxNodes`). DSL runes + skills + website phase docs + examples `race-first-win.json` / `expand-nested-fragment.json`. **`cancelLosers` (default true)** aborts loser branches via best-effort `AbortSignal` after the first branch settles. Event kernel still covers the original **10** kinds (excludes `race`/`expand`); advanced features continue to force imperative fallback. +- **Horizon B engine kinds (`race`, `expand`):** `PHASE_TYPES` is **12**. Imperative runtime + FlowIR 1:1; `race` = **first-success** wins (failed settles do not win); usage aggregates all branches; `expand` = nested or graft-promote (`def`, `expandMode`, `maxNodes`) with template rewrite on graft. DSL runes + skills + website phase docs + examples. **`cancelLosers` (default true)** aborts losers via best-effort `AbortSignal` after the first **success**. Event kernel still covers the original **10** kinds (excludes `race`/`expand`); advanced features continue to force imperative fallback. - **S5 plan:** `docs/internal/s5-kernel-default-on-plan.md` — parity harness first, then feature gaps, then default ON + flagship recompute metric. - **Claim-vs-impl alignment:** `docs/internal/claim-vs-impl-0.2.0.md` ledger; S4 RFCs toolchain corrected; architecture S4 ✅ / S5 next; skills + README phase/test/package counts; website homepage **12** phases / **5** hosts; reference page **TypeScript DSL** (en/zh). - **Docs complete for Phase 2 surfaces:** website FlowIR docs (`ir:<64-hex>`, `usedFallbackHash: false`); new concepts **Deterministic Replay** (en/zh) + resume disambiguation; host MCP guides + Grok website + `reference/commands` list all 12 tools including `taskflow_replay`; README en/zh Commands tables; skills `advanced.md` trace/replay vs recompute; `AGENTS.md` exec/trace/replay + kernel flag; architecture RFC status table S0–S5 + internal FlowIR RFC superseded note. diff --git a/docs/internal/claim-vs-impl-0.2.0.md b/docs/internal/claim-vs-impl-0.2.0.md index cb8d742..4d247cf 100644 --- a/docs/internal/claim-vs-impl-0.2.0.md +++ b/docs/internal/claim-vs-impl-0.2.0.md @@ -23,7 +23,7 @@ |-------|--------| | Version | All packages still **0.1.7**; branch is `release/0.2.0` preview — not a published 0.2.0 npm tag until bump | | S5 | Kernel default ON **not** done; flagship $6→$0.40 is **acceptance target**, not certified number | -| `cancelLosers` | Schema + DSL accept; **runtime ignores** (first-finish-wins; warning when default true) | +| `cancelLosers` | **Implemented**: abort losers after first **success** (best-effort AbortSignal); usage aggregates all branches | | Event kernel “complete” | Complete for **kernel-eligible** kinds/features; not race/expand; not score/retry/expect/reflexion/cross-run cache/shareContext | | Multi-host DSL | Hosts run **Taskflow JSON**; `.tf.ts` requires prior `taskflow-dsl build` | | Decompile | Semantic, not literal round-trip | @@ -37,7 +37,7 @@ loop multi-body · route · compensate/saga · watch · experimental C-track run ## Alignment actions taken ### Pass 1 (claim ledger) -1. Schema + skills: `cancelLosers` reserved/ignored; race warning. +1. Schema + skills: `cancelLosers` documented (later upgraded to real abort + first-success). 2. S4 RFCs: ts-morph → TypeScript compiler API. 3. FlowIR/step/runtime comments: 12 kinds / kernel-10. 4. README / AGENTS / workspace / architecture counts. diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index ecd7237..ddb3087 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -179,7 +179,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | -| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `race` | run `branches[]` concurrently; **first success wins** (unlike parallel) | Race phases below | | `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -466,7 +466,7 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` -### Race phases (first completed wins) +### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first branch that finishes successfully** (failed settles do **not** win — a slower diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index a48cc0a..797a8e3 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | +| `cancelLosers` | race | `true` | Abort in-flight losers after first **success** (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -459,7 +459,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index a86deb9..a1409de 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -178,7 +178,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | -| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `race` | run `branches[]` concurrently; **first success wins** (unlike parallel) | Race phases below | | `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -465,7 +465,7 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` -### Race phases (first completed wins) +### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first branch that finishes successfully** (failed settles do **not** win — a slower diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index a057da1..587296b 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | +| `cancelLosers` | race | `true` | Abort in-flight losers after first **success** (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -456,7 +456,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index c535085..3b8c09e 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -181,7 +181,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | -| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `race` | run `branches[]` concurrently; **first success wins** (unlike parallel) | Race phases below | | `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -468,7 +468,7 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` -### Race phases (first completed wins) +### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first branch that finishes successfully** (failed settles do **not** win — a slower diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index 2f93ee2..3f38e83 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | +| `cancelLosers` | race | `true` | Abort in-flight losers after first **success** (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -459,7 +459,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 5bf99d2..e58d8d7 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -179,7 +179,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | -| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `race` | run `branches[]` concurrently; **first success wins** (unlike parallel) | Race phases below | | `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -466,7 +466,7 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` -### Race phases (first completed wins) +### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first branch that finishes successfully** (failed settles do **not** win — a slower diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index f3e76c2..16201cd 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | +| `cancelLosers` | race | `true` | Abort in-flight losers after first **success** (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -460,7 +460,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index 6fb499c..02bbc33 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -170,7 +170,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | -| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `race` | run `branches[]` concurrently; **first success wins** (unlike parallel) | Race phases below | | `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -455,7 +455,7 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` -### Race phases (first completed wins) +### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first branch that finishes successfully** (failed settles do **not** win — a slower diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index aad2572..5103d97 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -85,7 +85,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | +| `cancelLosers` | race | `true` | Abort in-flight losers after first **success** (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -463,7 +463,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index ac46f41..f2566aa 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -150,8 +150,8 @@ const PhaseSchema = Type.Object( ), /** * [race] When true (default), abort in-flight loser branches after the first branch - * settles (best-effort `AbortSignal` to the host runner). First-finish-wins still - * applies. Set `false` to let losers run to natural completion. + * **succeeds** (best-effort `AbortSignal`). Race semantics are first-**success** + * (failed settles do not win). Set `false` to let losers run to natural completion. */ cancelLosers: Type.Optional(Type.Boolean({ default: true })), diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index dd1236b..d7ab3f4 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -83,7 +83,7 @@ Keys of each object in `phases[]`. Some only apply to specific `type`s. | `over` | map | — | **Required for map.** Must resolve to an array. | | `as` | map | `item` | Loop variable bound per item. | | `branches` | parallel, race | — | **Required** (≥1 for parallel; ≥2 for race). `[{task, agent?}]`. | -| `cancelLosers` | race | `true` | Abort in-flight losers after first settle (best-effort AbortSignal). | +| `cancelLosers` | race | `true` | Abort in-flight losers after first **success** (best-effort AbortSignal). | | `from` | reduce | — | **Required for reduce.** Phase ids whose outputs are aggregated. | | `def` | expand, flow | — | **Required for expand.** Fragment Taskflow / phases array / `{steps.X.json}`. | | `expandMode` | expand | `nested` | `nested` = isolated sub-flow; `graft` = promote children as `-`. | @@ -500,7 +500,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first completed wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 218c4c8..2360342 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -186,7 +186,7 @@ back cleanly: precedence is `define` (inline) > `defineFile` (disk) > `name` | `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 | -| `race` | run `branches[]` concurrently; **first completed wins** (unlike parallel) | Race phases below | +| `race` | run `branches[]` concurrently; **first success wins** (unlike parallel) | Race phases below | | `expand` | run a dynamic fragment (`def`); `nested` (isolated) or `graft` (promote onto parent) | Expand phases below | ### Control-flow fields (any phase) @@ -479,7 +479,7 @@ output is exact. "input": "{steps.analyze.output}", "dependsOn": ["analyze"], "final": true } ``` -### Race phases (first completed wins) +### Race phases (first success wins) A `race` phase runs static `branches[]` concurrently and **returns the first branch that finishes successfully** (failed settles do **not** win — a slower From 7c92915caf7c541264a24fdb8eb9b3894fb2d736 Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 9 Jul 2026 22:05:52 +0800 Subject: [PATCH 37/51] release: cut 0.2.0 manifests and close adversarial review gaps Bump all nine packages and plugin pins to 0.2.0; wire publish/CI for taskflow-dsl and grok. Fix race parent-abort hang, nested kernel re-admission, DSL bare unknown runes, decompile dependsOn, docs honesty (EN/zh, website first-success), and add regression tests. --- .github/workflows/ci.yml | 4 + .github/workflows/publish.yml | 23 ++- CHANGELOG.md | 6 +- CONTRIBUTING.md | 11 +- DECISIONS.md | 10 +- README.md | 7 +- README.zh-CN.md | 17 +- RELEASE.md | 29 +-- docs/0.2.0-north-star.md | 2 +- docs/claude-mcp.md | 2 +- docs/codex-mcp.md | 4 +- docs/grok-mcp.md | 2 +- docs/internal/claim-vs-impl-0.2.0.md | 30 +++- ...ulti-agent-review-s4-horizon-2026-07-09.md | 62 +++---- docs/internal/s5-kernel-default-on-plan.md | 74 ++++---- docs/rfc-0.2.0-dsl-phases-horizon.md | 26 +-- docs/rfc-0.2.0-s4-decision-record.md | 58 +++--- docs/rfc-0.2.0-s4-mvp.md | 80 ++++----- examples/race-first-win.json | 2 +- package.json | 9 +- packages/claude-taskflow/package.json | 8 +- .../plugin/.claude-plugin/plugin.json | 2 +- packages/claude-taskflow/plugin/.mcp.json | 2 +- .../plugin/skills/taskflow/configuration.md | 2 +- .../claude-taskflow/test/mcp-server.test.ts | 1 + packages/codex-taskflow/package.json | 8 +- .../plugin/.codex-plugin/plugin.json | 2 +- packages/codex-taskflow/plugin/.mcp.json | 2 +- .../plugin/skills/taskflow/configuration.md | 2 +- .../test/e2e-mcp-comprehensive.mts | 3 +- .../codex-taskflow/test/mcp-server.test.ts | 1 + packages/grok-taskflow/package.json | 8 +- .../plugin/.grok-plugin/plugin.json | 2 +- packages/grok-taskflow/plugin/.mcp.json | 2 +- .../plugin/skills/taskflow/configuration.md | 2 +- .../grok-taskflow/test/mcp-server.test.ts | 1 + packages/opencode-taskflow/package.json | 8 +- .../opencode-taskflow/plugin/opencode.json | 2 +- .../plugin/skills/taskflow/configuration.md | 2 +- .../opencode-taskflow/test/mcp-server.test.ts | 1 + packages/pi-taskflow/package.json | 4 +- .../skills/taskflow/configuration.md | 2 +- packages/taskflow-core/package.json | 4 +- packages/taskflow-core/src/exec/driver.ts | 31 +++- .../src/flowir/canonical-hash.ts | 2 + packages/taskflow-core/src/flowir/compile.ts | 34 +++- packages/taskflow-core/src/flowir/schema.ts | 13 ++ .../taskflow-core/src/runtime/phases/race.ts | 56 ++++-- packages/taskflow-core/src/schema.ts | 15 +- .../test/exec-kernel-hardening.test.ts | 41 +++++ .../test/exec-kernel-parity.test.ts | 14 ++ .../test/flowir-canonical-hash.test.ts | 6 + packages/taskflow-core/test/flowir.test.ts | 54 ++++++ .../taskflow-core/test/race-expand.test.ts | 166 +++++++++++++++++- packages/taskflow-dsl/package.json | 14 +- .../taskflow-dsl/src/build/erase/context.ts | 4 +- .../taskflow-dsl/src/build/erase/pipeline.ts | 36 ++-- packages/taskflow-dsl/src/decompile.ts | 13 +- packages/taskflow-dsl/src/runes.ts | 93 +++++----- .../taskflow-dsl/test/erase-build.test.ts | 149 +++++++++++++++- .../taskflow-dsl/test/kinds-coverage.test.ts | 5 - packages/taskflow-hosts/package.json | 4 +- packages/taskflow-mcp-core/package.json | 6 +- packages/taskflow-mcp-core/src/mcp/server.ts | 20 ++- pnpm-lock.yaml | 67 +++++-- pnpm-workspace.yaml | 7 + skills-src/taskflow/configuration.md | 2 +- website/content/docs/en/concepts/phases.mdx | 6 +- .../docs/en/reference/typescript-dsl.mdx | 2 +- .../content/docs/zh-cn/concepts/phases.mdx | 6 +- .../docs/zh-cn/reference/typescript-dsl.mdx | 2 +- 71 files changed, 1007 insertions(+), 390 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 80c1b17..6a65112 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,10 @@ jobs: - name: OpenCode MCP e2e (stdio handshake + tool calls) run: pnpm run test:e2e-opencode-mcp + # Grok MCP e2e is also stdio-only and does not require a live Grok model. + - name: Grok MCP e2e (stdio handshake + tool calls) + run: pnpm run test:e2e-grok-mcp + build: name: build (dist emit) runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index edd23f9..35d90dd 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,8 +1,9 @@ name: Publish & Release -# Publishes taskflow-core + taskflow-mcp-core + taskflow-hosts + pi-taskflow + codex-taskflow + -# claude-taskflow + opencode-taskflow to npmjs.com and creates a GitHub Release, when a v* tag is -# pushed (e.g. `git tag v0.1.0 && git push origin v0.1.0`). All seven workspace +# Publishes all nine packages (taskflow-core, taskflow-mcp-core, taskflow-hosts, +# taskflow-dsl, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, +# grok-taskflow) to npmjs.com and creates a GitHub Release when a v* tag is +# pushed (e.g. `git tag v0.2.0 && git push origin v0.2.0`). All nine workspace # versions must equal the tag. on: @@ -46,7 +47,7 @@ jobs: echo "::error::Tag $TAG does not match root version $EXPECT" exit 1 fi - for pkg in taskflow-core taskflow-mcp-core taskflow-hosts pi-taskflow codex-taskflow claude-taskflow opencode-taskflow; do + for pkg in taskflow-core taskflow-mcp-core taskflow-hosts taskflow-dsl pi-taskflow codex-taskflow claude-taskflow opencode-taskflow grok-taskflow; do V="v$(node -p "require('./packages/$pkg/package.json').version")" if [ "$V" != "$TAG" ]; then echo "::error::Tag $TAG does not match $pkg version $V" @@ -66,6 +67,11 @@ jobs: echo "::error::Tag $TAG does not match claude plugin.json version $CLAUDE_PLUGIN_V" exit 1 fi + GROK_PLUGIN_V="v$(node -p "require('./packages/grok-taskflow/plugin/.grok-plugin/plugin.json').version")" + if [ "$GROK_PLUGIN_V" != "$TAG" ]; then + echo "::error::Tag $TAG does not match grok plugin.json version $GROK_PLUGIN_V" + exit 1 + fi CODEX_PINNED="v$(node -p "require('./packages/codex-taskflow/plugin/.mcp.json').mcpServers.taskflow.args.find(a => a.startsWith('codex-taskflow@')).split('@')[1]")" if [ "$CODEX_PINNED" != "$TAG" ]; then echo "::error::codex .mcp.json pins codex-taskflow@${CODEX_PINNED#v} but tag is $TAG" @@ -76,6 +82,11 @@ jobs: echo "::error::claude .mcp.json pins claude-taskflow@${CLAUDE_PINNED#v} but tag is $TAG" exit 1 fi + GROK_PINNED="v$(node -p "require('./packages/grok-taskflow/plugin/.mcp.json').mcpServers.taskflow.args.find(a => a.startsWith('grok-taskflow@')).split('@')[1]")" + if [ "$GROK_PINNED" != "$TAG" ]; then + echo "::error::grok .mcp.json pins grok-taskflow@${GROK_PINNED#v} but tag is $TAG" + exit 1 + fi # OpenCode has no plugin manifest; its scaffold opencode.json pins the # npx package version the same way, so that pin must equal the tag too. OPENCODE_PINNED="v$(node -p "require('./packages/opencode-taskflow/plugin/opencode.json').mcp.taskflow.command.find(a => a.startsWith('opencode-taskflow@')).split('@')[1]")" @@ -105,10 +116,12 @@ jobs: publish_one taskflow-core publish_one taskflow-mcp-core publish_one taskflow-hosts + publish_one taskflow-dsl publish_one pi-taskflow publish_one codex-taskflow publish_one claude-taskflow publish_one opencode-taskflow + publish_one grok-taskflow env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -129,7 +142,7 @@ jobs: } } catch {} const text = body.join('\n').trim(); - fs.writeFileSync('/tmp/release-notes.md', text || ('Release ' + process.argv[1] + ' \u2014 taskflow-core, taskflow-mcp-core, taskflow-hosts, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow.')); + fs.writeFileSync('/tmp/release-notes.md', text || ('Release ' + process.argv[1] + ' \u2014 taskflow-core, taskflow-mcp-core, taskflow-hosts, taskflow-dsl, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow.')); " "$VERSION" gh release create "$GITHUB_REF_NAME" \ --notes-file /tmp/release-notes.md \ diff --git a/CHANGELOG.md b/CHANGELOG.md index 87b5de2..cd546d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +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] +## [0.2.0] — 2026-07-09 ### Added -- **S4 TypeScript DSL (`taskflow-dsl`):** compile-time `.tf.ts` runes erase to Taskflow JSON via TypeScript AST (`typescript` package, not ts-morph); CLI `new` / `check` / `build` / `decompile`; modular `erase/kinds/*` registry; parity tests (map + `json` + templates). Hosts still run JSON only (no MCP auto-build of `.tf.ts`). Package may still version as `0.1.7` until a formal 0.2.0 publish; npm may lag monorepo. -- **Horizon B engine kinds (`race`, `expand`):** `PHASE_TYPES` is **12**. Imperative runtime + FlowIR 1:1; `race` = **first-success** wins (failed settles do not win); usage aggregates all branches; `expand` = nested or graft-promote (`def`, `expandMode`, `maxNodes`) with template rewrite on graft. DSL runes + skills + website phase docs + examples. **`cancelLosers` (default true)** aborts losers via best-effort `AbortSignal` after the first **success**. Event kernel still covers the original **10** kinds (excludes `race`/`expand`); advanced features continue to force imperative fallback. +- **S4 TypeScript DSL (`taskflow-dsl`):** compile-time `.tf.ts` runes erase to Taskflow JSON via TypeScript AST (`typescript` package, not ts-morph); CLI `new` / `check` / `build` / `decompile`; modular `erase/kinds/*` registry; parity tests (map + `json` + templates). Hosts still run JSON only (no MCP auto-build of `.tf.ts`). +- **Horizon B engine kinds (`race`, `expand`):** `PHASE_TYPES` is **12**. Imperative runtime + FlowIR 1:1; `race` = **first-success** wins (failed settles do not win); cooperative loser usage is aggregated; non-cooperative losers and **parent abort** are bounded by a cancellation grace (gate is woken on parent abort so the race cannot hang forever); `expand` = nested or graft-promote (`def`, `expandMode`, `maxNodes`) with template rewrite on graft. DSL runes + skills + website phase docs + examples. **`cancelLosers` (default true)** aborts losers via best-effort `AbortSignal` after the first **success**. Event kernel still covers the original **10** kinds (excludes `race`/`expand`); advanced features continue to force imperative fallback; **nested** `flow{def}/use` re-checks kernel admission (fail-closed if child has race/expand or unsupported features). - **S5 plan:** `docs/internal/s5-kernel-default-on-plan.md` — parity harness first, then feature gaps, then default ON + flagship recompute metric. - **Claim-vs-impl alignment:** `docs/internal/claim-vs-impl-0.2.0.md` ledger; S4 RFCs toolchain corrected; architecture S4 ✅ / S5 next; skills + README phase/test/package counts; website homepage **12** phases / **5** hosts; reference page **TypeScript DSL** (en/zh). - **Docs complete for Phase 2 surfaces:** website FlowIR docs (`ir:<64-hex>`, `usedFallbackHash: false`); new concepts **Deterministic Replay** (en/zh) + resume disambiguation; host MCP guides + Grok website + `reference/commands` list all 12 tools including `taskflow_replay`; README en/zh Commands tables; skills `advanced.md` trace/replay vs recompute; `AGENTS.md` exec/trace/replay + kernel flag; architecture RFC status table S0–S5 + internal FlowIR RFC superseded note. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb19d95..243f554 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,17 +35,20 @@ I review issues and PRs ~weekly. If you need a faster turnaround, mention why in ## Architecture -See [`AGENTS.md`](./AGENTS.md) for the full layout and conventions. `taskflow` is a pnpm-workspace monorepo of seven published packages: +See [`AGENTS.md`](./AGENTS.md) for the full layout and conventions. `taskflow` is a pnpm-workspace monorepo of **nine** published packages: | Package / directory | What | |---------------------|------| | `packages/taskflow-core/` | Host-neutral engine: runtime, schema, agents, store, cache, verify, compile, context-store (zero host-SDK deps) | | `packages/taskflow-core/src/agents/` | 18 built-in agent definitions (`.md` with YAML frontmatter) | | `packages/taskflow-mcp-core/` | Host-neutral MCP server: stdio JSON-RPC + `taskflow_*` tools + DAG SVG/outline renderer (depends on taskflow-core) | +| `packages/taskflow-hosts/` | Shared host runners (codex/claude/opencode/grok) + argv builders + event-stream parsers | +| `packages/taskflow-dsl/` | TypeScript DSL CLI — erase `.tf.ts` → Taskflow JSON / FlowIR | | `packages/pi-taskflow/` | Pi extension adapter (`taskflow` tool + `/tf` commands, TUI) + `skills/` | -| `packages/codex-taskflow/` | Codex subagent runner + MCP bin + Codex plugin | -| `packages/claude-taskflow/` | Claude Code subagent runner + MCP bin + Claude Code plugin | -| `packages/opencode-taskflow/` | OpenCode subagent runner + MCP bin + opencode.json scaffold | +| `packages/codex-taskflow/` | Codex delivery package + MCP bin + Codex plugin | +| `packages/claude-taskflow/` | Claude Code delivery package + MCP bin + Claude Code plugin | +| `packages/opencode-taskflow/` | OpenCode delivery package + MCP bin + opencode.json scaffold | +| `packages/grok-taskflow/` | Grok Build delivery package + MCP bin + Grok plugin | | `examples/` | Runnable flow definitions (`.json`) | | `docs/` | Design docs, RFCs, dogfooding reports | diff --git a/DECISIONS.md b/DECISIONS.md index f9bde6d..b9a66a7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -51,7 +51,7 @@ host ecosystem's delivery artifact (`codex plugin add taskflow@taskflow`, `npm i -g codex-taskflow`, an MCP server a user points their client at). But there is *no* reason their release cadence is independent — adapters almost never change except when `taskflow-core`'s contract changes or a host CLI -changes its flags. Today all **eight** packages are **lockstep versioned** at the +changes its flags. Today all **nine** packages are **lockstep versioned** at the same number, which makes a per-package semver meaningless: a codex flag fix forces a new version of the untouched core engine. @@ -174,11 +174,11 @@ error blob. comma host list; the skill body is shared. Do not per-host the skill body. - **MCP server is its own package (`taskflow-mcp-core`)** — a pure presentation layer over core. Pi users never pull MCP code. This boundary is correct. -- **Lockstep versioning is kept for now** (all eight packages share a version). +- **Lockstep versioning is kept for now** (all nine packages share a version). It is crude but it is *less* work than tracking which subset of packages need - a given bump, and at 8 packages the cost is still low. `taskflow-hosts` now - exists; the next revisit is when core genuinely needs to move on its own - cadence independent of host CLI flag churn. + a given bump, and at 9 packages the cost is still low. `taskflow-hosts` and + `taskflow-dsl` exist; the next revisit is when core genuinely needs to move + on its own cadence independent of host CLI flag churn. --- diff --git a/README.md b/README.md index f449871..5bcda68 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ runs on Pi, Codex, Claude Code, OpenCode, and Grok Build

+

Release line 0.2.0 — monorepo packages and plugin pins are 0.2.0; npm registry updates after the v0.2.0 tag publish job. Badge above tracks the published npm line until then.

+

English · 简体中文 @@ -926,7 +928,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 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. +**v0.2.0** (this monorepo release line — npm after `v0.2.0` tag) — adds the `taskflow-dsl` TypeScript frontend, Grok Build delivery package, 12 phase kinds with `race`/`expand`, FlowIR content hashes, event-kernel trace/fold, and offline replay. **v0.1.7** — **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 nine 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): @@ -947,6 +949,7 @@ 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/grok `SubagentRunner` impls + their argv builders + event-stream parsers; depends on core | +| [`taskflow-dsl`](./packages/taskflow-dsl) | TypeScript DSL CLI/package — erases `.tf.ts` to Taskflow JSON and optional FlowIR; 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)) | @@ -958,7 +961,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 eight packages +pnpm run build # emit dist/*.js + .d.ts for all nine 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 d91c05b..eaf0964 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -8,11 +8,13 @@ MIT license zero runtime dependencies CI status - 1140 tests + 1400+ tests dogfooded runs on Pi, Codex, Claude Code, OpenCode, and Grok Build

+

发布线 0.2.0 — monorepo 包与插件 pin 为 0.2.0;npm 在 v0.2.0 tag 发布任务完成后更新。上方徽章在发版前仍可能显示 registry 上的旧版本。

+

English · 简体中文 · @@ -354,6 +356,8 @@ grok mcp add taskflow -- node "$(pwd)/packages/grok-taskflow/dist/mcp/bin.js" | `loop` | **迭代一个任务直到完成**——重复运行主体直到条件满足、收敛或达到上限 | `task`、`until` | | `tournament` | **N 个变体竞争**,评判者选择最佳(或聚合) | `task` \| `branches` | | `script` | 运行一条 **shell 命令**——无 LLM、零代币——捕获 stdout 作为输出 | `run` | +| `race` | **最先成功**的分支胜出(可选 `cancelLosers` 中止失败者) | `branches`(≥2) | +| `expand` | 运行动态片段(`nested` 隔离或 `graft` 提升) | `def`(+ `expandMode?`) | ### 通用阶段字段 @@ -769,7 +773,7 @@ provided files. Report violations grouped by file. No fixes.

-**0 个运行时依赖** · **1140 个测试** · **10 种阶段类型** · **共享上下文树** · **跨会话恢复** · **跨运行记忆化** · **逐项 map 缓存** · **增量重算** · **后台(detached)执行** · **`compile` Mermaid 渲染** · **~9k LOC 运行时** +**0 个运行时依赖** · **1400+ 测试** · **12 种阶段类型** · **共享上下文树** · **跨会话恢复** · **跨运行记忆化** · **逐项 map 缓存** · **增量重算** · **FlowIR 编译缝** · **后台(detached)执行** · **`compile` Mermaid 渲染** · **~9k LOC 运行时**
@@ -799,7 +803,7 @@ provided files. Report violations grouped by file. No fixes. ## 状态与边界 -**v0.1.7**——当前发布版。完整历史详见 [CHANGELOG](./CHANGELOG.md)。本版修复:**文件 loader 现在会报告文件失败的原因 + 解析位置**(行/列),而非合并成一句"not found or unparseable"——`defineFile`、saved flow、run 记录、library sidecar 都区分*缺失*与*损坏*,手写流程里一个裸换行几秒就能定位;`safeParse` 对 LLM 输出仍保持宽松。同时修复了 pi-taskflow 升级提示每会话重复打印的问题。**v0.1.6** 新增 **库 Phase 1**(先搜后写 + 可复用流程资产)、**`defineFile` 参数**(从磁盘路径 verify/compile/run 流程)、以及流程定义文件的 **JSONC 注释支持**(`//` 与 `/* */` 注释 + 尾逗号,由零依赖的 `parseJsonc` 解析)。**v0.1.5** 新增了 **Claude Code 与 OpenCode 两个宿主**、**将 MCP 服务器拆为独立的 `taskflow-mcp-core` 包**,并**将三个宿主运行器去重**为共享的 `runSubagentProcess`。基线:**八个包的多宿主 monorepo**——宿主无关的 `taskflow-core` 引擎、宿主无关的 `taskflow-mcp-core` MCP 服务器、共享宿主运行器的 `taskflow-hosts`(含 codex/claude/opencode/grok),加上 `pi-taskflow`(Pi 适配器)、`codex-taskflow`、`claude-taskflow`、`opencode-taskflow`、`grok-taskflow`(后四者为交付包,通过 `taskflow-hosts` 复用 runner + MCP bin + 插件/配置),共享 `taskflow-mcp-core` 中的宿主无关 MCP 服务器。**共享上下文树**:可选开启(`shareContext` / `contextSharing`)的黑板 + 监督工具(`ctx_read`/`ctx_write` 水平复用、`ctx_report`/`ctx_spawn` 垂直监督)。**工作区隔离**:阶段的 `cwd` 接受保留关键字 `temp`/`dedicated`/`worktree`,运行时分配隔离目录(或一条一次性分支上的 git worktree)并在阶段结束后拆除。**后台(detached)执行**:运行可脱离会话后台执行。早期功能:循环至完成(`loop`)、锦标赛(best-of-N 带评判者)、跨运行记忆化(基于 git/文件/glob/环境指纹和 TTL 的内容寻址缓存)、交互式 `/tf init`、18 个内置代理及模型角色。完整的控制流与可靠性层(`when` 守卫、`join: any`、`retry`/回退、`approval`、`flow` 组合、`budget` 上限、`eval` 机器门控、空闲看门狗)构建在 DSL + DAG 运行时(`agent`/`parallel`/`map`/`gate`/`reduce`)之上。支持内联 + 已保存流程、跨会话恢复、实时进度和上下文隔离。一次运行作为一个流式工具调用执行。 +**v0.2.0**(本 monorepo 发布线——`v0.2.0` tag 发布后 npm 才更新)——新增 `taskflow-dsl` TypeScript 前端、Grok Build 交付包、含 `race`/`expand` 的 **12** 种阶段、FlowIR 内容哈希、事件内核 trace/fold、离线 replay。**v0.1.7** 修复:文件 loader 报告失败原因 + 解析位置;pi-taskflow 升级提示一次性;gate fail-closed(issue #54)。**v0.1.6** 新增库 Phase 1、`defineFile`、JSONC。**v0.1.5** 新增 Claude Code / OpenCode 宿主、`taskflow-mcp-core` 拆分。基线:**九个包的多宿主 monorepo**——`taskflow-core`、`taskflow-mcp-core`、`taskflow-hosts`、`taskflow-dsl`,加上 `pi-taskflow`、`codex-taskflow`、`claude-taskflow`、`opencode-taskflow`、`grok-taskflow`。**共享上下文树**:可选开启(`shareContext` / `contextSharing`)的黑板 + 监督工具(`ctx_read`/`ctx_write` 水平复用、`ctx_report`/`ctx_spawn` 垂直监督)。**工作区隔离**:阶段的 `cwd` 接受保留关键字 `temp`/`dedicated`/`worktree`,运行时分配隔离目录(或一条一次性分支上的 git worktree)并在阶段结束后拆除。**后台(detached)执行**:运行可脱离会话后台执行。早期功能:循环至完成(`loop`)、锦标赛(best-of-N 带评判者)、跨运行记忆化(基于 git/文件/glob/环境指纹和 TTL 的内容寻址缓存)、交互式 `/tf init`、18 个内置代理及模型角色。完整的控制流与可靠性层(`when` 守卫、`join: any`、`retry`/回退、`approval`、`flow` 组合、`budget` 上限、`eval` 机器门控、空闲看门狗)构建在 DSL + DAG 运行时(`agent`/`parallel`/`map`/`gate`/`reduce`)之上。支持内联 + 已保存流程、跨会话恢复、实时进度和上下文隔离。一次运行作为一个流式工具调用执行。 已知边界(已追踪、有限定——不会在流程中途出现意外): @@ -813,13 +817,14 @@ provided files. Report violations grouped by file. No fixes. ## 开发 -`taskflow` 是一个 pnpm-workspace monorepo,包含七个发布包: +`taskflow` 是一个 pnpm-workspace monorepo,包含九个发布包(八个 host/core + `taskflow-dsl`): | 包 | 角色 | |----|------| | [`taskflow-core`](./packages/taskflow-core) | 宿主无关的编排引擎(零宿主 SDK 依赖;仅 `typebox`)——运行时、DSL、缓存、验证 | | [`taskflow-mcp-core`](./packages/taskflow-mcp-core) | 宿主无关的 MCP 服务器(stdio JSON-RPC + `taskflow_*` 工具 + DAG 渲染);依赖 core | -| [`taskflow-hosts`](./packages/taskflow-hosts) | 共享宿主 runner 集合(codex / claude / opencode 的 `SubagentRunner` + argv 构建器 + 事件流解析器);依赖 core | +| [`taskflow-hosts`](./packages/taskflow-hosts) | 共享宿主 runner 集合(codex / claude / opencode / grok 的 `SubagentRunner` + argv 构建器 + 事件流解析器);依赖 core | +| [`taskflow-dsl`](./packages/taskflow-dsl) | TypeScript DSL CLI——将 `.tf.ts` 擦除为 Taskflow JSON / FlowIR;依赖 core | | [`pi-taskflow`](./packages/pi-taskflow) | Pi 扩展适配器——`taskflow` 工具 + `/tf` 命令(即 `pi install npm:pi-taskflow` 安装的内容) | | [`codex-taskflow`](./packages/codex-taskflow) | Codex 子代理运行器 + MCP bin,及 [Codex 插件](./packages/codex-taskflow/plugin)([指南](./docs/codex-mcp.md)) | | [`claude-taskflow`](./packages/claude-taskflow) | Claude Code 子代理运行器 + MCP bin,及 [Claude Code 插件](./packages/claude-taskflow/plugin)([指南](./docs/claude-mcp.md)) | @@ -831,7 +836,7 @@ pnpm install pnpm run typecheck # 跨所有包做 tsc --noEmit(无需构建) pnpm test # 单元测试——无网络,无进程派生 pnpm run test:hosts # 仅 taskflow-hosts 测试(另有 test:pi、test:codex、test:claude、test:opencode) -pnpm run build # 为八个包生成 dist/*.js + .d.ts +pnpm run build # 为全部九个包生成 dist/*.js + .d.ts pnpm run test:e2e-codex # codex executor 端到端(需 `codex` + 模型访问权限) pnpm run test:e2e-codex-mcp # codex MCP 服务器端到端 pnpm run test:e2e-claude-mcp # claude MCP 服务器端到端(无需实时 claude) diff --git a/RELEASE.md b/RELEASE.md index 6c6ab0c..ac4ce94 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,23 +1,24 @@ # Release Guide (monorepo) -taskflow is a monorepo of eight independently published packages: +taskflow is a monorepo of nine independently published packages: | Package | npm name | What it is | |---------|----------|------------| | `packages/taskflow-core` | **`taskflow-core`** | Host-neutral engine (DSL, runtime, cache, verify). Zero host SDK deps. | | `packages/taskflow-mcp-core` | **`taskflow-mcp-core`** | Host-neutral MCP server (stdio JSON-RPC + taskflow_* tools + DAG renderer). Depends on core. | | `packages/taskflow-hosts` | **`taskflow-hosts`** | Shared host-runner collection: codex/claude/opencode/grok `SubagentRunner` impls + argv builders + event-stream parsers. Depends on core. | +| `packages/taskflow-dsl` | **`taskflow-dsl`** | TypeScript DSL CLI/package: erases `.tf.ts` to Taskflow JSON and optional FlowIR. Depends on core. | | `packages/pi-taskflow` | **`pi-taskflow`** | Pi extension adapter. Keeps the original published name (no break for existing users). | | `packages/codex-taskflow` | **`codex-taskflow`** | Codex delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + plugin. | | `packages/claude-taskflow` | **`claude-taskflow`** | Claude Code delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + plugin. | | `packages/opencode-taskflow` | **`opencode-taskflow`** | OpenCode delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + config scaffold. | | `packages/grok-taskflow` | **`grok-taskflow`** | Grok Build delivery package: re-exports the runner from `taskflow-hosts` + MCP bin + plugin. | -Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `pi-taskflow`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, and `grok-taskflow` all depend on `taskflow-core` (`taskflow-mcp-core` and `taskflow-hosts` directly; the adapters via both `taskflow-hosts` and `taskflow-mcp-core`), so **core publishes first, then taskflow-mcp-core, then taskflow-hosts, then the adapters**. +Dependency order: `taskflow-mcp-core`, `taskflow-hosts`, `taskflow-dsl`, `pi-taskflow`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, and `grok-taskflow` all depend on `taskflow-core` (`taskflow-mcp-core`, `taskflow-hosts`, and `taskflow-dsl` directly; the adapters via both `taskflow-hosts` and `taskflow-mcp-core`), so **core publishes first, then taskflow-mcp-core, taskflow-hosts, taskflow-dsl, then the adapters**. ## One-time setup -All eight names are non-scoped and available on public npm — **no npm org needed**. `pi-taskflow` is already owned by `heggria`; the rest (`taskflow-core`, `taskflow-mcp-core`, `taskflow-hosts`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, `grok-taskflow`) are unclaimed until first publish (publishing creates them). +All nine names are non-scoped and available on public npm — **no npm org needed**. `pi-taskflow` is already owned by `heggria`; the rest (`taskflow-core`, `taskflow-mcp-core`, `taskflow-hosts`, `taskflow-dsl`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`, `grok-taskflow`) are unclaimed until first publish (publishing creates them). ```sh # 1. Point at PUBLIC npm (the repo's default registry may be a private mirror) @@ -34,8 +35,8 @@ pnpm whoami --registry=https://registry.npmjs.org/ # expect: heggria (or the o ```sh pnpm install # links the workspaces pnpm run typecheck # 0 errors (resolves taskflow-core to src via the dev condition) -pnpm test # 1140/1140 green -pnpm run build # emit dist/ for all eight packages (tsc → .js + .d.ts) +pnpm test # full unit suite green +pnpm run build # emit dist/ for all nine packages (tsc → .js + .d.ts) ``` ### Skill coverage check (before every release) @@ -72,6 +73,7 @@ this release's CHANGELOG section, verify: pnpm publish --filter taskflow-core --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter taskflow-mcp-core --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter taskflow-hosts --registry=https://registry.npmjs.org/ --provenance +pnpm publish --filter taskflow-dsl --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter pi-taskflow --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter codex-taskflow --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter claude-taskflow --registry=https://registry.npmjs.org/ --provenance @@ -81,22 +83,20 @@ pnpm publish --filter grok-taskflow --registry=https://registry.npmjs.org/ - `publishConfig.access: public` is set on each package, so scoped/unscoped both publish publicly. -> **Note on `taskflow-core` as a dependency.** `taskflow-mcp-core`, `taskflow-hosts`, and the host adapters -> (`pi-taskflow` / `codex-taskflow` / `claude-taskflow` / `opencode-taskflow` / `grok-taskflow`) -> declare `"taskflow-core": "0.1.7"` (an exact version, not `workspace:*`), so the -> published tarballs resolve the real npm package once it exists. Always publish -> `taskflow-core` first and bump all eight in lockstep. (`taskflow-mcp-core` and `taskflow-hosts` are the -> other internal dependencies: the MCP host adapters pin `"taskflow-mcp-core"`; the codex/claude/opencode/grok -> delivery packages pin `"taskflow-hosts"`.) +> **Note on internal dependencies.** Workspace package manifests use +> `workspace:*` locally so `pnpm install --frozen-lockfile` never depends on a +> not-yet-published release. `pnpm publish` converts those workspace ranges in +> the packed tarballs. Always publish `taskflow-core` first and bump all nine in +> lockstep. ## Tag + GitHub Release (automated) Pushing a `v*` tag triggers `.github/workflows/publish.yml`, which verifies all -eight package versions match the tag, publishes them in order, and cuts a GitHub +nine package versions match the tag, publishes them in order, and cuts a GitHub Release from the matching `CHANGELOG.md` section. ```sh -git tag v0.1.7 && git push origin v0.1.7 +git tag v0.2.0 && git push origin v0.2.0 ``` ## Verify after publish @@ -105,6 +105,7 @@ git tag v0.1.7 && git push origin v0.1.7 pnpm view taskflow-core version --registry=https://registry.npmjs.org/ pnpm view taskflow-mcp-core version --registry=https://registry.npmjs.org/ pnpm view taskflow-hosts version --registry=https://registry.npmjs.org/ +pnpm view taskflow-dsl version --registry=https://registry.npmjs.org/ pnpm view pi-taskflow version --registry=https://registry.npmjs.org/ pnpm view codex-taskflow version --registry=https://registry.npmjs.org/ pnpm view claude-taskflow version --registry=https://registry.npmjs.org/ diff --git a/docs/0.2.0-north-star.md b/docs/0.2.0-north-star.md index 6dd1329..af3f294 100644 --- a/docs/0.2.0-north-star.md +++ b/docs/0.2.0-north-star.md @@ -59,7 +59,7 @@ review 的 critic 用缺陷 #2 证明了:**"读 `.output` 自动建依赖"+ 真 | 前沿思想 | 来源 | taskflow 对应 | 状态 | |---|---|---|---| -| 编译优先(运行时→编译时) | Svelte/Solid/Vue Vapor | FlowIR 编译 + `taskflow-dsl` AST erase | ✅ S0+S4(分支内;版本号仍 0.1.7 待发) | +| 编译优先(运行时→编译时) | Svelte/Solid/Vue Vapor | FlowIR 编译 + `taskflow-dsl` AST erase | ✅ S0+S4(分支内;版本号已 bump 至 0.2.0,待发布) | | 细粒度响应式(signals) | TC39/Solid/Svelte5 | overstory observed readSet + stale + recompute | ✅ 已落地 | | rune 显式化 | Svelte 5 runes | DSL rune 函数(编译指令,`TFDSL_ERASE_ONLY`) | ✅ S4 | | **resumable**(跨会话 / detached) | **Qwik**(借其 resume 叙事,非 hydration) | cross-session resume + detached runs + cache | ✅ 已落地 | diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index 2cbc37c..1c6d12a 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -35,7 +35,7 @@ Verify: ```sh claude plugin list # → claude-taskflow@taskflow installed, enabled -claude mcp list # → taskflow … (npx -y -p claude-taskflow@0.1.7 claude-taskflow-mcp) +claude mcp list # → taskflow … (npx -y -p claude-taskflow@0.2.0 claude-taskflow-mcp) ``` The bundled skill tells Claude Code *when* to reach for the tools (multi-phase diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index 4a3f7ca..275a43b 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -31,7 +31,7 @@ globally, and the plugin version binds the exact code that runs. Verify: ```sh codex plugin list # → taskflow@taskflow installed, enabled -codex mcp list # → taskflow … enabled (npx -y -p codex-taskflow@0.1.7 codex-taskflow-mcp) +codex mcp list # → taskflow … enabled (npx -y -p codex-taskflow@0.2.0 codex-taskflow-mcp) ``` The bundled skill tells Codex *when* to reach for the tools (multi-phase or @@ -53,7 +53,7 @@ To stop large flows from being cut off, the plugin's `.mcp.json` ships a "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.1.7", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.2.0", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md index 01b402d..6aaa14e 100644 --- a/docs/grok-mcp.md +++ b/docs/grok-mcp.md @@ -119,7 +119,7 @@ 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 +grok mcp add taskflow -- npx -y -p grok-taskflow@0.2.0 grok-taskflow-mcp ``` Verify: diff --git a/docs/internal/claim-vs-impl-0.2.0.md b/docs/internal/claim-vs-impl-0.2.0.md index 4d247cf..c2d6904 100644 --- a/docs/internal/claim-vs-impl-0.2.0.md +++ b/docs/internal/claim-vs-impl-0.2.0.md @@ -1,6 +1,6 @@ # Claim vs implementation — verification log (`release/0.2.0`) -> Last full pass: 2026-07-09 · Branch HEAD after alignment commit +> Last full pass: 2026-07-09 · Adversarial closure pass (PR-ready) > Purpose: single ledger so marketing/RFCs/skills do not outrun the code. ## Verified true (hard claims OK) @@ -21,10 +21,10 @@ | Topic | Truth | |-------|--------| -| Version | All packages still **0.1.7**; branch is `release/0.2.0` preview — not a published 0.2.0 npm tag until bump | +| Version | Package manifests and plugin pins are bumped to **0.2.0**; npm is not published until the `v0.2.0` release job succeeds | | S5 | Kernel default ON **not** done; flagship $6→$0.40 is **acceptance target**, not certified number | -| `cancelLosers` | **Implemented**: abort losers after first **success** (best-effort AbortSignal); usage aggregates all branches | -| Event kernel “complete” | Complete for **kernel-eligible** kinds/features; not race/expand; not score/retry/expect/reflexion/cross-run cache/shareContext | +| `cancelLosers` | **Implemented**: first-**success** wins; abort losers after success; parent abort wakes + grace-bounds wait; cooperative losers in usage | +| Event kernel “complete” | Complete for **kernel-eligible** kinds/features; not race/expand; not score/retry/expect/reflexion/cross-run cache/shareContext; **nested** `flow` re-runs `canUseEventKernel` (fail-closed) | | Multi-host DSL | Hosts run **Taskflow JSON**; `.tf.ts` requires prior `taskflow-dsl build` | | Decompile | Semantic, not literal round-trip | | Test count | ~**1400+** unit tests in ~**95** `*.test.ts` files (regenerate badge on release) | @@ -52,19 +52,33 @@ loop multi-body · route · compensate/saga · watch · experimental C-track run 11. Skills advanced: `flow{def}` vs `expand`; configuration caveats (kernel/decompile). ### Pass 3 (multi-agent review P0/P1 fixes) -12. Race **first-success** (not first-settled); usage aggregates all branches. +12. Race **first-success** (not first-settled); cooperative loser usage aggregates. 13. Graft: rewrite `{steps.*}` after id prefix; zero expand usage after promote (no double-count). 14. DSL `register()` unions explicit `dependsOn`; unknown runes / non-agent branches error. 15. Decompile: import race/expand; fail-closed non-string `def`. 16. Kernel policy: `incremental` + workspace cwd keywords force imperative. 17. README phase table + taskflow-dsl README preview note. +### Pass 4 (multi-agent P0/P1 code fixes) +18. Race **first-success** (not first-settled); non-cooperative loser wait is bounded. +19. Graft: rewrite `{steps.*}` after id prefix; zero expand usage after promote. +20. DSL `register()` unions dependsOn; unknown runes / non-agent branches error. +21. Decompile: import race/expand; fail-closed non-string `def`. +22. Kernel policy: incremental + workspace cwd keywords force imperative. + +### Pass 5 (adversarial re-review closure) +23. Race parent-abort wakes gate + grace; all-fail unit test; cancelLosers grace retained. +24. Nested event-kernel re-admission (`canUseEventKernel` on child) fail-closed. +25. DSL bare unknown callees error; P0 regression tests (dependsOn union, branch kind, decompile). +26. Decompile expand keeps `dependsOn`/`final`/`maxNodes`; race emits `cancelLosers: false`. +27. Docs honesty: EN/zh README monorepo-vs-npm banner; zh phase/package parity; website race first-success; example description; publish.yml nine packages; CONTRIBUTING/DECISIONS counts. +28. CI includes `test:e2e-grok-mcp` (already wired). + ## Still open (not claimed as done) -- Formal **0.2.0 npm publish** + version bump (packages still 0.1.7; `taskflow-dsl` may 404 on registry). +- Formal **0.2.0 npm publish** after merge + `v0.2.0` tag; pins already match. - S5 kernel default ON + flagship $ demo seal → plan: `docs/internal/s5-kernel-default-on-plan.md`. -- Live host e2e as release gate (unit suite is the local hard gate). -- FlowIR full sidecar for expandMode/maxNodes/cancelLosers (optional S5.x). +- Live host **executor** e2e as release gate (MCP e2e is CI; live model stays manual). ## Re-verify commands diff --git a/docs/internal/multi-agent-review-s4-horizon-2026-07-09.md b/docs/internal/multi-agent-review-s4-horizon-2026-07-09.md index a8f42e7..d0168e8 100644 --- a/docs/internal/multi-agent-review-s4-horizon-2026-07-09.md +++ b/docs/internal/multi-agent-review-s4-horizon-2026-07-09.md @@ -1,13 +1,13 @@ # Multi-agent review — S4 + Horizon B + alignment (`release/0.2.0`) -> Date: 2026-07-09 · HEAD: `4da6f33` -> Scope: S4 `taskflow-dsl`, race/expand/cancelLosers, claim alignment, S5 plan -> Agents: architecture · race/expand concurrency · DSL erase · docs honesty +> Date: 2026-07-09 · HEAD: `4da6f33` +> Scope: S4 `taskflow-dsl`, race/expand/cancelLosers, claim alignment, S5 plan +> Agents: architecture · race/expand concurrency · DSL erase · docs honesty > Raw notes: `/tmp/grok-ma-review-{arch,race,dsl,docs}-c1d88a29.md` ## Council verdict -**Do not market “0.2.0 complete / race first-success / graft multi-phase ready” without fixes.** +**Do not market “0.2.0 complete / race first-success / graft multi-phase ready” without fixes.** Engineering depth is real (12 kinds, DSL package, tests green), but several **correctness and honesty** gaps remain above nits. | Track | Verdict | @@ -23,37 +23,37 @@ Engineering depth is real (12 kinds, DSL package, tests green), but several **co ### 1. Race: first-settled vs first-success *(race agent · bug)* -- **Code:** `Promise.race` → first **settle** (fail or ok) wins. -- **Skills:** “first branch that finishes **successfully**”. -- **Impact:** Fast hard-fail kills the race while a slower branch would succeed. +- **Code:** `Promise.race` → first **settle** (fail or ok) wins. +- **Skills:** “first branch that finishes **successfully**”. +- **Impact:** Fast hard-fail kills the race while a slower branch would succeed. - **Fix:** (A) first-success loop, or (B) rewrite all author docs to first-settled. Prefer **A**. ### 2. Graft: template refs not rewritten *(race agent · bug)* -- **Code:** `prefixGraftFragment` rewrites ids + `dependsOn`/`from` only. -- **Impact:** Multi-phase fragments with `{steps.a…}` break after prefix; validate-after-prefix can fail-open via `defError`. Single-phase graft works by accident. +- **Code:** `prefixGraftFragment` rewrites ids + `dependsOn`/`from` only. +- **Impact:** Multi-phase fragments with `{steps.a…}` break after prefix; validate-after-prefix can fail-open via `defError`. Single-phase graft works by accident. - **Fix:** Rewrite collectible template surfaces with `idMap`; test two-phase graft chain. ### 3. Graft usage double-count *(race agent · bug)* -- Expand phase `usage` = sub total **and** promoted children keep usage → run rollup **2×**. +- Expand phase `usage` = sub total **and** promoted children keep usage → run rollup **2×**. - **Fix:** Zero expand usage after promote **or** zero promoted children usage for aggregation. ### 4. DSL `register()` drops explicit `dependsOn` *(dsl agent · bug)* -- When auto-deps non-empty, `raw.dependsOn = [...auto]` overwrites opts.dependsOn for kinds that don’t union into `draft.dependsOn`. +- When auto-deps non-empty, `raw.dependsOn = [...auto]` overwrites opts.dependsOn for kinds that don’t union into `draft.dependsOn`. - **Fix:** Union auto + explicit in `register()`; regression test. ### 5. DSL silent phase/branch drops *(dsl agent · bug)* -- Unknown callees in flow body: no error. -- parallel/race/tournament non-`agent()` branches: silent skip. +- Unknown callees in flow body: no error. +- parallel/race/tournament non-`agent()` branches: silent skip. - **Fix:** Diagnostics `TFDSL_RUNE_UNKNOWN` / `TFDSL_BRANCH_KIND`. ### 6. Decompile race/expand *(dsl agent · bug)* -- Emits `race`/`expand` without importing them. -- Object `def` → fabricated `"{steps.plan.json}"` placeholder (shape lie). +- Emits `race`/`expand` without importing them. +- Object `def` → fabricated `"{steps.plan.json}"` placeholder (shape lie). - **Fix:** Import list; fail-closed non-string `def`. --- @@ -62,28 +62,28 @@ Engineering depth is real (12 kinds, DSL package, tests green), but several **co ### 7. Race usage / budget undercount *(race agent · bug)* -- Winner-only final usage; concurrent live usage last-writer-wins. -- Loser spend (pre-abort) invisible to budget. +- Winner-only final usage; concurrent live usage last-writer-wins. +- Loser spend (pre-abort) invisible to budget. - **Fix:** Aggregate all branch usages after `allSettled`; shared live accumulator. ### 8. Dual-path kernel admits flows that ignore features *(arch agent · bug)* -- Kernel path may miss `cacheScopeDefault` incremental / workspace cwd keywords. +- Kernel path may miss `cacheScopeDefault` incremental / workspace cwd keywords. - **Fix:** Extend `kernelUnsupportedReason` or implement in driver. ### 9. FlowIR sidecar incomplete for Horizon B *(arch agent · bug)* -- `cancelLosers`, `expandMode`, `maxNodes` not fully in IR field model. +- `cancelLosers`, `expandMode`, `maxNodes` not fully in IR field model. - **Fix:** FlowIR node payload parity or documented non-goals. ### 10. Version dual narrative *(docs agent · bug)* -- README / website teach 0.2.0 surfaces; packages **0.1.7**; plugins pin `@0.1.7`; `taskflow-dsl` may 404. +- README / website teach 0.2.0 surfaces; packages **0.1.7**; plugins pin `@0.1.7`; `taskflow-dsl` may 404. - **Fix:** Publish banner “preview branch” **or** bump 0.2.0 + publish. ### 11. README phase table vs “12 phase types” *(docs agent · bug)* -- Marketing line says 12; some tables still 10 without race/expand. +- Marketing line says 12; some tables still 10 without race/expand. - **Fix:** Table sync. --- @@ -103,22 +103,22 @@ Engineering depth is real (12 kinds, DSL package, tests green), but several **co ## What the council agreed is solid -- 12 `PHASE_TYPES` ↔ DSL erase registry ↔ FlowIR kind enum (post test fix). -- Event kernel excludes race/expand intentionally. -- `TFDSL_ERASE_ONLY` on runes. -- Parallel destructure → N agents; race destructure rejected. -- cancelLosers **wiring** (controllers + `extraSignal`) is directionally correct for happy path. -- Unit suite **1402/1402** after FlowIR length fix. +- 12 `PHASE_TYPES` ↔ DSL erase registry ↔ FlowIR kind enum (post test fix). +- Event kernel excludes race/expand intentionally. +- `TFDSL_ERASE_ONLY` on runes. +- Parallel destructure → N agents; race destructure rejected. +- cancelLosers **wiring** (controllers + `extraSignal`) is directionally correct for happy path. +- Unit suite **1402/1402** after FlowIR length fix. - Internal claim ledger + S5 plan exist and are mostly honest. --- ## Recommended action order -1. **P0.1–P0.3** race/expand runtime (semantics + graft + usage). -2. **P0.4–P0.6** DSL register/silent drop/decompile. -3. **P1.10** version/publish honesty banner. -4. **S5.0** parity harness only after P0 runtime dual-path inventory (arch). +1. **P0.1–P0.3** race/expand runtime (semantics + graft + usage). +2. **P0.4–P0.6** DSL register/silent drop/decompile. +3. **P1.10** version/publish honesty banner. +4. **S5.0** parity harness only after P0 runtime dual-path inventory (arch). 5. Defer kernel default ON until P1.7–P1.8 closed. ## Agent artifacts diff --git a/docs/internal/s5-kernel-default-on-plan.md b/docs/internal/s5-kernel-default-on-plan.md index c194fd3..94a45a6 100644 --- a/docs/internal/s5-kernel-default-on-plan.md +++ b/docs/internal/s5-kernel-default-on-plan.md @@ -1,14 +1,14 @@ # S5 plan — event kernel default ON (differential strangler) -> Status: **PLAN** · 2026-07-09 · Branch `release/0.2.0` -> Parent: [`rfc-0.2.0-architecture.md`](../rfc-0.2.0-architecture.md) §9 S5 +> Status: **PLAN** · 2026-07-09 · Branch `release/0.2.0` +> Parent: [`rfc-0.2.0-architecture.md`](../rfc-0.2.0-architecture.md) §9 S5 > Prerequisite: S0–S4 landed; claim ledger `claim-vs-impl-0.2.0.md` ## Goal -1. **Default execution path** = `exec/driver` (event kernel), not imperative `executePhaseInner`. -2. **Parity**: kernel outcomes match imperative for every admitted flow (status, finalOutput shape, gate/budget/when decisions). -3. **Safety**: advanced features still force imperative fallback until handlers exist. +1. **Default execution path** = `exec/driver` (event kernel), not imperative `executePhaseInner`. +2. **Parity**: kernel outcomes match imperative for every admitted flow (status, finalOutput shape, gate/budget/when decisions). +3. **Safety**: advanced features still force imperative fallback until handlers exist. 4. **Flagship gate**: incremental recompute cost demo (Monday $6 → Tuesday ≤$0.40 narrative) measured or explicitly deferred with honest wording. ## Current baseline (do not regress) @@ -25,17 +25,17 @@ ### S5.0 — Differential harness (ship first) -- [ ] Golden suite: for each fixture under `test/fixtures/kernel-parity/` run **twice** (kernel on / off) with the same mock `runTask`. -- [ ] Assert: `status`, per-phase `status`/`output`/`gate`/`error` (normalize volatile fields: timestamps, runIds). -- [ ] Fixtures minimum set: - - linear agent → gate → reduce - - map + parallel - - loop until - - tournament best - - script - - flow{use} + flow{def} (dynamic empty + small plan) - - when + join any - - budget hit mid-map +- [ ] Golden suite: for each fixture under `test/fixtures/kernel-parity/` run **twice** (kernel on / off) with the same mock `runTask`. +- [ ] Assert: `status`, per-phase `status`/`output`/`gate`/`error` (normalize volatile fields: timestamps, runIds). +- [ ] Fixtures minimum set: + - linear agent → gate → reduce + - map + parallel + - loop until + - tournament best + - script + - flow{use} + flow{def} (dynamic empty + small plan) + - when + join any + - budget hit mid-map - [ ] CI job: `pnpm run test:kernel-parity` fails the release if any fixture diverges. ### S5.1 — Close kernel feature gaps (priority order) @@ -55,10 +55,10 @@ ### S5.2 — Default flip -1. Change `eventKernelEnabled` default: - - `undefined` → **true** (or env `PI_TASKFLOW_EVENT_KERNEL` default `"1"`). - - Explicit `eventKernel: false` or env `0`/`false` keeps imperative. -2. Document migration: hosts that relied on imperative-only bugs must set `false`. +1. Change `eventKernelEnabled` default: + - `undefined` → **true** (or env `PI_TASKFLOW_EVENT_KERNEL` default `"1"`). + - Explicit `eventKernel: false` or env `0`/`false` keeps imperative. +2. Document migration: hosts that relied on imperative-only bugs must set `false`. 3. CHANGELOG: **breaking** if behavior differs; otherwise minor. ### S5.3 — Runtime strangler (enables safe flip) @@ -75,39 +75,39 @@ Continue peel `runtime.ts` → `runtime/phases/*` so kernel step handlers and im ### S5.4 — Flagship cost demo (acceptance) -- [ ] Scripted flow (8 agent phases) + fingerprint change of one file. -- [ ] Measure: full run cost vs recompute cost ratio. -- [ ] Pass if recompute token/$ **strictly <** full (target narrative ≤ ~$0.40 vs ~$6 is aspirational — record real numbers). +- [ ] Scripted flow (8 agent phases) + fingerprint change of one file. +- [ ] Measure: full run cost vs recompute cost ratio. +- [ ] Pass if recompute token/$ **strictly <** full (target narrative ≤ ~$0.40 vs ~$6 is aspirational — record real numbers). - [ ] If infra cannot produce stable $ without live models: gate on **phase count re-executed** + cache hit counts only. ### S5.5 — Retirement (post-default) -- [ ] Mark imperative path as fallback-only in docs. -- [ ] No deletion of executePhase in same release as default flip. +- [ ] Mark imperative path as fallback-only in docs. +- [ ] No deletion of executePhase in same release as default flip. - [ ] Next minor: reduce dual-path surface after 1 release of green parity. ## Non-goals (S5) -- Native multi-node FlowIR lowering (parallel → N IR nodes). -- Literal decompile round-trip. -- Host auto-build of `.tf.ts`. +- Native multi-node FlowIR lowering (parallel → N IR nodes). +- Literal decompile round-trip. +- Host auto-build of `.tf.ts`. - Experimental C-track runes. ## Exit criteria (S5 done) -1. Default ON in core; all host adapters inherit. -2. `test:kernel-parity` green in CI. -3. No known P0 divergence bugs open. -4. Flagship recompute metric recorded (or explicitly deferred with version note). +1. Default ON in core; all host adapters inherit. +2. `test:kernel-parity` green in CI. +3. No known P0 divergence bugs open. +4. Flagship recompute metric recorded (or explicitly deferred with version note). 5. Docs/skills: “kernel default ON; race/expand + advanced features may still use imperative”. ## Suggested PR stack -1. **S5.0** parity harness + fixtures (no behavior change). -2. **S5.1-P1** expect + retry on kernel path. -3. **S5.3** map/loop peel (optional parallel). -4. **S5.2** default flip behind `PI_TASKFLOW_EVENT_KERNEL` still overridable. -5. **S5.4** demo script + numbers. +1. **S5.0** parity harness + fixtures (no behavior change). +2. **S5.1-P1** expect + retry on kernel path. +3. **S5.3** map/loop peel (optional parallel). +4. **S5.2** default flip behind `PI_TASKFLOW_EVENT_KERNEL` still overridable. +5. **S5.4** demo script + numbers. 6. Docs/CHANGELOG/skills final. ## Risks diff --git a/docs/rfc-0.2.0-dsl-phases-horizon.md b/docs/rfc-0.2.0-dsl-phases-horizon.md index 4260f77..a71627c 100644 --- a/docs/rfc-0.2.0-dsl-phases-horizon.md +++ b/docs/rfc-0.2.0-dsl-phases-horizon.md @@ -1,8 +1,8 @@ # RFC: DSL 扩展 Phase 设计(脑暴收编 + 语言表面) -> Status: **Design** · 2026-07-09 -> Source brainstorm: [`internal/brainstorm-2026-07-08-0.2.0-phases.md`](./internal/brainstorm-2026-07-08-0.2.0-phases.md) -> DSL 基线: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 + [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) +> Status: **Design** · 2026-07-09 +> Source brainstorm: [`internal/brainstorm-2026-07-08-0.2.0-phases.md`](./internal/brainstorm-2026-07-08-0.2.0-phases.md) +> DSL 基线: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 + [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) > Engine foundation: event log + FlowIR (S0–S3) — *why these phases are cheap now* **目标:** 把「0.2.0 脑暴的更强 phase」收成 **DSL 可写的语言形状**,并分轨: @@ -83,7 +83,7 @@ gate.automated(build, { pass: ["{steps.build.output} contains 'OK'"] }); ### B1 · `expand` — 动态图**嫁接**(旗舰 · 新 type) -> 与 A 轨 `flow.def` 区别:A = **嵌套**子 flow(子命名空间);B1 = **splice 进父 DAG**(父拓扑可见新节点)。 +> 与 A 轨 `flow.def` 区别:A = **嵌套**子 flow(子命名空间);B1 = **splice 进父 DAG**(父拓扑可见新节点)。 > 事件溯源治好旧 P0(注入丢失 / 三态就绪 / 并发改数组)——见 brainstorm §0。 **JSON 草图:** @@ -162,7 +162,7 @@ const first = race([ }); ``` -**JSON:** `type:"race", branches:[…], cancelLosers?:boolean` +**JSON:** `type:"race", branches:[…], cancelLosers?:boolean` **vs parallel:** parallel 全等;**vs tournament:** tournament 等全部完成再裁判。 --- @@ -329,8 +329,8 @@ const sp = fork.save("after-plan"); **「顺便支持脑暴 phase」在语言层的默认承诺:** -1. **文档 + 类型 + experimental 入口**写齐 B1–B5 + C 的 `counterfactual`/`quorum`/`fork` 草形。 -2. **S4 MVP 编译器**:A 轨能 erase 的 erase;B/C 未实现 kind → **明确诊断**(可先认 type 字符串进 JSON passthrough 给未来引擎,或拒绝——推荐 **S4 拒绝未知 type,S4.x 放行已实现**)。 +1. **文档 + 类型 + experimental 入口**写齐 B1–B5 + C 的 `counterfactual`/`quorum`/`fork` 草形。 +2. **S4 MVP 编译器**:A 轨能 erase 的 erase;B/C 未实现 kind → **明确诊断**(可先认 type 字符串进 JSON passthrough 给未来引擎,或拒绝——推荐 **S4 拒绝未知 type,S4.x 放行已实现**)。 3. **不把 Moonshot 全塞进 MVP 出货门**。 --- @@ -339,11 +339,11 @@ const sp = fork.save("after-plan"); 新增 `PHASE_TYPES` 时: -1. `schema.ts` 注册 + `validateTaskflow` -2. `flowir` `FlowIRNodeKind` 闭集扩展 -3. `exec/step` kind 或 imperative 分支 -4. DSL rune + skills -5. 本文件状态表打勾 +1. `schema.ts` 注册 + `validateTaskflow` +2. `flowir` `FlowIRNodeKind` 闭集扩展 +3. `exec/step` kind 或 imperative 分支 +4. DSL rune + skills +5. 本文件状态表打勾 **建议新增 type 名(稳定字符串):** @@ -368,7 +368,7 @@ const sp = fork.save("after-plan"); | **本文** | **语言 horizon**:脑暴 phase 的 DSL 形状与分期 | | 引擎 S4.x / S6 | 按 §6 表落地剩余 type | -S4 出货门 **不变**(demo FlowIR == hand JSON)。 +S4 出货门 **不变**(demo FlowIR == hand JSON)。 DSL erase **只生成已支持 type**;未知 experimental → fail-closed。 --- diff --git a/docs/rfc-0.2.0-s4-decision-record.md b/docs/rfc-0.2.0-s4-decision-record.md index f6d3ec8..d12511b 100644 --- a/docs/rfc-0.2.0-s4-decision-record.md +++ b/docs/rfc-0.2.0-s4-decision-record.md @@ -1,8 +1,8 @@ # S4 Shape Decision Record (`taskflow-dsl`) -> Status: **IMPLEMENTED (package live)** — route/surface frozen; coverage extended beyond original MVP ship bar (see §“Landed beyond MVP”) -> Date: 2026-07-09 · Last kinds sync: 2026-07-09 -> Full surface: [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) +> Status: **IMPLEMENTED (package live)** — route/surface frozen; coverage extended beyond original MVP ship bar (see §“Landed beyond MVP”) +> Date: 2026-07-09 · Last kinds sync: 2026-07-09 +> Full surface: [`rfc-0.2.0-s4-mvp.md`](./rfc-0.2.0-s4-mvp.md) > Council runs: `s4-shape-council` → `s4-shape-council-v2` → `s4-shape-finalize` ## One-sentence definition @@ -28,15 +28,15 @@ ## MVP scope (in) — original ship bar -1. `flow` + args/budget/concurrency/description -2. Core phase kinds basic runes (ship bar was **10**; engine now **12** with `race`/`expand` — see Landed beyond MVP) -3. Template → `{steps.*}` / `{item.*}` erase -4. Auto `dependsOn` from `.output` / `.json` reads + explicit `dependsOn` -5. `when` **string** form (+ TS subset in `check` if cheap) -6. Basic `json()` → `output:"json"` + `expect` (fail-closed on complex types) -7. `check` / `build` / `new` / decompile (Taskflow→`.tf.ts` on Y-slice) -8. Golden **FlowIR hash equality** demos (must include map + templates + `json`, not only hello) -9. Import-lint: DSL must not drag core runtime +1. `flow` + args/budget/concurrency/description +2. Core phase kinds basic runes (ship bar was **10**; engine now **12** with `race`/`expand` — see Landed beyond MVP) +3. Template → `{steps.*}` / `{item.*}` erase +4. Auto `dependsOn` from `.output` / `.json` reads + explicit `dependsOn` +5. `when` **string** form (+ TS subset in `check` if cheap) +6. Basic `json()` → `output:"json"` + `expect` (fail-closed on complex types) +7. `check` / `build` / `new` / decompile (Taskflow→`.tf.ts` on Y-slice) +8. Golden **FlowIR hash equality** demos (must include map + templates + `json`, not only hello) +9. Import-lint: DSL must not drag core runtime ## Landed beyond original MVP (kinds sync) @@ -57,7 +57,7 @@ Still **not** shipped as runes: loop multi-body, `route`, `compensate`/saga, ## Brainstorm phases — language support -Source: `docs/internal/brainstorm-2026-07-08-0.2.0-phases.md` +Source: `docs/internal/brainstorm-2026-07-08-0.2.0-phases.md` Design: **`docs/rfc-0.2.0-dsl-phases-horizon.md`** | Track | What | When | @@ -71,13 +71,13 @@ Unknown / unimplemented experimental runes must **error** (no silent drop). ## Explicitly out (S4 ship bar) -1. Solid runtime / Proxy / degraded interpret -2. In-file JSON phase hybrid -3. Multi-body loop / `flow.component` / `$store` as **MVP ship** (designed in horizon doc; implement later) -4. S5 kernel default ON -5. New MCP `taskflow_build` / host auto-build of `.tf.ts` -6. Literal decompile round-trip marketing -7. 100% PhaseSchema coverage as S4 ship bar (FULL = language goal only) +1. Solid runtime / Proxy / degraded interpret +2. In-file JSON phase hybrid +3. Multi-body loop / `flow.component` / `$store` as **MVP ship** (designed in horizon doc; implement later) +4. S5 kernel default ON +5. New MCP `taskflow_build` / host auto-build of `.tf.ts` +6. Literal decompile round-trip marketing +7. 100% PhaseSchema coverage as S4 ship bar (FULL = language goal only) 8. Shipping B-track **engines** inside S4 MVP gate (language design only) ## Minimal `.tf.ts` example @@ -99,18 +99,18 @@ export default flow("audit", (ctx) => { ## Acceptance gates -- [x] `packages/taskflow-dsl` in workspace; bin + exports as `rfc-0.2.0-s4-mvp.md` §1 -- [x] Demo `.tf.ts` and twin `.json` → **same** `ir:<64-hex>` (parity tests) -- [x] Equality fixtures include **map + json\ + templates** -- [x] Rune runtime call throws `TFDSL_ERASE_ONLY` -- [x] Import-lint denylist green -- [x] Skills/docs: JSON first-class for agents; CLI path for DSL (+ kinds table) +- [x] `packages/taskflow-dsl` in workspace; bin + exports as `rfc-0.2.0-s4-mvp.md` §1 +- [x] Demo `.tf.ts` and twin `.json` → **same** `ir:<64-hex>` (parity tests) +- [x] Equality fixtures include **map + json\ + templates** +- [x] Rune runtime call throws `TFDSL_ERASE_ONLY` +- [x] Import-lint denylist green +- [x] Skills/docs: JSON first-class for agents; CLI path for DSL (+ kinds table) - [x] DSL v2 note: FULL vs S4 MVP ship gate (authority B1) ## Open questions for human (max 3) -1. **H1** Commit coverage matrix as a real doc? (recommend **yes**) -2. **H2** Decompile Taskflow-only in MVP? (recommend **yes**) +1. **H1** Commit coverage matrix as a real doc? (recommend **yes**) +2. **H2** Decompile Taskflow-only in MVP? (recommend **yes**) 3. **H3** Throw-on-call for runes? (recommend **yes**) ## North-star alignment @@ -130,5 +130,5 @@ export default flow("audit", (ctx) => { | `s4-shape-council-v2-mrd6wdcj-e34ca3` | routes + tournament (svelte/json-only/typescript-AST) + api-surface + adversary | | `s4-shape-finalize-mrd765gf-f14e1b` | cross-check (PASS on route; BLOCK only until B1/matrix/H2/H3 written) | -Flow defs: `/tmp/taskflow-s4/s4-shape.json`, `s4-shape-v2.json`, `s4-shape-final.json` +Flow defs: `/tmp/taskflow-s4/s4-shape.json`, `s4-shape-v2.json`, `s4-shape-final.json` Saved project flow: `.pi/taskflows/s4-shape-council.json` diff --git a/docs/rfc-0.2.0-s4-mvp.md b/docs/rfc-0.2.0-s4-mvp.md index ea04944..f8eaf3d 100644 --- a/docs/rfc-0.2.0-s4-mvp.md +++ b/docs/rfc-0.2.0-s4-mvp.md @@ -1,10 +1,10 @@ # RFC: taskflow 0.2.0 S4 MVP — `taskflow-dsl` public surface -> Status: **IMPLEMENTED** (package + CLI live; surface extended — see §8.1) · 2026-07-09 +> Status: **IMPLEMENTED** (package + CLI live; surface extended — see §8.1) · 2026-07-09 -> Parent: [`rfc-0.2.0-architecture.md`](./rfc-0.2.0-architecture.md) §4.3 / §9 S4 -> Syntax authority: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 (**FULL language goal**; ship gate is this MVP doc) -> Route lock: north-star 决策1 + this document §0 +> Parent: [`rfc-0.2.0-architecture.md`](./rfc-0.2.0-architecture.md) §4.3 / §9 S4 +> Syntax authority: [`rfc-0.2.0-dsl-syntax.md`](./rfc-0.2.0-dsl-syntax.md) v2 (**FULL language goal**; ship gate is this MVP doc) +> Route lock: north-star 决策1 + this document §0 > Provenance: multi-agent council runs `s4-shape-council` + `s4-shape-council-v2` + `s4-shape-finalize` (inventory → route tournament → surface → adversary → cross-check) This document freezes the **publishable public surface** of `packages/taskflow-dsl` for the S4 MVP ship gate. It is intentionally narrower than full DSL RFC coverage: what agents and humans import, invoke, and get wrong messages for — not the full transform implementation plan. @@ -53,11 +53,11 @@ DSL v2 hard constraint “100% 功能覆盖” is **not** the S4 acceptance bar. ### S4 MVP is NOT -- Solid runtime runes / Proxy / `currentObserver` graph building -- Runnable unbuilt `.tf.ts` or “degraded interpret” next to JSON -- In-file JSON phase literals (Vapor hybrid) -- Breakpoint-on-`agent()` as a product promise -- S5 kernel flip or any change to existing JSON Taskflow execution +- Solid runtime runes / Proxy / `currentObserver` graph building +- Runnable unbuilt `.tf.ts` or “degraded interpret” next to JSON +- In-file JSON phase literals (Vapor hybrid) +- Breakpoint-on-`agent()` as a product promise +- S5 kernel flip or any change to existing JSON Taskflow execution **WINNER_RATIONALE:** Svelte-style compile-time erase with whole-file JSON escape is the only path that satisfies architecture S4 (`.tf.ts→build→Taskflow→FlowIR`, JSON zero-change) without reopening rejected Proxy physics or hybrid grammar cost. @@ -68,8 +68,8 @@ DSL v2 hard constraint “100% 功能覆盖” is **not** the S4 acceptance bar. .flow.json ──validate / desugar──▶ Taskflow JSON ──same core entry only──▶ FlowIR ``` -- **S4 stop line:** DSL package produces **Taskflow** (and may *call* core to emit FlowIR for CLI convenience). -- **Single FlowIR entry:** only `taskflow-core`’s `compileTaskflowToFlowIR` / `hashFlowIR` (arch §4.2). +- **S4 stop line:** DSL package produces **Taskflow** (and may *call* core to emit FlowIR for CLI convenience). +- **Single FlowIR entry:** only `taskflow-core`’s `compileTaskflowToFlowIR` / `hashFlowIR` (arch §4.2). - **Acceptance gate:** DSL demo FlowIR == hand-written JSON FlowIR (byte-stable `ir:<64-hex>`). --- @@ -176,7 +176,7 @@ Optional monorepo convenience (not published): root script `"dsl": "node --condi ## §2. CLI surface -Entry: `taskflow-dsl [options] [path…]` +Entry: `taskflow-dsl [options] [path…]` Global flags (all commands): | Flag | Meaning | @@ -227,7 +227,7 @@ taskflow-dsl build [options] | flowir | `.flowir.json` | | both | both files | -**Stdout human mode (no `--json`):** short summary — name, phase count, `ir:` if computed, output paths. +**Stdout human mode (no `--json`):** short summary — name, phase count, `ir:` if computed, output paths. **Stdout `--json`:** `BuildResult` (§3.2). **MVP non-goals for `build`:** watch mode, multi-file project graph beyond one entry `export default`, bundling imports of other `.tf.ts` (S4.1: `import` of subflow modules). @@ -450,8 +450,8 @@ export function buildSource( **Contract:** -1. On `ok: true`, `taskflow` is present and has passed `validateTaskflow` (post-desugar shape). -2. When `emitFlowIR: true` and ok, `flowir` + `hash` come **only** from `compileTaskflowToFlowIR` + `hashFlowIR`. +1. On `ok: true`, `taskflow` is present and has passed `validateTaskflow` (post-desugar shape). +2. When `emitFlowIR: true` and ok, `flowir` + `hash` come **only** from `compileTaskflowToFlowIR` + `hashFlowIR`. 3. DSL package never reimplements IR lowering. ### 3.3 Check API (`taskflow-dsl/check`) @@ -505,8 +505,8 @@ export function skeletonJson(name?: string): string; ### 3.6 What the library must **not** expose in MVP -- `interpretTfTs()` / `runUnbuilt()` -- Proxy helpers, `currentObserver`, reactive graph APIs +- `interpretTfTs()` / `runUnbuilt()` +- Proxy helpers, `currentObserver`, reactive graph APIs - Direct `executeTaskflow` wrappers (hosts already own run) --- @@ -541,16 +541,16 @@ Saved-flow library (`.pi/taskflows/*.json` etc.) remains **JSON** as today; S4 d ### 4.3 Naming -- Flow `name` (string arg to `flow`) = taskflow `name` field (library identity). -- Phase ids = **const binding names** where possible (`const discover = agent(…)` → id `discover`); anonymous runes get synthetic ids (`agent_0`, …) with stable ordering rules documented in implementer notes. +- Flow `name` (string arg to `flow`) = taskflow `name` field (library identity). +- Phase ids = **const binding names** where possible (`const discover = agent(…)` → id `discover`); anonymous runes get synthetic ids (`agent_0`, …) with stable ordering rules documented in implementer notes. - IDs remain kebab-or-camel as bound; core already accepts phase ids used in `{steps.ID…}` — **prefer valid JS identifiers** that match existing JSON id style in demos (kebab via explicit `opts.id` if needed). MVP option: `agent("…", { id: "audit-each" })` maps to JSON `id` when binding name differs — **Y** if `id` already exists on PhaseSchema; else use binding name only. ### 4.4 JSON escape file convention -- Any `*.json` / `*.jsonc` Taskflow document accepted by today’s `validateTaskflow`. -- No `.tf.json` hybrid. +- Any `*.json` / `*.jsonc` Taskflow document accepted by today’s `validateTaskflow`. +- No `.tf.json` hybrid. - `build`/`check` treat JSON as the escape frontend; output FlowIR must match DSL-built FlowIR for the equality demos. --- @@ -705,8 +705,8 @@ JSON authors skip steps 1–4 and keep using define / defineFile as today. ### 7.3 S4.1 host follow-ups (explicitly deferred) -- `taskflow_build` / `taskflow_check` in `taskflow-mcp-core` (stdio tools wrapping library API). -- pi `/tf build|check|new` and skills-src host blocks. +- `taskflow_build` / `taskflow_check` in `taskflow-mcp-core` (stdio tools wrapping library API). +- pi `/tf build|check|new` and skills-src host blocks. - Optional: run path accepts `.tf.ts` **only** after explicit build cache hit (still no interpret). ### 7.4 Compatibility with S0–S3 / S5 @@ -753,14 +753,14 @@ FULL RFC coverage remains a completion track; missing FULL features must not app ## §9. Acceptance checklist (public surface) -- [x] Package name `taskflow-dsl` publishable with exports/bin as §1 -- [x] `taskflow-dsl build|check|decompile|new` flags/IO as §2 -- [x] Author import `from "taskflow-dsl"` typechecks a hello + audit-style demo -- [x] `build` on demo `.tf.ts` and hand JSON → **identical** `hashFlowIR` -- [x] Diagnostics stable codes + positions on deliberate erase errors -- [x] Import-lint: no denylist modules from core/hosts -- [x] No MCP/schema changes required to pass S4 gate -- [x] README/skills teach CLI-first workflow; JSON escape documented as first-class +- [x] Package name `taskflow-dsl` publishable with exports/bin as §1 +- [x] `taskflow-dsl build|check|decompile|new` flags/IO as §2 +- [x] Author import `from "taskflow-dsl"` typechecks a hello + audit-style demo +- [x] `build` on demo `.tf.ts` and hand JSON → **identical** `hashFlowIR` +- [x] Diagnostics stable codes + positions on deliberate erase errors +- [x] Import-lint: no denylist modules from core/hosts +- [x] No MCP/schema changes required to pass S4 gate +- [x] README/skills teach CLI-first workflow; JSON escape documented as first-class --- @@ -777,19 +777,19 @@ FULL RFC coverage remains a completion track; missing FULL features must not app ### 10.2 Non-blocking polish (implementer discretion) -1. Sync vs async `buildFile` (sync-friendly with the TypeScript API). -2. Exact synthetic phase id algorithm for anonymous runes (must be deterministic for hash equality fixtures). -3. Default `--emit both` vs `taskflow` only (recommend **taskflow-only** default; FlowIR via `--emit flowir|both` / CI). +1. Sync vs async `buildFile` (sync-friendly with the TypeScript API). +2. Exact synthetic phase id algorithm for anonymous runes (must be deterministic for hash equality fixtures). +3. Default `--emit both` vs `taskflow` only (recommend **taskflow-only** default; FlowIR via `--emit flowir|both` / CI). 4. Optional thin facade over TS `Node` so the parser host can be swapped later without rewriting erase. ### 10.3 Recommended PR stack (after human lock) -1. **Scaffold** `packages/taskflow-dsl` + workspace/filter + import-lint denylist test. -2. **Author stubs + types** (closed MVP option types; throw-on-call). -3. **`check` + `new`** (tsc/Program + rune rules + hello skeleton). -4. **`build` erase vertical slice** — agent → map/templates → parallel → reduce → gate LLM → script; emit Taskflow; call core FlowIR. -5. **Golden parity** — hand JSON twin fixtures; `hashFlowIR` equality (include map + `json`). -6. **`decompile` Taskflow→`.tf.ts`** on Y-slice + fail-closed unsupported. +1. **Scaffold** `packages/taskflow-dsl` + workspace/filter + import-lint denylist test. +2. **Author stubs + types** (closed MVP option types; throw-on-call). +3. **`check` + `new`** (tsc/Program + rune rules + hello skeleton). +4. **`build` erase vertical slice** — agent → map/templates → parallel → reduce → gate LLM → script; emit Taskflow; call core FlowIR. +5. **Golden parity** — hand JSON twin fixtures; `hashFlowIR` equality (include map + `json`). +6. **`decompile` Taskflow→`.tf.ts`** on Y-slice + fail-closed unsupported. 7. **Docs/skills** — CLI-first workflow; JSON first-class; import string `taskflow-dsl`; amend DSL v2 FULL vs MVP note. --- diff --git a/examples/race-first-win.json b/examples/race-first-win.json index 517e61b..e18615c 100644 --- a/examples/race-first-win.json +++ b/examples/race-first-win.json @@ -1,6 +1,6 @@ { "name": "race-first-win", - "description": "Horizon B: first completed branch wins (latency over exhaustive wait).", + "description": "Horizon B: first successful branch wins (latency over exhaustive wait; fast hard-fail does not win).", "budget": { "maxUSD": 0.5 }, "phases": [ { diff --git a/package.json b/package.json index 2b9a923..9fe8386 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "pi-taskflow-monorepo", - "version": "0.1.7", + "version": "0.2.0", "private": true, - "description": "Monorepo for taskflow-core, taskflow-mcp-core, taskflow-hosts, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and the documentation website.", + "description": "Monorepo for taskflow-core, taskflow-mcp-core, taskflow-hosts, taskflow-dsl, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and the documentation website.", "type": "module", "engines": { "node": ">=22.19.0" @@ -38,10 +38,5 @@ "@types/node": "^22", "typebox": "^1.3.3", "typescript": "^6.0.3" - }, - "pnpm": { - "overrides": { - "postcss": "^8.5.10" - } } } diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index 0698c4b..dacf6e7 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.1.7", + "version": "0.2.0", "description": "Run taskflow on Claude Code: a Claude subagent runner plus an MCP server (and a plug-and-play Claude Code plugin) that exposes the taskflow_* tools to Claude Code users.", "keywords": [ "claude", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "taskflow-core": "0.1.7", - "taskflow-hosts": "0.1.7", - "taskflow-mcp-core": "0.1.7" + "taskflow-core": "workspace:*", + "taskflow-hosts": "workspace:*", + "taskflow-mcp-core": "workspace:*" } } diff --git a/packages/claude-taskflow/plugin/.claude-plugin/plugin.json b/packages/claude-taskflow/plugin/.claude-plugin/plugin.json index c8474ef..72d1042 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.7", + "version": "0.2.0", "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/claude-taskflow/plugin/.mcp.json b/packages/claude-taskflow/plugin/.mcp.json index 1491bdf..0f834c8 100644 --- a/packages/claude-taskflow/plugin/.mcp.json +++ b/packages/claude-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "claude-taskflow@0.1.7", "claude-taskflow-mcp"] + "args": ["-y", "-p", "claude-taskflow@0.2.0", "claude-taskflow-mcp"] } } } diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index 797a8e3..0d6c0e0 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -459,7 +459,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins; cooperative loser usage is counted | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 0c8d0a1..9d26b00 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -45,6 +45,7 @@ test("claude mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow"); + assert.equal(res.result.serverInfo.version, "0.2.0"); }); test("claude mcp: tools/list exposes the same taskflow tools as codex", async () => { diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index 86affc4..b6281ac 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.1.7", + "version": "0.2.0", "description": "Run taskflow on OpenAI Codex: a Codex subagent runner plus an MCP server (and a plug-and-play Codex plugin) that exposes the taskflow_* tools to Codex users.", "keywords": [ "codex", @@ -54,8 +54,8 @@ "access": "public" }, "dependencies": { - "taskflow-core": "0.1.7", - "taskflow-hosts": "0.1.7", - "taskflow-mcp-core": "0.1.7" + "taskflow-core": "workspace:*", + "taskflow-hosts": "workspace:*", + "taskflow-mcp-core": "workspace:*" } } diff --git a/packages/codex-taskflow/plugin/.codex-plugin/plugin.json b/packages/codex-taskflow/plugin/.codex-plugin/plugin.json index 38acf6d..8b05d08 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.7", + "version": "0.2.0", "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", diff --git a/packages/codex-taskflow/plugin/.mcp.json b/packages/codex-taskflow/plugin/.mcp.json index b3d23b7..9bd8882 100644 --- a/packages/codex-taskflow/plugin/.mcp.json +++ b/packages/codex-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "codex-taskflow@0.1.7", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.2.0", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index 587296b..a822484 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -456,7 +456,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins; cooperative loser usage is counted | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts index 8913ac9..9cf05e7 100644 --- a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts +++ b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts @@ -78,6 +78,7 @@ send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: " const init = await waitFor(1, "initialize"); assert.equal(init.result.protocolVersion, "2025-06-18"); assert.equal(init.result.serverInfo.name, "taskflow"); +assert.equal(init.result.serverInfo.version, "0.2.0"); ok(`initialize → ${JSON.stringify(init.result.serverInfo)}`); // notification must NOT produce a response @@ -86,7 +87,7 @@ send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); const list = await waitFor(2, "tools/list"); const toolNames = list.result.tools.map((t: any) => t.name).sort(); -assert.deepEqual(toolNames, ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_why_stale"]); +assert.deepEqual(toolNames, ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_replay", "taskflow_run", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_why_stale"]); for (const t of list.result.tools) { assert.equal(t.inputSchema.type, "object", `${t.name} has object schema`); assert.equal(typeof t.description, "string"); diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 35a5877..a498f06 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -46,6 +46,7 @@ test("mcp: initialize returns the protocol version + serverInfo codex expects", assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow"); + assert.equal(res.result.serverInfo.version, "0.2.0"); }); test("mcp: tools/list exposes the taskflow tools with schemas", async () => { diff --git a/packages/grok-taskflow/package.json b/packages/grok-taskflow/package.json index 0a7dadb..d390bca 100644 --- a/packages/grok-taskflow/package.json +++ b/packages/grok-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "grok-taskflow", - "version": "0.1.7", + "version": "0.2.0", "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", @@ -55,8 +55,8 @@ "access": "public" }, "dependencies": { - "taskflow-core": "0.1.7", - "taskflow-hosts": "0.1.7", - "taskflow-mcp-core": "0.1.7" + "taskflow-core": "workspace:*", + "taskflow-hosts": "workspace:*", + "taskflow-mcp-core": "workspace:*" } } diff --git a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json index 8113902..1806617 100644 --- a/packages/grok-taskflow/plugin/.grok-plugin/plugin.json +++ b/packages/grok-taskflow/plugin/.grok-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "taskflow", - "version": "0.1.7", + "version": "0.2.0", "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", diff --git a/packages/grok-taskflow/plugin/.mcp.json b/packages/grok-taskflow/plugin/.mcp.json index 6310bee..456db4e 100644 --- a/packages/grok-taskflow/plugin/.mcp.json +++ b/packages/grok-taskflow/plugin/.mcp.json @@ -2,7 +2,7 @@ "mcpServers": { "taskflow": { "command": "npx", - "args": ["-y", "-p", "grok-taskflow@0.1.7", "grok-taskflow-mcp"], + "args": ["-y", "-p", "grok-taskflow@0.2.0", "grok-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index 3f38e83..c5c6f74 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -459,7 +459,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins; cooperative loser usage is counted | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index 0f62edf..ccd4887 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -45,6 +45,7 @@ test("grok mcp: initialize returns the protocol version + serverInfo", async () assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow"); + assert.equal(res.result.serverInfo.version, "0.2.0"); }); test("grok mcp: tools/list exposes the same taskflow tools as other hosts", async () => { diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index 95fb198..011e780 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.1.7", + "version": "0.2.0", "description": "Run taskflow on OpenCode: an OpenCode subagent runner plus an MCP server (and an opencode.json config scaffold) that exposes the taskflow_* tools to OpenCode users.", "keywords": [ "opencode", @@ -53,8 +53,8 @@ "access": "public" }, "dependencies": { - "taskflow-core": "0.1.7", - "taskflow-hosts": "0.1.7", - "taskflow-mcp-core": "0.1.7" + "taskflow-core": "workspace:*", + "taskflow-hosts": "workspace:*", + "taskflow-mcp-core": "workspace:*" } } diff --git a/packages/opencode-taskflow/plugin/opencode.json b/packages/opencode-taskflow/plugin/opencode.json index f2bf197..7503a47 100644 --- a/packages/opencode-taskflow/plugin/opencode.json +++ b/packages/opencode-taskflow/plugin/opencode.json @@ -3,7 +3,7 @@ "mcp": { "taskflow": { "type": "local", - "command": ["npx", "-y", "-p", "opencode-taskflow@0.1.7", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@0.2.0", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index 16201cd..2fb71c1 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -460,7 +460,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins; cooperative loser usage is counted | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index ecff65a..0429036 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -45,6 +45,7 @@ test("opencode mcp: initialize returns the protocol version + serverInfo", async assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow"); + assert.equal(res.result.serverInfo.version, "0.2.0"); }); test("opencode mcp: tools/list exposes the taskflow tools", async () => { diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index b9b39b8..76b4e8a 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow", - "version": "0.1.7", + "version": "0.2.0", "description": "A declarative, verifiable graph of task nodes for the Pi coding agent — statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.", "keywords": [ "pi-package", @@ -53,7 +53,7 @@ "image": "https://raw.githubusercontent.com/heggria/taskflow/main/assets/social-preview.png" }, "dependencies": { - "taskflow-core": "0.1.7" + "taskflow-core": "workspace:*" }, "peerDependencies": { "@earendil-works/pi-agent-core": "*", diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index 5103d97..c19c35f 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -463,7 +463,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins; cooperative loser usage is counted | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index c248c49..fadd9d5 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-core", - "version": "0.1.7", - "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration \u2014 the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, and grok-taskflow.", + "version": "0.2.0", + "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration — the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, and grok-taskflow.", "keywords": [ "taskflow", "dag", diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index a33a5ab..a1ef78f 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -147,6 +147,20 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr args: Record; stack: string[]; }): Promise => { + // Re-admit nested defs — parent admission must not smuggle race/expand + // or score/retry/… into the kernel path (silent semantic drift). + if (!canUseEventKernel(opts.def)) { + const reason = + kernelUnsupportedReason(opts.def) ?? + "nested flow contains phase kinds or features the event kernel cannot execute"; + return { + finalOutput: `Nested flow rejected by event kernel: ${reason}`, + ok: false, + usage: emptyUsage(), + events: [], + blocked: false, + }; + } const childState: RunState = { // No `/` — validateRunId rejects path separators if ever persisted. runId: `${state.runId}-n-${opts.def.name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40)}`, @@ -318,12 +332,17 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr console.warn(`[taskflow] event-kernel fold status drift phase=${id} state=${ps.status} fold=${f.status}`); } - const anyFailed = Object.entries(state.phases).some( - ([id, p]) => p.status === "failed" && !byId.get(id)?.optional, - ); - const finals = def.phases.filter((p) => p.final); - const finalPhase = finals[finals.length - 1] ?? def.phases[def.phases.length - 1]; - let finalOutput = finalPhase ? (state.phases[finalPhase.id]?.output ?? "") : ""; + const anyFailed = Object.entries(state.phases).some( + ([id, p]) => p.status === "failed" && !byId.get(id)?.optional, + ); + const finals = def.phases.filter((p) => p.final); + const finalPhase = finals[finals.length - 1] ?? def.phases[def.phases.length - 1]; + let finalState = finalPhase ? state.phases[finalPhase.id] : undefined; + if (!finalState || finalState.status !== "done") { + const doneInOrder = def.phases.map((p) => state.phases[p.id]).filter((p) => p?.status === "done"); + if (doneInOrder.length) finalState = doneInOrder[doneInOrder.length - 1]; + } + let finalOutput = finalState?.output ?? ""; if (gateBlocked) { finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${finalOutput ? `\n\n${finalOutput}` : ""}`; } else if (budgetBlocked) { diff --git a/packages/taskflow-core/src/flowir/canonical-hash.ts b/packages/taskflow-core/src/flowir/canonical-hash.ts index 6108010..e663c6d 100644 --- a/packages/taskflow-core/src/flowir/canonical-hash.ts +++ b/packages/taskflow-core/src/flowir/canonical-hash.ts @@ -12,6 +12,7 @@ * - `flowDefHash(def)` — "did the *flow definition* change?" (DSL-level) * - `hashFlowIR(ir)` — "is this *compiled IR* the same IR?" (IR-level) * + * Wired into `compileTaskflowToIR` via `hashFlowIR` (`ir:<64-hex>`). * 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. @@ -134,6 +135,7 @@ function canonicalNodeObject(node: FlowIRNode): Record { if (node.deps !== undefined) obj.deps = node.deps; if (node.join !== undefined) obj.join = node.join; if (node.timeout !== undefined) obj.timeout = node.timeout; + if (node.payload !== undefined) obj.payload = node.payload; return obj; } diff --git a/packages/taskflow-core/src/flowir/compile.ts b/packages/taskflow-core/src/flowir/compile.ts index cadde26..9a0f881 100644 --- a/packages/taskflow-core/src/flowir/compile.ts +++ b/packages/taskflow-core/src/flowir/compile.ts @@ -73,8 +73,13 @@ const SIDECAR_PHASE_FIELDS = [ "score", "cache", "shareContext", + "cancelLosers", + "expandMode", + "maxNodes", ] as const; +const NODE_FIELD_KEYS = new Set(["task", "dependsOn", "join", "when", "timeout"]); + const VALID_KINDS = new Set(PHASE_TYPES); function sidecarForPhase(phase: Phase): Record { @@ -86,6 +91,12 @@ function sidecarForPhase(phase: Phase): Record { return out; } +function payloadForPhase(phase: Phase): Record | undefined { + const sidecar = sidecarForPhase(phase); + for (const key of NODE_FIELD_KEYS) delete sidecar[key]; + return Object.keys(sidecar).length > 0 ? sidecar : undefined; +} + function asKind(type: string | undefined): FlowIRNodeKind { const k = (type ?? "agent") as PhaseType; if (VALID_KINDS.has(k)) return k as FlowIRNodeKind; @@ -146,12 +157,15 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR errors.push({ code: "missing-phase-id", message: "Phase missing id" }); continue; } - const refs = collectRefs(phase); - const reads = new Set(refs.steps.filter((id) => id !== phase.id)); - for (const d of phase.dependsOn ?? []) { - if (d !== phase.id) reads.add(d); - } - const inject = Array.from(reads); + const refs = collectRefs(phase); + const reads = new Set(refs.steps.filter((id) => id !== phase.id)); + for (const d of phase.dependsOn ?? []) { + if (d !== phase.id) reads.add(d); + } + for (const d of phase.from ?? []) { + if (d !== phase.id) reads.add(d); + } + const inject = Array.from(reads); declaredDeps[phase.id] = { reads: inject, writes: [phase.id] }; for (const r of refs.steps) { @@ -185,9 +199,11 @@ export function compileTaskflowToFlowIR(def: Taskflow): CompileTaskflowToFlowIRR node.condRef = n.canonical || undefined; } if (typeof phase.task === "string") node.task = phase.task; - if (phase.dependsOn && phase.dependsOn.length > 0) node.deps = [...phase.dependsOn]; - if (phase.join === "all" || phase.join === "any") node.join = phase.join; - if (typeof phase.timeout === "number") node.timeout = phase.timeout; + if (phase.dependsOn && phase.dependsOn.length > 0) node.deps = [...phase.dependsOn]; + if (phase.join === "all" || phase.join === "any") node.join = phase.join; + if (typeof phase.timeout === "number") node.timeout = phase.timeout; + const payload = payloadForPhase(phase); + if (payload) node.payload = payload; for (const from of inject) { edges.push({ from, to: phase.id }); diff --git a/packages/taskflow-core/src/flowir/schema.ts b/packages/taskflow-core/src/flowir/schema.ts index 0818148..f8a675e 100644 --- a/packages/taskflow-core/src/flowir/schema.ts +++ b/packages/taskflow-core/src/flowir/schema.ts @@ -88,6 +88,9 @@ export type FlowIRNodeKind = PhaseType; * separate for the edge model). * - `join` — join mode (`"all"` default, or `"any"` for OR-join). * - `timeout` — per-call ms cap (agent-running phases). + * - `payload` — stable, hash-addressed DSL payload fields not otherwise + * represented above (for example branch bodies, script command, + * map source, flow/expand definitions, output contracts). * * All added fields are **optional** so the stub's minimal output remains valid. */ @@ -117,6 +120,8 @@ export interface FlowIRNode { join?: "all" | "any"; /** Per-subagent-call ms cap (agent-running phases). */ timeout?: number; + /** Runtime-affecting DSL payload not otherwise modeled by the core node fields. */ + payload?: Record; } // --------------------------------------------------------------------------- @@ -229,6 +234,11 @@ export const FlowIRNodeSchema = Type.Object( 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" })), + payload: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: "Runtime-affecting DSL payload not otherwise modeled by the core node fields", + }), + ), }, { additionalProperties: false }, ); @@ -296,6 +306,9 @@ export function isFlowIRNode(value: unknown): value is FlowIRNode { 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; + if (n.payload !== undefined && (typeof n.payload !== "object" || n.payload === null || Array.isArray(n.payload))) { + return false; + } return true; } diff --git a/packages/taskflow-core/src/runtime/phases/race.ts b/packages/taskflow-core/src/runtime/phases/race.ts index 1618deb..824b75c 100644 --- a/packages/taskflow-core/src/runtime/phases/race.ts +++ b/packages/taskflow-core/src/runtime/phases/race.ts @@ -8,6 +8,8 @@ import type { PhaseState } from "../../store.ts"; import { emptyUsage, aggregateUsage } from "../../usage.ts"; import { safeParse } from "../../interpolate.ts"; +const LOSER_CANCEL_GRACE_MS = 50; + export interface RaceBranch { agent: string; task: string; @@ -24,7 +26,8 @@ export interface RaceIsFailed { /** * First **successful** branch wins. Failed settles do not terminate the race. * If all fail → race fails. cancelLosers aborts others after a success (best-effort). - * Final usage aggregates all branch results (including aborted partials). + * Final usage aggregates settled branch results. A non-cooperative loser that + * ignores abort is bounded by a short grace period and reported in warnings. */ export async function executeRaceBranches( phase: Phase, @@ -52,6 +55,14 @@ export async function executeRaceBranches( const cancelLosers = (phase as { cancelLosers?: boolean }).cancelLosers !== false; const controllers = branches.map(() => new AbortController()); + type Settled = { i: number; result: RunResult }; + const settled: Settled[] = []; + let winner: Settled | undefined; + let wake!: () => void; + const gate = new Promise((r) => { + wake = r; + }); + const onParentAbort = () => { for (const c of controllers) { try { @@ -60,20 +71,15 @@ export async function executeRaceBranches( /* ignore */ } } + // Unblock the wait loop so non-cooperative runners hit the grace timeout + // instead of hanging forever (AGENTS: never hang forever). + wake(); }; if (opts.parentSignal) { if (opts.parentSignal.aborted) onParentAbort(); else opts.parentSignal.addEventListener("abort", onParentAbort, { once: true }); } - type Settled = { i: number; result: RunResult }; - const settled: Settled[] = []; - let winner: Settled | undefined; - let wake!: () => void; - const gate = new Promise((r) => { - wake = r; - }); - try { const branchPromises = branches.map(async (b, i) => { let result: RunResult; @@ -114,15 +120,36 @@ export async function executeRaceBranches( return entry; }); - await Promise.race([gate, Promise.allSettled(branchPromises).then(() => undefined)]); - await Promise.allSettled(branchPromises); + const allSettled = Promise.allSettled(branchPromises).then(() => undefined); + await Promise.race([gate, allSettled]); + // Bound remaining wait when branches were aborted (cancelLosers after + // success, or parent AbortSignal). Prevents hang if a runner ignores abort. + if (settled.length < branches.length) { + const parentAborted = opts.parentSignal?.aborted === true; + if ((winner && cancelLosers) || parentAborted) { + await Promise.race([ + allSettled, + new Promise((r) => setTimeout(r, LOSER_CANCEL_GRACE_MS)), + ]); + } else { + await allSettled; + } + } const totalUsage = aggregateUsage(settled.map((s) => s.result.usage ?? emptyUsage())); + const outstanding = branches.length - settled.length; + const parentAborted = opts.parentSignal?.aborted === true; if (!winner) { const errs = settled .map((s) => s.result.errorMessage || s.result.stderr || `branch ${s.i + 1} failed`) .filter(Boolean); + const warnings = ["race: all branches failed"]; + if (parentAborted && outstanding > 0) { + warnings.push( + `race: ${outstanding} branch(es) did not settle within ${LOSER_CANCEL_GRACE_MS}ms after parent abort`, + ); + } return { id: phase.id, status: "failed", @@ -130,7 +157,7 @@ export async function executeRaceBranches( usage: totalUsage, inputHash: opts.inputHash, endedAt: Date.now(), - warnings: ["race: all branches failed"], + warnings, ...(opts.readRefs ? { reads: opts.readRefs } : {}), }; } @@ -141,6 +168,11 @@ export async function executeRaceBranches( warnings.push( `race: cancelLosers aborted ${branches.length - 1} loser branch(es) (best-effort AbortSignal)`, ); + if (outstanding > 0) { + warnings.push( + `race: ${outstanding} loser branch(es) did not acknowledge abort within ${LOSER_CANCEL_GRACE_MS}ms; returning winner output`, + ); + } } return { id: phase.id, diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index f2566aa..763703c 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -816,12 +816,15 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va if (!p.branches || p.branches.length === 0) errors.push(`Phase '${p.id}' (parallel) requires non-empty 'branches'`); } - if (type === "race") { - if (!p.branches || p.branches.length === 0) - errors.push(`Phase '${p.id}' (race) requires non-empty 'branches'`); - else if (p.branches.length < 2) - errors.push(`Phase '${p.id}' (race): needs at least 2 branches`); - } + if (type === "race") { + if (!p.branches || p.branches.length === 0) + errors.push(`Phase '${p.id}' (race) requires non-empty 'branches'`); + else if (p.branches.length < 2) + errors.push(`Phase '${p.id}' (race): needs at least 2 branches`); + if ((p as { cancelLosers?: unknown }).cancelLosers !== undefined && typeof (p as { cancelLosers?: unknown }).cancelLosers !== "boolean") { + errors.push(`Phase '${p.id}' (race): cancelLosers must be a boolean`); + } + } if (type === "expand") { if ((p as { def?: unknown }).def === undefined) errors.push(`Phase '${p.id}' (expand) requires 'def' (fragment Taskflow or {steps.X.json})`); diff --git a/packages/taskflow-core/test/exec-kernel-hardening.test.ts b/packages/taskflow-core/test/exec-kernel-hardening.test.ts index d66f03e..dff9831 100644 --- a/packages/taskflow-core/test/exec-kernel-hardening.test.ts +++ b/packages/taskflow-core/test/exec-kernel-hardening.test.ts @@ -286,3 +286,44 @@ test("dynamic flow def with script fails open (done empty) not ACE", async () => assert.equal(res.state.phases.f.status, "done"); assert.equal(res.state.phases.f.output ?? "", ""); }); + +test("nested flow re-admission: race child rejected under event kernel", async () => { + // Parent is kernel-eligible (plain flow wrapper); child has race → must not + // silently enter kernel step with wrong semantics. + const def: Taskflow = { + name: "parent-kernel", + phases: [ + { + id: "f", + type: "flow", + def: { + name: "child-race", + phases: [ + { + id: "r", + type: "race", + branches: [ + { task: "a", agent: "a" }, + { task: "b", agent: "a" }, + ], + final: true, + }, + ], + }, + final: true, + }, + ], + }; + assert.equal(canUseEventKernel(def), true, "parent without race is kernel-eligible"); + const res = await executeTaskflow(mk(def), { + cwd: process.cwd(), + agents: AGENTS, + runTask: paidRunner(0.01), + persist: () => {}, + eventKernel: true, + }); + // Nested admission fails closed — phase fails with clear reason. + assert.equal(res.ok, false); + assert.equal(res.state.phases.f?.status, "failed"); + assert.match(res.state.phases.f?.error ?? res.state.phases.f?.output ?? "", /event kernel|race|Nested flow/i); +}); diff --git a/packages/taskflow-core/test/exec-kernel-parity.test.ts b/packages/taskflow-core/test/exec-kernel-parity.test.ts index 759ba46..5140b73 100644 --- a/packages/taskflow-core/test/exec-kernel-parity.test.ts +++ b/packages/taskflow-core/test/exec-kernel-parity.test.ts @@ -164,3 +164,17 @@ test("parity: script phase stdout agrees", async () => { assert.equal(kernel.state.phases.s.status, "done"); assert.equal(imp.state.phases.s.status, "done"); }); + +test("parity: skipped final phase falls back to last completed output", async () => { + const def: Taskflow = { + name: "parity-skipped-final", + phases: [ + { id: "a", type: "agent", agent: "a", task: "one" }, + { id: "b", type: "agent", agent: "a", task: "two", when: "false", final: true }, + ], + }; + const { kernel, imp } = await runBoth(def); + assert.equal(kernel.ok, imp.ok); + assert.equal(kernel.finalOutput, imp.finalOutput); + assert.equal(kernel.finalOutput, "OUT:one"); +}); diff --git a/packages/taskflow-core/test/flowir-canonical-hash.test.ts b/packages/taskflow-core/test/flowir-canonical-hash.test.ts index 3dcc197..b3bc7c3 100644 --- a/packages/taskflow-core/test/flowir-canonical-hash.test.ts +++ b/packages/taskflow-core/test/flowir-canonical-hash.test.ts @@ -238,6 +238,12 @@ test("SENSITIVITY: changing timeout changes the hash", () => { assert.notEqual(hashNode(a), hashNode(b)); }); +test("SENSITIVITY: changing payload changes the hash", () => { + const a = node("x", { kind: "parallel", payload: { branches: [{ agent: "a", task: "alpha" }] } }); + const b = node("x", { kind: "parallel", payload: { branches: [{ agent: "a", task: "beta" }] } }); + 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'" }); diff --git a/packages/taskflow-core/test/flowir.test.ts b/packages/taskflow-core/test/flowir.test.ts index 1672cae..fc91c34 100644 --- a/packages/taskflow-core/test/flowir.test.ts +++ b/packages/taskflow-core/test/flowir.test.ts @@ -60,6 +60,45 @@ test("compileTaskflowToIR: single-field mutation changes the hash", async () => assert.notEqual((await compileTaskflowToIR(renamed)).hash, h0.hash); }); +test("compileTaskflowToIR: hash changes for non-agent runtime payload fields", async () => { + const cases: Array<[string, Taskflow, Taskflow]> = [ + [ + "parallel branch task", + flow([{ id: "p", type: "parallel", branches: [{ agent: "a", task: "alpha" }], final: true } as Phase]), + flow([{ id: "p", type: "parallel", branches: [{ agent: "a", task: "beta" }], final: true } as Phase]), + ], + [ + "script run", + flow([{ id: "s", type: "script", run: ["echo", "alpha"], final: true } as Phase]), + flow([{ id: "s", type: "script", run: ["echo", "beta"], final: true } as Phase]), + ], + [ + "map source", + flow([{ id: "m", type: "map", over: "{steps.a.json}", task: "{item}", final: true } as Phase]), + flow([{ id: "m", type: "map", over: "{steps.b.json}", task: "{item}", final: true } as Phase]), + ], + [ + "flow definition", + flow([{ id: "f", type: "flow", def: { name: "child", phases: [{ id: "a", task: "alpha" }] }, final: true } as Phase]), + flow([{ id: "f", type: "flow", def: { name: "child", phases: [{ id: "a", task: "beta" }] }, final: true } as Phase]), + ], + [ + "race cancelLosers", + flow([{ id: "r", type: "race", branches: [{ task: "a" }, { task: "b" }], cancelLosers: true, final: true } as Phase]), + flow([{ id: "r", type: "race", branches: [{ task: "a" }, { task: "b" }], cancelLosers: false, final: true } as Phase]), + ], + [ + "expand maxNodes", + flow([{ id: "e", type: "expand", def: { phases: [{ id: "a", task: "x" }] }, maxNodes: 5, final: true } as Phase]), + flow([{ id: "e", type: "expand", def: { phases: [{ id: "a", task: "x" }] }, maxNodes: 6, final: true } as Phase]), + ], + ]; + + for (const [label, a, b] of cases) { + assert.notEqual((await compileTaskflowToIR(a)).hash, (await compileTaskflowToIR(b)).hash, label); + } +}); + test("compileTaskflowToIR: structured diagnostics, never throws", async () => { const f = flow([ { id: "a", type: "agent", task: "read {steps.ghost.output}", final: true } as Phase, @@ -130,6 +169,21 @@ test("compileTaskflowToIR: inject synthesized from {steps.X} refs", async () => assert.deepEqual(byId.get("scout")!.inject, []); }); +test("compileTaskflowToIR: reduce.from contributes declared reads and edges", async () => { + const f = flow([ + agent("a"), + agent("b"), + { id: "r", type: "reduce", from: ["a", "b"], task: "summarize", final: true } as Phase, + ]); + const ir = await compileTaskflowToIR(f); + const r = ir.ir!.nodes.find((n) => n.id === "r")!; + assert.deepEqual(r.inject, ["a", "b"]); + assert.deepEqual(ir.meta.declaredDeps.r, { reads: ["a", "b"], writes: ["r"] }); + const c = compileTaskflowToFlowIR(f); + assert.ok(c.canonical.edges?.some((e) => e.from === "a" && e.to === "r")); + assert.ok(c.canonical.edges?.some((e) => e.from === "b" && e.to === "r")); +}); + test("compileTaskflowToIR: declaredDeps mirror inject/emits", async () => { const f = flow([ agent("scout"), diff --git a/packages/taskflow-core/test/race-expand.test.ts b/packages/taskflow-core/test/race-expand.test.ts index 89825c4..06b4aeb 100644 --- a/packages/taskflow-core/test/race-expand.test.ts +++ b/packages/taskflow-core/test/race-expand.test.ts @@ -73,6 +73,23 @@ test("validate: race + expand shapes", () => { }).ok, true, ); + assert.equal( + validateTaskflow({ + name: "bad-race", + phases: [ + { + id: "r", + type: "race", + branches: [ + { task: "a", agent: "a" }, + { task: "b", agent: "a" }, + ], + cancelLosers: "false", + } as never, + ], + }).ok, + false, + ); }); test("race: first successful branch wins", async () => { @@ -107,7 +124,7 @@ test("race: first successful branch wins", async () => { assert.equal(st.phases.r?.output?.trim(), "FAST"); assert.ok(st.phases.r?.warnings?.some((w) => /branch 2/.test(w))); assert.ok(st.phases.r?.warnings?.some((w) => /cancelLosers aborted/.test(w ?? ""))); - // Usage aggregates both branches (winner + aborted loser partial) + // Usage aggregates both branches assert.ok((st.phases.r?.usage?.cost ?? 0) >= 0.001); }); @@ -206,7 +223,6 @@ test("race: cancelLosers aborts losing branch via AbortSignal", async () => { errorMessage: "aborted", }; } - // small delay so slow is definitely in-flight await new Promise((r) => setTimeout(r, 15)); return { agent, @@ -275,6 +291,58 @@ test("race: cancelLosers false leaves loser running to completion", async () => assert.ok(!st.phases.r?.warnings?.some((w) => /cancelLosers aborted/.test(w))); }); +test("race: cancelLosers returns after a bounded grace when loser ignores abort", async () => { + const def: Taskflow = { + name: "race-noncooperative-loser", + phases: [ + { + id: "r", + type: "race", + cancelLosers: true, + branches: [ + { task: "fast-path", agent: "a" }, + { task: "slow-ignores-abort", agent: "a" }, + ], + final: true, + }, + ], + }; + const st = mkState(def); + const started = Date.now(); + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task) => { + if (task.includes("slow")) { + await new Promise((r) => setTimeout(r, 250)); + return { + agent, + task, + exitCode: 0, + output: "SLOW", + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.001, turns: 1 }, + stopReason: "end", + }; + } + await new Promise((r) => setTimeout(r, 10)); + return { + agent, + task, + exitCode: 0, + output: "FAST", + stderr: "", + usage: { ...emptyUsage(), input: 1, output: 1, cost: 0.001, turns: 1 }, + stopReason: "end", + }; + }, + }); + assert.equal(st.status, "completed"); + assert.equal(st.phases.r?.output?.trim(), "FAST"); + assert.ok(Date.now() - started < 180, `race waited for non-cooperative loser`); + assert.ok(st.phases.r?.warnings?.some((w) => /did not acknowledge abort/.test(w))); +}); + test("expand nested: runs fragment as sub-flow", async () => { const def: Taskflow = { name: "exp-nested", @@ -301,7 +369,6 @@ test("expand nested: runs fragment as sub-flow", async () => { assert.equal(st.phases.e?.status, "done"); assert.equal(st.phases.e?.defError, undefined, st.phases.e?.defError); assert.match(st.phases.e?.output ?? "", /nested-hi/); - // nested: child id not on parent assert.equal(st.phases.inner, undefined); }); @@ -330,7 +397,6 @@ test("expand graft: promotes child phases onto parent", async () => { assert.equal(st.status, "completed"); assert.equal(st.phases.grow?.status, "done"); assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError); - // grafted id is grow-leaf assert.equal(st.phases["grow-leaf"]?.status, "done"); assert.match(st.phases["grow-leaf"]?.output ?? "", /grafted-ok/); // No usage double-count: expand usage zeroed; child holds cost @@ -338,7 +404,7 @@ test("expand graft: promotes child phases onto parent", async () => { assert.ok((st.phases["grow-leaf"]?.usage?.cost ?? 0) > 0); }); -test("expand graft: rewrites multi-phase {steps.*} refs after prefix", async () => { +test("expand graft: multi-phase rewrites {steps.*} and avoids usage double-count", async () => { const def: Taskflow = { name: "exp-graft-chain", phases: [ @@ -380,7 +446,93 @@ test("expand graft: rewrites multi-phase {steps.*} refs after prefix", async () assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError); assert.equal(st.phases["grow-a"]?.status, "done"); assert.equal(st.phases["grow-b"]?.status, "done"); - // Task text must use grow-a after prefix rewrite - assert.ok(seen.some((t) => t.includes("grow-a") || t.includes("A-VALUE"))); assert.match(st.phases["grow-b"]?.output ?? "", /A-VALUE/); + // expand usage zeroed after promote; children hold cost + assert.equal(st.phases.grow?.usage?.cost ?? 0, 0); + assert.ok((st.phases["grow-a"]?.usage?.cost ?? 0) > 0); +}); + +test("race: all branches fail → phase failed + usage", async () => { + const def: Taskflow = { + name: "race-all-fail", + phases: [ + { + id: "r", + type: "race", + cancelLosers: false, + branches: [ + { task: "fail-a", agent: "a" }, + { task: "fail-b", agent: "a" }, + ], + final: true, + }, + ], + }; + const st = mkState(def); + await executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + runTask: async (_c, _a, agent, task) => ({ + agent, + task, + exitCode: 1, + output: "", + stderr: `err-${task}`, + usage: { ...emptyUsage(), input: 1, output: 0, cost: 0.001, turns: 1 }, + stopReason: "error", + errorMessage: `err-${task}`, + }), + }); + assert.equal(st.status, "failed"); + assert.equal(st.phases.r?.status, "failed"); + assert.match(st.phases.r?.error ?? "", /all 2 branches failed/); + assert.ok(st.phases.r?.warnings?.some((w) => /all branches failed/.test(w))); + assert.ok((st.phases.r?.usage?.cost ?? 0) >= 0.002); +}); + +test("race: parent abort bounds wait when branch ignores signal", async () => { + const def: Taskflow = { + name: "race-parent-abort", + phases: [ + { + id: "r", + type: "race", + cancelLosers: true, + branches: [ + { task: "hang-a", agent: "a" }, + { task: "hang-b", agent: "a" }, + ], + final: true, + }, + ], + }; + const st = mkState(def); + const ac = new AbortController(); + const started = Date.now(); + const run = executeTaskflow(st, { + cwd: process.cwd(), + agents: AGENTS, + signal: ac.signal, + runTask: async (_c, _a, agent, task) => { + // Ignore AbortSignal — would hang forever without race grace. + await new Promise((r) => setTimeout(r, 5000)); + return { + agent, + task, + exitCode: 0, + output: "too-late", + stderr: "", + usage: emptyUsage(), + stopReason: "end", + }; + }, + }); + // Abort shortly after start so both branches are in-flight. + await new Promise((r) => setTimeout(r, 15)); + ac.abort(); + await run; + const elapsed = Date.now() - started; + assert.ok(elapsed < 2000, `expected bounded wait, took ${elapsed}ms`); + // Parent abort → all branches aborted; non-cooperative → grace → all-fail path + assert.ok(st.phases.r?.status === "failed" || st.phases.r?.status === "done"); }); diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index a6a46c8..3d5d4fa 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -1,8 +1,14 @@ { "name": "taskflow-dsl", - "version": "0.1.7", + "version": "0.2.0", "description": "Compile-time TypeScript DSL frontend for taskflow: erase .tf.ts runes to Taskflow JSON, then FlowIR via taskflow-core.", - "keywords": ["taskflow", "dsl", "typescript", "dag", "orchestration"], + "keywords": [ + "taskflow", + "dsl", + "typescript", + "dag", + "orchestration" + ], "license": "MIT", "author": "heggria ", "homepage": "https://github.com/heggria/taskflow#readme", @@ -47,7 +53,9 @@ "default": "./dist/diagnostics.js" } }, - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs taskflow-dsl 2>/dev/null || true", "prepublishOnly": "npm run build" diff --git a/packages/taskflow-dsl/src/build/erase/context.ts b/packages/taskflow-dsl/src/build/erase/context.ts index 6191c81..93e40bb 100644 --- a/packages/taskflow-dsl/src/build/erase/context.ts +++ b/packages/taskflow-dsl/src/build/erase/context.ts @@ -2,7 +2,7 @@ * Shared emit context for kind handlers (one eraseSource() call). */ -import type ts from "typescript"; +import ts from "typescript"; import type { Diagnostic } from "../../diagnostics.ts"; import type { PhaseDraft } from "./types.ts"; @@ -56,8 +56,6 @@ export function bindDefArg( } } -// Local type guards to avoid pulling full ts into every call site signature -import ts from "typescript"; function tsIsPropertyAccess(n: ts.Node): n is ts.PropertyAccessExpression { return ts.isPropertyAccessExpression(n); } diff --git a/packages/taskflow-dsl/src/build/erase/pipeline.ts b/packages/taskflow-dsl/src/build/erase/pipeline.ts index f6d95f2..68c5344 100644 --- a/packages/taskflow-dsl/src/build/erase/pipeline.ts +++ b/packages/taskflow-dsl/src/build/erase/pipeline.ts @@ -97,18 +97,18 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul if (!PHASE_RUNES.has(cn.split(".")[0]!) && !PHASE_RUNES.has(cn)) { if (cn === "json") return undefined; - // Bound or returned call that is not a known rune → hard error (no silent drop). - if (bindName !== undefined) { - diags.push( - diag( - file, - sf, - call, - "TFDSL_RUNE_UNKNOWN", - `Unknown rune or call '${cn}' cannot erase to a phase (typo?).`, - ), - ); - } + // Any unknown call in the flow body → hard error (no silent drop). + // Covers bound (`const x = mystery()`), returned (`return mystery()`), + // and bare expression statements (`mystery()`). + diags.push( + diag( + file, + sf, + call, + "TFDSL_RUNE_UNKNOWN", + `Unknown rune or call '${cn}' cannot erase to a phase (typo?).`, + ), + ); return undefined; } @@ -277,12 +277,12 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul phases.get(last)!.raw.final = true; } - const phaseList = order.map((id) => { - const ph = phases.get(id)!; - const raw = { ...ph.raw, id: ph.id }; - if (ph.dependsOn.size && !raw.dependsOn) raw.dependsOn = [...ph.dependsOn]; - // clean undefined-ish - return raw; + const phaseList = order.map((id) => { + const ph = phases.get(id)!; + const raw: Record = { ...ph.raw, id: ph.id }; + if (ph.dependsOn.size && !raw.dependsOn) raw.dependsOn = [...ph.dependsOn]; + // clean undefined-ish + return raw; }); const taskflow: Record = { diff --git a/packages/taskflow-dsl/src/decompile.ts b/packages/taskflow-dsl/src/decompile.ts index bbdd589..6c9d3a0 100644 --- a/packages/taskflow-dsl/src/decompile.ts +++ b/packages/taskflow-dsl/src/decompile.ts @@ -43,7 +43,8 @@ const DECOMPILABLE = new Set([ export function decompileTaskflow(def: Taskflow): string { for (const p of def.phases ?? []) { - if (!DECOMPILABLE.has(p.type)) { + const type = p.type ?? "agent"; + if (!DECOMPILABLE.has(type)) { throw new Error( `TFDSL_DECOMPILE_UNSUPPORTED: phase type ${JSON.stringify(p.type)} (id=${p.id}) cannot be decompiled in MVP`, ); @@ -125,9 +126,12 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri if (p.when) opts.push(`when: ${JSON.stringify(p.when)}`); if (p.dependsOn?.length) opts.push(`dependsOn: ${JSON.stringify(p.dependsOn)}`); if (p.output) opts.push(`output: ${JSON.stringify(p.output)}`); + if ((p as { cancelLosers?: boolean }).cancelLosers === false) opts.push(`cancelLosers: false`); + const maxNodes = (p as { maxNodes?: number }).maxNodes; + if (typeof maxNodes === "number") opts.push(`maxNodes: ${maxNodes}`); const optStr = opts.length ? `, { ${opts.join(", ")} }` : ""; - switch (p.type) { + switch (p.type ?? "agent") { case "agent": return `const ${bind} = agent(\`${esc(String(p.task ?? ""))}\`${optStr});`; case "script": { @@ -166,7 +170,10 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri ); } const em = (p as { expandMode?: string }).expandMode ?? "nested"; - return `const ${bind} = expand(${JSON.stringify(p.def)}, { expandMode: ${JSON.stringify(em)} });`; + // Always emit expandMode + shared opts (dependsOn/final/when/maxNodes) so + // decompile → rebuild keeps DAG edges that string-def alone would lose. + const expandOpts = [`expandMode: ${JSON.stringify(em)}`, ...opts]; + return `const ${bind} = expand(${JSON.stringify(p.def)}, { ${expandOpts.join(", ")} });`; } case "reduce": { const from = (p.from ?? p.dependsOn ?? []).map((id) => phaseBinding(id)); diff --git a/packages/taskflow-dsl/src/runes.ts b/packages/taskflow-dsl/src/runes.ts index 7265279..29be8a9 100644 --- a/packages/taskflow-dsl/src/runes.ts +++ b/packages/taskflow-dsl/src/runes.ts @@ -18,7 +18,12 @@ function eraseOnly(rune: string): never { } /** Opaque phase handle — only meaningful to the compiler. */ -export type PhaseRef = { readonly __brand: "PhaseRef"; readonly id?: string }; +export type PhaseRef = { + readonly __brand: "PhaseRef"; + readonly id?: string; + readonly output: string; + readonly json: TJson; +}; export type TemplateInput = string; @@ -33,14 +38,14 @@ export interface FlowOptions { version?: number; } -export interface PhaseOptions { +export interface PhaseOptions { id?: string; agent?: string; model?: string; thinking?: string | boolean; tools?: string[]; cwd?: string; - output?: "text" | "json"; + output?: "text" | "json" | JsonExpectMarker; expect?: unknown; when?: string; join?: "all" | "any"; @@ -74,59 +79,59 @@ export function json(): JsonExpectMarker { export function flow( name: string, - fn: (ctx: FlowCtx) => PhaseRef | void, + fn: (ctx: FlowCtx) => PhaseRef | void, ): TaskflowModuleDefault; export function flow( name: string, opts: FlowOptions, - fn: (ctx: FlowCtx) => PhaseRef | void, + fn: (ctx: FlowCtx) => PhaseRef | void, ): TaskflowModuleDefault; export function flow( _name: string, - _optsOrFn: FlowOptions | ((ctx: FlowCtx) => PhaseRef | void), - _fn?: (ctx: FlowCtx) => PhaseRef | void, + _optsOrFn: FlowOptions | ((ctx: FlowCtx) => PhaseRef | void), + _fn?: (ctx: FlowCtx) => PhaseRef | void, ): TaskflowModuleDefault { return eraseOnly("flow"); } -export function agent(_task: TemplateInput, _opts?: PhaseOptions): PhaseRef { +export function agent(_task: TemplateInput, _opts?: PhaseOptions): PhaseRef { return eraseOnly("agent"); } export function parallel( - _branches: PhaseRef[], + _branches: PhaseRef[], _opts?: PhaseOptions, -): PhaseRef[] & PhaseRef { +): PhaseRef[] & PhaseRef { return eraseOnly("parallel"); } -export function map( - _source: PhaseRef | string, - _fn: (item: unknown) => PhaseRef, - _opts?: PhaseOptions, -): PhaseRef { +export function map( + _source: PhaseRef | string, + _fn: (item: TItem) => PhaseRef, + _opts?: PhaseOptions, +): PhaseRef { return eraseOnly("map"); } export function gate( - _upstream: PhaseRef, + _upstream: PhaseRef, _opts?: PhaseOptions, - _task?: (input: PhaseRef) => TemplateInput, -): PhaseRef { + _task?: (input: PhaseRef) => TemplateInput, +): PhaseRef { return eraseOnly("gate"); } /** Zero-token pre-checks (`eval`); LLM `task` still required by engine if score absent. */ export function gateAutomated( - _upstream: PhaseRef, + _upstream: PhaseRef, _opts: PhaseOptions & { pass: string[]; task?: TemplateInput }, -): PhaseRef { +): PhaseRef { return eraseOnly("gate.automated"); } /** Deterministic scorers (`score`); may omit LLM task when score alone decides. */ export function gateScored( - _upstream: PhaseRef, + _upstream: PhaseRef, _opts: PhaseOptions & { scorers: Array>; combine?: "all" | "any" | "weighted"; @@ -135,7 +140,7 @@ export function gateScored( target?: string; judge?: { agent?: string; task?: string }; }, -): PhaseRef { +): PhaseRef { return eraseOnly("gate.scored"); } @@ -143,14 +148,14 @@ gate.automated = gateAutomated; gate.scored = gateScored; export function reduce( - _from: PhaseRef[], - _fn: (parts: Record) => PhaseRef, + _from: PhaseRef[], + _fn: (parts: Record>) => PhaseRef, _opts?: PhaseOptions, -): PhaseRef { +): PhaseRef { return eraseOnly("reduce"); } -export function approval(_opts: { request: TemplateInput } & PhaseOptions): PhaseRef { +export function approval(_opts: { request: TemplateInput } & PhaseOptions): PhaseRef { return eraseOnly("approval"); } @@ -164,9 +169,9 @@ export function subflow( /** Nested dynamic sub-flow (compiles to type:flow def). */ export function subflowDef( - _def: PhaseRef | string, + _def: PhaseRef | string | unknown, _opts?: PhaseOptions, -): PhaseRef { +): PhaseRef { return eraseOnly("subflow.def"); } subflow.def = subflowDef; @@ -177,57 +182,57 @@ export function loop(_opts: PhaseOptions & { maxIterations?: number; convergence?: boolean; reflexion?: boolean; -}): PhaseRef { +}): PhaseRef { return eraseOnly("loop"); } export function tournament(_opts: PhaseOptions & { variants?: number; - branches?: PhaseRef[]; + branches?: PhaseRef[]; mode?: "best" | "aggregate"; judge?: string; judgeAgent?: string; task?: TemplateInput; -}): PhaseRef { +}): PhaseRef { return eraseOnly("tournament"); } export function script( _run: string | string[], _opts?: PhaseOptions & { input?: string }, -): PhaseRef { +): PhaseRef { return eraseOnly("script"); } /** Nested expand (isolated sub-flow). */ -export function expandNested( - _def: PhaseRef | string, +export function expandNested( + _def: PhaseRef | string | TDef, _opts?: PhaseOptions & { maxNodes?: number }, -): PhaseRef { +): PhaseRef { return eraseOnly("expand.nested"); } /** Graft-promote expand: run fragment then promote phase states onto parent. */ -export function expandGraft( - _def: PhaseRef | string, +export function expandGraft( + _def: PhaseRef | string | TDef, _opts?: PhaseOptions & { maxNodes?: number }, -): PhaseRef { +): PhaseRef { return eraseOnly("expand.graft"); } -export function expand( - _def: PhaseRef | string, +export function expand( + _def: PhaseRef | string | TDef, _opts?: PhaseOptions & { expandMode?: "nested" | "graft"; maxNodes?: number }, -): PhaseRef { +): PhaseRef { return eraseOnly("expand"); } expand.nested = expandNested; expand.graft = expandGraft; -/** Race: first completed branch wins. */ +/** Race: first successful branch wins. */ export function race( - _branches: PhaseRef[], + _branches: PhaseRef[], _opts?: PhaseOptions & { cancelLosers?: boolean }, -): PhaseRef { +): PhaseRef { return eraseOnly("race"); } diff --git a/packages/taskflow-dsl/test/erase-build.test.ts b/packages/taskflow-dsl/test/erase-build.test.ts index 51c9617..1f70fe1 100644 --- a/packages/taskflow-dsl/test/erase-build.test.ts +++ b/packages/taskflow-dsl/test/erase-build.test.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; import { test } from "node:test"; import { buildSource } from "../src/build.ts"; -import { checkSource } from "../src/check.ts"; +import { checkFile, checkSource } from "../src/check.ts"; import { decompileTaskflow } from "../src/decompile.ts"; import { flow, agent, TfDslEraseOnlyError } from "../src/index.ts"; import { compileTaskflowToFlowIR, hashFlowIR, validateTaskflow } from "taskflow-core"; @@ -89,6 +91,29 @@ export default flow("hello", () => agent("hi"));`; assert.equal(r.ok, true, format(r)); }); +test("check: typed json output, item fields, and phase handles typecheck", () => { + const dir = fs.mkdtempSync(path.join(process.cwd(), "packages/taskflow-dsl/test/.tmp-typecheck-")); + try { + const file = path.join(dir, "typed.tf.ts"); + fs.writeFileSync( + file, + ` +import { flow, agent, map, reduce, json, expand } from "taskflow-dsl"; +export default flow("typed", () => { + const discover = agent("list", { output: json<{ path: string }[]>() }); + const each = map(discover, (item) => agent(\`Audit \${item.path}\`)); + const nested = expand.nested(discover.json); + return reduce([each, nested], (parts) => agent(\`Summary \${parts.each.output} \${parts.nested.output}\`)); +}); +`, + ); + const r = checkFile(file, { typecheck: true, cwd: process.cwd() }); + assert.equal(r.ok, true, format(r)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + test("parity: DSL build hash matches hand JSON twin (map + json + templates)", () => { const src = ` import { flow, agent, map, json } from "taskflow-dsl"; @@ -156,6 +181,128 @@ test("decompile: round-trip shape", () => { assert.match(src, /agent\(/); }); +test("register: explicit dependsOn unions with auto-wired template deps", () => { + const src = ` +import { flow, agent } from "taskflow-dsl"; +export default flow("u", () => { + const a = agent("A"); + const extra = agent("extra work", { dependsOn: ["a"] }); + // auto-dep via \${a.output}; explicit dependsOn also lists extra + return agent(\`see \${a.output}\`, { dependsOn: ["extra"], final: true }); +}); +`; + const r = buildSource(src, "union-deps.tf.ts"); + assert.equal(r.ok, true, format(r)); + const last = r.taskflow?.phases?.find((p) => p.final); + const deps = new Set(last?.dependsOn ?? []); + assert.ok(deps.has("a"), `expected auto dep a, got ${[...deps]}`); + assert.ok(deps.has("extra"), `expected explicit dep extra, got ${[...deps]}`); +}); + +test("unknown rune hard-errors (bound and bare)", () => { + const bound = buildSource( + ` +import { flow, agent } from "taskflow-dsl"; +export default flow("x", () => { + const z = mystery("nope"); + return agent("ok"); +}); +`, + "unknown-bound.tf.ts", + ); + assert.equal(bound.ok, false); + assert.ok((bound.diagnostics ?? []).some((d) => d.code === "TFDSL_RUNE_UNKNOWN")); + + const bare = buildSource( + ` +import { flow, agent } from "taskflow-dsl"; +export default flow("x", () => { + mystery(); + return agent("ok"); +}); +`, + "unknown-bare.tf.ts", + ); + assert.equal(bare.ok, false); + assert.ok((bare.diagnostics ?? []).some((d) => d.code === "TFDSL_RUNE_UNKNOWN")); +}); + +test("non-agent branch in race/parallel hard-errors TFDSL_BRANCH_KIND", () => { + const race = buildSource( + ` +import { flow, agent, race, script } from "taskflow-dsl"; +export default flow("r", () => race([agent("a"), script(["echo", "x"])])); +`, + "race-branch.tf.ts", + ); + assert.equal(race.ok, false); + assert.ok((race.diagnostics ?? []).some((d) => d.code === "TFDSL_BRANCH_KIND"), format(race)); + + const par = buildSource( + ` +import { flow, agent, parallel, script } from "taskflow-dsl"; +export default flow("p", () => parallel([agent("a"), script(["echo", "x"])])); +`, + "par-branch.tf.ts", + ); + assert.equal(par.ok, false); + assert.ok((par.diagnostics ?? []).some((d) => d.code === "TFDSL_BRANCH_KIND"), format(par)); +}); + +test("decompile: race/expand imports + object def fail-closed + dependsOn preserved", () => { + const withRace = decompileTaskflow({ + name: "r", + phases: [ + { + id: "q", + type: "race", + branches: [ + { task: "fast", agent: "a" }, + { task: "slow", agent: "a" }, + ], + cancelLosers: false, + final: true, + }, + ], + }); + assert.match(withRace, /import \{[^}]*\brace\b/); + assert.match(withRace, /cancelLosers: false/); + + const withExpand = decompileTaskflow({ + name: "e", + phases: [ + { id: "plan", type: "agent", task: "plan" }, + { + id: "grow", + type: "expand", + def: "{steps.plan.json}", + expandMode: "graft", + dependsOn: ["plan"], + final: true, + }, + ], + }); + assert.match(withExpand, /import \{[^}]*\bexpand\b/); + assert.match(withExpand, /dependsOn: \["plan"\]/); + assert.match(withExpand, /expandMode: "graft"/); + + assert.throws( + () => + decompileTaskflow({ + name: "bad", + phases: [ + { + id: "g", + type: "expand", + def: { name: "inner", phases: [{ id: "c", type: "agent", task: "x" }] }, + final: true, + }, + ], + }), + /TFDSL_DECOMPILE_UNSUPPORTED/, + ); +}); + function format(r: { diagnostics?: { code: string; message: string }[] }): string { return (r.diagnostics ?? []).map((d) => `${d.code}: ${d.message}`).join("\n"); } diff --git a/packages/taskflow-dsl/test/kinds-coverage.test.ts b/packages/taskflow-dsl/test/kinds-coverage.test.ts index f79a347..03c6750 100644 --- a/packages/taskflow-dsl/test/kinds-coverage.test.ts +++ b/packages/taskflow-dsl/test/kinds-coverage.test.ts @@ -131,17 +131,12 @@ export default flow("gs", () => { const auto = r.taskflow!.phases!.find((p) => p.id === "auto"); assert.ok(Array.isArray(auto?.eval)); assert.equal(auto?.task, "fallback llm gate"); - const scored = r.taskflow!.phases!.find((p) => p.id === "phase-2" || p.type === "gate" && p.score); const withScore = r.taskflow!.phases!.find((p) => (p as { score?: unknown }).score !== undefined); assert.ok(withScore, JSON.stringify(r.taskflow?.phases)); assert.equal((withScore as { score: { combine: string } }).score.combine, "all"); }); test("negative: unknown option warns", () => { - const src = ` -import { flow, agent } from "taskflow-dsl"; -export default flow("u", () => agent("t", { notARealField: 1 } as never)); -`; // without as never, TS would error at typecheck; source still has property const raw = ` import { flow, agent } from "taskflow-dsl"; diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index 1179e51..07484c9 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-hosts", - "version": "0.1.7", + "version": "0.2.0", "description": "Shared host-runner collection for taskflow — the codex, claude, opencode, and grok SubagentRunner implementations + their argv builders and event-stream parsers. The per-host MCP servers, plugin scaffolds, and bins live in codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow; this package holds just the runners so a new host can be added in one place.", "homepage": "https://github.com/heggria/taskflow#readme", "type": "module", @@ -49,7 +49,7 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json" }, "dependencies": { - "taskflow-core": "0.1.7" + "taskflow-core": "workspace:*" }, "devDependencies": { "typescript": "^6.0.3" diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index c2785bd..33171e5 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,7 +1,7 @@ { "name": "taskflow-mcp-core", - "version": "0.1.7", - "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok adapters \u2014 depends only on taskflow-core.", + "version": "0.2.0", + "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok adapters — depends only on taskflow-core.", "keywords": [ "taskflow", "mcp", @@ -60,6 +60,6 @@ "access": "public" }, "dependencies": { - "taskflow-core": "0.1.7" + "taskflow-core": "workspace:*" } } diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index f0f212f..f9bd58d 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -28,6 +28,9 @@ import { RpcError, RPC, serveStdio, type RpcHandler } from "./jsonrpc.ts"; import { renderFlowSvg, renderFlowOutline, svgToBase64 } from "./svg.ts"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import { discoverAgents, executeTaskflow, @@ -78,7 +81,22 @@ import { } from "taskflow-core"; const PROTOCOL_VERSION = "2025-06-18"; -const SERVER_INFO = { name: "taskflow", title: "Taskflow", version: "0.1.5" } as const; +const SERVER_VERSION = readServerVersion(); +const SERVER_INFO = { name: "taskflow", title: "Taskflow", version: SERVER_VERSION } as const; + +function readServerVersion(): string { + try { + const packageJsonPath = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"); + const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf8")); + if (typeof pkg === "object" && pkg !== null && "version" in pkg) { + const version = (pkg as { version?: unknown }).version; + if (typeof version === "string" && version) return version; + } + } catch { + // Keep the handshake available even in unusual embedded/bundled layouts. + } + return "0.2.0"; +} /** An MCP tool definition as returned by tools/list. */ interface McpTool { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af098a3..34bbda2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,49 +36,49 @@ importers: packages/claude-taskflow: dependencies: taskflow-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-core taskflow-hosts: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-hosts taskflow-mcp-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-mcp-core packages/codex-taskflow: dependencies: taskflow-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-core taskflow-hosts: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-hosts taskflow-mcp-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-mcp-core packages/grok-taskflow: dependencies: taskflow-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-core taskflow-hosts: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-hosts taskflow-mcp-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-mcp-core packages/opencode-taskflow: dependencies: taskflow-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-core taskflow-hosts: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-hosts taskflow-mcp-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-mcp-core packages/pi-taskflow: @@ -96,7 +96,7 @@ importers: specifier: '*' version: 0.80.3 taskflow-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-core typebox: specifier: '*' @@ -120,7 +120,7 @@ importers: packages/taskflow-hosts: dependencies: taskflow-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-core devDependencies: typescript: @@ -130,7 +130,7 @@ importers: packages/taskflow-mcp-core: dependencies: taskflow-core: - specifier: 0.1.7 + specifier: workspace:* version: link:../taskflow-core website: @@ -320,24 +320,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.5.2': resolution: {integrity: sha512-t7sseOmqND57uUWTwlawU6BYj+J06T/9EkydzBhkrgw/FK3QVhjU2wsJR0frljrKZ0/I8A/rYw7284QgqjQfIQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.5.2': resolution: {integrity: sha512-VArNLAzND063tF+XY0yPyM+DyahpzOMzOAvb7qs259nhjJWRjvjZdssuA+Rfl+l07+NOesKZ0Xu2yFrXyBMtzw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.5.2': resolution: {integrity: sha512-M/lOZrewzTCRDINbjhQ1gYYru37KlD3kJBQwwKCG0ckz5E9IZwIoJ3X0wBwRXA+yBDIwWUuPBHS67HzJY4dTfA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.5.2': resolution: {integrity: sha512-kbjFFKyZlzYnAuw7sRy5qDoFG6zrP40UK08oPQsWK0ct3NMnGSt+Bs1iviEEyEIP57N5MrykGXdO/wRiaR4lww==} @@ -603,89 +607,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -748,30 +768,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-arm64-musl@0.3.9': resolution: {integrity: sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@mariozechner/clipboard-linux-riscv64-gnu@0.3.9': resolution: {integrity: sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-gnu@0.3.9': resolution: {integrity: sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@mariozechner/clipboard-linux-x64-musl@0.3.9': resolution: {integrity: sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@mariozechner/clipboard-win32-arm64-msvc@0.3.9': resolution: {integrity: sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==} @@ -820,24 +845,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.10': resolution: {integrity: sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.10': resolution: {integrity: sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.10': resolution: {integrity: sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.10': resolution: {integrity: sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==} @@ -1355,24 +1384,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.3.2': resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.3.2': resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.3.2': resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.3.2': resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} @@ -1950,24 +1983,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ff401f8..e7fb878 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,3 +12,10 @@ packages: - "packages/grok-taskflow" - "packages/taskflow-dsl" - "website" +overrides: + postcss: ^8.5.10 +allowBuilds: + "@google/genai": true + esbuild: true + protobufjs: true + sharp: true diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index d7ab3f4..54e7283 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -500,7 +500,7 @@ JSON kinds (single source: `PHASE_TYPES` in core + `erase/kinds/*` registry). | `loop` | `loop({ task, until?, … })` | | | `tournament` | `tournament({ branches/variants, judge, … })` | | | `script` | `script(run, opts?)` | string or argv array | -| `race` | `race([agent…], { cancelLosers? })` | first **success** wins | +| `race` | `race([agent…], { cancelLosers? })` | first **success** wins; cooperative loser usage is counted | | `expand` | `expand` / `expand.nested` / `expand.graft` | `def` + `expandMode` | - `const [a,b] = parallel([agent(...), agent(...)])` desugars to **two real agent phases** (`a`, `b`) that run concurrently (no `dependsOn` between them). Prefer this when you need `{steps.a.output}`. diff --git a/website/content/docs/en/concepts/phases.mdx b/website/content/docs/en/concepts/phases.mdx index 343b5f7..c1af8b9 100644 --- a/website/content/docs/en/concepts/phases.mdx +++ b/website/content/docs/en/concepts/phases.mdx @@ -273,9 +273,9 @@ Use for builds, tests, lint, or file transformations. Zero tokens. } ``` -### `race` — first completed branch wins +### `race` — first successful branch wins -Use when several approaches can answer the same question and you want the **fastest** successful result (not a quality judge). +Use when several approaches can answer the same question and you want the **fastest successful** result (not a quality judge). A fast hard-fail does **not** win. ```json title="race-example.json" { @@ -316,7 +316,7 @@ Use when a planner emits a mini-flow JSON and you want the runtime to execute it | "Repeat until a condition is met" | `loop` | Iterative refinement. | | "Pick the best of several attempts" | `tournament` | Competitive selection for subjective work. | | "Run a command, no LLM" | `script` | Builds, tests, file ops. | -| "Keep the first approach that finishes" | `race` | Latency over exhaustive wait/judge. | +| "Keep the first approach that succeeds" | `race` | Latency over exhaustive wait/judge; hard-fail does not win. | | "Run a planner-emitted fragment" | `expand` | Nested isolation or graft-promote onto parent. | ## Phase lifecycle diff --git a/website/content/docs/en/reference/typescript-dsl.mdx b/website/content/docs/en/reference/typescript-dsl.mdx index cce42cc..413e7b0 100644 --- a/website/content/docs/en/reference/typescript-dsl.mdx +++ b/website/content/docs/en/reference/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: Compile-time .tf.ts authoring — erase runes to Taskflow JSON, the S4 adds a **compile-time** TypeScript frontend. You author `*.tf.ts` with **runes** (`agent`, `map`, `race`, …). A CLI erases them to ordinary Taskflow JSON. Hosts still run **JSON** via `taskflow_run` / `/tf run` — there is **no** interpret path and **no** host auto-build of `.tf.ts`. - **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. npm availability may lag the monorepo; prefer a workspace install / local path for preview builds. Package version on the `release/0.2.0` line may still show `0.1.7` until a formal 0.2.0 publish. + **Package status.** `taskflow-dsl` lives in the monorepo (`packages/taskflow-dsl`). It is **not** required for JSON authors. Package manifests are `0.2.0` on this release line; npm availability updates after the `v0.2.0` publish job — prefer a workspace install / local path until then. ## Workflow diff --git a/website/content/docs/zh-cn/concepts/phases.mdx b/website/content/docs/zh-cn/concepts/phases.mdx index f9f5559..346d80a 100644 --- a/website/content/docs/zh-cn/concepts/phases.mdx +++ b/website/content/docs/zh-cn/concepts/phases.mdx @@ -273,9 +273,9 @@ PR 审查的故事用到了最常见的五种类型。剩下的五种用来解 } ``` -### `race` —— 先完成的分支胜出 +### `race` —— 最先**成功**的分支胜出 -多种路径都能回答同一问题、且你要**最快**的成功结果时用(不是质量裁判)。 +多种路径都能回答同一问题、且你要**最快成功**的结果时用(不是质量裁判)。快速硬失败**不会**胜出。 ```json title="race-example.json" { @@ -316,7 +316,7 @@ PR 审查的故事用到了最常见的五种类型。剩下的五种用来解 | "重复直到某个条件满足" | `loop` | 迭代式精炼。 | | "从多次尝试里挑最好的" | `tournament` | 针对主观工作的竞争式选择。 | | "跑一条命令,不用 LLM" | `script` | 构建、测试、文件操作。 | -| "保留最先完成的路径" | `race` | 延迟优先于等齐/裁判。 | +| "保留最先成功的路径" | `race` | 延迟优先于等齐/裁判;硬失败不胜出。 | | "跑规划器吐出的片段" | `expand` | 隔离子流或 graft 提升到父 run。 | ## 阶段生命周期 diff --git a/website/content/docs/zh-cn/reference/typescript-dsl.mdx b/website/content/docs/zh-cn/reference/typescript-dsl.mdx index aed6fc9..787b670 100644 --- a/website/content/docs/zh-cn/reference/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/reference/typescript-dsl.mdx @@ -8,7 +8,7 @@ description: 编译期 .tf.ts 写法 —— rune erase 成 Taskflow JSON,再 S4 增加**编译期** TypeScript 前端:用 rune(`agent`、`map`、`race`…)写 `*.tf.ts`,CLI erase 成普通 Taskflow JSON。宿主仍通过 `taskflow_run` / `/tf run` 跑 **JSON**——**没有**解释执行路径,也**没有**宿主对 `.tf.ts` 的自动 build。 - **包状态。** `taskflow-dsl` 在 monorepo 的 `packages/taskflow-dsl`。纯 JSON 作者不需要它。npm 发布可能滞后;预览请用 workspace / 本地 path。`release/0.2.0` 线上版本号在正式 0.2.0 发版前可能仍是 `0.1.7`。 + **包状态。** `taskflow-dsl` 在 monorepo 的 `packages/taskflow-dsl`。纯 JSON 作者不需要它。本发布线 manifest 为 `0.2.0`;npm 在 `v0.2.0` 发布任务完成后更新——此前请用 workspace / 本地 path。 ## 工作流 From 5e4d5e765a5aa89ff7b3952324c27025dd350657 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 10 Jul 2026 13:35:40 +0800 Subject: [PATCH 38/51] fix(release): close 0.2.0 adversarial gaps Harden runtime replay, budgets, caching, graft ownership, cancellation, and event-kernel fallback semantics. Close the TypeScript DSL compiler and CLI surface, secure Grok and MCP delivery, and fail closed on release provenance and workflow trust boundaries. --- .github/workflows/ci.yml | 24 +- .github/workflows/deploy-website.yml | 10 +- .github/workflows/publish.yml | 30 +- CHANGELOG.md | 44 + README.md | 3 + docs/claude-mcp.md | 2 +- docs/grok-mcp.md | 50 +- docs/internal/claim-vs-impl-0.2.0.md | 31 +- docs/opencode-mcp.md | 2 +- docs/rfc-0.2.0-dsl-syntax.md | 6 +- package.json | 6 + .../plugin/skills/taskflow/configuration.md | 6 +- .../plugin/skills/taskflow/configuration.md | 6 +- .../plugin/skills/taskflow/configuration.md | 19 +- packages/grok-taskflow/src/mcp/server.ts | 6 +- packages/grok-taskflow/test/e2e-grok.mts | 82 ++ .../plugin/skills/taskflow/configuration.md | 6 +- .../skills/taskflow/configuration.md | 6 +- packages/taskflow-core/src/exec/driver.ts | 68 +- packages/taskflow-core/src/exec/events.ts | 4 + packages/taskflow-core/src/exec/fold.ts | 5 +- .../taskflow-core/src/exec/kernel-policy.ts | 81 +- packages/taskflow-core/src/exec/step-kinds.ts | 107 +- packages/taskflow-core/src/exec/step.ts | 136 +- packages/taskflow-core/src/flowir/compile.ts | 8 + packages/taskflow-core/src/flowir/phasefp.ts | 14 +- .../taskflow-core/src/host/runner-types.ts | 3 + packages/taskflow-core/src/replay.ts | 541 +++++++- packages/taskflow-core/src/runner-core.ts | 138 +- packages/taskflow-core/src/runtime.ts | 312 ++++- .../src/runtime/phases/expand.ts | 16 +- packages/taskflow-core/src/store.ts | 6 + packages/taskflow-core/src/trace.ts | 4 + .../test/cache-migration.test.ts | 27 +- .../taskflow-core/test/cache-peritem.test.ts | 3 +- .../taskflow-core/test/cache-phasefp.test.ts | 3 +- .../taskflow-core/test/exec-driver.test.ts | 2 +- .../test/exec-kernel-hardening.test.ts | 26 +- .../test/exec-kernel-parity.test.ts | 4 +- .../test/exec-kernel-s2-complete.test.ts | 3 +- .../taskflow-core/test/race-expand.test.ts | 8 +- packages/taskflow-core/test/replay.test.ts | 431 ++++++ .../taskflow-core/test/runner-process.test.ts | 73 ++ .../test/runtime-closure-0.2.0.test.ts | 1160 +++++++++++++++++ packages/taskflow-core/test/runtime.test.ts | 11 +- .../test/usage-accounting.test.ts | 96 ++ packages/taskflow-dsl/package.json | 3 +- packages/taskflow-dsl/src/build.ts | 51 +- packages/taskflow-dsl/src/build/erase/ast.ts | 30 +- .../taskflow-dsl/src/build/erase/context.ts | 56 +- .../src/build/erase/kinds/agent-script.ts | 2 + .../src/build/erase/kinds/approval.ts | 1 + .../src/build/erase/kinds/expand-flow.ts | 17 +- .../src/build/erase/kinds/gate-sugar.ts | 11 +- .../src/build/erase/kinds/gate.ts | 12 +- .../src/build/erase/kinds/loop.ts | 3 +- .../taskflow-dsl/src/build/erase/kinds/map.ts | 45 +- .../src/build/erase/kinds/parallel.ts | 10 + .../src/build/erase/kinds/race.ts | 10 + .../src/build/erase/kinds/reduce.ts | 39 +- .../src/build/erase/kinds/tournament.ts | 12 +- packages/taskflow-dsl/src/build/erase/opts.ts | 154 ++- .../taskflow-dsl/src/build/erase/pipeline.ts | 197 ++- .../taskflow-dsl/src/build/erase/templates.ts | 31 +- .../taskflow-dsl/src/build/erase/types.ts | 12 + packages/taskflow-dsl/src/check.ts | 5 +- packages/taskflow-dsl/src/cli.ts | 55 +- packages/taskflow-dsl/src/decompile.ts | 109 +- packages/taskflow-dsl/src/paths.ts | 41 +- packages/taskflow-dsl/src/runes.ts | 31 +- packages/taskflow-dsl/src/typecheck.ts | 7 +- .../test/closure-regressions.test.ts | 356 +++++ packages/taskflow-dsl/test/e2e-dist-cli.mts | 77 ++ .../taskflow-dsl/test/kinds-coverage.test.ts | 4 +- packages/taskflow-hosts/src/grok-runner.ts | 115 +- .../taskflow-hosts/test/grok-args.test.ts | 61 +- .../taskflow-hosts/test/grok-runner.test.ts | 11 +- .../test/publish-verification.test.ts | 132 ++ packages/taskflow-mcp-core/src/mcp/jsonrpc.ts | 163 ++- packages/taskflow-mcp-core/src/mcp/server.ts | 23 +- .../test/jsonrpc-cancellation.test.ts | 331 +++++ pnpm-lock.yaml | 19 +- pnpm-workspace.yaml | 2 - scripts/copy-readme.mjs | 1 + scripts/verify-published-package.d.mts | 13 + scripts/verify-published-package.mjs | 136 ++ skills-src/taskflow/configuration.md | 19 +- .../docs/en/guides/background-runs.mdx | 17 +- .../content/docs/en/guides/claude-code.mdx | 2 +- website/content/docs/en/guides/codex.mdx | 2 +- website/content/docs/en/guides/grok-build.mdx | 12 +- website/content/docs/en/guides/opencode.mdx | 2 +- .../docs/zh-cn/guides/background-runs.mdx | 6 +- .../content/docs/zh-cn/guides/claude-code.mdx | 2 +- .../content/docs/zh-cn/guides/grok-build.mdx | 12 +- .../content/docs/zh-cn/guides/opencode.mdx | 2 +- website/next.config.ts | 7 + website/package.json | 2 +- 98 files changed, 5512 insertions(+), 590 deletions(-) create mode 100644 packages/grok-taskflow/test/e2e-grok.mts create mode 100644 packages/taskflow-core/test/runtime-closure-0.2.0.test.ts create mode 100644 packages/taskflow-core/test/usage-accounting.test.ts create mode 100644 packages/taskflow-dsl/test/closure-regressions.test.ts create mode 100644 packages/taskflow-dsl/test/e2e-dist-cli.mts create mode 100644 packages/taskflow-hosts/test/publish-verification.test.ts create mode 100644 packages/taskflow-mcp-core/test/jsonrpc-cancellation.test.ts create mode 100644 scripts/verify-published-package.d.mts create mode 100644 scripts/verify-published-package.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f2b40..e07ee5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,13 +24,13 @@ jobs: matrix: node: ["22", "24"] steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 # pnpm via corepack — the version is pinned by the `packageManager` field # in package.json, so every contributor and CI run use the same pnpm. - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ matrix.node }} registry-url: "https://registry.npmjs.org" @@ -49,11 +49,11 @@ jobs: name: e2e (codex MCP, network-free) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" registry-url: "https://registry.npmjs.org" @@ -101,11 +101,11 @@ jobs: name: build (dist emit) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - - uses: actions/setup-node@v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" registry-url: "https://registry.npmjs.org" @@ -126,8 +126,8 @@ jobs: contents: read security-events: write steps: - - uses: actions/checkout@v7 - - uses: github/codeql-action/init@v4 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 with: languages: javascript-typescript - - uses: github/codeql-action/analyze@v4 + - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4 diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 6420ee5..3a595c9 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -26,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 with: # Full history so the lastModified remark plugin can read accurate # git timestamps for every docs page (shallow clone would leave @@ -37,10 +37,10 @@ jobs: # in the root package.json, so CI uses the same pnpm as local dev. The # website is a pnpm workspace member (pnpm-workspace.yaml), so one # `pnpm install` at the root installs every package including the website. - - uses: pnpm/action-setup@v6 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: 22 cache: pnpm @@ -55,7 +55,7 @@ jobs: TASKFLOW_BASE_PATH: /taskflow - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5 with: path: website/dist @@ -68,4 +68,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e9b4e59..d8aa2c4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -15,13 +15,13 @@ jobs: publish: runs-on: ubuntu-latest permissions: - contents: write # create GitHub Release + contents: read # checkout source for build + publish id-token: write # npm provenance steps: - - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: "22" registry-url: "https://registry.npmjs.org" @@ -97,17 +97,20 @@ jobs: - name: Publish workspaces to npmjs.com # Order matters: taskflow-core first (the adapters depend on it). - # Idempotent: if this exact version is already on npm (e.g. a re-run or a - # partial earlier publish), skip that package instead of failing the job. + # Idempotent but fail-closed: an existing version is skipped only after + # its trusted npm owner, GitHub/SLSA provenance, source commit, and exact + # locally-packed tarball integrity have all been verified. A squatter + # pre-publishing name@version must never be mistaken for our release. run: | - set -e + set -euo pipefail publish_one() { local pkg="$1" local name version name="$(node -p "require('./packages/$pkg/package.json').name")" version="$(node -p "require('./packages/$pkg/package.json').version")" if pnpm view "$name@$version" version >/dev/null 2>&1; then - echo "::notice::$name@$version already published — skipping" + node scripts/verify-published-package.mjs "packages/$pkg" + echo "::notice::$name@$version already published and verified — skipping" else echo "Publishing $name@$version" pnpm publish --filter "$pkg" --provenance --access public --no-git-checks @@ -124,6 +127,17 @@ jobs: publish_one grok-taskflow env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_TRUSTED_OWNERS: "heggria,muyun" + PUBLISH_REPOSITORY_URL: "https://github.com/heggria/taskflow" + + release: + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write # create GitHub Release + + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Create GitHub Release run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ef358..c988862 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,50 @@ All notable changes to taskflow are documented here. This project follows [Keep - **North-star slogan:** `compiled · resumable · incremental · replayable-for-what-if` (drops Qwik "not replayable" collision with deterministic replay). - **S3 replay surface:** `taskflow_replay` MCP tool; pi `action=replay` and `/tf replay [--threshold phase=n] [--budget-usd n]`; golden trace fixture under `test/fixtures/`. +### Fixed +- **MCP cancellation is now real and end-to-end.** The dependency-free stdio + transport dispatches requests concurrently, handles + `notifications/cancelled`, and propagates a per-request `AbortSignal` through + `taskflow_run` into the runtime and active host subprocess. A cancelled tool + call returns JSON-RPC `-32800` instead of leaving hidden background work. + Input disconnect aborts active requests and notifications; duplicate request + ids abort the original controller instead of overwriting it. Completed host + subprocesses remove their abort listeners, avoiding long-DAG listener leaks. + Transport shutdown is grace-bounded and suppresses late writes; explicit + cancellation also races non-cooperative handlers and observes any late + rejection, so neither a hung promise nor an unhandled rejection can wedge the + MCP process. Asynchronous stdio `error` events (including output `EPIPE`) use + the same bounded teardown, abort active work, suppress late responses, and + remove their transport listeners after settling. +- **Grok thinking overrides now work.** Phase → agent → global thinking is + mapped to `grok --reasoning-effort` (`off` → `none`). +- **Grok budgets no longer fail open.** Grok 0.2.93 streaming JSON contains no + token/cost usage, so the Grok MCP adapter explicitly rejects flows declaring + `budget` rather than silently reporting zero and ignoring the ceiling. The + runtime capability check applies at every execution boundary, including + inline object/string flows, saved flows, and nested/graft `expand` fragments. + +### Security +- **Grok read-only phases are kernel-enforced and defence-in-depth.** They now + use `--sandbox read-only`, a known-good file-read allowlist, independent + mutator/MCP deny rules, and disabled subagents. `web_search` / `web_fetch` are + omitted from the Grok 0.2.93 allowlist because that CLI version can label + them unmappable and restore the full toolset. A live executor E2E verifies a + write attempt is blocked even when the workspace is under `/tmp`. +- **Grok mutating/default phases are workspace-sandboxed.** They keep + `--always-approve` for non-interactive execution but now also pass + `--sandbox workspace`, confining writes to the phase cwd plus Grok's + documented temp/session paths. A live executor E2E proves an in-workspace + write succeeds while an outside marker is rejected. +- **Release reruns no longer blindly trust an existing npm version.** Before + skipping, the publish workflow verifies a trusted npm owner, SLSA provenance + from this repository/workflow/tag/commit, and exact locally-packed tarball + integrity. A preclaimed `name@version` now fails the release. Every + third-party action across CI, Pages, and publish/release workflows is pinned + to the official major tag's full commit SHA; npm publish has only + `contents: read` and `id-token: write`, while GitHub Release creation is + isolated in a dependent job with only `contents: write`. + ## [0.1.8] — 2026-07-09 ### Fixed diff --git a/README.md b/README.md index 5bcda68..5f13ea2 100644 --- a/README.md +++ b/README.md @@ -928,6 +928,9 @@ Our `self-improve` flow is a 10-phase DAG — it audits the codebase, patches de ## Status & limits +**Compatibility baseline from v0.1.8:** interpolation placeholders in phase +`cwd` are rejected; the release dependency/security sweep is also retained. + **v0.2.0** (this monorepo release line — npm after `v0.2.0` tag) — adds the `taskflow-dsl` TypeScript frontend, Grok Build delivery package, 12 phase kinds with `race`/`expand`, FlowIR content hashes, event-kernel trace/fold, and offline replay. **v0.1.7** — **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 nine 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): diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index 1c6d12a..3649663 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -138,7 +138,7 @@ Inside a Claude Code session, just ask — Claude will call the tools: ``` > **Note on approvals.** MCP-driven runs are non-interactive, so an `approval` -> phase **auto-rejects** (fail-open). Prefer a `gate` (agent review) in flows +> phase **auto-rejects** (fail-closed for the approval decision). Prefer a `gate` (agent review) in flows > you run through the `taskflow_*` tools; use `approval` only in flows a human > runs interactively. diff --git a/docs/grok-mcp.md b/docs/grok-mcp.md index 6aaa14e..326895b 100644 --- a/docs/grok-mcp.md +++ b/docs/grok-mcp.md @@ -84,22 +84,46 @@ 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: +The runner always selects a Grok kernel sandbox profile and 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"`). + `search_replace` in the phase/agent `tools`) → a known-good + kernel-enforced `--sandbox read-only`, a + `--tools read_file,grep,list_dir` allowlist, an independent + `--disallowed-tools` mutator denylist, `--deny Bash/Edit/Write/MCPTool`, and + `--no-subagents`. `--always-approve` then applies only to the surviving + read-only tools. Grok 0.2.93 treats `web_search` / `web_fetch` as unmappable + allowlist entries and can restore the full toolset, so those two tools are + deliberately unavailable in a read-only phase until the CLI fixes that + fail-open behavior. +- **Mutating phase** (or no whitelist) → kernel-enforced `--sandbox workspace` + plus `--always-approve`. All tools remain available, but filesystem writes + are confined to the phase cwd plus Grok's documented temp/session paths + (`/tmp`, `/var/tmp`, platform temp directories, and `~/.grok/`). Reads and + network remain available. Prefer `cwd: "worktree"` for disposable changes. + +Both sandbox profiles cover the whole Grok process and spawned tools on +macOS/Linux. The independent built-in/MCP denies are also required for +read-only phases because that profile deliberately permits session writes under +temporary directories; together they keep a read-only phase non-mutating even +when its workspace itself is under `/tmp`. A live executor E2E additionally +proves that the workspace profile permits an in-cwd write and rejects a marker +outside the cwd and documented exceptions. 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. +Effective Taskflow thinking is passed as `--reasoning-effort` (`off` maps to +`none`). + +### Budget limitation + +Grok 0.2.93 does not include token or cost usage in its `streaming-json` +events. Consequently the Grok-bound MCP server **refuses any flow that declares +`budget`**; accepting it would advertise a ceiling the runtime cannot enforce. +Unbudgeted flows still run normally. Use another host when a hard token/USD +ceiling is required. ## Long-running flows and the tool-call timeout @@ -107,7 +131,9 @@ suffixes (`:xhigh`) are dropped so Grok uses its configured default. 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`. +`taskflow_run` calls. MCP does not expose Pi's detached-run mode. If the client +sends `notifications/cancelled` (including after a tool timeout), the server +aborts the active DAG and subagent instead of leaving hidden background work. ## Alternative: register the MCP server manually @@ -171,7 +197,7 @@ grok -p "Call taskflow_verify on this define (do not run it): ``` > **Note on approvals.** MCP-driven runs are non-interactive, so an `approval` -> phase **auto-rejects** (fail-open). Prefer a `gate` (agent review) in flows +> phase **auto-rejects** (fail-closed for the approval decision). Prefer a `gate` (agent review) in flows > you run through the `taskflow_*` tools; use `approval` only in flows a human > runs interactively. diff --git a/docs/internal/claim-vs-impl-0.2.0.md b/docs/internal/claim-vs-impl-0.2.0.md index c2d6904..5d38eea 100644 --- a/docs/internal/claim-vs-impl-0.2.0.md +++ b/docs/internal/claim-vs-impl-0.2.0.md @@ -1,6 +1,7 @@ # Claim vs implementation — verification log (`release/0.2.0`) -> Last full pass: 2026-07-09 · Adversarial closure pass (PR-ready) +> Last updated: 2026-07-10 · Local closure gates pass on the merged tree; +> do not publish until the release PR/CI passes and the tag job owns all names. > Purpose: single ledger so marketing/RFCs/skills do not outrun the code. ## Verified true (hard claims OK) @@ -16,6 +17,9 @@ | MCP 12 tools | `taskflow-mcp-core` server tool list | | Five host delivery packages | pi/codex/claude/opencode/grok | | Toolchain = TypeScript AST (not ts-morph) | `taskflow-dsl` depends on `typescript` only | +| MCP request cancellation | concurrent stdio dispatch; `notifications/cancelled` → `AbortSignal` → runtime/host child | +| Grok sandbox policy | read-only phases: kernel `read-only` + known-good allowlist + independent mutator denies; mutating/default phases: kernel `workspace`; live Grok 0.2.93 E2E proves read-only denial plus in-cwd allow/outside-cwd deny | +| Existing npm version verification | trusted owner + SLSA/GitHub provenance + tag/commit + exact tarball integrity before skip | ## Honest / qualified @@ -27,8 +31,9 @@ | Event kernel “complete” | Complete for **kernel-eligible** kinds/features; not race/expand; not score/retry/expect/reflexion/cross-run cache/shareContext; **nested** `flow` re-runs `canUseEventKernel` (fail-closed) | | Multi-host DSL | Hosts run **Taskflow JSON**; `.tf.ts` requires prior `taskflow-dsl build` | | Decompile | Semantic, not literal round-trip | -| Test count | ~**1400+** unit tests in ~**95** `*.test.ts` files (regenerate badge on release) | +| Test count | **1500+** unit tests in **100** `*.test.ts` files (regenerate the exact count on release) | | Package count | **9** under `packages/` + `website` | +| Grok budgets | Grok 0.2.93 reports no usage; Grok MCP explicitly rejects any flow declaring `budget` | ## Explicitly not shipped @@ -74,16 +79,36 @@ loop multi-body · route · compensate/saga · watch · experimental C-track run 27. Docs honesty: EN/zh README monorepo-vs-npm banner; zh phase/package parity; website race first-success; example description; publish.yml nine packages; CONTRIBUTING/DECISIONS counts. 28. CI includes `test:e2e-grok-mcp` (already wired). +### Pass 6 (release-closure hardening) +29. Grok 0.2.93 read-only execution no longer includes unmappable web ids; mutator deny rules remain even if allowlist handling regresses. +30. Grok thinking maps to `--reasoning-effort`; its unavailable usage accounting rejects budgeted MCP runs fail-closed. +31. MCP stdio dispatch is concurrent and propagates `notifications/cancelled` through a per-request `AbortSignal`. +32. Existing npm versions are never blindly skipped: owner, provenance repository/workflow/ref/commit, and local tarball integrity must match. +33. Added a live Grok executor E2E (`pnpm run test:e2e-grok`) in addition to the network-free Grok MCP E2E. +34. Corrected detached-run documentation: detach is Pi-only; MCP cancellation aborts rather than creating hidden background work. + +### Pass 7 (cross-adversarial terminal closure) +35. Runtime/kernel/replay/cache/trace/graft/resume semantics were challenged with executable counterexamples; nested and supervision-tree budgets now use remaining caps, replay fails safe on incomplete/legacy graph evidence, and graft ownership/usage survives definition evolution and collisions. +36. DSL compiler/decompiler/CLI is fail-closed for unsupported dynamic syntax, round-trips all 12 phase kinds, defaults `check` to TypeScript diagnostics, and passes clean tarball/install E2E. +37. MCP cancellation tears down the full host process tree; stdio disconnect/error paths are bounded and suppress late work/responses. +38. Grok read-only and workspace-mutating policies have live 0.2.93 enforcement probes; unavailable usage accounting rejects every nested budget path before spawn. +39. All GitHub Actions are pinned to verified full SHAs; npm publish and GitHub Release use separate least-privilege jobs; published-version reruns verify provenance and exact tarball integrity. +40. Root typecheck, full unit suite, all package builds, website static export, DSL install E2E, four host MCP E2Es, built-dist comprehensive MCP E2E, and live Codex/OpenCode/Grok executors pass locally. + ## Still open (not claimed as done) - Formal **0.2.0 npm publish** after merge + `v0.2.0` tag; pins already match. - S5 kernel default ON + flagship $ demo seal → plan: `docs/internal/s5-kernel-default-on-plan.md`. -- Live host **executor** e2e as release gate (MCP e2e is CI; live model stays manual). +- Live Claude executor E2E is still an external release-environment gate: the current local Claude route returns HTTP 403 from `api.ohmyrouter.com`. Codex, OpenCode, and Grok live executors pass; all four MCP adapters pass without live model access. ## Re-verify commands ```bash pnpm run test:dsl +pnpm run test:hosts +node --conditions=development --experimental-strip-types --test \ + 'packages/taskflow-mcp-core/test/*.test.ts' +pnpm run test:e2e-grok node --conditions=development --experimental-strip-types --test \ packages/taskflow-core/test/race-expand.test.ts \ packages/taskflow-core/test/script.test.ts diff --git a/docs/opencode-mcp.md b/docs/opencode-mcp.md index 6cd9b53..55b38ce 100644 --- a/docs/opencode-mcp.md +++ b/docs/opencode-mcp.md @@ -123,7 +123,7 @@ Inside an OpenCode session, just ask — OpenCode will call the tools: ``` > **Note on approvals.** MCP-driven runs are non-interactive, so an `approval` -> phase **auto-rejects** (fail-open). Prefer a `gate` (agent review) in flows +> phase **auto-rejects** (fail-closed for the approval decision). Prefer a `gate` (agent review) in flows > you run through the `taskflow_*` tools; use `approval` only in flows a human > runs interactively. diff --git a/docs/rfc-0.2.0-dsl-syntax.md b/docs/rfc-0.2.0-dsl-syntax.md index 2e284a5..02e89b9 100644 --- a/docs/rfc-0.2.0-dsl-syntax.md +++ b/docs/rfc-0.2.0-dsl-syntax.md @@ -162,9 +162,11 @@ const audits = map(discover, (item) => agent(`Audit ${item.route}`, { agent: "an ### 4.3 `parallel` ```ts -const [a, b] = parallel([ agent("auth"), agent("perf") ], { concurrency: 2 }); +const [a, b] = parallel([ agent("auth"), agent("perf") ]); ``` -**v2 澄清(review FEASIBILITY #8):** 解构 `[a,b]` 是**编译期**的 —— 编译器把 `parallel([...])` 转成一个 parallel phase + 给每个 branch 一个合成 id;`a`/`b` 是这些 branch 的符号引用(可被下游 `a.output` 引用,编译器转成对应 branch 的占位符)。不是运行时多返回值。 +**v2 澄清(review FEASIBILITY #8):** 解构 `[a,b]` 是**编译期**的 —— 0.2.0 编译器把每个 branch 降低为一个独立 agent phase;`a`/`b` 是真实 phase handle(可被下游 `a.output` 引用)。不是运行时多返回值。 + +0.2.0 的 erase 实现将解构形式降低为多个独立 agent phase,因此解构形式不接受第二个 options 参数(无法诚实保留 group-level `concurrency` / `dependsOn`);需要这些选项时应绑定为单个 `const group = parallel([...], opts)`。 ### 4.4 `gate`(三形态) ```ts diff --git a/package.json b/package.json index 4539022..97ba73a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "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-grok": "node --conditions=development --experimental-strip-types packages/grok-taskflow/test/e2e-grok.mts", "test:e2e-grok-mcp": "node --conditions=development --experimental-strip-types packages/grok-taskflow/test/e2e-grok-mcp.mts" }, "devDependencies": { @@ -38,5 +39,10 @@ "@types/node": "^26", "typebox": "^1.3.6", "typescript": "^7.0.2" + }, + "pnpm": { + "overrides": { + "postcss": "^8.5.10" + } } } diff --git a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md index 0d6c0e0..289cc01 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/configuration.md @@ -426,9 +426,9 @@ node --conditions=development --experimental-strip-types \ # edit audit.tf.ts node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts check audit.tf.ts -# optional full tsc Program pass: +# Fast rune/static-only pass (skip the default full tsc Program check): node --conditions=development --experimental-strip-types \ - packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --no-typecheck node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both # → audit.taskflow.json (+ audit.flowir.json) @@ -438,7 +438,7 @@ node --conditions=development --experimental-strip-types \ | Command | Purpose | |---------|---------| | `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | -| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `check ` | Erase + `validateTaskflow` + tsc (use `--no-typecheck` for a faster static-only pass) | | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | diff --git a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md index a822484..c56c3a9 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/configuration.md @@ -423,9 +423,9 @@ node --conditions=development --experimental-strip-types \ # edit audit.tf.ts node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts check audit.tf.ts -# optional full tsc Program pass: +# Fast rune/static-only pass (skip the default full tsc Program check): node --conditions=development --experimental-strip-types \ - packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --no-typecheck node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both # → audit.taskflow.json (+ audit.flowir.json) @@ -435,7 +435,7 @@ node --conditions=development --experimental-strip-types \ | Command | Purpose | |---------|---------| | `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | -| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `check ` | Erase + `validateTaskflow` + tsc (use `--no-typecheck` for a faster static-only pass) | | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | diff --git a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md index c5c6f74..e57bea7 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/configuration.md @@ -221,9 +221,16 @@ Notes: - 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). + configured default. Effective thinking maps to `--reasoning-effort` (`off` + → `none`). Read-only phases get Grok's kernel-enforced `--sandbox + read-only`, a known-good `--tools + read_file,grep,list_dir` allowlist plus independent mutator/MCP deny rules and + no subagents; web tools are omitted because Grok 0.2.93 can fail open on those + allowlist ids. `--always-approve` then applies only to surviving tools. Grok + mutating/no-whitelist phases use kernel-enforced `--sandbox workspace` plus + `--always-approve`, keeping writes inside the phase cwd and Grok's documented + temp/session paths. Grok 0.2.93 reports no usage, so its MCP adapter rejects + flows with `budget` rather than pretending to enforce an unobservable ceiling. - The agent's markdown body becomes the subagent's appended system prompt. --- @@ -426,9 +433,9 @@ node --conditions=development --experimental-strip-types \ # edit audit.tf.ts node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts check audit.tf.ts -# optional full tsc Program pass: +# Fast rune/static-only pass (skip the default full tsc Program check): node --conditions=development --experimental-strip-types \ - packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --no-typecheck node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both # → audit.taskflow.json (+ audit.flowir.json) @@ -438,7 +445,7 @@ node --conditions=development --experimental-strip-types \ | Command | Purpose | |---------|---------| | `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | -| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `check ` | Erase + `validateTaskflow` + tsc (use `--no-typecheck` for a faster static-only pass) | | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | diff --git a/packages/grok-taskflow/src/mcp/server.ts b/packages/grok-taskflow/src/mcp/server.ts index 44d5494..fbdff32 100644 --- a/packages/grok-taskflow/src/mcp/server.ts +++ b/packages/grok-taskflow/src/mcp/server.ts @@ -12,11 +12,13 @@ import { makeToolHandlers as coreMakeToolHandlers, startMcpServer as coreStartMcpServer, } from "taskflow-mcp-core/server"; -import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; +import type { RpcContext, 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> { +export function makeToolHandlers( + cwd: string, +): Record, context?: RpcContext) => Promise> { return coreMakeToolHandlers(cwd, grokSubagentRunner); } diff --git a/packages/grok-taskflow/test/e2e-grok.mts b/packages/grok-taskflow/test/e2e-grok.mts new file mode 100644 index 0000000..d2167b6 --- /dev/null +++ b/packages/grok-taskflow/test/e2e-grok.mts @@ -0,0 +1,82 @@ +/** + * Live Grok executor E2E. Requires an authenticated `grok` CLI. + * + * Exercises the real taskflow-hosts runner, including Grok 0.2.93's tool + * allowlist parser. The read-only policy must neither trigger the known + * "unmappable -> full toolset" fallback nor permit a workspace write. + */ + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; +import { join } from "node:path"; +import { runGrokAgentTask } from "taskflow-hosts/grok"; +import type { AgentConfig } from "taskflow-core"; + +// Grok's read-only sandbox intentionally permits /tmp for session internals. +// Keeping the test workspace there proves the independent tool/MCP deny rules +// also prevent mutation when the OS sandbox's temp exemption applies. +const readOnlyCwd = await mkdtemp(join(tmpdir(), "taskflow-grok-read-only-")); +const readOnlyMarker = join(readOnlyCwd, "MUST_NOT_EXIST.txt"); +const mutatingCwd = await mkdtemp(join(homedir(), ".taskflow-grok-workspace-")); +const workspaceMarker = join(mutatingCwd, "WORKSPACE_WRITE_OK.txt"); +const outsideMarker = join(homedir(), `.taskflow-grok-outside-${randomUUID()}.txt`); +const agents: AgentConfig[] = [ + { + name: "read-only-probe", + description: "live read-only permission probe", + tools: ["read", "grep", "glob", "web_search", "web_fetch"], + thinking: "low", + systemPrompt: "Follow the task exactly. Do not claim a tool succeeded unless it actually did.", + source: "project", + filePath: "(e2e)", + }, + { + name: "mutating-probe", + description: "live workspace sandbox probe", + tools: ["write", "bash"], + thinking: "low", + systemPrompt: "Follow the task exactly. Attempt each requested write and report real tool outcomes.", + source: "project", + filePath: "(e2e)", + }, +]; + +try { + const readOnlyResult = await runGrokAgentTask( + readOnlyCwd, + agents, + "read-only-probe", + `Try to create the file ${readOnlyMarker} using any available tool. Then report whether it exists.`, + { idleTimeoutMs: 90_000 }, + ); + assert.equal(readOnlyResult.exitCode, 0, readOnlyResult.stderr || readOnlyResult.errorMessage); + assert.ok(readOnlyResult.output.trim(), "live read-only Grok run returned no final text"); + assert.equal(existsSync(readOnlyMarker), false, "read-only Grok phase mutated the workspace"); + assert.doesNotMatch( + readOnlyResult.stderr, + /tool allowlist had unmappable entries|keeping full grok toolset/i, + "Grok rejected the allowlist and restored its full toolset", + ); + + const mutatingResult = await runGrokAgentTask( + mutatingCwd, + agents, + "mutating-probe", + `First create ${workspaceMarker} with content OK. Then attempt to create ${outsideMarker} with content MUST_NOT_WRITE. Check both paths and report the real results.`, + { idleTimeoutMs: 90_000 }, + ); + assert.equal(mutatingResult.exitCode, 0, mutatingResult.stderr || mutatingResult.errorMessage); + assert.ok(mutatingResult.output.trim(), "live mutating Grok run returned no final text"); + assert.equal(existsSync(workspaceMarker), true, "workspace sandbox blocked an in-workspace write"); + assert.equal(existsSync(outsideMarker), false, "workspace sandbox allowed a write outside cwd"); + console.log( + `e2e-grok: ok (${mutatingResult.model ?? readOnlyResult.model ?? "default model"}; read-only + workspace policies enforced)`, + ); +} finally { + await rm(readOnlyCwd, { recursive: true, force: true }); + await rm(mutatingCwd, { recursive: true, force: true }); + await rm(outsideMarker, { force: true }); +} diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md index 2fb71c1..3103e70 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/configuration.md @@ -427,9 +427,9 @@ node --conditions=development --experimental-strip-types \ # edit audit.tf.ts node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts check audit.tf.ts -# optional full tsc Program pass: +# Fast rune/static-only pass (skip the default full tsc Program check): node --conditions=development --experimental-strip-types \ - packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --no-typecheck node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both # → audit.taskflow.json (+ audit.flowir.json) @@ -439,7 +439,7 @@ node --conditions=development --experimental-strip-types \ | Command | Purpose | |---------|---------| | `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | -| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `check ` | Erase + `validateTaskflow` + tsc (use `--no-typecheck` for a faster static-only pass) | | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | diff --git a/packages/pi-taskflow/skills/taskflow/configuration.md b/packages/pi-taskflow/skills/taskflow/configuration.md index c19c35f..6d39086 100644 --- a/packages/pi-taskflow/skills/taskflow/configuration.md +++ b/packages/pi-taskflow/skills/taskflow/configuration.md @@ -430,9 +430,9 @@ node --conditions=development --experimental-strip-types \ # edit audit.tf.ts node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts check audit.tf.ts -# optional full tsc Program pass: +# Fast rune/static-only pass (skip the default full tsc Program check): node --conditions=development --experimental-strip-types \ - packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --no-typecheck node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both # → audit.taskflow.json (+ audit.flowir.json) @@ -442,7 +442,7 @@ node --conditions=development --experimental-strip-types \ | Command | Purpose | |---------|---------| | `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | -| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `check ` | Erase + `validateTaskflow` + tsc (use `--no-typecheck` for a faster static-only pass) | | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index a1ef78f..ee81699 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -26,7 +26,7 @@ import { foldEvents } from "./fold.ts"; import { EVENT_SCHEMA_VERSION, type Event } from "./events.ts"; import type { AgentConfig } from "../agents.ts"; import type { UsageStats } from "../usage.ts"; -import { depsSatisfied, kernelUnsupportedReason } from "./kernel-policy.ts"; +import { clampSubFlowBudget, depsSatisfied, kernelUnsupportedReason } from "./kernel-policy.ts"; export { EVENT_KERNEL_PHASE_TYPES, kernelUnsupportedReason }; @@ -36,6 +36,7 @@ export interface EventKernelDeps { runTask: RunTaskFn; signal?: AbortSignal; globalThinking?: string; + usageAccounting?: "available" | "unavailable"; trace?: TraceSink; persist?: (state: RunState) => void; onProgress?: (state: RunState) => void; @@ -53,13 +54,45 @@ export interface EventKernelResult { } /** True when types are known AND no advanced features force imperative fall-back. */ -export function canUseEventKernel(def: Taskflow): boolean { +export function canUseEventKernel( + def: Taskflow, + loadFlow?: (name: string) => Taskflow | undefined, + seen: Set = new Set(), +): boolean { + if (seen.has(def.name)) return false; + const nextSeen = new Set(seen).add(def.name); const typesOk = (def.phases ?? []).every((p) => { const t = p.type ?? "agent"; return (EVENT_KERNEL_PHASE_TYPES as readonly string[]).includes(t); }); if (!typesOk) return false; - return kernelUnsupportedReason(def) === undefined; + if (kernelUnsupportedReason(def) !== undefined) return false; + for (const phase of def.phases ?? []) { + if ((phase.type ?? "agent") !== "flow") continue; + let child: Taskflow | undefined; + const raw = (phase as { def?: unknown }).def; + if (raw !== undefined) { + if (typeof raw === "string") { + // Interpolated/runtime-generated definitions cannot be admitted statically. + if (/\{[^}]+\}/.test(raw)) return false; + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) child = { name: `${phase.id}-inline`, phases: parsed as Taskflow["phases"] }; + else if (parsed && typeof parsed === "object" && Array.isArray((parsed as Taskflow).phases)) child = parsed as Taskflow; + } catch { + return false; + } + } else if (Array.isArray(raw)) { + child = { name: `${phase.id}-inline`, phases: raw as Taskflow["phases"] }; + } else if (raw && typeof raw === "object" && Array.isArray((raw as Taskflow).phases)) { + child = raw as Taskflow; + } + } else if (phase.use && loadFlow) { + child = loadFlow(phase.use); + } + if (!child || !canUseEventKernel(child, loadFlow, nextSeen)) return false; + } + return true; } export function eventKernelEnabled(deps: { eventKernel?: boolean }): boolean { @@ -147,11 +180,22 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr args: Record; stack: string[]; }): Promise => { + if (deps.usageAccounting === "unavailable" && (opts.def.budget || def.budget)) { + return { + finalOutput: `Usage accounting is unavailable; refusing budgeted nested flow '${opts.def.name}'`, + ok: false, + usage: emptyUsage(), + events: [], + blocked: false, + }; + } + const parentSpent = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage())); + const effectiveDef = clampSubFlowBudget(opts.def, def.budget, parentSpent); // Re-admit nested defs — parent admission must not smuggle race/expand // or score/retry/… into the kernel path (silent semantic drift). - if (!canUseEventKernel(opts.def)) { + if (!canUseEventKernel(effectiveDef, deps.loadFlow)) { const reason = - kernelUnsupportedReason(opts.def) ?? + kernelUnsupportedReason(effectiveDef) ?? "nested flow contains phase kinds or features the event kernel cannot execute"; return { finalOutput: `Nested flow rejected by event kernel: ${reason}`, @@ -163,9 +207,9 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr } const childState: RunState = { // No `/` — validateRunId rejects path separators if ever persisted. - runId: `${state.runId}-n-${opts.def.name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40)}`, - flowName: opts.def.name, - def: opts.def, + runId: `${state.runId}-n-${effectiveDef.name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(0, 40)}`, + flowName: effectiveDef.name, + def: effectiveDef, args: opts.args, status: "running", phases: {}, @@ -175,6 +219,10 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr }; const child = await runEventKernel(childState, { ...deps, + // A trace file belongs to exactly one runId. Nested flow events are + // intentionally isolated; the parent flow phase is marked unreplayable + // by the imperative path rather than polluting the parent's event log. + trace: undefined, _stack: opts.stack, }); const childErr = Object.values(child.state.phases) @@ -205,7 +253,6 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr if (deps.signal?.aborted) break; for (const phase of layer) { if (deps.signal?.aborted) break; - let skipReason: string | undefined; if (gateBlocked) skipReason = `Gate blocked${gateReason ? `: ${gateReason}` : ""}`; else if (budgetBlocked) skipReason = `Budget exceeded${budgetReason ? `: ${budgetReason}` : ""}`; @@ -278,6 +325,7 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr startedAt, endedAt: Date.now(), usage: result.usage ?? emptyUsage(), + attempts: result.attempts, gate: result.gate, approval: result.approval, ...(result.status === "timedOut" ? { timedOut: true as const } : {}), @@ -307,6 +355,8 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr }; allEvents.push(be); safeTraceEmit(deps, be); + // stepPhase already flushed this phase's lifecycle batch. + safeTraceFlush(deps, phase.id); } try { deps.persist?.(state); diff --git a/packages/taskflow-core/src/exec/events.ts b/packages/taskflow-core/src/exec/events.ts index b3095db..5f2784e 100644 --- a/packages/taskflow-core/src/exec/events.ts +++ b/packages/taskflow-core/src/exec/events.ts @@ -89,6 +89,10 @@ export function upgradeTraceEvent(old: Record): Event { ) ? (old.kind as EventKind) : "phase-start", + dependencies: Array.isArray(old.dependencies) + ? old.dependencies.filter((x): x is string => typeof x === "string") + : undefined, + optional: old.optional === true, input: old.input as Event["input"], output: old.output as Event["output"], decision: old.decision as EventDecision | undefined, diff --git a/packages/taskflow-core/src/exec/fold.ts b/packages/taskflow-core/src/exec/fold.ts index 9ef50c6..5a563eb 100644 --- a/packages/taskflow-core/src/exec/fold.ts +++ b/packages/taskflow-core/src/exec/fold.ts @@ -125,7 +125,10 @@ export function foldEvents(events: readonly Event[]): FoldedRun { p.status = "skipped"; } if (ev.decision.type === "budget-hit") { - p.status = "skipped"; + // A budget decision can be emitted either on the phase whose + // completion crossed the cap (already terminal: keep it done) or + // on a later phase that was prevented from running. + if (p.status === "pending" || p.status === "running") p.status = "skipped"; p.error = ev.decision.reason ?? ev.decision.value; } } diff --git a/packages/taskflow-core/src/exec/kernel-policy.ts b/packages/taskflow-core/src/exec/kernel-policy.ts index fc882f9..48eb9ba 100644 --- a/packages/taskflow-core/src/exec/kernel-policy.ts +++ b/packages/taskflow-core/src/exec/kernel-policy.ts @@ -4,7 +4,10 @@ */ import type { Budget, Phase, Taskflow } from "../schema.ts"; -import { dependenciesOf } from "../schema.ts"; +import { dependenciesOf, topoLayers } from "../schema.ts"; +import { emptyUsage, type UsageStats } from "../usage.ts"; +import type { RunState } from "../store.ts"; +import { overBudget } from "../deterministic.ts"; /** * If the definition needs imperative-only features, return a short reason. @@ -42,11 +45,43 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { if (p.shareContext === true || def.contextSharing === true) { return `phase '${id}': Shared Context Tree requires the imperative runtime`; } - // Workspace isolation keywords are imperative-only today. + if (p.context && p.context.length > 0) { + return `phase '${id}': context pre-read requires the imperative runtime`; + } + // Every phase-local cwd (literal or isolated keyword) is imperative-only. + // The kernel currently has only one flow-level cwd; accepting a literal + // override would silently run in the wrong workspace. const cwd = p.cwd; - if (typeof cwd === "string" && (cwd === "temp" || cwd === "dedicated" || cwd === "worktree")) { + if (typeof cwd === "string") { return `phase '${id}': workspace cwd '${cwd}' requires the imperative runtime`; } + if ((p.type ?? "agent") === "script" && p.input !== undefined) { + return `phase '${id}': script stdin input requires the imperative runtime`; + } + if ((p.type ?? "agent") === "script" && Array.isArray(p.run) && p.run.some((arg) => /\{[^}]+\}/.test(arg))) { + return `phase '${id}': interpolated script argv requires the imperative runtime`; + } + } + // The imperative scheduler runs independent phases concurrently. Until the + // event driver has an atomic layer commit, admit only linear layers; this + // prevents a completed gate/budget decision from incorrectly skipping an + // independent sibling that should already have been in flight. + const layers = topoLayers(def.phases ?? []); + if (layers.some((layer) => layer.length > 1)) { + return "concurrent DAG layers require the imperative runtime"; + } + // A fan-out budget guard must stop spawning items as spend accumulates. The + // kernel aggregates only after the whole node today, so budgeted fan-out is + // deliberately routed to the imperative implementation. + if (def.budget && (def.phases ?? []).some((p) => p.type === "map" || p.type === "parallel")) { + return "budgeted fan-out requires the imperative runtime"; + } + // Loop/tournament perform multiple logical calls inside one uncommitted phase. + // Until their handlers expose phase-local cumulative usage to every call, + // route budgeted variants to the imperative scheduler. Single-call advanced + // kinds (gate/reduce) use kernelAttemptsOverBudget between retries. + if (def.budget && (def.phases ?? []).some((p) => p.type === "loop" || p.type === "tournament")) { + return "budgeted multi-call advanced phases require the imperative runtime"; } return undefined; } @@ -74,13 +109,22 @@ export function depsSatisfied( }; } -/** Clamp child sub-flow budget so it cannot raise the parent cap. */ -export function clampSubFlowBudget(sub: Taskflow, parentBudget: Budget | undefined): Taskflow { +/** Clamp child budget to the parent's remaining run-wide allowance. */ +export function clampSubFlowBudget( + sub: Taskflow, + parentBudget: Budget | undefined, + spent: UsageStats = emptyUsage(), +): Taskflow { if (!parentBudget) return sub; const child = sub.budget; + const remainingUSD = + parentBudget.maxUSD === undefined ? Infinity : Math.max(0, parentBudget.maxUSD - spent.cost); + const spentTokens = spent.input + spent.output; + const remainingTokens = + parentBudget.maxTokens === undefined ? Infinity : Math.max(0, parentBudget.maxTokens - spentTokens); const clamped: Budget = { - maxUSD: Math.min(child?.maxUSD ?? Infinity, parentBudget.maxUSD ?? Infinity), - maxTokens: Math.min(child?.maxTokens ?? Infinity, parentBudget.maxTokens ?? Infinity), + maxUSD: Math.min(child?.maxUSD ?? Infinity, remainingUSD), + maxTokens: Math.min(child?.maxTokens ?? Infinity, remainingTokens), }; const budget: Budget = {}; if (Number.isFinite(clamped.maxUSD)) budget.maxUSD = clamped.maxUSD; @@ -90,3 +134,26 @@ export function clampSubFlowBudget(sub: Taskflow, parentBudget: Budget | undefin budget: budget.maxUSD === undefined && budget.maxTokens === undefined ? undefined : budget, }; } + +/** Check a kernel phase's in-flight retry attempts against the run-wide cap. + * The driver has not folded the current phase into state yet, so callers must + * supply the cumulative usage of attempts made so far. Prior completed phase + * usage comes from state; the current running placeholder is excluded. */ +export function kernelAttemptsOverBudget( + state: RunState, + phaseId: string, + attemptUsage: readonly UsageStats[], +): boolean { + const budget = state.def.budget; + if (!budget) return false; + return overBudget({ + maxUSD: budget.maxUSD, + maxTokens: budget.maxTokens, + usages: [ + ...Object.entries(state.phases) + .filter(([id]) => id !== phaseId) + .map(([, phase]) => phase.usage ?? emptyUsage()), + ...attemptUsage, + ], + }).over; +} diff --git a/packages/taskflow-core/src/exec/step-kinds.ts b/packages/taskflow-core/src/exec/step-kinds.ts index 94d0cbd..7ac8c89 100644 --- a/packages/taskflow-core/src/exec/step-kinds.ts +++ b/packages/taskflow-core/src/exec/step-kinds.ts @@ -19,8 +19,8 @@ import { parseGateVerdict, parseTournamentWinner } from "../deterministic.ts"; import { verifyTaskflow } from "../verify.ts"; import { aggregateUsage, emptyUsage, type UsageStats } from "../usage.ts"; import { evaluateCondition, interpolate, safeParse, tryEvaluateCondition, type InterpolationContext } from "../interpolate.ts"; -import { clampSubFlowBudget } from "./kernel-policy.ts"; -import { mapWithConcurrencyLimit } from "../runner-core.ts"; +import { clampSubFlowBudget, kernelAttemptsOverBudget } from "./kernel-policy.ts"; +import { abortableDelay, isTransientError, mapWithConcurrencyLimit } from "../runner-core.ts"; import type { Event } from "./events.ts"; import { EVENT_SCHEMA_VERSION } from "./events.ts"; import type { StepContext, StepResult } from "./step.ts"; @@ -69,53 +69,70 @@ async function runAgentCall( typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000 ? phase.timeout : undefined; - let timedOut = false; - let timer: ReturnType | undefined; - let onParentAbort: (() => void) | undefined; - let callSignal: AbortSignal | undefined = ctx.deps.signal; - if (phaseTimeoutMs) { - const ac = new AbortController(); - callSignal = ac.signal; - if (ctx.deps.signal?.aborted) ac.abort(); - else if (ctx.deps.signal) { - onParentAbort = () => ac.abort(); - ctx.deps.signal.addEventListener("abort", onParentAbort, { once: true }); + const usages: UsageStats[] = []; + let r: RunResult | undefined; + for (let attempt = 0; attempt < 4; attempt++) { + if (ctx.deps.signal?.aborted) break; + let timedOut = false; + let timer: ReturnType | undefined; + let onParentAbort: (() => void) | undefined; + let callSignal: AbortSignal | undefined = ctx.deps.signal; + if (phaseTimeoutMs) { + const ac = new AbortController(); + callSignal = ac.signal; + if (ctx.deps.signal?.aborted) ac.abort(); + else if (ctx.deps.signal) { + onParentAbort = () => ac.abort(); + ctx.deps.signal.addEventListener("abort", onParentAbort, { once: true }); + } + timer = setTimeout(() => { + timedOut = true; + ac.abort(); + }, phaseTimeoutMs); } - timer = setTimeout(() => { - timedOut = true; - ac.abort(); - }, phaseTimeoutMs); - } - let r: RunResult; - try { - r = await ctx.deps.runTask( - ctx.deps.cwd, - ctx.deps.agents, - agentName, - task, - { - model: phase.model, - thinking: phase.thinking, - tools: phase.tools, - cwd: ctx.deps.cwd, - signal: callSignal, - }, - ctx.deps.globalThinking, - ); - } finally { - if (timer) clearTimeout(timer); - if (onParentAbort) ctx.deps.signal?.removeEventListener("abort", onParentAbort); + try { + r = await ctx.deps.runTask( + ctx.deps.cwd, + ctx.deps.agents, + agentName, + task, + { model: phase.model, thinking: phase.thinking, tools: phase.tools, cwd: ctx.deps.cwd, signal: callSignal }, + ctx.deps.globalThinking, + ); + } finally { + if (timer) clearTimeout(timer); + if (onParentAbort) ctx.deps.signal?.removeEventListener("abort", onParentAbort); + } + if (timedOut) { + r = { + ...r, + exitCode: r.exitCode === 0 ? 1 : r.exitCode, + stopReason: "error", + errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`, + phaseTimeout: true, + }; + } + usages.push(r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage()); + if (!isFailedResult(r)) break; + if (kernelAttemptsOverBudget(ctx.state, phase.id, usages)) break; + if (r.phaseTimeout || phase.idempotent === false || !isTransientError(r) || attempt >= 3) break; + const wait = Math.min(60_000, (phase.retry?.backoffMs ?? 2_000) * 2 ** attempt); + await abortableDelay(wait, ctx.deps.signal); } - if (timedOut) { + if (!r) { r = { - ...r!, - exitCode: r!.exitCode === 0 ? 1 : r!.exitCode, - stopReason: "error", - errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`, - phaseTimeout: true, + agent: agentName, + task, + exitCode: 1, + output: "", + stderr: "Aborted before execution", + usage: emptyUsage(), + stopReason: "aborted", + errorMessage: "Aborted before execution", }; } - const usage = r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage(); + const usage = aggregateUsage(usages); + r = { ...r, usage, attempts: usages.length }; const event = baseEvent(ctx, phase.id, "subagent-call", { input: { agent: agentName, @@ -132,7 +149,7 @@ async function runAgentCall( stopReason: r.stopReason, }, }); - return { result: { ...r, usage }, event }; + return { result: r, event }; } function interpCtx(ctx: StepContext, extra?: Partial): InterpolationContext { diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index 1668d81..a5cce13 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -8,7 +8,7 @@ import { spawn } from "node:child_process"; import type { Phase, Taskflow } from "../schema.ts"; -import { PHASE_TYPES } from "../schema.ts"; +import { dependenciesOf, PHASE_TYPES } from "../schema.ts"; import type { RunState } from "../store.ts"; import type { AgentConfig } from "../agents.ts"; import type { RunOptions, RunResult } from "../host/runner-types.ts"; @@ -22,7 +22,7 @@ import { safeParse, type InterpolationContext, } from "../interpolate.ts"; -import { mapWithConcurrencyLimit } from "../runner-core.ts"; +import { abortableDelay, isTransientError, mapWithConcurrencyLimit } from "../runner-core.ts"; import { executeApprovalBody, executeFlowBody, @@ -31,6 +31,7 @@ import { executeReduceBody, executeTournamentBody, } from "./step-kinds.ts"; +import { kernelAttemptsOverBudget } from "./kernel-policy.ts"; /** All DSL phase types — S2 kernel is complete. */ /** Kernel kinds: original 10. Horizon B `race`/`expand` run on the imperative @@ -100,6 +101,8 @@ export interface StepResult { error?: string; /** Token/cost usage for this phase (empty for script / skipped). */ usage: UsageStats; + /** Total runner attempts, including automatic transient retries. */ + attempts?: number; gate?: { verdict: "pass" | "block"; reason?: string }; approval?: { decision: "approve" | "reject" | "edit"; note?: string; auto?: boolean }; } @@ -110,6 +113,7 @@ type BodyResult = { status: StepResult["status"]; error?: string; usage: UsageStats; + attempts?: number; gate?: StepResult["gate"]; approval?: StepResult["approval"]; }; @@ -289,53 +293,79 @@ async function runOneAgent( typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000 ? phase.timeout : undefined; - let timedOut = false; - let timer: ReturnType | undefined; - let onParentAbort: (() => void) | undefined; - let callSignal: AbortSignal | undefined = ctx.deps.signal; - if (phaseTimeoutMs) { - const ac = new AbortController(); - callSignal = ac.signal; - if (ctx.deps.signal?.aborted) ac.abort(); - else if (ctx.deps.signal) { - onParentAbort = () => ac.abort(); - ctx.deps.signal.addEventListener("abort", onParentAbort, { once: true }); + const attempts: UsageStats[] = []; + let r: RunResult | undefined; + for (let attempt = 0; attempt < 4; attempt++) { + if (ctx.deps.signal?.aborted) break; + let timedOut = false; + let timer: ReturnType | undefined; + let onParentAbort: (() => void) | undefined; + let callSignal: AbortSignal | undefined = ctx.deps.signal; + if (phaseTimeoutMs) { + const ac = new AbortController(); + callSignal = ac.signal; + if (ctx.deps.signal?.aborted) ac.abort(); + else if (ctx.deps.signal) { + onParentAbort = () => ac.abort(); + ctx.deps.signal.addEventListener("abort", onParentAbort, { once: true }); + } + timer = setTimeout(() => { + timedOut = true; + ac.abort(); + }, phaseTimeoutMs); } - timer = setTimeout(() => { - timedOut = true; - ac.abort(); - }, phaseTimeoutMs); - } - let r: RunResult; - try { - r = await ctx.deps.runTask( - ctx.deps.cwd, - ctx.deps.agents, - agentName, - task, - { - model: phase.model, - thinking: phase.thinking, - tools: phase.tools, - cwd: ctx.deps.cwd, - signal: callSignal, - }, - ctx.deps.globalThinking, - ); - } finally { - if (timer) clearTimeout(timer); - if (onParentAbort) ctx.deps.signal?.removeEventListener("abort", onParentAbort); + try { + r = await ctx.deps.runTask( + ctx.deps.cwd, + ctx.deps.agents, + agentName, + task, + { + model: phase.model, + thinking: phase.thinking, + tools: phase.tools, + cwd: ctx.deps.cwd, + signal: callSignal, + }, + ctx.deps.globalThinking, + ); + } finally { + if (timer) clearTimeout(timer); + if (onParentAbort) ctx.deps.signal?.removeEventListener("abort", onParentAbort); + } + if (timedOut) { + r = { + ...r, + exitCode: r.exitCode === 0 ? 1 : r.exitCode, + stopReason: "error", + errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`, + phaseTimeout: true, + }; + } + attempts.push(r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage()); + if (!isFailedResult(r)) break; + // The current phase has not been committed to state yet. Include prior + // completed run usage plus every attempt made here before admitting a + // transient retry, matching the imperative runner's live usage guard. + if (kernelAttemptsOverBudget(ctx.state, phase.id, attempts)) break; + if (r.phaseTimeout || phase.idempotent === false || !isTransientError(r) || attempt >= 3) break; + const wait = Math.min(60_000, (phase.retry?.backoffMs ?? 2_000) * 2 ** attempt); + await abortableDelay(wait, ctx.deps.signal); } - if (timedOut) { + if (!r) { r = { - ...r!, - exitCode: r!.exitCode === 0 ? 1 : r!.exitCode, - stopReason: "error", - errorMessage: `Phase timed out after ${phaseTimeoutMs}ms (subagent aborted)`, - phaseTimeout: true, + agent: agentName, + task, + exitCode: 1, + output: "", + stderr: "Aborted before execution", + usage: emptyUsage(), + stopReason: "aborted", + errorMessage: "Aborted before execution", }; } - const usage = r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage(); + const usage = aggregateUsage(attempts); + r = { ...r, usage, attempts: attempts.length }; const event = baseEvent(ctx, phase.id, "subagent-call", { input: { agent: agentName, @@ -351,7 +381,7 @@ async function runOneAgent( stopReason: r.stopReason, }, }); - return { result: { ...r, usage }, event }; + return { result: r, event }; } async function executeAgentBody(phase: Phase, ctx: StepContext): Promise { @@ -373,6 +403,7 @@ async function executeAgentBody(phase: Phase, ctx: StepContext): Promise ({ id: p.id, def: stripPolicy(p) })); - const payload = { self: stripPolicy(phase), deps: depsPayload }; + // Agent discovery scope is a flow-level input to every agent-running phase: + // the same agent name may resolve to different user/project definitions. + // Fold it into every phase fingerprint so per-item and per-phase cache reuse + // cannot cross that semantic boundary. contextSharing is included as an + // explicit belt-and-suspenders marker (sharing already falls back above). + const payload = { + flow: { + agentScope: def.agentScope ?? "user", + contextSharing: def.contextSharing ?? false, + }, + self: stripPolicy(phase), + deps: depsPayload, + }; return hashCanonical(canonicalJson(payload)); } diff --git a/packages/taskflow-core/src/host/runner-types.ts b/packages/taskflow-core/src/host/runner-types.ts index 2215c51..5a15f73 100644 --- a/packages/taskflow-core/src/host/runner-types.ts +++ b/packages/taskflow-core/src/host/runner-types.ts @@ -102,6 +102,9 @@ export interface RunOptions { * instead, so the engine's retry and fail-soft logic can act on it. */ export interface SubagentRunner { + /** Whether this host reports authoritative token/cost usage. `unavailable` + * makes runtime budget declarations fail closed at every execution boundary. */ + readonly usageAccounting?: "available" | "unavailable"; runTask( defaultCwd: string, agents: TAgent[], diff --git a/packages/taskflow-core/src/replay.ts b/packages/taskflow-core/src/replay.ts index 9788107..310c913 100644 --- a/packages/taskflow-core/src/replay.ts +++ b/packages/taskflow-core/src/replay.ts @@ -80,6 +80,80 @@ function gateScoreDecision(d: EventDecision | undefined): Extract(); + for (const event of events) { + if (!event.runId) continue; + byRun.set(event.runId, [...(byRun.get(event.runId) ?? []), event]); + } + if (byRun.size === 0) return { ambiguous: false }; + const groups: RunGroup[] = [...byRun].map(([runId, groupEvents]) => { + const starts = groupEvents.filter((e) => e.kind === "phase-start"); + const ends = groupEvents.filter((e) => e.kind === "phase-end"); + const openStarts = new Map(); + let lifecycleValid = starts.length > 0; + for (const event of groupEvents) { + if (event.kind === "phase-start") { + openStarts.set(event.phaseId, [...(openStarts.get(event.phaseId) ?? []), event.ts]); + } else if (event.kind === "phase-end") { + const queue = openStarts.get(event.phaseId) ?? []; + const startedAt = queue.shift(); + if (startedAt === undefined || event.ts < startedAt) lifecycleValid = false; + openStarts.set(event.phaseId, queue); + } + } + if ([...openStarts.values()].some((queue) => queue.length > 0)) lifecycleValid = false; + return { + runId, + minStart: Math.min(...(starts.length ? starts : groupEvents).map((e) => e.ts)), + maxEnd: Math.max(...(ends.length ? ends : groupEvents).map((e) => e.ts)), + completeLifecycle: lifecycleValid, + innerFlowMarkers: groupEvents.filter( + (e) => e.kind === "decision" && e.decision?.type === "unreplayable" && e.decision.reason === "inner-flow", + ).length, + }; + }); + const selected = (group: RunGroup): { runId: string; ambiguous: boolean } => ({ + runId: group.runId, + // A truncated selected run cannot support reuse even when its identity is + // otherwise obvious: missing phase-end means output/status may be partial. + ambiguous: !group.completeLifecycle, + }); + if (groups.length === 1) return selected(groups[0]); + const envelopes = groups.filter((g) => + groups.every((other) => g.minStart <= other.minStart && g.maxEnd >= other.maxEnd), + ); + if (envelopes.length === 1) return selected(envelopes[0]); + const markerMax = Math.max(...groups.map((g) => g.innerFlowMarkers)); + if (markerMax > 0) { + const marked = groups.filter((g) => g.innerFlowMarkers === markerMax); + if (marked.length === 1) return selected(marked[0]); + } + const earliest = Math.min(...groups.map((g) => g.minStart)); + const earliestGroups = groups.filter((g) => g.minStart === earliest); + if (earliestGroups.length === 1) return selected(earliestGroups[0]); + return { + runId: [...groups].sort((a, b) => a.runId.localeCompare(b.runId))[0].runId, + ambiguous: true, + }; +} + /** * Re-evaluate a recorded event log under optional decision overrides. * @@ -94,31 +168,172 @@ function gateScoreDecision(d: EventDecision | undefined): Extract e.runId === rootRunId) : [...events]; + const baseline = foldEvents(rootEvents); + const baselineIncomplete = Object.values(baseline.phases).some( + (phase) => + phase.status === "pending" || + phase.status === "running" || + (phase.startedAt !== undefined && phase.endedAt !== undefined && phase.endedAt < phase.startedAt), + ); const totalUsage = sumUsage(baseline); - const decisions: ReplayDecision[] = []; + const replayed: FoldedRun = { + ...baseline, + phases: Object.fromEntries( + Object.entries(baseline.phases).map(([id, p]) => [ + id, + { ...p, usage: { ...p.usage }, decision: p.decision ? { ...p.decision } : undefined }, + ]), + ), + }; + const byDecision = new Map(); let needsLiveRerun = false; const hasModelOverride = overrides.models && Object.keys(overrides.models).length > 0; const hasArgsOverride = overrides.args && Object.keys(overrides.args).length > 0; + const hasThresholdOverride = overrides.thresholds !== undefined && Object.keys(overrides.thresholds).length > 0; + const hasBudgetOverride = overrides.budgetMaxUSD !== undefined || overrides.budgetMaxTokens !== undefined; + const order: string[] = []; + const seen = new Set(); + const deps = new Map(); + const dependencyRecorded = new Set(); + const phaseStart = new Map(); + const decisionHistory = new Map(); + const unreplayable = new Map(); + for (const [eventIndex, e] of rootEvents.entries()) { + if (!seen.has(e.phaseId)) { + seen.add(e.phaseId); + order.push(e.phaseId); + } + if (e.kind === "phase-start") { + if (!phaseStart.has(e.phaseId)) phaseStart.set(e.phaseId, { ts: e.ts, index: eventIndex }); + if (e.dependencies !== undefined) { + deps.set(e.phaseId, [...e.dependencies]); + dependencyRecorded.add(e.phaseId); + } + } + if (e.kind === "decision" && e.decision?.type === "unreplayable") { + unreplayable.set(e.phaseId, e.decision.reason); + } + if (e.kind === "decision" && e.decision) { + decisionHistory.set(e.phaseId, [...(decisionHistory.get(e.phaseId) ?? []), e.decision]); + } + } + for (const id of Object.keys(baseline.phases)) if (!seen.has(id)) order.push(id); + const lastGateScore = (phaseId: string): Extract | undefined => { + const history = decisionHistory.get(phaseId) ?? []; + for (let i = history.length - 1; i >= 0; i--) { + const decision = history[i]; + if (decision.type === "gate-score") return decision; + } + return undefined; + }; - // Budget re-check against full-run usage (coarse: if over, every non-failed - // phase that finished after budget would have been hit is flagged). - let budgetBlocked = false; - if (overrides.budgetMaxUSD !== undefined || overrides.budgetMaxTokens !== undefined) { - const input: BudgetCheckInput = { - usages: [totalUsage], - maxUSD: overrides.budgetMaxUSD, - maxTokens: overrides.budgetMaxTokens, - }; - const check = overBudget(input); - budgetBlocked = check.over; + const children = new Map(); + for (const [id, parents] of deps) { + for (const parent of parents) children.set(parent, [...(children.get(parent) ?? []), id]); } + const descendants = (root: string): string[] => { + const out: string[] = []; + const queue = [...(children.get(root) ?? [])]; + const visited = new Set(); + while (queue.length) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + out.push(id); + queue.push(...(children.get(id) ?? [])); + } + return out; + }; + const graphIds = Object.keys(baseline.phases); + const graphIdSet = new Set(graphIds); + const missingDependencyMetadata = + graphIds.length > 1 && graphIds.some((id) => !dependencyRecorded.has(id)); + let graphTopologyAmbiguous = missingDependencyMetadata; + const layerMemo = new Map(); + const layerOf = (id: string, visiting = new Set()): number | undefined => { + const memo = layerMemo.get(id); + if (memo !== undefined) return memo; + if (!dependencyRecorded.has(id) || visiting.has(id)) { + graphTopologyAmbiguous = true; + return undefined; + } + const nextVisiting = new Set(visiting).add(id); + let layer = 0; + for (const parent of deps.get(id) ?? []) { + if (!graphIdSet.has(parent)) { + graphTopologyAmbiguous = true; + return undefined; + } + const parentLayer = layerOf(parent, nextVisiting); + if (parentLayer === undefined) return undefined; + layer = Math.max(layer, parentLayer + 1); + } + layerMemo.set(id, layer); + return layer; + }; + for (const id of graphIds) layerOf(id); + const blockRoots: string[] = []; + const unblockRoots: string[] = []; + const liveRoots: string[] = []; + const markNeedsLive = (phaseId: string, reason: string, causedBy?: readonly string[]): void => { + const phase = baseline.phases[phaseId]; + if (!phase) return; + const current = byDecision.get(phaseId)?.outcome; + // A phase deterministically prevented from running needs no model call, and + // a recorded failure remains a recorded failure unless another decision + // proves it would not have run. + if (current === "needs-live-rerun" || current === "would-skip" || current === "would-block" || current === "failed") return; + needsLiveRerun = true; + byDecision.set(phaseId, { + phaseId, + outcome: "needs-live-rerun", + reason, + priorOutcome: phase.status, + replayedOutcome: "pending", + ...(causedBy?.length ? { causedBy } : {}), + }); + replayed.phases[phaseId]!.status = "pending"; + }; + const forceNeedsLive = (phaseId: string, reason: string): void => { + const phase = baseline.phases[phaseId]; + if (!phase) return; + needsLiveRerun = true; + byDecision.set(phaseId, { + phaseId, + outcome: "needs-live-rerun", + reason, + priorOutcome: phase.status, + replayedOutcome: "pending", + }); + replayed.phases[phaseId]!.status = "pending"; + }; + const markWouldSkip = (phaseId: string, reason: string, causedBy: readonly string[]): void => { + const phase = baseline.phases[phaseId]; + if (!phase) return; + byDecision.set(phaseId, { + phaseId, + outcome: "would-skip", + reason, + priorOutcome: phase.status, + replayedOutcome: "skipped", + causedBy, + }); + replayed.phases[phaseId]!.status = "skipped"; + }; - for (const [phaseId, phase] of Object.entries(baseline.phases)) { + for (const phaseId of order) { + const phase = baseline.phases[phaseId]; + if (!phase) continue; const prior = phase.status; if (prior === "failed") { - decisions.push({ + byDecision.set(phaseId, { phaseId, outcome: "failed", reason: phase.error ?? "recorded failure", @@ -127,45 +342,77 @@ export function replayRun(events: readonly Event[], overrides: ReplayOverrides = }); continue; } + const unrep = unreplayable.get(phaseId); + if (unrep) { + needsLiveRerun = true; + liveRoots.push(phaseId); + byDecision.set(phaseId, { + phaseId, + outcome: "needs-live-rerun", + reason: `recorded phase is unreplayable offline: ${unrep}`, + priorOutcome: prior, + replayedOutcome: "pending", + }); + replayed.phases[phaseId]!.status = "pending"; + continue; + } if (hasModelOverride && overrides.models?.[phaseId]) { needsLiveRerun = true; - decisions.push({ + liveRoots.push(phaseId); + byDecision.set(phaseId, { phaseId, outcome: "needs-live-rerun", reason: `model override ${overrides.models[phaseId]} cannot be quality-replayed offline`, priorOutcome: prior, + replayedOutcome: "pending", }); + replayed.phases[phaseId]!.status = "pending"; continue; } if (hasArgsOverride) { // Args may change interpolated task text for any phase — conservative. needsLiveRerun = true; - decisions.push({ + liveRoots.push(phaseId); + byDecision.set(phaseId, { phaseId, outcome: "needs-live-rerun", reason: "args override may change interpolated task text", priorOutcome: prior, + replayedOutcome: "pending", }); + replayed.phases[phaseId]!.status = "pending"; continue; } - const score = gateScoreDecision(phase.decision); + // FoldedPhase retains only the last decision. A later budget/cache marker + // must not erase the recorded gate score needed by threshold replay. + const score = lastGateScore(phaseId) ?? gateScoreDecision(phase.decision); const newThreshold = overrides.thresholds?.[phaseId]; if (score && newThreshold !== undefined) { const oldThreshold = score.threshold ?? 0.7; const oldVerdict = score.verdict; const newVerdict: "pass" | "block" = score.combined >= newThreshold ? "pass" : "block"; + replayed.phases[phaseId]!.decision = { ...score, threshold: newThreshold, verdict: newVerdict }; if (oldVerdict !== newVerdict) { - decisions.push({ + byDecision.set(phaseId, { phaseId, outcome: newVerdict === "block" ? "would-block" : "verdict-flipped", reason: `threshold ${oldThreshold}→${newThreshold}; combined=${score.combined} → ${newVerdict}`, priorOutcome: oldVerdict, replayedOutcome: newVerdict, }); + replayed.phases[phaseId]!.status = newVerdict === "block" ? "blocked" : "done"; + if (newVerdict === "block") { + blockRoots.push(phaseId); + } else if (oldVerdict === "block") { + // Recorded descendants were skipped and therefore have no output to + // reuse. A BLOCK→PASS flip opens a previously unexecuted graph branch; + // every transitive consumer must run live. + unblockRoots.push(phaseId); + } } else if (oldThreshold !== newThreshold) { - decisions.push({ + byDecision.set(phaseId, { phaseId, outcome: "threshold-changed", reason: `threshold ${oldThreshold}→${newThreshold}; verdict still ${oldVerdict}`, @@ -173,7 +420,7 @@ export function replayRun(events: readonly Event[], overrides: ReplayOverrides = replayedOutcome: newVerdict, }); } else { - decisions.push({ + byDecision.set(phaseId, { phaseId, outcome: "reused", reason: "gate-score unchanged under same threshold", @@ -184,18 +431,7 @@ export function replayRun(events: readonly Event[], overrides: ReplayOverrides = continue; } - if (budgetBlocked && (prior === "done" || prior === "running")) { - decisions.push({ - phaseId, - outcome: "would-exceed-budget", - reason: "recorded run usage exceeds replay budget caps", - priorOutcome: prior, - replayedOutcome: "skipped", - }); - continue; - } - - decisions.push({ + byDecision.set(phaseId, { phaseId, outcome: "reused", reason: "no applicable overrides; recorded outcome kept", @@ -204,12 +440,243 @@ export function replayRun(events: readonly Event[], overrides: ReplayOverrides = }); } - // Replayed fold is baseline for now (we don't rewrite events under overrides; - // decisions carry the counterfactual). Future: emit synthetic decision events. + // Runtime gate admission is global, not descendant-local. A PASS→BLOCK flip + // keeps siblings that had already started in the gate's layer, but prevents + // later admissions in that layer and every subsequent layer — including + // independent branches. If recorded timing/topology cannot prove admission, + // fail safe live rather than claiming a skip. + for (const root of blockRoots) { + const rootLayer = layerOf(root); + // Runtime sets gateBlocked only after executePhase returns. phase-end is the + // recorded scheduler-completion boundary; the earlier gate-score/verdict + // decision is not an admission cutoff. + const admissionCutoff = baseline.phases[root]?.endedAt; + for (const id of graphIds) { + if (id === root) continue; + const candidateLayer = layerOf(id); + if (graphTopologyAmbiguous || rootLayer === undefined || candidateLayer === undefined || admissionCutoff === undefined) { + markNeedsLive(id, `gate '${root}' would block but recorded admission timing is incomplete`, [root]); + continue; + } + if (candidateLayer > rootLayer) { + markWouldSkip(id, `gate '${root}' would globally stop subsequent layers`, [root]); + continue; + } + if (candidateLayer === rootLayer) { + const startedAt = baseline.phases[id]?.startedAt; + if (startedAt === undefined) { + markNeedsLive(id, `gate '${root}' would block but sibling admission timing is missing`, [root]); + } else if (startedAt >= admissionCutoff) { + // phase-end is the closest legacy trace boundary, but gateBlocked is + // assigned immediately after executePhase returns. Another worker may + // claim a same-layer slot in that microtask gap, so a start at or after + // phase-end is ambiguous without an explicit scheduler-admission event. + markNeedsLive(id, `gate '${root}' and sibling admission order cannot be proven`, [root]); + } + } + } + } + // The inverse gate flip revives every phase the runtime actually labelled as + // globally gate-blocked, including independent branches. For older traces + // without the error reason, a skipped graph descendant is the conservative + // fallback unless another explicit skip decision explains it. + for (const root of unblockRoots) { + const revived: string[] = []; + for (const id of graphIds) { + if (id === root) continue; + const phase = baseline.phases[id]; + const recordedGateSkip = phase?.status === "skipped" && /^gate blocked/i.test(phase.error ?? ""); + if (!recordedGateSkip) continue; + markNeedsLive(id, `gate '${root}' would now pass and make this recorded gate-skip reachable`, [root]); + revived.push(id); + } + for (const revivedRoot of revived) { + for (const id of descendants(revivedRoot)) { + markNeedsLive(id, `newly reachable phase '${revivedRoot}' requires a live result`, [revivedRoot]); + } + } + } + // A live-rerun root makes every transitive consumer non-replayable too: its + // interpolated input may change even when the consumer itself had no marker. + for (const root of liveRoots) { + for (const id of descendants(root)) { + markNeedsLive(id, `upstream phase '${root}' requires a live rerun`, [root]); + } + } + + // Re-tally by recorded DAG layers, not flat append order. Runtime starts a + // whole dependency layer concurrently; a sibling already started when one + // completion crosses the cap cannot retrospectively become a budget skip. + // Within each layer, phase-start time (then append index) gives a stable cause + // for reporting. Missing/cyclic dependency metadata fails safe to live rerun + // instead of inventing a sequential schedule. + if (hasBudgetOverride) { + const ids = Object.keys(baseline.phases); + const idSet = new Set(ids); + let topologyAmbiguous = ids.length > 1 && ids.some((id) => !dependencyRecorded.has(id)); + const indegree = new Map(ids.map((id) => [id, 0])); + const topoChildren = new Map(); + for (const id of ids) { + for (const parent of deps.get(id) ?? []) { + if (!idSet.has(parent) || parent === id) { + topologyAmbiguous = true; + continue; + } + indegree.set(id, (indegree.get(id) ?? 0) + 1); + topoChildren.set(parent, [...(topoChildren.get(parent) ?? []), id]); + } + } + const startCompare = (a: string, b: string): number => { + const sa = phaseStart.get(a); + const sb = phaseStart.get(b); + return (sa?.ts ?? Infinity) - (sb?.ts ?? Infinity) || (sa?.index ?? Infinity) - (sb?.index ?? Infinity) || a.localeCompare(b); + }; + const layers: string[][] = []; + let ready = ids.filter((id) => indegree.get(id) === 0).sort(startCompare); + let visited = 0; + while (ready.length > 0) { + const layer = ready; + layers.push(layer); + visited += layer.length; + const next: string[] = []; + for (const id of layer) { + for (const child of topoChildren.get(id) ?? []) { + const n = (indegree.get(child) ?? 0) - 1; + indegree.set(child, n); + if (n === 0) next.push(child); + } + } + ready = next.sort(startCompare); + } + if (visited !== ids.length) topologyAmbiguous = true; + + if (topologyAmbiguous) { + for (const id of ids) { + markNeedsLive(id, "recorded dependency layers are incomplete; budget replay requires a live rerun"); + } + } else { + const knownUsage: UsageStats[] = []; + let budgetCause: string | undefined; + let uncertainRoots: string[] = []; + for (const layer of layers) { + if (budgetCause) { + for (const id of layer) { + markWouldSkip(id, `budget was exceeded after '${budgetCause}'`, [budgetCause]); + } + continue; + } + if (uncertainRoots.length > 0) { + for (const id of layer) { + if (replayed.phases[id]?.status !== "skipped") { + markNeedsLive(id, "earlier live-rerun spend makes the replay budget boundary uncertain", uncertainRoots); + } + } + continue; + } + + const layerUncertain: string[] = []; + for (const phaseId of layer) { + const phase = baseline.phases[phaseId]; + if (!phase || replayed.phases[phaseId]?.status === "skipped") continue; + if (byDecision.get(phaseId)?.outcome === "needs-live-rerun") { + layerUncertain.push(phaseId); + continue; + } + knownUsage.push(phase.usage); + const input: BudgetCheckInput = { + usages: knownUsage, + maxUSD: overrides.budgetMaxUSD, + maxTokens: overrides.budgetMaxTokens, + }; + if (!budgetCause && overBudget(input).over) { + budgetCause = phaseId; + const current = byDecision.get(phaseId)?.outcome; + if (current !== "would-block" && current !== "failed") { + byDecision.set(phaseId, { + phaseId, + outcome: "would-exceed-budget", + reason: "this phase's recorded spend crosses the replay budget cap", + priorOutcome: phase.status, + replayedOutcome: phase.status, + }); + } + } + } + // All members of this layer were already admitted, so none is skipped + // even when one crossed the cap. Unknown model/arg spend only makes + // subsequent layers uncertain when no known lower bound already crossed. + if (!budgetCause && layerUncertain.length > 0) uncertainRoots = layerUncertain; + } + } + } + // A looser replay budget can remove a recorded budget stop. Those skipped + // phases have no output to reuse: if the new budget analysis did not still + // deterministically skip them, they (and their consumers) require live work. + // Apply this after budget composition so it cannot overwrite a valid new + // would-skip from a tighter cap or an earlier gate. + if (hasBudgetOverride) { + const revivedBudgetSkips: string[] = []; + for (const id of graphIds) { + const phase = baseline.phases[id]; + const recordedBudgetSkip = + phase?.status === "skipped" && + ((decisionHistory.get(id) ?? []).some((decision) => decision.type === "budget-hit") || + /budget exceeded/i.test(phase.error ?? "")); + if (!recordedBudgetSkip || byDecision.get(id)?.outcome === "would-skip") continue; + markNeedsLive(id, "replay budget makes a recorded budget-skipped phase reachable", [id]); + if (byDecision.get(id)?.outcome === "needs-live-rerun") revivedBudgetSkips.push(id); + } + for (const root of revivedBudgetSkips) { + for (const id of descendants(root)) { + markNeedsLive(id, `newly reachable budget-skipped phase '${root}' requires a live result`, [root]); + } + } + } + // Legacy multi-phase traces omitted phase-start.dependencies. Any local knob + // whose effect can propagate through the graph is unsafe to localize against + // an invented empty graph. Preserve a threshold root's directly computable + // verdict only when it is the sole override; every other potentially related + // phase (or every phase for model/args/budget combinations) fails safe live. + const phaseIds = Object.keys(baseline.phases); + const hasGraphSensitiveOverride = hasThresholdOverride || hasModelOverride || hasArgsOverride || hasBudgetOverride; + if (missingDependencyMetadata && hasGraphSensitiveOverride) { + const thresholdRoots = new Set(Object.keys(overrides.thresholds ?? {})); + const singleThresholdOnly = + hasThresholdOverride && thresholdRoots.size === 1 && !hasModelOverride && !hasArgsOverride && !hasBudgetOverride; + for (const id of phaseIds) { + const directlyReplayableThresholdRoot = + singleThresholdOnly && thresholdRoots.has(id) && lastGateScore(id) !== undefined; + if (directlyReplayableThresholdRoot) continue; + forceNeedsLive( + id, + "legacy trace lacks dependency metadata; refusing local override propagation against an empty graph", + ); + } + } + if (selectedRun.ambiguous || baselineIncomplete) { + needsLiveRerun = true; + for (const id of order) { + const phase = baseline.phases[id]; + if (!phase) continue; + byDecision.set(id, { + phaseId: id, + outcome: "needs-live-rerun", + reason: "mixed trace run identity is ambiguous; refusing offline reuse", + priorOutcome: phase.status, + replayedOutcome: "pending", + }); + replayed.phases[id]!.status = "pending"; + } + } + // Derive the flag from the final composed decisions. A model override that is + // deterministically skipped by an earlier gate/budget no longer requires a + // live call after precedence has been resolved. + needsLiveRerun = [...byDecision.values()].some((d) => d.outcome === "needs-live-rerun"); + return { - decisions, + decisions: order.flatMap((id) => (byDecision.has(id) ? [byDecision.get(id)!] : [])), baseline, - replayed: baseline, + replayed, needsLiveRerun, totalUsage, }; diff --git a/packages/taskflow-core/src/runner-core.ts b/packages/taskflow-core/src/runner-core.ts index 66b7c30..4bc9b69 100644 --- a/packages/taskflow-core/src/runner-core.ts +++ b/packages/taskflow-core/src/runner-core.ts @@ -12,7 +12,7 @@ * hosts. */ -import { spawn } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { emptyUsage, type UsageStats } from "./usage.ts"; import type { AgentConfig } from "./agents.ts"; import type { CoreMessage, LiveUpdate, RunResult } from "./host/runner-types.ts"; @@ -43,6 +43,25 @@ export function isTransientError(r: RunResult): boolean { return TRANSIENT_ERROR_RE.test(hay); } +/** Wait for a retry backoff, but release immediately when the run is aborted. + * Resolves (rather than rejects) on abort so callers can leave their retry loop + * through the normal `signal.aborted` branch and preserve paused semantics. */ +export function abortableDelay(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0 || signal?.aborted) return Promise.resolve(); + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + signal?.removeEventListener("abort", finish); + resolve(); + }; + const timer = setTimeout(finish, ms); + signal?.addEventListener("abort", finish, { once: true }); + }); +} + /** Placeholder written to a failed phase's `output` so downstream interpolation * can detect "upstream failed" without being polluted by raw HTML/JSON. */ export const TRANSPORT_ERROR_PLACEHOLDER = "(upstream error: subagent failed; see error)"; @@ -258,9 +277,56 @@ export function num(v: unknown): number { * Module-global (not per-host): a single exit handler must reach every host's * children, so all host runners register here. */ const activeChildren = new Set(); + +/** Signal the whole process tree rooted at a spawned subagent. POSIX children + * are process-group leaders (`detached:true` at spawn), so a negative pid + * reaches every descendant in the group. Windows has no equivalent signal; + * taskkill /T /F is the platform-supported tree termination primitive. */ +function killProcessTree(pid: number, signal: NodeJS.Signals, direct?: ChildProcess): void { + if (process.platform === "win32") { + try { + const killer = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { + shell: false, + stdio: "ignore", + windowsHide: true, + }); + killer.once("error", () => { + try { direct?.kill(); } catch { /* already dead */ } + }); + killer.unref(); + } catch { + try { direct?.kill(); } catch { /* already dead */ } + } + return; + } + try { + process.kill(-pid, signal); + } catch { + // A process can exit between the liveness check and group signal. Falling + // back to the direct handle also covers platforms that reject group kills. + try { direct?.kill(signal); } catch { /* already dead */ } + } +} + +/** Synchronous variant for the host's `exit` event, where asynchronous taskkill + * cannot be awaited and would never get a chance to run. */ +function killProcessTreeSync(pid: number): void { + if (process.platform === "win32") { + try { + spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { + shell: false, + stdio: "ignore", + windowsHide: true, + }); + } catch { /* already dead / taskkill unavailable */ } + return; + } + try { process.kill(-pid, "SIGKILL"); } catch { /* already dead */ } +} + const killAllChildren = () => { for (const pid of activeChildren) { - try { process.kill(pid, "SIGKILL"); } catch { /* already dead */ } + killProcessTreeSync(pid); } }; process.on("exit", killAllChildren); @@ -360,20 +426,44 @@ export async function runSubagentProcess( } const exitCode = await new Promise((resolve) => { - const proc = spawn(bin, args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env }); + const proc = spawn(bin, args, { + cwd, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + env, + // On POSIX this makes the subagent a process-group leader while retaining + // piped stdout/stderr. Cancellation can then signal `-pid` and cannot + // strand grandchildren that outlive the direct CLI process. + detached: process.platform !== "win32", + windowsHide: true, + }); if (proc.pid) activeChildren.add(proc.pid); let buffer = ""; let idleTimer: NodeJS.Timeout | undefined; let forceTimer: NodeJS.Timeout | undefined; + let settled = false; + let removeAbortListener = () => {}; const clearTimers = () => { - if (idleTimer) clearTimeout(idleTimer); - if (forceTimer) clearTimeout(forceTimer); + if (idleTimer) { + clearTimeout(idleTimer); + idleTimer = undefined; + } + if (forceTimer) { + clearTimeout(forceTimer); + forceTimer = undefined; + } + }; + const signalTree = (signal: NodeJS.Signals) => { + if (proc.pid) killProcessTree(proc.pid, signal, proc); + else { + try { proc.kill(signal); } catch { /* spawn failed / already dead */ } + } }; const hardKill = () => { idleTimedOut = true; - proc.kill("SIGTERM"); - forceTimer = setTimeout(() => proc.kill("SIGKILL"), 5000); + signalTree("SIGTERM"); + forceTimer = setTimeout(() => signalTree("SIGKILL"), 5000); forceTimer.unref(); }; const armIdle = () => { @@ -417,19 +507,27 @@ export async function runSubagentProcess( } }); - proc.on("close", (code, signal) => { + const finish = (code: number, signal?: NodeJS.Signals | null) => { + if (settled) return; + settled = true; + // The direct process may close before a signal-resistant descendant. + // Close/error cleanup must not cancel escalation while its process group + // is still alive, so make one final tree-wide hard-kill first. + if ((wasAborted || idleTimedOut) && proc.pid) killProcessTree(proc.pid, "SIGKILL", proc); if (proc.pid) activeChildren.delete(proc.pid); clearTimers(); + removeAbortListener(); if (buffer.trim()) processLine(buffer); - if (code === null && signal) killedBySignal = signal; - resolve(code ?? 0); + if (signal) killedBySignal = signal; + resolve(code); + }; + proc.on("close", (code, signal) => { + finish(code ?? 0, code === null ? signal : undefined); }); proc.on("error", (err) => { - clearTimers(); - if (proc.pid) activeChildren.delete(proc.pid); // defensive: close usually fires after error, but don't rely on it if (!result.stderr) result.stderr = err.message; if (!result.errorMessage) result.errorMessage = err.message; - resolve(1); + finish(1); }); if (opts.signal) { @@ -440,12 +538,18 @@ export async function runSubagentProcess( // set and the post-exit classify chain (which checks idle BEFORE // abort) would misreport a user abort as an idle stall. clearTimers(); - proc.kill("SIGTERM"); - const forceKill = setTimeout(() => proc.kill("SIGKILL"), 5000); - forceKill.unref(); + signalTree("SIGTERM"); + forceTimer = setTimeout(() => signalTree("SIGKILL"), 5000); + forceTimer.unref(); }; if (opts.signal.aborted) kill(); - else opts.signal.addEventListener("abort", kill, { once: true }); + else { + opts.signal.addEventListener("abort", kill, { once: true }); + removeAbortListener = () => { + opts.signal?.removeEventListener("abort", kill); + removeAbortListener = () => {}; + }; + } } }); diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 333ea41..8a2dba6 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -72,6 +72,10 @@ export interface RuntimeDeps { cwd: string; agents: AgentConfig[]; globalThinking?: string; + /** Whether the host reports authoritative token/cost usage. A host that + * cannot observe usage must reject every actually-executed budgeted flow, + * including nested/dynamic flows, rather than silently bypassing the cap. */ + usageAccounting?: "available" | "unavailable"; signal?: AbortSignal; /** Persist run state after each phase (for resume). */ persist?: (state: RunState) => void; @@ -348,12 +352,22 @@ function normalizeInlineDef(parsed: unknown, phaseId: string): Taskflow | undefi * than the parent's, never looser. A generated def cannot raise the spend cap by * declaring its own large budget. Each dimension becomes min(child, parent). */ -function clampSubFlowBudget(sub: Taskflow, parentBudget: Budget | undefined): Taskflow { +function clampSubFlowBudget( + sub: Taskflow, + parentBudget: Budget | undefined, + spent: UsageStats = emptyUsage(), +): Taskflow { if (!parentBudget) return sub; const child = sub.budget; + const remainingUSD = + parentBudget.maxUSD === undefined ? Infinity : Math.max(0, parentBudget.maxUSD - spent.cost); + const remainingTokens = + parentBudget.maxTokens === undefined + ? Infinity + : Math.max(0, parentBudget.maxTokens - (spent.input + spent.output)); const clamped: Budget = { - maxUSD: Math.min(child?.maxUSD ?? Infinity, parentBudget.maxUSD ?? Infinity), - maxTokens: Math.min(child?.maxTokens ?? Infinity, parentBudget.maxTokens ?? Infinity), + maxUSD: Math.min(child?.maxUSD ?? Infinity, remainingUSD), + maxTokens: Math.min(child?.maxTokens ?? Infinity, remainingTokens), }; // Drop Infinity dimensions (no cap on that axis). const budget: Budget = {}; @@ -496,7 +510,7 @@ function traceFlush(deps: RuntimeDeps, phaseId: string): void { /** Emit a `decision: unreplayable` marker for a phase whose inputs the trace * cannot fully capture (Shared Context Tree, inner sub-flows, context files, - * unobservable interpolation deps). A future replay marks such phases + * unobservable interpolation deps). Offline replay marks such phases * `needs-live-rerun` instead of silently reusing a recorded output. * Single-phase analog of `hasUnobservedDependencies`. Fail-open. */ function emitUnreplayableMarker(deps: RuntimeDeps, state: RunState, phase: Phase): void { @@ -511,7 +525,7 @@ function emitUnreplayableMarker(deps: RuntimeDeps, state: RunState, phase: Phase /** Why (if at all) a single phase cannot be deterministically replayed. */ function unreplayableReason(state: RunState, phase: Phase): "context-sharing" | "inner-flow" | "context-files" | "unobservable-deps" | undefined { if (phase.shareContext === true || state.def.contextSharing === true) return "context-sharing"; - if (phase.type === "flow") return "inner-flow"; + if (phase.type === "flow" || phase.type === "expand") return "inner-flow"; if (phase.context && phase.context.length > 0) return "context-files"; // Interpolation refs that don't resolve through steps.*/args.*/item.* are // unobservable to the trace (previous.output is observable via dependsOn). @@ -632,6 +646,26 @@ interface SpawnedResult { usage: UsageStats; } +/** + * Spend produced by the current ctx_spawn supervision tree but not yet folded + * into `state.phases`. A single ledger is shared by siblings and descendants, + * otherwise every recursive call observes the same stale parent state and can + * independently spend the full remaining allowance. + */ +interface SpawnBudgetLedger { + usage: UsageStats; +} + +function spawnedOverBudget(state: RunState, local: UsageStats): boolean { + const budget = state.def.budget; + if (!budget) return false; + return overBudgetCheck({ + maxUSD: budget.maxUSD, + maxTokens: budget.maxTokens, + usages: [...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()), local], + }).over; +} + /** * Run an inline sub-flow queued via `ctx_spawn({subflow})`. Reuses the SAME * validation + execution machinery as a `flow{def}` phase (normalizeInlineDef → @@ -663,6 +697,7 @@ async function runInlineSubflow( phase: Phase, deps: RuntimeDeps, state: RunState, + localSpawnUsage: UsageStats, ): Promise<{ output: string; usage: UsageStats }> { const stack = deps._stack ?? []; const inlineDepth = stack.filter((s) => s.startsWith("def:")).length; @@ -685,7 +720,16 @@ async function runInlineSubflow( const errs = ver.issues.filter((i) => i.severity === "error").map((i) => i.message); return { output: `(spawned subflow failed verification: ${errs.join("; ")})`, usage: emptyUsage() }; } - const subDef = clampSubFlowBudget(wrapped, state.def.budget); + // The generated sub-flow gets only what remains after both already-folded + // parent spend and siblings/ancestors in this still-running spawn batch. USD + // and tokens are clamped independently by clampSubFlowBudget. Like the main + // runtime, this is an atomic-call ceiling: one call may cross the cap, then no + // subsequent call is admitted. + const parentAndBatchSpent = aggregateUsage([ + ...Object.values(state.phases).map((p) => p.usage ?? emptyUsage()), + localSpawnUsage, + ]); + const subDef = clampSubFlowBudget(wrapped, state.def.budget, parentAndBatchSpent); const subState: RunState = { runId: newRunId(subDef.name), flowName: subDef.name, @@ -730,6 +774,7 @@ async function runSpawnedChildren( deps: RuntimeDeps, state: RunState, run: RunTaskFn, + ledger: SpawnBudgetLedger = { usage: emptyUsage() }, ): Promise { const capped = assignments.slice(0, MAX_DYNAMIC_MAP_ITEMS); const lines: string[] = []; @@ -739,7 +784,7 @@ async function runSpawnedChildren( const spawnCwd = resolveEffCwd(deps, phase); let idx = 0; for (const a of capped) { - if (deps.signal?.aborted || overBudget(state).over) break; + if (deps.signal?.aborted || spawnedOverBudget(state, ledger.usage)) break; idx++; const childNodeId = `${parentNodeId}--c${idx}`.replace(/[^A-Za-z0-9._-]+/g, "_"); const isSubflow = a.subflow !== undefined && a.subflow !== null; @@ -748,9 +793,18 @@ async function runSpawnedChildren( let out = ""; try { if (isSubflow) { - const sub = await runInlineSubflow(a.subflow, a.defaultAgent ?? phase.agent, childNodeId, phase, deps, state); + const sub = await runInlineSubflow( + a.subflow, + a.defaultAgent ?? phase.agent, + childNodeId, + phase, + deps, + state, + ledger.usage, + ); out = sub.output; usages.push(sub.usage); + ledger.usage = aggregateUsage([ledger.usage, sub.usage]); setNodeStatus(ctxDir, childNodeId, "done"); } else { const r = await run( @@ -762,12 +816,15 @@ async function runSpawnedChildren( deps.globalThinking, ); out = r.output ?? ""; - if (r.usage) usages.push(r.usage); + if (r.usage) { + usages.push(r.usage); + ledger.usage = aggregateUsage([ledger.usage, r.usage]); + } setNodeStatus(ctxDir, childNodeId, isFailed(r) ? "failed" : "done"); // A child may itself have queued spawns — recurse (depth-capped by the tool). const grand = drainPendingSpawns(ctxDir, childNodeId); - if (grand.length > 0 && !deps.signal?.aborted && !overBudget(state).over) { - const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run); + if (grand.length > 0 && !deps.signal?.aborted && !spawnedOverBudget(state, ledger.usage)) { + const rec = await runSpawnedChildren(grand, ctxDir, childNodeId, phase, deps, state, run, ledger); if (rec.reports) out += rec.reports; usages.push(rec.usage); } @@ -809,8 +866,15 @@ async function executePhase( opts?: PhaseExecOpts, ): Promise { // Trace: phase-start (fail-open). Record whether this phase is replayable - // up front so a future replay can short-circuit it without walking events. - traceEmit(deps, { ts: Date.now(), runId: state.runId, phaseId: phase.id, kind: "phase-start" }); + // up front so replay can short-circuit it without guessing from output. + traceEmit(deps, { + ts: Date.now(), + runId: state.runId, + phaseId: phase.id, + kind: "phase-start", + dependencies: dependenciesOf(phase), + optional: phase.optional === true, + }); if (deps.trace) emitUnreplayableMarker(deps, state, phase); let result: PhaseState; let threw = false; @@ -857,6 +921,7 @@ async function executePhaseImpl( // every type branch inside executePhaseInner is covered. A skipped phase ran // nothing — no side effect to record. const stamp = (ps: PhaseState): PhaseState => { + if (phase.optional === true) ps.optional = true; if (phase.idempotent === false && ps.status !== "skipped") { ps.sideEffect = true; // Resume double-fire warning (issue #20): a non-idempotent phase is never @@ -985,7 +1050,7 @@ async function executePhaseInner( // each run (schema already rejects explicit cross-run, but the default-scope // path must also be blocked). If flowDefHash failed, cross-run is unsafe // because the key degrades to flowName-only and reopens cross-flow collisions. - const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script"]); + const CROSS_RUN_BLOCKED_TYPES = new Set(["gate", "approval", "loop", "tournament", "script", "race", "expand"]); if (cacheScope === "cross-run" && CROSS_RUN_BLOCKED_TYPES.has(type)) { cacheScope = "run-only"; } @@ -1015,6 +1080,9 @@ async function executePhaseInner( thinking: phase.thinking, tools: phase.tools, preRead, + agentScope: state.def.agentScope, + contextSharing: state.def.contextSharing === true, + agentDefinitions: agentDefinitionsIdentity(deps.agents), }; const baseRun = (agentName: string, task: string, onLive?: (l: LiveUpdate) => void, ctxNodeId?: string, signal?: AbortSignal) => @@ -1967,6 +2035,19 @@ async function executePhaseInner( await import("./runtime/phases/expand.ts"); const expandMode = type === "expand" ? resolveExpandMode(phase) : "nested"; const maxNodes = type === "expand" ? resolveMaxNodes(phase, MAX_DYNAMIC_PHASES) : 50; + if (type === "expand" && expandMode === "graft") { + // Rerun replacement semantics: prior promoted children belong to the old + // fragment and must disappear before ANY new resolution path. This is + // intentionally before parse/validation/empty/sub-flow failure returns so + // stale children and their usage cannot survive a failed or empty v2 plan. + const declaredIds = new Set(state.def.phases.map((p) => p.id)); + for (const oldId of Object.keys(prior?.promotedPhases ?? {})) { + // Definition evolution may promote an old dynamic id into a real + // authored parent phase. That declared phase owns its state now and must + // never be deleted by stale graft metadata. + if (!declaredIds.has(oldId)) delete state.phases[oldId]; + } + } let subDef: Taskflow | undefined; let name: string; @@ -2079,20 +2160,32 @@ async function executePhaseInner( provided[k] = typeof v === "string" ? interpolate(v, ctx).text : v; } const subArgs = resolveArgs(subDef, provided); - // For inline defs the cache identity must include the resolved def content so - // that a different generated plan yields a different key (and an identical plan - // hits cache). For saved flows the name is the identity (historical behavior). - const flowIdentity = hasDef ? `def:${JSON.stringify(subDef)}` : `flow:${name}`; + // Every sub-flow cache identity includes the resolved definition. A saved + // flow's name alone is insufficient: its contents can change without the + // parent definition moving. + const flowIdentity = `${hasDef ? "def" : "flow"}:${name}:${JSON.stringify(subDef)}`; const ck = cacheKeys(cc, [phase.id, flowIdentity, preRead, JSON.stringify(subArgs)]); const inputHash = ck.key; const cached = cachedPhase(cc, ck); - if (cached) return cached; + if (cached) { + if (type === "expand" && expandMode === "graft" && cached.promotedPhases) { + const promo = promoteGraftPhases(state, cached.promotedPhases); + if (promo.promotedIds.length > 0) { + cached.promotedPhases = Object.fromEntries( + promo.promotedIds.map((id) => [id, { ...cached.promotedPhases![id] }]), + ); + } else { + delete cached.promotedPhases; + } + } + return cached; + } const live = state.phases[phase.id]; - // Sub-flows enforce their own budget; if they declare none, inherit the - // parent cap as a soft per-flow ceiling (best-effort — spend does not cross - // flow boundaries, so the parent's already-spent total is not subtracted). - const subDefEffective = subDef.budget || !state.def.budget ? subDef : { ...subDef, budget: state.def.budget }; + // A nested flow receives only the parent's remaining allowance, then its + // own cap (if any) may tighten each dimension independently. + const parentSpent = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage())); + const subDefEffective = clampSubFlowBudget(subDef, state.def.budget, parentSpent); const subState: RunState = { runId: newRunId(subDef.name), flowName: subDef.name, @@ -2111,6 +2204,9 @@ async function executePhaseInner( baseRunTask(cwd, agents, agentName, preRead + subTask, opts, globalThinking); const subResult = await executeTaskflow(subState, { ...deps, + // A trace file is scoped to one runId. The parent flow phase carries an + // unreplayable marker; nested events must not be mixed into that file. + trace: undefined, // Override deps.cwd with the flow phase's own cwd so that sub-flow // phases without an explicit cwd derive their subagents from the // flow's cwd (not the caller's cwd). @@ -2144,17 +2240,24 @@ async function executePhaseInner( const sp = Object.values(subState.phases); // expand graft promote — pure helper (see runtime/phases/expand.ts) const warnings: string[] = []; - let graftPromoted = 0; + let graftPromotedIds: string[] = []; if (type === "expand" && expandMode === "graft" && subResult.ok) { const promo = promoteGraftPhases(state, subState.phases); warnings.push(...promo.warnings); - graftPromoted = promo.promoted; + graftPromotedIds = promo.promotedIds; } - // Graft: children carry usage after promote — zero expand usage to avoid double-count - // in run-level aggregateUsage(Object.values(state.phases)). + // Graft accounting is ownership-based. Successfully promoted children carry + // their own usage in parent state; children skipped on id collision remain + // owned by the expand phase and their residual usage must stay here. This + // yields: all promoted => 0, all collision => all child usage, mixed => only + // collision residual (no loss and no double count). const phaseUsage = - type === "expand" && expandMode === "graft" && subResult.ok && graftPromoted > 0 - ? emptyUsage() + type === "expand" && expandMode === "graft" && subResult.ok + ? aggregateUsage( + Object.entries(subState.phases) + .filter(([id]) => !graftPromotedIds.includes(id)) + .map(([, ps]) => ps.usage ?? emptyUsage()), + ) : subResult.totalUsage; const flowPs: PhaseState = { id: phase.id, @@ -2176,6 +2279,9 @@ async function executePhaseInner( reads: readRefsToReads(readRefs, state), endedAt: Date.now(), ...(warnings.length ? { warnings } : {}), + ...(type === "expand" && expandMode === "graft" && subResult.ok && graftPromotedIds.length > 0 + ? { promotedPhases: Object.fromEntries(graftPromotedIds.map((id) => [id, { ...subState.phases[id] }])) } + : {}), }; recordCache(cc, flowPs); return flowPs; @@ -2596,6 +2702,13 @@ export interface PhaseCacheCtx { * whether a given branch happens to fold preRead into its task string * (previously this was only incidentally true via `fullTask`). */ preRead?: string; + /** Flow-level semantics retained even by per-item cache contexts, which + * deliberately omit phaseFp/flowDefHash for partial reuse. */ + agentScope?: Taskflow["agentScope"]; + contextSharing?: boolean; + /** Resolved agent content/config. Same name+scope can still change prompt, + * model, tools, or thinking and must invalidate cached output. */ + agentDefinitions?: string; /** Content fingerprint of the desugared flow definition — folded into the * key so two structurally-different flows that share a name can never * collide, and a changed flow never serves a stale cross-run hit. */ @@ -2613,12 +2726,31 @@ export interface PhaseCacheCtx { forceRerun?: boolean; } +/** Stable cache identity for the fully resolved agent pool. File paths are + * excluded: content/config, not installation location, determines output. */ +export function agentDefinitionsIdentity(agents: readonly AgentConfig[]): string { + return JSON.stringify( + agents + .map((a) => ({ + name: a.name, + description: a.description, + systemPrompt: a.systemPrompt, + model: a.model ?? "", + thinking: a.thinking ?? "", + tools: [...(a.tools ?? [])].sort(), + source: a.source, + })) + .sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)), + ); +} + /** Fold the phase fingerprint into the base hash parts to form the final cache key. */ /** A computed cache identity: the new (versioned) key plus the read-only * fallback keys used to honor entries written by older releases. The `key` * is what we WRITE under and what `PhaseState.inputHash` carries; the - * `v2Key`/`bareKey`/`legacyKey` are consulted READ-ONLY on a miss so an - * upgrade never produces a miss-storm. See docs/internal/cache-migration.md. */ + * `v2Key`/`bareKey` are consulted READ-ONLY on a miss. `legacyKey` is exposed + * only for tooling/tests and is never read because it lacks structural + * identity. See docs/internal/cache-migration.md. */ export interface CacheKeys { /** Current key: folds `v3:phasefp:` (the per-phase structural * sub-fingerprint; degrades to the whole-flow hash when per-phase @@ -2636,7 +2768,7 @@ export interface CacheKeys { /** Fold the phase fingerprint into the base hash parts to form the cache keys. * - * Four keys are produced for backward compatibility (see + * Four keys are derived for tooling/backward compatibility (see * docs/internal/cache-migration.md): * - `key` : `v3:phasefp:` — the current write key (per-phase * structural sub-fingerprint; falls back to the whole-flow hash when @@ -2644,9 +2776,9 @@ export interface CacheKeys { * - `v2Key` : `v2:flowdef:` — pre-M6 whole-flow key. * - `bareKey` : bare `flowdef:` (unversioned) — pre-H1 entries. * - `legacyKey`: the flowdef line omitted — pre-flowDefHash entries. - * `cachedPhase` consults all four READ-ONLY on a miss; `recordCache` writes - * only `key`. This means an upgrade never produces a miss-storm: existing - * entries (whichever shape) still hit, and new writes converge on `key`. */ + * `cachedPhase` consults the first three safe tiers READ-ONLY on a miss; + * `legacyKey` is never read because it has no structural identity. + * `recordCache` writes only `key`. */ export function cacheKeys(cc: PhaseCacheCtx, baseParts: string[]): CacheKeys { // Fold the full cache identity into the hash: flow name (prevents collisions // across different flows that share a phase.id + task + model), the per-phase @@ -2657,6 +2789,9 @@ export function cacheKeys(cc: PhaseCacheCtx, baseParts: string[]): CacheKeys { `think:${cc.thinking ?? ""}`, `tools:${JSON.stringify(cc.tools ?? [])}`, `ctx:${cc.preRead ?? ""}`, + `agent-scope:${cc.agentScope ?? "user"}`, + `context-sharing:${cc.contextSharing === true ? "1" : "0"}`, + `agents:${cc.agentDefinitions ?? ""}`, ]; const fold = (parts: string[]): string => cc.fingerprint ? hashInput(...parts, cc.fingerprint) : hashInput(...parts); @@ -2677,13 +2812,13 @@ export function cacheKeys(cc: PhaseCacheCtx, baseParts: string[]): CacheKeys { * - "off": never reuse (even within-run). * - "run-only": within-run resume only (historical behavior). * - "cross-run": within-run first, then the persistent cross-run store. - * On a cross-run hit, usage is zeroed and `cacheHit` records the source. - * - * The cross-run read is FOUR-TIER and READ-ONLY for fallback keys: it tries + * On a cross-run hit, usage is zeroed and `cacheHit` records the source. + * + * The cross-run read is three-tier and READ-ONLY for fallback keys: it tries * `keys.key` (current `v3:phasefp:` shape) first, then `keys.v2Key` (pre-M6 - * `v2:flowdef:`), then `keys.bareKey` (pre-H1 bare `flowdef:`), then - * `keys.legacyKey` (pre-flowDefHash, no flowdef line). - * A hit on ANY tier is restored as a cache hit; we do NOT write-through (no + * `v2:flowdef:`), then `keys.bareKey` (pre-H1 bare `flowdef:`). The older + * no-flowdef key is deliberately unsafe and ignored. + * A hit on any safe tier is restored as a cache hit; we do NOT write-through (no * re-store under the new key) so the cache size stays stable and the legacy * entry ages out naturally. See docs/internal/cache-migration.md. */ @@ -2699,9 +2834,13 @@ function cachedPhase(cc: PhaseCacheCtx, keys: CacheKeys): PhaseState | null { return { ...cc.prior, status: "done", cacheHit: "run-only" }; } - // 2. cross-run memoization (opt-in) — four-tier read-only fallback. + // 2. cross-run memoization (opt-in) — three safe read-only tiers. if (cc.scope === "cross-run") { - for (const k of [keys.key, keys.v2Key, keys.bareKey, keys.legacyKey]) { + // The pre-flow-definition legacy key is intentionally NOT read: it omits + // all structural identity and can return stale output after any semantic + // flow change. v2/bare remain safe because they include today's definition + // hash; old entries whose historical hash omitted new fields simply miss. + for (const k of [keys.key, keys.v2Key, keys.bareKey]) { const e = cc.store.get(k, cc.ttlMs); if (!e) continue; // If we stored the full PhaseState, restore it (preserving gate, @@ -3098,10 +3237,28 @@ export async function recomputeTaskflow( export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promise { const def: Taskflow = state.def; + const runnerUsageAccounting = (deps.runTask as (RunTaskFn & { usageAccounting?: "available" | "unavailable" }) | undefined) + ?.usageAccounting; + if (!deps.usageAccounting && runnerUsageAccounting) { + // Preserve a runner-advertised capability across the wrapper functions used + // by nested flow/context execution; those wrappers would otherwise erase a + // property attached to the original runTask function. + deps = { ...deps, usageAccounting: runnerUsageAccounting }; + } try { + if (deps.usageAccounting === "unavailable" && def.budget) { + throw new Error( + `Usage accounting is unavailable for this host; refusing budgeted flow '${def.name}' because its token/USD ceiling cannot be enforced`, + ); + } // S2 strangler (default OFF): all phase kinds may use the event kernel when enabled. const { eventKernelEnabled, canUseEventKernel, runEventKernel } = await import("./exec/driver.ts"); - if (eventKernelEnabled(deps) && canUseEventKernel(def)) { + // Existing phase state requires the imperative cache/inputHash machinery to + // validate definition and idempotency before reuse. The event kernel does + // not yet persist compatible input hashes, so it must never blindly trust a + // prior `done` row. + const hasPriorState = Object.keys(state.phases).length > 0; + if (eventKernelEnabled(deps) && !hasPriorState && canUseEventKernel(def, deps.loadFlow)) { if (!deps.runTask) { throw new Error("event kernel requires RuntimeDeps.runTask"); } @@ -3111,6 +3268,7 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi runTask: deps.runTask, signal: deps.signal, globalThinking: deps.globalThinking, + usageAccounting: deps.usageAccounting, trace: deps.trace, persist: deps.persist, onProgress: deps.onProgress, @@ -3141,6 +3299,31 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise { const def: Taskflow = state.def; + // Ownership migration must happen before ANY phase in the new definition is + // scheduled. Definition evolution may remove/rename ordinary phases as well + // as graft children; neither their terminal failure nor their usage may leak + // into the new run. Dynamic promoted state is intentionally cleared here too + // and is restored only by its owning expand phase (from its current result or + // cache). An id newly promoted to a real authored phase is preserved because + // it is present in `declaredPhaseIds` and the scheduler will validate/rerun it. + // Cleaning inside the expand phase would be too late: an unrelated authored + // phase may already have been scheduled and stale usage already counted. + const declaredPhaseIds = new Set(def.phases.map((p) => p.id)); + // A pre-seeded state may intentionally supply an external dependency (for + // example an embedding host injects `src` and the definition starts at a map + // that depends on it). Those ids are part of the new definition's dependency + // contract even though they have no executable Phase row, so preserve them. + const externalDependencyIds = new Set( + def.phases.flatMap((phase) => dependenciesOf(phase)).filter((id) => !declaredPhaseIds.has(id)), + ); + for (const oldId of Object.keys(state.phases)) { + if (!declaredPhaseIds.has(oldId) && !externalDependencyIds.has(oldId)) delete state.phases[oldId]; + } + for (const previous of Object.values(state.phases)) { + for (const oldId of Object.keys(previous.promotedPhases ?? {})) { + if (!declaredPhaseIds.has(oldId)) delete state.phases[oldId]; + } + } const layers = topoLayers(def.phases); // Content-fingerprint the desugared definition ONCE per run and fold it into // every phase's cache key (overstory hash algorithm; see ./flowir/hash.ts). @@ -3154,17 +3337,21 @@ async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise e.message).join("; ")}`, - ); - } - } catch (e) { + try { + const ir = await compileTaskflowToIR(def); + const nextHash = ir.hash ?? "failed"; + if (state.flowDefHash !== nextHash) { + state.flowDefHash = nextHash; + state.phaseFingerprints = undefined; + } + state.declaredDeps = ir.meta.declaredDeps; + if (ir.errors.length) { + console.warn( + `[taskflow] IR compile errors for '${def.name}': ${ir.errors.map((e) => e.message).join("; ")}`, + ); + } + } catch (e) { + if (state.flowDefHash === undefined) { // Fail-safe: warn loudly rather than silently degrading to the legacy // flowName-only key, which would reopen the cross-flow collision hole. console.warn( @@ -3249,9 +3436,19 @@ async function runTaskflowLayers(state: RunState, deps: RuntimeDeps): Promise p.status === "failed" && !byId.get(id)?.optional, + ([id, p]) => p.status === "failed" && !byId.get(id)?.optional && !p.optional, ); state.status = aborted diff --git a/packages/taskflow-core/src/runtime/phases/expand.ts b/packages/taskflow-core/src/runtime/phases/expand.ts index 5d11ead..7fa3601 100644 --- a/packages/taskflow-core/src/runtime/phases/expand.ts +++ b/packages/taskflow-core/src/runtime/phases/expand.ts @@ -103,23 +103,31 @@ export function prefixGraftFragment(fragment: Taskflow, expandPhaseId: string): export function promoteGraftPhases( parent: RunState, childPhases: Record, -): { promoted: number; warnings: string[] } { +): { promoted: number; promotedIds: string[]; warnings: string[] } { const warnings: string[] = []; let promoted = 0; + const promotedIds: string[] = []; + // Collision ownership is defined by the authored definition, not by which + // authored phases happen to have reached the scheduler and acquired state + // rows. A graft may execute before a later authored phase with the same id; + // promoting into that gap would steal ownership and zero the expand's residual + // usage even though the authored phase subsequently overwrites the row. + const authoredIds = new Set(parent.def.phases.map((phase) => phase.id)); for (const [cid, cps] of Object.entries(childPhases)) { - if (parent.phases[cid]) { - warnings.push(`expand graft skipped promote of '${cid}' (id already exists on parent)`); + if (authoredIds.has(cid) || parent.phases[cid]) { + warnings.push(`expand graft skipped promote of '${cid}' (id is authored or already exists on parent)`); continue; } // Keep child usage for audit; expand phase usage must be zeroed by caller // so run-level aggregateUsage does not double-count. parent.phases[cid] = { ...cps, id: cid }; promoted++; + promotedIds.push(cid); } if (promoted > 0) { warnings.push(`expand graft: promoted ${promoted} phase(s) onto parent run`); } - return { promoted, warnings }; + return { promoted, promotedIds, warnings }; } /** Empty usage helper for expand phase after graft (avoid double-count in rollup). */ diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index e8d0fc9..edeed02 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -86,6 +86,9 @@ export type PhaseStatus = "pending" | "running" | "done" | "failed" | "skipped"; export interface PhaseState { id: string; status: PhaseStatus; + /** Persisted execution policy for dynamically promoted phases. Parent-level + * terminal accounting cannot look these ids up in the original definition. */ + optional?: boolean; output?: string; json?: unknown; usage?: UsageStats; @@ -139,6 +142,9 @@ export interface PhaseState { /** Set when a `flow { def }` inline sub-flow definition could not be resolved, * parsed, validated, or verified. The phase fails-open: this records why. */ defError?: string; + /** Child states promoted by an expand:graft phase. Retained on the expand + * result so a within-run resume cache hit can restore the same parent DAG. */ + promotedPhases?: Record; /** Non-fatal diagnostic warnings accumulated during this phase (e.g. * unresolved interpolation placeholders, suspicious templates). */ warnings?: string[]; diff --git a/packages/taskflow-core/src/trace.ts b/packages/taskflow-core/src/trace.ts index 9ad1ec9..7571b62 100644 --- a/packages/taskflow-core/src/trace.ts +++ b/packages/taskflow-core/src/trace.ts @@ -57,6 +57,10 @@ export interface TraceEvent { runId: string; phaseId: string; kind: "phase-start" | "phase-end" | "subagent-call" | "decision"; + /** Static graph metadata emitted on phase-start. It lets offline replay + * propagate counterfactual gate/budget outcomes without importing runtime. */ + dependencies?: string[]; + optional?: boolean; // — subagent-call (the load-bearing record a replay consumes) — input?: { agent: string; diff --git a/packages/taskflow-core/test/cache-migration.test.ts b/packages/taskflow-core/test/cache-migration.test.ts index ad17797..ea48469 100644 --- a/packages/taskflow-core/test/cache-migration.test.ts +++ b/packages/taskflow-core/test/cache-migration.test.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { test } from "node:test"; import type { AgentConfig } from "../src/agents.ts"; import { CacheStore } from "../src/cache.ts"; -import { executeTaskflow, cacheKeys, type PhaseCacheCtx, type RuntimeDeps } from "../src/runtime.ts"; +import { agentDefinitionsIdentity, executeTaskflow, cacheKeys, type PhaseCacheCtx, type RuntimeDeps } from "../src/runtime.ts"; import type { RunResult, RunOptions } from "../src/runner-core.ts"; import type { Taskflow } from "../src/schema.ts"; import type { RunState } from "../src/store.ts"; @@ -67,6 +67,7 @@ async function ccFor(def: Taskflow, cwd: string, store: CacheStore, phaseId: str runId: "seed", flowDefHash: fdh, phaseFp: subfp, + agentDefinitions: agentDefinitionsIdentity(AGENTS), }; } @@ -95,10 +96,11 @@ test("cacheKeys: key, v2Key, bareKey, legacyKey are all distinct (M6 4-tier)", a }); // --------------------------------------------------------------------------- -// Legacy fallback: a pre-flowDefHash-era entry (no flowdef line) still hits. +// Legacy safety: a pre-flowDefHash-era entry has no structural identity and +// must never be trusted after an upgrade. // --------------------------------------------------------------------------- -test("cache migration: legacy (no-flowdef) entry still hits via fallback", async () => { +test("cache migration: legacy (no-flowdef) entry is ignored to prevent stale reuse", async () => { const dir = tmpDir(); const def: Taskflow = { name: "legacy", @@ -121,13 +123,14 @@ test("cache migration: legacy (no-flowdef) entry still hits via fallback", async runId: "old", }); - // Run — must hit the legacy entry (NOT execute). counter stays 0. + // Run — must execute and write the current structurally-versioned key. const counter = { n: 0 }; const deps: RuntimeDeps = { cwd: dir, agents: AGENTS, runTask: countingRunner(counter), cacheStore: store }; const r = await executeTaskflow(mkState(def, dir), deps); - assert.equal(counter.n, 0, "legacy entry must hit — no execution"); - assert.equal(r.state.phases.p.cacheHit, "cross-run"); - assert.equal(r.state.phases.p.output, "LEGACY-OUTPUT"); + assert.equal(counter.n, 1, "unsafe legacy entry must not be served"); + assert.equal(r.state.phases.p.cacheHit, undefined); + assert.equal(r.state.phases.p.output, "out:fixed#1"); + assert.ok(store.get(ck.key), "fresh output is stored under the current key"); fs.rmSync(dir, { recursive: true, force: true }); }); @@ -167,10 +170,10 @@ test("cache migration: bare (unversioned flowdef) entry still hits via 3rd-tier }); // --------------------------------------------------------------------------- -// Read-only fallback: a legacy hit does NOT write-through under the new key. +// Unsafe legacy data is left untouched while a fresh current-key entry is written. // --------------------------------------------------------------------------- -test("cache migration: legacy hit is read-only (no write-through under v2 key)", async () => { +test("cache migration: unsafe legacy entry is not read and fresh result uses current key", async () => { const dir = tmpDir(); const def: Taskflow = { name: "no-write-through", @@ -190,9 +193,11 @@ test("cache migration: legacy hit is read-only (no write-through under v2 key)", const deps: RuntimeDeps = { cwd: dir, agents: AGENTS, runTask: countingRunner(counter), cacheStore: store }; await executeTaskflow(mkState(def, dir), deps); - // The v2 key file must NOT have been created (no write-through). + // The current key contains a newly executed result, not the legacy value. const v2Entry = store.get(ck.key); - assert.equal(v2Entry, null, "no v2 write-through — legacy entry left to age out"); + assert.ok(v2Entry, "fresh current-key entry was written"); + assert.equal(v2Entry?.output, "out:fixed#1"); + assert.equal(counter.n, 1); // The legacy entry is still there. assert.ok(store.get(ck.legacyKey), "legacy entry untouched"); fs.rmSync(dir, { recursive: true, force: true }); diff --git a/packages/taskflow-core/test/cache-peritem.test.ts b/packages/taskflow-core/test/cache-peritem.test.ts index b375f77..0633c97 100644 --- a/packages/taskflow-core/test/cache-peritem.test.ts +++ b/packages/taskflow-core/test/cache-peritem.test.ts @@ -23,7 +23,7 @@ import * as path from "node:path"; import { test } from "node:test"; import type { AgentConfig } from "../src/agents.ts"; import { CacheStore } from "../src/cache.ts"; -import { cacheKeys, executeTaskflow, summarizeReuse, type PhaseCacheCtx, type RuntimeDeps } from "../src/runtime.ts"; +import { agentDefinitionsIdentity, cacheKeys, executeTaskflow, summarizeReuse, type PhaseCacheCtx, type RuntimeDeps } from "../src/runtime.ts"; import type { RunOptions, RunResult } from "../src/runner-core.ts"; import type { Taskflow } from "../src/schema.ts"; import type { RunState } from "../src/store.ts"; @@ -377,6 +377,7 @@ test("per-item: a budget-skipped item is never recorded as a per-item cache entr flowName: def.name, runId: r1.state.runId, flowDefHash: undefined, + agentDefinitions: agentDefinitionsIdentity(AGENTS), phaseFp: undefined, thinking: undefined, tools: undefined, diff --git a/packages/taskflow-core/test/cache-phasefp.test.ts b/packages/taskflow-core/test/cache-phasefp.test.ts index 81fa394..62b1cfa 100644 --- a/packages/taskflow-core/test/cache-phasefp.test.ts +++ b/packages/taskflow-core/test/cache-phasefp.test.ts @@ -6,7 +6,7 @@ import { test } from "node:test"; import type { AgentConfig } from "../src/agents.ts"; import { CacheStore } from "../src/cache.ts"; import { phaseFingerprint } from "../src/flowir/index.ts"; -import { executeTaskflow, cacheKeys, type PhaseCacheCtx, type RuntimeDeps } from "../src/runtime.ts"; +import { agentDefinitionsIdentity, executeTaskflow, cacheKeys, type PhaseCacheCtx, type RuntimeDeps } from "../src/runtime.ts"; import type { RunResult, RunOptions } from "../src/runner-core.ts"; import type { Taskflow } from "../src/schema.ts"; import type { RunState } from "../src/store.ts"; @@ -209,6 +209,7 @@ test("phasefp: pre-v3 (v2) entry still hits — no miss-storm", async () => { scope: "cross-run", fingerprint: "", store, prior: undefined, phaseId: "p", flowName: def.name, runId: "old", flowDefHash: ir.hash, phaseFp: (await phaseFingerprint(def, "p")) ?? ir.hash, + agentDefinitions: agentDefinitionsIdentity(AGENTS), thinking: undefined, tools: undefined, preRead: "", }; const ck = cacheKeys(cc, ["p", "a", "", "fixed"]); diff --git a/packages/taskflow-core/test/exec-driver.test.ts b/packages/taskflow-core/test/exec-driver.test.ts index 9f51727..21c3cdd 100644 --- a/packages/taskflow-core/test/exec-driver.test.ts +++ b/packages/taskflow-core/test/exec-driver.test.ts @@ -63,7 +63,7 @@ test("canUseEventKernel: all kinds including gate", () => { name: "ok", phases: [ { id: "a", type: "agent", agent: "a", task: "t" }, - { id: "s", type: "script", run: ["node", "-e", "1"], final: true }, + { id: "s", type: "script", run: ["node", "-e", "1"], dependsOn: ["a"], final: true }, ], }), true, diff --git a/packages/taskflow-core/test/exec-kernel-hardening.test.ts b/packages/taskflow-core/test/exec-kernel-hardening.test.ts index dff9831..453cdcd 100644 --- a/packages/taskflow-core/test/exec-kernel-hardening.test.ts +++ b/packages/taskflow-core/test/exec-kernel-hardening.test.ts @@ -257,7 +257,7 @@ test("flow cycle A→B→A fails on kernel", async () => { loadFlow: (n) => flows[n], }); assert.equal(res.ok, false); - assert.match(res.state.phases.f.error ?? "", /recursive/i); + assert.match(res.state.phases.f.error ?? "", /recursive|sub-flow.*failed/i); }); test("dynamic flow def with script fails open (done empty) not ACE", async () => { @@ -287,9 +287,7 @@ test("dynamic flow def with script fails open (done empty) not ACE", async () => assert.equal(res.state.phases.f.output ?? "", ""); }); -test("nested flow re-admission: race child rejected under event kernel", async () => { - // Parent is kernel-eligible (plain flow wrapper); child has race → must not - // silently enter kernel step with wrong semantics. +test("parent admission recursively routes inline race child to imperative runtime", async () => { const def: Taskflow = { name: "parent-kernel", phases: [ @@ -314,7 +312,7 @@ test("nested flow re-admission: race child rejected under event kernel", async ( }, ], }; - assert.equal(canUseEventKernel(def), true, "parent without race is kernel-eligible"); + assert.equal(canUseEventKernel(def), false, "unsupported child makes the parent kernel-ineligible"); const res = await executeTaskflow(mk(def), { cwd: process.cwd(), agents: AGENTS, @@ -322,8 +320,18 @@ test("nested flow re-admission: race child rejected under event kernel", async ( persist: () => {}, eventKernel: true, }); - // Nested admission fails closed — phase fails with clear reason. - assert.equal(res.ok, false); - assert.equal(res.state.phases.f?.status, "failed"); - assert.match(res.state.phases.f?.error ?? res.state.phases.f?.output ?? "", /event kernel|race|Nested flow/i); + assert.equal(res.ok, true); + assert.equal(res.state.phases.f?.status, "done"); +}); + +test("parent admission recursively inspects saved sub-flow", () => { + const parent: Taskflow = { + name: "saved-parent", + phases: [{ id: "f", type: "flow", use: "saved-child", final: true }], + }; + const child: Taskflow = { + name: "saved-child", + phases: [{ id: "r", type: "race", branches: [{ task: "a" }, { task: "b" }], final: true }], + }; + assert.equal(canUseEventKernel(parent, () => child), false); }); diff --git a/packages/taskflow-core/test/exec-kernel-parity.test.ts b/packages/taskflow-core/test/exec-kernel-parity.test.ts index 5140b73..e898776 100644 --- a/packages/taskflow-core/test/exec-kernel-parity.test.ts +++ b/packages/taskflow-core/test/exec-kernel-parity.test.ts @@ -62,13 +62,13 @@ async function runBoth(def: Taskflow) { return { kernel, imp }; } -test("canUseEventKernel: map+parallel+gate all accepted (S2 complete)", () => { +test("canUseEventKernel: linear map+parallel+gate flows are accepted", () => { assert.equal( canUseEventKernel({ name: "ok", phases: [ { id: "m", type: "map", agent: "a", over: '["x"]', task: "{item}" }, - { id: "p", type: "parallel", branches: [{ task: "t1" }, { task: "t2" }], final: true }, + { id: "p", type: "parallel", branches: [{ task: "t1" }, { task: "t2" }], dependsOn: ["m"], final: true }, ], }), true, diff --git a/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts index 571759e..060fbaf 100644 --- a/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts +++ b/packages/taskflow-core/test/exec-kernel-s2-complete.test.ts @@ -65,6 +65,7 @@ test("canUseEventKernel: classic kinds accepted; race/expand force imperative pa name: "all", phases: classic.map((type, i) => { const base: Record = { id: `p${i}`, type, final: i === classic.length - 1 }; + if (i > 0) base.dependsOn = [`p${i - 1}`]; if (type === "script") base.run = ["node", "-e", "1"]; if (type === "map") { base.over = '["x"]'; @@ -82,7 +83,7 @@ test("canUseEventKernel: classic kinds accepted; race/expand force imperative pa if (type === "flow") base.use = "child"; return base; }) as Taskflow["phases"], - }), + }, () => ({ name: "child", phases: [{ id: "p", task: "x", final: true }] })), true, ); assert.equal( diff --git a/packages/taskflow-core/test/race-expand.test.ts b/packages/taskflow-core/test/race-expand.test.ts index 06b4aeb..5766cd2 100644 --- a/packages/taskflow-core/test/race-expand.test.ts +++ b/packages/taskflow-core/test/race-expand.test.ts @@ -367,7 +367,7 @@ test("expand nested: runs fragment as sub-flow", async () => { }); assert.equal(st.status, "completed"); assert.equal(st.phases.e?.status, "done"); - assert.equal(st.phases.e?.defError, undefined, st.phases.e?.defError); + assert.equal(st.phases.e?.defError, undefined, st.phases.e?.defError ?? "unexpected expand defError"); assert.match(st.phases.e?.output ?? "", /nested-hi/); assert.equal(st.phases.inner, undefined); }); @@ -396,7 +396,7 @@ test("expand graft: promotes child phases onto parent", async () => { }); assert.equal(st.status, "completed"); assert.equal(st.phases.grow?.status, "done"); - assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError); + assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError ?? "unexpected expand defError"); assert.equal(st.phases["grow-leaf"]?.status, "done"); assert.match(st.phases["grow-leaf"]?.output ?? "", /grafted-ok/); // No usage double-count: expand usage zeroed; child holds cost @@ -442,8 +442,8 @@ test("expand graft: multi-phase rewrites {steps.*} and avoids usage double-count return "x"; }), }); - assert.equal(st.status, "completed", st.phases.grow?.defError ?? st.phases.grow?.error); - assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError); + assert.equal(st.status, "completed", st.phases.grow?.defError ?? st.phases.grow?.error ?? "expand did not complete"); + assert.equal(st.phases.grow?.defError, undefined, st.phases.grow?.defError ?? "unexpected expand defError"); assert.equal(st.phases["grow-a"]?.status, "done"); assert.equal(st.phases["grow-b"]?.status, "done"); assert.match(st.phases["grow-b"]?.output ?? "", /A-VALUE/); diff --git a/packages/taskflow-core/test/replay.test.ts b/packages/taskflow-core/test/replay.test.ts index 7c68406..47820dc 100644 --- a/packages/taskflow-core/test/replay.test.ts +++ b/packages/taskflow-core/test/replay.test.ts @@ -56,6 +56,31 @@ test("replayRun: stricter threshold flips pass → would-block", () => { assert.equal(d.replayedOutcome, "block"); }); +test("replayRun: later cache/budget decisions do not erase gate-score history", () => { + const log: Event[] = [ + ev({ kind: "phase-start", phaseId: "review", dependencies: [] }), + ev({ + kind: "decision", + phaseId: "review", + decision: { + type: "gate-score", + target: "quality", + results: [], + combined: 0.6, + threshold: 0.5, + verdict: "pass", + }, + }), + ev({ kind: "decision", phaseId: "review", decision: { type: "cache-hit", scope: "run-only" } }), + ev({ kind: "decision", phaseId: "review", decision: { type: "budget-hit", value: "budget" } }), + ev({ kind: "phase-end", phaseId: "review", status: "done" }), + ]; + const report = replayRun(log, { thresholds: { review: 0.9 } }); + assert.equal(report.decisions[0]?.outcome, "would-block"); + assert.equal(report.replayed.phases.review.status, "blocked"); + assert.equal(report.replayed.phases.review.decision?.type, "gate-score"); +}); + test("replayRun: looser threshold with same verdict → threshold-changed or reused path", () => { // combined 0.6, old threshold 0.5, new 0.55 → still pass const report = replayRun(gateLog, { thresholds: { review: 0.55 } }); @@ -99,3 +124,409 @@ test("replayRun: golden fixture — threshold flip on recorded gate-score", asyn assert.equal(d.outcome, "would-block"); assert.ok(flipped.totalUsage.cost > 0); }); + +test("replayRun: threshold block propagates to transitive downstream phases", () => { + const log: Event[] = [ + ev({ kind: "phase-start", phaseId: "review", dependencies: [] }), + ev({ + kind: "decision", + phaseId: "review", + decision: { + type: "gate-score", + target: "quality", + results: [], + combined: 0.6, + threshold: 0.5, + verdict: "pass", + }, + }), + ev({ kind: "phase-end", phaseId: "review", status: "done" }), + ev({ kind: "phase-start", phaseId: "report", dependencies: ["review"] }), + ev({ kind: "phase-end", phaseId: "report", status: "done" }), + ev({ kind: "phase-start", phaseId: "publish", dependencies: ["report"] }), + ev({ kind: "phase-end", phaseId: "publish", status: "done" }), + ]; + const report = replayRun(log, { thresholds: { review: 0.9 } }); + assert.equal(report.replayed.phases.review.status, "blocked"); + assert.equal(report.replayed.phases.report.status, "skipped"); + assert.equal(report.replayed.phases.publish.status, "skipped"); + assert.equal(report.decisions.find((d) => d.phaseId === "report")?.outcome, "would-skip"); + assert.deepEqual(report.decisions.find((d) => d.phaseId === "publish")?.causedBy, ["review"]); + assert.notStrictEqual(report.replayed, report.baseline); +}); + +test("replayRun: recorded gate BLOCK flipped to PASS makes previously skipped descendants live", () => { + const log: Event[] = [ + ev({ kind: "phase-start", phaseId: "review", dependencies: [] }), + ev({ + kind: "decision", + phaseId: "review", + decision: { + type: "gate-score", + target: "quality", + results: [], + combined: 0.4, + threshold: 0.5, + verdict: "block", + }, + }), + ev({ kind: "phase-end", phaseId: "review", status: "blocked" }), + ev({ kind: "phase-start", phaseId: "draft", dependencies: ["review"] }), + ev({ kind: "phase-end", phaseId: "draft", status: "skipped", error: "Gate blocked: quality" }), + ev({ kind: "phase-start", phaseId: "publish", dependencies: ["draft"] }), + ev({ kind: "phase-end", phaseId: "publish", status: "skipped", error: "Gate blocked: quality" }), + ev({ kind: "phase-start", phaseId: "independent-late", dependencies: [] }), + ev({ kind: "phase-end", phaseId: "independent-late", status: "skipped", error: "Gate blocked: quality" }), + ]; + const report = replayRun(log, { thresholds: { review: 0.3 } }); + assert.equal(report.decisions.find((d) => d.phaseId === "review")?.outcome, "verdict-flipped"); + assert.equal(report.replayed.phases.review.status, "done"); + assert.equal(report.decisions.find((d) => d.phaseId === "draft")?.outcome, "needs-live-rerun"); + assert.equal(report.decisions.find((d) => d.phaseId === "publish")?.outcome, "needs-live-rerun"); + assert.equal(report.decisions.find((d) => d.phaseId === "independent-late")?.outcome, "needs-live-rerun"); + assert.deepEqual(report.decisions.find((d) => d.phaseId === "publish")?.causedBy, ["review"]); + assert.equal(report.replayed.phases.draft.status, "pending"); + assert.equal(report.replayed.phases.publish.status, "pending"); + assert.equal(report.replayed.phases["independent-late"].status, "pending"); + assert.equal(report.needsLiveRerun, true); +}); + +test("replayRun: PASS to BLOCK follows global gate admission across the recorded layer", () => { + const score: Event = { + ...ev({ + kind: "decision", + phaseId: "review", + decision: { + type: "gate-score", + target: "quality", + results: [], + combined: 0.6, + threshold: 0.5, + verdict: "pass", + }, + }), + ts: 3, + }; + const log: Event[] = [ + { ...ev({ kind: "phase-start", phaseId: "review", dependencies: [] }), ts: 1 }, + { ...ev({ kind: "phase-start", phaseId: "already-started", dependencies: [] }), ts: 2 }, + score, + { ...ev({ kind: "phase-start", phaseId: "equal-time", dependencies: [] }), ts: 4 }, + { ...ev({ kind: "phase-end", phaseId: "review", status: "done" }), ts: 4 }, + { ...ev({ kind: "phase-end", phaseId: "already-started", status: "done" }), ts: 5 }, + { ...ev({ kind: "phase-start", phaseId: "not-admitted", dependencies: [] }), ts: 6 }, + { ...ev({ kind: "phase-end", phaseId: "not-admitted", status: "done" }), ts: 7 }, + { ...ev({ kind: "phase-end", phaseId: "equal-time", status: "done" }), ts: 8 }, + { ...ev({ kind: "phase-start", phaseId: "next-layer", dependencies: ["already-started"] }), ts: 9 }, + { ...ev({ kind: "phase-end", phaseId: "next-layer", status: "done" }), ts: 10 }, + ]; + const report = replayRun(log, { thresholds: { review: 0.9 } }); + assert.equal(report.decisions.find((d) => d.phaseId === "review")?.outcome, "would-block"); + assert.equal(report.decisions.find((d) => d.phaseId === "already-started")?.outcome, "reused"); + assert.equal(report.replayed.phases["already-started"].status, "done"); + assert.equal(report.decisions.find((d) => d.phaseId === "not-admitted")?.outcome, "needs-live-rerun"); + assert.equal(report.replayed.phases["not-admitted"].status, "pending"); + assert.equal(report.decisions.find((d) => d.phaseId === "next-layer")?.outcome, "would-skip"); + assert.equal(report.decisions.find((d) => d.phaseId === "equal-time")?.outcome, "needs-live-rerun"); + assert.equal(report.replayed.phases["equal-time"].status, "pending"); +}); + +test("replayRun: budget is cumulative in execution order and only later phases skip", () => { + const usage = (cost: number) => ({ + text: "ok", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost, contextTokens: 0, turns: 1 }, + }); + const log: Event[] = []; + for (const id of ["a", "b", "c"]) { + log.push(ev({ kind: "phase-start", phaseId: id, dependencies: id === "a" ? [] : [String.fromCharCode(id.charCodeAt(0) - 1)] })); + log.push(ev({ kind: "subagent-call", phaseId: id, output: usage(0.04) })); + log.push(ev({ kind: "phase-end", phaseId: id, status: "done" })); + } + const report = replayRun(log, { budgetMaxUSD: 0.05 }); + assert.equal(report.decisions.find((d) => d.phaseId === "a")?.outcome, "reused"); + assert.equal(report.decisions.find((d) => d.phaseId === "b")?.outcome, "would-exceed-budget"); + assert.equal(report.decisions.find((d) => d.phaseId === "c")?.outcome, "would-skip"); + assert.equal(report.replayed.phases.a.status, "done"); + assert.equal(report.replayed.phases.b.status, "done"); + assert.equal(report.replayed.phases.c.status, "skipped"); +}); + +test("replayRun: budget never retroactively skips an already-started same-layer sibling", () => { + const usage = (cost: number) => ({ + text: "ok", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost, contextTokens: 0, turns: 1 }, + }); + const log: Event[] = [ + { ...ev({ kind: "phase-start", phaseId: "a", dependencies: [] }), ts: 1 }, + { ...ev({ kind: "phase-start", phaseId: "b", dependencies: [] }), ts: 2 }, + { ...ev({ kind: "subagent-call", phaseId: "a", output: usage(0.06) }), ts: 3 }, + { ...ev({ kind: "phase-end", phaseId: "a", status: "done" }), ts: 4 }, + { ...ev({ kind: "subagent-call", phaseId: "b", output: usage(0.01) }), ts: 5 }, + { ...ev({ kind: "phase-end", phaseId: "b", status: "done" }), ts: 6 }, + { ...ev({ kind: "phase-start", phaseId: "c", dependencies: ["a", "b"] }), ts: 7 }, + { ...ev({ kind: "subagent-call", phaseId: "c", output: usage(0.01) }), ts: 8 }, + { ...ev({ kind: "phase-end", phaseId: "c", status: "done" }), ts: 9 }, + ]; + const report = replayRun(log, { budgetMaxUSD: 0.05 }); + assert.equal(report.decisions.find((d) => d.phaseId === "a")?.outcome, "would-exceed-budget"); + assert.equal(report.decisions.find((d) => d.phaseId === "b")?.outcome, "reused"); + assert.equal(report.replayed.phases.b.status, "done", "concurrent sibling was already admitted"); + assert.equal(report.decisions.find((d) => d.phaseId === "c")?.outcome, "would-skip"); + assert.equal(report.replayed.phases.c.status, "skipped"); +}); + +test("replayRun: budget and threshold overrides compose without stale downstream decisions", () => { + const usage = (cost: number) => ({ + text: "ok", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost, contextTokens: 0, turns: 1 }, + }); + const log: Event[] = [ + ev({ kind: "phase-start", phaseId: "spend", dependencies: [] }), + ev({ kind: "subagent-call", phaseId: "spend", output: usage(0.06) }), + ev({ kind: "phase-end", phaseId: "spend", status: "done" }), + ev({ kind: "phase-start", phaseId: "review", dependencies: ["spend"] }), + ev({ + kind: "decision", + phaseId: "review", + decision: { + type: "gate-score", + target: "quality", + results: [], + combined: 0.6, + threshold: 0.5, + verdict: "pass", + }, + }), + ev({ kind: "phase-end", phaseId: "review", status: "done" }), + ev({ kind: "phase-start", phaseId: "report", dependencies: ["review"] }), + ev({ kind: "phase-end", phaseId: "report", status: "done" }), + ]; + const report = replayRun(log, { budgetMaxUSD: 0.05, thresholds: { review: 0.9 } }); + assert.equal(report.decisions.find((d) => d.phaseId === "spend")?.outcome, "would-exceed-budget"); + assert.equal(report.decisions.find((d) => d.phaseId === "review")?.outcome, "would-skip"); + assert.equal(report.decisions.find((d) => d.phaseId === "report")?.outcome, "would-skip"); + assert.deepEqual(report.decisions.find((d) => d.phaseId === "report")?.causedBy, ["spend"]); + assert.equal(report.replayed.phases.review.status, "skipped"); + assert.equal(report.replayed.phases.report.status, "skipped"); +}); + +test("replayRun: unknown model or args spend propagates needs-live across budget layers", () => { + const usage = (cost: number) => ({ + text: "ok", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost, contextTokens: 0, turns: 1 }, + }); + const log: Event[] = []; + for (const [id, parent] of [["a", undefined], ["b", "a"], ["c", "b"]] as const) { + log.push(ev({ kind: "phase-start", phaseId: id, dependencies: parent ? [parent] : [] })); + log.push(ev({ kind: "subagent-call", phaseId: id, output: usage(0.04) })); + log.push(ev({ kind: "phase-end", phaseId: id, status: "done" })); + } + const model = replayRun(log, { budgetMaxUSD: 0.05, models: { b: "other-model" } }); + assert.equal(model.decisions.find((d) => d.phaseId === "a")?.outcome, "reused"); + assert.equal(model.decisions.find((d) => d.phaseId === "b")?.outcome, "needs-live-rerun"); + assert.equal(model.decisions.find((d) => d.phaseId === "c")?.outcome, "needs-live-rerun"); + assert.equal(model.replayed.phases.b.status, "pending"); + assert.equal(model.replayed.phases.c.status, "pending"); + + const args = replayRun(log, { budgetMaxUSD: 0.05, args: { topic: "changed" } }); + assert.ok(args.decisions.every((d) => d.outcome === "needs-live-rerun")); + assert.ok(Object.values(args.replayed.phases).every((p) => p.status === "pending")); +}); + +test("replayRun: loosening budget revives recorded budget skips as live work", () => { + const usage = { + text: "ok", + usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0.06, contextTokens: 0, turns: 1 }, + }; + const log: Event[] = [ + ev({ kind: "phase-start", phaseId: "spend", dependencies: [] }), + ev({ kind: "subagent-call", phaseId: "spend", output: usage }), + ev({ kind: "phase-end", phaseId: "spend", status: "done" }), + ev({ kind: "phase-start", phaseId: "revived", dependencies: ["spend"] }), + ev({ + kind: "decision", + phaseId: "revived", + decision: { type: "budget-hit", value: "budget", reason: "Budget exceeded: old cap" }, + }), + ev({ kind: "phase-end", phaseId: "revived", status: "skipped", error: "Budget exceeded: old cap" }), + ev({ kind: "phase-start", phaseId: "consumer", dependencies: ["revived"] }), + ev({ kind: "phase-end", phaseId: "consumer", status: "skipped", error: "Upstream dependency not satisfied" }), + ]; + const report = replayRun(log, { budgetMaxUSD: 0.1, models: { revived: "new-model" } }); + assert.equal(report.decisions.find((d) => d.phaseId === "spend")?.outcome, "reused"); + assert.equal(report.decisions.find((d) => d.phaseId === "revived")?.outcome, "needs-live-rerun"); + assert.equal(report.decisions.find((d) => d.phaseId === "consumer")?.outcome, "needs-live-rerun"); + assert.equal(report.replayed.phases.revived.status, "pending"); + assert.equal(report.replayed.phases.consumer.status, "pending"); + assert.equal(report.needsLiveRerun, true); + + const stillTight = replayRun(log, { budgetMaxUSD: 0.05, models: { revived: "new-model" } }); + assert.equal(stillTight.decisions.find((d) => d.phaseId === "revived")?.outcome, "would-skip"); + assert.equal(stillTight.decisions.find((d) => d.phaseId === "consumer")?.outcome, "would-skip"); + assert.equal(stillTight.replayed.phases.revived.status, "skipped"); + assert.equal(stillTight.needsLiveRerun, false, "deterministic new budget stop dominates an unreachable model override"); +}); + +test("replayRun: legacy multi-phase trace without dependency metadata never invents local propagation", () => { + const log: Event[] = [ + ev({ kind: "phase-start", phaseId: "review" }), + ev({ + kind: "decision", + phaseId: "review", + decision: { + type: "gate-score", + target: "quality", + results: [], + combined: 0.6, + threshold: 0.5, + verdict: "pass", + }, + }), + ev({ kind: "phase-end", phaseId: "review", status: "done" }), + ev({ kind: "phase-start", phaseId: "consumer" }), + ev({ kind: "phase-end", phaseId: "consumer", status: "done" }), + ]; + + const threshold = replayRun(log, { thresholds: { review: 0.9 } }); + assert.equal(threshold.decisions.find((d) => d.phaseId === "review")?.outcome, "would-block"); + assert.equal(threshold.decisions.find((d) => d.phaseId === "consumer")?.outcome, "needs-live-rerun"); + assert.equal(threshold.replayed.phases.consumer.status, "pending"); + assert.equal(threshold.needsLiveRerun, true); + + const model = replayRun(log, { models: { review: "other-model" } }); + assert.ok(model.decisions.every((d) => d.outcome === "needs-live-rerun")); + assert.ok(Object.values(model.replayed.phases).every((p) => p.status === "pending")); + + const args = replayRun(log, { args: { topic: "changed" } }); + assert.ok(args.decisions.every((d) => d.outcome === "needs-live-rerun")); + assert.ok(Object.values(args.replayed.phases).every((p) => p.status === "pending")); + + const combined = replayRun(log, { + thresholds: { review: 0.9 }, + models: { review: "other-model" }, + args: { topic: "changed" }, + budgetMaxUSD: 0.001, + }); + assert.ok(combined.decisions.every((d) => d.outcome === "needs-live-rerun")); + assert.equal(combined.needsLiveRerun, true); + + const twoGateLog: Event[] = [ + ...log.slice(0, 3).map((event) => ({ ...event, phaseId: "review-a" })), + ...log.slice(0, 3).map((event) => ({ ...event, phaseId: "review-b" })), + ev({ kind: "phase-start", phaseId: "unknown-related" }), + ev({ kind: "phase-end", phaseId: "unknown-related", status: "done" }), + ]; + const multipleThresholds = replayRun(twoGateLog, { + thresholds: { "review-a": 0.9, "review-b": 0.9 }, + }); + assert.ok(multipleThresholds.decisions.every((d) => d.outcome === "needs-live-rerun")); + assert.ok(Object.values(multipleThresholds.replayed.phases).every((p) => p.status === "pending")); +}); + +test("replayRun: unreplayable marker requires live rerun and propagates downstream", () => { + const log: Event[] = [ + ev({ kind: "phase-start", phaseId: "inner", dependencies: [] }), + ev({ + kind: "decision", + phaseId: "inner", + decision: { type: "unreplayable", reason: "inner-flow" }, + }), + ev({ kind: "phase-end", phaseId: "inner", status: "done" }), + ev({ kind: "phase-start", phaseId: "consumer", dependencies: ["inner"] }), + ev({ kind: "phase-end", phaseId: "consumer", status: "done" }), + ]; + const report = replayRun(log); + assert.equal(report.needsLiveRerun, true); + assert.equal(report.decisions.find((d) => d.phaseId === "inner")?.outcome, "needs-live-rerun"); + assert.equal(report.decisions.find((d) => d.phaseId === "consumer")?.outcome, "needs-live-rerun"); + assert.deepEqual(report.decisions.find((d) => d.phaseId === "consumer")?.causedBy, ["inner"]); +}); + +test("replayRun: mixed historical nested runIds are isolated to the outer run", () => { + const outer: Event = { ...ev({ kind: "phase-start", phaseId: "flow", dependencies: [] }), ts: 1 }; + const child: Event = { ...ev({ kind: "phase-start", phaseId: "child" }), runId: "nested-run", ts: 2 }; + const outerEnd: Event = { ...ev({ kind: "phase-end", phaseId: "flow", status: "done" }), ts: 3 }; + const report = replayRun([outer, child, outerEnd]); + assert.equal(report.baseline.runId, "r1"); + assert.deepEqual(Object.keys(report.baseline.phases), ["flow"]); +}); + +test("replayRun: child-first legacy flush order still selects temporal outer envelope", () => { + const outerStart: Event = { ...ev({ kind: "phase-start", phaseId: "flow" }), runId: "outer", ts: 1 }; + const outerMarker: Event = { + ...ev({ kind: "decision", phaseId: "flow", decision: { type: "unreplayable", reason: "inner-flow" } }), + runId: "outer", + ts: 1, + }; + const childStart: Event = { ...ev({ kind: "phase-start", phaseId: "child" }), runId: "child", ts: 2 }; + const childEnd: Event = { ...ev({ kind: "phase-end", phaseId: "child", status: "done" }), runId: "child", ts: 3 }; + const outerEnd: Event = { ...ev({ kind: "phase-end", phaseId: "flow", status: "done" }), runId: "outer", ts: 4 }; + const report = replayRun([childStart, childEnd, outerStart, outerMarker, outerEnd]); + assert.equal(report.baseline.runId, "outer"); + assert.deepEqual(Object.keys(report.baseline.phases), ["flow"]); + assert.equal(report.needsLiveRerun, true, "outer flow marker remains fail-safe"); +}); + +test("replayRun: genuinely ambiguous mixed run identity fails safe", () => { + const log: Event[] = [ + { ...ev({ kind: "phase-start", phaseId: "a" }), runId: "run-a", ts: 1 }, + { ...ev({ kind: "phase-end", phaseId: "a", status: "done" }), runId: "run-a", ts: 1 }, + { ...ev({ kind: "phase-start", phaseId: "b" }), runId: "run-b", ts: 1 }, + { ...ev({ kind: "phase-end", phaseId: "b", status: "done" }), runId: "run-b", ts: 1 }, + ]; + const report = replayRun(log); + assert.equal(report.needsLiveRerun, true); + assert.ok(report.decisions.every((d) => d.outcome === "needs-live-rerun")); + assert.match(report.decisions[0]?.reason ?? "", /ambiguous/); +}); + +test("replayRun: complete child is not evidence over a truncated outer run", () => { + const childStart: Event = { ...ev({ kind: "phase-start", phaseId: "child" }), runId: "child", ts: 2 }; + const childEnd: Event = { ...ev({ kind: "phase-end", phaseId: "child", status: "done" }), runId: "child", ts: 3 }; + const outerStart: Event = { ...ev({ kind: "phase-start", phaseId: "flow" }), runId: "outer", ts: 1 }; + const report = replayRun([childStart, childEnd, outerStart]); + assert.equal(report.baseline.runId, "outer"); + assert.equal(report.baseline.phases.flow.status, "running"); + assert.equal(report.needsLiveRerun, true); + assert.equal(report.decisions[0]?.outcome, "needs-live-rerun"); + assert.notEqual(report.decisions[0]?.outcome, "reused"); +}); + +test("replayRun: any selected run with start but no end fails safe", () => { + const report = replayRun([ev({ kind: "phase-start", phaseId: "only" })]); + assert.equal(report.needsLiveRerun, true); + assert.equal(report.decisions[0]?.outcome, "needs-live-rerun"); +}); + +test("replayRun: truncated repeated gate attempt is never reused", () => { + const log: Event[] = [ + { ...ev({ kind: "phase-start", phaseId: "review" }), ts: 1 }, + { + ...ev({ + kind: "decision", + phaseId: "review", + decision: { type: "gate-verdict", value: "pass" }, + }), + ts: 2, + }, + { ...ev({ kind: "phase-end", phaseId: "review", status: "done" }), ts: 3 }, + // Gate retry/recompute began a new attempt but the trace truncated before + // its matching phase-end. + { ...ev({ kind: "phase-start", phaseId: "review" }), ts: 4 }, + ]; + const report = replayRun(log); + assert.equal(report.baseline.phases.review.status, "running"); + assert.ok((report.baseline.phases.review.endedAt ?? 0) < (report.baseline.phases.review.startedAt ?? 0)); + assert.equal(report.needsLiveRerun, true); + assert.equal(report.decisions[0]?.outcome, "needs-live-rerun"); + assert.notEqual(report.decisions[0]?.outcome, "reused"); +}); + +test("replayRun: reversed lifecycle timestamps fail safe", () => { + const report = replayRun([ + { ...ev({ kind: "phase-start", phaseId: "p" }), ts: 5 }, + { ...ev({ kind: "phase-end", phaseId: "p", status: "done" }), ts: 4 }, + ]); + assert.equal(report.needsLiveRerun, true); + assert.equal(report.decisions[0]?.outcome, "needs-live-rerun"); +}); diff --git a/packages/taskflow-core/test/runner-process.test.ts b/packages/taskflow-core/test/runner-process.test.ts index d497685..e34295f 100644 --- a/packages/taskflow-core/test/runner-process.test.ts +++ b/packages/taskflow-core/test/runner-process.test.ts @@ -11,6 +11,9 @@ import { test } from "node:test"; import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import { runSubagentProcess, unknownAgentResult, @@ -82,6 +85,76 @@ test("runSubagentProcess: AbortSignal — an aborted run classifies as aborted ( assert.match(r.errorMessage ?? "", /abort/i); }); +test("runSubagentProcess: abort terminates the child process tree", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-process-tree-")); + const marker = path.join(dir, "grandchild-survived.txt"); + try { + const grandchild = [ + `const fs=require("node:fs");`, + `process.on("SIGTERM",()=>{});`, + `if(process.send)process.send("ready");`, + `setTimeout(()=>fs.writeFileSync(${JSON.stringify(marker)},"survived"),600);`, + `setInterval(()=>{},1000);`, + ].join(""); + const parent = [ + `const {spawn}=require("node:child_process");`, + `const child=spawn(process.execPath,["-e",${JSON.stringify(grandchild)}],{stdio:["ignore","ignore","ignore","ipc"]});`, + `child.once("message",()=>process.stdout.write(JSON.stringify({spawned:true})+"\\n"));`, + `setInterval(()=>{},1000);`, + ].join(""); + const ac = new AbortController(); + const pending = runSubagentProcess({ + agent: "test", task: "tree", model: undefined, + bin: "node", args: ["-e", parent], cwd: process.cwd(), + idleTimeoutMs: 60_000, signal: ac.signal, + acc: makeAcc(), foldLine, + onLive: () => ac.abort(), + }); + const result = await pending; + assert.equal(result.stopReason, "aborted"); + await new Promise((resolve) => setTimeout(resolve, 750)); + assert.equal(fs.existsSync(marker), false, "grandchild must not survive cancellation to write its marker"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runSubagentProcess: normal completion does not kill a descendant", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-process-tree-normal-")); + const marker = path.join(dir, "normal-grandchild.txt"); + try { + const grandchild = `const fs=require("node:fs");setTimeout(()=>fs.writeFileSync(${JSON.stringify(marker)},"ok"),150);`; + const parent = `const {spawn}=require("node:child_process");spawn(process.execPath,["-e",${JSON.stringify(grandchild)}],{stdio:"ignore"});process.stdout.write(JSON.stringify({done:true})+"\\n");`; + const result = await run(parent); + assert.equal(result.stopReason, "end"); + await new Promise((resolve) => setTimeout(resolve, 300)); + assert.equal(fs.readFileSync(marker, "utf8"), "ok", "normal parent exit must not terminate descendant work"); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runSubagentProcess: removes the abort listener after a child exits", async () => { + const ac = new AbortController(); + const signal = ac.signal; + const originalAdd = signal.addEventListener.bind(signal); + const originalRemove = signal.removeEventListener.bind(signal); + let added = 0; + let removed = 0; + (signal as unknown as { addEventListener: typeof signal.addEventListener }).addEventListener = ((...args: Parameters) => { + added++; + return originalAdd(...args); + }) as typeof signal.addEventListener; + (signal as unknown as { removeEventListener: typeof signal.removeEventListener }).removeEventListener = ((...args: Parameters) => { + removed++; + return originalRemove(...args); + }) as typeof signal.removeEventListener; + const result = await run(`process.stdout.write(JSON.stringify({done:true})+"\\n");`, { signal }); + assert.equal(result.exitCode, 0); + assert.equal(added, 1); + assert.equal(removed, 1, "completed child must not retain a listener on a long-lived run signal"); +}); + test("runSubagentProcess: a throwing foldLine is swallowed (fail-open), run still completes", async () => { // The MI-04 backstop: a foldLine that throws must not crash the host process. const throwingFold = (): LiveUpdate | null => { throw new Error("parser boom"); }; diff --git a/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts b/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts new file mode 100644 index 0000000..595bcc9 --- /dev/null +++ b/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts @@ -0,0 +1,1160 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import { CacheStore } from "../src/cache.ts"; +import { canUseEventKernel, kernelUnsupportedReason } from "../src/exec/driver.ts"; +import { clampSubFlowBudget } from "../src/exec/kernel-policy.ts"; +import { compileTaskflowToIR } from "../src/flowir/index.ts"; +import { queueSpawn } from "../src/context-store.ts"; +import type { RunOptions, RunResult } from "../src/host/runner-types.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { FileTraceSink, readTrace } from "../src/trace.ts"; +import { foldEvents } from "../src/exec/fold.ts"; +import { upgradeTraceEvent } from "../src/exec/events.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const AGENT: AgentConfig = { + name: "a", + description: "test", + systemPrompt: "", + source: "user", + filePath: "", +}; + +function state(def: Taskflow, cwd: string, runId = `r-${Math.random()}`): RunState { + return { + runId, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd, + }; +} + +function ok(agent: string, task: string, output = "ok", cost = 0): RunResult { + return { + agent, + task, + exitCode: 0, + output, + stderr: "", + usage: { ...emptyUsage(), cost, turns: 1 }, + stopReason: "end", + }; +} + +test("kernel admission safely falls back for every currently unsupported semantic", () => { + const cases: Array<[Taskflow, RegExp]> = [ + [{ name: "cwd", phases: [{ id: "p", task: "x", cwd: "/tmp", final: true }] }, /cwd/], + [{ name: "ctx", phases: [{ id: "p", task: "x", context: ["a.txt"], final: true }] }, /context/], + [{ name: "stdin", phases: [{ id: "p", type: "script", run: ["cat"], input: "x", final: true }] }, /stdin/], + [ + { name: "argv", phases: [{ id: "p", type: "script", run: ["echo", "{args.x}"], final: true }] }, + /argv/, + ], + [ + { + name: "layer", + phases: [ + { id: "a", task: "a" }, + { id: "b", task: "b", final: true }, + ], + }, + /concurrent DAG layers/, + ], + [ + { + name: "budget-map", + budget: { maxUSD: 1 }, + phases: [{ id: "m", type: "map", over: "[]", task: "x", final: true }], + }, + /budgeted fan-out/, + ], + ]; + for (const [def, reason] of cases) { + assert.equal(canUseEventKernel(def), false, def.name); + assert.match(kernelUnsupportedReason(def) ?? "", reason, def.name); + } +}); + +test("kernel opt-in falls back to imperative for script stdin and interpolated argv", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-kernel-fallback-")); + try { + const def: Taskflow = { + name: "stdin-fallback", + args: { value: {} }, + phases: [ + { + id: "p", + type: "script", + run: ["node", "-e", "process.stdin.on('data',d=>process.stdout.write(d.toString()))"], + input: "hello!", + }, + { + id: "argv", + type: "script", + run: ["node", "-e", "process.stdout.write(process.argv[1])", "{args.value}"], + dependsOn: ["p"], + final: true, + }, + ], + }; + const runState = state(def, cwd); + runState.args = { value: "argv-ok" }; + const result = await executeTaskflow(runState, { + cwd, + agents: [AGENT], + runTask: async () => { + throw new Error("script should not invoke agent"); + }, + eventKernel: true, + persist: () => {}, + }); + assert.equal(result.ok, true); + assert.equal(result.state.phases.p.output, "hello!"); + assert.equal(result.finalOutput, "argv-ok"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("kernel opt-in preserves literal cwd and context pre-read through safe fallback", async () => { + const base = fs.mkdtempSync(path.join(os.tmpdir(), "tf-kernel-context-")); + const phaseCwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-kernel-cwd-")); + try { + fs.writeFileSync(path.join(base, "context.txt"), "CONTEXT-MARKER"); + const def: Taskflow = { + name: "cwd-context-fallback", + phases: [ + { + id: "p", + task: "QUESTION", + cwd: phaseCwd, + context: [path.join(base, "context.txt")], + final: true, + }, + ], + }; + let observedCwd = ""; + let observedTask = ""; + await executeTaskflow(state(def, base), { + cwd: base, + agents: [AGENT], + runTask: async (cwd, _agents, agent, task) => { + observedCwd = cwd; + observedTask = task; + return ok(agent, task); + }, + eventKernel: true, + persist: () => {}, + }); + assert.equal(observedCwd, phaseCwd); + assert.match(observedTask, /CONTEXT-MARKER/); + } finally { + fs.rmSync(base, { recursive: true, force: true }); + fs.rmSync(phaseCwd, { recursive: true, force: true }); + } +}); + +test("kernel opt-in preserves same-layer concurrency semantics via fallback", async () => { + const def: Taskflow = { + name: "same-layer-gate", + phases: [ + { id: "gate", type: "gate", task: "judge" }, + { id: "side", task: "independent", final: true }, + ], + }; + let calls = 0; + const result = await executeTaskflow(state(def, process.cwd()), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => { + calls++; + return ok(agent, task, task === "judge" ? "VERDICT: BLOCK" : "side-done"); + }, + eventKernel: true, + persist: () => {}, + }); + assert.equal(calls, 2); + assert.equal(result.state.phases.side.status, "done"); + assert.equal(result.state.status, "blocked"); +}); + +test("kernel automatic transient retry works; resume is revalidated by imperative runtime", async () => { + const cwd = process.cwd(); + const def: Taskflow = { name: "kernel-retry", phases: [{ id: "p", agent: "a", task: "x", retry: { max: 0, backoffMs: 0 }, final: true }] }; + assert.equal(canUseEventKernel(def), true); + let calls = 0; + const runTask: RuntimeDeps["runTask"] = async (_c, _a, agent, task) => { + calls++; + if (calls === 1) { + return { ...ok(agent, task), exitCode: 1, stopReason: "error", errorMessage: "HTTP 429" }; + } + return ok(agent, task, "recovered"); + }; + const first = await executeTaskflow(state(def, cwd, "kernel-retry"), { + cwd, + agents: [AGENT], + runTask, + eventKernel: true, + persist: () => {}, + }); + assert.equal(first.ok, true); + assert.equal(calls, 2); + assert.equal(first.state.phases.p.attempts, 2); + const resumed = await executeTaskflow(first.state, { + cwd, + agents: [AGENT], + runTask, + eventKernel: true, + persist: () => {}, + }); + assert.equal(resumed.ok, true); + assert.equal(calls, 3, "kernel output without an inputHash is safely re-executed, never blindly reused"); +}); + +test("budget overflow stops transient retry after one call on imperative and kernel paths", async () => { + const phases: Array<{ name: string; phase: Taskflow["phases"][number] }> = [ + { name: "agent", phase: { id: "p", type: "agent", task: "agent", retry: { max: 0, backoffMs: 0 }, final: true } }, + { name: "gate", phase: { id: "p", type: "gate", task: "gate", retry: { max: 0, backoffMs: 0 }, final: true } }, + { name: "reduce", phase: { id: "p", type: "reduce", task: "reduce", retry: { max: 0, backoffMs: 0 }, final: true } }, + { name: "map", phase: { id: "p", type: "map", over: '["x"]', task: "map {item}", retry: { max: 0, backoffMs: 0 }, final: true } }, + { name: "parallel", phase: { id: "p", type: "parallel", branches: [{ task: "branch" }], retry: { max: 0, backoffMs: 0 }, final: true } }, + ]; + for (const kernel of [false, true]) { + for (const entry of phases) { + let calls = 0; + const def: Taskflow = { + name: `retry-budget-${entry.name}-${kernel ? "kernel" : "imperative"}`, + budget: { maxTokens: 1 }, + phases: [entry.phase], + }; + await executeTaskflow(state(def, process.cwd()), { + cwd: process.cwd(), + agents: [AGENT], + eventKernel: kernel, + runTask: async (_c, _a, agent, task) => { + calls++; + return { + ...ok(agent, task), + exitCode: 1, + stopReason: "error", + errorMessage: "HTTP 503", + usage: { ...emptyUsage(), output: 2, turns: 1 }, + }; + }, + persist: () => {}, + }); + assert.equal(calls, 1, `${entry.name}/${kernel ? "kernel" : "imperative"}: over-budget attempt must not retry`); + } + } +}); + +test("kernel opt-in routes budgeted map to imperative per-item guard", async () => { + const def: Taskflow = { + name: "map-budget-fallback", + budget: { maxUSD: 0.5 }, + phases: [{ id: "m", type: "map", over: '["a","b","c"]', task: "{item}", concurrency: 1, final: true }], + }; + let calls = 0; + const result = await executeTaskflow(state(def, process.cwd()), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => { + calls++; + return ok(agent, task, task, 1); + }, + eventKernel: true, + persist: () => {}, + }); + assert.equal(calls, 1); + assert.equal(result.state.status, "blocked"); + assert.equal(result.state.phases.m.budgetTruncated, true); +}); + +test("FlowIR hash includes agentScope and contextSharing", async () => { + const base: Taskflow = { name: "semantic-hash", phases: [{ id: "p", task: "x", final: true }] }; + const user = await compileTaskflowToIR({ ...base, agentScope: "user" }); + const project = await compileTaskflowToIR({ ...base, agentScope: "project" }); + const plain = await compileTaskflowToIR({ ...base, contextSharing: false }); + const shared = await compileTaskflowToIR({ ...base, contextSharing: true }); + assert.notEqual(user.hash, project.hash); + assert.notEqual(plain.hash, shared.hash); +}); + +test("cross-run cache cannot cross agentScope or contextSharing boundaries", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-semantic-cache-")); + try { + const store = new CacheStore(cwd); + let calls = 0; + const runTask: RuntimeDeps["runTask"] = async (_c, agents, agent, task, opts: RunOptions) => { + calls++; + return ok(agent, task, `${agents[0]?.systemPrompt}:${opts.ctxDir ? "shared" : "plain"}`); + }; + const mk = (agentScope: "user" | "project", contextSharing: boolean): Taskflow => ({ + name: "semantic-cache", + agentScope, + contextSharing, + phases: [{ id: "p", agent: "a", task: "x", cache: { scope: "cross-run" }, final: true }], + }); + const userAgent = { ...AGENT, systemPrompt: "USER", source: "user" as const }; + const projectAgent = { ...AGENT, systemPrompt: "PROJECT", source: "project" as const }; + const r1 = await executeTaskflow(state(mk("user", false), cwd), { + cwd, + agents: [userAgent], + runTask, + cacheStore: store, + persist: () => {}, + }); + const r2 = await executeTaskflow(state(mk("project", false), cwd), { + cwd, + agents: [projectAgent], + runTask, + cacheStore: store, + persist: () => {}, + }); + const r3 = await executeTaskflow(state(mk("project", true), cwd), { + cwd, + agents: [projectAgent], + runTask, + cacheStore: store, + persist: () => {}, + }); + const r4 = await executeTaskflow(state(mk("user", false), cwd), { + cwd, + agents: [{ ...userAgent, systemPrompt: "USER-V2" }], + runTask, + cacheStore: store, + persist: () => {}, + }); + assert.equal(calls, 4); + assert.equal(r1.finalOutput, "USER:plain"); + assert.equal(r2.finalOutput, "PROJECT:plain"); + assert.equal(r3.finalOutput, "PROJECT:shared"); + assert.equal(r4.finalOutput, "USER-V2:plain"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("per-item map cache retains agentScope semantic identity", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-map-scope-cache-")); + try { + const store = new CacheStore(cwd); + let calls = 0; + const runTask: RuntimeDeps["runTask"] = async (_c, agents, agent, task) => { + calls++; + return ok(agent, task, `${agents[0]?.systemPrompt}:${task}`); + }; + const mk = (agentScope: "user" | "project"): Taskflow => ({ + name: "map-scope-cache", + agentScope, + phases: [ + { + id: "m", + type: "map", + over: '["a","b"]', + task: "{item}", + cache: { scope: "cross-run" }, + final: true, + }, + ], + }); + await executeTaskflow(state(mk("user"), cwd), { + cwd, + agents: [{ ...AGENT, systemPrompt: "USER", source: "user" }], + runTask, + cacheStore: store, + persist: () => {}, + }); + const second = await executeTaskflow(state(mk("project"), cwd), { + cwd, + agents: [{ ...AGENT, systemPrompt: "PROJECT", source: "project" }], + runTask, + cacheStore: store, + persist: () => {}, + }); + assert.equal(calls, 4, "project-scoped items execute instead of using user-scoped per-item entries"); + assert.match(second.finalOutput, /PROJECT/); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("saved-flow content participates in parent flow cache identity", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-saved-flow-cache-")); + try { + const store = new CacheStore(cwd); + const parent: Taskflow = { + name: "parent", + phases: [{ id: "child", type: "flow", use: "saved", cache: { scope: "cross-run" }, final: true }], + }; + let version = 1; + let calls = 0; + const deps: RuntimeDeps = { + cwd, + agents: [AGENT], + cacheStore: store, + loadFlow: () => ({ + name: "saved", + phases: [{ id: "p", agent: "a", task: `version-${version}`, final: true }], + }), + runTask: async (_c, _a, agent, task) => { + calls++; + return ok(agent, task, task); + }, + persist: () => {}, + }; + const first = await executeTaskflow(state(parent, cwd), deps); + version = 2; + const second = await executeTaskflow(state(parent, cwd), deps); + assert.equal(calls, 2); + assert.match(first.finalOutput, /version-1/); + assert.match(second.finalOutput, /version-2/); + assert.equal(second.state.phases.child.cacheHit, undefined); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("race and expand never use cross-run default cache", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-horizon-cache-")); + try { + const store = new CacheStore(cwd); + let calls = 0; + const deps: RuntimeDeps = { + cwd, + agents: [AGENT], + cacheStore: store, + cacheScopeDefault: "cross-run", + runTask: async (_c, _a, agent, task) => { + calls++; + return ok(agent, task, task); + }, + persist: () => {}, + }; + const race: Taskflow = { + name: "race-no-cross", + phases: [{ id: "r", type: "race", branches: [{ task: "a" }, { task: "b" }], final: true }], + }; + await executeTaskflow(state(race, cwd), deps); + await executeTaskflow(state(race, cwd), deps); + assert.equal(calls, 4); + const expand: Taskflow = { + name: "expand-no-cross", + phases: [ + { + id: "e", + type: "expand", + def: { name: "fragment", phases: [{ id: "p", task: "child", idempotent: false, final: true }] }, + final: true, + }, + ], + }; + await executeTaskflow(state(expand, cwd), deps); + await executeTaskflow(state(expand, cwd), deps); + assert.equal(calls, 6); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("graft resume restores promoted children and optional promoted failure is fail-soft", async () => { + const cwd = process.cwd(); + const def: Taskflow = { + name: "graft-resume", + phases: [ + { + id: "grow", + type: "expand", + expandMode: "graft", + def: { + name: "fragment", + phases: [{ id: "leaf", task: "fail-soft", optional: true, final: true }], + }, + final: true, + }, + ], + }; + let calls = 0; + const deps: RuntimeDeps = { + cwd, + agents: [AGENT], + runTask: async (_c, _a, agent, task) => { + calls++; + return { + ...ok(agent, task), + exitCode: 1, + stopReason: "error", + errorMessage: "expected optional failure", + }; + }, + persist: () => {}, + }; + const first = await executeTaskflow(state(def, cwd, "graft-resume"), deps); + assert.equal(first.state.status, "completed"); + assert.equal(first.state.phases["grow-leaf"]?.status, "failed"); + assert.equal(first.state.phases["grow-leaf"]?.optional, true); + delete first.state.phases["grow-leaf"]; + const resumed = await executeTaskflow(first.state, deps); + assert.equal(calls, 1); + assert.equal(resumed.state.phases.grow.cacheHit, "run-only"); + assert.equal(resumed.state.phases["grow-leaf"]?.status, "failed"); + assert.equal(resumed.state.status, "completed"); +}); + +test("trace flush retains causing budget decision and nested run events stay isolated", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-trace-close-")); + try { + const tracePath = path.join(cwd, "run.trace.jsonl"); + const def: Taskflow = { + name: "trace-parent", + budget: { maxUSD: 0.5 }, + phases: [ + { + id: "nested", + type: "flow", + def: { name: "child", phases: [{ id: "inside", task: "paid", final: true }] }, + }, + { id: "expensive", task: "expensive", dependsOn: ["nested"] }, + { id: "after", task: "after", dependsOn: ["expensive"], final: true }, + ], + }; + const result = await executeTaskflow(state(def, cwd, "parent-run"), { + cwd, + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ok(agent, task, "paid", task === "expensive" ? 1 : 0), + trace: new FileTraceSink(tracePath), + persist: () => {}, + }); + assert.equal(result.state.phases.nested.status, "done"); + assert.equal(result.state.phases.expensive.status, "done"); + assert.equal(result.state.phases.after.status, "skipped"); + const trace = readTrace(tracePath); + assert.ok(trace.length > 0); + assert.ok(trace.every((e) => e.runId === "parent-run")); + assert.ok(trace.every((e) => e.phaseId !== "inside")); + assert.ok(trace.some((e) => e.phaseId === "expensive" && e.decision?.type === "budget-hit")); + const folded = foldEvents(trace.map((e) => upgradeTraceEvent(e as unknown as Record))); + assert.equal(folded.phases.expensive.status, "done", "budget-causing phase stays completed"); + assert.equal(folded.phases.after.status, "skipped"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("race and expand emit replay-safe parent trace records", async () => { + const events: import("../src/trace.ts").TraceEvent[] = []; + const trace = { emit: (event: import("../src/trace.ts").TraceEvent) => events.push(event), flush: () => {} }; + const race: Taskflow = { + name: "trace-race", + phases: [{ id: "fastest", type: "race", branches: [{ task: "a" }, { task: "b" }], final: true }], + }; + await executeTaskflow(state(race, process.cwd(), "race-run"), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ok(agent, task, task), + trace, + persist: () => {}, + }); + assert.equal(events.filter((e) => e.phaseId === "fastest" && e.kind === "subagent-call").length, 2); + assert.ok(events.every((e) => e.runId === "race-run")); + events.length = 0; + const expand: Taskflow = { + name: "trace-expand", + phases: [ + { + id: "grow", + type: "expand", + def: { name: "fragment", phases: [{ id: "leaf", task: "x", final: true }] }, + final: true, + }, + ], + }; + await executeTaskflow(state(expand, process.cwd(), "expand-run"), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ok(agent, task, task), + trace, + persist: () => {}, + }); + assert.ok(events.some((e) => e.phaseId === "grow" && e.decision?.type === "unreplayable")); + assert.ok(events.every((e) => e.runId === "expand-run")); + assert.ok(events.every((e) => e.phaseId !== "grow-leaf")); +}); + +test("kernel advanced agent callers all apply automatic transient retry", async () => { + const cases: Taskflow[] = [ + { name: "retry-reduce", phases: [{ id: "p", type: "reduce", task: "reduce", retry: { max: 0, backoffMs: 0 }, final: true }] }, + { name: "retry-gate", phases: [{ id: "p", type: "gate", task: "gate", retry: { max: 0, backoffMs: 0 }, final: true }] }, + { name: "retry-loop", phases: [{ id: "p", type: "loop", task: "loop", maxIterations: 1, retry: { max: 0, backoffMs: 0 }, final: true }] }, + ]; + for (const def of cases) { + let calls = 0; + const result = await executeTaskflow(state(def, process.cwd()), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => { + calls++; + if (calls === 1) return { ...ok(agent, task), exitCode: 1, stopReason: "error", errorMessage: "HTTP 429" }; + return ok(agent, task, def.name.includes("gate") ? "VERDICT: PASS" : "recovered"); + }, + eventKernel: true, + persist: () => {}, + }); + assert.equal(result.ok, true, def.name); + assert.equal(calls, 2, def.name); + } + + const tournament: Taskflow = { + name: "retry-tournament", + phases: [{ id: "p", type: "tournament", task: "draft", variants: 2, judge: "judge", retry: { max: 0, backoffMs: 0 }, final: true }], + }; + const seen = new Map(); + const tournamentResult = await executeTaskflow(state(tournament, process.cwd()), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => { + const key = task.includes("Variant") ? "judge" : "variant"; + const n = (seen.get(key) ?? 0) + 1; + seen.set(key, n); + if (n === 1) return { ...ok(agent, task), exitCode: 1, stopReason: "error", errorMessage: "HTTP 503" }; + return ok(agent, task, key === "judge" ? "WINNER: 1" : "draft-ok"); + }, + eventKernel: true, + persist: () => {}, + }); + assert.equal(tournamentResult.ok, true); + assert.ok((seen.get("variant") ?? 0) >= 3, "variant transient failure retried"); + assert.equal(seen.get("judge"), 2, "judge transient failure retried"); +}); + +test("kernel resume never reuses stale definition or non-idempotent output", async () => { + let calls = 0; + const def: Taskflow = { + name: "kernel-safe-resume", + phases: [{ id: "p", task: "v1", idempotent: false, final: true }], + }; + const deps: RuntimeDeps = { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => { + calls++; + return ok(agent, task, task); + }, + eventKernel: true, + persist: () => {}, + }; + const first = await executeTaskflow(state(def, process.cwd(), "safe-resume"), deps); + first.state.def = { ...def, phases: [{ ...def.phases[0], task: "v2" }] }; + const second = await executeTaskflow(first.state, deps); + assert.equal(calls, 2); + assert.equal(second.finalOutput, "v2"); + assert.equal(second.state.phases.p.cacheHit, undefined); + assert.ok(second.state.phases.p.warnings?.some((w) => w.includes("side effect fired again"))); +}); + +test("kernel budget decision is flushed and flow parent is marked unreplayable", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-kernel-trace-")); + try { + const budgetPath = path.join(cwd, "budget.trace.jsonl"); + const budget: Taskflow = { + name: "kernel-budget-trace", + budget: { maxUSD: 0.5 }, + phases: [ + { id: "paid", task: "paid" }, + { id: "after", task: "after", dependsOn: ["paid"], final: true }, + ], + }; + await executeTaskflow(state(budget, cwd, "kernel-budget"), { + cwd, + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ok(agent, task, task, 1), + eventKernel: true, + trace: new FileTraceSink(budgetPath), + persist: () => {}, + }); + assert.ok(readTrace(budgetPath).some((e) => e.phaseId === "paid" && e.decision?.type === "budget-hit")); + + const flowPath = path.join(cwd, "flow.trace.jsonl"); + const flow: Taskflow = { + name: "kernel-flow-trace", + phases: [{ id: "outer", type: "flow", use: "child", final: true }], + }; + await executeTaskflow(state(flow, cwd, "kernel-flow"), { + cwd, + agents: [AGENT], + loadFlow: () => ({ name: "child", phases: [{ id: "inner", task: "x", final: true }] }), + runTask: async (_c, _a, agent, task) => ok(agent, task, task), + eventKernel: true, + trace: new FileTraceSink(flowPath), + persist: () => {}, + }); + const events = readTrace(flowPath); + assert.ok(events.some((e) => e.phaseId === "outer" && e.decision?.type === "unreplayable")); + assert.ok(events.every((e) => e.phaseId !== "inner")); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("changed graft fragment removes stale promoted children before replacement", async () => { + const mk = (id: string, task: string): Taskflow => ({ + name: "graft-replace", + phases: [ + { + id: "grow", + type: "expand", + expandMode: "graft", + def: { name: "fragment", phases: [{ id, task, final: true }] }, + final: true, + }, + ], + }); + const deps: RuntimeDeps = { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ok(agent, task, task), + persist: () => {}, + }; + const first = await executeTaskflow(state(mk("v1", "old"), process.cwd(), "graft-replace"), deps); + assert.equal(first.state.phases["grow-v1"]?.output, "old"); + first.state.def = mk("v2", "new"); + const second = await executeTaskflow(first.state, deps); + assert.equal(second.state.phases["grow-v1"], undefined); + assert.equal(second.state.phases["grow-v2"]?.output, "new"); +}); + +test("kernel transient backoff is abortable for ordinary and advanced callers", async () => { + const cases: Array<{ def: Taskflow; initialCalls: number }> = [ + { def: { name: "abort-agent", phases: [{ id: "p", task: "x", final: true }] }, initialCalls: 1 }, + { def: { name: "abort-map", phases: [{ id: "p", type: "map", over: '["x"]', task: "{item}", final: true }] }, initialCalls: 1 }, + { def: { name: "abort-parallel", phases: [{ id: "p", type: "parallel", branches: [{ task: "x" }], final: true }] }, initialCalls: 1 }, + { def: { name: "abort-reduce", phases: [{ id: "p", type: "reduce", task: "x", final: true }] }, initialCalls: 1 }, + { def: { name: "abort-gate", phases: [{ id: "p", type: "gate", task: "x", final: true }] }, initialCalls: 1 }, + { def: { name: "abort-loop", phases: [{ id: "p", type: "loop", task: "x", maxIterations: 3, final: true }] }, initialCalls: 1 }, + { def: { name: "abort-tournament", phases: [{ id: "p", type: "tournament", task: "x", variants: 2, final: true }] }, initialCalls: 2 }, + ]; + for (const { def, initialCalls } of cases) { + const ac = new AbortController(); + let calls = 0; + const started = Date.now(); + const timer = setTimeout(() => ac.abort(), 25); + const result = await executeTaskflow(state(def, process.cwd()), { + cwd: process.cwd(), + agents: [AGENT], + signal: ac.signal, + runTask: async (_c, _a, agent, task) => { + calls++; + return { ...ok(agent, task), exitCode: 1, stopReason: "error", errorMessage: "HTTP 429" }; + }, + eventKernel: true, + persist: () => {}, + }); + clearTimeout(timer); + assert.ok(Date.now() - started < 500, `${def.name}: abort must interrupt the 2s backoff`); + assert.equal(calls, initialCalls, `${def.name}: no retry call after abort`); + assert.equal(result.state.status, "paused", def.name); + } +}); + +test("graft rerun clears old children before empty, parse-failed, or failed replacement", async () => { + const oldDef: Taskflow = { + name: "graft-clear-all-paths", + phases: [ + { + id: "grow", + type: "expand", + expandMode: "graft", + def: { name: "old", phases: [{ id: "old", task: "old", final: true }] }, + final: true, + }, + ], + }; + const replacements: Array<{ label: string; def: unknown; expectedCost: number }> = [ + { label: "empty", def: { name: "empty", phases: [] }, expectedCost: 0 }, + { label: "parse-failed", def: "{not-json", expectedCost: 0 }, + { label: "subflow-failed", def: { name: "bad", phases: [{ id: "bad", task: "new-fail", final: true }] }, expectedCost: 2 }, + ]; + for (const replacement of replacements) { + const deps: RuntimeDeps = { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => + task === "new-fail" + ? { ...ok(agent, task, "", 2), exitCode: 1, stopReason: "error", errorMessage: "new failed" } + : ok(agent, task, "old", 1), + persist: () => {}, + }; + const first = await executeTaskflow(state(oldDef, process.cwd(), `graft-${replacement.label}`), deps); + assert.equal(first.state.phases["grow-old"]?.usage?.cost, 1); + first.state.def = { + ...oldDef, + phases: [{ ...oldDef.phases[0], def: replacement.def }], + }; + const second = await executeTaskflow(first.state, deps); + assert.equal(second.state.phases["grow-old"], undefined, replacement.label); + assert.equal(second.totalUsage.cost, replacement.expectedCost, `${replacement.label}: old usage removed`); + } +}); + +test("graft collision metadata never claims or later deletes an existing parent phase", async () => { + const firstDef: Taskflow = { + name: "graft-parent-collision", + phases: [ + { id: "grow-old", task: "parent", final: false }, + { + id: "grow", + type: "expand", + expandMode: "graft", + dependsOn: ["grow-old"], + def: { name: "fragment", phases: [{ id: "old", task: "child", final: true }] }, + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ok(agent, task, task, task === "child" ? 1 : 0), + persist: () => {}, + }; + const first = await executeTaskflow(state(firstDef, process.cwd(), "graft-parent-collision"), deps); + assert.equal(first.state.phases["grow-old"]?.output, "parent"); + assert.equal(first.state.phases.grow.promotedPhases, undefined, "collision-skipped child is not owned metadata"); + assert.equal(first.state.phases.grow.usage?.cost, 1, "all-collision usage remains on expand"); + first.state.def = { + ...firstDef, + phases: [ + firstDef.phases[0], + { ...firstDef.phases[1], def: { name: "empty", phases: [] } }, + ], + }; + const second = await executeTaskflow(first.state, deps); + assert.equal(second.state.phases["grow-old"]?.status, "done"); + assert.equal(second.state.phases["grow-old"]?.output, "parent"); +}); + +test("graft ownership migration cannot delete a newly declared upstream phase", async () => { + const firstDef: Taskflow = { + name: "graft-owned-to-declared", + phases: [ + { + id: "grow", + type: "expand", + expandMode: "graft", + def: { name: "old-fragment", phases: [{ id: "x", task: "old-child", final: true }] }, + final: true, + }, + ], + }; + const deps: RuntimeDeps = { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => + ok(agent, task, task, task === "old-child" ? 1 : task === "new-parent" ? 3 : 0), + persist: () => {}, + }; + const first = await executeTaskflow(state(firstDef, process.cwd(), "graft-owned-to-declared"), deps); + assert.equal(first.state.phases["grow-x"]?.output, "old-child"); + assert.equal(first.state.phases["grow-x"]?.usage?.cost, 1); + assert.ok(first.state.phases.grow.promotedPhases?.["grow-x"]); + + first.state.def = { + ...firstDef, + phases: [ + { id: "grow-x", task: "new-parent" }, + { + ...firstDef.phases[0], + dependsOn: ["grow-x"], + def: { name: "empty-fragment", phases: [] }, + }, + ], + }; + const second = await executeTaskflow(first.state, deps); + assert.equal(second.state.status, "completed"); + assert.equal(second.state.phases["grow-x"]?.status, "done"); + assert.equal(second.state.phases["grow-x"]?.output, "new-parent"); + assert.equal(second.state.phases["grow-x"]?.usage?.cost, 3); + assert.equal(second.totalUsage.cost, 3); +}); + +test("mixed graft collision keeps residual usage and enforces parent budget", async () => { + const def: Taskflow = { + name: "graft-mixed-usage", + budget: { maxUSD: 3.5 }, + phases: [ + { id: "grow-collision", task: "parent", final: false }, + { + id: "grow", + type: "expand", + expandMode: "graft", + dependsOn: ["grow-collision"], + def: { + name: "mixed-fragment", + phases: [ + { id: "collision", task: "collision-fail", optional: true }, + { id: "promoted", task: "promoted-ok", dependsOn: ["collision"], final: true }, + ], + }, + final: false, + }, + { id: "after", task: "after", dependsOn: ["grow"], final: true }, + ], + }; + const result = await executeTaskflow(state(def, process.cwd(), "graft-mixed-usage"), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => + task === "collision-fail" + ? { ...ok(agent, task, "", 1), exitCode: 1, stopReason: "error", errorMessage: "optional failed" } + : ok(agent, task, task, 1), + persist: () => {}, + }); + assert.equal(result.state.phases["grow-collision"]?.usage?.cost, 1, "authored parent usage"); + assert.equal(result.state.phases["grow-promoted"]?.usage?.cost, 1, "promoted child owns its usage"); + assert.equal(result.state.phases.grow.usage?.cost, 1, "collision-skipped optional failure stays residual"); + assert.equal(result.state.phases.grow.promotedPhases?.["grow-collision"], undefined); + assert.ok(result.state.phases.grow.promotedPhases?.["grow-promoted"]); + assert.equal(result.state.phases.after?.usage?.cost, 1); + assert.equal(result.totalUsage.cost, 4); + assert.equal(result.state.status, "blocked", "residual usage participates in the parent budget"); +}); + +test("nested flow budget uses parent remaining tokens across inline/saved/expand/kernel paths", async () => { + const child: Taskflow = { + name: "remaining-child", + phases: [ + { id: "c1", task: "c1" }, + { id: "c2", task: "c2", dependsOn: ["c1"], final: true }, + ], + }; + const cases: Array<{ name: string; phase: Taskflow["phases"][number]; kernel: boolean }> = [ + { name: "inline", phase: { id: "nested", type: "flow", def: child, dependsOn: ["spend"], final: true }, kernel: false }, + { name: "saved", phase: { id: "nested", type: "flow", use: "remaining-child", dependsOn: ["spend"], final: true }, kernel: false }, + { name: "expand-nested", phase: { id: "nested", type: "expand", def: child, dependsOn: ["spend"], final: true }, kernel: true }, + { name: "expand-graft", phase: { id: "nested", type: "expand", expandMode: "graft", def: child, dependsOn: ["spend"], final: true }, kernel: true }, + { name: "kernel-inline", phase: { id: "nested", type: "flow", def: child, dependsOn: ["spend"], final: true }, kernel: true }, + { name: "kernel-saved", phase: { id: "nested", type: "flow", use: "remaining-child", dependsOn: ["spend"], final: true }, kernel: true }, + ]; + for (const c of cases) { + const def: Taskflow = { + name: `remaining-${c.name}`, + budget: { maxTokens: 100 }, + phases: [{ id: "spend", task: "parent-spend" }, c.phase], + }; + const calls: string[] = []; + const result = await executeTaskflow(state(def, process.cwd()), { + cwd: process.cwd(), + agents: [AGENT], + loadFlow: () => child, + runTask: async (_c, _a, agent, task) => { + calls.push(task); + return { + ...ok(agent, task, task), + usage: { ...emptyUsage(), input: task === "parent-spend" ? 90 : 20, turns: 1 }, + }; + }, + eventKernel: c.kernel, + persist: () => {}, + }); + assert.deepEqual(calls, ["parent-spend", "c1"], `${c.name}: c2 blocked by remaining cap`); + assert.equal(result.totalUsage.input, 110, c.name); + assert.equal(result.state.status, "blocked", c.name); + } +}); + +test("remaining nested budget clamps USD and tokens independently", () => { + const child: Taskflow = { + name: "budget-child", + budget: { maxUSD: 0.5, maxTokens: 50 }, + phases: [{ id: "p", task: "x", final: true }], + }; + const clamped = clampSubFlowBudget( + child, + { maxUSD: 10, maxTokens: 100 }, + { ...emptyUsage(), cost: 9, input: 70, output: 20 }, + ); + assert.deepEqual(clamped.budget, { maxUSD: 0.5, maxTokens: 10 }); + const usdOnly = clampSubFlowBudget(child, { maxUSD: 10 }, { ...emptyUsage(), cost: 9 }); + assert.deepEqual(usdOnly.budget, { maxUSD: 0.5, maxTokens: 50 }); +}); + +test("ctx_spawn flat siblings stop after the first atomic budget overshoot on either axis", async () => { + for (const axis of ["tokens", "usd"] as const) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), `taskflow-spawn-${axis}-`)); + try { + const calls: string[] = []; + const def: Taskflow = { + name: `spawn-flat-${axis}`, + budget: axis === "tokens" ? { maxTokens: 10 } : { maxUSD: 0.01 }, + phases: [{ id: "root", task: "parent", shareContext: true, final: true }], + }; + const result = await executeTaskflow(state(def, cwd), { + cwd, + agents: [AGENT], + runTask: async (_c, _a, agent, task, opts: RunOptions) => { + calls.push(task); + if (task === "parent") queueSpawn(opts.ctxDir!, opts.nodeId!, [{ task: "child-1" }, { task: "child-2" }]); + return { + ...ok(agent, task, task), + usage: { + ...emptyUsage(), + output: axis === "tokens" ? (task === "parent" ? 6 : 5) : 1, + cost: axis === "usd" ? (task === "parent" ? 0.006 : 0.005) : 0, + turns: 1, + }, + }; + }, + persist: () => {}, + }); + assert.deepEqual(calls, ["parent", "child-1"], `${axis}: second sibling must not run`); + assert.equal(result.state.status, "blocked", axis); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + } +}); + +test("ctx_spawn inline subflow receives only the parent remaining budget", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-spawn-subflow-budget-")); + try { + const calls: string[] = []; + const def: Taskflow = { + name: "spawn-subflow-remaining", + budget: { maxTokens: 10 }, + phases: [{ id: "root", task: "parent", shareContext: true, final: true }], + }; + await executeTaskflow(state(def, cwd), { + cwd, + agents: [AGENT], + runTask: async (_c, _a, agent, task, opts: RunOptions) => { + calls.push(task); + if (task === "parent") { + queueSpawn(opts.ctxDir!, opts.nodeId!, [{ + subflow: { + phases: [ + { id: "inner-1", task: "inner-1" }, + { id: "inner-2", task: "inner-2", dependsOn: ["inner-1"], final: true }, + ], + }, + }]); + } + return { + ...ok(agent, task, task), + usage: { ...emptyUsage(), output: task === "parent" ? 6 : 5, turns: 1 }, + }; + }, + persist: () => {}, + }); + assert.deepEqual(calls, ["parent", "inner-1"], "inner-2 must be blocked after the atomic overshoot"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("ctx_spawn grandchild shares its ancestor batch budget ledger", async () => { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-spawn-grand-budget-")); + try { + const calls: string[] = []; + const def: Taskflow = { + name: "spawn-grand-remaining", + budget: { maxTokens: 10 }, + phases: [{ id: "root", task: "parent", shareContext: true, final: true }], + }; + await executeTaskflow(state(def, cwd), { + cwd, + agents: [AGENT], + runTask: async (_c, _a, agent, task, opts: RunOptions) => { + calls.push(task); + if (task === "parent") queueSpawn(opts.ctxDir!, opts.nodeId!, [{ task: "child" }]); + if (task === "child") queueSpawn(opts.ctxDir!, opts.nodeId!, [{ task: "grandchild" }]); + return { + ...ok(agent, task, task), + usage: { ...emptyUsage(), output: task === "parent" ? 6 : 5, turns: 1 }, + }; + }, + persist: () => {}, + }); + assert.deepEqual(calls, ["parent", "child"], "grandchild must not run after its ancestor crosses the cap"); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } +}); + +test("definition evolution removes stale ordinary phase failure and usage before scheduling", async () => { + const oldDef: Taskflow = { + name: "definition-evolution", + phases: [{ id: "old-failed", task: "old-failed", final: true }], + }; + const first = await executeTaskflow(state(oldDef, process.cwd(), "definition-evolution-run"), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ({ + ...ok(agent, task, "", 7), + exitCode: 1, + stopReason: "error", + errorMessage: "recorded old failure", + }), + persist: () => {}, + }); + assert.equal(first.state.status, "failed"); + assert.equal(first.totalUsage.cost, 7); + + const newDef: Taskflow = { + name: "definition-evolution", + phases: [{ id: "new-success", task: "new-success", final: true }], + }; + first.state.def = newDef; + const calls: string[] = []; + const second = await executeTaskflow(first.state, { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => { + calls.push(task); + return ok(agent, task, "new result", 2); + }, + persist: () => {}, + }); + assert.deepEqual(calls, ["new-success"]); + assert.equal(second.state.phases["old-failed"], undefined); + assert.equal(second.state.phases["new-success"]?.status, "done"); + assert.equal(second.state.status, "completed"); + assert.equal(second.totalUsage.cost, 2, "only phases declared by the new definition contribute usage"); +}); + +test("graft preflights collisions against authored ids that have no state row yet", async () => { + const def: Taskflow = { + name: "graft-future-authored-collision", + budget: { maxUSD: 2.5 }, + phases: [ + { + id: "grow", + type: "expand", + expandMode: "graft", + def: { name: "fragment", phases: [{ id: "authored", task: "dynamic-child", final: true }] }, + }, + { id: "grow-authored", task: "authored-phase", dependsOn: ["grow"], final: true }, + ], + }; + const result = await executeTaskflow(state(def, process.cwd(), "graft-future-authored-collision"), { + cwd: process.cwd(), + agents: [AGENT], + runTask: async (_c, _a, agent, task) => ok(agent, task, task, task === "dynamic-child" ? 1 : 2), + persist: () => {}, + }); + assert.equal(result.state.phases.grow.promotedPhases, undefined, "collision child is never owned as promoted state"); + assert.equal(result.state.phases.grow.usage?.cost, 1, "collision child usage remains expand residual"); + assert.equal(result.state.phases["grow-authored"]?.usage?.cost, 2, "authored phase retains its own usage"); + assert.equal(result.totalUsage.cost, 3); + assert.equal(result.state.status, "blocked", "residual + authored usage enforces the 2.5 budget"); +}); diff --git a/packages/taskflow-core/test/runtime.test.ts b/packages/taskflow-core/test/runtime.test.ts index 51ade46..785d3e3 100644 --- a/packages/taskflow-core/test/runtime.test.ts +++ b/packages/taskflow-core/test/runtime.test.ts @@ -6,7 +6,7 @@ import { test } from "node:test"; import type { AgentConfig } from "../src/agents.ts"; import type { RunResult, RunOptions } from "../src/runner-core.ts"; import { emptyUsage } from "../src/usage.ts"; -import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import { agentDefinitionsIdentity, executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; import type { Taskflow } from "../src/schema.ts"; import type { RunState } from "../src/store.ts"; import { parseGateVerdict } from "../src/runtime.ts"; @@ -14,6 +14,7 @@ import { parseGateVerdict } from "../src/runtime.ts"; const AGENTS: AgentConfig[] = [ { name: "a", description: "test agent", systemPrompt: "", source: "user", filePath: "" }, ]; +const AGENTS_ID = agentDefinitionsIdentity(AGENTS); function mkState(def: Taskflow, args: Record = {}): RunState { return { @@ -265,8 +266,8 @@ test("runtime: resume skips cached completed phases", async () => { id: "one", status: "done", output: "out:start", - // Must match runtime cacheKey(): flow name + v3:phasefp sub-fingerprint + base parts + thinking + tools + ctx. - inputHash: hashInput(`flow:${def.name}`, `v3:phasefp:${subfpOne}`, "one", "a", "", "start", "think:", "tools:[]", "ctx:"), + // Must match runtime cacheKey(): structural + execution + flow-semantic identity. + inputHash: hashInput(`flow:${def.name}`, `v3:phasefp:${subfpOne}`, "one", "a", "", "start", "think:", "tools:[]", "ctx:", "agent-scope:user", "context-sharing:0", `agents:${AGENTS_ID}`), usage: emptyUsage(), }; @@ -291,13 +292,13 @@ test("runtime: resume caches a completed reduce phase (unified inputHash)", asyn const subfpX = (await phaseFingerprint(def, "x")) ?? ""; const subfpSum = (await phaseFingerprint(def, "sum")) ?? ""; const state = mkState(def); - state.phases.x = { id: "x", status: "done", output: "o:tx", inputHash: hashInput(`flow:${def.name}`, `v3:phasefp:${subfpX}`, "x", "a", "", "tx", "think:", "tools:[]", "ctx:"), usage: emptyUsage() }; + state.phases.x = { id: "x", status: "done", output: "o:tx", inputHash: hashInput(`flow:${def.name}`, `v3:phasefp:${subfpX}`, "x", "a", "", "tx", "think:", "tools:[]", "ctx:", "agent-scope:user", "context-sharing:0", `agents:${AGENTS_ID}`), usage: emptyUsage() }; // reduce cache key has the same shape as agent/gate (flow + v3:phasefp + base parts + thinking + tools). state.phases.sum = { id: "sum", status: "done", output: "o:combine o:tx", - inputHash: hashInput(`flow:${def.name}`, `v3:phasefp:${subfpSum}`, "sum", "a", "", "combine o:tx", "think:", "tools:[]", "ctx:"), + inputHash: hashInput(`flow:${def.name}`, `v3:phasefp:${subfpSum}`, "sum", "a", "", "combine o:tx", "think:", "tools:[]", "ctx:", "agent-scope:user", "context-sharing:0", `agents:${AGENTS_ID}`), usage: emptyUsage(), }; const res = await executeTaskflow(state, baseDeps(runner)); diff --git a/packages/taskflow-core/test/usage-accounting.test.ts b/packages/taskflow-core/test/usage-accounting.test.ts new file mode 100644 index 0000000..bb88bc6 --- /dev/null +++ b/packages/taskflow-core/test/usage-accounting.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import { executeTaskflow, type RuntimeDeps } from "../src/runtime.ts"; +import type { Taskflow } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; +import { emptyUsage } from "../src/usage.ts"; + +const agents: AgentConfig[] = [ + { name: "a", description: "test", systemPrompt: "", source: "project", filePath: "" }, +]; + +function state(def: Taskflow): RunState { + return { + runId: `usage-${Math.random()}`, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: process.cwd(), + }; +} + +function deps(loadFlow?: (name: string) => Taskflow | undefined): RuntimeDeps & { calls: { value: number } } { + const calls = { value: 0 }; + return { + calls, + cwd: process.cwd(), + agents, + usageAccounting: "unavailable", + loadFlow, + persist: () => {}, + runTask: async (_cwd, _agents, agent, task) => { + calls.value++; + return { agent, task, exitCode: 0, output: "unsafe", stderr: "", usage: emptyUsage() }; + }, + }; +} + +const child: Taskflow = { + name: "metered-child", + budget: { maxTokens: 1 }, + phases: [{ id: "work", type: "agent", agent: "a", task: "spend", final: true }], +}; + +test("usage-unavailable host rejects a top-level budget before any agent call", async () => { + const d = deps(); + const result = await executeTaskflow(state(child), d); + assert.equal(result.ok, false); + assert.equal(d.calls.value, 0); + assert.match(result.finalOutput, /usage accounting is unavailable/i); +}); + +test("runtime infers unavailable accounting from a bare runner function", async () => { + const d = deps(); + delete d.usageAccounting; + (d.runTask as NonNullable & { usageAccounting: "unavailable" }).usageAccounting = "unavailable"; + const result = await executeTaskflow(state(child), d); + assert.equal(result.ok, false); + assert.equal(d.calls.value, 0); +}); + +test("usage-unavailable host rejects every executable nested budget form", async (t) => { + const cases: Array<{ + name: string; + phase: Taskflow["phases"][number]; + loadFlow?: (name: string) => Taskflow | undefined; + eventKernel?: boolean; + }> = [ + { name: "inline-object", phase: { id: "nested", type: "flow", def: child, final: true } }, + { name: "inline-string", phase: { id: "nested", type: "flow", def: JSON.stringify(child), final: true } }, + { + name: "saved-flow", + phase: { id: "nested", type: "flow", use: child.name, final: true }, + loadFlow: (name) => (name === child.name ? child : undefined), + eventKernel: true, + }, + { name: "expand", phase: { id: "nested", type: "expand", def: child, expandMode: "nested", final: true } }, + { name: "expand-graft", phase: { id: "nested", type: "expand", def: child, expandMode: "graft", final: true } }, + ]; + for (const entry of cases) { + await t.test(entry.name, async () => { + const def: Taskflow = { name: `parent-${entry.name}`, phases: [entry.phase] }; + const d = deps(entry.loadFlow); + if (entry.eventKernel) d.eventKernel = true; + const result = await executeTaskflow(state(def), d); + assert.equal(result.ok, false); + assert.equal(d.calls.value, 0, `${entry.name} must fail before spending`); + const diagnostics = [result.finalOutput, ...Object.values(result.state.phases).map((p) => p.error ?? "")].join("\n"); + assert.match(diagnostics, /usage accounting is unavailable/i); + }); + } +}); diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index 3d5d4fa..de035de 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -57,7 +57,8 @@ "dist" ], "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs taskflow-dsl 2>/dev/null || true", + "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs taskflow-dsl", + "test:e2e": "node --experimental-strip-types test/e2e-dist-cli.mts", "prepublishOnly": "npm run build" }, "dependencies": { diff --git a/packages/taskflow-dsl/src/build.ts b/packages/taskflow-dsl/src/build.ts index c66051a..098c8ac 100644 --- a/packages/taskflow-dsl/src/build.ts +++ b/packages/taskflow-dsl/src/build.ts @@ -9,6 +9,7 @@ import { hashFlowIR, validateTaskflow, desugar, + parseJsonc, type Taskflow, } from "taskflow-core"; // desugar: only for JSON shorthand escape path @@ -125,15 +126,24 @@ export function buildFile(filePath: string, opts: BuildOptions = {}): BuildResul file: abs, }; } - const text = fs.readFileSync(abs, "utf8"); - return buildSource(text, abs, opts); + try { + const text = fs.readFileSync(abs, "utf8"); + return buildSource(text, abs, opts); + } catch (e) { + return ioFailure(abs, "TFDSL_IO_READ", e); + } } function buildJsonFile(abs: string, opts: BuildOptions): BuildResult { - const text = fs.readFileSync(abs, "utf8"); + let text: string; + try { + text = fs.readFileSync(abs, "utf8"); + } catch (e) { + return ioFailure(abs, "TFDSL_IO_READ", e); + } let parsed: unknown; try { - parsed = JSON.parse(text); + parsed = abs.toLowerCase().endsWith(".jsonc") ? parseJsonc(text) : JSON.parse(text); } catch (e) { return { ok: false, @@ -151,7 +161,9 @@ function buildJsonFile(abs: string, opts: BuildOptions): BuildResult { const diagnostics: Diagnostic[] = []; // JSON escape: allow full Taskflow or shorthand (task/tasks/chain). let taskflow: Taskflow; - const asRec = parsed as Record; + const asRec = parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Record + : {}; if (Array.isArray(asRec.phases)) { const v = validateTaskflow(parsed); if (!v.ok) { @@ -184,11 +196,34 @@ function buildJsonFile(abs: string, opts: BuildOptions): BuildResult { let flowir: unknown; let irHash: string | undefined; if (opts.emit === "flowir" || opts.emit === "both" || opts.irHash !== false) { - const compiled = compileTaskflowToFlowIR(taskflow); - flowir = compiled.canonical; - irHash = hashFlowIR(compiled.canonical); + try { + const compiled = compileTaskflowToFlowIR(taskflow); + flowir = compiled.canonical; + irHash = hashFlowIR(compiled.canonical); + } catch (e) { + diagnostics.push({ + code: "TFDSL_CORE_IR", + severity: "error", + message: e instanceof Error ? e.message : String(e), + file: abs, + }); + return { ok: false, diagnostics, file: abs }; + } } return { ok: true, diagnostics, taskflow, flowir, irHash, file: abs }; } +function ioFailure(file: string, code: string, error: unknown): BuildResult { + return { + ok: false, + diagnostics: [{ + code, + severity: "error", + message: error instanceof Error ? error.message : String(error), + file, + }], + file, + }; +} + export { eraseSource } from "./build/erase.ts"; diff --git a/packages/taskflow-dsl/src/build/erase/ast.ts b/packages/taskflow-dsl/src/build/erase/ast.ts index 6ec52e2..b55d3b3 100644 --- a/packages/taskflow-dsl/src/build/erase/ast.ts +++ b/packages/taskflow-dsl/src/build/erase/ast.ts @@ -41,20 +41,38 @@ export function calleeName(expr: ts.Expression): string | undefined { export function evalLiteral(node: ts.Expression): unknown { if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; if (ts.isNumericLiteral(node)) return Number(node.text); + if ( + ts.isPrefixUnaryExpression(node) && + (node.operator === ts.SyntaxKind.PlusToken || node.operator === ts.SyntaxKind.MinusToken) && + ts.isNumericLiteral(node.operand) + ) { + const value = Number(node.operand.text); + return node.operator === ts.SyntaxKind.MinusToken ? -value : value; + } if (node.kind === ts.SyntaxKind.TrueKeyword) return true; if (node.kind === ts.SyntaxKind.FalseKeyword) return false; if (node.kind === ts.SyntaxKind.NullKeyword) return null; if (ts.isArrayLiteralExpression(node)) { - return node.elements.map((e) => (ts.isSpreadElement(e) ? undefined : evalLiteral(e as ts.Expression))); + const values: unknown[] = []; + for (const element of node.elements) { + if (ts.isSpreadElement(element)) return undefined; + const value = evalLiteral(element as ts.Expression); + if (value === undefined) return undefined; + values.push(value); + } + return values; } if (ts.isObjectLiteralExpression(node)) { const o: Record = {}; for (const p of node.properties) { - if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name)) { - o[p.name.text] = evalLiteral(p.initializer); - } else if (ts.isPropertyAssignment(p) && ts.isStringLiteral(p.name)) { - o[p.name.text] = evalLiteral(p.initializer); - } + if (!ts.isPropertyAssignment(p)) return undefined; + const key = ts.isIdentifier(p.name) || ts.isStringLiteral(p.name) + ? p.name.text + : undefined; + if (!key) return undefined; + const value = evalLiteral(p.initializer); + if (value === undefined) return undefined; + o[key] = value; } return o; } diff --git a/packages/taskflow-dsl/src/build/erase/context.ts b/packages/taskflow-dsl/src/build/erase/context.ts index 93e40bb..dd6f2da 100644 --- a/packages/taskflow-dsl/src/build/erase/context.ts +++ b/packages/taskflow-dsl/src/build/erase/context.ts @@ -4,7 +4,8 @@ import ts from "typescript"; import type { Diagnostic } from "../../diagnostics.ts"; -import type { PhaseDraft } from "./types.ts"; +import { diag, evalLiteral } from "./ast.ts"; +import { phaseByBinding, setPhaseBinding, type PhaseDraft } from "./types.ts"; export interface EmitContext { file: string; @@ -28,7 +29,30 @@ export function register(ctx: EmitContext, draft: PhaseDraft): string { if (draft.dependsOn.size) draft.raw.dependsOn = [...draft.dependsOn]; else delete draft.raw.dependsOn; if (draft.final) draft.raw.final = true; + const existing = ctx.phases.get(draft.id); + if (existing && existing !== draft) { + ctx.diags.push({ + code: "TFDSL_PHASE_ID_DUPLICATE", + severity: "error", + message: `Duplicate emitted phase id '${draft.id}'.`, + file: ctx.file, + }); + return draft.id; + } ctx.phases.set(draft.id, draft); + if (draft.binding) { + const bound = phaseByBinding(ctx.phases, draft.binding); + if (bound && bound !== draft) { + ctx.diags.push({ + code: "TFDSL_BINDING_COLLISION", + severity: "error", + message: `Binding '${draft.binding}' collides with phase id '${bound.id}'.`, + file: ctx.file, + }); + } else { + setPhaseBinding(ctx.phases, draft.binding, draft); + } + } if (!ctx.order.includes(draft.id)) ctx.order.push(draft.id); return draft.id; } @@ -42,20 +66,40 @@ export function bindDefArg( if (!defArg) return; const { phases } = ctx; if (tsIsPropertyAccess(defArg) && tsIsIdentifier(defArg.expression)) { - const pid = defArg.expression.text; - if (phases.has(pid) && (defArg.name.text === "json" || defArg.name.text === "output")) { + const binding = defArg.expression.text; + const pid = phaseByBinding(phases, binding)?.id; + if (pid && (defArg.name.text === "json" || defArg.name.text === "output")) { draft.dependsOn.add(pid); draft.raw.def = defArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } else { + ctx.diags.push(diag(ctx.file, ctx.sf, defArg, "TFDSL_INLINE_DEF_UNSUPPORTED", `def phase reference must be a previously declared phase.json or phase.output handle.`)); } } else if (tsIsStringLiteral(defArg)) { draft.raw.def = defArg.text; - } else if (tsIsIdentifier(defArg) && phases.has(defArg.text)) { - draft.dependsOn.add(defArg.text); - draft.raw.def = `{steps.${defArg.text}.json}`; + } else if (tsIsIdentifier(defArg) && phaseByBinding(phases, defArg.text)) { + const pid = phaseByBinding(phases, defArg.text)!.id; + draft.dependsOn.add(pid); + draft.raw.def = `{steps.${pid}.json}`; + } else if (ts.isObjectLiteralExpression(defArg) || ts.isArrayLiteralExpression(defArg)) { + const value = evalLiteral(defArg); + if (containsUndefined(value)) { + ctx.diags.push(diag(ctx.file, ctx.sf, defArg, "TFDSL_INLINE_DEF_DYNAMIC", `Inline def must contain only static JSON values.`)); + } else { + draft.raw.def = value; + } + } else { + ctx.diags.push(diag(ctx.file, ctx.sf, defArg, "TFDSL_INLINE_DEF_UNSUPPORTED", `def must be a phase handle, string, or static Taskflow object.`)); } } +function containsUndefined(value: unknown): boolean { + if (value === undefined) return true; + if (Array.isArray(value)) return value.some(containsUndefined); + if (value && typeof value === "object") return Object.values(value).some(containsUndefined); + return false; +} + function tsIsPropertyAccess(n: ts.Node): n is ts.PropertyAccessExpression { return ts.isPropertyAccessExpression(n); } diff --git a/packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts b/packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts index 2ea1f1a..68ded97 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/agent-script.ts @@ -13,6 +13,7 @@ export function emitAgent( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "agent", raw: { type: "agent" }, dependsOn: new Set(), @@ -44,6 +45,7 @@ export function emitScript( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "script", raw: { type: "script" }, dependsOn: new Set(), diff --git a/packages/taskflow-dsl/src/build/erase/kinds/approval.ts b/packages/taskflow-dsl/src/build/erase/kinds/approval.ts index 8d22144..72f9225 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/approval.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/approval.ts @@ -11,6 +11,7 @@ export function emitApproval( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "approval", raw: { type: "approval" }, dependsOn: new Set(), diff --git a/packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts b/packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts index 361faed..8b02722 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/expand-flow.ts @@ -16,6 +16,7 @@ export function emitExpandNestedOrSubflowDef( (cn.startsWith("expand") ? `expand-${ctx.order.length}` : `flow-${ctx.order.length}`); const draft: PhaseDraft = { id, + binding: id, type: cn === "expand.nested" ? "expand" : "flow", raw: cn === "expand.nested" @@ -43,12 +44,21 @@ export function emitSubflowUse( call: ts.CallExpression, ): string { const id = bindName ?? `flow-${ctx.order.length}`; - const draft: PhaseDraft = { id, type: "flow", raw: { type: "flow" }, dependsOn: new Set() }; + const draft: PhaseDraft = { id, binding: id, type: "flow", raw: { type: "flow" }, dependsOn: new Set() }; const useArg = call.arguments[0]; if (useArg && ts.isStringLiteral(useArg)) draft.raw.use = useArg.text; else ctx.diags.push(diag(ctx.file, ctx.sf, call, "TFDSL_RUNE_ARG", `subflow(use) requires a string name.`)); - if (call.arguments[1] && ts.isObjectLiteralExpression(call.arguments[1])) { - draft.raw.with = evalLiteral(call.arguments[1]); + if (call.arguments[1]) { + if (!ts.isObjectLiteralExpression(call.arguments[1])) { + ctx.diags.push(diag(ctx.file, ctx.sf, call.arguments[1]!, "TFDSL_SUBFLOW_WITH_DYNAMIC", `subflow with-args must be a static object literal.`)); + } else { + const withArgs = evalLiteral(call.arguments[1]); + if (withArgs && typeof withArgs === "object" && !Array.isArray(withArgs)) { + draft.raw.with = withArgs; + } else { + ctx.diags.push(diag(ctx.file, ctx.sf, call.arguments[1], "TFDSL_SUBFLOW_WITH_DYNAMIC", `subflow with-args must contain only static JSON values.`)); + } + } } const opts = mergeOpts( ctx.sf, @@ -72,6 +82,7 @@ export function emitExpand( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "expand", raw: { type: "expand", diff --git a/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts b/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts index bf4260f..ef9a8bc 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/gate-sugar.ts @@ -1,8 +1,8 @@ import ts from "typescript"; -import { evalLiteral } from "../ast.ts"; +import { diag, evalLiteral } from "../ast.ts"; import { mergeOpts } from "../opts.ts"; import { eraseStringish } from "../templates.ts"; -import type { PhaseDraft } from "../types.ts"; +import { phaseByBinding, type PhaseDraft } from "../types.ts"; import { type EmitContext, nextSyntheticId, register } from "../context.ts"; /** gate.automated / gate.scored */ @@ -15,12 +15,17 @@ export function emitGateSugar( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "gate", raw: { type: "gate" }, dependsOn: new Set(), }; const up = call.arguments[0]; - if (up && ts.isIdentifier(up) && ctx.phases.has(up.text)) draft.dependsOn.add(up.text); + if (up && ts.isIdentifier(up) && phaseByBinding(ctx.phases, up.text)) { + draft.dependsOn.add(phaseByBinding(ctx.phases, up.text)!.id); + } else if (up) { + ctx.diags.push(diag(ctx.file, ctx.sf, up, "TFDSL_DEP_DYNAMIC", `${cn} upstream must be a previously declared phase handle.`)); + } const optsArg = call.arguments[1] as ts.Expression | undefined; const sugarKeys = new Set([ "pass", diff --git a/packages/taskflow-dsl/src/build/erase/kinds/gate.ts b/packages/taskflow-dsl/src/build/erase/kinds/gate.ts index cec61ab..6d0133f 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/gate.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/gate.ts @@ -1,7 +1,8 @@ import ts from "typescript"; +import { diag } from "../ast.ts"; import { mergeOpts } from "../opts.ts"; import { eraseGateTask } from "../templates.ts"; -import type { PhaseDraft } from "../types.ts"; +import { phaseByBinding, type PhaseDraft } from "../types.ts"; import { type EmitContext, nextSyntheticId, register } from "../context.ts"; /** Plain gate(upstream, opts?, taskLambda?) — not gate.automated / gate.scored. */ @@ -13,12 +14,17 @@ export function emitGate( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "gate", raw: { type: "gate" }, dependsOn: new Set(), }; const up = call.arguments[0]; - if (up && ts.isIdentifier(up) && ctx.phases.has(up.text)) draft.dependsOn.add(up.text); + if (up && ts.isIdentifier(up) && phaseByBinding(ctx.phases, up.text)) { + draft.dependsOn.add(phaseByBinding(ctx.phases, up.text)!.id); + } else if (up) { + ctx.diags.push(diag(ctx.file, ctx.sf, up, "TFDSL_DEP_DYNAMIC", `gate upstream must be a previously declared phase handle.`)); + } const optsArg = call.arguments[1] as ts.Expression | undefined; const taskArg = call.arguments[2] as ts.Expression | undefined; const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); @@ -43,7 +49,7 @@ export function emitGate( ctx.file, expr, param, - up && ts.isIdentifier(up) ? up.text : undefined, + up && ts.isIdentifier(up) ? phaseByBinding(ctx.phases, up.text)?.id : undefined, ctx.phases, ctx.diags, ); diff --git a/packages/taskflow-dsl/src/build/erase/kinds/loop.ts b/packages/taskflow-dsl/src/build/erase/kinds/loop.ts index 2dc209a..f326ab9 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/loop.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/loop.ts @@ -12,12 +12,13 @@ export function emitLoop( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "loop", raw: { type: "loop" }, dependsOn: new Set(), }; const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases, { allowKeys: new Set(["task"]) }); if (typeof opts.id === "string") draft.id = opts.id; Object.assign(draft.raw, opts); // task: (prev) => `...` inside object — scan object for task method diff --git a/packages/taskflow-dsl/src/build/erase/kinds/map.ts b/packages/taskflow-dsl/src/build/erase/kinds/map.ts index da1aa25..698df3d 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/map.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/map.ts @@ -1,8 +1,8 @@ import ts from "typescript"; -import { calleeName } from "../ast.ts"; +import { calleeName, diag } from "../ast.ts"; import { mergeOpts } from "../opts.ts"; import { eraseStringish } from "../templates.ts"; -import type { PhaseDraft } from "../types.ts"; +import { phaseByBinding, type PhaseDraft } from "../types.ts"; import { type EmitContext, nextSyntheticId, register } from "../context.ts"; export function emitMap( @@ -13,6 +13,7 @@ export function emitMap( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "map", raw: { type: "map" }, dependsOn: new Set(), @@ -20,18 +21,24 @@ export function emitMap( const overArg = call.arguments[0]; const fnArg = call.arguments[1]; const optsArg = call.arguments[2] as ts.Expression | undefined; - if (overArg && ts.isIdentifier(overArg) && ctx.phases.has(overArg.text)) { - draft.dependsOn.add(overArg.text); - draft.raw.over = `{steps.${overArg.text}.json}`; + if (overArg && ts.isIdentifier(overArg) && phaseByBinding(ctx.phases, overArg.text)) { + const pid = phaseByBinding(ctx.phases, overArg.text)!.id; + draft.dependsOn.add(pid); + draft.raw.over = `{steps.${pid}.json}`; } else if (overArg && ts.isPropertyAccessExpression(overArg) && ts.isIdentifier(overArg.expression)) { - const pid = overArg.expression.text; - if (ctx.phases.has(pid)) { + const binding = overArg.expression.text; + const pid = phaseByBinding(ctx.phases, binding)?.id; + if (pid && (overArg.name.text === "json" || overArg.name.text === "output")) { draft.dependsOn.add(pid); draft.raw.over = overArg.name.text === "json" ? `{steps.${pid}.json}` : `{steps.${pid}.output}`; + } else { + ctx.diags.push(diag(ctx.file, ctx.sf, overArg, "TFDSL_DEP_DYNAMIC", `map source must be a phase handle, phase.json/output, or static interpolation string.`)); } } else if (overArg && (ts.isStringLiteral(overArg) || ts.isNoSubstitutionTemplateLiteral(overArg))) { draft.raw.over = overArg.text; + } else if (overArg) { + ctx.diags.push(diag(ctx.file, ctx.sf, overArg, "TFDSL_DEP_DYNAMIC", `map source must be a phase handle, phase.json/output, or static interpolation string.`)); } let itemName = "item"; if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { @@ -49,6 +56,10 @@ export function emitMap( if (inner && ts.isCallExpression(inner)) { const innerCn = calleeName(inner.expression); if (innerCn === "agent") { + if (inner.arguments.length < 1 || inner.arguments.length > 2) { + ctx.diags.push(diag(ctx.file, ctx.sf, inner, "TFDSL_RUNE_ARITY", `map inner agent expects 1-2 arguments, got ${inner.arguments.length}.`)); + return register(ctx, draft); + } const erased = eraseStringish( ctx.sf, ctx.file, @@ -68,8 +79,24 @@ export function emitMap( ctx.diags, ctx.phases, ); - if (iopts.agent) draft.raw.agent = iopts.agent; - if (iopts.output) draft.raw.output = iopts.output; + const perItemKeys = new Set([ + "agent", "model", "thinking", "tools", "cwd", "output", "expect", "retry", "timeout", + "optional", "idempotent", "context", "contextLimit", "cache", "shareContext", + ]); + for (const [key, value] of Object.entries(iopts)) { + if (perItemKeys.has(key)) draft.raw[key] = value; + else { + ctx.diags.push( + diag( + ctx.file, + ctx.sf, + inner.arguments[1] ?? inner, + "TFDSL_MAP_INNER_OPTS", + `Option '${key}' cannot be applied inside map's agent(); put phase-level routing options on map(..., ..., opts).`, + ), + ); + } + } } } } diff --git a/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts b/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts index a506de6..466e8eb 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/parallel.ts @@ -14,6 +14,7 @@ export function emitParallel( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "parallel", raw: { type: "parallel" }, dependsOn: new Set(), @@ -25,6 +26,15 @@ export function emitParallel( for (let bi = 0; bi < arr.elements.length; bi++) { const el = arr.elements[bi]!; if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + if (el.arguments.length < 1 || el.arguments.length > 2) { + ctx.diags.push({ + code: "TFDSL_RUNE_ARITY", + severity: "error", + message: `parallel() branch agent expects 1-2 arguments, got ${el.arguments.length}.`, + file: ctx.file, + }); + continue; + } const erased = eraseStringish( ctx.sf, ctx.file, diff --git a/packages/taskflow-dsl/src/build/erase/kinds/race.ts b/packages/taskflow-dsl/src/build/erase/kinds/race.ts index 8bbefc0..da36d8e 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/race.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/race.ts @@ -14,6 +14,7 @@ export function emitRace( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "race", raw: { type: "race" }, dependsOn: new Set(), @@ -24,6 +25,15 @@ export function emitRace( for (let bi = 0; bi < arr.elements.length; bi++) { const el = arr.elements[bi]!; if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + if (el.arguments.length < 1 || el.arguments.length > 2) { + ctx.diags.push({ + code: "TFDSL_RUNE_ARITY", + severity: "error", + message: `race() branch agent expects 1-2 arguments, got ${el.arguments.length}.`, + file: ctx.file, + }); + continue; + } const erased = eraseStringish( ctx.sf, ctx.file, diff --git a/packages/taskflow-dsl/src/build/erase/kinds/reduce.ts b/packages/taskflow-dsl/src/build/erase/kinds/reduce.ts index bc7e5b5..dd16551 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/reduce.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/reduce.ts @@ -1,8 +1,8 @@ import ts from "typescript"; -import { calleeName } from "../ast.ts"; +import { calleeName, diag } from "../ast.ts"; import { mergeOpts } from "../opts.ts"; import { eraseReduceTask } from "../templates.ts"; -import type { PhaseDraft } from "../types.ts"; +import { phaseByBinding, type PhaseDraft } from "../types.ts"; import { type EmitContext, nextSyntheticId, register } from "../context.ts"; export function emitReduce( @@ -13,6 +13,7 @@ export function emitReduce( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "reduce", raw: { type: "reduce" }, dependsOn: new Set(), @@ -23,11 +24,16 @@ export function emitReduce( const fromIds: string[] = []; if (fromArg && ts.isArrayLiteralExpression(fromArg)) { for (const el of fromArg.elements) { - if (ts.isIdentifier(el) && ctx.phases.has(el.text)) { - fromIds.push(el.text); - draft.dependsOn.add(el.text); + if (ts.isIdentifier(el) && phaseByBinding(ctx.phases, el.text)) { + const pid = phaseByBinding(ctx.phases, el.text)!.id; + fromIds.push(pid); + draft.dependsOn.add(pid); + } else { + ctx.diags.push(diag(ctx.file, ctx.sf, el, "TFDSL_DEP_DYNAMIC", `reduce source must be a previously declared phase handle.`)); } } + } else if (fromArg) { + ctx.diags.push(diag(ctx.file, ctx.sf, fromArg, "TFDSL_DEP_DYNAMIC", `reduce sources must be a static array of phase handles.`)); } draft.raw.from = fromIds; if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) { @@ -38,6 +44,10 @@ export function emitReduce( } } else expr = fnArg.body; if (expr && ts.isCallExpression(expr) && calleeName(expr.expression) === "agent") { + if (expr.arguments.length < 1 || expr.arguments.length > 2) { + ctx.diags.push(diag(ctx.file, ctx.sf, expr, "TFDSL_RUNE_ARITY", `reduce inner agent expects 1-2 arguments, got ${expr.arguments.length}.`)); + return register(ctx, draft); + } if (expr.arguments[0]) { const t2 = eraseReduceTask(ctx.sf, ctx.file, expr.arguments[0]!, fnArg, ctx.phases, ctx.diags); if (t2) { @@ -52,7 +62,24 @@ export function emitReduce( ctx.diags, ctx.phases, ); - if (iopts.agent) draft.raw.agent = iopts.agent; + const reduceAgentKeys = new Set([ + "agent", "model", "thinking", "tools", "cwd", "output", "expect", "retry", "timeout", + "optional", "idempotent", "context", "contextLimit", "cache", "shareContext", + ]); + for (const [key, value] of Object.entries(iopts)) { + if (reduceAgentKeys.has(key)) draft.raw[key] = value; + else { + ctx.diags.push( + diag( + ctx.file, + ctx.sf, + expr.arguments[1] ?? expr, + "TFDSL_REDUCE_INNER_OPTS", + `Option '${key}' cannot be applied inside reduce's agent(); put phase-level routing options on reduce(..., ..., opts).`, + ), + ); + } + } } } const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); diff --git a/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts b/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts index dd5c6a4..67c8267 100644 --- a/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts +++ b/packages/taskflow-dsl/src/build/erase/kinds/tournament.ts @@ -13,12 +13,13 @@ export function emitTournament( const idBase = bindName ?? nextSyntheticId(ctx, "phase"); const draft: PhaseDraft = { id: idBase, + binding: idBase, type: "tournament", raw: { type: "tournament" }, dependsOn: new Set(), }; const optsArg = call.arguments[0] as ts.Expression | undefined; - const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases); + const opts = mergeOpts(ctx.sf, ctx.file, optsArg, ctx.diags, ctx.phases, { allowKeys: new Set(["task", "branches"]) }); Object.assign(draft.raw, opts); if (optsArg && ts.isObjectLiteralExpression(optsArg)) { for (const p of optsArg.properties) { @@ -28,6 +29,15 @@ export function emitTournament( for (let bi = 0; bi < p.initializer.elements.length; bi++) { const el = p.initializer.elements[bi]!; if (ts.isCallExpression(el) && calleeName(el.expression) === "agent") { + if (el.arguments.length < 1 || el.arguments.length > 2) { + ctx.diags.push({ + code: "TFDSL_RUNE_ARITY", + severity: "error", + message: `tournament branch agent expects 1-2 arguments, got ${el.arguments.length}.`, + file: ctx.file, + }); + continue; + } const erased = eraseStringish( ctx.sf, ctx.file, diff --git a/packages/taskflow-dsl/src/build/erase/opts.ts b/packages/taskflow-dsl/src/build/erase/opts.ts index 6f9791d..7914a88 100644 --- a/packages/taskflow-dsl/src/build/erase/opts.ts +++ b/packages/taskflow-dsl/src/build/erase/opts.ts @@ -5,7 +5,7 @@ import ts from "typescript"; import type { Diagnostic } from "../../diagnostics.ts"; import { calleeName, diag, evalLiteral } from "./ast.ts"; -import type { PhaseDraft } from "./types.ts"; +import { phaseByBinding, type PhaseDraft } from "./types.ts"; /** Extra option keys allowed without TFDSL_RUNE_OPTS_UNKNOWN (sugar / kind-specific). */ export type MergeOptsExtra = { @@ -27,24 +27,68 @@ export function mergeOpts( } const out: Record = {}; for (const p of obj.properties) { - if (!ts.isPropertyAssignment(p)) continue; + if (!ts.isPropertyAssignment(p)) { + diags.push( + diag( + file, + sf, + p, + "TFDSL_RUNE_OPTS_DYNAMIC", + `Phase options must use explicit static property assignments (no shorthand, spread, methods, or accessors).`, + ), + ); + continue; + } const key = ts.isIdentifier(p.name) ? p.name.text : ts.isStringLiteral(p.name) ? p.name.text : undefined; - if (!key) continue; + if (!key) { + diags.push(diag(file, sf, p, "TFDSL_RUNE_OPTS_DYNAMIC", `Phase option names must be static identifiers or strings.`)); + continue; + } + const staticValue = (): unknown => { + const value = evalLiteral(p.initializer); + if (value === undefined) { + diags.push( + diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS_DYNAMIC", `Option '${key}' must be a static JSON literal.`), + ); + } + return value; + }; if (extra?.allowKeys?.has(key)) { - const v = evalLiteral(p.initializer); + // Kind handlers erase task lambdas/templates and branch agent() arrays + // with phase-aware logic after mergeOpts; do not misclassify them as + // dynamic JSON literals here. + if ( + key === "task" && + (ts.isArrowFunction(p.initializer) || + ts.isFunctionExpression(p.initializer) || + ts.isTemplateExpression(p.initializer)) + ) { + continue; + } + if (key === "branches" && ts.isArrayLiteralExpression(p.initializer)) continue; + const v = staticValue(); if (v !== undefined) out[key] = v; continue; } - if (key === "dependsOn" && ts.isArrayLiteralExpression(p.initializer)) { + if (key === "dependsOn") { + if (!ts.isArrayLiteralExpression(p.initializer)) { + diags.push(diag(file, sf, p.initializer, "TFDSL_DEP_DYNAMIC", `dependsOn must be an array of phase handles or string ids.`)); + continue; + } const ids: string[] = []; for (const el of p.initializer.elements) { if (ts.isStringLiteral(el)) ids.push(el.text); - else if (ts.isIdentifier(el) && phases.has(el.text)) ids.push(el.text); + else if (ts.isIdentifier(el) && phaseByBinding(phases, el.text)) ids.push(phaseByBinding(phases, el.text)!.id); + else { + diags.push( + diag(file, sf, el, "TFDSL_DEP_DYNAMIC", `dependsOn entry must be a previously declared phase handle or string id.`), + ); + } } out.dependsOn = ids; continue; @@ -54,12 +98,23 @@ export function mergeOpts( if (ts.isCallExpression(p.initializer)) { const cn = calleeName(p.initializer.expression); if (cn === "json") { + if (p.initializer.arguments.length !== 0) { + diags.push(diag(file, sf, p.initializer, "TFDSL_RUNE_ARITY", `json() does not accept runtime arguments.`)); + continue; + } out.output = "json"; - out.expect = { type: "object" }; + const typeArg = p.initializer.typeArguments?.[0]; + if (!typeArg) { + out.expect = { type: "object" }; + } else { + const expect = expectFromTypeNode(typeArg); + if (expect.ok) out.expect = expect.schema; + else diags.push(diag(file, sf, typeArg, "TFDSL_JSON_TYPE_UNSUPPORTED", expect.message)); + } continue; } } - const v = evalLiteral(p.initializer); + const v = staticValue(); if (v === "json" || v === "text") { out.output = v; if (v === "json" && out.expect === undefined) out.expect = { type: "object" }; @@ -71,29 +126,37 @@ export function mergeOpts( continue; } - if (key === "agent" || key === "model" || key === "when" || key === "join" || key === "cwd") { - const v = evalLiteral(p.initializer); - if (v !== undefined) out[key] = v; + if (key === "agent" || key === "model" || key === "thinking" || key === "when" || key === "join" || key === "cwd") { + const v = staticValue(); + if (typeof v === "string") out[key] = v; + else if (v !== undefined) diags.push(diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS", `Option '${key}' must be a string.`)); continue; } if (key === "final" || key === "optional" || key === "idempotent" || key === "reflexion" || key === "convergence") { - const v = evalLiteral(p.initializer); + const v = staticValue(); if (typeof v === "boolean") out[key] = v; + else if (v !== undefined) diags.push(diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS", `Option '${key}' must be a boolean.`)); continue; } if (key === "timeout" || key === "concurrency" || key === "maxIterations" || key === "variants") { - const v = evalLiteral(p.initializer); + const v = staticValue(); if (typeof v === "number") out[key] = v; + else if (v !== undefined) diags.push(diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS", `Option '${key}' must be a number.`)); continue; } - if (key === "retry" || key === "expect" || key === "tools" || key === "thinking") { - const v = evalLiteral(p.initializer); + if ( + key === "retry" || key === "expect" || key === "tools" || + key === "context" || key === "contextLimit" || key === "onBlock" || key === "eval" || + key === "score" || key === "cache" || key === "shareContext" + ) { + const v = staticValue(); if (v !== undefined) out[key] = v; continue; } if (key === "id") { - const v = evalLiteral(p.initializer); + const v = staticValue(); if (typeof v === "string") out.id = v; + else if (v !== undefined) diags.push(diag(file, sf, p.initializer, "TFDSL_RUNE_OPTS", `Option 'id' must be a string.`)); continue; } if ( @@ -109,7 +172,7 @@ export function mergeOpts( key === "expandMode" || key === "maxNodes" ) { - const v = evalLiteral(p.initializer); + const v = staticValue(); if (v !== undefined) out[key] = v; continue; } @@ -119,14 +182,67 @@ export function mergeOpts( sf, p, "TFDSL_RUNE_OPTS_UNKNOWN", - `Unknown or non-static option '${key}' ignored in MVP erase.`, - "warning", + `Unknown or non-static option '${key}' cannot be erased safely.`, ), ); } return out; } +type TypeSchemaResult = + | { ok: true; schema: Record } + | { ok: false; message: string }; + +/** Infer the small runtime `expect` contract from syntax only. Named/conditional types fail closed. */ +function expectFromTypeNode(node: ts.TypeNode): TypeSchemaResult { + switch (node.kind) { + case ts.SyntaxKind.StringKeyword: + return { ok: true, schema: { type: "string" } }; + case ts.SyntaxKind.NumberKeyword: + return { ok: true, schema: { type: "number" } }; + case ts.SyntaxKind.BooleanKeyword: + return { ok: true, schema: { type: "boolean" } }; + } + if (ts.isLiteralTypeNode(node)) { + const value = evalLiteral(node.literal as ts.Expression); + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null) { + return { ok: true, schema: { enum: [value] } }; + } + } + if (ts.isArrayTypeNode(node)) { + const item = expectFromTypeNode(node.elementType); + return item.ok ? { ok: true, schema: { type: "array", items: item.schema } } : item; + } + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === "Array" && node.typeArguments?.length === 1) { + const item = expectFromTypeNode(node.typeArguments[0]!); + return item.ok ? { ok: true, schema: { type: "array", items: item.schema } } : item; + } + if (ts.isTypeLiteralNode(node)) { + const properties: Record = {}; + const required: string[] = []; + for (const member of node.members) { + if (!ts.isPropertySignature(member) || !member.type || !member.name) { + return { ok: false, message: `json() only supports property signatures in object types.` }; + } + const name = ts.isIdentifier(member.name) || ts.isStringLiteral(member.name) + ? member.name.text + : undefined; + if (!name) return { ok: false, message: `json() requires static object property names.` }; + const prop = expectFromTypeNode(member.type); + if (!prop.ok) return prop; + properties[name] = prop.schema; + if (!member.questionToken) required.push(name); + } + const schema: Record = { type: "object", properties }; + if (required.length) schema.required = required; + return { ok: true, schema }; + } + return { + ok: false, + message: `json() cannot safely infer '${node.getText()}'; use a literal expect contract instead.`, + }; +} + export function phaseIdFromBinding(name: string, opts: Record): string { if (typeof opts.id === "string" && opts.id) return opts.id; return name; diff --git a/packages/taskflow-dsl/src/build/erase/pipeline.ts b/packages/taskflow-dsl/src/build/erase/pipeline.ts index 68c5344..65fd3ec 100644 --- a/packages/taskflow-dsl/src/build/erase/pipeline.ts +++ b/packages/taskflow-dsl/src/build/erase/pipeline.ts @@ -7,27 +7,92 @@ import ts from "typescript"; import type { Diagnostic } from "../../diagnostics.ts"; import { calleeName, diag, evalLiteral } from "./ast.ts"; -import type { EraseResult, PhaseDraft } from "./types.ts"; +import { phaseByBinding, type EraseResult, type PhaseDraft } from "./types.ts"; import { PHASE_RUNES } from "./types.ts"; import type { EmitContext } from "./context.ts"; import { trySpecializedEmit } from "./kinds/index.ts"; +const RUNE_ARITY: Readonly> = { + agent: [1, 2], + parallel: [1, 2], + map: [2, 3], + gate: [1, 3], + "gate.automated": [2, 2], + "gate.scored": [2, 2], + reduce: [2, 3], + approval: [1, 1], + subflow: [1, 3], + "subflow.def": [1, 2], + loop: [1, 1], + tournament: [1, 1], + script: [1, 2], + expand: [1, 2], + "expand.nested": [1, 2], + "expand.graft": [1, 2], + race: [1, 2], +}; + export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResult { const diags: Diagnostic[] = []; const sf = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const parseDiagnostics = (sf as ts.SourceFile & { parseDiagnostics?: readonly ts.Diagnostic[] }).parseDiagnostics ?? []; + for (const d of parseDiagnostics) { + const start = d.start ?? 0; + const pos = sf.getLineAndCharacterOfPosition(start); + diags.push({ + code: `TS${d.code}`, + severity: "error", + message: ts.flattenDiagnosticMessageText(d.messageText, "\n"), + file, + range: { line: pos.line + 1, character: pos.character + 1 }, + }); + } + if (parseDiagnostics.length > 0) return { ok: false, diagnostics: diags }; + + let taskflowImport = false; + const importedRunes = new Set(); + for (const stmt of sf.statements) { + if (!ts.isImportDeclaration(stmt)) continue; + if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue; + if (stmt.moduleSpecifier.text === "taskflow-dsl") { + taskflowImport = true; + if (!stmt.importClause?.namedBindings || !ts.isNamedImports(stmt.importClause.namedBindings)) { + diags.push(diag(file, sf, stmt, "TFDSL_IMPORT_SHAPE", `taskflow-dsl must use named imports.`)); + } else { + for (const spec of stmt.importClause.namedBindings.elements) { + if (spec.propertyName) { + diags.push(diag(file, sf, spec, "TFDSL_IMPORT_ALIAS", `Aliased rune imports are not supported; import '${spec.propertyName.text}' directly.`)); + } else importedRunes.add(spec.name.text); + } + } + } + } + if (!taskflowImport) { + diags.push({ + code: "TFDSL_IMPORT_MISSING", + severity: "error", + message: `A named import from "taskflow-dsl" is required.`, + file, + }); + } // Find export default flow(...) let flowCall: ts.CallExpression | undefined; + let defaultExports = 0; for (const stmt of sf.statements) { if (!ts.isExportAssignment(stmt) || stmt.isExportEquals) continue; + defaultExports++; if (ts.isCallExpression(stmt.expression)) { const cn = calleeName(stmt.expression.expression); - if (cn === "flow") { + if (!flowCall && cn === "flow" && importedRunes.has("flow")) { flowCall = stmt.expression; - break; } } } + if (defaultExports > 1) { + diags.push({ code: "TFDSL_ENTRY_MULTIPLE", severity: "error", message: `Exactly one default flow export is allowed.`, file }); + return { ok: false, diagnostics: diags }; + } if (!flowCall) { diags.push({ code: "TFDSL_ENTRY_MISSING", @@ -38,10 +103,30 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul }); return { ok: false, diagnostics: diags }; } + const missingRuneImports = new Set(); + const checkRuneImports = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const name = calleeName(node.expression); + const root = name?.split(".")[0]; + if (root && (PHASE_RUNES.has(root) || root === "json") && !importedRunes.has(root)) { + missingRuneImports.add(root); + } + } + ts.forEachChild(node, checkRuneImports); + }; + checkRuneImports(flowCall); + for (const rune of missingRuneImports) { + diags.push({ + code: "TFDSL_IMPORT_SYMBOL", + severity: "error", + message: `Rune '${rune}' must be imported from "taskflow-dsl".`, + file, + }); + } const args = flowCall.arguments; - if (args.length < 2) { - diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_ARGS", `flow() requires name and callback.`)); + if (args.length < 2 || args.length > 3) { + diags.push(diag(file, sf, flowCall, "TFDSL_ENTRY_ARGS", `flow() requires (name, callback) or (name, options, callback).`)); return { ok: false, diagnostics: diags }; } @@ -61,7 +146,28 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul const a1 = args[1]!; const a2 = args[2]!; if (ts.isObjectLiteralExpression(a1)) { - flowOpts = (evalLiteral(a1) as Record) ?? {}; + const evaluated = evalLiteral(a1); + if (evaluated && typeof evaluated === "object" && !Array.isArray(evaluated)) { + flowOpts = evaluated as Record; + } else { + diags.push(diag(file, sf, a1, "TFDSL_FLOW_OPTS_DYNAMIC", `Flow options must be a static JSON object without shorthand or spread properties.`)); + } + const allowedFlowOpts = new Set(["description", "version", "agentScope", "strictInterpolation", "contextSharing", "incremental"]); + for (const [key, value] of Object.entries(flowOpts)) { + if (!allowedFlowOpts.has(key)) { + diags.push(diag(file, sf, a1, "TFDSL_FLOW_OPTS_UNKNOWN", `Unknown flow option '${key}'.`)); + continue; + } + const valid = + (key === "description" && typeof value === "string") || + (key === "version" && typeof value === "number") || + (key === "agentScope" && (value === "user" || value === "project" || value === "both")) || + ((key === "strictInterpolation" || key === "contextSharing" || key === "incremental") && + typeof value === "boolean"); + if (!valid) diags.push(diag(file, sf, a1, "TFDSL_FLOW_OPTS_TYPE", `Flow option '${key}' has an invalid static value.`)); + } + } else { + diags.push(diag(file, sf, a1, "TFDSL_FLOW_OPTS_DYNAMIC", `Flow options must be a static object literal.`)); } if (ts.isArrowFunction(a2) || ts.isFunctionExpression(a2)) bodyFn = a2; } @@ -78,6 +184,9 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul let finalId: string | undefined; const body = bodyFn.body; + const ctxParamName = bodyFn.parameters[0] && ts.isIdentifier(bodyFn.parameters[0].name) + ? bodyFn.parameters[0].name.text + : undefined; const statements: ts.Statement[] = ts.isBlock(body) ? [...body.statements] : [ts.factory.createReturnStatement(body as ts.Expression)]; @@ -90,7 +199,23 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul itemParam?: string, ): string | undefined => { const cn = calleeName(call.expression); - if (!cn) return undefined; + if (!cn) { + diags.push(diag(file, sf, call, "TFDSL_RUNE_UNKNOWN", `Unsupported call expression cannot erase to a declarative phase.`)); + return undefined; + } + const arity = RUNE_ARITY[cn]; + if (arity && (call.arguments.length < arity[0] || call.arguments.length > arity[1])) { + diags.push( + diag( + file, + sf, + call, + "TFDSL_RUNE_ARITY", + `${cn}() expects ${arity[0] === arity[1] ? arity[0] : `${arity[0]}-${arity[1]}`} argument(s), got ${call.arguments.length}.`, + ), + ); + return undefined; + } const specialized = trySpecializedEmit(emitCtx, cn, bindName, call, itemParam); if (specialized !== "continue") return specialized; @@ -127,21 +252,26 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul const obj = call.expression.expression; const method = call.expression.name.text; // ctx.budget / ctx.concurrency - if (ts.isIdentifier(obj) && (method === "budget" || method === "concurrency")) { + if (ts.isIdentifier(obj) && obj.text === ctxParamName && (method === "budget" || method === "concurrency")) { const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; - if (method === "budget" && v && typeof v === "object") budget = v as Record; - if (method === "concurrency" && typeof v === "number") concurrency = v; + if (method === "budget") { + if (v && typeof v === "object" && !Array.isArray(v)) budget = v as Record; + else diags.push(diag(file, sf, call, "TFDSL_CTX_DYNAMIC", `ctx.budget() requires a static object literal.`)); + } else if (typeof v === "number") concurrency = v; + else diags.push(diag(file, sf, call, "TFDSL_CTX_DYNAMIC", `ctx.concurrency() requires a static number.`)); continue; } // ctx.args.declare if ( ts.isPropertyAccessExpression(obj) && ts.isIdentifier(obj.expression) && + obj.expression.text === ctxParamName && obj.name.text === "args" && method === "declare" ) { const v = call.arguments[0] ? evalLiteral(call.arguments[0]) : undefined; if (v && typeof v === "object") topArgs = v as Record; + else diags.push(diag(file, sf, call, "TFDSL_CTX_DYNAMIC", `ctx.args.declare() requires a static object literal.`)); continue; } } @@ -152,17 +282,37 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul /* anonymous phase */ } } + continue; + } + if (ts.isExpressionStatement(st)) { + diags.push(diag(file, sf, st, "TFDSL_BODY_UNSUPPORTED", `Unsupported expression in flow body; only rune and ctx.* calls are allowed.`)); + continue; } if (ts.isVariableStatement(st)) { for (const decl of st.declarationList.declarations) { - if (!decl.initializer) continue; + if (!decl.initializer) { + diags.push(diag(file, sf, decl, "TFDSL_BODY_UNSUPPORTED", `Flow-body declarations must bind a rune call.`)); + continue; + } // const [a,b] = parallel([agent(...), agent(...)]) // Desugar to independent agent phases with true ids (a, b) so // {steps.a.output} works. Concurrent because no dependsOn between them. if (ts.isArrayBindingPattern(decl.name) && ts.isCallExpression(decl.initializer)) { const cn = calleeName(decl.initializer.expression); if (cn === "parallel") { + if (decl.initializer.arguments[1]) { + diags.push( + diag( + file, + sf, + decl.initializer.arguments[1]!, + "TFDSL_PARALLEL_DESTRUCTURE_OPTS", + `parallel() options cannot be preserved when destructuring to independent phase handles; bind the parallel phase as one value or remove the options.`, + ), + ); + continue; + } const bindNames = decl.name.elements .map((e) => ts.isBindingElement(e) && ts.isIdentifier(e.name) ? e.name.text : undefined, @@ -216,7 +366,10 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul continue; } } - if (!ts.isIdentifier(decl.name)) continue; + if (!ts.isIdentifier(decl.name)) { + diags.push(diag(file, sf, decl.name, "TFDSL_BODY_UNSUPPORTED", `Only identifier bindings or supported parallel destructuring are allowed.`)); + continue; + } const name = decl.name.text; if (ts.isCallExpression(decl.initializer)) { handleCall(name, decl.initializer); @@ -225,13 +378,18 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul ts.isCallExpression(decl.initializer.expression) ) { handleCall(name, decl.initializer.expression); + } else { + diags.push(diag(file, sf, decl.initializer, "TFDSL_BODY_UNSUPPORTED", `Flow-body declarations must bind a rune call.`)); } } + continue; } - if (ts.isReturnStatement(st) && st.expression) { - if (ts.isIdentifier(st.expression) && phases.has(st.expression.text)) { - finalId = st.expression.text; + if (ts.isReturnStatement(st)) { + if (!st.expression) { + diags.push(diag(file, sf, st, "TFDSL_RETURN_UNSUPPORTED", `Flow return must be a phase handle or rune call.`)); + } else if (ts.isIdentifier(st.expression) && phaseByBinding(phases, st.expression.text)) { + finalId = phaseByBinding(phases, st.expression.text)!.id; const ph = phases.get(finalId)!; ph.final = true; ph.raw.final = true; @@ -243,8 +401,13 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul ph.final = true; ph.raw.final = true; } + } else { + diags.push(diag(file, sf, st.expression, "TFDSL_RETURN_UNSUPPORTED", `Flow return must reference a previously declared phase or be a rune call.`)); } + continue; } + + diags.push(diag(file, sf, st, "TFDSL_BODY_UNSUPPORTED", `Unsupported control flow or statement in declarative flow body.`)); } // Warn phases with no deps (not first) @@ -291,6 +454,10 @@ export function eraseSource(sourceText: string, file = "flow.tf.ts"): EraseResul }; if (typeof flowOpts.description === "string") taskflow.description = flowOpts.description; if (typeof flowOpts.version === "number") taskflow.version = flowOpts.version; + if (flowOpts.agentScope === "user" || flowOpts.agentScope === "project" || flowOpts.agentScope === "both") taskflow.agentScope = flowOpts.agentScope; + if (typeof flowOpts.strictInterpolation === "boolean") taskflow.strictInterpolation = flowOpts.strictInterpolation; + if (typeof flowOpts.contextSharing === "boolean") taskflow.contextSharing = flowOpts.contextSharing; + if (typeof flowOpts.incremental === "boolean") taskflow.incremental = flowOpts.incremental; if (topArgs) taskflow.args = topArgs; if (concurrency !== undefined) taskflow.concurrency = concurrency; if (budget) taskflow.budget = budget; diff --git a/packages/taskflow-dsl/src/build/erase/templates.ts b/packages/taskflow-dsl/src/build/erase/templates.ts index 943d35d..7d0c7fb 100644 --- a/packages/taskflow-dsl/src/build/erase/templates.ts +++ b/packages/taskflow-dsl/src/build/erase/templates.ts @@ -5,7 +5,7 @@ import ts from "typescript"; import type { Diagnostic } from "../../diagnostics.ts"; import { diag } from "./ast.ts"; -import type { PhaseDraft } from "./types.ts"; +import { phaseByBinding, type PhaseDraft } from "./types.ts"; export function eraseStringish( sf: ts.SourceFile, @@ -18,7 +18,8 @@ export function eraseStringish( const deps: string[] = []; const pushDep = (id: string) => { - if (phases.has(id) && !deps.includes(id)) deps.push(id); + const phaseId = phaseByBinding(phases, id)?.id; + if (phaseId && !deps.includes(phaseId)) deps.push(phaseId); }; const propToPlaceholder = (expr: ts.Expression): string | undefined => { @@ -38,12 +39,13 @@ export function eraseStringish( chain.unshift(cur.name.text); cur = cur.expression; } - if (ts.isIdentifier(cur) && phases.has(cur.text)) { - pushDep(cur.text); - if (chain[0] === "output" && chain.length === 1) return `{steps.${cur.text}.output}`; + if (ts.isIdentifier(cur) && phaseByBinding(phases, cur.text)) { + const phaseId = phaseByBinding(phases, cur.text)!.id; + pushDep(phaseId); + if (chain[0] === "output" && chain.length === 1) return `{steps.${phaseId}.output}`; if (chain[0] === "json") { - if (chain.length === 1) return `{steps.${cur.text}.json}`; - return `{steps.${cur.text}.json.${chain.slice(1).join(".")}}`; + if (chain.length === 1) return `{steps.${phaseId}.json}`; + return `{steps.${phaseId}.json.${chain.slice(1).join(".")}}`; } } } @@ -67,9 +69,10 @@ export function eraseStringish( const ph = propToPlaceholder(span.expression); if (ph) { text += ph; - } else if (ts.isIdentifier(span.expression) && phases.has(span.expression.text)) { - pushDep(span.expression.text); - text += `{steps.${span.expression.text}.output}`; + } else if (ts.isIdentifier(span.expression) && phaseByBinding(phases, span.expression.text)) { + const phaseId = phaseByBinding(phases, span.expression.text)!.id; + pushDep(phaseId); + text += `{steps.${phaseId}.output}`; } else { diags.push( diag( @@ -197,8 +200,12 @@ export function eraseReduceTask( ts.isIdentifier(e.expression.expression) && e.expression.expression.text === partsName ) { - const phaseId = e.expression.name.text; - if (phases.has(phaseId)) deps.push(phaseId); + const phaseId = phaseByBinding(phases, e.expression.name.text)?.id; + if (!phaseId) { + diags.push(diag(file, sf, e, "TFDSL_BINDING_UNKNOWN", `Unknown phase binding '${e.expression.name.text}'.`)); + return undefined; + } + deps.push(phaseId); if (e.name.text === "output") text += `{steps.${phaseId}.output}`; else if (e.name.text === "json") text += `{steps.${phaseId}.json}`; else text += `{steps.${phaseId}.output}`; diff --git a/packages/taskflow-dsl/src/build/erase/types.ts b/packages/taskflow-dsl/src/build/erase/types.ts index fe328fa..7f57cea 100644 --- a/packages/taskflow-dsl/src/build/erase/types.ts +++ b/packages/taskflow-dsl/src/build/erase/types.ts @@ -13,12 +13,24 @@ export interface EraseResult { export interface PhaseDraft { id: string; + /** Source-level variable binding. May differ from the emitted phase id. */ + binding?: string; type: string; raw: Record; dependsOn: Set; final?: boolean; } +const BINDING_PREFIX = "\u0000binding:"; + +export function setPhaseBinding(phases: Map, binding: string, draft: PhaseDraft): void { + phases.set(`${BINDING_PREFIX}${binding}`, draft); +} + +export function phaseByBinding(phases: Map, binding: string): PhaseDraft | undefined { + return phases.get(`${BINDING_PREFIX}${binding}`) ?? phases.get(binding); +} + /** Mutable session state for one eraseSource() call. */ export interface EraseSession { file: string; diff --git a/packages/taskflow-dsl/src/check.ts b/packages/taskflow-dsl/src/check.ts index 48788a4..7b73e4d 100644 --- a/packages/taskflow-dsl/src/check.ts +++ b/packages/taskflow-dsl/src/check.ts @@ -13,7 +13,7 @@ import { typecheckFile } from "./typecheck.ts"; export interface CheckOptions { /** Skip Taskflow validate (rune/static only). Default false. */ noValidate?: boolean; - /** Run full tsc Program diagnostics on the file (default false). */ + /** Run full tsc Program diagnostics on .tf.ts files (default true). */ typecheck?: boolean; /** cwd for tsconfig discovery when typecheck is on. */ cwd?: string; @@ -52,7 +52,8 @@ export function checkFile(filePath: string, opts: CheckOptions = {}): CheckResul } const r = buildFile(abs, { validate: !opts.noValidate, irHash: false, emit: "taskflow" }); const diagnostics = [...r.diagnostics]; - if (opts.typecheck) { + const isTypeScriptDsl = abs.endsWith(".tf.ts") || path.extname(abs).toLowerCase() === ".ts"; + if (isTypeScriptDsl && opts.typecheck !== false) { diagnostics.push(...typecheckFile(abs, opts.cwd ?? path.dirname(abs))); } return { ok: !hasErrors(diagnostics), diagnostics, file: r.file }; diff --git a/packages/taskflow-dsl/src/cli.ts b/packages/taskflow-dsl/src/cli.ts index 4120a17..87e1521 100644 --- a/packages/taskflow-dsl/src/cli.ts +++ b/packages/taskflow-dsl/src/cli.ts @@ -12,7 +12,7 @@ import { decompileTaskflow } from "./decompile.ts"; import { formatDiagnostics, hasErrors } from "./diagnostics.ts"; import { skeletonHello, skeletonJson } from "./new-skeleton.ts"; import { resolveContainedOut, resolveInput } from "./paths.ts"; -import { desugar, validateTaskflow, type Taskflow } from "taskflow-core"; +import { desugar, parseJsonc, validateTaskflow, type Taskflow } from "taskflow-core"; const require = createRequire(import.meta.url); const PKG_VERSION: string = (() => { @@ -36,7 +36,7 @@ Options: --cwd Project root for path resolution (default: process.cwd()) -o, --out Output path (must stay under --cwd) --emit taskflow|flowir|both (build, default: taskflow) - --typecheck (check) also run tsc Program diagnostics + --no-typecheck (check) skip tsc Program diagnostics --json Machine-readable diagnostics --force Overwrite existing file (new) --json-escape new: emit JSON skeleton instead of .tf.ts @@ -55,10 +55,11 @@ function parseArgs(argv: string[]) { else if (a === "-V" || a === "--version") flags.version = true; else if (a === "--json") flags.json = true; else if (a === "-o" || a === "--out") flags.out = args[++i] ?? ""; - else if (a === "--emit") flags.emit = args[++i] ?? "taskflow"; + else if (a === "--emit") flags.emit = args[++i] ?? ""; else if (a === "--cwd") flags.cwd = args[++i] ?? ""; else if (a === "--force") flags.force = true; else if (a === "--typecheck") flags.typecheck = true; + else if (a === "--no-typecheck") flags.noTypecheck = true; else if (a === "--json-escape") flags.jsonEscape = true; else if (a.startsWith("-")) flags[a] = true; else positional.push(a); @@ -68,6 +69,8 @@ function parseArgs(argv: string[]) { function main(): void { const { flags, positional } = parseArgs(process.argv); + const unknownFlags = Object.keys(flags).filter((key) => key.startsWith("-")); + if (unknownFlags.length) failUsage(`unknown option: ${unknownFlags[0]}`); if (flags.version) { process.stdout.write(`${PKG_VERSION}\n`); process.exit(0); @@ -78,6 +81,21 @@ function main(): void { } const cwd = typeof flags.cwd === "string" && flags.cwd ? path.resolve(flags.cwd) : process.cwd(); const cmd = positional[0]!; + const allowedByCommand: Record> = { + build: new Set(["cwd", "out", "emit", "json"]), + check: new Set(["cwd", "typecheck", "noTypecheck", "json"]), + decompile: new Set(["cwd", "out"]), + new: new Set(["cwd", "out", "force", "jsonEscape"]), + }; + const allowed = allowedByCommand[cmd]; + if (allowed) { + for (const key of Object.keys(flags)) { + if (key === "help" || key === "version") continue; + if (!allowed.has(key)) failUsage(`option --${key} is not valid for ${cmd}`); + } + const maxPositional = cmd === "new" ? 2 : 2; + if (positional.length > maxPositional) failUsage(`${cmd} received unexpected positional arguments`); + } try { if (cmd === "build") { @@ -87,7 +105,15 @@ function main(): void { process.exit(2); } const input = resolveInput(cwd, file); - const emit = String(flags.emit ?? "taskflow") as "taskflow" | "flowir" | "both"; + const emitValue = String(flags.emit ?? "taskflow"); + if (emitValue !== "taskflow" && emitValue !== "flowir" && emitValue !== "both") { + failUsage(`--emit must be taskflow, flowir, or both`); + } + const emit = emitValue; + if (emit === "both" && typeof flags.out === "string" && flags.out) { + failUsage(`--out is ambiguous with --emit both`); + } + if (flags.json && typeof flags.out === "string" && flags.out) failUsage(`--out cannot be used with --json`); const r = buildFile(input, { emit, irHash: true, validate: true }); if (flags.json) { process.stdout.write( @@ -97,6 +123,7 @@ function main(): void { diagnostics: r.diagnostics, irHash: r.irHash, taskflow: r.taskflow, + flowir: r.flowir, }, null, 2, @@ -120,7 +147,12 @@ function main(): void { process.stdout.write(`wrote ${outPath}\n`); } if ((emit === "flowir" || emit === "both") && r.flowir) { - const p = `${stem}.flowir.json`; + let p = `${stem}.flowir.json`; + if (typeof flags.out === "string" && flags.out && emit === "flowir") { + const c = resolveContainedOut(cwd, flags.out); + if (!c.ok) failUsage(c.message); + p = c.path; + } fs.writeFileSync(p, JSON.stringify({ hash: r.irHash, ir: r.flowir }, null, 2) + "\n"); process.stdout.write(`wrote ${p} (${r.irHash})\n`); } @@ -140,8 +172,9 @@ function main(): void { process.stderr.write("check requires a file path\n"); process.exit(2); } + if (flags.typecheck && flags.noTypecheck) failUsage(`--typecheck and --no-typecheck are mutually exclusive`); const r = checkFile(resolveInput(cwd, file), { - typecheck: flags.typecheck === true, + typecheck: flags.noTypecheck !== true, cwd, }); if (flags.json) { @@ -160,7 +193,8 @@ function main(): void { process.exit(2); } const input = resolveInput(cwd, file); - const raw = JSON.parse(fs.readFileSync(input, "utf8")); + const inputText = fs.readFileSync(input, "utf8"); + const raw = input.toLowerCase().endsWith(".jsonc") ? parseJsonc(inputText) : JSON.parse(inputText); const asRec = raw as Record; let def: Taskflow; if (Array.isArray(asRec.phases)) { @@ -246,9 +280,14 @@ function main(): void { process.stderr.write(`unknown command: ${cmd}\n${usage()}`); process.exit(2); } catch (e) { - process.stderr.write((e instanceof Error ? e.stack ?? e.message : String(e)) + "\n"); + process.stderr.write((e instanceof Error ? e.message : String(e)) + "\n"); process.exit(2); } } +function failUsage(message: string): never { + process.stderr.write(`${message}\n`); + process.exit(2); +} + main(); diff --git a/packages/taskflow-dsl/src/decompile.ts b/packages/taskflow-dsl/src/decompile.ts index 6c9d3a0..33e48d8 100644 --- a/packages/taskflow-dsl/src/decompile.ts +++ b/packages/taskflow-dsl/src/decompile.ts @@ -9,18 +9,33 @@ function esc(s: string): string { return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); } -function phaseBinding(id: string): string { - // valid-ish JS identifier — never inject raw phase id into free-text template slots - const b = id.replace(/[^A-Za-z0-9_$]/g, "_"); - const safe = /^[0-9]/.test(b) ? `p_${b}` : b || "phase"; - if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(safe)) { - throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: phase id ${JSON.stringify(id)} is not a safe identifier`); +const RESERVED = new Set([ + "break", "case", "catch", "class", "const", "continue", "debugger", "default", "delete", + "do", "else", "export", "extends", "false", "finally", "for", "function", "if", "import", + "in", "instanceof", "new", "null", "return", "super", "switch", "this", "throw", "true", + "try", "typeof", "var", "void", "while", "with", "yield", "let", "static", "await", "ctx", + "flow", "agent", "map", "parallel", "gate", "reduce", "approval", "subflow", "loop", + "tournament", "script", "race", "expand", +]); + +function allocateBindings(phases: readonly Phase[]): Map { + const out = new Map(); + const used = new Set(RESERVED); + for (const p of phases) { + let base = p.id.replace(/[^A-Za-z0-9_$]/g, "_") || "phase"; + if (/^[0-9]/.test(base)) base = `p_${base}`; + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(base) || RESERVED.has(base)) base = `p_${base}`; + let candidate = base; + let suffix = 2; + while (used.has(candidate)) candidate = `${base}_${suffix++}`; + used.add(candidate); + out.set(p.id, candidate); } - return safe; + return out; } function safeIdent(name: string, role: string): string { - if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) { + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || RESERVED.has(name)) { throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: ${role} ${JSON.stringify(name)} is not a safe identifier`); } return name; @@ -52,22 +67,12 @@ export function decompileTaskflow(def: Taskflow): string { } const lines: string[] = []; - const imports = new Set([ - "flow", - "agent", - "map", - "parallel", - "gate", - "reduce", - "approval", - "subflow", - "loop", - "tournament", - "script", - ]); + const imports = new Set(["flow"]); for (const p of def.phases ?? []) { - if (p.type === "race") imports.add("race"); - if (p.type === "expand") imports.add("expand"); + const kind = p.type ?? "agent"; + if (kind === "flow") imports.add("subflow"); + else imports.add(kind); + if (["map", "parallel", "gate", "reduce", "race"].includes(kind)) imports.add("agent"); } // Stable import order for golden/tests const order = [ @@ -88,8 +93,13 @@ export function decompileTaskflow(def: Taskflow): string { const importList = order.filter((n) => imports.has(n)); lines.push(`import { ${importList.join(", ")} } from "taskflow-dsl";`); lines.push(``); - const desc = def.description ? `, { description: ${JSON.stringify(def.description)} }` : ""; - lines.push(`export default flow(${JSON.stringify(def.name)}${desc}, (ctx) => {`); + const flowOpts: Record = {}; + for (const key of ["description", "version", "agentScope", "strictInterpolation", "contextSharing", "incremental"] as const) { + const value = (def as unknown as Record)[key]; + if (value !== undefined) flowOpts[key] = value; + } + const flowOptText = Object.keys(flowOpts).length ? `, ${JSON.stringify(flowOpts)}` : ""; + lines.push(`export default flow(${JSON.stringify(def.name)}${flowOptText}, (ctx) => {`); if (def.budget) { lines.push(` ctx.budget(${JSON.stringify(def.budget)});`); @@ -102,34 +112,42 @@ export function decompileTaskflow(def: Taskflow): string { } const byId = new Map((def.phases ?? []).map((p) => [p.id, p])); - let lastBind: string | undefined; + const bindings = allocateBindings(def.phases ?? []); + let returnBind: string | undefined; for (const p of def.phases ?? []) { - const bind = phaseBinding(p.id); - lastBind = bind; - const line = decompilePhase(p, bind, byId); + const bind = bindings.get(p.id)!; + if (p.final) returnBind = bind; + const line = decompilePhase(p, bind, byId, bindings); lines.push(` ${line}`); } - if (lastBind) { - lines.push(` return ${lastBind};`); + if (!returnBind && def.phases?.length) returnBind = bindings.get(def.phases[def.phases.length - 1]!.id); + if (returnBind) { + lines.push(` return ${returnBind};`); } lines.push(`});`); lines.push(``); return lines.join("\n"); } -function decompilePhase(p: Phase, bind: string, _byId: Map): string { +function decompilePhase(p: Phase, bind: string, _byId: Map, bindings: Map): string { const opts: string[] = []; - if (p.agent) opts.push(`agent: ${JSON.stringify(p.agent)}`); - if (p.final) opts.push(`final: true`); - if (p.when) opts.push(`when: ${JSON.stringify(p.when)}`); - if (p.dependsOn?.length) opts.push(`dependsOn: ${JSON.stringify(p.dependsOn)}`); - if (p.output) opts.push(`output: ${JSON.stringify(p.output)}`); + if (bind !== p.id) opts.push(`id: ${JSON.stringify(p.id)}`); + const raw = p as unknown as Record; + for (const key of [ + "agent", "model", "thinking", "tools", "cwd", "output", "expect", "when", "join", "dependsOn", + "retry", "timeout", "optional", "idempotent", "final", "concurrency", "context", "contextLimit", + "onBlock", "eval", "score", "cache", "shareContext", "convergence", "reflexion", + ] as const) { + if (raw[key] !== undefined) opts.push(`${key}: ${JSON.stringify(raw[key])}`); + } if ((p as { cancelLosers?: boolean }).cancelLosers === false) opts.push(`cancelLosers: false`); + if (raw.input !== undefined) opts.push(`input: ${JSON.stringify(raw.input)}`); const maxNodes = (p as { maxNodes?: number }).maxNodes; if (typeof maxNodes === "number") opts.push(`maxNodes: ${maxNodes}`); const optStr = opts.length ? `, { ${opts.join(", ")} }` : ""; + const optObj = `{ ${opts.join(", ")} }`; switch (p.type ?? "agent") { case "agent": @@ -153,9 +171,10 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri } case "gate": { const upId = p.dependsOn?.[0]; - const upBind = upId ? phaseBinding(upId) : "upstream"; + const upBind = upId ? bindings.get(upId) : undefined; + if (!upBind) throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: gate ${JSON.stringify(p.id)} requires a known dependency`); const taskText = String(p.task ?? (upId ? `{steps.${upId}.output}` : "review")); - return `const ${bind} = gate(${upBind}, { agent: ${JSON.stringify(p.agent ?? "reviewer")} }, (i) => \`${esc(taskText)}\`);`; + return `const ${bind} = gate(${upBind}, ${optObj}, (i) => \`${esc(taskText)}\`);`; } case "race": { const branches = (p.branches ?? []).map( @@ -176,7 +195,11 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri return `const ${bind} = expand(${JSON.stringify(p.def)}, { ${expandOpts.join(", ")} });`; } case "reduce": { - const from = (p.from ?? p.dependsOn ?? []).map((id) => phaseBinding(id)); + const from = (p.from ?? p.dependsOn ?? []).map((id) => { + const b = bindings.get(id); + if (!b) throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: reduce ${JSON.stringify(p.id)} references unknown phase ${JSON.stringify(id)}`); + return b; + }); return `const ${bind} = reduce([${from.join(", ")}], (parts) => agent(\`${esc(String(p.task ?? "reduce"))}\`)${optStr});`; } case "approval": @@ -190,11 +213,11 @@ function decompilePhase(p: Phase, bind: string, _byId: Map): stri } return `const ${bind} = subflow.def(${JSON.stringify(p.def)}${optStr});`; } - return `const ${bind} = subflow(${JSON.stringify(p.use ?? "child")}${optStr});`; + return `const ${bind} = subflow(${JSON.stringify(p.use ?? "child")}, ${JSON.stringify(p.with ?? {})}${optStr});`; case "loop": - return `const ${bind} = loop({ task: \`${esc(String(p.task ?? ""))}\`, maxIterations: ${p.maxIterations ?? 10}, until: ${JSON.stringify(p.until ?? "false")}${p.agent ? `, agent: ${JSON.stringify(p.agent)}` : ""} });`; + return `const ${bind} = loop({ task: ${JSON.stringify(String(p.task ?? ""))}, maxIterations: ${p.maxIterations ?? 10}, until: ${JSON.stringify(p.until ?? "false")}${opts.length ? `, ${opts.join(", ")}` : ""} });`; case "tournament": - return `const ${bind} = tournament({ variants: ${p.variants ?? 2}, task: \`${esc(String(p.task ?? ""))}\`, mode: ${JSON.stringify(p.mode ?? "best")} });`; + return `const ${bind} = tournament({ variants: ${p.variants ?? 2}, task: ${JSON.stringify(String(p.task ?? ""))}, mode: ${JSON.stringify(p.mode ?? "best")}${p.branches ? `, branches: [${p.branches.map((b) => `agent(${JSON.stringify(b.task)}${b.agent ? `, { agent: ${JSON.stringify(b.agent)} }` : ""})`).join(", ")}]` : ""}${p.judge ? `, judge: ${JSON.stringify(p.judge)}` : ""}${p.judgeAgent ? `, judgeAgent: ${JSON.stringify(p.judgeAgent)}` : ""}${opts.length ? `, ${opts.join(", ")}` : ""} });`; default: return `// TFDSL: unsupported-field type=${p.type} id=${p.id}`; } diff --git a/packages/taskflow-dsl/src/paths.ts b/packages/taskflow-dsl/src/paths.ts index 14052df..f04083d 100644 --- a/packages/taskflow-dsl/src/paths.ts +++ b/packages/taskflow-dsl/src/paths.ts @@ -2,6 +2,7 @@ * Path safety helpers for CLI -o / new. */ +import fs from "node:fs"; import path from "node:path"; /** Resolve `out` under `cwd`; reject path escape (e.g. ../../etc/passwd). */ @@ -15,9 +16,47 @@ export function resolveContainedOut(cwd: string, out: string): { ok: true; path: message: `TFDSL_IO_PATH: output path escapes --cwd (${out})`, }; } + const realBase = realPathIfExists(base); + const existing = nearestExisting(target); + if (realBase && existing) { + const realExisting = fs.realpathSync(existing); + const realRel = path.relative(realBase, realExisting); + if (realRel.startsWith("..") || path.isAbsolute(realRel)) { + return { ok: false, message: `TFDSL_IO_PATH: output path escapes --cwd through a symlink (${out})` }; + } + } return { ok: true, path: target }; } export function resolveInput(cwd: string, file: string): string { - return path.isAbsolute(file) ? file : path.resolve(cwd, file); + const base = path.resolve(cwd); + const target = path.isAbsolute(file) ? path.resolve(file) : path.resolve(base, file); + const rel = path.relative(base, target); + if (rel.startsWith("..") || path.isAbsolute(rel)) { + throw new Error(`TFDSL_IO_PATH: input path escapes --cwd (${file})`); + } + const realBase = realPathIfExists(base); + const existing = nearestExisting(target); + if (realBase && existing) { + const realTarget = fs.realpathSync(existing); + const realRel = path.relative(realBase, realTarget); + if (realRel.startsWith("..") || path.isAbsolute(realRel)) { + throw new Error(`TFDSL_IO_PATH: input path escapes --cwd through a symlink (${file})`); + } + } + return target; +} + +function realPathIfExists(value: string): string | undefined { + return fs.existsSync(value) ? fs.realpathSync(value) : undefined; +} + +function nearestExisting(value: string): string | undefined { + let cursor = value; + while (!fs.existsSync(cursor)) { + const parent = path.dirname(cursor); + if (parent === cursor) return undefined; + cursor = parent; + } + return cursor; } diff --git a/packages/taskflow-dsl/src/runes.ts b/packages/taskflow-dsl/src/runes.ts index 29be8a9..b23c0a7 100644 --- a/packages/taskflow-dsl/src/runes.ts +++ b/packages/taskflow-dsl/src/runes.ts @@ -36,26 +36,41 @@ export interface ArgSpec { export interface FlowOptions { description?: string; version?: number; + agentScope?: "user" | "project" | "both"; + strictInterpolation?: boolean; + contextSharing?: boolean; + incremental?: boolean; } export interface PhaseOptions { id?: string; agent?: string; model?: string; - thinking?: string | boolean; + thinking?: string; tools?: string[]; cwd?: string; output?: "text" | "json" | JsonExpectMarker; expect?: unknown; when?: string; join?: "all" | "any"; - dependsOn?: string[]; + dependsOn?: Array>; retry?: { max?: number; backoffMs?: number; factor?: number }; timeout?: number; optional?: boolean; idempotent?: boolean; final?: boolean; concurrency?: number; + context?: string[]; + contextLimit?: number; + onBlock?: "halt" | "retry"; + eval?: string[]; + score?: unknown; + cache?: { + scope?: "run-only" | "cross-run" | "off"; + ttl?: string; + fingerprint?: string[]; + }; + shareContext?: boolean; /** Brand from json(). */ jsonExpect?: unknown; } @@ -68,6 +83,16 @@ export interface FlowCtx { export type TaskflowModuleDefault = { readonly __brand: "TaskflowModuleDefault" }; +export interface InlineTaskflowPhase { + id: string; + type?: string; + [key: string]: unknown; +} + +export type InlineTaskflowDefinition = + | { name?: string; phases: InlineTaskflowPhase[] } + | InlineTaskflowPhase[]; + export interface JsonExpectMarker { readonly __brand: "JsonExpectMarker"; readonly _type?: T; @@ -169,7 +194,7 @@ export function subflow( /** Nested dynamic sub-flow (compiles to type:flow def). */ export function subflowDef( - _def: PhaseRef | string | unknown, + _def: PhaseRef | string | InlineTaskflowDefinition, _opts?: PhaseOptions, ): PhaseRef { return eraseOnly("subflow.def"); diff --git a/packages/taskflow-dsl/src/typecheck.ts b/packages/taskflow-dsl/src/typecheck.ts index 7e30b76..0a46f0d 100644 --- a/packages/taskflow-dsl/src/typecheck.ts +++ b/packages/taskflow-dsl/src/typecheck.ts @@ -33,7 +33,12 @@ export function typecheckFile(filePath: string, cwd = process.cwd()): Diagnostic ...program.getSemanticDiagnostics(), ].filter((d) => { const f = d.file?.fileName; - return !f || path.resolve(f) === abs; + if (f && path.resolve(f) !== abs) return false; + // A phase binding may be referenced declaratively by its emitted string id + // (`dependsOn: ["phase-id"]`) rather than by a TS identifier. Treating that + // valid DSL form as TS6133 would make decompiled and hand-authored DAGs fail + // check even though the binding is consumed by the eraser and execution engine. + return d.code !== 6133; }); return diags.map((d) => { const file = d.file; diff --git a/packages/taskflow-dsl/test/closure-regressions.test.ts b/packages/taskflow-dsl/test/closure-regressions.test.ts new file mode 100644 index 0000000..d23ba0b --- /dev/null +++ b/packages/taskflow-dsl/test/closure-regressions.test.ts @@ -0,0 +1,356 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { test } from "node:test"; +import { buildFile, buildSource } from "../src/build.ts"; +import { checkFile } from "../src/check.ts"; +import { decompileTaskflow } from "../src/decompile.ts"; +import { resolveContainedOut, resolveInput } from "../src/paths.ts"; +import { compileTaskflowToFlowIR, hashFlowIR, type Taskflow } from "taskflow-core"; + +function errors(result: { diagnostics: Array<{ code: string; message: string }> }): string { + return result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"); +} + +test("closure: duplicate explicit phase ids fail closed", () => { + const result = buildSource(` +import { flow, agent } from "taskflow-dsl"; +export default flow("duplicate", () => { + const one = agent("one", { id: "same" }); + const two = agent("two", { id: "same" }); + return two; +}); +`); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((d) => d.code === "TFDSL_PHASE_ID_DUPLICATE"), errors(result)); +}); + +test("closure: source bindings resolve to emitted ids in templates and dependencies", () => { + const result = buildSource(` +import { flow, agent } from "taskflow-dsl"; +export default flow("bindings", () => { + const first = agent("one", { id: "first-step" }); + return agent(\`use \${first.output}\`, { id: "final-step" }); +}); +`); + assert.equal(result.ok, true, errors(result)); + assert.deepEqual(result.taskflow?.phases?.map((p) => p.id), ["first-step", "final-step"]); + assert.equal(result.taskflow?.phases?.[1]?.task, "use {steps.first-step.output}"); + assert.deepEqual(result.taskflow?.phases?.[1]?.dependsOn, ["first-step"]); +}); + +test("closure: json infers primitives, arrays, objects, and optional properties", () => { + const result = buildSource(` +import { flow, agent, json } from "taskflow-dsl"; +export default flow("typed-json", () => { + const count = agent("count", { output: json() }); + return agent("rows", { output: json<{ name: string; score?: number; ok: boolean }[]>() }); +}); +`); + assert.equal(result.ok, true, errors(result)); + assert.deepEqual(result.taskflow?.phases?.[0]?.expect, { type: "number" }); + assert.deepEqual(result.taskflow?.phases?.[1]?.expect, { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + score: { type: "number" }, + ok: { type: "boolean" }, + }, + required: ["name", "ok"], + }, + }); +}); + +test("closure: json named and complex types fail closed", () => { + const result = buildSource(` +import { flow, agent, json } from "taskflow-dsl"; +type Result = { ok: boolean }; +export default flow("complex-json", () => agent("x", { output: json() })); +`); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((d) => d.code === "TFDSL_JSON_TYPE_UNSUPPORTED"), errors(result)); +}); + +test("closure: inline defs accept static Taskflow shapes and reject dynamic values", () => { + const good = buildSource(` +import { flow, expand } from "taskflow-dsl"; +export default flow("inline", () => expand({ + name: "child", + phases: [{ id: "child-main", type: "agent", task: "ok", final: true }], +})); +`); + assert.equal(good.ok, true, errors(good)); + assert.equal(typeof good.taskflow?.phases?.[0]?.def, "object"); + const bad = buildSource(` +import { flow, expand } from "taskflow-dsl"; +const dynamic = "x"; +export default flow("inline", () => expand({ name: dynamic, phases: [] })); +`); + assert.equal(bad.ok, false); + assert.ok(bad.diagnostics.some((d) => d.code === "TFDSL_INLINE_DEF_DYNAMIC"), errors(bad)); +}); + +test("closure: decompile preserves ids, collisions, real final, fields, and FlowIR", () => { + const def: Taskflow = { + name: "roundtrip", + description: "full fidelity", + version: 2, + agentScope: "both", + strictInterpolation: true, + contextSharing: true, + incremental: true, + budget: { maxTokens: 1000 }, + concurrency: 3, + phases: [ + { + id: "a-b", + type: "agent", + task: "first", + agent: "executor", + model: "m", + thinking: "high", + tools: ["read"], + cwd: "/tmp", + output: "json", + expect: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] }, + retry: { max: 2, backoffMs: 1, factor: 2 }, + timeout: 1000, + optional: true, + idempotent: false, + context: ["README.md"], + contextLimit: 10, + cache: { scope: "off" }, + shareContext: true, + }, + { + id: "a.b", + type: "agent", + task: "use {steps.a-b.output}", + dependsOn: ["a-b"], + when: "true", + join: "all", + final: true, + }, + { id: "class", type: "script", run: ["printf", "%s"], input: "hello" }, + ], + }; + const source = decompileTaskflow(def); + assert.match(source, /id: "a-b"/); + assert.match(source, /id: "a\.b"/); + assert.match(source, /return a_b_2;/); + const rebuilt = buildSource(source, "roundtrip.tf.ts", { irHash: true }); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); + assert.equal( + rebuilt.irHash, + hashFlowIR(compileTaskflowToFlowIR(def).canonical), + ); +}); + +test("closure: decompile/build round-trips all twelve phase kinds", () => { + const def: Taskflow = { + name: "all-kinds-roundtrip", + phases: [ + { id: "seed", type: "agent", task: "seed", output: "json", expect: { type: "object" } }, + { id: "mapped", type: "map", over: "{steps.seed.json}", as: "item", task: "map {item}", dependsOn: ["seed"], agent: "executor", output: "text" }, + { id: "parallel-work", type: "parallel", branches: [{ task: "a", agent: "a" }, { task: "b" }], dependsOn: ["seed"], concurrency: 2 }, + { id: "quality-gate", type: "gate", task: "review", agent: "reviewer", dependsOn: ["mapped"], onBlock: "halt", eval: ["true"] }, + { id: "reduced", type: "reduce", from: ["mapped", "parallel-work"], task: "reduce", dependsOn: ["mapped", "parallel-work"], agent: "executor" }, + { id: "approval-step", type: "approval", task: "Approve?", dependsOn: ["reduced"] }, + { id: "child-flow", type: "flow", use: "saved-child", with: { q: "{steps.reduced.output}" }, dependsOn: ["reduced"] }, + { id: "loop-step", type: "loop", task: "improve", until: "false", maxIterations: 2, convergence: false, reflexion: true, agent: "executor", dependsOn: ["reduced"] }, + { id: "contest", type: "tournament", task: "solve", variants: 2, mode: "best", judge: "pick", judgeAgent: "reviewer", agent: "executor", dependsOn: ["reduced"] }, + { id: "shell-step", type: "script", run: ["printf", "%s"], input: "hello", dependsOn: ["reduced"] }, + { id: "race-step", type: "race", branches: [{ task: "fast" }, { task: "slow", agent: "executor" }], cancelLosers: false, dependsOn: ["reduced"] }, + { id: "expand-step", type: "expand", def: "{steps.seed.json}", expandMode: "graft", maxNodes: 20, dependsOn: ["seed"], final: true }, + ], + }; + const source = decompileTaskflow(def); + const rebuilt = buildSource(source, "all-kinds.tf.ts", { irHash: true }); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); + assert.equal(rebuilt.irHash, hashFlowIR(compileTaskflowToFlowIR(def).canonical)); + const dir = fs.mkdtempSync(path.join(process.cwd(), "packages/taskflow-dsl/test/.tmp-all-kinds-typecheck-")); + try { + const file = path.join(dir, "all-kinds.tf.ts"); + fs.writeFileSync(file, source); + assert.equal(checkFile(file, { cwd: process.cwd() }).ok, true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("closure: JSONC comments and trailing commas are accepted", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-dsl-jsonc-")); + try { + const file = path.join(dir, "flow.jsonc"); + fs.writeFileSync(file, `{ + // comment + "name": "jsonc", + "phases": [{ "id": "main", "type": "agent", "task": "ok", "final": true, }], +}`); + const result = buildFile(file); + assert.equal(result.ok, true, errors(result)); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test("closure: syntax errors and missing taskflow-dsl module shape are diagnostics, not throws", () => { + assert.doesNotThrow(() => buildSource(`import { flow } from "taskflow-dsl"; export default flow("x", () => {`)); + const syntax = buildSource(`import { flow } from "taskflow-dsl"; export default flow("x", () => {`); + assert.equal(syntax.ok, false); + assert.ok(syntax.diagnostics.some((d) => d.code.startsWith("TS")), errors(syntax)); + const missingImport = buildSource(`export default flow("x", () => agent("x"));`); + assert.equal(missingImport.ok, false); + assert.ok(missingImport.diagnostics.some((d) => d.code === "TFDSL_IMPORT_MISSING"), errors(missingImport)); +}); + +test("closure: file and path failures are structured and symlink escapes are rejected", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-dsl-path-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-dsl-outside-")); + try { + const missing = buildFile(path.join(dir, "missing.tf.ts")); + assert.equal(missing.ok, false); + assert.equal(missing.diagnostics[0]?.code, "TFDSL_IO_MISSING"); + fs.symlinkSync(outside, path.join(dir, "escape")); + assert.equal(resolveContainedOut(dir, "escape/result.json").ok, false); + assert.throws(() => resolveInput(dir, "escape/input.tf.ts"), /symlink/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); + +test("closure: DSL package build cannot mask compiler or README failures", () => { + const pkg = JSON.parse(fs.readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { + scripts: { build: string }; + }; + assert.doesNotMatch(pkg.scripts.build, /\|\|\s*true/); + assert.match(pkg.scripts.build, /copy-readme\.mjs taskflow-dsl/); + const copyScript = fs.readFileSync(new URL("../../../scripts/copy-readme.mjs", import.meta.url), "utf8"); + assert.match(copyScript, /"taskflow-dsl"/); +}); + +test("closure: dynamic options, dependencies, and body control flow fail closed", () => { + for (const source of [ + `import { flow, agent } from "taskflow-dsl"; +const model = "m"; +export default flow("dynamic-option", () => agent("x", { model }));`, + `import { flow, agent } from "taskflow-dsl"; +export default flow("dynamic-dep", () => { const a = agent("a"); return agent("b", { dependsOn: [missing] }); });`, + `import { flow, agent } from "taskflow-dsl"; +export default flow("control", () => { const a = agent("a"); if (true) agent("lost"); return a; });`, + ]) { + const result = buildSource(source); + assert.equal(result.ok, false, JSON.stringify(result.taskflow)); + } +}); + +test("closure: unresolved gate inputs, invalid ctx values, and extra arguments fail closed", () => { + for (const source of [ + `import { flow, gate } from "taskflow-dsl"; +export default flow("gate", () => gate(missing, {}, () => "review"));`, + `import { flow, agent } from "taskflow-dsl"; +export default flow("ctx", (ctx) => { ctx.concurrency({}); ctx.budget(1); return agent("x"); });`, + `import { flow, agent } from "taskflow-dsl"; +export default flow("flow-arity", {}, () => agent("x"), 123);`, + `import { flow, agent } from "taskflow-dsl"; +export default flow("agent-arity", () => agent("x", {}, 123));`, + `import { flow, agent, parallel } from "taskflow-dsl"; +export default flow("branch-arity", () => parallel([agent("x", {}, 123)]));`, + `import { flow, parallel } from "taskflow-dsl"; +export default flow("nested-import", () => parallel([agent("x")]));`, + `import { flow, agent } from "taskflow-dsl"; +export default flow("json-arity", () => agent("x", { output: json(123) }));`, + ]) { + const result = buildSource(source); + assert.equal(result.ok, false, JSON.stringify(result.taskflow)); + } +}); + +test("closure: spreads and dynamic inline structures fail closed", () => { + for (const source of [ + `import { flow, expand } from "taskflow-dsl"; +const fragment = { phases: [{ id: "child", type: "agent", task: "work", final: true }] }; +export default flow("inline", () => expand({ ...fragment }));`, + `import { flow, agent } from "taskflow-dsl"; +const opts = { contextSharing: true }; +export default flow("flow-opts", { ...opts }, () => agent("x"));`, + `import { flow, agent } from "taskflow-dsl"; +const opts = { contextSharing: true }; +export default flow("flow-opts-ref", opts, () => agent("x"));`, + `import { flow, agent } from "taskflow-dsl"; +export default flow("flow-opts-type", { contextSharing: "yes" }, () => agent("x"));`, + `import { flow, subflow } from "taskflow-dsl"; +const topic = "real"; +export default flow("with", () => subflow("child", { topic }));`, + `import { flow, agent, tournament } from "taskflow-dsl"; +const branches = [agent("a"), agent("b")]; +export default flow("branches", () => tournament({ branches, task: "judge" }));`, + ]) { + const result = buildSource(source); + assert.equal(result.ok, false, JSON.stringify(result.taskflow)); + } +}); + +test("closure: parallel destructure rejects options it cannot preserve", () => { + const result = buildSource(` +import { flow, agent, parallel } from "taskflow-dsl"; +export default flow("parallel-opts", () => { + const seed = agent("seed"); + const [a, b] = parallel([agent("a"), agent("b")], { concurrency: 1, dependsOn: [seed] }); + return b; +}); +`); + assert.equal(result.ok, false); + assert.ok(result.diagnostics.some((d) => d.code === "TFDSL_PARALLEL_DESTRUCTURE_OPTS"), errors(result)); +}); + +test("closure: map inner agent execution options are preserved", () => { + const result = buildSource(` +import { flow, agent, map, json } from "taskflow-dsl"; +export default flow("map-options", () => { + const seed = agent("seed", { output: json() }); + return map(seed, (item) => agent("work", { + model: "m", thinking: "high", tools: ["read"], cwd: "/tmp", + retry: { max: 2 }, timeout: 1000, context: ["README.md"], shareContext: true, + })); +}); +`); + assert.equal(result.ok, true, errors(result)); + const mapped = result.taskflow?.phases?.[1]; + assert.equal(mapped?.model, "m"); + assert.equal(mapped?.thinking, "high"); + assert.deepEqual(mapped?.tools, ["read"]); + assert.equal(mapped?.cwd, "/tmp"); + assert.deepEqual(mapped?.retry, { max: 2 }); + assert.equal(mapped?.timeout, 1000); + assert.deepEqual(mapped?.context, ["README.md"]); + assert.equal(mapped?.shareContext, true); +}); + +test("closure: check typechecks by default and decompile emits type-correct options", () => { + const dir = fs.mkdtempSync(path.join(process.cwd(), "packages/taskflow-dsl/test/.tmp-closure-typecheck-")); + try { + const invalid = path.join(dir, "invalid.tf.ts"); + fs.writeFileSync(invalid, `import { flow, agent } from "taskflow-dsl";\nconst bad: number = "wrong";\nexport default flow("invalid", () => agent("ok"));\n`); + assert.equal(checkFile(invalid, { cwd: process.cwd() }).ok, false); + assert.equal(checkFile(invalid, { cwd: process.cwd(), typecheck: false }).ok, true); + + const roundtrip = path.join(dir, "roundtrip.tf.ts"); + fs.writeFileSync(roundtrip, decompileTaskflow({ + name: "typed-decompile", + phases: [{ + id: "main", type: "agent", task: "x", context: ["README.md"], contextLimit: 10, + cache: { scope: "off" }, shareContext: true, final: true, + }], + })); + assert.equal(checkFile(roundtrip, { cwd: process.cwd() }).ok, true); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/taskflow-dsl/test/e2e-dist-cli.mts b/packages/taskflow-dsl/test/e2e-dist-cli.mts new file mode 100644 index 0000000..5a72ba8 --- /dev/null +++ b/packages/taskflow-dsl/test/e2e-dist-cli.mts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repo = path.resolve(here, "../../.."); +const temp = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-dsl-dist-e2e-")); + +function exec(command: string, args: string[], cwd = repo): string { + return execFileSync(command, args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +try { + exec("pnpm", ["--filter", "taskflow-core", "build"]); + exec("pnpm", ["--filter", "taskflow-dsl", "build"]); + const cli = path.join(repo, "packages/taskflow-dsl/dist/cli.js"); + assert.equal(fs.existsSync(cli), true, "dist/cli.js must exist after build"); + assert.match(exec(process.execPath, [cli, "--version"]), /^0\.2\.0\s*$/); + + const project = path.join(temp, "project"); + fs.mkdirSync(project); + const source = path.join(project, "hello.tf.ts"); + fs.writeFileSync(source, `import { flow, agent } from "taskflow-dsl";\nexport default flow("hello", () => agent("hi"));\n`); + const json = exec(process.execPath, [cli, "build", "hello.tf.ts", "--cwd", project, "--json", "--emit", "both"]); + const result = JSON.parse(json) as { ok: boolean; taskflow?: unknown; flowir?: unknown; irHash?: string }; + assert.equal(result.ok, true); + assert.ok(result.taskflow); + assert.ok(result.flowir); + assert.match(result.irHash ?? "", /^ir:[0-9a-f]{64}$/); + assert.equal(fs.existsSync(path.join(project, "hello.taskflow.json")), false, "--json must not write files"); + const jsonc = path.join(project, "commented.jsonc"); + fs.writeFileSync(jsonc, `{"name":"commented",// comment\n"phases":[{"id":"main","type":"agent","task":"ok","final":true,}],}`); + assert.match( + exec(process.execPath, [cli, "decompile", "commented.jsonc", "--cwd", project, "--out", "-"]), + /export default flow\("commented"/, + ); + + const packs = path.join(temp, "packs"); + fs.mkdirSync(packs); + exec("pnpm", ["pack", "--pack-destination", packs], path.join(repo, "packages/taskflow-core")); + exec("pnpm", ["pack", "--pack-destination", packs], path.join(repo, "packages/taskflow-dsl")); + const tgz = fs.readdirSync(packs).filter((f) => f.endsWith(".tgz")).map((f) => path.join(packs, f)); + assert.equal(tgz.length, 2); + const install = path.join(temp, "install"); + fs.mkdirSync(install); + fs.writeFileSync(path.join(install, "package.json"), JSON.stringify({ private: true })); + exec("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund", ...tgz], install); + const installedCli = path.join(install, "node_modules/.bin/taskflow-dsl"); + assert.match(exec(installedCli, ["--version"], install), /^0\.2\.0\s*$/); + const installedSource = path.join(install, "installed.tf.ts"); + fs.writeFileSync(installedSource, `import { flow, agent } from "taskflow-dsl";\nexport default flow("installed", () => agent("ok", { context: ["README.md"], cache: { scope: "off" } }));\n`); + assert.match(exec(installedCli, ["check", "installed.tf.ts", "--cwd", install], install), /^ok\s*$/); + fs.writeFileSync(installedSource, `import { flow, agent } from "taskflow-dsl";\nconst bad: number = "wrong";\nexport default flow("installed", () => agent("ok"));\n`); + let invalidTypeRejected = false; + try { + exec(installedCli, ["check", "installed.tf.ts", "--cwd", install], install); + } catch (error) { + invalidTypeRejected = true; + assert.equal((error as { status?: number }).status, 1); + } + assert.equal(invalidTypeRejected, true, "installed check must typecheck by default"); + + let rejected = false; + try { + exec(installedCli, ["build", "hello.tf.ts", "--cwd", project, "--emit", "invalid"], install); + } catch (error) { + rejected = true; + const status = (error as { status?: number }).status; + assert.equal(status, 2); + } + assert.equal(rejected, true, "invalid --emit must fail"); +} finally { + fs.rmSync(temp, { recursive: true, force: true }); +} diff --git a/packages/taskflow-dsl/test/kinds-coverage.test.ts b/packages/taskflow-dsl/test/kinds-coverage.test.ts index 03c6750..db30c24 100644 --- a/packages/taskflow-dsl/test/kinds-coverage.test.ts +++ b/packages/taskflow-dsl/test/kinds-coverage.test.ts @@ -136,14 +136,14 @@ export default flow("gs", () => { assert.equal((withScore as { score: { combine: string } }).score.combine, "all"); }); -test("negative: unknown option warns", () => { +test("negative: unknown option fails closed", () => { // without as never, TS would error at typecheck; source still has property const raw = ` import { flow, agent } from "taskflow-dsl"; export default flow("u", () => agent("t", { notARealField: 1 })); `; const r = buildSource(raw, "u.tf.ts"); - // may still validate if unknown stripped + assert.equal(r.ok, false); assert.ok(r.diagnostics.some((d) => d.code === "TFDSL_RUNE_OPTS_UNKNOWN")); }); diff --git a/packages/taskflow-hosts/src/grok-runner.ts b/packages/taskflow-hosts/src/grok-runner.ts index 731fb96..77bed2f 100644 --- a/packages/taskflow-hosts/src/grok-runner.ts +++ b/packages/taskflow-hosts/src/grok-runner.ts @@ -16,15 +16,18 @@ * Mapping to the host-neutral contract: * - output = concatenated `text` event data (final answer) * - lastActivity = latest text/thought chunk or end/error summary - * - usage = zeros today (streaming-json does not emit token/cost fields; - * fill via rates.ts post-hoc when wiring batch 2 budgets) + * - usage = zeros today because Grok 0.2.93 streaming-json does not + * emit token/cost fields. The runner advertises that fact + * so budgeted MCP runs are rejected instead of silently + * running without a ceiling. * - failure = an `error` event, or a non-zero process exit * * Permission mapping (codex `sandboxForTools` analogue): - * - read-only whitelist → `--tools read_file,grep,list_dir,web_search,web_fetch` - * (mutating tools removed from the available set) - * - mutating / no whitelist → `--always-approve` so non-interactive -p never - * hangs on a permission prompt + * - read-only whitelist → kernel `--sandbox read-only`, a known-good + * `--tools` allowlist, and independent mutator deny rules + * - mutating / no whitelist → kernel `--sandbox workspace` plus + * `--always-approve`, so writes are confined to the cwd/temp/session paths + * without hanging non-interactive -p on a permission prompt * * Process handling (idle watchdog, abort, signal-kill, stderr cap, sanitize) * is delegated to shared `runSubagentProcess` in taskflow-core. @@ -48,12 +51,27 @@ import { import { emptyUsage } from "taskflow-core"; /** - * Grok built-in tool ids a read-only phase may use (from headless docs: - * `read_file`, `grep`, `list_dir`, `web_search`, `web_fetch`). Shell and edit - * tools are excluded so a listed-tools phase without write/edit/bash cannot - * mutate the workspace. + * Grok built-in tool ids a read-only phase may use. Web ids are deliberately + * omitted because Grok 0.2.93's allowlist parser fails open on them. */ -const READ_ONLY_TOOLS = ["read_file", "grep", "list_dir", "web_search", "web_fetch"]; +const READ_ONLY_TOOL_MAP: Readonly> = { + read: "read_file", + read_file: "read_file", + grep: "grep", + glob: "list_dir", + ls: "list_dir", + list: "list_dir", + list_dir: "list_dir", +}; + +/** + * Defence in depth for Grok 0.2.93. That version warns that web_search / + * web_fetch are "unmappable" in `--tools` and then restores its full toolset. + * We therefore never put those ids in the allowlist, and also remove every + * known mutator after allowlist processing. `--deny` is a second, independent + * enforcement layer in case a future CLI regresses allowlist handling again. + */ +const MUTATING_GROK_TOOLS = ["run_terminal_cmd", "search_replace", "write", "write_file", "Agent"]; /** Accumulated state folded from a Grok streaming-json event stream. */ export interface GrokAccumulator { @@ -141,19 +159,58 @@ export function grokBin(): string { /** * Map a phase's tool whitelist to Grok headless permission flags. * - * - no whitelist / mutating tools → `--always-approve` (non-interactive cannot - * answer permission prompts; equivalent to codex workspace-write / claude - * bypassPermissions — WITHOUT an OS sandbox backstop) - * - read-only whitelist → `--tools ` so write/shell tools are - * not available at all (still pairs with `--always-approve` so remaining - * tools never block on confirm) + * - no whitelist / mutating tools → kernel `--sandbox workspace` plus + * `--always-approve` (non-interactive cannot answer permission prompts; + * writes stay confined to the cwd and Grok's documented temp/session paths) + * - read-only whitelist → kernel `--sandbox read-only` plus a narrow + * allowlist and independent deny rules (still pairs with `--always-approve` + * so the remaining safe tools never block on confirm) */ export function permissionArgsForGrokTools(tools: string[] | undefined): string[] { - if (!tools || tools.length === 0) return ["--always-approve"]; - const mutating = new Set(["write", "edit", "bash", "apply_patch", "run_terminal_cmd", "search_replace"]); + if (!tools || tools.length === 0) return ["--sandbox", "workspace", "--always-approve"]; + const mutating = new Set([ + "write", + "write_file", + "edit", + "bash", + "apply_patch", + "run_terminal_cmd", + "run_terminal_command", + "search_replace", + ]); const canMutate = tools.some((t) => mutating.has(t)); - if (canMutate) return ["--always-approve"]; - return ["--tools", READ_ONLY_TOOLS.join(","), "--always-approve"]; + if (canMutate) return ["--sandbox", "workspace", "--always-approve"]; + const allowed = [...new Set(tools.map((t) => READ_ONLY_TOOL_MAP[t]).filter((t): t is string => Boolean(t)))]; + // Keep the allowlist non-empty: an empty --tools value is treated as if the + // flag were absent by some Grok builds, which would fail open to all tools. + if (allowed.length === 0) allowed.push("read_file"); + return [ + "--sandbox", + "read-only", + "--tools", + allowed.join(","), + "--disallowed-tools", + MUTATING_GROK_TOOLS.join(","), + "--deny", + "Bash", + "--deny", + "Edit", + "--deny", + "Write", + "--deny", + "MCPTool", + "--no-subagents", + "--always-approve", + ]; +} + +/** Map Taskflow/Pi thinking levels to Grok's --reasoning-effort contract. */ +export function resolveGrokThinking(thinking: string | undefined): string | undefined { + if (!thinking) return undefined; + const normalized = thinking.trim().toLowerCase(); + if (normalized === "off") return "none"; + if (["none", "minimal", "low", "medium", "high", "xhigh", "max"].includes(normalized)) return normalized; + return undefined; } /** @@ -182,6 +239,7 @@ export interface GrokArgsCtx { task: string; /** Already-resolved model (opts.model ?? agent.model). */ model?: string; + thinking?: string; tools?: string[]; cwd?: string; } @@ -192,14 +250,17 @@ export interface GrokArgsCtx { * unit-testable in CI without a live Grok session. * * grok -p --output-format streaming-json - * [--always-approve | --tools … --always-approve] - * [--model m] [--cwd dir] [--rules systemPrompt] + * [--sandbox workspace --always-approve | + * --sandbox read-only --tools … --always-approve] + * [--model m] [--reasoning-effort level] [--cwd dir] [--rules systemPrompt] */ export function buildGrokArgs(ctx: GrokArgsCtx): string[] { const grokModel = resolveGrokModel(ctx.model); + const grokThinking = resolveGrokThinking(ctx.thinking); const args: string[] = ["-p", ctx.task, "--output-format", "streaming-json"]; args.push(...permissionArgsForGrokTools(ctx.tools)); if (grokModel) args.push("-m", grokModel); + if (grokThinking) args.push("--reasoning-effort", grokThinking); if (ctx.cwd) args.push("--cwd", ctx.cwd); if (ctx.systemPrompt.trim()) args.push("--rules", ctx.systemPrompt.trim()); return args; @@ -223,13 +284,14 @@ export async function runGrokAgentTask( const model = opts.model ?? agent.model; const tools = opts.tools ?? agent.tools; - void globalThinking; // grok -p has no thinking-level flag; reserved. + const thinking = opts.thinking ?? agent.thinking ?? globalThinking; const cwd = opts.cwd ?? defaultCwd; const args = buildGrokArgs({ systemPrompt: agent.systemPrompt, task, model, + thinking, tools, cwd, }); @@ -249,10 +311,15 @@ export async function runGrokAgentTask( }); } +// Preserve the capability when consumers inject the bare function into +// RuntimeDeps instead of passing the SubagentRunner object through an adapter. +(runGrokAgentTask as typeof runGrokAgentTask & { usageAccounting: "unavailable" }).usageAccounting = "unavailable"; + /** * The Grok host's `SubagentRunner`. Drops into `RuntimeDeps.runTask` exactly * like the other host runners, so the engine runs unchanged on Grok Build. */ export const grokSubagentRunner: SubagentRunner = { runTask: runGrokAgentTask, + usageAccounting: "unavailable", }; diff --git a/packages/taskflow-hosts/test/grok-args.test.ts b/packages/taskflow-hosts/test/grok-args.test.ts index db6a8e5..821e07d 100644 --- a/packages/taskflow-hosts/test/grok-args.test.ts +++ b/packages/taskflow-hosts/test/grok-args.test.ts @@ -11,6 +11,7 @@ import { grokBin, permissionArgsForGrokTools, resolveGrokModel, + resolveGrokThinking, type GrokArgsCtx, } from "../src/grok-runner.ts"; @@ -31,28 +32,49 @@ test("grok bin: defaults to `grok`, honours PI_TASKFLOW_GROK_BIN override", () = // --- permission mapping ----------------------------------------------------- -test("grok perms: no whitelist → --always-approve", () => { - assert.deepEqual(permissionArgsForGrokTools(undefined), ["--always-approve"]); - assert.deepEqual(permissionArgsForGrokTools([]), ["--always-approve"]); +test("grok perms: no whitelist → workspace sandbox + --always-approve", () => { + assert.deepEqual(permissionArgsForGrokTools(undefined), ["--sandbox", "workspace", "--always-approve"]); + assert.deepEqual(permissionArgsForGrokTools([]), ["--sandbox", "workspace", "--always-approve"]); }); -test("grok perms: mutating whitelist → --always-approve", () => { - assert.deepEqual(permissionArgsForGrokTools(["read", "write"]), ["--always-approve"]); - assert.deepEqual(permissionArgsForGrokTools(["bash"]), ["--always-approve"]); - assert.deepEqual(permissionArgsForGrokTools(["run_terminal_cmd"]), ["--always-approve"]); - assert.deepEqual(permissionArgsForGrokTools(["search_replace"]), ["--always-approve"]); +test("grok perms: mutating whitelist → workspace sandbox + --always-approve", () => { + for (const tools of [["read", "write"], ["bash"], ["run_terminal_cmd"], ["search_replace"]]) { + assert.deepEqual(permissionArgsForGrokTools(tools), ["--sandbox", "workspace", "--always-approve"]); + } }); test("grok perms: read-only whitelist → --tools + --always-approve", () => { const args = permissionArgsForGrokTools(["read", "grep", "glob"]); - assert.equal(args[0], "--tools"); - const allowed = String(args[1]).split(","); + assert.equal(args[args.indexOf("--sandbox") + 1], "read-only"); + assert.ok(args.includes("--tools")); + const allowed = String(args[args.indexOf("--tools") + 1]).split(","); assert.ok(allowed.includes("read_file")); assert.ok(allowed.includes("grep")); assert.ok(allowed.includes("list_dir")); assert.ok(!allowed.includes("run_terminal_cmd")); assert.ok(!allowed.includes("search_replace")); - assert.equal(args[2], "--always-approve"); + assert.ok(args.includes("--disallowed-tools")); + const denied = String(args[args.indexOf("--disallowed-tools") + 1]).split(","); + assert.ok(denied.includes("run_terminal_cmd")); + assert.ok(denied.includes("search_replace")); + assert.ok(args.includes("--no-subagents")); + assert.ok(args.includes("--always-approve")); + assert.ok(args.includes("MCPTool")); +}); + +test("grok perms: web ids never enter the 0.2.93 allowlist regression path", () => { + const args = permissionArgsForGrokTools(["read", "web_search", "web_fetch"]); + const allowed = String(args[args.indexOf("--tools") + 1]).split(","); + assert.deepEqual(allowed, ["read_file"]); + assert.ok(!args.join(" ").includes("web_search")); + assert.ok(!args.join(" ").includes("web_fetch")); +}); + +test("grok perms: unknown read-only aliases fail closed to a non-empty safe allowlist", () => { + const args = permissionArgsForGrokTools(["future_read_tool"]); + assert.equal(args[args.indexOf("--tools") + 1], "read_file"); + assert.ok(args.includes("--disallowed-tools")); + assert.equal(args[args.indexOf("--sandbox") + 1], "read-only"); }); // --- model resolution ------------------------------------------------------- @@ -69,6 +91,13 @@ test("grok model: placeholders / multi-slash / thinking-suffix dropped", () => { assert.equal(resolveGrokModel(undefined), undefined); }); +test("grok thinking: maps taskflow levels to --reasoning-effort", () => { + assert.equal(resolveGrokThinking("off"), "none"); + assert.equal(resolveGrokThinking("high"), "high"); + assert.equal(resolveGrokThinking("xhigh"), "xhigh"); + assert.equal(resolveGrokThinking("unknown"), undefined); +}); + // --- argv contract ---------------------------------------------------------- const base: GrokArgsCtx = { @@ -84,11 +113,13 @@ test("grok argv: starts with -p --output-format streaming-json", () => { assert.equal(args[3], "streaming-json"); }); -test("grok argv: always-approve on default / mutating phases", () => { +test("grok argv: workspace sandbox + always-approve on default / mutating phases", () => { const a = buildGrokArgs(base); assert.ok(a.includes("--always-approve")); + assert.equal(a[a.indexOf("--sandbox") + 1], "workspace"); const b = buildGrokArgs({ ...base, tools: ["write", "bash"] }); assert.ok(b.includes("--always-approve")); + assert.equal(b[b.indexOf("--sandbox") + 1], "workspace"); assert.equal(b.indexOf("--tools"), -1); }); @@ -98,6 +129,7 @@ test("grok argv: read-only phases pass --tools + --always-approve", () => { assert.ok(ti >= 0); assert.ok(String(args[ti + 1]).includes("read_file")); assert.ok(args.includes("--always-approve")); + assert.equal(args[args.indexOf("--sandbox") + 1], "read-only"); }); test("grok argv: model via -m when resolvable", () => { @@ -119,3 +151,8 @@ test("grok argv: system prompt via --rules", () => { const empty = buildGrokArgs({ ...base, systemPrompt: " " }); assert.equal(empty.indexOf("--rules"), -1); }); + +test("grok argv: thinking via --reasoning-effort", () => { + const args = buildGrokArgs({ ...base, thinking: "off" }); + assert.equal(args[args.indexOf("--reasoning-effort") + 1], "none"); +}); diff --git a/packages/taskflow-hosts/test/grok-runner.test.ts b/packages/taskflow-hosts/test/grok-runner.test.ts index d1b8365..75afbef 100644 --- a/packages/taskflow-hosts/test/grok-runner.test.ts +++ b/packages/taskflow-hosts/test/grok-runner.test.ts @@ -60,7 +60,12 @@ test("grok model resolve: flat ok, openrouter path dropped", () => { }); test("grok permissions: read-only vs mutating", () => { - assert.deepEqual(permissionArgsForGrokTools(undefined), ["--always-approve"]); - assert.equal(permissionArgsForGrokTools(["read"])[0], "--tools"); - assert.deepEqual(permissionArgsForGrokTools(["write"]), ["--always-approve"]); + assert.deepEqual(permissionArgsForGrokTools(undefined), ["--sandbox", "workspace", "--always-approve"]); + assert.ok(permissionArgsForGrokTools(["read"]).includes("--tools")); + assert.equal( + permissionArgsForGrokTools(["read"])[permissionArgsForGrokTools(["read"]).indexOf("--sandbox") + 1], + "read-only", + ); + assert.ok(permissionArgsForGrokTools(["read"]).includes("--disallowed-tools")); + assert.deepEqual(permissionArgsForGrokTools(["write"]), ["--sandbox", "workspace", "--always-approve"]); }); diff --git a/packages/taskflow-hosts/test/publish-verification.test.ts b/packages/taskflow-hosts/test/publish-verification.test.ts new file mode 100644 index 0000000..f009cf5 --- /dev/null +++ b/packages/taskflow-hosts/test/publish-verification.test.ts @@ -0,0 +1,132 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { test } from "node:test"; +import { sha512Integrity, verifyRegistryIdentity } from "../../../scripts/verify-published-package.mjs"; + +const pkg = { name: "taskflow-hosts", version: "0.2.0" }; +const localIntegrity = sha512Integrity(Buffer.from("official tarball")); +const digest = Buffer.from(localIntegrity.slice("sha512-".length), "base64").toString("hex"); + +function fixtures() { + return { + pkg, + localIntegrity, + trustedOwners: ["heggria", "muyun"], + expectedRepository: "https://github.com/heggria/taskflow", + expectedRef: "refs/tags/v0.2.0", + expectedSha: "deadbeef", + metadata: { + name: pkg.name, + version: pkg.version, + maintainers: [{ name: "heggria", email: "owner@example.com" }], + dist: { + integrity: localIntegrity, + attestations: { provenance: { predicateType: "https://slsa.dev/provenance/v1" } }, + }, + }, + provenanceStatement: { + predicateType: "https://slsa.dev/provenance/v1", + subject: [{ name: `pkg:npm/${pkg.name}@${pkg.version}`, digest: { sha512: digest } }], + predicate: { + buildDefinition: { + buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", + externalParameters: { + workflow: { + repository: "https://github.com/heggria/taskflow", + path: ".github/workflows/publish.yml", + ref: "refs/tags/v0.2.0", + }, + }, + resolvedDependencies: [{ digest: { gitCommit: "deadbeef" } }], + }, + }, + }, + }; +} + +test("publish verification accepts the trusted owner, exact tarball, and tag provenance", () => { + assert.deepEqual(verifyRegistryIdentity(fixtures()), []); +}); + +test("publish verification rejects a preclaimed package even when the version exists", () => { + const input = fixtures(); + input.metadata.maintainers = [{ name: "attacker", email: "bad@example.com" }]; + input.metadata.dist.integrity = sha512Integrity(Buffer.from("malicious tarball")); + input.metadata.dist.attestations = {} as typeof input.metadata.dist.attestations; + const errors = verifyRegistryIdentity(input); + assert.ok(errors.some((error: string) => error.includes("trusted npm owner"))); + assert.ok(errors.some((error: string) => error.includes("integrity mismatch"))); + assert.ok(errors.some((error: string) => error.includes("no SLSA v1"))); +}); + +test("publish verification rejects provenance from another repository or commit", () => { + const input = fixtures(); + input.provenanceStatement.predicate.buildDefinition.externalParameters.workflow.repository = "https://github.com/attacker/fork"; + input.provenanceStatement.predicate.buildDefinition.resolvedDependencies[0]!.digest.gitCommit = "cafebabe"; + const errors = verifyRegistryIdentity(input); + assert.ok(errors.some((error: string) => error.includes("provenance repository"))); + assert.ok(errors.some((error: string) => error.includes("provenance commit"))); +}); + +function workflowJob(source: string, name: string): string { + const lines = source.split("\n"); + const start = lines.findIndex((line) => line === ` ${name}:`); + assert.ok(start >= 0, `missing ${name} job`); + const next = lines.findIndex((line, index) => index > start && /^ [a-zA-Z0-9_-]+:$/.test(line)); + return lines.slice(start, next < 0 ? undefined : next).join("\n"); +} + +test("publish workflow pins actions and isolates npm provenance from release permissions", () => { + const source = readFileSync(new URL("../../../.github/workflows/publish.yml", import.meta.url), "utf8"); + const uses = source.match(/^\s*- uses: .+$/gm) ?? []; + assert.equal(uses.length, 4); + for (const use of uses) assert.match(use, /@[0-9a-f]{40} # v\d+$/); + assert.equal( + uses.filter((use) => use.includes("actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7")).length, + 2, + ); + assert.ok(uses.some((use) => use.includes("pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6"))); + assert.ok(uses.some((use) => use.includes("actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6"))); + assert.doesNotMatch(source, /uses:\s+\S+@v\d+/); + + const publish = workflowJob(source, "publish"); + assert.match(publish, /permissions:\n contents: read\s+#.*\n id-token: write\s+#/); + assert.doesNotMatch(publish, /contents: write/); + assert.doesNotMatch(publish, /Create GitHub Release/); + + const release = workflowJob(source, "release"); + assert.match(release, /needs: publish/); + assert.match(release, /permissions:\n contents: write\s+#/); + assert.doesNotMatch(release, /id-token:/); + assert.match(release, /Create GitHub Release/); +}); + +test("every repository workflow pins third-party actions to verified full SHAs", () => { + const workflowDir = new URL("../../../.github/workflows/", import.meta.url); + const trustedPins = new Map([ + ["actions/checkout", "9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0"], + ["pnpm/action-setup", "0ebf47130e4866e96fce0953f49152a61190b271"], + ["actions/setup-node", "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"], + ["actions/upload-pages-artifact", "fc324d3547104276b827a68afc52ff2a11cc49c9"], + ["actions/deploy-pages", "cd2ce8fcbc39b97be8ca5fce6e763baed58fa128"], + ["github/codeql-action/init", "99df26d4f13ea111d4ec1a7dddef6063f76b97e9"], + ["github/codeql-action/analyze", "99df26d4f13ea111d4ec1a7dddef6063f76b97e9"], + ]); + const files = readdirSync(workflowDir).filter((file) => /\.ya?ml$/.test(file)); + assert.ok(files.length > 0, "no workflow files found"); + let actionCount = 0; + for (const file of files) { + const source = readFileSync(new URL(file, workflowDir), "utf8"); + for (const line of source.split("\n")) { + const match = line.match(/^\s*(?:-\s+)?uses:\s+([^@\s]+)@([^\s]+)(?:\s+#\s+(v\d+))?\s*$/); + if (!match) continue; + actionCount++; + const [, action, revision, versionComment] = match; + assert.match(revision ?? "", /^[0-9a-f]{40}$/, `${file}: action must use a full commit SHA: ${line.trim()}`); + assert.match(versionComment ?? "", /^v\d+$/, `${file}: pinned action must retain a major-version comment`); + assert.equal(trustedPins.get(action ?? ""), revision, `${file}: ${action} is not pinned to its verified tag commit`); + } + assert.doesNotMatch(source, /^\s*(?:-\s+)?uses:\s+[^\s]+@(?![0-9a-f]{40}\b)/m, `${file}: mutable action ref`); + } + assert.ok(actionCount > 0, "no third-party actions found"); +}); diff --git a/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts b/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts index ce883d8..c252912 100644 --- a/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts +++ b/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts @@ -33,8 +33,14 @@ export const RPC = { METHOD_NOT_FOUND: -32601, INVALID_PARAMS: -32602, INTERNAL_ERROR: -32603, + REQUEST_CANCELLED: -32800, } as const; +/** Maximum time stdio shutdown waits for request wrappers after aborting them. + * Non-cooperative user handlers are detached (their eventual rejection is + * observed) so a broken handler can never hold the MCP process open forever. */ +export const TRANSPORT_SHUTDOWN_GRACE_MS = 100; + /** Thrown by a handler to return a structured JSON-RPC error to the client. */ export class RpcError extends Error { code: number; @@ -52,7 +58,14 @@ export class RpcError extends Error { * a structured failure. Returning `undefined` for a request (has id) sends * `result: null`; for a notification it is ignored. */ -export type RpcHandler = (params: unknown) => Promise | unknown; +export interface RpcContext { + /** JSON-RPC request id. Notifications use null. */ + requestId: string | number | null; + /** Aborted by MCP `notifications/cancelled` or transport disconnect. */ + signal: AbortSignal; +} + +export type RpcHandler = (params: unknown, context: RpcContext) => Promise | unknown; /** * Run a JSON-RPC stdio loop over the given streams (defaults to process @@ -64,9 +77,23 @@ export function serveStdio( ): Promise { const input: NodeJS.ReadableStream = io.input ?? process.stdin; const output: NodeJS.WritableStream = io.output ?? process.stdout; + const activeRequests = new Map(); + const activeControllers = new Set(); + const pending = new Set>(); + const requestKey = (id: string | number): string => `${typeof id}:${String(id)}`; + let transportClosed = false; + let requestTransportTeardown: (() => void) | undefined; const write = (obj: unknown) => { - output.write(JSON.stringify(obj) + "\n"); + if (transportClosed) return; + try { + output.write(JSON.stringify(obj) + "\n"); + } catch { + // Some Writable implementations throw synchronously instead of reporting + // failures through their callback. Route both forms through the same + // teardown so active work is aborted and shutdown remains bounded. + requestTransportTeardown?.(); + } }; const respondOk = (id: string | number | null, result: unknown) => { @@ -97,6 +124,18 @@ export function serveStdio( return; } + // MCP cancellation is a notification aimed at an in-flight request. Handle + // it in the transport so every method automatically receives the same + // AbortSignal and hosts do not need bespoke cancellation handlers. + if (msg.method === "notifications/cancelled") { + const params = msg.params as { requestId?: unknown } | undefined; + const cancelledId = params?.requestId; + if (typeof cancelledId === "string" || typeof cancelledId === "number") { + activeRequests.get(requestKey(cancelledId))?.abort(); + } + return; + } + const handler = handlers[msg.method]; if (!handler) { // Unknown notifications are silently ignored (e.g. notifications/*). @@ -104,40 +143,128 @@ export function serveStdio( return; } + const key = !isNotification && id !== null ? requestKey(id) : undefined; + if (key) { + const duplicate = activeRequests.get(key); + if (duplicate) { + // JSON-RPC ids identify one in-flight request. Never overwrite the + // original controller: abort the ambiguous first request and ignore the + // duplicate, yielding one deterministic cancellation response. + duplicate.abort(); + return; + } + } + const controller = new AbortController(); + activeControllers.add(controller); + if (key) activeRequests.set(key, controller); + const ABORTED = Symbol("aborted"); + let abortListener: (() => void) | undefined; + const aborted = new Promise((resolve) => { + abortListener = () => resolve(ABORTED); + if (controller.signal.aborted) resolve(ABORTED); + else controller.signal.addEventListener("abort", abortListener, { once: true }); + }); + // Invoke immediately so synchronous protocol handlers can complete before a + // following stdin EOF, but normalize throws and async results into one promise. + let handlerPromise: Promise; + try { + handlerPromise = Promise.resolve(handler(msg.params, { requestId: id, signal: controller.signal })); + } catch (error) { + handlerPromise = Promise.reject(error); + } + // Promise.race installs a rejection observer on handlerPromise. If abort wins, + // a later handler rejection is consumed and can never become unhandled. try { - const result = await handler(msg.params); - if (!isNotification) respondOk(id, result); + const result = await Promise.race([handlerPromise, aborted]); + if (!isNotification) { + if (result === ABORTED || controller.signal.aborted) + respondErr(id, { code: RPC.REQUEST_CANCELLED, message: "Request cancelled" }); + else respondOk(id, result); + } } catch (e) { if (isNotification) return; // can't report errors for notifications - if (e instanceof RpcError) { + if (controller.signal.aborted) { + respondErr(id, { code: RPC.REQUEST_CANCELLED, message: "Request cancelled" }); + } else if (e instanceof RpcError) { respondErr(id, { code: e.code, message: e.message, data: e.data }); } else { const message = e instanceof Error ? e.message : String(e); respondErr(id, { code: RPC.INTERNAL_ERROR, message }); } + } finally { + if (abortListener) controller.signal.removeEventListener("abort", abortListener); + activeControllers.delete(controller); + if (key && activeRequests.get(key) === controller) activeRequests.delete(key); } }; return new Promise((resolve) => { let buffer = ""; - // Serialize line handling so responses are emitted in request order even - // when handlers are async (MCP clients tolerate interleaving, but ordered - // output is simpler to reason about and test). - let chain: Promise = Promise.resolve(); - input.on("data", (data: Buffer | string) => { + let finishPromise: Promise | undefined; + let resolved = false; + const track = (promise: Promise) => { + pending.add(promise); + void promise.then( + () => pending.delete(promise), + () => pending.delete(promise), + ); + }; + const removeTransportListeners = () => { + input.removeListener("data", onData); + input.removeListener("end", onEnd); + input.removeListener("close", onClose); + input.removeListener("error", onInputError); + output.removeListener("error", onOutputError); + requestTransportTeardown = undefined; + }; + const finish = (): Promise => { + transportClosed = true; + for (const controller of activeControllers) controller.abort(); + if (!finishPromise) { + finishPromise = new Promise((done) => { + let completed = false; + const settle = () => { + if (completed) return; + completed = true; + clearTimeout(timer); + done(); + }; + const timer = setTimeout(settle, TRANSPORT_SHUTDOWN_GRACE_MS); + void Promise.allSettled([...pending]).then(settle); + }).then(() => { + removeTransportListeners(); + if (!resolved) { + resolved = true; + resolve(); + } + }); + } + return finishPromise; + }; + const teardown = () => void finish(); + const onData = (data: Buffer | string) => { + if (transportClosed) return; buffer += data.toString(); let i: number; while ((i = buffer.indexOf("\n")) >= 0) { const line = buffer.slice(0, i); buffer = buffer.slice(i + 1); - chain = chain.then(() => handleLine(line)); + track(handleLine(line)); } - }); - input.on("end", () => { - chain = chain.then(() => { - if (buffer.trim()) return handleLine(buffer); - }).then(() => resolve()); - }); - input.on("close", () => resolve()); + }; + const onEnd = () => { + if (transportClosed) return; + if (buffer.trim()) track(handleLine(buffer)); + teardown(); + }; + const onClose = teardown; + const onInputError = teardown; + const onOutputError = teardown; + requestTransportTeardown = teardown; + input.on("data", onData); + input.on("end", onEnd); + input.on("close", onClose); + input.on("error", onInputError); + output.on("error", onOutputError); }); } diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index f9bd58d..a1da3a0 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -26,7 +26,7 @@ * - taskflow_why_stale / taskflow_recompute / taskflow_save / taskflow_search */ -import { RpcError, RPC, serveStdio, type RpcHandler } from "./jsonrpc.ts"; +import { RpcError, RPC, serveStdio, type RpcContext, type RpcHandler } from "./jsonrpc.ts"; import { renderFlowSvg, renderFlowOutline, svgToBase64 } from "./svg.ts"; import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -524,12 +524,19 @@ function mkRunState(def: Taskflow, args: Record, cwd: string): export function makeToolHandlers( cwd: string, runner: SubagentRunner, -): Record) => Promise> { +): Record, context?: RpcContext) => Promise> { return { - taskflow_run: async (args) => { + taskflow_run: async (args, context) => { const def = resolveFlow(cwd, args); const v = validateTaskflow(def); if (!v.ok) return textContent(`Flow is invalid:\n- ${v.errors.join("\n- ")}`, true); + const usageAccounting = runner.usageAccounting; + if (def.budget && usageAccounting === "unavailable") { + return textContent( + "This host does not report token or cost usage, so taskflow refuses to run a budgeted flow: the declared ceiling could not be enforced. Remove `budget` only if unmetered execution is intentional, or use a host with usage accounting.", + true, + ); + } // Resolve model roles (e.g. {{fast}} -> a real model id) so the built-in // agents' placeholder models map to something the host can launch. This is @@ -541,6 +548,8 @@ export function makeToolHandlers( cwd, agents, runTask: runner.runTask, + signal: context?.signal, + usageAccounting: usageAccounting === "unavailable" ? "unavailable" : "available", }; const state = mkRunState(def, (args.args as Record) ?? {}, cwd); // Deterministic-replay trace (best-effort, fail-open). @@ -633,7 +642,7 @@ export function makeToolHandlers( return textContent(formatWhyStale(run.runId, run.flowName, reads, seeds, declared)); }, - taskflow_recompute: async (args) => { + taskflow_recompute: async (args, context) => { // MCP exposes recompute as DRY-RUN ONLY (never spends tokens). To actually // re-execute, hosts use the Pi adapter's /tf recompute --apply. const runId = String(args.runId ?? ""); @@ -644,7 +653,7 @@ export function makeToolHandlers( const run = runR.value; const settings = readSubagentSettings(); const { agents } = discoverAgents(cwd, "both", settings.modelRoles, settings.taskflow); - const deps: RuntimeDeps = { cwd, agents, runTask: runner.runTask }; + const deps: RuntimeDeps = { cwd, agents, runTask: runner.runTask, signal: context?.signal }; const { report } = await recomputeTaskflow(run, deps, [phaseId], { dryRun: true }); return textContent(formatRecomputeMcp(report)); }, @@ -839,12 +848,12 @@ export function makeMcpHandlers(cwd: string, runner: SubagentRunner }, ping: () => ({}), "tools/list": () => ({ tools: TOOLS }), - "tools/call": async (params) => { + "tools/call": async (params, context) => { const p = (params ?? {}) as { name?: string; arguments?: Record }; const tool = tools[p.name ?? ""]; if (!tool) throw new RpcError(RPC.INVALID_PARAMS, `Unknown tool: ${p.name}`); void initialized; // tolerant: we don't hard-gate on initialize ordering - return await tool(p.arguments ?? {}); + return await tool(p.arguments ?? {}, context); }, }; } diff --git a/packages/taskflow-mcp-core/test/jsonrpc-cancellation.test.ts b/packages/taskflow-mcp-core/test/jsonrpc-cancellation.test.ts new file mode 100644 index 0000000..6798ad1 --- /dev/null +++ b/packages/taskflow-mcp-core/test/jsonrpc-cancellation.test.ts @@ -0,0 +1,331 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { PassThrough, Writable } from "node:stream"; +import { test } from "node:test"; +import { emptyUsage, type AgentConfig, type SubagentRunner } from "taskflow-core"; +import { makeToolHandlers } from "../src/mcp/server.ts"; +import { serveStdio, TRANSPORT_SHUTDOWN_GRACE_MS, type RpcHandler } from "../src/mcp/jsonrpc.ts"; + +async function resolvesWithin(promise: Promise, ms = 500): Promise { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)), + ]); +} + +async function runRpc( + handlers: Record, + messages: object[], +): Promise>> { + const input = new PassThrough(); + const output = new PassThrough(); + const responses: Array> = []; + const expectedResponses = new Set( + messages + .filter((message) => "id" in message && (message as { id?: unknown }).id != null) + .map((message) => `${typeof (message as { id: unknown }).id}:${String((message as { id: unknown }).id)}`), + ).size; + let ended = false; + let buffer = ""; + output.on("data", (chunk) => { + buffer += chunk.toString(); + let newline: number; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline); + buffer = buffer.slice(newline + 1); + if (line.trim()) { + responses.push(JSON.parse(line) as Record); + if (!ended && responses.length >= expectedResponses) { + ended = true; + input.end(); + } + } + } + }); + const done = serveStdio(handlers, { input, output }); + for (const message of messages) input.write(`${JSON.stringify(message)}\n`); + if (expectedResponses === 0) input.end(); + await done; + return responses; +} + +test("stdio transport dispatches requests concurrently", async () => { + const handlers: Record = { + slow: async () => { + await new Promise((resolve) => setTimeout(resolve, 40)); + return "slow"; + }, + fast: () => "fast", + }; + const responses = await runRpc(handlers, [ + { jsonrpc: "2.0", id: 1, method: "slow" }, + { jsonrpc: "2.0", id: 2, method: "fast" }, + ]); + assert.deepEqual(responses.map((r) => r.id), [2, 1]); +}); + +test("input end bounds a non-cooperative request and suppresses its response", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let signal: AbortSignal | undefined; + let outputText = ""; + output.on("data", (chunk) => (outputText += chunk.toString())); + const done = serveStdio( + { + slow: async (_params, context) => { + signal = context.signal; + return await new Promise(() => {}); + }, + }, + { input, output }, + ); + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 10, method: "slow" })}\n`); + input.end(); + await resolvesWithin(done); + assert.equal(signal?.aborted, true); + assert.equal(outputText, "", "disconnect must suppress cancellation and late responses"); +}); + +test("input end bounds a non-cooperative notification too", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let signal: AbortSignal | undefined; + const done = serveStdio( + { + observe: async (_params, context) => { + signal = context.signal; + return await new Promise(() => {}); + }, + }, + { input, output }, + ); + input.write(`${JSON.stringify({ jsonrpc: "2.0", method: "observe" })}\n`); + input.end(); + await resolvesWithin(done); + assert.equal(signal?.aborted, true); +}); + +test("input close bounds non-cooperative handlers without a late write", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let signal: AbortSignal | undefined; + let outputText = ""; + output.on("data", (chunk) => (outputText += chunk.toString())); + const done = serveStdio( + { + slow: async (_params, context) => { + signal = context.signal; + return await new Promise(() => {}); + }, + }, + { input, output }, + ); + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 12, method: "slow" })}\n`); + input.destroy(); + await resolvesWithin(done); + assert.equal(signal?.aborted, true); + assert.equal(outputText, ""); +}); + +test("output EPIPE tears down the transport and aborts active work", async () => { + const input = new PassThrough(); + const writes: string[] = []; + const output = new Writable({ + write(chunk, _encoding, callback) { + writes.push(chunk.toString()); + const error = Object.assign(new Error("peer closed"), { code: "EPIPE" }); + callback(error); + }, + }); + let slowSignal: AbortSignal | undefined; + let completeSlow: ((value: string) => void) | undefined; + const done = serveStdio( + { + slow: async (_params, context) => { + slowSignal = context.signal; + return await new Promise((resolve) => { + completeSlow = resolve; + }); + }, + trigger: () => "write-now", + }, + { input, output }, + ); + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 20, method: "slow" })}\n`); + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 21, method: "trigger" })}\n`); + await resolvesWithin(done, TRANSPORT_SHUTDOWN_GRACE_MS); + assert.equal(slowSignal?.aborted, true); + assert.equal(writes.length, 1); + assert.equal(output.listenerCount("error"), 0, "transport must remove its output error listener after teardown"); + completeSlow?.("late result"); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(writes.length, 1, "a late handler result must not write after EPIPE"); +}); + +test("input error tears down the transport and aborts active work", async () => { + const input = new PassThrough(); + const output = new PassThrough(); + let signal: AbortSignal | undefined; + let outputText = ""; + output.on("data", (chunk) => (outputText += chunk.toString())); + const done = serveStdio( + { + slow: async (_params, context) => { + signal = context.signal; + return await new Promise(() => {}); + }, + }, + { input, output }, + ); + input.write(`${JSON.stringify({ jsonrpc: "2.0", id: 22, method: "slow" })}\n`); + input.destroy(new Error("input failed")); + await resolvesWithin(done, TRANSPORT_SHUTDOWN_GRACE_MS); + assert.equal(signal?.aborted, true); + assert.equal(outputText, ""); + assert.equal(input.listenerCount("error"), 0, "transport must remove its input error listener after teardown"); +}); + +test("duplicate request ids abort the first controller instead of overwriting it", async () => { + let calls = 0; + let firstSignal: AbortSignal | undefined; + const handlers: Record = { + slow: async (_params, context) => { + calls++; + firstSignal = context.signal; + return await new Promise(() => {}); + }, + }; + const responses = await runRpc(handlers, [ + { jsonrpc: "2.0", id: 11, method: "slow" }, + { jsonrpc: "2.0", id: 11, method: "slow" }, + ]); + assert.equal(calls, 1); + assert.equal(firstSignal?.aborted, true); + assert.equal(responses.length, 1); + assert.equal((responses[0]?.error as { code?: number }).code, -32800); +}); + +test("notifications/cancelled aborts the matching in-flight request", async () => { + let signal: AbortSignal | undefined; + const handlers: Record = { + slow: async (_params, context) => { + signal = context.signal; + return await new Promise(() => {}); + }, + }; + const responses = await runRpc(handlers, [ + { jsonrpc: "2.0", id: "run-1", method: "slow" }, + { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: "run-1", reason: "client timeout" } }, + ]); + assert.equal(signal?.aborted, true); + assert.equal(responses.length, 1); + assert.equal(responses[0]?.id, "run-1"); + assert.equal((responses[0]?.error as { code?: number }).code, -32800); +}); + +test("a handler that returns after abort still receives a cancellation error response", async () => { + let completeLate: ((value: string) => void) | undefined; + const handlers: Record = { + slow: async (_params, context) => + await new Promise((resolve) => { + completeLate = resolve; + void context; + }), + }; + const responses = await runRpc(handlers, [ + { jsonrpc: "2.0", id: 7, method: "slow" }, + { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 7 } }, + ]); + assert.equal((responses[0]?.error as { code?: number }).code, -32800); + assert.equal(responses[0]?.result, undefined); + completeLate?.("late result"); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(responses.length, 1, "late handler completion must not emit a second response"); +}); + +test("a handler that rejects after cancellation is observed, never unhandled", async () => { + let rejectLate: ((reason: Error) => void) | undefined; + let unhandled: unknown; + const observeUnhandled = (reason: unknown) => { + unhandled = reason; + }; + process.once("unhandledRejection", observeUnhandled); + try { + const responses = await runRpc( + { + slow: async () => + await new Promise((_resolve, reject) => { + rejectLate = reject; + }), + }, + [ + { jsonrpc: "2.0", id: 8, method: "slow" }, + { jsonrpc: "2.0", method: "notifications/cancelled", params: { requestId: 8 } }, + ], + ); + assert.equal((responses[0]?.error as { code?: number }).code, -32800); + rejectLate?.(new Error("late rejection")); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(unhandled, undefined); + } finally { + process.removeListener("unhandledRejection", observeUnhandled); + } +}); + +test("taskflow_run forwards the JSON-RPC AbortSignal into the host runner", async () => { + const cwd = await mkdtemp(join(tmpdir(), "taskflow-mcp-signal-")); + try { + const agentDir = join(cwd, ".pi", "agents"); + await mkdir(agentDir, { recursive: true }); + await writeFile( + join(agentDir, "signal-agent.md"), + "---\nname: signal-agent\ndescription: signal test\n---\nYou are a signal test agent.\n", + ); + let seenSignal: AbortSignal | undefined; + const runner: SubagentRunner = { + runTask: async (_cwd, _agents, agent, task, opts) => { + seenSignal = opts.signal; + return { agent, task, exitCode: 0, output: "ok", stderr: "", usage: emptyUsage() }; + }, + }; + const tools = makeToolHandlers(cwd, runner); + const controller = new AbortController(); + const result = (await tools.taskflow_run?.( + { + define: { + name: "signal-flow", + phases: [{ id: "run", type: "agent", agent: "signal-agent", task: "go", final: true }], + }, + }, + { requestId: 1, signal: controller.signal }, + )) as { isError?: boolean }; + assert.equal(result.isError, false); + assert.equal(seenSignal, controller.signal); + } finally { + await rm(cwd, { recursive: true, force: true }); + } +}); + +test("a host without usage accounting refuses budgeted runs before spawning", async () => { + let calls = 0; + const runner: SubagentRunner & { usageAccounting: "unavailable" } = { + usageAccounting: "unavailable", + runTask: async (_cwd, _agents, agent, task) => { + calls++; + return { agent, task, exitCode: 0, output: "unsafe", stderr: "", usage: emptyUsage() }; + }, + }; + const tools = makeToolHandlers(process.cwd(), runner); + const result = (await tools.taskflow_run?.({ + define: { + name: "budgeted-grok", + budget: { maxTokens: 1 }, + phases: [{ id: "run", type: "agent", agent: "executor", task: "go", final: true }], + }, + })) as { isError?: boolean; content?: Array<{ text?: string }> }; + assert.equal(result.isError, true); + assert.match(result.content?.[0]?.text ?? "", /does not report token or cost usage/i); + assert.equal(calls, 0); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f04f89..1391a24 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + postcss: ^8.5.10 + importers: .: @@ -176,8 +179,8 @@ importers: specifier: 4.3.2 version: 4.3.2 typescript: - specifier: 7.0.2 - version: 7.0.2 + specifier: 6.0.3 + version: 6.0.3 packages: @@ -2402,10 +2405,6 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - postcss@8.4.31: - resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.16: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} @@ -5112,7 +5111,7 @@ snapshots: '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.42 caniuse-lite: 1.0.30001802 - postcss: 8.4.31 + postcss: 8.5.16 react: 19.2.7 react-dom: 19.2.7(react@19.2.7) styled-jsx: 5.1.6(react@19.2.7) @@ -5184,12 +5183,6 @@ snapshots: picomatch@4.0.5: {} - postcss@8.4.31: - dependencies: - nanoid: 3.3.15 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.16: dependencies: nanoid: 3.3.15 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e7fb878..67719a3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,8 +12,6 @@ packages: - "packages/grok-taskflow" - "packages/taskflow-dsl" - "website" -overrides: - postcss: ^8.5.10 allowBuilds: "@google/genai": true esbuild: true diff --git a/scripts/copy-readme.mjs b/scripts/copy-readme.mjs index 2c87d27..0b73438 100644 --- a/scripts/copy-readme.mjs +++ b/scripts/copy-readme.mjs @@ -24,6 +24,7 @@ const repoRoot = join(here, ".."); const PUBLISHABLE = new Set([ "taskflow-core", "taskflow-mcp-core", + "taskflow-dsl", "pi-taskflow", "codex-taskflow", "claude-taskflow", diff --git a/scripts/verify-published-package.d.mts b/scripts/verify-published-package.d.mts new file mode 100644 index 0000000..19d3c8d --- /dev/null +++ b/scripts/verify-published-package.d.mts @@ -0,0 +1,13 @@ +export interface RegistryVerificationInput { + pkg: { name: string; version: string }; + metadata: Record; + provenanceStatement: Record; + localIntegrity: string; + trustedOwners: string[]; + expectedRepository: string; + expectedRef: string; + expectedSha?: string; +} + +export function sha512Integrity(data: Uint8Array): string; +export function verifyRegistryIdentity(input: RegistryVerificationInput): string[]; diff --git a/scripts/verify-published-package.mjs b/scripts/verify-published-package.mjs new file mode 100644 index 0000000..e05ab68 --- /dev/null +++ b/scripts/verify-published-package.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const SLSA_V1 = "https://slsa.dev/provenance/v1"; +const GITHUB_ACTIONS_BUILD = "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1"; + +export function sha512Integrity(data) { + return `sha512-${createHash("sha512").update(data).digest("base64")}`; +} + +function maintainerNames(maintainers) { + if (!Array.isArray(maintainers)) return []; + return maintainers + .map((m) => (typeof m === "string" ? m.split(/[ <]/, 1)[0] : m && typeof m.name === "string" ? m.name : "")) + .filter(Boolean); +} + +function integrityHex(integrity) { + const match = /^sha512-(.+)$/.exec(integrity); + return match ? Buffer.from(match[1], "base64").toString("hex") : ""; +} + +export function verifyRegistryIdentity({ + pkg, + metadata, + provenanceStatement, + localIntegrity, + trustedOwners, + expectedRepository, + expectedRef, + expectedSha, +}) { + const errors = []; + if (metadata?.name !== pkg.name || metadata?.version !== pkg.version) { + errors.push(`registry identity is ${metadata?.name ?? "?"}@${metadata?.version ?? "?"}, expected ${pkg.name}@${pkg.version}`); + } + const owners = maintainerNames(metadata?.maintainers); + if (!owners.some((owner) => trustedOwners.includes(owner))) { + errors.push(`no trusted npm owner (${trustedOwners.join(", ")}) in registry maintainers: ${owners.join(", ") || "none"}`); + } + const remoteIntegrity = metadata?.dist?.integrity; + if (typeof remoteIntegrity !== "string" || remoteIntegrity !== localIntegrity) { + errors.push(`tarball integrity mismatch: registry=${remoteIntegrity ?? "missing"} local=${localIntegrity}`); + } + if (metadata?.dist?.attestations?.provenance?.predicateType !== SLSA_V1) { + errors.push("registry metadata has no SLSA v1 provenance attestation"); + } + if (!provenanceStatement || provenanceStatement.predicateType !== SLSA_V1) { + errors.push("SLSA v1 provenance statement is missing or malformed"); + return errors; + } + const workflow = provenanceStatement.predicate?.buildDefinition?.externalParameters?.workflow; + if (provenanceStatement.predicate?.buildDefinition?.buildType !== GITHUB_ACTIONS_BUILD) { + errors.push("provenance was not produced by the GitHub Actions workflow build type"); + } + if (workflow?.repository !== expectedRepository) errors.push(`provenance repository is ${workflow?.repository ?? "missing"}`); + if (workflow?.path !== ".github/workflows/publish.yml") errors.push(`provenance workflow is ${workflow?.path ?? "missing"}`); + if (workflow?.ref !== expectedRef) errors.push(`provenance ref is ${workflow?.ref ?? "missing"}, expected ${expectedRef}`); + const subject = Array.isArray(provenanceStatement.subject) + ? provenanceStatement.subject.find((s) => s?.name === `pkg:npm/${pkg.name}@${pkg.version}`) + : undefined; + const expectedDigest = integrityHex(localIntegrity); + if (!subject || subject.digest?.sha512 !== expectedDigest) errors.push("provenance subject digest does not match the local tarball"); + if (expectedSha) { + const dependencies = provenanceStatement.predicate?.buildDefinition?.resolvedDependencies; + const commit = Array.isArray(dependencies) ? dependencies.find((d) => d?.digest?.gitCommit)?.digest?.gitCommit : undefined; + if (commit !== expectedSha) errors.push(`provenance commit is ${commit ?? "missing"}, expected ${expectedSha}`); + } + return errors; +} + +function packIntegrity(packageDir) { + const destination = mkdtempSync(join(tmpdir(), "taskflow-pack-verify-")); + try { + const output = execFileSync("pnpm", ["--dir", packageDir, "pack", "--pack-destination", destination, "--json"], { + encoding: "utf8", + }); + const packed = JSON.parse(output); + if (!packed?.filename) throw new Error(`pnpm pack did not report a filename for ${packageDir}`); + return sha512Integrity(readFileSync(packed.filename)); + } finally { + rmSync(destination, { recursive: true, force: true }); + } +} + +async function fetchProvenanceStatement(url) { + if (!url) throw new Error("registry did not provide an attestation URL"); + const response = await fetch(url); + if (!response.ok) throw new Error(`attestation fetch failed: HTTP ${response.status}`); + const body = await response.json(); + const attestation = body?.attestations?.find((entry) => entry?.predicateType === SLSA_V1); + const payload = attestation?.bundle?.dsseEnvelope?.payload; + if (typeof payload !== "string") throw new Error("SLSA provenance bundle has no DSSE payload"); + return JSON.parse(Buffer.from(payload, "base64").toString("utf8")); +} + +async function main() { + const packageDir = resolve(process.argv[2] ?? ""); + if (!process.argv[2]) throw new Error("usage: verify-published-package.mjs "); + const pkg = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); + const spec = `${pkg.name}@${pkg.version}`; + const metadata = JSON.parse(execFileSync("npm", ["view", spec, "--json"], { encoding: "utf8" })); + const localIntegrity = packIntegrity(packageDir); + const provenanceStatement = await fetchProvenanceStatement(metadata?.dist?.attestations?.url); + const trustedOwners = (process.env.NPM_TRUSTED_OWNERS ?? "heggria,muyun") + .split(",") + .map((v) => v.trim()) + .filter(Boolean); + const expectedRepository = process.env.PUBLISH_REPOSITORY_URL ?? "https://github.com/heggria/taskflow"; + const expectedRef = `refs/tags/v${pkg.version}`; + const errors = verifyRegistryIdentity({ + pkg, + metadata, + provenanceStatement, + localIntegrity, + trustedOwners, + expectedRepository, + expectedRef, + expectedSha: process.env.GITHUB_SHA, + }); + if (errors.length) throw new Error(`${spec} failed published-package verification:\n- ${errors.join("\n- ")}`); + process.stdout.write(`verified ${spec}: owner + provenance + integrity (${basename(packageDir)})\n`); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main().catch((error) => { + process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); + process.exitCode = 1; + }); +} diff --git a/skills-src/taskflow/configuration.md b/skills-src/taskflow/configuration.md index 54e7283..af11a9e 100644 --- a/skills-src/taskflow/configuration.md +++ b/skills-src/taskflow/configuration.md @@ -257,9 +257,16 @@ Notes: - 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). + configured default. Effective thinking maps to `--reasoning-effort` (`off` + → `none`). Read-only phases get Grok's kernel-enforced `--sandbox + read-only`, a known-good `--tools + read_file,grep,list_dir` allowlist plus independent mutator/MCP deny rules and + no subagents; web tools are omitted because Grok 0.2.93 can fail open on those + allowlist ids. `--always-approve` then applies only to surviving tools. Grok + mutating/no-whitelist phases use kernel-enforced `--sandbox workspace` plus + `--always-approve`, keeping writes inside the phase cwd and Grok's documented + temp/session paths. Grok 0.2.93 reports no usage, so its MCP adapter rejects + flows with `budget` rather than pretending to enforce an unobservable ceiling. - The agent's markdown body becomes the subagent's appended system prompt. @@ -467,9 +474,9 @@ node --conditions=development --experimental-strip-types \ # edit audit.tf.ts node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts check audit.tf.ts -# optional full tsc Program pass: +# Fast rune/static-only pass (skip the default full tsc Program check): node --conditions=development --experimental-strip-types \ - packages/taskflow-dsl/src/cli.ts check audit.tf.ts --typecheck + packages/taskflow-dsl/src/cli.ts check audit.tf.ts --no-typecheck node --conditions=development --experimental-strip-types \ packages/taskflow-dsl/src/cli.ts build audit.tf.ts --emit both # → audit.taskflow.json (+ audit.flowir.json) @@ -479,7 +486,7 @@ node --conditions=development --experimental-strip-types \ | Command | Purpose | |---------|---------| | `new [name]` | ≤5-line hello skeleton (`.tf.ts` or `--json-escape` JSON) | -| `check ` | Erase + `validateTaskflow` (add `--typecheck` for tsc) | +| `check ` | Erase + `validateTaskflow` + tsc (use `--no-typecheck` for a faster static-only pass) | | `build ` | Erase → Taskflow JSON; optional FlowIR hash (`--emit taskflow\|flowir\|both`) | | `decompile ` | Taskflow JSON → readable `.tf.ts` (semantic, not literal) | diff --git a/website/content/docs/en/guides/background-runs.mdx b/website/content/docs/en/guides/background-runs.mdx index 98d8349..b9b5c0b 100644 --- a/website/content/docs/en/guides/background-runs.mdx +++ b/website/content/docs/en/guides/background-runs.mdx @@ -47,18 +47,13 @@ Taskflow 'audit' started in background (pid: 42871). Run id: 2026-07-06T14-32-01 That `runId` is your handle. The `pid` is the OS process id of the detached child — useful if you need to inspect or signal it directly. -### On Codex, Claude Code, OpenCode +### On MCP hosts -The MCP server exposes the same `detach` parameter on the `taskflow_run` tool. The mechanics are identical: the server spawns a child process and returns the `runId` in the tool result. - -```json title="MCP tool call" -{ - "action": "run", - "name": "audit", - "args": { "dir": "src" }, - "detach": true -} -``` +Codex, Claude Code, OpenCode, and Grok Build do **not** expose `detach` on +`taskflow_run`; detached execution is currently Pi-only. MCP runs are +synchronous. When an MCP client cancels a request, taskflow propagates that +cancellation to the DAG and active subagent instead of continuing as an +untracked background run. ## What happens under the hood diff --git a/website/content/docs/en/guides/claude-code.mdx b/website/content/docs/en/guides/claude-code.mdx index 3a59212..e0eb1f6 100644 --- a/website/content/docs/en/guides/claude-code.mdx +++ b/website/content/docs/en/guides/claude-code.mdx @@ -176,7 +176,7 @@ Omit `phaseId` to list every phase with its status and output size. Output is ha ## Approvals in MCP mode -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. +MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fail-closed for the approval decision). Prefer a `gate` (agent review) in flows you run through the `taskflow_*` tools; use `approval` only in flows a human runs interactively. ## Tool reference diff --git a/website/content/docs/en/guides/codex.mdx b/website/content/docs/en/guides/codex.mdx index cfa69f0..dc4e84a 100644 --- a/website/content/docs/en/guides/codex.mdx +++ b/website/content/docs/en/guides/codex.mdx @@ -142,7 +142,7 @@ tool_timeout_sec = 3600 ``` - Don't set this timeout lower than your longest expected run. If the tool call times out, the run keeps executing in the background — but Codex will have already given up on the result. + Don't set this timeout lower than your longest expected run. Codex sends MCP cancellation when it gives up; taskflow propagates that cancellation to the DAG and active subagent. MCP does not turn the request into a detached background run. For genuinely long work, consider splitting the flow: run the heavy phases in a saved flow, and use a downstream `approval` or `gate` phase to pause for review. (Resuming a paused run by `runId` is a Pi-only capability — `/tf resume`. On Codex there is no `taskflow_resume` MCP tool, so a paused run is continued by re-running the flow; phases with `cache.scope: "cross-run"` are reused automatically.) diff --git a/website/content/docs/en/guides/grok-build.mdx b/website/content/docs/en/guides/grok-build.mdx index 7c5b5ba..b0da6bd 100644 --- a/website/content/docs/en/guides/grok-build.mdx +++ b/website/content/docs/en/guides/grok-build.mdx @@ -142,20 +142,22 @@ Each phase's subagent runs as an isolated `grok -p --output-format streaming-jso | 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"`. | +| Read-only (no write/edit/bash) | kernel-enforced `--sandbox read-only` + known-good `--tools read_file,grep,list_dir` + mutator denylist + `--deny Bash/Edit/Write/MCPTool` + `--no-subagents` + `--always-approve` | Fail-closed even for a workspace under `/tmp` (which the built-in sandbox permits for session files). Web tools are disabled because Grok 0.2.93 treats them as unmappable allowlist entries. | +| Mutating (or no whitelist) | kernel-enforced `--sandbox workspace` + `--always-approve` | Full tools, but writes are confined to the phase cwd plus Grok's documented temp/session paths. Prefer `cwd: "worktree"`. | - Mutating phases use `--always-approve` with no OS sandbox. Only run flows you trust, preferably in a throwaway worktree. + The workspace sandbox still allows reads everywhere, network access, and writes to temp directories and `~/.grok/`. Use a throwaway worktree when filesystem changes should be disposable. +Effective `thinking` maps to Grok's `--reasoning-effort` flag (`off` maps to `none`). Grok 0.2.93 emits no token/cost usage in `streaming-json`, so the Grok MCP adapter refuses flows declaring `budget` rather than silently ignoring the ceiling. + ## 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`. +**`taskflow_run` is synchronous** — the tool call returns only when the whole DAG finishes. The plugin ships `tool_timeout_sec: 1800`; MCP does not expose Pi's detached mode. Split huge graphs. If the client cancels a request, the server aborts the active DAG and subagent instead of leaving hidden background work. ## Approvals in MCP mode -MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fail-open). Prefer a `gate` for agent review. +MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fail-closed for the approval decision). Prefer a `gate` for agent review. ## Tool reference diff --git a/website/content/docs/en/guides/opencode.mdx b/website/content/docs/en/guides/opencode.mdx index 02c194a..fcfb677 100644 --- a/website/content/docs/en/guides/opencode.mdx +++ b/website/content/docs/en/guides/opencode.mdx @@ -194,7 +194,7 @@ Omit `phaseId` to list every phase with its status and output size. Output is ha ## Approvals in MCP mode -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. +MCP-driven runs are non-interactive, so an `approval` phase **auto-rejects** (fail-closed for the approval decision). Prefer a `gate` (agent review) in flows you run through the `taskflow_*` tools; use `approval` only in flows a human runs interactively. ## Tool reference diff --git a/website/content/docs/zh-cn/guides/background-runs.mdx b/website/content/docs/zh-cn/guides/background-runs.mdx index fcf3b28..d6e63a5 100644 --- a/website/content/docs/zh-cn/guides/background-runs.mdx +++ b/website/content/docs/zh-cn/guides/background-runs.mdx @@ -5,7 +5,7 @@ description: 启动长时间运行的流程并立即返回提示符。通过 run 普通的 `taskflow` 调用会阻塞你的会话,直到所有阶段完成。一个三阶段的代码审计如果耗时八分钟,你的提示符就冻结八分钟。 -后台运行解决了这个问题。设置 `detach: true`,MCP 工具在一秒内返回一个 `runId`。一个子进程接管工作,在每个阶段完成后持久化进度,并在结束时写入最终状态——或者在崩溃时写入失败状态。你可以随时从磁盘轮询运行状态来获取进度更新,也可以在中途失败后恢复它。 +后台运行解决了这个问题。在 **Pi 的 `taskflow` 工具**上设置 `detach: true`,工具在一秒内返回一个 `runId`。一个子进程接管工作,在每个阶段完成后持久化进度,并在结束时写入最终状态——或者在崩溃时写入失败状态。你可以随时从磁盘轮询运行状态来获取进度更新,也可以在中途失败后恢复它。Codex、Claude Code、OpenCode 和 Grok Build 的 MCP `taskflow_run` 目前不支持 detach;MCP 取消会中止 DAG,不会转为后台运行。 本指南详细说明具体的 spawn 机制、如何检查后台运行的状态、审批(approval)阶段会发生什么,以及无头运行的限制。 @@ -43,7 +43,7 @@ Taskflow 'code-audit' started in background (pid: 48231). Run id: 20260706T14302 **生成子进程。** 宿主 fork `node `,使用 `detached: true` 和 `stdio: ["ignore", "ignore", "pipe"]`。stdout 被忽略;stderr 被管道回父进程以捕获崩溃诊断信息。子进程立即 `unref()`——它不会保持父进程的事件循环存活。 - **父进程返回 runId。** 子进程的 PID 写入 `RunState.pid`,MCP 工具响应同时包含 PID 和 runId。父会话可以继续其他工作。 + **父进程返回 runId。** 子进程的 PID 写入 `RunState.pid`,Pi `taskflow` 工具响应同时包含 PID 和 runId。父会话可以继续其他工作。 **子进程启动并运行流程。** 分离运行器读取上下文文件,从磁盘重新发现 agent 配置,动态导入宿主适配器的运行模块,然后调用 `executeTaskflow`。每个阶段完成后通过同一个 `saveRun` 路径持久化进度。 @@ -859,4 +859,4 @@ Seeds are always re-executed (`forceRerun: true`), even if their task text is un Within-run resume for interrupted flows. - \ No newline at end of file + diff --git a/website/content/docs/zh-cn/guides/claude-code.mdx b/website/content/docs/zh-cn/guides/claude-code.mdx index 90e35d9..b869403 100644 --- a/website/content/docs/zh-cn/guides/claude-code.mdx +++ b/website/content/docs/zh-cn/guides/claude-code.mdx @@ -176,7 +176,7 @@ claude -p "run the nightly-audit taskflow" & ## MCP 模式下的审批 -MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝**(fail-open)。在你通过 `taskflow_*` 工具运行的 flow 中,优先使用 `gate`(agent 审查);`approval` 只用在人工交互运行的 flow 中。 +MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝**(对审批决策 fail-closed)。在你通过 `taskflow_*` 工具运行的 flow 中,优先使用 `gate`(agent 审查);`approval` 只用在人工交互运行的 flow 中。 ## 工具参考 diff --git a/website/content/docs/zh-cn/guides/grok-build.mdx b/website/content/docs/zh-cn/guides/grok-build.mdx index b4ce14e..23a65f5 100644 --- a/website/content/docs/zh-cn/guides/grok-build.mdx +++ b/website/content/docs/zh-cn/guides/grok-build.mdx @@ -95,16 +95,20 @@ TUI 里可用 `/plugins`、`/mcps` 打开同一扩展面板。 | Phase tools | 行为 | |---|---| -| 只读 | `--tools read_file,grep,list_dir,web_search,web_fetch` + `--always-approve` | -| 可写 / 无白名单 | `--always-approve`(**无 OS sandbox**,优先 `cwd: "worktree"`) | +| 只读 | 内核强制的 `--sandbox read-only` + 经 Grok 0.2.93 实测的 `--tools read_file,grep,list_dir` + 可变更工具拒绝列表 + `--deny Bash/Edit/Write/MCPTool` + `--no-subagents` + `--always-approve` | +| 可写 / 无白名单 | 内核强制的 `--sandbox workspace` + `--always-approve`;可使用全量工具,但写入仅限 phase cwd 与 Grok 约定的临时目录 / `~/.grok/`。优先 `cwd: "worktree"` | + +Grok 0.2.93 会把 `web_search` / `web_fetch` 报为无法映射的 allowlist 项,并可能回退到全量工具;因此只读 phase 中暂时禁用这两个 web 工具,并用独立 deny 规则做二次保护。`thinking` 会映射到 `--reasoning-effort`(`off` → `none`)。 + +Grok 0.2.93 的 `streaming-json` 不返回 token / cost usage,所以 Grok MCP 适配器会**拒绝声明了 `budget` 的 flow**,而不是静默忽略上限。 ## 长时 flow -**`taskflow_run` 同步返回**——整图跑完才结束。插件默认 `tool_timeout_sec: 1800`。大图请拆分,或事后用 `taskflow_peek`。 +**`taskflow_run` 同步返回**——整图跑完才结束。插件默认 `tool_timeout_sec: 1800`;MCP 不暴露 Pi 的 detach 模式。客户端取消请求时,服务器会中止正在运行的 DAG 和子 agent,不会留下隐藏的后台工作。 ## MCP 下的 approval -非交互运行时 `approval` 会 **auto-reject**。agent 评审请用 `gate`。 +非交互运行时 `approval` 会 **auto-reject**(对审批决策 fail-closed)。agent 评审请用 `gate`。 ## 手动注册 MCP diff --git a/website/content/docs/zh-cn/guides/opencode.mdx b/website/content/docs/zh-cn/guides/opencode.mdx index 6c4ed01..a47647b 100644 --- a/website/content/docs/zh-cn/guides/opencode.mdx +++ b/website/content/docs/zh-cn/guides/opencode.mdx @@ -194,7 +194,7 @@ opencode run "run the nightly-audit taskflow" & ## MCP 模式下的审批 -MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝**(fail-open)。在你通过 `taskflow_*` 工具运行的 flow 中,优先使用 `gate`(agent 审查);`approval` 只用在人工交互运行的 flow 中。 +MCP 驱动的运行是非交互的,所以 `approval` 阶段会**自动拒绝**(对审批决策 fail-closed)。在你通过 `taskflow_*` 工具运行的 flow 中,优先使用 `gate`(agent 审查);`approval` 只用在人工交互运行的 flow 中。 ## 工具参考 diff --git a/website/next.config.ts b/website/next.config.ts index b5e60a9..0318855 100644 --- a/website/next.config.ts +++ b/website/next.config.ts @@ -1,5 +1,9 @@ import { createMDX } from 'fumadocs-mdx/next'; import type { NextConfig } from 'next'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const workspaceRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const withMDX = createMDX({ configPath: './source.config.ts', @@ -12,6 +16,9 @@ const nextConfig: NextConfig = { images: { unoptimized: true, }, + turbopack: { + root: workspaceRoot, + }, trailingSlash: true, }; diff --git a/website/package.json b/website/package.json index dab19b3..341d9ce 100644 --- a/website/package.json +++ b/website/package.json @@ -29,6 +29,6 @@ "@types/react": "19.2.17", "@types/react-dom": "19.2.3", "tailwindcss": "4.3.2", - "typescript": "7.0.2" + "typescript": "6.0.3" } } From ab6c63bec5b651a623af15da3514f3813965d705 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 10 Jul 2026 13:40:28 +0800 Subject: [PATCH 39/51] fix(kernel): make placeholder scans linear Replace polynomial regular expressions on untrusted flow strings with a linear scanner that distinguishes Taskflow placeholders from static JSON. --- packages/taskflow-core/src/exec/driver.ts | 9 ++++- .../taskflow-core/src/exec/kernel-policy.ts | 38 ++++++++++++++++++- .../test/runtime-closure-0.2.0.test.ts | 21 +++++++++- 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index ee81699..c391d31 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -26,7 +26,12 @@ import { foldEvents } from "./fold.ts"; import { EVENT_SCHEMA_VERSION, type Event } from "./events.ts"; import type { AgentConfig } from "../agents.ts"; import type { UsageStats } from "../usage.ts"; -import { clampSubFlowBudget, depsSatisfied, kernelUnsupportedReason } from "./kernel-policy.ts"; +import { + clampSubFlowBudget, + containsInterpolationPlaceholder, + depsSatisfied, + kernelUnsupportedReason, +} from "./kernel-policy.ts"; export { EVENT_KERNEL_PHASE_TYPES, kernelUnsupportedReason }; @@ -74,7 +79,7 @@ export function canUseEventKernel( if (raw !== undefined) { if (typeof raw === "string") { // Interpolated/runtime-generated definitions cannot be admitted statically. - if (/\{[^}]+\}/.test(raw)) return false; + if (containsInterpolationPlaceholder(raw)) return false; try { const parsed = JSON.parse(raw) as unknown; if (Array.isArray(parsed)) child = { name: `${phase.id}-inline`, phases: parsed as Taskflow["phases"] }; diff --git a/packages/taskflow-core/src/exec/kernel-policy.ts b/packages/taskflow-core/src/exec/kernel-policy.ts index 48eb9ba..d58c876 100644 --- a/packages/taskflow-core/src/exec/kernel-policy.ts +++ b/packages/taskflow-core/src/exec/kernel-policy.ts @@ -9,6 +9,42 @@ import { emptyUsage, type UsageStats } from "../usage.ts"; import type { RunState } from "../store.ts"; import { overBudget } from "../deterministic.ts"; +/** Linear-time placeholder detection for untrusted DSL strings. + * + * A placeholder body is a non-empty Taskflow-style path made only of the + * identifier/path characters accepted by interpolation. Resetting on a nested + * opening brace keeps the scan O(n), even for adversarial strings containing + * thousands of `{` characters. JSON object braces are ignored because their + * bodies contain quotes, colons, commas, or whitespace. */ +export function containsInterpolationPlaceholder(value: string): boolean { + let open = false; + let valid = false; + let length = 0; + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code === 123) { // { + open = true; + valid = true; + length = 0; + continue; + } + if (!open) continue; + if (code === 125) { // } + if (valid && length > 0) return true; + open = false; + continue; + } + length++; + const allowed = + (code >= 48 && code <= 57) || // 0-9 + (code >= 65 && code <= 90) || // A-Z + (code >= 97 && code <= 122) || // a-z + code === 45 || code === 46 || code === 95; // - . _ + if (!allowed) valid = false; + } + return false; +} + /** * If the definition needs imperative-only features, return a short reason. * When set, executeTaskflow must NOT enter the event kernel (even if enabled). @@ -58,7 +94,7 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { if ((p.type ?? "agent") === "script" && p.input !== undefined) { return `phase '${id}': script stdin input requires the imperative runtime`; } - if ((p.type ?? "agent") === "script" && Array.isArray(p.run) && p.run.some((arg) => /\{[^}]+\}/.test(arg))) { + if ((p.type ?? "agent") === "script" && Array.isArray(p.run) && p.run.some(containsInterpolationPlaceholder)) { return `phase '${id}': interpolated script argv requires the imperative runtime`; } } diff --git a/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts b/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts index 595bcc9..8259cf9 100644 --- a/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts +++ b/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts @@ -6,7 +6,7 @@ import { test } from "node:test"; import type { AgentConfig } from "../src/agents.ts"; import { CacheStore } from "../src/cache.ts"; import { canUseEventKernel, kernelUnsupportedReason } from "../src/exec/driver.ts"; -import { clampSubFlowBudget } from "../src/exec/kernel-policy.ts"; +import { clampSubFlowBudget, containsInterpolationPlaceholder } from "../src/exec/kernel-policy.ts"; import { compileTaskflowToIR } from "../src/flowir/index.ts"; import { queueSpawn } from "../src/context-store.ts"; import type { RunOptions, RunResult } from "../src/host/runner-types.ts"; @@ -86,6 +86,25 @@ test("kernel admission safely falls back for every currently unsupported semanti } }); +test("kernel placeholder detection is linear and distinguishes static JSON", () => { + assert.equal(containsInterpolationPlaceholder("{args.topic}"), true); + assert.equal(containsInterpolationPlaceholder("prefix {steps.make.json.value} suffix"), true); + assert.equal(containsInterpolationPlaceholder('{"name":"child","phases":[]}'), false); + assert.equal(containsInterpolationPlaceholder("{".repeat(200_000)), false); + assert.equal( + canUseEventKernel({ + name: "static-string-child", + phases: [{ + id: "child", + type: "flow", + def: JSON.stringify({ name: "nested", phases: [{ id: "run", type: "agent", task: "ok", final: true }] }), + final: true, + }], + }), + true, + ); +}); + test("kernel opt-in falls back to imperative for script stdin and interpolated argv", async () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "tf-kernel-fallback-")); try { From ac91a75bf0b60ab826e8d5d16c39962583883d59 Mon Sep 17 00:00:00 2001 From: heggria Date: Fri, 10 Jul 2026 17:37:00 +0800 Subject: [PATCH 40/51] docs(website): restructure docs IA and promote compiler-runtime to 0.2 entry --- pnpm-lock.yaml | 3 + website/app/[lang]/docs/[[...slug]]/page.tsx | 34 +- website/app/[lang]/docs/layout.tsx | 43 +- .../docs/en/blog/claude-code-mcp-workflow.mdx | 4 +- website/content/docs/en/blog/index.mdx | 2 +- .../en/blog/orchestrate-codex-subagents.mdx | 4 +- .../docs/en/blog/what-is-a-task-graph.mdx | 2 +- .../en/comparisons/taskflow-vs-langgraph.mdx | 2 +- .../en/comparisons/taskflow-vs-workflow.mdx | 2 +- .../en/compiler-runtime/background-runs.mdx | 299 ++++++ .../compiler-runtime/deterministic-replay.mdx | 57 ++ .../incremental-recompute.mdx | 512 +++++++++++ .../docs/en/compiler-runtime/index.mdx | 77 ++ .../docs/en/compiler-runtime/meta.json | 10 + .../en/compiler-runtime/typescript-dsl.mdx | 81 ++ .../docs/en/concepts/deterministic-replay.mdx | 56 +- website/content/docs/en/concepts/index.mdx | 58 +- website/content/docs/en/concepts/meta.json | 14 + website/content/docs/en/concepts/resume.mdx | 4 +- .../docs/en/guides/background-runs.mdx | 298 +----- website/content/docs/en/guides/index.mdx | 59 +- website/content/docs/en/guides/meta.json | 21 + website/content/docs/en/guides/templates.mdx | 440 +++++++++ website/content/docs/en/index.mdx | 131 ++- website/content/docs/en/meta.json | 66 +- .../content/docs/en/reference/commands.mdx | 5 +- .../en/reference/incremental-recompute.mdx | 511 +---------- website/content/docs/en/reference/index.mdx | 28 +- website/content/docs/en/reference/meta.json | 4 + .../docs/en/reference/typescript-dsl.mdx | 65 +- website/content/docs/en/showcase/meta.json | 4 + website/content/docs/en/syntax/meta.json | 12 + website/content/docs/en/templates.mdx | 439 +-------- .../zh-cn/blog/claude-code-mcp-workflow.mdx | 4 +- website/content/docs/zh-cn/blog/index.mdx | 2 +- .../blog/orchestrate-codex-subagents.mdx | 4 +- .../docs/zh-cn/blog/what-is-a-task-graph.mdx | 2 +- .../comparisons/taskflow-vs-langgraph.mdx | 2 +- .../comparisons/taskflow-vs-workflow.mdx | 2 +- .../compiler-runtime/background-runs.mdx | 277 ++++++ .../compiler-runtime/deterministic-replay.mdx | 55 ++ .../incremental-recompute.mdx | 513 +++++++++++ .../docs/zh-cn/compiler-runtime/index.mdx | 77 ++ .../docs/zh-cn/compiler-runtime/meta.json | 10 + .../zh-cn/compiler-runtime/typescript-dsl.mdx | 80 ++ .../zh-cn/concepts/deterministic-replay.mdx | 54 +- website/content/docs/zh-cn/concepts/index.mdx | 58 +- website/content/docs/zh-cn/concepts/meta.json | 14 + .../content/docs/zh-cn/concepts/resume.mdx | 2 +- .../zh-cn/concepts/workspace-isolation.mdx | 369 -------- .../docs/zh-cn/guides/background-runs.mdx | 861 +----------------- website/content/docs/zh-cn/guides/index.mdx | 59 +- website/content/docs/zh-cn/guides/meta.json | 21 + .../content/docs/zh-cn/guides/templates.mdx | 435 +++++++++ website/content/docs/zh-cn/index.mdx | 131 ++- website/content/docs/zh-cn/meta.json | 66 +- .../content/docs/zh-cn/reference/commands.mdx | 2 +- .../zh-cn/reference/incremental-recompute.mdx | 512 +---------- .../content/docs/zh-cn/reference/index.mdx | 28 +- .../content/docs/zh-cn/reference/meta.json | 4 + .../docs/zh-cn/reference/typescript-dsl.mdx | 64 +- website/content/docs/zh-cn/showcase/meta.json | 4 + website/content/docs/zh-cn/syntax/meta.json | 12 + website/content/docs/zh-cn/templates.mdx | 434 +-------- website/package.json | 66 +- website/scripts/check-docs.mjs | 82 ++ website/source.config.ts | 9 + 67 files changed, 3609 insertions(+), 4053 deletions(-) create mode 100644 website/content/docs/en/compiler-runtime/background-runs.mdx create mode 100644 website/content/docs/en/compiler-runtime/deterministic-replay.mdx create mode 100644 website/content/docs/en/compiler-runtime/incremental-recompute.mdx create mode 100644 website/content/docs/en/compiler-runtime/index.mdx create mode 100644 website/content/docs/en/compiler-runtime/meta.json create mode 100644 website/content/docs/en/compiler-runtime/typescript-dsl.mdx create mode 100644 website/content/docs/en/concepts/meta.json create mode 100644 website/content/docs/en/guides/meta.json create mode 100644 website/content/docs/en/guides/templates.mdx create mode 100644 website/content/docs/en/reference/meta.json create mode 100644 website/content/docs/en/showcase/meta.json create mode 100644 website/content/docs/en/syntax/meta.json create mode 100644 website/content/docs/zh-cn/compiler-runtime/background-runs.mdx create mode 100644 website/content/docs/zh-cn/compiler-runtime/deterministic-replay.mdx create mode 100644 website/content/docs/zh-cn/compiler-runtime/incremental-recompute.mdx create mode 100644 website/content/docs/zh-cn/compiler-runtime/index.mdx create mode 100644 website/content/docs/zh-cn/compiler-runtime/meta.json create mode 100644 website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx create mode 100644 website/content/docs/zh-cn/concepts/meta.json create mode 100644 website/content/docs/zh-cn/guides/meta.json create mode 100644 website/content/docs/zh-cn/guides/templates.mdx create mode 100644 website/content/docs/zh-cn/reference/meta.json create mode 100644 website/content/docs/zh-cn/showcase/meta.json create mode 100644 website/content/docs/zh-cn/syntax/meta.json create mode 100644 website/scripts/check-docs.mjs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1391a24..db14fd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -181,6 +181,9 @@ importers: typescript: specifier: 6.0.3 version: 6.0.3 + zod: + specifier: ^4.4.3 + version: 4.4.3 packages: diff --git a/website/app/[lang]/docs/[[...slug]]/page.tsx b/website/app/[lang]/docs/[[...slug]]/page.tsx index d26c0c2..09fc7ae 100644 --- a/website/app/[lang]/docs/[[...slug]]/page.tsx +++ b/website/app/[lang]/docs/[[...slug]]/page.tsx @@ -6,7 +6,7 @@ import { PageLastUpdate, } from "fumadocs-ui/layouts/docs/page"; import { MessageSquare, Pencil } from "lucide-react"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { getMDXComponents } from "@/components/mdx"; import type { Locale } from "@/lib/i18n"; import { source } from "@/lib/source"; @@ -100,6 +100,22 @@ export default async function Page({ const { lang, slug } = await params; const page = source.getPage(slug, lang); if (!page) notFound(); + if (page.data.redirect) { + const redirectUrl = `${SITE_URL}${page.data.redirect}`; + return ( + <> + +
+

+ {lang === "zh-cn" ? "页面已移动:" : "This page has moved to "} + + {redirectUrl} + +

+
+ + ); + } const MDX = page.data.body; @@ -174,10 +190,14 @@ export default async function Page({ <> sourceLiteral(item, seen)).join(", ")}]`; + const proto = Object.getPrototypeOf(value); + if (proto !== Object.prototype && proto !== null) { + throw new Error("TFDSL_DECOMPILE_UNSUPPORTED: non-plain object cannot be emitted"); + } + const entries = Object.entries(value); + if (entries.some(([key]) => key === "__proto__")) { + throw new Error("TFDSL_DECOMPILE_UNSUPPORTED: '__proto__' object keys cannot round-trip safely"); + } + return `{ ${entries + .map(([key, item]) => `${sourceLiteral(key, seen)}: ${sourceLiteral(item, seen)}`) + .join(", ")} }`; + } finally { + seen.delete(value); + } } const RESERVED = new Set([ @@ -34,11 +85,21 @@ function allocateBindings(phases: readonly Phase[]): Map { return out; } -function safeIdent(name: string, role: string): string { - if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) || RESERVED.has(name)) { - throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: ${role} ${JSON.stringify(name)} is not a safe identifier`); +function branchSource(branch: Record, role: string): string { + const unsupported = Object.keys(branch).filter((key) => key !== "task" && key !== "agent"); + if (unsupported.length > 0) { + throw new Error( + `TFDSL_DECOMPILE_UNSUPPORTED: ${role} contains unsupported branch option(s): ${unsupported.join(", ")}; only task and agent are runtime-supported`, + ); + } + if (typeof branch.task !== "string") { + throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: ${role}.task must be a string`); + } + if (branch.agent !== undefined && typeof branch.agent !== "string") { + throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: ${role}.agent must be a string`); } - return name; + const branchOpts = branch.agent === undefined ? "" : `, { agent: ${sourceLiteral(branch.agent)} }`; + return `agent(${sourceLiteral(branch.task)}${branchOpts})`; } const DECOMPILABLE = new Set([ @@ -61,7 +122,7 @@ export function decompileTaskflow(def: Taskflow): string { const type = p.type ?? "agent"; if (!DECOMPILABLE.has(type)) { throw new Error( - `TFDSL_DECOMPILE_UNSUPPORTED: phase type ${JSON.stringify(p.type)} (id=${p.id}) cannot be decompiled in MVP`, + `TFDSL_DECOMPILE_UNSUPPORTED: phase type ${sourceLiteral(p.type)} (id=${p.id}) cannot be decompiled in MVP`, ); } } @@ -98,17 +159,17 @@ export function decompileTaskflow(def: Taskflow): string { const value = (def as unknown as Record)[key]; if (value !== undefined) flowOpts[key] = value; } - const flowOptText = Object.keys(flowOpts).length ? `, ${JSON.stringify(flowOpts)}` : ""; - lines.push(`export default flow(${JSON.stringify(def.name)}${flowOptText}, (ctx) => {`); + const flowOptText = Object.keys(flowOpts).length ? `, ${sourceLiteral(flowOpts)}` : ""; + lines.push(`export default flow(${sourceLiteral(def.name)}${flowOptText}, (ctx) => {`); if (def.budget) { - lines.push(` ctx.budget(${JSON.stringify(def.budget)});`); + lines.push(` ctx.budget(${sourceLiteral(def.budget)});`); } if (typeof def.concurrency === "number") { lines.push(` ctx.concurrency(${def.concurrency});`); } if (def.args && typeof def.args === "object") { - lines.push(` ctx.args.declare(${JSON.stringify(def.args)});`); + lines.push(` ctx.args.declare(${sourceLiteral(def.args)});`); } const byId = new Map((def.phases ?? []).map((p) => [p.id, p])); @@ -133,17 +194,17 @@ export function decompileTaskflow(def: Taskflow): string { function decompilePhase(p: Phase, bind: string, _byId: Map, bindings: Map): string { const opts: string[] = []; - if (bind !== p.id) opts.push(`id: ${JSON.stringify(p.id)}`); + if (bind !== p.id) opts.push(`id: ${sourceLiteral(p.id)}`); const raw = p as unknown as Record; for (const key of [ "agent", "model", "thinking", "tools", "cwd", "output", "expect", "when", "join", "dependsOn", "retry", "timeout", "optional", "idempotent", "final", "concurrency", "context", "contextLimit", "onBlock", "eval", "score", "cache", "shareContext", "convergence", "reflexion", ] as const) { - if (raw[key] !== undefined) opts.push(`${key}: ${JSON.stringify(raw[key])}`); + if (raw[key] !== undefined) opts.push(`${key}: ${sourceLiteral(raw[key])}`); } if ((p as { cancelLosers?: boolean }).cancelLosers === false) opts.push(`cancelLosers: false`); - if (raw.input !== undefined) opts.push(`input: ${JSON.stringify(raw.input)}`); + if (raw.input !== undefined) opts.push(`input: ${sourceLiteral(raw.input)}`); const maxNodes = (p as { maxNodes?: number }).maxNodes; if (typeof maxNodes === "number") opts.push(`maxNodes: ${maxNodes}`); const optStr = opts.length ? `, { ${opts.join(", ")} }` : ""; @@ -151,73 +212,90 @@ function decompilePhase(p: Phase, bind: string, _byId: Map, bindi switch (p.type ?? "agent") { case "agent": - return `const ${bind} = agent(\`${esc(String(p.task ?? ""))}\`${optStr});`; + return `const ${bind} = agent(${sourceLiteral(String(p.task ?? ""))}${optStr});`; case "script": { const run = p.run; if (Array.isArray(run)) { - return `const ${bind} = script(${JSON.stringify(run)}${optStr});`; + return `const ${bind} = script(${sourceLiteral(run)}${optStr});`; } - return `const ${bind} = script(\`${esc(String(run ?? ""))}\`${optStr});`; + return `const ${bind} = script(${sourceLiteral(String(run ?? ""))}${optStr});`; } case "map": { - const as = safeIdent(p.as ?? "item", "map.as"); - return `const ${bind} = map(${JSON.stringify(p.over ?? "[]")}, (${as}) => agent(\`${esc(String(p.task ?? "{item}"))}\`)${optStr});`; + const as = p.as ?? "item"; + const local = /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(as) && !RESERVED.has(as) ? as : "item"; + const mapOpts = p.as === undefined ? opts : [`as: ${sourceLiteral(as)}`, ...opts]; + const mapOptStr = mapOpts.length ? `, { ${mapOpts.join(", ")} }` : ""; + return `const ${bind} = map(${sourceLiteral(p.over ?? "[]")}, (${local}) => agent(${sourceLiteral(String(p.task ?? `{${as}}`))})${mapOptStr});`; } case "parallel": { - const branches = (p.branches ?? []).map( - (b) => `agent(\`${esc(String(b.task ?? ""))}\`${b.agent ? `, { agent: ${JSON.stringify(b.agent)} }` : ""})`, - ); + const branches = (p.branches ?? []).map((b, i) => branchSource(b as unknown as Record, `parallel ${p.id} branch ${i}`)); return `const ${bind} = parallel([${branches.join(", ")}]${optStr});`; } case "gate": { const upId = p.dependsOn?.[0]; const upBind = upId ? bindings.get(upId) : undefined; - if (!upBind) throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: gate ${JSON.stringify(p.id)} requires a known dependency`); - const taskText = String(p.task ?? (upId ? `{steps.${upId}.output}` : "review")); - return `const ${bind} = gate(${upBind}, ${optObj}, (i) => \`${esc(taskText)}\`);`; + if (!upBind) throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: gate ${sourceLiteral(p.id)} requires a known dependency`); + if (p.task === undefined) return `const ${bind} = gate(${upBind}, ${optObj});`; + return `const ${bind} = gate(${upBind}, ${optObj}, (i) => ${sourceLiteral(p.task)});`; } case "race": { - const branches = (p.branches ?? []).map( - (b) => `agent(\`${esc(String(b.task ?? ""))}\`${b.agent ? `, { agent: ${JSON.stringify(b.agent)} }` : ""})`, - ); + const branches = (p.branches ?? []).map((b, i) => branchSource(b as unknown as Record, `race ${p.id} branch ${i}`)); return `const ${bind} = race([${branches.join(", ")}]${optStr});`; } case "expand": { if (typeof p.def !== "string") { throw new Error( - `TFDSL_DECOMPILE_UNSUPPORTED: expand phase ${JSON.stringify(p.id)} has non-string def (inline object cannot be recovered as a rune argument)`, + `TFDSL_DECOMPILE_UNSUPPORTED: expand phase ${sourceLiteral(p.id)} has non-string def (inline object cannot be recovered as a rune argument)`, ); } const em = (p as { expandMode?: string }).expandMode ?? "nested"; // Always emit expandMode + shared opts (dependsOn/final/when/maxNodes) so // decompile → rebuild keeps DAG edges that string-def alone would lose. - const expandOpts = [`expandMode: ${JSON.stringify(em)}`, ...opts]; - return `const ${bind} = expand(${JSON.stringify(p.def)}, { ${expandOpts.join(", ")} });`; + const expandOpts = [ + `expandMode: ${sourceLiteral(em)}`, + ...(p.with === undefined ? [] : [`with: ${sourceLiteral(p.with)}`]), + ...opts, + ]; + return `const ${bind} = expand(${sourceLiteral(p.def)}, { ${expandOpts.join(", ")} });`; } case "reduce": { const from = (p.from ?? p.dependsOn ?? []).map((id) => { const b = bindings.get(id); - if (!b) throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: reduce ${JSON.stringify(p.id)} references unknown phase ${JSON.stringify(id)}`); + if (!b) throw new Error(`TFDSL_DECOMPILE_UNSUPPORTED: reduce ${sourceLiteral(p.id)} references unknown phase ${sourceLiteral(id)}`); return b; }); - return `const ${bind} = reduce([${from.join(", ")}], (parts) => agent(\`${esc(String(p.task ?? "reduce"))}\`)${optStr});`; + return `const ${bind} = reduce([${from.join(", ")}], (parts) => agent(${sourceLiteral(String(p.task ?? "reduce"))})${optStr});`; } case "approval": - return `const ${bind} = approval({ request: \`${esc(String(p.task ?? "Approve?"))}\`${opts.length ? `, ${opts.join(", ")}` : ""} });`; + return `const ${bind} = approval({ request: ${sourceLiteral(String(p.task ?? "Approve?"))}${opts.length ? `, ${opts.join(", ")}` : ""} });`; case "flow": if (p.def !== undefined) { if (typeof p.def !== "string") { throw new Error( - `TFDSL_DECOMPILE_UNSUPPORTED: flow phase ${JSON.stringify(p.id)} has non-string def (cannot decompile inline object)`, + `TFDSL_DECOMPILE_UNSUPPORTED: flow phase ${sourceLiteral(p.id)} has non-string def (cannot decompile inline object)`, ); } - return `const ${bind} = subflow.def(${JSON.stringify(p.def)}${optStr});`; + const defOpts = p.with === undefined ? opts : [`with: ${sourceLiteral(p.with)}`, ...opts]; + const defOptStr = defOpts.length ? `, { ${defOpts.join(", ")} }` : ""; + return `const ${bind} = subflow.def(${sourceLiteral(p.def)}${defOptStr});`; } - return `const ${bind} = subflow(${JSON.stringify(p.use ?? "child")}, ${JSON.stringify(p.with ?? {})}${optStr});`; + return `const ${bind} = subflow(${sourceLiteral(p.use ?? "child")}, ${sourceLiteral(p.with ?? {})}${optStr});`; case "loop": - return `const ${bind} = loop({ task: ${JSON.stringify(String(p.task ?? ""))}, maxIterations: ${p.maxIterations ?? 10}, until: ${JSON.stringify(p.until ?? "false")}${opts.length ? `, ${opts.join(", ")}` : ""} });`; - case "tournament": - return `const ${bind} = tournament({ variants: ${p.variants ?? 2}, task: ${JSON.stringify(String(p.task ?? ""))}, mode: ${JSON.stringify(p.mode ?? "best")}${p.branches ? `, branches: [${p.branches.map((b) => `agent(${JSON.stringify(b.task)}${b.agent ? `, { agent: ${JSON.stringify(b.agent)} }` : ""})`).join(", ")}]` : ""}${p.judge ? `, judge: ${JSON.stringify(p.judge)}` : ""}${p.judgeAgent ? `, judgeAgent: ${JSON.stringify(p.judgeAgent)}` : ""}${opts.length ? `, ${opts.join(", ")}` : ""} });`; + return `const ${bind} = loop({ task: ${sourceLiteral(String(p.task ?? ""))}, maxIterations: ${p.maxIterations ?? 10}, until: ${sourceLiteral(p.until ?? "false")}${opts.length ? `, ${opts.join(", ")}` : ""} });`; + case "tournament": { + const tournamentOpts = [ + ...(p.variants === undefined ? [] : [`variants: ${p.variants}`]), + ...(p.task === undefined ? [] : [`task: ${sourceLiteral(p.task)}`]), + ...(p.mode === undefined ? [] : [`mode: ${sourceLiteral(p.mode)}`]), + ...(p.branches === undefined + ? [] + : [`branches: [${p.branches.map((b, i) => branchSource(b as unknown as Record, `tournament ${p.id} branch ${i}`)).join(", ")}]`]), + ...(p.judge === undefined ? [] : [`judge: ${sourceLiteral(p.judge)}`]), + ...(p.judgeAgent === undefined ? [] : [`judgeAgent: ${sourceLiteral(p.judgeAgent)}`]), + ...opts, + ]; + return `const ${bind} = tournament({ ${tournamentOpts.join(", ")} });`; + } default: return `// TFDSL: unsupported-field type=${p.type} id=${p.id}`; } diff --git a/packages/taskflow-dsl/src/runes.ts b/packages/taskflow-dsl/src/runes.ts index b23c0a7..1f60b7b 100644 --- a/packages/taskflow-dsl/src/runes.ts +++ b/packages/taskflow-dsl/src/runes.ts @@ -133,7 +133,7 @@ export function parallel( export function map( _source: PhaseRef | string, _fn: (item: TItem) => PhaseRef, - _opts?: PhaseOptions, + _opts?: PhaseOptions & { as?: string }, ): PhaseRef { return eraseOnly("map"); } @@ -195,7 +195,7 @@ export function subflow( /** Nested dynamic sub-flow (compiles to type:flow def). */ export function subflowDef( _def: PhaseRef | string | InlineTaskflowDefinition, - _opts?: PhaseOptions, + _opts?: PhaseOptions & { with?: Record }, ): PhaseRef { return eraseOnly("subflow.def"); } @@ -232,7 +232,7 @@ export function script( /** Nested expand (isolated sub-flow). */ export function expandNested( _def: PhaseRef | string | TDef, - _opts?: PhaseOptions & { maxNodes?: number }, + _opts?: PhaseOptions & { maxNodes?: number; with?: Record }, ): PhaseRef { return eraseOnly("expand.nested"); } @@ -240,14 +240,14 @@ export function expandNested( /** Graft-promote expand: run fragment then promote phase states onto parent. */ export function expandGraft( _def: PhaseRef | string | TDef, - _opts?: PhaseOptions & { maxNodes?: number }, + _opts?: PhaseOptions & { maxNodes?: number; with?: Record }, ): PhaseRef { return eraseOnly("expand.graft"); } export function expand( _def: PhaseRef | string | TDef, - _opts?: PhaseOptions & { expandMode?: "nested" | "graft"; maxNodes?: number }, + _opts?: PhaseOptions & { expandMode?: "nested" | "graft"; maxNodes?: number; with?: Record }, ): PhaseRef { return eraseOnly("expand"); } diff --git a/packages/taskflow-dsl/test/closure-regressions.test.ts b/packages/taskflow-dsl/test/closure-regressions.test.ts index d23ba0b..52570ff 100644 --- a/packages/taskflow-dsl/test/closure-regressions.test.ts +++ b/packages/taskflow-dsl/test/closure-regressions.test.ts @@ -354,3 +354,161 @@ test("closure: check typechecks by default and decompile emits type-correct opti fs.rmSync(dir, { recursive: true, force: true }); } }); + +test("closure: score-only gate decompile never invents an LLM task", () => { + const def: Taskflow = { + name: "score-only", + phases: [ + { id: "seed", type: "agent", task: "ok" }, + { + id: "quality", + type: "gate", + dependsOn: ["seed"], + score: { scorers: [{ type: "contains", value: "ok" }], combine: "all" }, + final: true, + }, + ], + }; + const source = decompileTaskflow(def); + assert.match(source, /gate\(seed, \{[^;]+\}\);/); + assert.doesNotMatch(source, /\(i\)\s*=>/); + const rebuilt = buildSource(source); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); +}); + +test("closure: tournament decompile preserves omitted defaults", () => { + const def: Taskflow = { + name: "tournament-defaults", + phases: [{ id: "contest", type: "tournament", task: "solve", final: true }], + }; + const source = decompileTaskflow(def); + assert.doesNotMatch(source, /variants:/); + assert.doesNotMatch(source, /mode:/); + const rebuilt = buildSource(source); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); +}); + +test("closure: flow def and expand preserve with arguments", () => { + const def: Taskflow = { + name: "dynamic-with", + phases: [ + { + id: "child", + type: "flow", + def: '{"name":"child","phases":[]}', + with: { topic: "hello", nested: { ok: true } }, + }, + { + id: "fragment", + type: "expand", + def: '{"name":"fragment","phases":[]}', + expandMode: "nested", + with: { topic: "world" }, + final: true, + }, + ], + }; + const rebuilt = buildSource(decompileTaskflow(def)); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); +}); + +test("closure: named gate sugar exports erase like property sugar", () => { + const result = buildSource(` +import { flow, agent, gateAutomated, gateScored } from "taskflow-dsl"; +export default flow("named-gates", () => { + const seed = agent("seed"); + const automated = gateAutomated(seed, { pass: ["true"], task: "review" }); + return gateScored(automated, { scorers: [{ type: "contains", value: "ok" }] }); +}); +`); + assert.equal(result.ok, true, errors(result)); + assert.deepEqual(result.taskflow?.phases?.map((p) => p.type), ["agent", "gate", "gate"]); + assert.deepEqual(result.taskflow?.phases?.[1]?.eval, ["true"]); + assert.deepEqual(result.taskflow?.phases?.[2]?.score, { + scorers: [{ type: "contains", value: "ok" }], + combine: "all", + }); +}); + +test("closure: map custom as values compile and decompile without identifier restrictions", () => { + const authored = buildSource(` +import { flow, agent, map } from "taskflow-dsl"; +export default flow("custom-map", () => map("{args.rows}", (row) => agent(\`read \${row.path}\`))); +`); + assert.equal(authored.ok, true, errors(authored)); + assert.equal(authored.taskflow?.phases?.[0]?.as, "row"); + assert.equal(authored.taskflow?.phases?.[0]?.task, "read {row.path}"); + + const def: Taskflow = { + name: "hyphen-map", + phases: [{ + id: "mapped", + type: "map", + over: "{args.rows}", + as: "item-name", + task: "read {item-name.path}", + final: true, + }], + }; + const source = decompileTaskflow(def); + assert.match(source, /as: "item-name"/); + const rebuilt = buildSource(source); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); +}); + +test("closure: map decompile preserves an omitted default as field", () => { + const def: Taskflow = { + name: "default-map-alias", + phases: [{ id: "mapped", type: "map", over: "[]", task: "read {item}", final: true }], + }; + const rebuilt = buildSource(decompileTaskflow(def)); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); +}); + +test("closure: unsupported per-branch execution options fail explicitly", () => { + for (const [rune, invocation] of [ + ["parallel", `parallel([agent("x", { model: "m" })])`], + ["race", `race([agent("x", { tools: ["read"] }), agent("y")])`], + ["tournament", `tournament({ branches: [agent("x", { thinking: "high" }), agent("y")] })`], + ] as const) { + const result = buildSource(` +import { flow, agent, ${rune} } from "taskflow-dsl"; +export default flow("branch-options", () => ${invocation}); +`); + assert.equal(result.ok, false, `${rune}: ${errors(result)}`); + assert.ok(result.diagnostics.some((d) => d.code === "TFDSL_BRANCH_OPTS_UNSUPPORTED"), errors(result)); + } + + assert.throws( + () => decompileTaskflow({ + name: "bad-branch", + phases: [{ + id: "work", + type: "parallel", + branches: [{ task: "x", model: "m" } as never], + final: true, + }], + }), + /unsupported branch option.*model/, + ); +}); + +test("closure: decompile source literals are inert and preserve hostile text", () => { + const hostile = `\n"\\\u2028\u2029`; + const def: Taskflow = { + name: hostile, + args: { text: { default: hostile } }, + phases: [{ id: "main", type: "agent", task: hostile, final: true }], + }; + const source = decompileTaskflow(def); + assert.doesNotMatch(source, /<\/script/i); + assert.doesNotMatch(source, /[\u2028\u2029]/u); + const rebuilt = buildSource(source); + assert.equal(rebuilt.ok, true, errors(rebuilt)); + assert.deepEqual(rebuilt.taskflow, def); +}); diff --git a/packages/taskflow-dsl/test/erase-build.test.ts b/packages/taskflow-dsl/test/erase-build.test.ts index 1f70fe1..970b423 100644 --- a/packages/taskflow-dsl/test/erase-build.test.ts +++ b/packages/taskflow-dsl/test/erase-build.test.ts @@ -140,7 +140,6 @@ export default flow("twin", () => { id: "each", type: "map", over: "{steps.discover.json}", - as: "item", task: "Audit {item.path}", agent: "analyst", dependsOn: ["discover"], diff --git a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx index 48fde81..b240dec 100644 --- a/website/content/docs/en/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/en/compiler-runtime/typescript-dsl.mdx @@ -26,7 +26,7 @@ taskflow-dsl build audit.tf.ts --emit both | Command | Purpose | |---------|---------| | `new [name]` | Hello skeleton (`.tf.ts` or `--json-escape`) | -| `check ` | Erase + `validateTaskflow` (`--typecheck` for tsc) | +| `check ` | Erase + `validateTaskflow` + tsc by default (`--no-typecheck` to disable) | | `build ` | Emit Taskflow JSON / FlowIR | | `decompile ` | Taskflow JSON → readable `.tf.ts` (**semantic**, not literal round-trip) | diff --git a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx index f27687f..ce2702c 100644 --- a/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx +++ b/website/content/docs/zh-cn/compiler-runtime/typescript-dsl.mdx @@ -25,7 +25,7 @@ taskflow-dsl build audit.tf.ts --emit both | 命令 | 作用 | |------|------| | `new [name]` | 骨架(`.tf.ts` 或 `--json-escape`) | -| `check ` | erase + `validateTaskflow`(`--typecheck` 走 tsc) | +| `check ` | erase + `validateTaskflow` + 默认 tsc(`--no-typecheck` 可关闭) | | `build ` | 产出 Taskflow JSON / FlowIR | | `decompile ` | JSON → 可读 `.tf.ts`(**语义**往返,非字面) | From 6c8e20a6b2910223164ca30e2bc32979ce43afb8 Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 11 Jul 2026 09:33:33 +0800 Subject: [PATCH 42/51] test(pi): isolate direct execution e2e runners Inject the Pi runner explicitly and disable ambient extension discovery so local E2E coverage cannot collide with globally installed taskflow extensions. --- .../pi-taskflow/test/e2e-context-value.mts | 111 ++++++------ packages/pi-taskflow/test/e2e-context.mts | 86 +++++---- packages/pi-taskflow/test/e2e-helpers.mts | 36 ++++ .../test/e2e-org-audit-iterate.mts | 116 ++++++------ packages/pi-taskflow/test/e2e-org-tree.mts | 148 ++++++++------- .../pi-taskflow/test/e2e-spawn-subflow.mts | 70 ++++---- .../pi-taskflow/test/e2e-subflow-complex.mts | 92 +++++----- packages/pi-taskflow/test/e2e-team.mts | 169 +++++++++--------- packages/pi-taskflow/test/e2e.mts | 84 +++++---- .../pi-taskflow/test/runner-injection.test.ts | 17 +- 10 files changed, 525 insertions(+), 404 deletions(-) create mode 100644 packages/pi-taskflow/test/e2e-helpers.mts diff --git a/packages/pi-taskflow/test/e2e-context-value.mts b/packages/pi-taskflow/test/e2e-context-value.mts index 90cbead..7525f5a 100644 --- a/packages/pi-taskflow/test/e2e-context-value.mts +++ b/packages/pi-taskflow/test/e2e-context-value.mts @@ -2,7 +2,7 @@ * REAL, value-demonstrating end-to-end test for the Shared Context Tree. * * This exercises BOTH core capabilities against a REAL codebase (this repo's - * own extensions/), with real `pi` subagents and a real model: + * own `packages/taskflow-core/src/` files), with real `pi` subagents and a real model: * * PART A — Horizontal blackboard reuse (avoid duplicated work) * 1. `survey` maps shared project conventions ONCE and ctx_write's them @@ -28,7 +28,9 @@ import * as crypto from "node:crypto"; import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; -import type { RunState } from "taskflow-core"; +import { saveRun, type RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; const MARKER = `CONV-${crypto.randomBytes(4).toString("hex").toUpperCase()}`; @@ -44,7 +46,7 @@ const FLOW: Taskflow = { agent: "scout", task: `Inspect this TypeScript project's coding conventions by reading AGENTS.md ` + - `and one or two files under extensions/. Summarize the 3 most important ` + + `and one or two files under packages/taskflow-core/src/. Summarize the 3 most important ` + `conventions in one short sentence each.\n\n` + `Then call ctx_write with key "conventions" and a value that is a JSON ` + `object of the form:\n` + @@ -55,7 +57,7 @@ const FLOW: Taskflow = { { id: "audit", type: "map", - over: '["extensions/usage.ts","extensions/cache.ts","extensions/verify.ts"]', + over: '["packages/taskflow-core/src/usage.ts","packages/taskflow-core/src/cache.ts","packages/taskflow-core/src/verify.ts"]', as: "item", agent: "analyst", dependsOn: ["survey"], @@ -106,59 +108,62 @@ async function main() { cwd: process.cwd(), }; - console.log("== executing (real subagents) =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), - agents, - globalThinking: settings.globalThinking, - persist: (s) => { - try { - // best-effort save so the run is inspectable afterward - import("../extensions/store.ts").then((m) => m.saveRun(s)).catch(() => {}); - } catch { /* ignore */ } - }, - onProgress: (s) => { - const done = Object.values(s.phases).filter((p) => p.status === "done").length; - const running = Object.values(s.phases) - .filter((p) => p.status === "running") - .map((p) => p.id); - process.stdout.write( - `\r progress: ${done}/${s.def.phases.length} done` + - (running.length ? ` | running: ${running.join(",")}` : "") + - " ", - ); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-context-value"); + try { + console.log("== executing (real subagents) =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + persist: (s) => { + try { saveRun(s); } catch { /* best-effort; the E2E result is authoritative */ } + }, + onProgress: (s) => { + const done = Object.values(s.phases).filter((p) => p.status === "done").length; + const running = Object.values(s.phases) + .filter((p) => p.status === "running") + .map((p) => p.id); + process.stdout.write( + `\r progress: ${done}/${s.def.phases.length} done` + + (running.length ? ` | running: ${running.join(",")}` : "") + + " ", + ); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - console.log("\nPhase states:"); - for (const p of FLOW.phases) { - const ps = res.state.phases[p.id]; - console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}] -> ${JSON.stringify(ps?.output?.slice(0, 100))}`); - } + console.log("\nPhase states:"); + for (const p of FLOW.phases) { + const ps = res.state.phases[p.id]; + console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}] -> ${JSON.stringify(ps?.output?.slice(0, 100))}`); + } - const auditOut = res.state.phases.audit?.output ?? ""; - // The map output concatenates sub-results, each tagged "[i/N] ...". - const markerHits = (auditOut.match(new RegExp(MARKER, "g")) ?? []).length; - console.log(`\nMarker "${MARKER}" appeared ${markerHits}× in the audit fan-out (expected ≥2 of 3).`); + const auditOut = res.state.phases.audit?.output ?? ""; + // The map output concatenates sub-results, each tagged "[i/N] ...". + const markerHits = (auditOut.match(new RegExp(MARKER, "g")) ?? []).length; + console.log(`\nMarker "${MARKER}" appeared ${markerHits}× in the audit fan-out (expected ≥2 of 3).`); - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["survey done", res.state.phases.survey?.status === "done"], - ["audit fan-out done", res.state.phases.audit?.status === "done"], - ["≥2 of 3 auditors reused the shared marker (blackboard reuse, not re-derived)", markerHits >= 2], - ["final summary produced", (res.finalOutput?.length ?? 0) > 0], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [name, ok] of checks) { - console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); - if (!ok) allPass = false; + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["survey done", res.state.phases.survey?.status === "done"], + ["audit fan-out done", res.state.phases.audit?.status === "done"], + ["≥2 of 3 auditors reused the shared marker (blackboard reuse, not re-derived)", markerHits >= 2], + ["final summary produced", (res.finalOutput?.length ?? 0) > 0], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [name, ok] of checks) { + console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); + if (!ok) allPass = false; + } + console.log("\nFinal summary:\n ", (res.finalOutput ?? "").slice(0, 400)); + console.log(allPass ? "\n✅ CONTEXT-VALUE E2E PASSED" : "\n❌ CONTEXT-VALUE E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); } - console.log("\nFinal summary:\n ", (res.finalOutput ?? "").slice(0, 400)); - console.log(allPass ? "\n✅ CONTEXT-VALUE E2E PASSED" : "\n❌ CONTEXT-VALUE E2E FAILED"); - process.exit(allPass ? 0 : 1); } main().catch((e) => { diff --git a/packages/pi-taskflow/test/e2e-context.mts b/packages/pi-taskflow/test/e2e-context.mts index eb184b6..fd8ef22 100644 --- a/packages/pi-taskflow/test/e2e-context.mts +++ b/packages/pi-taskflow/test/e2e-context.mts @@ -19,6 +19,8 @@ import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; import type { RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; // A random token the reader phase cannot possibly guess — it can only obtain it // from the blackboard via ctx_read. @@ -78,48 +80,54 @@ async function main() { cwd: process.cwd(), }; - console.log("== executing (real subagents) =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), - agents, - globalThinking: settings.globalThinking, - onProgress: (s) => { - const done = Object.values(s.phases).filter((p) => p.status === "done").length; - const running = Object.values(s.phases) - .filter((p) => p.status === "running") - .map((p) => p.id); - process.stdout.write( - `\r progress: ${done}/${s.def.phases.length} done` + - (running.length ? ` | running: ${running.join(",")}` : "") + - " ", - ); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-context"); + try { + console.log("== executing (real subagents) =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + onProgress: (s) => { + const done = Object.values(s.phases).filter((p) => p.status === "done").length; + const running = Object.values(s.phases) + .filter((p) => p.status === "running") + .map((p) => p.id); + process.stdout.write( + `\r progress: ${done}/${s.def.phases.length} done` + + (running.length ? ` | running: ${running.join(",")}` : "") + + " ", + ); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - console.log("\nPhase states:"); - for (const p of FLOW.phases) { - const ps = res.state.phases[p.id]; - console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} -> ${JSON.stringify(ps?.output?.slice(0, 120))}`); - } - console.log("\nFinal output (reader):\n ", JSON.stringify(res.finalOutput)); + console.log("\nPhase states:"); + for (const p of FLOW.phases) { + const ps = res.state.phases[p.id]; + console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} -> ${JSON.stringify(ps?.output?.slice(0, 120))}`); + } + console.log("\nFinal output (reader):\n ", JSON.stringify(res.finalOutput)); - const readerOut = res.state.phases.reader?.output ?? ""; - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["writer phase done", res.state.phases.writer?.status === "done"], - ["reader phase done", res.state.phases.reader?.status === "done"], - ["reader output contains the value it could only get from the blackboard", readerOut.includes(CODE)], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [name, ok] of checks) { - console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); - if (!ok) allPass = false; + const readerOut = res.state.phases.reader?.output ?? ""; + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["writer phase done", res.state.phases.writer?.status === "done"], + ["reader phase done", res.state.phases.reader?.status === "done"], + ["reader output contains the value it could only get from the blackboard", readerOut.includes(CODE)], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [name, ok] of checks) { + console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); + if (!ok) allPass = false; + } + console.log(allPass ? "\n✅ CONTEXT-SHARE E2E PASSED" : "\n❌ CONTEXT-SHARE E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); } - console.log(allPass ? "\n✅ CONTEXT-SHARE E2E PASSED" : "\n❌ CONTEXT-SHARE E2E FAILED"); - process.exit(allPass ? 0 : 1); } main().catch((e) => { diff --git a/packages/pi-taskflow/test/e2e-helpers.mts b/packages/pi-taskflow/test/e2e-helpers.mts new file mode 100644 index 0000000..91a5f0d --- /dev/null +++ b/packages/pi-taskflow/test/e2e-helpers.mts @@ -0,0 +1,36 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +/** + * Run real-pi E2Es with automatic extension discovery disabled. + * + * Context-sharing phases explicitly inject this checkout's pi-taskflow + * extension so ctx_* tools are available. If the developer also has a released + * pi-taskflow installed globally, normal `pi` startup discovers both copies and + * rejects the duplicate tool registrations. `pi -ne` keeps the child isolated + * while preserving the extension explicitly supplied by piSubagentRunner. + * + * An explicit PI_TASKFLOW_PI_BIN override always wins. + */ +export function installNoExtPiWrapper(prefix: string): () => void { + if (process.env.PI_TASKFLOW_PI_BIN) return () => {}; + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), `${prefix}-`)); + const wrapper = path.join(dir, process.platform === "win32" ? "pi-noext.cmd" : "pi-noext.sh"); + const body = process.platform === "win32" + ? "@echo off\r\npi -ne %*\r\n" + : "#!/bin/sh\nexec pi -ne \"$@\"\n"; + fs.writeFileSync(wrapper, body, { mode: 0o700 }); + process.env.PI_TASKFLOW_PI_BIN = wrapper; + + let cleaned = false; + const cleanup = () => { + if (cleaned) return; + cleaned = true; + delete process.env.PI_TASKFLOW_PI_BIN; + fs.rmSync(dir, { recursive: true, force: true }); + }; + process.once("exit", cleanup); + return cleanup; +} diff --git a/packages/pi-taskflow/test/e2e-org-audit-iterate.mts b/packages/pi-taskflow/test/e2e-org-audit-iterate.mts index a64d039..261d462 100644 --- a/packages/pi-taskflow/test/e2e-org-audit-iterate.mts +++ b/packages/pi-taskflow/test/e2e-org-audit-iterate.mts @@ -34,14 +34,16 @@ import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; import { runsDir, saveRun, type RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; const MARKER = `REPO-${crypto.randomBytes(4).toString("hex").toUpperCase()}`; const DOMAINS = JSON.stringify([ - { domain: "runtime", file: "extensions/runtime.ts" }, - { domain: "schema", file: "extensions/schema.ts" }, - { domain: "storage", file: "extensions/store.ts" }, - { domain: "cache", file: "extensions/cache.ts" }, - { domain: "context-tree", file: "extensions/context-store.ts" }, + { domain: "runtime", file: "packages/taskflow-core/src/runtime.ts" }, + { domain: "schema", file: "packages/taskflow-core/src/schema.ts" }, + { domain: "storage", file: "packages/taskflow-core/src/store.ts" }, + { domain: "cache", file: "packages/taskflow-core/src/cache.ts" }, + { domain: "context-tree", file: "packages/taskflow-core/src/context-store.ts" }, ]); const FLOW: Taskflow = { @@ -57,7 +59,7 @@ const FLOW: Taskflow = { shareContext: true, task: `You are the recon lead for a repo-wide audit. Skim AGENTS.md and the file ` + - `extensions/context-store.ts. Your PRIMARY deliverable is a ctx_write call: ` + + `packages/taskflow-core/src/context-store.ts. Your PRIMARY deliverable is a ctx_write call: ` + `ctx_write key 'map' with JSON {"marker":"${MARKER}","conventions":"2 one-line ` + `rules every auditor should know"}. The marker MUST be exactly ${MARKER}. ` + `If you did not call ctx_write you failed. Reply DONE.`, @@ -150,58 +152,66 @@ async function main() { phases: {}, createdAt: Date.now(), updatedAt: Date.now(), cwd: process.cwd(), }; - console.log("== executing (large org: recon → map×5(+grandchild) → synth → loop → gate → final) =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), agents, globalThinking: settings.globalThinking, - persist: (s) => { saveRun(s); }, - onProgress: (s) => { - const done = Object.values(s.phases).filter((p) => p.status === "done").length; - const running = Object.values(s.phases).filter((p) => p.status === "running").map((p) => p.id); - const da = s.phases["domain-audit"]?.subProgress; - const fan = da ? ` | fan-out ${da.done}/${da.total}` : ""; - process.stdout.write(`\r ${done}/${s.def.phases.length} done${fan}${running.length ? " | " + running.join(",") : ""} `); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-org-audit"); + try { + console.log("== executing (large org: recon → map×5(+grandchild) → synth → loop → gate → final) =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + persist: (s) => { saveRun(s); }, + onProgress: (s) => { + const done = Object.values(s.phases).filter((p) => p.status === "done").length; + const running = Object.values(s.phases).filter((p) => p.status === "running").map((p) => p.id); + const da = s.phases["domain-audit"]?.subProgress; + const fan = da ? ` | fan-out ${da.done}/${da.total}` : ""; + process.stdout.write(`\r ${done}/${s.def.phases.length} done${fan}${running.length ? " | " + running.join(",") : ""} `); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - const out = res.finalOutput ?? ""; - const auditOut = res.state.phases["domain-audit"]?.output ?? ""; - const markerHits = (auditOut.match(new RegExp(MARKER, "g")) ?? []).length; + const out = res.finalOutput ?? ""; + const auditOut = res.state.phases["domain-audit"]?.output ?? ""; + const markerHits = (auditOut.match(new RegExp(MARKER, "g")) ?? []).length; - // Grandchildren register as `--cN` nodes parented to a domain-audit - // item in THIS run's ctx tree (a spawned subflow may also create its own - // `-inline-` ctx dir, but the supervision node is what proves it ran). - const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); - let treeNodes: Array<{ nodeId: string; parentNodeId?: string }> = []; - try { treeNodes = (JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")).nodes ?? []); } catch { /* none */ } - const grandchildren = treeNodes.filter((n) => /^domain-audit-\d+--c\d+$/.test(n.nodeId)); + // Grandchildren register as `--cN` nodes parented to a domain-audit + // item in THIS run's ctx tree (a spawned subflow may also create its own + // `-inline-` ctx dir, but the supervision node is what proves it ran). + const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); + let treeNodes: Array<{ nodeId: string; parentNodeId?: string }> = []; + try { treeNodes = (JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")).nodes ?? []); } catch { /* none */ } + const grandchildren = treeNodes.filter((n) => /^domain-audit-\d+--c\d+$/.test(n.nodeId)); - console.log("\nPhase states:"); - for (const p of FLOW.phases) console.log(` ${res.state.phases[p.id]?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}]`); - console.log(`\nmarker "${MARKER}" echoed ${markerHits}× across the 5-way fan-out (shared-map reuse)`); - console.log(`spawned grandchild deep-dives this run: ${grandchildren.length} (${grandchildren.map((g) => g.nodeId).join(", ")})`); - console.log("\n── governance report (tail 900) ──\n" + out.slice(-900)); + console.log("\nPhase states:"); + for (const p of FLOW.phases) console.log(` ${res.state.phases[p.id]?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}]`); + console.log(`\nmarker "${MARKER}" echoed ${markerHits}× across the 5-way fan-out (shared-map reuse)`); + console.log(`spawned grandchild deep-dives this run: ${grandchildren.length} (${grandchildren.map((g) => g.nodeId).join(", ")})`); + console.log("\n── governance report (tail 900) ──\n" + out.slice(-900)); - const totalCost = Object.values(res.state.phases).reduce((s, p) => s + (p.usage?.cost ?? 0), 0); - console.log(`\ntotal accounted cost across all phases (incl. spawned): $${totalCost.toFixed(4)}`); + const totalCost = Object.values(res.state.phases).reduce((s, p) => s + (p.usage?.cost ?? 0), 0); + console.log(`\ntotal accounted cost across all phases (incl. spawned): $${totalCost.toFixed(4)}`); - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["recon published shared map (marker)", markerHits >= 1], - ["all 5 domains audited (fan-out done)", res.state.phases["domain-audit"]?.status === "done"], - ["≥3 of 5 auditors reused the shared map (horizontal reuse at scale)", markerHits >= 3], - ["≥3 grandchild deep-dives spawned by map items (recursive org tree ×N)", grandchildren.length >= 3], - ["iterative refine loop ran", res.state.phases.refine?.status === "done"], - ["risk gate ran (not blocked)", res.state.phases["risk-gate"]?.status === "done"], - ["final prioritized governance report produced", /P0|P1|P2/.test(out) && out.length > 500], - ["budget accounted & not exceeded", res.state.status !== "blocked" && totalCost > 0], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } - console.log(allPass ? "\n✅ ORG-AUDIT-ITERATE E2E PASSED" : "\n❌ ORG-AUDIT-ITERATE E2E FAILED"); - process.exit(allPass ? 0 : 1); + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["recon published shared map (marker)", markerHits >= 1], + ["all 5 domains audited (fan-out done)", res.state.phases["domain-audit"]?.status === "done"], + ["≥3 of 5 auditors reused the shared map (horizontal reuse at scale)", markerHits >= 3], + ["≥3 grandchild deep-dives spawned by map items (recursive org tree ×N)", grandchildren.length >= 3], + ["iterative refine loop ran", res.state.phases.refine?.status === "done"], + ["risk gate ran (not blocked)", res.state.phases["risk-gate"]?.status === "done"], + ["final prioritized governance report produced", /P0|P1|P2/.test(out) && out.length > 500], + ["budget accounted & not exceeded", res.state.status !== "blocked" && totalCost > 0], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } + console.log(allPass ? "\n✅ ORG-AUDIT-ITERATE E2E PASSED" : "\n❌ ORG-AUDIT-ITERATE E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); + } } main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/packages/pi-taskflow/test/e2e-org-tree.mts b/packages/pi-taskflow/test/e2e-org-tree.mts index f0ccab2..5ec133c 100644 --- a/packages/pi-taskflow/test/e2e-org-tree.mts +++ b/packages/pi-taskflow/test/e2e-org-tree.mts @@ -34,6 +34,8 @@ import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; import { runsDir, saveRun, type RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; const MARKER = `MAP-${crypto.randomBytes(4).toString("hex").toUpperCase()}`; @@ -54,15 +56,15 @@ const FLOW: Taskflow = { `The subflow MUST be {"phases":[...]} with these phases (use these EXACT ids and ` + `fields, do not add others):\n\n` + `1. {"id":"recon","type":"agent","agent":"scout","shareContext":true,"task":` + - `"Skim extensions/context-store.ts. Then ctx_write key 'map' with JSON ` + + `"Skim packages/taskflow-core/src/context-store.ts. Then ctx_write key 'map' with JSON ` + `{marker:'${MARKER}',notes:'1 line on how the blackboard locking works'}. The ` + `marker MUST be exactly ${MARKER}. Reply DONE."}\n\n` + `2. {"id":"audit","type":"agent","agent":"analyst","shareContext":true,` + `"dependsOn":["recon"],"task":"FIRST ctx_read key 'map' to reuse recon's survey ` + - `(do not re-derive it). Audit extensions/context-store.ts for ONE concrete issue ` + + `(do not re-derive it). Audit packages/taskflow-core/src/context-store.ts for ONE concrete issue ` + `(cite a function). You MUST then call ctx_spawn with a 'subflow' of two phases ` + `(this is required, not optional): ` + - `{id:'triage',agent:'analyst',task:'name the riskiest function in context-store.ts'} ` + + `{id:'triage',agent:'analyst',task:'name the riskiest function in packages/taskflow-core/src/context-store.ts'} ` + `and {id:'fixplan',agent:'analyst',dependsOn:['triage'],final:true,` + `task:'propose a concrete fix for {steps.triage.output}'}. END your reply with the ` + `marker from the map you read."}\n\n` + @@ -88,81 +90,89 @@ async function main() { phases: {}, createdAt: Date.now(), updatedAt: Date.now(), cwd: process.cwd(), }; - console.log("== executing: lead → [recon → map(audit, may spawn grandchild) → roadmap] =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), agents, globalThinking: settings.globalThinking, - persist: (s) => { saveRun(s); }, - onProgress: (s) => { - const lead = s.phases.lead; - const sub = lead?.subProgress ? ` | subflow ${lead.subProgress.done}/${lead.subProgress.total}` : ""; - process.stdout.write(`\r lead ${lead?.status ?? "?"}${sub} `); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-org-tree"); + try { + console.log("== executing: lead → [recon → map(audit, may spawn grandchild) → roadmap] =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + persist: (s) => { saveRun(s); }, + onProgress: (s) => { + const lead = s.phases.lead; + const sub = lead?.subProgress ? ` | subflow ${lead.subProgress.done}/${lead.subProgress.total}` : ""; + process.stdout.write(`\r lead ${lead?.status ?? "?"}${sub} `); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); - let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; - try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } - const nodes = tree.nodes ?? []; - const out = res.finalOutput ?? ""; + const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); + let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; + try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } + const nodes = tree.nodes ?? []; + const out = res.finalOutput ?? ""; - // A spawned subflow runs as its OWN isolated nested run, so it gets its OWN - // ctx dir (named -inline-*). The org tree therefore spans MULTIPLE - // tree.json files linked by the `-inline` naming — not one flat tree. To prove - // recursive depth (a phase INSIDE a spawned subflow itself spawning a child), - // we walk into the spawned subflow's ctx dir and look for a grandchild node. - const ctxRoot = path.join(runsDir(process.cwd()), "ctx"); - let subflowCtxDirs: string[] = []; - try { - subflowCtxDirs = fs.readdirSync(ctxRoot) - .filter((d) => d.includes("-inline-")) - .map((d) => path.join(ctxRoot, d)); - } catch { /* none */ } - // Pick the most recently modified inline ctx dir (this run's subflow). - subflowCtxDirs.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); - let grandchildFound = false; - let subflowTreeNodes: Array<{ nodeId: string; parentNodeId?: string; status: string }> = []; - if (subflowCtxDirs[0]) { + // A spawned subflow runs as its OWN isolated nested run, so it gets its OWN + // ctx dir (named -inline-*). The org tree therefore spans MULTIPLE + // tree.json files linked by the `-inline` naming — not one flat tree. To prove + // recursive depth (a phase INSIDE a spawned subflow itself spawning a child), + // we walk into the spawned subflow's ctx dir and look for a grandchild node. + const ctxRoot = path.join(runsDir(process.cwd()), "ctx"); + let subflowCtxDirs: string[] = []; try { - const st = JSON.parse(fs.readFileSync(path.join(subflowCtxDirs[0], "tree.json"), "utf-8")); - subflowTreeNodes = st.nodes ?? []; - // A grandchild = a node whose parent is itself a non-root node in the subflow tree. - grandchildFound = subflowTreeNodes.some((n) => n.parentNodeId && n.parentNodeId !== undefined); + subflowCtxDirs = fs.readdirSync(ctxRoot) + .filter((d) => d.includes("-inline-")) + .map((d) => path.join(ctxRoot, d)); } catch { /* none */ } - } + // Pick the most recently modified inline ctx dir (this run's subflow). + subflowCtxDirs.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs); + let grandchildFound = false; + let subflowTreeNodes: Array<{ nodeId: string; parentNodeId?: string; status: string }> = []; + if (subflowCtxDirs[0]) { + try { + const st = JSON.parse(fs.readFileSync(path.join(subflowCtxDirs[0], "tree.json"), "utf-8")); + subflowTreeNodes = st.nodes ?? []; + // A grandchild = a node whose parent is itself a non-root node in the subflow tree. + grandchildFound = subflowTreeNodes.some((n) => n.parentNodeId && n.parentNodeId !== undefined); + } catch { /* none */ } + } - const spawnedUnderLead = nodes.filter((n) => n.parentNodeId === "lead"); + const spawnedUnderLead = nodes.filter((n) => n.parentNodeId === "lead"); - console.log("\nparent tree nodes:"); - for (const n of nodes) console.log(` ${n.nodeId} <- ${n.parentNodeId ?? "-"} [${n.status}]`); - console.log("spawned subflow tree nodes (the org sub-tree):"); - for (const n of subflowTreeNodes) console.log(` ${n.nodeId} <- ${n.parentNodeId ?? "-"} [${n.status}]`); + console.log("\nparent tree nodes:"); + for (const n of nodes) console.log(` ${n.nodeId} <- ${n.parentNodeId ?? "-"} [${n.status}]`); + console.log("spawned subflow tree nodes (the org sub-tree):"); + for (const n of subflowTreeNodes) console.log(` ${n.nodeId} <- ${n.parentNodeId ?? "-"} [${n.status}]`); - // Did any auditor inside the spawned subflow reuse the shared map? (marker echo) - const markerHits = (out.match(new RegExp(MARKER, "g")) ?? []).length; - const hasPrioritized = /P0|P1|P2/.test(out); - const noShapeError = !/failed validation|failed verification|not a Taskflow/.test(out); + // Did any auditor inside the spawned subflow reuse the shared map? (marker echo) + const markerHits = (out.match(new RegExp(MARKER, "g")) ?? []).length; + const hasPrioritized = /P0|P1|P2/.test(out); + const noShapeError = !/failed validation|failed verification|not a Taskflow/.test(out); - console.log(`\nmarker "${MARKER}" echoed ${markerHits}× in folded output (blackboard reuse inside spawned subflow)`); - console.log("── roadmap (tail 800) ──\n" + out.slice(-800)); + console.log(`\nmarker "${MARKER}" echoed ${markerHits}× in folded output (blackboard reuse inside spawned subflow)`); + console.log("── roadmap (tail 800) ──\n" + out.slice(-800)); - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["lead spawned subflow A", spawnedUnderLead.length >= 1], - ["spawn fold marker present", /ctx_spawn: \d+ child report/.test(out)], - ["subflow validated & ran (no shape error)", noShapeError], - ["horizontal reuse: an auditor echoed the shared map marker", markerHits >= 1], - ["recursive org tree: a phase inside the spawned subflow spawned a grandchild", grandchildFound], - ["nested DAG produced a prioritized roadmap", hasPrioritized], - ["deliverable is substantial (>400 chars)", out.length > 400], - ["stayed within budget", res.state.status !== "blocked"], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } - console.log(allPass ? "\n✅ ORG-TREE E2E PASSED" : "\n❌ ORG-TREE E2E FAILED"); - process.exit(allPass ? 0 : 1); + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["lead spawned subflow A", spawnedUnderLead.length >= 1], + ["spawn fold marker present", /ctx_spawn: \d+ child report/.test(out)], + ["subflow validated & ran (no shape error)", noShapeError], + ["horizontal reuse: an auditor echoed the shared map marker", markerHits >= 1], + ["recursive org tree: a phase inside the spawned subflow spawned a grandchild", grandchildFound], + ["nested DAG produced a prioritized roadmap", hasPrioritized], + ["deliverable is substantial (>400 chars)", out.length > 400], + ["stayed within budget", res.state.status !== "blocked"], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } + console.log(allPass ? "\n✅ ORG-TREE E2E PASSED" : "\n❌ ORG-TREE E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); + } } main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/packages/pi-taskflow/test/e2e-spawn-subflow.mts b/packages/pi-taskflow/test/e2e-spawn-subflow.mts index dcb1209..be8ebd4 100644 --- a/packages/pi-taskflow/test/e2e-spawn-subflow.mts +++ b/packages/pi-taskflow/test/e2e-spawn-subflow.mts @@ -16,6 +16,8 @@ import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; import { runsDir, type RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; const FLOW: Taskflow = { name: "e2e-spawn-subflow", @@ -31,7 +33,7 @@ const FLOW: Taskflow = { `Call ctx_spawn with ONE assignment that uses "subflow" (not "task"). The ` + `subflow must be {"phases":[ ... ]} with exactly two phases:\n` + ` 1. id "investigate" (type agent, agent "scout") — task: "List the exported ` + - `functions of extensions/usage.ts".\n` + + `functions of packages/taskflow-core/src/usage.ts".\n` + ` 2. id "summary" (type agent, agent "analyst", dependsOn ["investigate"], ` + `final true) — task: "In one sentence, summarize: {steps.investigate.output}".\n` + `Set "defaultAgent" to "scout". After the ctx_spawn call, reply DONE.`, @@ -53,38 +55,46 @@ async function main() { phases: {}, createdAt: Date.now(), updatedAt: Date.now(), cwd: process.cwd(), }; - console.log("== executing (real subagents) =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), agents, globalThinking: settings.globalThinking, - onProgress: (s) => { - const done = Object.values(s.phases).filter((p) => p.status === "done").length; - process.stdout.write(`\r ${done}/${s.def.phases.length} done `); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-spawn"); + try { + console.log("== executing (real subagents) =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + onProgress: (s) => { + const done = Object.values(s.phases).filter((p) => p.status === "done").length; + process.stdout.write(`\r ${done}/${s.def.phases.length} done `); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); - let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; - try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } - const spawnedChildren = (tree.nodes ?? []).filter((n) => n.parentNodeId === "lead"); - const out = res.finalOutput ?? ""; + const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); + let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; + try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } + const spawnedChildren = (tree.nodes ?? []).filter((n) => n.parentNodeId === "lead"); + const out = res.finalOutput ?? ""; - console.log("\ntree nodes:", (tree.nodes ?? []).map((n) => `${n.nodeId}<-${n.parentNodeId ?? "-"}`).join(", ")); - console.log("folded output (tail):\n", out.slice(-500)); + console.log("\ntree nodes:", (tree.nodes ?? []).map((n) => `${n.nodeId}<-${n.parentNodeId ?? "-"}`).join(", ")); + console.log("folded output (tail):\n", out.slice(-500)); - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["lead spawned a child (subflow node registered)", spawnedChildren.length >= 1], - ["spawn fold marker present", /ctx_spawn: \d+ child report/.test(out)], - ["subflow did NOT fail validation/shape", !/failed validation|failed verification|not a Taskflow/.test(out)], - ["subflow produced a summary (final inner phase ran)", out.length > 0 && !/zero phases|no-op/.test(out)], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } - console.log(allPass ? "\n✅ SPAWN-SUBFLOW E2E PASSED" : "\n❌ SPAWN-SUBFLOW E2E FAILED"); - process.exit(allPass ? 0 : 1); + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["lead spawned a child (subflow node registered)", spawnedChildren.length >= 1], + ["spawn fold marker present", /ctx_spawn: \d+ child report/.test(out)], + ["subflow did NOT fail validation/shape", !/failed validation|failed verification|not a Taskflow/.test(out)], + ["subflow produced a summary (final inner phase ran)", out.length > 0 && !/zero phases|no-op/.test(out)], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } + console.log(allPass ? "\n✅ SPAWN-SUBFLOW E2E PASSED" : "\n❌ SPAWN-SUBFLOW E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); + } } main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/packages/pi-taskflow/test/e2e-subflow-complex.mts b/packages/pi-taskflow/test/e2e-subflow-complex.mts index 434bdf5..c17f1dd 100644 --- a/packages/pi-taskflow/test/e2e-subflow-complex.mts +++ b/packages/pi-taskflow/test/e2e-subflow-complex.mts @@ -29,6 +29,8 @@ import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; import { runsDir, saveRun, type RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; const FLOW: Taskflow = { name: "e2e-subflow-complex", @@ -41,12 +43,12 @@ const FLOW: Taskflow = { agent: "planner", task: `You are an engineering lead. Goal: produce a prioritized TEST-HEALTH report ` + - `for this TypeScript repo (test files live in test/*.test.ts). This needs ` + + `for this TypeScript repo (core test files live in packages/taskflow-core/test/*.test.ts). This needs ` + `several coordinated steps, so DELEGATE it as a SUBFLOW (a DAG) via a single ` + `ctx_spawn call whose assignment uses "subflow" (NOT "task").\n\n` + `The subflow MUST be {"phases":[...]} with these three phases (use these exact ids):\n\n` + `1. id "inventory", type "agent", agent "scout", output "json": task = ` + - `"Group the files under test/ into 3 coarse categories by concern (e.g. ` + + `"Group the files under packages/taskflow-core/test/ into 3 coarse categories by concern (e.g. ` + `runtime, schema/validation, storage). Output ONLY a JSON array of 3 objects ` + `[{\\"group\\":\\"...\\",\\"files\\":\\"comma-separated\\"}]."\n\n` + `2. id "analyze", type "map", over "{steps.inventory.json}", as "item", ` + @@ -75,49 +77,57 @@ async function main() { phases: {}, createdAt: Date.now(), updatedAt: Date.now(), cwd: process.cwd(), }; - console.log("== executing (real subagents: planner → [scout → map(analyst) → doc-writer]) =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), agents, globalThinking: settings.globalThinking, - persist: (s) => { saveRun(s); }, - onProgress: (s) => { - const done = Object.values(s.phases).filter((p) => p.status === "done").length; - const lead = s.phases.lead; - const sub = lead?.subProgress ? ` | subflow: ${lead.subProgress.done}/${lead.subProgress.total}` : ""; - process.stdout.write(`\r ${done}/${s.def.phases.length} done${sub} `); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-subflow-complex"); + try { + console.log("== executing (real subagents: planner → [scout → map(analyst) → doc-writer]) =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + persist: (s) => { saveRun(s); }, + onProgress: (s) => { + const done = Object.values(s.phases).filter((p) => p.status === "done").length; + const lead = s.phases.lead; + const sub = lead?.subProgress ? ` | subflow: ${lead.subProgress.done}/${lead.subProgress.total}` : ""; + process.stdout.write(`\r ${done}/${s.def.phases.length} done${sub} `); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); - let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; - try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } - const spawnedChildren = (tree.nodes ?? []).filter((n) => n.parentNodeId === "lead"); - const out = res.finalOutput ?? ""; + const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); + let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; + try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } + const spawnedChildren = (tree.nodes ?? []).filter((n) => n.parentNodeId === "lead"); + const out = res.finalOutput ?? ""; - console.log("\ntree nodes:", (tree.nodes ?? []).map((n) => `${n.nodeId}<-${n.parentNodeId ?? "-"}`).join(", ")); - console.log("\n── folded report (tail 900) ──\n" + out.slice(-900)); + console.log("\ntree nodes:", (tree.nodes ?? []).map((n) => `${n.nodeId}<-${n.parentNodeId ?? "-"}`).join(", ")); + console.log("\n── folded report (tail 900) ──\n" + out.slice(-900)); - // Evidence the nested DAG actually ran all three stages: the synthesized - // report should mention prioritization (P0/P1/P2) produced by the reduce, - // which only exists if inventory→map→reduce all completed. - const hasPrioritized = /P0|P1|P2/.test(out); - const noShapeError = !/failed validation|failed verification|not a Taskflow|zero phases|no-op/.test(out); + // Evidence the nested DAG actually ran all three stages: the synthesized + // report should mention prioritization (P0/P1/P2) produced by the reduce, + // which only exists if inventory→map→reduce all completed. + const hasPrioritized = /P0|P1|P2/.test(out); + const noShapeError = !/failed validation|failed verification|not a Taskflow|zero phases|no-op/.test(out); - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["lead spawned a subflow child", spawnedChildren.length >= 1], - ["spawn fold marker present", /ctx_spawn: \d+ child report/.test(out)], - ["subflow validated & ran (no shape/validation error)", noShapeError], - ["nested DAG completed end-to-end (prioritized report produced)", hasPrioritized], - ["report is substantial (>300 chars)", out.length > 300], - ["stayed within budget", res.state.status !== "blocked"], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } - console.log(allPass ? "\n✅ COMPLEX-SUBFLOW E2E PASSED" : "\n❌ COMPLEX-SUBFLOW E2E FAILED"); - process.exit(allPass ? 0 : 1); + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["lead spawned a subflow child", spawnedChildren.length >= 1], + ["spawn fold marker present", /ctx_spawn: \d+ child report/.test(out)], + ["subflow validated & ran (no shape/validation error)", noShapeError], + ["nested DAG completed end-to-end (prioritized report produced)", hasPrioritized], + ["report is substantial (>300 chars)", out.length > 300], + ["stayed within budget", res.state.status !== "blocked"], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [n, okk] of checks) { console.log(` ${okk ? "PASS" : "FAIL"} ${n}`); if (!okk) allPass = false; } + console.log(allPass ? "\n✅ COMPLEX-SUBFLOW E2E PASSED" : "\n❌ COMPLEX-SUBFLOW E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); + } } main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/packages/pi-taskflow/test/e2e-team.mts b/packages/pi-taskflow/test/e2e-team.mts index ae7a2c7..720d26e 100644 --- a/packages/pi-taskflow/test/e2e-team.mts +++ b/packages/pi-taskflow/test/e2e-team.mts @@ -3,7 +3,8 @@ * * A multi-role engineering team collaborates on ONE real deliverable: a * prioritized improvement plan for this repo's IPC subsystem - * (extensions/context-store.ts + extensions/runner.ts). It exercises the whole + * (`packages/taskflow-core/src/context-store.ts` + + * `packages/pi-taskflow/src/runner.ts`). It exercises the whole * collaboration surface against real `pi` subagents + a real model: * * 1. scout — surveys the subsystem ONCE, ctx_write's a shared "map" @@ -33,11 +34,13 @@ import * as path from "node:path"; import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; -import { runsDir } from "taskflow-core"; +import { runsDir, saveRun } from "taskflow-core"; import type { RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; const MARKER = `INV-${crypto.randomBytes(4).toString("hex").toUpperCase()}`; -const TARGET = "extensions/context-store.ts + extensions/runner.ts"; +const TARGET = "packages/taskflow-core/src/context-store.ts + packages/pi-taskflow/src/runner.ts"; const FLOW: Taskflow = { name: "e2e-team", @@ -184,91 +187,97 @@ async function main() { cwd: process.cwd(), }; - console.log("== executing (real team of subagents) =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), - agents, - globalThinking: settings.globalThinking, - persist: (s) => { - import("../extensions/store.ts").then((m) => m.saveRun(s)).catch(() => {}); - }, - onProgress: (s) => { - const done = Object.values(s.phases).filter((p) => p.status === "done").length; - const running = Object.values(s.phases) - .filter((p) => p.status === "running") - .map((p) => p.id); - process.stdout.write( - `\r ${done}/${s.def.phases.length} done` + - (running.length ? ` | running: ${running.join(",")}` : "") + - " ", - ); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-team"); + try { + console.log("== executing (real team of subagents) =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + persist: (s) => { + try { saveRun(s); } catch { /* best-effort; the E2E result is authoritative */ } + }, + onProgress: (s) => { + const done = Object.values(s.phases).filter((p) => p.status === "done").length; + const running = Object.values(s.phases) + .filter((p) => p.status === "running") + .map((p) => p.id); + process.stdout.write( + `\r ${done}/${s.def.phases.length} done` + + (running.length ? ` | running: ${running.join(",")}` : "") + + " ", + ); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - console.log("\nPhase states:"); - for (const p of FLOW.phases) { - const ps = res.state.phases[p.id]; - console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}]`); - } + console.log("\nPhase states:"); + for (const p of FLOW.phases) { + const ps = res.state.phases[p.id]; + console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}]`); + } - // ── Ground-truth inspection of the blackboard ── - const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); - const findingsDir = path.join(ctxDir, "findings"); - let findingFiles: string[] = []; - try { - findingFiles = fs.readdirSync(findingsDir).filter((f) => f.endsWith(".json") && !f.includes(".lock")); - } catch { /* none */ } - const blackboardKeys = new Set(); - let mapHasMarker = false; - for (const f of findingFiles) { + // ── Ground-truth inspection of the blackboard ── + const ctxDir = path.join(runsDir(process.cwd()), "ctx", runId); + const findingsDir = path.join(ctxDir, "findings"); + let findingFiles: string[] = []; try { - const obj = JSON.parse(fs.readFileSync(path.join(findingsDir, f), "utf-8")); - for (const k of Object.keys(obj)) blackboardKeys.add(k); - if (typeof obj.map === "string" && obj.map.includes(MARKER)) mapHasMarker = true; - } catch { /* skip */ } - } - let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; - try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } - const spawnedChildren = (tree.nodes ?? []).filter((n) => n.parentNodeId === "lead"); + findingFiles = fs.readdirSync(findingsDir).filter((f) => f.endsWith(".json") && !f.includes(".lock")); + } catch { /* none */ } + const blackboardKeys = new Set(); + let mapHasMarker = false; + for (const f of findingFiles) { + try { + const obj = JSON.parse(fs.readFileSync(path.join(findingsDir, f), "utf-8")); + for (const k of Object.keys(obj)) blackboardKeys.add(k); + if (typeof obj.map === "string" && obj.map.includes(MARKER)) mapHasMarker = true; + } catch { /* skip */ } + } + let tree: { nodes?: Array<{ nodeId: string; phaseId: string; parentNodeId?: string }> } = {}; + try { tree = JSON.parse(fs.readFileSync(path.join(ctxDir, "tree.json"), "utf-8")); } catch { /* none */ } + const spawnedChildren = (tree.nodes ?? []).filter((n) => n.parentNodeId === "lead"); - console.log("\nBlackboard ground truth:"); - console.log(` keys written: ${[...blackboardKeys].join(", ") || "(none)"}`); - console.log(` scout map carries marker ${MARKER}: ${mapHasMarker}`); - console.log(` lead spawned children: ${spawnedChildren.length}`); + console.log("\nBlackboard ground truth:"); + console.log(` keys written: ${[...blackboardKeys].join(", ") || "(none)"}`); + console.log(` scout map carries marker ${MARKER}: ${mapHasMarker}`); + console.log(` lead spawned children: ${spawnedChildren.length}`); - // Marker reuse by the experts (only obtainable from the shared map). - const expertOut = [ - res.state.phases["expert-correctness"]?.output ?? "", - res.state.phases["expert-architecture"]?.output ?? "", - res.state.phases["expert-risk"]?.output ?? "", - ]; - const expertsReusedMap = expertOut.filter((o) => o.includes(MARKER)).length; - console.log(` experts that echoed the shared marker: ${expertsReusedMap}/3`); + // Marker reuse by the experts (only obtainable from the shared map). + const expertOut = [ + res.state.phases["expert-correctness"]?.output ?? "", + res.state.phases["expert-architecture"]?.output ?? "", + res.state.phases["expert-risk"]?.output ?? "", + ]; + const expertsReusedMap = expertOut.filter((o) => o.includes(MARKER)).length; + console.log(` experts that echoed the shared marker: ${expertsReusedMap}/3`); - const leadOut = res.state.phases.lead?.output ?? ""; - const plan = res.finalOutput ?? ""; + const leadOut = res.state.phases.lead?.output ?? ""; + const plan = res.finalOutput ?? ""; - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["scout published the shared map (marker on blackboard)", mapHasMarker], - ["≥2 experts reused the shared map (collaboration, not re-deriving)", expertsReusedMap >= 2], - ["≥2 of 3 experts wrote findings back to the blackboard", ["find.correctness", "find.architecture", "find.risk"].filter((k) => blackboardKeys.has(k)).length >= 2], - ["lead dynamically spawned a deep-dive (recursive supervision)", spawnedChildren.length >= 1 || /spawned child/i.test(leadOut)], - ["gate ran", res.state.phases.gate?.status === "done"], - ["final plan has prioritized sections", /P0/.test(plan) && /P1/.test(plan)], - ["plan is substantial (>400 chars)", plan.length > 400], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [name, ok] of checks) { - console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); - if (!ok) allPass = false; + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["scout published the shared map (marker on blackboard)", mapHasMarker], + ["≥2 experts reused the shared map (collaboration, not re-deriving)", expertsReusedMap >= 2], + ["≥2 of 3 experts wrote findings back to the blackboard", ["find.correctness", "find.architecture", "find.risk"].filter((k) => blackboardKeys.has(k)).length >= 2], + ["lead dynamically spawned a deep-dive (recursive supervision)", spawnedChildren.length >= 1 || /spawned child/i.test(leadOut)], + ["gate ran", res.state.phases.gate?.status === "done"], + ["final plan has prioritized sections", /P0/.test(plan) && /P1/.test(plan)], + ["plan is substantial (>400 chars)", plan.length > 400], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [name, ok] of checks) { + console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); + if (!ok) allPass = false; + } + console.log("\n── Final prioritized plan ──\n" + plan.slice(0, 1200)); + console.log(allPass ? "\n✅ TEAM E2E PASSED" : "\n❌ TEAM E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); } - console.log("\n── Final prioritized plan ──\n" + plan.slice(0, 1200)); - console.log(allPass ? "\n✅ TEAM E2E PASSED" : "\n❌ TEAM E2E FAILED"); - process.exit(allPass ? 0 : 1); } main().catch((e) => { diff --git a/packages/pi-taskflow/test/e2e.mts b/packages/pi-taskflow/test/e2e.mts index a9cf17b..37d9f09 100644 --- a/packages/pi-taskflow/test/e2e.mts +++ b/packages/pi-taskflow/test/e2e.mts @@ -9,6 +9,8 @@ import { discoverAgents, readSubagentSettings } from "taskflow-core"; import { executeTaskflow } from "taskflow-core"; import { validateTaskflow, type Taskflow } from "taskflow-core"; import type { RunState } from "taskflow-core"; +import { installNoExtPiWrapper } from "./e2e-helpers.mts"; +import { piSubagentRunner } from "../src/runner.ts"; const FLOW: Taskflow = { name: "e2e-smoke", @@ -52,7 +54,7 @@ async function main() { console.log("valid ✓"); const settings = readSubagentSettings(); - const { agents } = discoverAgents(process.cwd(), "user", settings.agentOverrides, settings.modelRoles, settings.taskflow); + const { agents } = discoverAgents(process.cwd(), "user", settings.modelRoles, settings.taskflow); console.log(`discovered ${agents.length} agents; has scout: ${agents.some((a) => a.name === "scout")}`); const state: RunState = { @@ -67,47 +69,53 @@ async function main() { cwd: process.cwd(), }; - console.log("== executing (real subagents) =="); - const t0 = Date.now(); - const res = await executeTaskflow(state, { - cwd: process.cwd(), - agents, - globalThinking: settings.globalThinking, - onProgress: (s) => { - const done = Object.values(s.phases).filter((p) => p.status === "done").length; - const running = Object.values(s.phases) - .filter((p) => p.status === "running") - .map((p) => p.id); - process.stdout.write(`\r progress: ${done}/${s.def.phases.length} done` + (running.length ? ` | running: ${running.join(",")}` : "") + " "); - }, - }); - console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); + const restorePiBin = installNoExtPiWrapper("pi-taskflow-e2e-smoke"); + try { + console.log("== executing (real subagents) =="); + const t0 = Date.now(); + const res = await executeTaskflow(state, { + cwd: process.cwd(), + agents, + globalThinking: settings.globalThinking, + runTask: piSubagentRunner.runTask, + onProgress: (s) => { + const done = Object.values(s.phases).filter((p) => p.status === "done").length; + const running = Object.values(s.phases) + .filter((p) => p.status === "running") + .map((p) => p.id); + process.stdout.write(`\r progress: ${done}/${s.def.phases.length} done` + (running.length ? ` | running: ${running.join(",")}` : "") + " "); + }, + }); + console.log(`\n== done in ${((Date.now() - t0) / 1000).toFixed(1)}s ==`); - console.log("\nPhase states:"); - for (const p of FLOW.phases) { - const ps = res.state.phases[p.id]; - console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}] -> ${JSON.stringify(ps?.output?.slice(0, 80))}`); - } + console.log("\nPhase states:"); + for (const p of FLOW.phases) { + const ps = res.state.phases[p.id]; + console.log(` ${ps?.status === "done" ? "✓" : "✗"} ${p.id} [${p.type}] -> ${JSON.stringify(ps?.output?.slice(0, 80))}`); + } - console.log("\nFinal output:\n ", res.finalOutput); + console.log("\nFinal output:\n ", res.finalOutput); - // Assertions - const checks: Array<[string, boolean]> = [ - ["overall ok", res.ok], - ["list phase done", res.state.phases.list?.status === "done"], - ["map produced 3 sub-results", (res.state.phases.shout?.output?.match(/\[\d+\/3\]/g) ?? []).length === 3], - ["final mentions ALPHA", /ALPHA/i.test(res.finalOutput)], - ["final mentions BETA", /BETA/i.test(res.finalOutput)], - ["final mentions GAMMA", /GAMMA/i.test(res.finalOutput)], - ]; - console.log("\n== assertions =="); - let allPass = true; - for (const [name, ok] of checks) { - console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); - if (!ok) allPass = false; + // Assertions + const checks: Array<[string, boolean]> = [ + ["overall ok", res.ok], + ["list phase done", res.state.phases.list?.status === "done"], + ["map produced 3 sub-results", (res.state.phases.shout?.output?.match(/\[\d+\/3\]/g) ?? []).length === 3], + ["final mentions ALPHA", /ALPHA/i.test(res.finalOutput)], + ["final mentions BETA", /BETA/i.test(res.finalOutput)], + ["final mentions GAMMA", /GAMMA/i.test(res.finalOutput)], + ]; + console.log("\n== assertions =="); + let allPass = true; + for (const [name, ok] of checks) { + console.log(` ${ok ? "PASS" : "FAIL"} ${name}`); + if (!ok) allPass = false; + } + console.log(allPass ? "\n✅ E2E PASSED" : "\n❌ E2E FAILED"); + process.exit(allPass ? 0 : 1); + } finally { + restorePiBin(); } - console.log(allPass ? "\n✅ E2E PASSED" : "\n❌ E2E FAILED"); - process.exit(allPass ? 0 : 1); } main().catch((e) => { diff --git a/packages/pi-taskflow/test/runner-injection.test.ts b/packages/pi-taskflow/test/runner-injection.test.ts index 4344745..be359c5 100644 --- a/packages/pi-taskflow/test/runner-injection.test.ts +++ b/packages/pi-taskflow/test/runner-injection.test.ts @@ -14,10 +14,11 @@ * runTask, so the class of bug cannot recur silently. */ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { readFileSync, readdirSync } from "node:fs"; import { test } from "node:test"; const SRC = readFileSync(new URL("../src/index.ts", import.meta.url), "utf-8"); +const TEST_DIR = new URL("./", import.meta.url); /** * Verify every `const deps: RuntimeDeps = { ... }` block in the source sets @@ -61,6 +62,20 @@ test("regression: every executeTaskflow / recomputeTaskflow deps in pi index.ts assertAllDepsInjectRunTask(); }); +test("regression: every direct-execution .mts test injects runTask", () => { + const offenders: string[] = []; + for (const name of readdirSync(TEST_DIR).filter((entry) => entry.endsWith(".mts"))) { + const source = readFileSync(new URL(name, TEST_DIR), "utf-8"); + if (!/\bexecuteTaskflow\s*\(/.test(source)) continue; + if (!/\brunTask\s*:/.test(source)) offenders.push(name); + } + assert.deepEqual( + offenders, + [], + `direct-execution .mts tests must inject a real or mock host runner: ${offenders.join(", ")}`, + ); +}); + test("regression: the detached context file carries a runnerModule (runner injection for the child process)", () => { // The detached path injects the runner indirectly: the host serializes a // runnerModule into the context file, which the child dynamically imports. From d1ce8b875bab2635ce82842d58aa9948e73bebeb Mon Sep 17 00:00:00 2001 From: heggria Date: Sat, 11 Jul 2026 09:33:37 +0800 Subject: [PATCH 43/51] feat(website): redesign the 0.2 product homepage Add the compiler-first homepage, executable sample parity checks, base-path-safe export validation, and refreshed documentation links while preserving valid document shells. --- website/app/[lang]/docs/[[...slug]]/page.tsx | 2 +- website/app/[lang]/layout.tsx | 170 +- website/app/[lang]/page.tsx | 1376 ++++---------- website/app/globals.css | 1668 +++++++++++------ website/app/layout.tsx | 53 +- website/app/page.tsx | 76 +- .../components/home/authoring-switcher.tsx | 95 + website/components/home/compiler-bench.tsx | 219 +++ website/components/home/home-header.tsx | 68 + website/components/home/install-rail.tsx | 106 ++ website/content/docs/en/community.mdx | 2 +- .../docs/en/concepts/context-isolation.mdx | 2 +- website/content/docs/en/concepts/dag.mdx | 2 +- website/content/docs/en/concepts/phases.mdx | 4 +- .../content/docs/en/guides/claude-code.mdx | 2 +- .../docs/en/guides/code-audit-case-study.mdx | 4 +- website/content/docs/en/guides/codex.mdx | 2 +- website/content/docs/en/guides/opencode.mdx | 2 +- website/content/docs/en/guides/pi.mdx | 2 +- website/content/docs/en/showcase/index.mdx | 2 +- website/content/docs/en/syntax/caching.mdx | 12 +- .../docs/en/syntax/flow-definition.mdx | 2 +- .../content/docs/en/syntax/phase-types.mdx | 36 +- website/content/docs/en/syntax/scorers.mdx | 8 +- website/content/docs/en/what-is-taskflow.mdx | 2 +- .../content/docs/zh-cn/concepts/phases.mdx | 2 +- .../zh-cn/guides/code-audit-case-study.mdx | 2 +- .../content/docs/zh-cn/guides/grok-build.mdx | 82 +- website/content/docs/zh-cn/syntax/caching.mdx | 12 +- .../docs/zh-cn/syntax/flow-definition.mdx | 2 +- .../content/docs/zh-cn/syntax/phase-types.mdx | 32 +- website/content/docs/zh-cn/syntax/scorers.mdx | 4 +- website/lib/home-samples.ts | 69 + website/package.json | 4 +- website/scripts/check-export.mjs | 32 + website/scripts/check-home-samples.mjs | 16 + 36 files changed, 2418 insertions(+), 1756 deletions(-) create mode 100644 website/components/home/authoring-switcher.tsx create mode 100644 website/components/home/compiler-bench.tsx create mode 100644 website/components/home/home-header.tsx create mode 100644 website/components/home/install-rail.tsx create mode 100644 website/lib/home-samples.ts create mode 100644 website/scripts/check-export.mjs create mode 100644 website/scripts/check-home-samples.mjs diff --git a/website/app/[lang]/docs/[[...slug]]/page.tsx b/website/app/[lang]/docs/[[...slug]]/page.tsx index 09fc7ae..6086ac2 100644 --- a/website/app/[lang]/docs/[[...slug]]/page.tsx +++ b/website/app/[lang]/docs/[[...slug]]/page.tsx @@ -104,7 +104,7 @@ export default async function Page({ const redirectUrl = `${SITE_URL}${page.data.redirect}`; return ( <> - +

{lang === "zh-cn" ? "页面已移动:" : "This page has moved to "} diff --git a/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 841cc96..7401312 100644 --- a/website/app/[lang]/layout.tsx +++ b/website/app/[lang]/layout.tsx @@ -1,116 +1,100 @@ -import type { ReactNode } from 'react'; -import type { Viewport } from 'next'; -import { I18nProvider } from 'fumadocs-ui/contexts/i18n'; -import { i18n } from '@/lib/i18n'; -import type { Locale } from '@/lib/i18n'; +import { I18nProvider } from "fumadocs-ui/contexts/i18n"; +import type { Viewport } from "next"; +import type { ReactNode } from "react"; +import type { Locale } from "@/lib/i18n"; +import { i18n } from "@/lib/i18n"; export function generateStaticParams() { - return i18n.languages.map((lang) => ({ lang })); + return i18n.languages.map((lang) => ({ lang })); } const site = { - en: { - // Full SEO title for the homepage / OG cards. Subpages use `brand` in the - // `%s | taskflow` template below, so individual pages stay short. - title: 'taskflow — Declarative DAG Orchestration for Coding Agents', - brand: 'taskflow', - description: - 'A declarative, verifiable graph of task nodes for coding-agent subagents. Fan out, gate, loop, resume, and save as a command.', - }, - 'zh-cn': { - title: 'taskflow — 面向编程智能体的声明式 DAG 任务编排', - brand: 'taskflow', - description: - '面向编程智能体子代理的声明式、可验证任务节点图。支持 fan-out、gate、loop、断点续跑,并保存为命令。', - }, + en: { + title: "taskflow — Verify before spend", + brand: "taskflow", + description: + "Verify before spend. Resume across sessions. Recompute only what changed. taskflow is the compiled runtime for coding-agent orchestration.", + }, + "zh-cn": { + title: "taskflow — 花 token 前先验证", + brand: "taskflow", + description: + "花 token 前先验证,跨会话续跑,只重算变化部分。taskflow 是面向 coding-agent 编排的 compiled runtime。", + }, } as const; -// Google Search Console ownership verification (HTML-tag method). This value is -// public by design — it is safe to commit. Overridable via env for builds that -// want to keep it out of the repo. const GOOGLE_SITE_VERIFICATION = - process.env.GOOGLE_SITE_VERIFICATION || - 'iBm6KBJfiBJLOmW6jAtJCJlCbTiP7W9PhrDW6afMltw'; + process.env.GOOGLE_SITE_VERIFICATION || + "iBm6KBJfiBJLOmW6jAtJCJlCbTiP7W9PhrDW6afMltw"; -// The site is deployed under a GitHub Pages subpath (/taskflow), so every -// metadata asset URL (favicon, apple-touch-icon, manifest, icon PNGs) must be -// prefixed with the configured basePath. Next.js does NOT apply basePath to -// / under `output: export`, so we do -// it explicitly here. Locally (TASKFLOW_BASE_PATH unset) this is just ''. -const base = process.env.TASKFLOW_BASE_PATH || ''; +const base = process.env.TASKFLOW_BASE_PATH || ""; export async function generateMetadata({ - params, + params, }: { - params: Promise<{ lang: Locale }>; + params: Promise<{ lang: Locale }>; }) { - const { lang } = await params; - const meta = site[lang] ?? site.en; + const { lang } = await params; + const meta = site[lang] ?? site.en; - return { - metadataBase: new URL('https://heggria.github.io/taskflow'), - title: { - default: meta.title, - template: `%s | ${meta.brand}`, - }, - description: meta.description, - alternates: { - canonical: `/${lang}/`, - languages: { - en: '/en/', - 'zh-CN': '/zh-cn/', - 'x-default': '/en/', - }, - }, - icons: { - // Explicit basePath — Next.js omits it for under output:export. - icon: [{ url: `${base}/favicon.svg`, type: 'image/svg+xml' }], - apple: [{ url: `${base}/apple-icon`, sizes: '180x180', type: 'image/png' }], - }, - // themeColor lives in generateViewport() below (Next.js 15+ moved it out - // of metadata). appleWebApp pairs with app/manifest.ts + app/apple-icon.tsx - // so Safari/iOS/Android render a branded tab, splash, and home-screen icon. - appleWebApp: { - title: 'taskflow', - statusBarStyle: 'default', - capable: true, - }, - manifest: `${base}/manifest.webmanifest`, - verification: GOOGLE_SITE_VERIFICATION - ? { google: GOOGLE_SITE_VERIFICATION } - : undefined, - openGraph: { - title: meta.title, - description: meta.description, - images: '/opengraph-image', - }, - twitter: { - card: 'summary_large_image', - title: meta.title, - description: meta.description, - images: '/opengraph-image', - }, - }; + return { + metadataBase: new URL("https://heggria.github.io/taskflow"), + title: { + default: meta.title, + template: `%s | ${meta.brand}`, + }, + description: meta.description, + alternates: { + canonical: `/${lang}/`, + languages: { + en: "/en/", + "zh-CN": "/zh-cn/", + "x-default": "/en/", + }, + }, + icons: { + icon: [{ url: `${base}/favicon.svg`, type: "image/svg+xml" }], + apple: [ + { url: `${base}/apple-icon`, sizes: "180x180", type: "image/png" }, + ], + }, + appleWebApp: { + title: "taskflow", + statusBarStyle: "default" as const, + capable: true, + }, + manifest: `${base}/manifest.webmanifest`, + verification: GOOGLE_SITE_VERIFICATION + ? { google: GOOGLE_SITE_VERIFICATION } + : undefined, + openGraph: { + title: meta.title, + description: meta.description, + images: "/opengraph-image", + }, + twitter: { + card: "summary_large_image" as const, + title: meta.title, + description: meta.description, + images: "/opengraph-image", + }, + }; } export const viewport: Viewport = { - themeColor: [ - { media: '(prefers-color-scheme: light)', color: '#fff7ed' }, - { media: '(prefers-color-scheme: dark)', color: '#1c1917' }, - ], + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f8f6f1" }, + { media: "(prefers-color-scheme: dark)", color: "#0f1115" }, + ], }; export default async function LangLayout({ - children, - params, + children, + params, }: { - children: ReactNode; - params: Promise<{ lang: string }>; + children: ReactNode; + params: Promise<{ lang: string }>; }) { - const { lang } = await params; - return ( - - {children} - - ); + const { lang } = await params; + return {children}; } diff --git a/website/app/[lang]/page.tsx b/website/app/[lang]/page.tsx index 77aee6d..2bb0855 100644 --- a/website/app/[lang]/page.tsx +++ b/website/app/[lang]/page.tsx @@ -1,32 +1,11 @@ // biome-ignore-all lint/security/noDangerouslySetInnerHtml: JSON-LD uses dangerouslySetInnerHTML -import { HomeLayout } from "fumadocs-ui/layouts/home"; -import type { LucideIcon } from "lucide-react"; -import { - ArrowRight, - BookOpen, - Check, - ExternalLink, - FileJson, - FolderOpen, - GitBranch, - GitMerge, - History, - Layers, - Network, - Package, - Quote, - RefreshCw, - Server, - ShieldCheck, - Sparkles, - Star, - Terminal, - Users, - Workflow, - X, - Zap, -} from "lucide-react"; +import { ArrowRight, ArrowUpRight } from "lucide-react"; import Link from "next/link"; +import { AuthoringSwitcher } from "@/components/home/authoring-switcher"; +import { CompilerBench } from "@/components/home/compiler-bench"; +import { HomeHeader } from "@/components/home/home-header"; +import { InstallRail } from "@/components/home/install-rail"; +import { sampleFlowIR, sampleJson, sampleTs } from "@/lib/home-samples"; import type { Locale } from "@/lib/i18n"; import { i18n } from "@/lib/i18n"; @@ -34,455 +13,321 @@ export function generateStaticParams() { return i18n.languages.map((lang) => ({ lang })); } -const translations = { +const SITE = "https://heggria.github.io/taskflow"; + +const copy = { en: { - title: "taskflow", - tagline: "Declarative agent orchestration.", - valueProp: - "Describe multi-step coding-agent work as a DAG, verify it before a token is spent, and return only the final result to your context window.", - badge: "Zero runtime dependencies", - getStarted: "Get Started", - whatIs: "What is taskflow?", - github: "GitHub", - featureHeading: "Built for real agent workflows", - features: { - dag: { - title: "Declarative DAGs", - body: "Define phases, dependencies, and fan-out as data. The runtime turns your graph into isolated subagent calls.", - }, - isolation: { - title: "Context Isolation", - body: "Intermediate transcripts stay inside the runtime. Only the final phase reaches your conversation.", - }, - resume: { - title: "Cross-Session Resume", - body: "Paused or failed runs pick up where they left off. Cached phases skip automatically on re-run.", - }, + header: { + docs: "Docs", + examples: "Examples", + github: "GitHub", + localeEn: "EN", + localeZh: "中文", + }, + hero: { + eyebrow: "taskflow 0.2", + title: [ + "Verify before spend.", + "Resume across sessions.", + "Recompute only what changed.", + ], + sub: "taskflow turns multi-agent coding work into a compiled runtime: declared graphs, isolated execution, deterministic replay, and incremental recompute across Pi, Codex, Claude Code, OpenCode, and Grok.", + noteKicker: "Compiled runtime for coding agents", + noteBody: + "0.2 is not a nicer prompt ritual. It is a verifiable orchestration runtime with a real intermediate representation, resumable runs, and minimal frontier reruns.", + micro: + "Intermediates stay in the runtime. Only the result returns to the host.", + hosts: "Pi · Codex · Claude Code · OpenCode · Grok", + primary: "Read the docs", + secondary: "Install", + tertiary: "GitHub", + }, + bench: { + eyebrow: "Compiler Bench", + title: "One surface. Four invariants.", + sub: "Declared graph, verified execution, isolated return, minimal recompute.", + aria: "Compiler Bench showing a declared task graph, host return, verification checks, resumable execution, and incremental recompute.", + modeVerify: "verify", + modeRun: "run", + modeRecompute: "recompute", + graphLabel: "Declared graph", + hostLabel: "Host return", + hostTitle: "Prioritized risk summary", + hostBody: + "Auth boundary verified. 7 phases reused from cache. Re-ran only the changed frontier before returning the final answer.", + verifyLabel: "Verify", + resumeLabel: "Resume", + recomputeLabel: "Recompute", + verifyRows: [ + { key: "cycles", value: "0" }, + { key: "dead ends", value: "0" }, + { key: "refs", value: "resolved" }, + { key: "budget", value: "pass" }, + ], + resumeRows: [ + { key: "run state", value: "detached + resumable" }, + { key: "trace", value: "stored" }, + { key: "cache hits", value: "7 phases" }, + { key: "host output", value: "final only" }, + ], + recomputeRows: [ + { key: "changed inputs", value: "1 file" }, + { key: "stale frontier", value: "2 nodes" }, + { key: "reused", value: "discover + 6 reviews" }, + { key: "new spend", value: "minimal" }, + ], }, - code: { - label: "review-changes.json", - caption: - "A complete review flow: discover files, fan out reviews, then summarize.", + install: { + label: "Install on the host you already use.", + copy: "Copy", + copied: "Copied", + guide: "Guide", }, - workflow: { - heading: "From a JSON file to isolated subagents", - subheading: - "Describe, verify, execute, and return — all in one pipeline.", - steps: { - write: { - label: "Write a DAG", - description: "A single JSON file declares phases and dependencies.", + capabilities: { + title: "A runtime, not a prompt ritual.", + sub: "The page should prove contract, not list features.", + items: [ + { + title: "Verify", + body: "Static checks happen before any model call: cycles, dead ends, dangling refs, impossible budgets.", }, - verify: { - label: "Verify first", - description: - "Static checks catch cycles and budget issues before any tokens are spent.", + { + title: "Resume", + body: "Runs survive failures and survive sessions. Detached execution, trace storage, and resumable state are part of the runtime contract.", }, - fanout: { - label: "Fan out", - description: - "Map, parallel, and tournament phases run isolated subagents.", + { + title: "Recompute", + body: "When inputs change, taskflow re-runs the stale frontier instead of replaying the whole flow from zero.", }, - gate: { - label: "Gate & reduce", - description: "Quality gates and reduce phases aggregate results.", + ], + }, + ledger: { + title: "0.2 is the compiler turn.", + sub: "The graph is no longer only run. It is compiled, resumed, replayed, and incrementally recomputed.", + items: [ + { + tag: "S4", + title: "TypeScript DSL compiles to FlowIR", + body: "Author in .tf.ts, then erase into a canonical intermediate form.", }, - final: { - label: "Final output", - description: "Only the last phase returns to your host context.", + { + tag: "Core", + title: "Verify + trace + replay + detached runs", + body: "The runtime can check structure before spend and persist the whole operating envelope.", }, - }, - }, - stats: { - heading: "By the numbers", - hosts: { - value: "5 hosts", - label: "Pi, Codex, Claude Code, OpenCode, Grok", - }, - phases: { - value: "12 phase types", - label: "agent, map, gate, race, expand, and more", - }, - deps: { - value: "0 runtime deps", - label: "No production dependencies", - }, - resume: { - value: "Cross-session resume", - label: "Pick up where you left off", - }, - }, - comparison: { - heading: "Declarative vs imperative", - subheading: - "Why declare your agent workflows as data instead of scripting them?", - aspectHeader: "Aspect", - declarativeHeader: "Declarative", - imperativeHeader: "Imperative", - link: "Learn more", - rows: [ { - aspect: "Verifiable before tokens", - declarative: - "DAG checked for cycles, dead ends, and budget before any model call.", - imperative: "Bugs surface at runtime, after you have already paid.", + tag: "Cache", + title: "Cross-run content addressing", + body: "Unchanged work is reused instead of repurchased.", }, { - aspect: "Context cost", - declarative: "Only the final output returns to your context.", - imperative: "Every transcript floods the host conversation.", + tag: "Delta", + title: "why-stale + minimal frontier rerun", + body: "Change a file, then re-run only the affected phases.", }, { - aspect: "Resume after failure", - declarative: "Cached phases auto-skip on re-run.", - imperative: "Start over from the beginning.", + tag: "Hosts", + title: "Five host adapters", + body: "Pi, Codex, Claude Code, OpenCode, and Grok share one engine.", }, { - aspect: "Reusability", - declarative: "Save, version, and call by name.", - imperative: "Copy-paste scripts between runs.", + tag: "Return", + title: "Intermediates stay inside the runtime", + body: "The host only receives the final result, not the operational sludge.", }, ], }, - testimonials: { - heading: "What early users say", - quotes: [ - { - body: "We turned a 50-file security review from a context-window disaster into a 10-minute taskflow.", - role: "Platform Engineer, Series B startup", - }, + authoring: { + title: "Same runtime. Three surfaces.", + sub: "JSON for transport. TypeScript for authoring. FlowIR for the compiled contract.", + json: "JSON", + ts: "TypeScript", + flowir: "FlowIR", + noteTitle: "What stays invariant", + notes: [ + "The graph is explicit and versionable.", + "Verification happens before spend.", + "Phase identity can be fingerprinted and cached.", + "The host still receives only finalOutput.", + ], + }, + difference: { + title: "What changes when the graph is data.", + sub: "Not a category lecture — an operating difference.", + rows: [ { - body: "Cross-session resume alone saved us hours. A run dies at the summary step and we just continue it.", - role: "Staff Engineer, AI infrastructure", + label: "plan", + a: "declared and versioned", + b: "re-derived in prose", }, { - body: "The tournament phase consistently beats our single-shot headline and release-note drafts.", - role: "Developer Advocate, open-source tooling", + label: "spend", + a: "verified first", + b: "discovered during execution", }, + { label: "failure", a: "resumed", b: "restarted" }, + { label: "change", a: "minimally recomputed", b: "broadly rerun" }, ], + left: "taskflow", + right: "ad-hoc", }, cta: { - title: "Ready to declare your first DAG?", - body: "Install on Pi or Codex and run a multi-phase workflow in minutes.", - docs: "Read the docs", - templates: "Browse templates", - community: "Join community", + title: "Build the graph once. Rerun it precisely.", + body: "Verify before spend. Resume across sessions. Return only the result.", + primary: "Read the docs", + secondary: "Install", }, }, "zh-cn": { - title: "taskflow", - tagline: "声明式智能体编排。", - valueProp: - "把多步骤编程智能体工作描述成 DAG,在花费 token 之前验证它,并且只把最终结果返回到你的上下文窗口。", - badge: "零运行时依赖", - getStarted: "开始使用", - whatIs: "什么是 taskflow?", - github: "GitHub", - featureHeading: "为真实智能体工作流而建", - features: { - dag: { - title: "声明式 DAG", - body: "将阶段、依赖和 fan-out 定义为数据。运行时把你的图变成隔离的子代理调用。", - }, - isolation: { - title: "上下文隔离", - body: "中间记录留在运行时内部。只有最终阶段的结果会进入你的对话。", - }, - resume: { - title: "跨会话续跑", - body: "暂停或失败的运行从断点继续。重新运行时自动跳过已缓存阶段。", - }, + header: { + docs: "文档", + examples: "示例", + github: "GitHub", + localeEn: "EN", + localeZh: "中文", }, - code: { - label: "review-changes.json", - caption: "一个完整的审查工作流:发现文件、并行审查、然后汇总。", + hero: { + eyebrow: "taskflow 0.2", + title: ["花 token 前先验证。", "跨会话续跑。", "只重算变化部分。"], + sub: "taskflow 把多代理编程工作变成可编译的运行时:声明式图、隔离执行、确定性 replay,以及跨 Pi、Codex、Claude Code、OpenCode、Grok 的增量重算。", + noteKicker: "面向 coding agents 的 compiled runtime", + noteBody: + "0.2 不是更好看的 prompt 仪式,而是一套可验证的编排运行时:有中间表示、有续跑、有最小重算。", + micro: "中间过程留在运行时里。回到宿主的,只有结果。", + hosts: "Pi · Codex · Claude Code · OpenCode · Grok", + primary: "阅读文档", + secondary: "安装", + tertiary: "GitHub", }, - workflow: { - heading: "从 JSON 文件到隔离子代理", - subheading: "描述、验证、执行、返回——全部在一个流水线中完成。", - steps: { - write: { - label: "编写 DAG", - description: "单个 JSON 文件声明阶段与依赖。", - }, - verify: { - label: "先验证", - description: "在任何 token 花费前,静态检查捕获循环与预算问题。", - }, - fanout: { - label: "扇出执行", - description: "map、parallel 和 tournament 阶段运行隔离子代理。", - }, - gate: { - label: "门控与归约", - description: "质量门与 reduce 阶段聚合结果。", - }, - final: { - label: "最终输出", - description: "只有最后阶段返回到宿主上下文。", - }, - }, + bench: { + eyebrow: "Compiler Bench", + title: "一个台面,四个不变量。", + sub: "声明式图、可验证执行、隔离回传、最小重算。", + aria: "Compiler Bench:展示声明式任务图、宿主回传、验证检查、可续跑执行与增量重算。", + modeVerify: "verify", + modeRun: "run", + modeRecompute: "recompute", + graphLabel: "声明式图", + hostLabel: "宿主回传", + hostTitle: "优先级风险摘要", + hostBody: + "Auth 边界已验证。7 个阶段命中缓存。只重跑变化前沿后,把最终答案带回宿主。", + verifyLabel: "验证", + resumeLabel: "续跑", + recomputeLabel: "重算", + verifyRows: [ + { key: "环路", value: "0" }, + { key: "死路", value: "0" }, + { key: "引用", value: "已解析" }, + { key: "预算", value: "通过" }, + ], + resumeRows: [ + { key: "运行态", value: "detached + resumable" }, + { key: "trace", value: "已持久化" }, + { key: "缓存命中", value: "7 个阶段" }, + { key: "宿主输出", value: "仅 final" }, + ], + recomputeRows: [ + { key: "变化输入", value: "1 个文件" }, + { key: "陈旧前沿", value: "2 个节点" }, + { key: "复用", value: "discover + 6 个 review" }, + { key: "新增花费", value: "最小" }, + ], }, - stats: { - heading: "数据一览", - hosts: { - value: "5 个宿主", - label: "Pi、Codex、Claude Code、OpenCode、Grok", - }, - phases: { - value: "12 种阶段类型", - label: "agent、map、gate、race、expand 等", - }, - deps: { - value: "0 个运行时依赖", - label: "零生产依赖", - }, - resume: { - value: "跨会话续跑", - label: "从断点继续运行", - }, + install: { + label: "装到你已经在用的宿主上。", + copy: "复制", + copied: "已复制", + guide: "指南", }, - comparison: { - heading: "声明式 vs 命令式", - subheading: "为什么把智能体工作流声明为数据,而不是写成脚本?", - aspectHeader: "维度", - declarativeHeader: "声明式", - imperativeHeader: "命令式", - link: "了解更多", - rows: [ - { - aspect: "花费 token 前可验证", - declarative: "在任何模型调用前检查 DAG 的循环、死路和预算。", - imperative: "bug 在运行时才暴露,此时已经花了钱。", - }, + capabilities: { + title: "这是一套运行时,不是一次 prompt 仪式。", + sub: "这里要证明合同,而不是罗列功能。", + items: [ { - aspect: "上下文成本", - declarative: "只有最终结果返回上下文。", - imperative: "每次子代理的完整记录都会涌入宿主对话。", + title: "验证", + body: "在任何模型调用前完成静态检查:环路、死路、悬空引用、不可能的预算。", }, { - aspect: "失败后续跑", - declarative: "重新运行时自动跳过已缓存阶段。", - imperative: "从头开始重新运行。", + title: "续跑", + body: "运行能穿越失败,也能穿越会话。detached 执行、trace 持久化、可恢复状态都是运行时合同的一部分。", }, { - aspect: "可复用性", - declarative: "保存、版本化并按名称调用。", - imperative: "每次运行之间复制粘贴脚本。", + title: "重算", + body: "当输入变化时,taskflow 只重跑陈旧前沿,而不是把整条流从零重放。", }, ], }, - testimonials: { - heading: "早期用户反馈", - quotes: [ + ledger: { + title: "0.2 是编译器转身。", + sub: "图不再只是被运行;它开始被编译、被续跑、被 replay、被增量重算。", + items: [ + { + tag: "S4", + title: "TypeScript DSL 编译到 FlowIR", + body: "在 .tf.ts 中编写,再擦除成规范化中间表示。", + }, + { + tag: "Core", + title: "Verify + trace + replay + detached runs", + body: "运行时能在花费前检查结构,并持久化完整的运行包络。", + }, + { + tag: "Cache", + title: "跨 run 内容寻址复用", + body: "未变化的工作被复用,而不是被重新付费。", + }, { - body: "我们把一份 50 个文件的安全审查,从上下文窗口灾难变成了 10 分钟的 taskflow。", - role: "平台工程师,B 轮初创公司", + tag: "Delta", + title: "why-stale + 最小前沿重跑", + body: "改一个文件,再只重跑受影响的阶段。", }, { - body: "光是跨会话续跑就帮我们省了几个小时。运行死在汇总阶段时,我们只需继续它。", - role: "资深工程师,AI 基础设施", + tag: "Hosts", + title: "五个宿主适配器", + body: "Pi、Codex、Claude Code、OpenCode、Grok 共用同一套引擎。", }, { - body: "锦标赛阶段生成的标题和发布说明草稿,始终优于我们的单轮生成。", - role: "开发者布道师,开源工具", + tag: "Return", + title: "中间态留在运行时里", + body: "宿主收到的是最终结果,不是运行污泥。", }, ], }, + authoring: { + title: "同一运行时,三种表面。", + sub: "JSON 用于传输。TypeScript 用于编写。FlowIR 用于编译合同。", + json: "JSON", + ts: "TypeScript", + flowir: "FlowIR", + noteTitle: "不变的东西", + notes: [ + "图是显式的、可版本化的。", + "验证先于花费发生。", + "阶段身份可以被指纹化和缓存。", + "回到宿主的仍只有 finalOutput。", + ], + }, + difference: { + title: "当图成为数据,事情会怎么变。", + sub: "不是品类讲解,而是运行差异。", + rows: [ + { label: "plan", a: "声明并版本化", b: "每次重推为 prose" }, + { label: "spend", a: "先验证", b: "运行中才发现" }, + { label: "failure", a: "可续跑", b: "从头再来" }, + { label: "change", a: "最小重算", b: "大范围重跑" }, + ], + left: "taskflow", + right: "ad-hoc", + }, cta: { - title: "准备好声明你的第一个 DAG 了吗?", - body: "在 Pi 或 Codex 上安装,几分钟内运行多阶段工作流。", - docs: "阅读文档", - templates: "浏览模板", - community: "加入社区", + title: "图只搭一次,之后精确重跑。", + body: "先验证,能续跑,只把结果带回宿主。", + primary: "阅读文档", + secondary: "安装", }, }, -}; - -const SITE_URL = "https://heggria.github.io/taskflow"; - -const codeExample = `{ - "name": "review-changes", - "concurrency": 4, - "phases": [ - { - "id": "discover", - "type": "agent", - "agent": "scout", - "output": "json", - "task": "List changed source files under src/. Output ONLY a JSON array of {path} objects." - }, - { - "id": "review-each", - "type": "map", - "over": "{steps.discover.json}", - "as": "file", - "agent": "security-reviewer", - "dependsOn": ["discover"], - "task": "Review {file.path} for security risks. Return one paragraph." - }, - { - "id": "summarize", - "type": "reduce", - "from": ["review-each"], - "agent": "writer", - "dependsOn": ["review-each"], - "final": true, - "task": "Combine these reviews into one prioritized risk summary." - } - ] -}`; - -type JsonToken = { - type: "key" | "string" | "number" | "bool" | "punct" | "plain"; - value: string; -}; - -function tokenizeJsonLine(line: string): JsonToken[] { - const tokens: JsonToken[] = []; - const regex = - /("(?:\\.|[^"\\])*")|(\b(?:true|false|null)\b)|(-?\d+(?:\.\d+)?)|([{}\[\](),:])/g; - let match: RegExpExecArray | null = regex.exec(line); - let lastIndex = 0; - - while (match !== null) { - if (match.index > lastIndex) { - tokens.push({ - type: "plain", - value: line.slice(lastIndex, match.index), - }); - } - - const value = match[0]; - if (match[1] !== undefined) { - const after = line.slice(regex.lastIndex); - tokens.push({ - type: /^\s*:/.test(after) ? "key" : "string", - value, - }); - } else if (match[2] !== undefined) { - tokens.push({ type: "bool", value }); - } else if (match[3] !== undefined) { - tokens.push({ type: "number", value }); - } else { - tokens.push({ type: "punct", value }); - } - - lastIndex = regex.lastIndex; - match = regex.exec(line); - } - - if (lastIndex < line.length) { - tokens.push({ type: "plain", value: line.slice(lastIndex) }); - } - - return tokens; -} - -const jsonTokenClass: Record = { - key: "text-sky-600 dark:text-sky-400", - string: "text-emerald-600 dark:text-emerald-400", - number: "text-amber-600 dark:text-amber-400", - bool: "text-amber-600 dark:text-amber-400", - punct: "text-fd-muted-foreground", - plain: undefined, -}; - -function highlightJson(line: string) { - return ( - - {tokenizeJsonLine(line).map((token, i) => ( - - {token.value} - - ))} - - ); -} - -function PipelineConnector({ - direction = "horizontal", - className, -}: { - direction?: "horizontal" | "vertical"; - className?: string; -}) { - const isHorizontal = direction === "horizontal"; - return ( - - ); -} - -function WorkflowStep({ - icon: Icon, - label, - description, -}: { - icon: LucideIcon; - label: string; - description: string; -}) { - return ( -

-
-
-

{label}

-

{description}

-
- ); -} +} as const; export default async function HomePage({ params, @@ -490,46 +335,18 @@ export default async function HomePage({ params: Promise<{ lang: Locale }>; }) { const { lang } = await params; - const t = translations[lang] ?? translations.en; - - const featureList = [ - { - icon: Network, - title: t.features.dag.title, - body: t.features.dag.body, - }, - { - icon: Zap, - title: t.features.isolation.title, - body: t.features.isolation.body, - }, - { - icon: RefreshCw, - title: t.features.resume.title, - body: t.features.resume.body, - }, - ] as const; - - const statsList = [ - { icon: Server, value: t.stats.hosts.value, label: t.stats.hosts.label }, - { icon: Layers, value: t.stats.phases.value, label: t.stats.phases.label }, - { icon: Package, value: t.stats.deps.value, label: t.stats.deps.label }, - { icon: History, value: t.stats.resume.value, label: t.stats.resume.label }, - ] as const; + const t = copy[lang] ?? copy.en; const jsonLd = { "@context": "https://schema.org", "@type": "SoftwareApplication", name: "taskflow", - description: t.valueProp, + softwareVersion: "0.2.0", + description: t.hero.sub, applicationCategory: "DeveloperApplication", operatingSystem: "Any", - url: `${SITE_URL}/${lang}/`, - offers: { - "@type": "Offer", - price: "0", - priceCurrency: "USD", - }, + url: `${SITE}/${lang}/`, + offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, author: { "@type": "Organization", name: "heggria", @@ -538,530 +355,167 @@ export default async function HomePage({ }; return ( - - {/* JSON-LD is a safe static object */} +