From 76fcd11ffbba809702a26f0af1f31c661366754d Mon Sep 17 00:00:00 2001 From: heggria Date: Tue, 7 Jul 2026 10:54:58 +0800 Subject: [PATCH 01/16] refactor(mcp-core): rename taskflow-mcp package to taskflow-mcp-core The npm names `taskflow-mcp` and `taskflow-mcp-server` were squatted by an unrelated package, so the host-neutral MCP server ships as `taskflow-mcp-core`. Syncs the directory rename across dependency pins, workspace config, publish workflow, host adapters, and docs; fixes two stale references in CONTRIBUTING.md and the library RFC. Adapter bin names (codex-taskflow-mcp / claude-taskflow-mcp / opencode-taskflow-mcp) are unchanged. Part of the v0.1.6 release. --- .github/workflows/publish.yml | 8 +++--- AGENTS.md | 16 ++++++------ CHANGELOG.md | 26 ++++++++++++------- CONTRIBUTING.md | 2 +- DECISIONS.md | 6 ++--- README.md | 4 +-- README.zh-CN.md | 4 +-- RELEASE.md | 16 ++++++------ docs/claude-mcp.md | 6 ++--- docs/codex-mcp.md | 2 +- docs/opencode-mcp.md | 6 ++--- docs/rfc-library-reuse.md | 2 +- package.json | 4 +-- packages/claude-taskflow/package.json | 2 +- packages/claude-taskflow/src/mcp/server.ts | 6 ++--- .../claude-taskflow/test/mcp-server.test.ts | 2 +- packages/codex-taskflow/package.json | 2 +- packages/codex-taskflow/src/mcp/server.ts | 6 ++--- .../codex-taskflow/test/mcp-server.test.ts | 2 +- packages/opencode-taskflow/package.json | 2 +- packages/opencode-taskflow/src/mcp/server.ts | 6 ++--- .../opencode-taskflow/test/mcp-server.test.ts | 2 +- .../package.json | 6 ++--- .../src/mcp/jsonrpc.ts | 0 .../src/mcp/server.ts | 0 .../src/mcp/svg.ts | 0 .../tsconfig.build.json | 0 pnpm-lock.yaml | 14 +++++----- pnpm-workspace.yaml | 2 +- scripts/copy-readme.mjs | 2 +- 30 files changed, 82 insertions(+), 74 deletions(-) rename packages/{taskflow-mcp => taskflow-mcp-core}/package.json (92%) rename packages/{taskflow-mcp => taskflow-mcp-core}/src/mcp/jsonrpc.ts (100%) rename packages/{taskflow-mcp => taskflow-mcp-core}/src/mcp/server.ts (100%) rename packages/{taskflow-mcp => taskflow-mcp-core}/src/mcp/svg.ts (100%) rename packages/{taskflow-mcp => taskflow-mcp-core}/tsconfig.build.json (100%) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 65e70e7..edd23f9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,6 @@ name: Publish & Release -# Publishes taskflow-core + taskflow-mcp + taskflow-hosts + pi-taskflow + codex-taskflow + +# 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 # versions must equal the tag. @@ -46,7 +46,7 @@ jobs: echo "::error::Tag $TAG does not match root version $EXPECT" exit 1 fi - for pkg in taskflow-core taskflow-mcp taskflow-hosts pi-taskflow codex-taskflow claude-taskflow opencode-taskflow; do + for pkg in taskflow-core taskflow-mcp-core taskflow-hosts pi-taskflow codex-taskflow claude-taskflow opencode-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" @@ -103,7 +103,7 @@ jobs: fi } publish_one taskflow-core - publish_one taskflow-mcp + publish_one taskflow-mcp-core publish_one taskflow-hosts publish_one pi-taskflow publish_one codex-taskflow @@ -129,7 +129,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, 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, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow.')); " "$VERSION" gh release create "$GITHUB_REF_NAME" \ --notes-file /tmp/release-notes.md \ diff --git a/AGENTS.md b/AGENTS.md index f4b1b41..d3979f6 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`) and the three MCP host adapters (`codex-taskflow`, `claude-taskflow`, `opencode-taskflow`) all depend on `taskflow-core` (the adapters also depend on `taskflow-mcp`). Everything depends on `typebox`.\ -**Layout:** pnpm-workspace monorepo of seven published packages — `taskflow-core` (host-neutral engine), `taskflow-mcp` (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 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`).\ **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 @@ -39,7 +39,7 @@ packages/ │ │ ├─ typebox-helpers.ts / frontmatter.ts / paths.ts ← vendored pi-SDK helpers (zero-dep) │ │ └─ agents/ ← 18 built-in agent definitions (*.md with YAML frontmatter; copied to dist) │ └─ test/ ← engine unit tests -├─ taskflow-mcp/ ← host-neutral MCP server (depends on taskflow-core) +├─ taskflow-mcp-core/ ← host-neutral MCP server (depends on taskflow-core) │ ├─ src/mcp/ ← jsonrpc.ts (stdio JSON-RPC), server.ts (taskflow_* tools; parameterized by │ │ a SubagentRunner), svg.ts (DAG SVG/outline renderer) │ └─ test/ ← (covered by the host adapters' MCP tests) @@ -59,7 +59,7 @@ packages/ │ │ └─ init.ts ← /tf init command: scaffolds a taskflow / model roles interactively │ ├─ test/ ← pi-adapter unit tests + .mts e2e scripts │ └─ skills/ ← GENERATED per-host skill files (do not edit; see skills-src/) -└─ codex-taskflow/ ← Codex DELIVERY package (depends on taskflow-hosts + taskflow-mcp) +└─ codex-taskflow/ ← Codex DELIVERY package (depends on taskflow-hosts + taskflow-mcp-core) ├─ src/ │ ├─ index.ts ← re-exports the codex runner from taskflow-hosts (back-compat public surface) │ └─ mcp/ ← thin bind: server.ts re-exports core's MCP server bound to codexSubagentRunner; bin.ts @@ -69,7 +69,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 -└─ claude-taskflow/ ← Claude Code DELIVERY package (depends on taskflow-hosts + taskflow-mcp) +└─ claude-taskflow/ ← Claude Code DELIVERY package (depends on taskflow-hosts + taskflow-mcp-core) ├─ src/ │ ├─ index.ts ← re-exports the claude runner from taskflow-hosts (back-compat public surface) │ └─ mcp/ ← thin bind: server.ts re-exports core's MCP server bound to claudeSubagentRunner; bin.ts @@ -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) +└─ 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 @@ -211,7 +211,7 @@ pnpm run test:e2e-opencode-mcp # opencode MCP stdio e2e (src; no live opencod ### 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/*`). -- **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` package — host adapters import it via `taskflow-mcp/server` / `taskflow-mcp/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. +- **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). - **Examples**: flow definitions as `.json` in `examples/`. @@ -264,7 +264,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/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 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/CHANGELOG.md b/CHANGELOG.md index 917cf7e..6cb299f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,13 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [0.1.6] — 2026-07-06 ### Changed -- **Extracted the MCP server into its own `taskflow-mcp` package** (sixth +- **Extracted the MCP server into its own `taskflow-mcp-core` package** (sixth package). The stdio JSON-RPC server + `taskflow_*` tool handlers + DAG - SVG/outline renderer moved out of `taskflow-core` into `taskflow-mcp`, so + SVG/outline renderer moved out of `taskflow-core` into `taskflow-mcp-core`, so core is again purely the portable engine (DSL/runtime/cache/verify) and the MCP presentation layer is an independently-publishable unit. The host - adapters (codex/claude/opencode) now depend on `taskflow-mcp` and import it - via `taskflow-mcp/server` / `taskflow-mcp/jsonrpc`. `pi-taskflow` is + adapters (codex/claude/opencode) now depend on `taskflow-mcp-core` and import it + via `taskflow-mcp-core/server` / `taskflow-mcp-core/jsonrpc`. `pi-taskflow` is unaffected (it never used the MCP server). - **De-duplicated the three host runners.** The codex/claude/opencode runners each copy-pasted ~82 lines of identical process-handling boilerplate @@ -22,9 +22,17 @@ All notable changes to taskflow are documented here. This project follows [Keep parameterized by a per-host `SubagentAccumulator` + `foldLine`. Each host runner shrank to just its host-specific bits (argv, model-id rule, permission mapping, event parser): codex 366→217, claude 417→266, opencode - 397→247 lines. Behavior is identical (1092/1092 tests pass); adding a new + 397→247 lines. Behavior is identical (1140/1140 tests pass); adding a new host can no longer drift the process/classify contract. +- **Renamed the MCP server package from `taskflow-mcp` to `taskflow-mcp-core`** + at release time: `taskflow-mcp` (and `taskflow-mcp-server`) had been squatted + on npm by an unrelated package, so the host-neutral MCP server ships as + `taskflow-mcp-core`. The directory (`packages/taskflow-mcp-core`), the host + adapters dependency pins, and all `taskflow-mcp/server` / `taskflow-mcp/jsonrpc` + imports were updated accordingly. The adapters own bin names + (`codex-taskflow-mcp` / `claude-taskflow-mcp` / `opencode-taskflow-mcp`) are unchanged. + ### Added - **Library Phase 1: search-before-author + reusable-flow assets.** A new reusable-flow asset layer with sidecar `.meta.json` metadata @@ -44,7 +52,7 @@ All notable changes to taskflow are documented here. This project follows [Keep config scaffold, mirroring the Codex/Claude adapters. A flow's subagents can now execute as isolated `opencode run` sessions, and taskflow is exposed to OpenCode via the same `taskflow_*` MCP tools. Register with - `opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp` + `opencode mcp add taskflow -- npx -y -p opencode-taskflow opencode-taskflow-mcp-core` (or an `opencode.json` `mcp` entry). See `docs/opencode-mcp.md`. - Read-only phases inject a deny-mutations permission policy via `OPENCODE_CONFIG_CONTENT` (genuinely enforced); mutating phases run with @@ -325,7 +333,7 @@ All notable changes to taskflow are documented here. This project follows [Keep - **Codex plugin** (`packages/codex-taskflow/plugin/`) for zero-config, plug-and-play install: `codex plugin marketplace add heggria/taskflow` then `codex plugin add taskflow@taskflow`. Ships a `.codex-plugin/plugin.json` - manifest, a `.mcp.json` that launches the MCP server via `npx -y -p codex-taskflow@ codex-taskflow-mcp` + manifest, a `.mcp.json` that launches the MCP server via `npx -y -p codex-taskflow@ codex-taskflow-mcp-core` (no separate global install), and a routing `SKILL.md` so Codex reaches for the `taskflow_*` tools on multi-phase / fan-out work automatically. A repo-root `.claude-plugin/marketplace.json` makes the plugin discoverable. @@ -403,9 +411,9 @@ All notable changes to taskflow are documented here. This project follows [Keep typebox). Vendors the three small pi helpers it used (`StringEnum`, `parseFrontmatter`, `getAgentDir`) so it is fully standalone. - **`codex-taskflow`** — run taskflow on OpenAI Codex: a `codex exec`-backed - subagent runner, plus a dependency-free MCP server (`codex-taskflow-mcp`) that + subagent runner, plus a dependency-free MCP server (`codex-taskflow-mcp-core`) that exposes `taskflow_run/list/show/verify/compile` to Codex users. Register with - `codex mcp add taskflow -- codex-taskflow-mcp`. + `codex mcp add taskflow -- codex-taskflow-mcp-core`. - **Host-neutral `SubagentRunner` seam** — the engine drives any host via an injected `runTask`; `piSubagentRunner` and `codexSubagentRunner` are the two implementations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6d4fa3..bb19d95 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ See [`AGENTS.md`](./AGENTS.md) for the full layout and conventions. `taskflow` i |---------------------|------| | `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/` | Host-neutral MCP server: stdio JSON-RPC + `taskflow_*` tools + DAG SVG/outline renderer (depends on taskflow-core) | +| `packages/taskflow-mcp-core/` | Host-neutral MCP server: stdio JSON-RPC + `taskflow_*` tools + DAG SVG/outline renderer (depends on taskflow-core) | | `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 | diff --git a/DECISIONS.md b/DECISIONS.md index 2bc516e..de820d0 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,7 +1,7 @@ # Architecture Decisions (taskflow) > Status: **living document**. Records the structural decisions behind the -> multi-host layout (taskflow-core / taskflow-mcp / taskflow-hosts / pi-taskflow / +> multi-host layout (taskflow-core / taskflow-mcp-core / taskflow-hosts / pi-taskflow / > codex-taskflow / claude-taskflow / opencode-taskflow), the trade-offs that > were considered, and the direction to take as the host count grows. > @@ -164,12 +164,12 @@ error blob. - **`runner-core.ts` lives in `taskflow-core`.** It is host-neutral (no host SDK import — only `node:child_process`), so keeping it in core does not violate the "core has zero host-SDK deps" rule. Moving it out (e.g. into - `taskflow-mcp`) would force every host adapter to depend on a second package + `taskflow-mcp-core`) would force every host adapter to depend on a second package for no benefit. - **Skill generation is single-sourced** (`skills-src/` + `build-skills.mjs` + a drift-guard test). New hosts add one `entry..md` and extend the comma host list; the skill body is shared. Do not per-host the skill body. -- **MCP server is its own package (`taskflow-mcp`)** — a pure presentation +- **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). It is crude but it is *less* work than tracking which subset of packages need diff --git a/README.md b/README.md index 8030be4..84b74b7 100644 --- a/README.md +++ b/README.md @@ -895,7 +895,7 @@ Our `self-improve` flow is a 10-phase DAG — it audits the codebase, patches de ## Status & limits -**v0.1.6** (current release) — adds **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` 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` 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`. **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.6** (current release) — adds **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. Known boundaries (tracked, bounded — no surprises mid-flow): @@ -914,7 +914,7 @@ Known boundaries (tracked, bounded — no surprises mid-flow): | Package | Role | |---------|------| | [`taskflow-core`](./packages/taskflow-core) | Host-neutral orchestration engine (zero host-SDK deps; only `typebox`) — runtime, DSL, cache, verify | -| [`taskflow-mcp`](./packages/taskflow-mcp) | Host-neutral MCP server (stdio JSON-RPC + `taskflow_*` tools + DAG renderer); depends on core | +| [`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 | | [`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)) | diff --git a/README.zh-CN.md b/README.zh-CN.md index addb2e9..4f4646b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -770,7 +770,7 @@ provided files. Report violations grouped by file. No fixes. ## 状态与边界 -**v0.1.6**——当前发布版。完整历史详见 [CHANGELOG](./CHANGELOG.md)。本版新增 **库 Phase 1**(先搜后写 + 可复用流程资产)、**`defineFile` 参数**(从磁盘路径 verify/compile/run 流程)、以及流程定义文件的 **JSONC 注释支持**(`//` 与 `/* */` 注释 + 尾逗号,由零依赖的 `parseJsonc` 解析)。**v0.1.5** 新增了 **Claude Code 与 OpenCode 两个宿主**、**将 MCP 服务器拆为独立的 `taskflow-mcp` 包**,并**将三个宿主运行器去重**为共享的 `runSubagentProcess`。基线:**七个包的多宿主 monorepo**——宿主无关的 `taskflow-core` 引擎、宿主无关的 `taskflow-mcp` MCP 服务器、共享宿主运行器的 `taskflow-hosts`,加上 `pi-taskflow`(Pi 适配器)、`codex-taskflow`、`claude-taskflow`、`opencode-taskflow`(后三者为交付包,通过 `taskflow-hosts` 复用 runner + MCP bin + 插件/配置),共享 `taskflow-mcp` 中的宿主无关 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.6**——当前发布版。完整历史详见 [CHANGELOG](./CHANGELOG.md)。本版新增 **库 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`)之上。支持内联 + 已保存流程、跨会话恢复、实时进度和上下文隔离。一次运行作为一个流式工具调用执行。 已知边界(已追踪、有限定——不会在流程中途出现意外): @@ -789,7 +789,7 @@ provided files. Report violations grouped by file. No fixes. | 包 | 角色 | |----|------| | [`taskflow-core`](./packages/taskflow-core) | 宿主无关的编排引擎(零宿主 SDK 依赖;仅 `typebox`)——运行时、DSL、缓存、验证 | -| [`taskflow-mcp`](./packages/taskflow-mcp) | 宿主无关的 MCP 服务器(stdio JSON-RPC + `taskflow_*` 工具 + DAG 渲染);依赖 core | +| [`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 | | [`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)) | diff --git a/RELEASE.md b/RELEASE.md index ab63c11..2719126 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -5,18 +5,18 @@ taskflow is a monorepo of seven 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` | **`taskflow-mcp`** | Host-neutral MCP server (stdio JSON-RPC + taskflow_* tools + DAG renderer). Depends on core. | +| `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/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. | -Dependency order: `taskflow-mcp`, `taskflow-hosts`, `pi-taskflow`, `codex-taskflow`, `claude-taskflow`, and `opencode-taskflow` all depend on `taskflow-core` (`taskflow-mcp` and `taskflow-hosts` directly; the adapters via both `taskflow-hosts` and `taskflow-mcp`), so **core publishes first, then taskflow-mcp, then taskflow-hosts, then the adapters**. +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**. ## 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`, `taskflow-hosts`, `codex-taskflow`, `claude-taskflow`, `opencode-taskflow`) are unclaimed (publishing creates them). +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). ```sh # 1. Point at PUBLIC npm (the repo's default registry may be a private mirror) @@ -69,7 +69,7 @@ this release's CHANGELOG section, verify: ```sh # core FIRST — the adapters depend on it pnpm publish --filter taskflow-core --registry=https://registry.npmjs.org/ --provenance -pnpm publish --filter taskflow-mcp --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 pi-taskflow --registry=https://registry.npmjs.org/ --provenance pnpm publish --filter codex-taskflow --registry=https://registry.npmjs.org/ --provenance @@ -79,12 +79,12 @@ pnpm publish --filter opencode-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`, `taskflow-hosts`, and the host adapters +> **Note on `taskflow-core` as a dependency.** `taskflow-mcp-core`, `taskflow-hosts`, and the host adapters > (`pi-taskflow` / `codex-taskflow` / `claude-taskflow` / `opencode-taskflow`) > declare `"taskflow-core": "0.1.6"` (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` and `taskflow-hosts` are the -> other internal dependencies: the MCP host adapters pin `"taskflow-mcp"`; the codex/claude/opencode +> `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 > delivery packages pin `"taskflow-hosts"`.) ## Tag + GitHub Release (automated) @@ -101,7 +101,7 @@ git tag v0.1.6 && git push origin v0.1.6 ```sh pnpm view taskflow-core version --registry=https://registry.npmjs.org/ -pnpm view taskflow-mcp 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 pi-taskflow version --registry=https://registry.npmjs.org/ pnpm view codex-taskflow version --registry=https://registry.npmjs.org/ diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index e51ae70..5d725d2 100644 --- a/docs/claude-mcp.md +++ b/docs/claude-mcp.md @@ -8,13 +8,13 @@ directions, both built on the host-neutral `SubagentRunner` seam sessions (`packages/taskflow-hosts/src/claude-runner.ts`). 2. **Claude Code as the caller** — taskflow is exposed to a Claude Code 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 package - (`packages/taskflow-mcp/src/mcp/`); the claude adapter just binds them to + MCP protocol, tools, and rendering all live in the host-neutral taskflow-mcp-core package + (`packages/taskflow-mcp-core/src/mcp/`); the claude adapter just binds them to the `claude -p` subagent runner (`packages/claude-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/src/mcp/jsonrpc.ts`), so taskflow keeps its +built-ins (`packages/taskflow-mcp-core/src/mcp/jsonrpc.ts`), so taskflow keeps its **zero runtime dependencies** guarantee — no `@modelcontextprotocol/sdk`. ## Install (recommended): the Claude Code plugin diff --git a/docs/codex-mcp.md b/docs/codex-mcp.md index 6e5a02b..c165c13 100644 --- a/docs/codex-mcp.md +++ b/docs/codex-mcp.md @@ -11,7 +11,7 @@ both built on the host-neutral `SubagentRunner` seam (`packages/codex-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/src/mcp/jsonrpc.ts`), so taskflow keeps its +built-ins (`packages/taskflow-mcp-core/src/mcp/jsonrpc.ts`), so taskflow keeps its **zero runtime dependencies** guarantee — no `@modelcontextprotocol/sdk`. ## Install (recommended): the Codex plugin diff --git a/docs/opencode-mcp.md b/docs/opencode-mcp.md index a1228ac..8885307 100644 --- a/docs/opencode-mcp.md +++ b/docs/opencode-mcp.md @@ -8,13 +8,13 @@ on the host-neutral `SubagentRunner` seam sessions (`packages/taskflow-hosts/src/opencode-runner.ts`). 2. **OpenCode as the caller** — taskflow is exposed to an OpenCode user as an **MCP server**, so the `taskflow_*` tools appear in the session. The MCP - protocol, tools, and rendering all live in the host-neutral taskflow-mcp package - (`packages/taskflow-mcp/src/mcp/`); the opencode adapter just binds them to + protocol, tools, and rendering all live in the host-neutral taskflow-mcp-core package + (`packages/taskflow-mcp-core/src/mcp/`); the opencode adapter just binds them to the `opencode run` subagent runner (`packages/opencode-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/src/mcp/jsonrpc.ts`), so taskflow keeps its +built-ins (`packages/taskflow-mcp-core/src/mcp/jsonrpc.ts`), so taskflow keeps its **zero runtime dependencies** guarantee — no `@modelcontextprotocol/sdk`. ## Install: register the MCP server diff --git a/docs/rfc-library-reuse.md b/docs/rfc-library-reuse.md index d6c078e..9c8d75d 100644 --- a/docs/rfc-library-reuse.md +++ b/docs/rfc-library-reuse.md @@ -472,7 +472,7 @@ function buildReuseHint(result: SearchResult): string { ### 5.4 新增 MCP 工具清单(A5/N2 修复) -当前 MCP server(`taskflow-mcp`)的 TOOLS 数组只有 `taskflow_run`/`taskflow_list`/`taskflow_show`/`taskflow_verify`/`taskflow_compile`/`taskflow_peek`。library 功能需要新增以下 MCP 工具: +当前 MCP server(`taskflow-mcp-core`)的 TOOLS 数组只有 `taskflow_run`/`taskflow_list`/`taskflow_show`/`taskflow_verify`/`taskflow_compile`/`taskflow_peek`。library 功能需要新增以下 MCP 工具: | 工具名 | 参数 | 说明 | |--------|------|------| diff --git a/package.json b/package.json index d5e8ffd..dfa874e 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "pi-taskflow-monorepo", "version": "0.1.6", "private": true, - "description": "Monorepo for taskflow-core, taskflow-mcp, 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, 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 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", "build:website": "cd website && npm run build", "build:skills": "node scripts/build-skills.mjs", "typecheck": "tsc --noEmit", diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index f6fedcd..633dd34 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -56,6 +56,6 @@ "dependencies": { "taskflow-core": "0.1.6", "taskflow-hosts": "0.1.6", - "taskflow-mcp": "0.1.6" + "taskflow-mcp-core": "0.1.6" } } diff --git a/packages/claude-taskflow/src/mcp/server.ts b/packages/claude-taskflow/src/mcp/server.ts index 170598a..f6e291d 100644 --- a/packages/claude-taskflow/src/mcp/server.ts +++ b/packages/claude-taskflow/src/mcp/server.ts @@ -1,5 +1,5 @@ /** - * The Claude Code binding of the host-neutral MCP server (taskflow-mcp/server). + * The Claude Code 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 Claude Code: every subagent a flow spawns is itself @@ -11,8 +11,8 @@ import { makeMcpHandlers as coreMakeMcpHandlers, makeToolHandlers as coreMakeToolHandlers, startMcpServer as coreStartMcpServer, -} from "taskflow-mcp/server"; -import type { RpcHandler } from "taskflow-mcp/jsonrpc"; +} from "taskflow-mcp-core/server"; +import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { claudeSubagentRunner } from "taskflow-hosts"; /** Per-call tool handlers with claude subagent execution bound in. */ diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index a2b910c..72c8c35 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -10,7 +10,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; -import { serveStdio } from "taskflow-mcp/jsonrpc"; +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. */ diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index 8d9c2b2..1c61757 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -56,6 +56,6 @@ "dependencies": { "taskflow-core": "0.1.6", "taskflow-hosts": "0.1.6", - "taskflow-mcp": "0.1.6" + "taskflow-mcp-core": "0.1.6" } } diff --git a/packages/codex-taskflow/src/mcp/server.ts b/packages/codex-taskflow/src/mcp/server.ts index 1086669..5d6f613 100644 --- a/packages/codex-taskflow/src/mcp/server.ts +++ b/packages/codex-taskflow/src/mcp/server.ts @@ -1,5 +1,5 @@ /** - * The Codex binding of the host-neutral MCP server (taskflow-mcp/server). + * The Codex 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 Codex: every subagent a flow spawns is itself a @@ -11,8 +11,8 @@ import { makeMcpHandlers as coreMakeMcpHandlers, makeToolHandlers as coreMakeToolHandlers, startMcpServer as coreStartMcpServer, -} from "taskflow-mcp/server"; -import type { RpcHandler } from "taskflow-mcp/jsonrpc"; +} from "taskflow-mcp-core/server"; +import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { codexSubagentRunner } from "taskflow-hosts"; /** Per-call tool handlers with codex subagent execution bound in. */ diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 0d41b2a..e9187c9 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -11,7 +11,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; -import { serveStdio } from "taskflow-mcp/jsonrpc"; +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. */ diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index c2d2c57..5a0f103 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -55,6 +55,6 @@ "dependencies": { "taskflow-core": "0.1.6", "taskflow-hosts": "0.1.6", - "taskflow-mcp": "0.1.6" + "taskflow-mcp-core": "0.1.6" } } diff --git a/packages/opencode-taskflow/src/mcp/server.ts b/packages/opencode-taskflow/src/mcp/server.ts index 025803f..8478167 100644 --- a/packages/opencode-taskflow/src/mcp/server.ts +++ b/packages/opencode-taskflow/src/mcp/server.ts @@ -1,5 +1,5 @@ /** - * The OpenCode binding of the host-neutral MCP server (taskflow-mcp/server). + * The OpenCode 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 OpenCode: every subagent a flow spawns is itself an @@ -12,8 +12,8 @@ import { makeMcpHandlers as coreMakeMcpHandlers, makeToolHandlers as coreMakeToolHandlers, startMcpServer as coreStartMcpServer, -} from "taskflow-mcp/server"; -import type { RpcHandler } from "taskflow-mcp/jsonrpc"; +} from "taskflow-mcp-core/server"; +import type { RpcHandler } from "taskflow-mcp-core/jsonrpc"; import { opencodeSubagentRunner } from "taskflow-hosts"; /** Per-call tool handlers with opencode subagent execution bound in. */ diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 470ffb5..421af20 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -10,7 +10,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { PassThrough } from "node:stream"; -import { serveStdio } from "taskflow-mcp/jsonrpc"; +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. */ diff --git a/packages/taskflow-mcp/package.json b/packages/taskflow-mcp-core/package.json similarity index 92% rename from packages/taskflow-mcp/package.json rename to packages/taskflow-mcp-core/package.json index d7646ce..7dd0e68 100644 --- a/packages/taskflow-mcp/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,5 +1,5 @@ { - "name": "taskflow-mcp", + "name": "taskflow-mcp-core", "version": "0.1.6", "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.", "keywords": [ @@ -19,7 +19,7 @@ "repository": { "type": "git", "url": "git+https://github.com/heggria/taskflow.git", - "directory": "packages/taskflow-mcp" + "directory": "packages/taskflow-mcp-core" }, "type": "module", "engines": { @@ -53,7 +53,7 @@ "dist" ], "scripts": { - "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs taskflow-mcp", + "build": "rm -rf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-readme.mjs taskflow-mcp-core", "prepublishOnly": "npm run build" }, "publishConfig": { diff --git a/packages/taskflow-mcp/src/mcp/jsonrpc.ts b/packages/taskflow-mcp-core/src/mcp/jsonrpc.ts similarity index 100% rename from packages/taskflow-mcp/src/mcp/jsonrpc.ts rename to packages/taskflow-mcp-core/src/mcp/jsonrpc.ts diff --git a/packages/taskflow-mcp/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts similarity index 100% rename from packages/taskflow-mcp/src/mcp/server.ts rename to packages/taskflow-mcp-core/src/mcp/server.ts diff --git a/packages/taskflow-mcp/src/mcp/svg.ts b/packages/taskflow-mcp-core/src/mcp/svg.ts similarity index 100% rename from packages/taskflow-mcp/src/mcp/svg.ts rename to packages/taskflow-mcp-core/src/mcp/svg.ts diff --git a/packages/taskflow-mcp/tsconfig.build.json b/packages/taskflow-mcp-core/tsconfig.build.json similarity index 100% rename from packages/taskflow-mcp/tsconfig.build.json rename to packages/taskflow-mcp-core/tsconfig.build.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 106fa6d..ac37ca2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,9 +38,9 @@ importers: taskflow-hosts: specifier: 0.1.6 version: link:../taskflow-hosts - taskflow-mcp: + taskflow-mcp-core: specifier: 0.1.6 - version: link:../taskflow-mcp + version: link:../taskflow-mcp-core packages/codex-taskflow: dependencies: @@ -50,9 +50,9 @@ importers: taskflow-hosts: specifier: 0.1.6 version: link:../taskflow-hosts - taskflow-mcp: + taskflow-mcp-core: specifier: 0.1.6 - version: link:../taskflow-mcp + version: link:../taskflow-mcp-core packages/opencode-taskflow: dependencies: @@ -62,9 +62,9 @@ importers: taskflow-hosts: specifier: 0.1.6 version: link:../taskflow-hosts - taskflow-mcp: + taskflow-mcp-core: specifier: 0.1.6 - version: link:../taskflow-mcp + version: link:../taskflow-mcp-core packages/pi-taskflow: dependencies: @@ -103,7 +103,7 @@ importers: specifier: ^6.0.3 version: 6.0.3 - packages/taskflow-mcp: + packages/taskflow-mcp-core: dependencies: taskflow-core: specifier: 0.1.6 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 56b4563..2cad3ee 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,7 +3,7 @@ # that field and requires this file instead.) packages: - "packages/taskflow-core" - - "packages/taskflow-mcp" + - "packages/taskflow-mcp-core" - "packages/taskflow-hosts" - "packages/pi-taskflow" - "packages/codex-taskflow" diff --git a/scripts/copy-readme.mjs b/scripts/copy-readme.mjs index 97d06fa..ebab4ea 100644 --- a/scripts/copy-readme.mjs +++ b/scripts/copy-readme.mjs @@ -21,7 +21,7 @@ const here = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(here, ".."); // Packages that are published to npm and should carry the README. -const PUBLISHABLE = new Set(["taskflow-core", "taskflow-mcp", "pi-taskflow", "codex-taskflow", "claude-taskflow", "opencode-taskflow"]); +const PUBLISHABLE = new Set(["taskflow-core", "taskflow-mcp-core", "pi-taskflow", "codex-taskflow", "claude-taskflow", "opencode-taskflow"]); const REPO = "heggria/taskflow"; const BRANCH = "main"; From 9b1b7c9e6745d842a4f8d7313c0cb7006300d21f Mon Sep 17 00:00:00 2001 From: heggria Date: Tue, 7 Jul 2026 11:01:40 +0800 Subject: [PATCH 02/16] fix(pi-taskflow): make built-in agents upgrade hint truly one-time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session_start "built-in agents are no longer synced to .pi/agents/" hint claimed to be a one-time upgrade hint but had no persistence — it re-printed on every session as long as settings.json lacked a taskflow key and the project had .pi/agents/*.md. Track shown-state with a marker file (~/.pi/agent/.taskflow-upgrade-hint-shown), created atomically (flag "wx") right after the first print. Subsequent sessions skip the print. Best-effort: an unwritable agent dir only means the hint may show once more; it never blocks session startup. --- packages/pi-taskflow/src/index.ts | 34 ++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 8e84f3b..6b27694 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -18,6 +18,7 @@ import { RECOMMENDED_DEFAULTS, readSettings, writeSettings, + getSettingsPath, formatRolesReport, formatDiffReport, formatFlowResult, @@ -516,23 +517,32 @@ export default function (pi: ExtensionAPI) { // Upgrade hint: if the project already has .pi/agents/ with agent // files but no explicit taskflow settings, the user is upgrading // from the old default (sync=true) and may be surprised that sync - // is now disabled by default. + // is now disabled by default. Tracked by a marker file so it is shown + // at most once per agent dir (never repeats every session). try { const raw = readSettings(); if (!("taskflow" in raw)) { const fs = await import("node:fs"); const path = await import("node:path"); - const projectAgentsDir = path.join(ctx.cwd, ".pi", "agents"); - try { - const entries = fs.readdirSync(projectAgentsDir).filter((e: string) => e.endsWith(".md")); - if (entries.length > 0) { - console.warn( - `[taskflow] Note: built-in agents are no longer synced to .pi/agents/ by default. ` + - `If you rely on this, run /tf init → 'Configure taskflow preferences' to re-enable. ` + - `(This is a one-time upgrade hint.)`, - ); - } - } catch { /* .pi/agents/ doesn't exist — no hint needed */ } + const markerPath = path.join(path.dirname(getSettingsPath()), ".taskflow-upgrade-hint-shown"); + if (!fs.existsSync(markerPath)) { + const projectAgentsDir = path.join(ctx.cwd, ".pi", "agents"); + try { + const entries = fs.readdirSync(projectAgentsDir).filter((e: string) => e.endsWith(".md")); + if (entries.length > 0) { + console.warn( + `[taskflow] Note: built-in agents are no longer synced to .pi/agents/ by default. ` + + `If you rely on this, run /tf init → 'Configure taskflow preferences' to re-enable. ` + + `(This is a one-time upgrade hint.)`, + ); + // Persist the marker so the hint is not shown again. Best-effort: + // an unwritable agent dir just means it may show once more. + try { + fs.writeFileSync(markerPath, new Date().toISOString() + "\n", { flag: "wx" }); + } catch { /* marker already exists or unwritable — best effort */ } + } + } catch { /* .pi/agents/ doesn't exist — no hint needed */ } + } } } catch { // Best-effort: settings.json missing or unreadable is not an error. From cd384f77e695a2a755bfe76743307fa12cc927c5 Mon Sep 17 00:00:00 2001 From: heggria Date: Tue, 7 Jul 2026 11:40:43 +0800 Subject: [PATCH 03/16] docs(changelog): record the one-time upgrade-hint fix under [0.1.7] Fixed The ba0249d change (marker file so the "built-in agents are no longer synced" hint stops repeating every session) ships with the 0.1.7 release branch and is now noted in the changelog under a new [0.1.7] section. --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cb299f..1c630df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. +## [0.1.7] — 2026-07-07 + +### Fixed +- **The pi-taskflow "built-in agents upgrade" hint is now truly one-time.** It + previously re-printed every session while `settings.json` lacked a `taskflow` + key and the project had `.pi/agents/*.md`. A marker file + (`~/.pi/agent/.taskflow-upgrade-hint-shown`) is now written atomically (`wx` + flag) after the first print, so subsequent sessions skip it. Best-effort: an + unwritable agent dir only means the hint may show once more; it never blocks + session startup. + ## [0.1.6] — 2026-07-06 ### Changed From 7b481055a34098ad56fb3f0d69e6077f0bc9c13d Mon Sep 17 00:00:00 2001 From: heggria Date: Tue, 7 Jul 2026 15:41:15 +0800 Subject: [PATCH 04/16] fix(flowir): close three provenance/round-trip gaps found by design review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-agent review of the 0.2.0 DSL RFC (run review-020-design) cross-checked the FlowIR seam against PhaseSchema and found three correctness bugs in the *current* engine — independent of the DSL, but the DSL design made them load-bearing. All three affect cache/provenance soundness or JSON↔DSL round-trip fidelity. 1. SIDECAR_PHASE_FIELDS omitted 8 Phase fields (agent, run, input, timeout, expect, reflexion, idempotent, score). A JSON→FlowIR→JSON round-trip silently dropped them, leaving script/gate/loop phases unrunnable. Added them + a structural round-trip test that asserts every author field survives the projection (so a future omission fails loudly, not silently). 2. collectRefs did not scan `run` (array form), `input` (script stdin), or `def` (inline sub-flow string form) — all three support {steps.X} interpolation at runtime, so their references never entered the declared dependency graph. recompute could skip a phase whose stdin or argv genuinely depended on a now-stale upstream. Added scanning + per-field tests. 3. translateTaskflow built declaredDeps.reads from interpolation refs only; a phase's explicit `dependsOn` (a semantic ordering no interpolation captures — e.g. sequential scripts sharing the filesystem) was dropped from RunState.declaredDeps. Now merged (observed ∪ declared), with a regression test. 1144/1144 tests pass (4 new). The new sidecar test is reverse-verified: removing any covered field fails it. --- .../taskflow-core/src/flowir/translate.ts | 30 +++- packages/taskflow-core/src/schema.ts | 8 + .../test/flowir-declared.test.ts | 137 ++++++++++++++++++ 3 files changed, 168 insertions(+), 7 deletions(-) diff --git a/packages/taskflow-core/src/flowir/translate.ts b/packages/taskflow-core/src/flowir/translate.ts index aa92548..c100fc8 100644 --- a/packages/taskflow-core/src/flowir/translate.ts +++ b/packages/taskflow-core/src/flowir/translate.ts @@ -32,7 +32,12 @@ import type { // automatically through `Phase` indexing). // --------------------------------------------------------------------------- +// NOTE: keep in sync with PhaseSchema (schema.ts). Every Phase field that is +// NOT represented on FlowIRNode (id/type/when) must appear here, or it is +// silently dropped on JSON ↔ DSL round-trip (translateTaskflow copies these +// verbatim into the sidecar). Missing fields here = data loss on round-trip. const SIDECAR_PHASE_FIELDS = [ + "agent", "task", "over", "as", @@ -41,9 +46,13 @@ const SIDECAR_PHASE_FIELDS = [ "use", "def", "with", + "run", + "input", + "timeout", "until", "maxIterations", "convergence", + "reflexion", "variants", "judge", "judgeAgent", @@ -53,19 +62,22 @@ const SIDECAR_PHASE_FIELDS = [ "when", "retry", "output", + "expect", "model", "thinking", "tools", "cwd", + "final", + "optional", + "idempotent", + "concurrency", "context", "contextLimit", "onBlock", "eval", + "score", "cache", "shareContext", - "optional", - "final", - "concurrency", ] as const; /** Build the per-phase sidecar record (verbatim copy of non-IR fields). */ @@ -112,9 +124,13 @@ export function translateTaskflow(def: Taskflow): { const nodes: FlowIRNode[] = def.phases.map((phase) => { const refs = collectRefs(phase); - // declared reads: the {steps.X} refs this phase statically references. - const reads = refs.steps.filter((id) => id !== phase.id); - declaredDeps[phase.id] = { reads, writes: [phase.id] }; + // declared reads: the {steps.X} refs this phase statically references, + // UNION the phase's explicit dependsOn (a declared-but-unobserved edge — + // e.g. a semantic ordering that no interpolation captures — still counts + // as a dependency for staleness propagation; observed ∪ declared). + const reads = new Set(refs.steps.filter((id) => id !== phase.id)); + for (const d of phase.dependsOn ?? []) if (d !== phase.id) reads.add(d); + declaredDeps[phase.id] = { reads: Array.from(reads), writes: [phase.id] }; // Advisory: a {steps.X} ref whose target doesn't exist (mirrors the // validation check but non-fatal here — validation is the source of @@ -139,7 +155,7 @@ export function translateTaskflow(def: Taskflow): { return { id: phase.id, kind: phase.type ?? "agent", - inject: reads, + inject: Array.from(reads), emits: [phase.id], when: phase.when, } satisfies FlowIRNode; diff --git a/packages/taskflow-core/src/schema.ts b/packages/taskflow-core/src/schema.ts index dfe4873..1a7fb4f 100644 --- a/packages/taskflow-core/src/schema.ts +++ b/packages/taskflow-core/src/schema.ts @@ -1073,6 +1073,14 @@ export function collectRefs(phase: Phase): { steps: string[]; args: string[] } { scan(phase.over); scan(phase.when); scan(phase.until); + // Script phases: the array form of `run` supports {steps.X}/{args.X} + // interpolation (the string form does NOT — it's a raw shell command, + // validation rejects placeholders in it), and `input` (stdin) does too. + if (Array.isArray(phase.run)) for (const r of phase.run) if (typeof r === "string") scan(r); + if (typeof phase.input === "string") scan(phase.input); + // Inline sub-flow: a *string* `def` is interpolated then JSON-parsed, so its + // {steps.X} refs are real dependencies. An object `def` is used verbatim. + if (typeof phase.def === "string") scan(phase.def); for (const e of asArray(phase.eval)) if (typeof e === "string") scan(e); // Scoring gates: the target ref and judge prompt are interpolated at runtime. const score = (phase as { score?: { target?: unknown; judge?: { task?: unknown } } }).score; diff --git a/packages/taskflow-core/test/flowir-declared.test.ts b/packages/taskflow-core/test/flowir-declared.test.ts index 0176f7a..9416371 100644 --- a/packages/taskflow-core/test/flowir-declared.test.ts +++ b/packages/taskflow-core/test/flowir-declared.test.ts @@ -206,3 +206,140 @@ test("DeclaredDeps: gate `eval` refs are captured in the declared plane", async const ir = await compileTaskflowToIR(f); assert.deepEqual(ir.meta.declaredDeps.quality.reads, ["build"]); }); + +// --------------------------------------------------------------------------- +// Regression coverage for three bugs found by the 0.2.0-DSL multi-agent review +// (review-020-design run, 2026-07-07). Each test pins a previously-broken +// behavior so the bug cannot silently return. +// --------------------------------------------------------------------------- + +test("DeclaredDeps: script `run` (array) + `input` interpolation refs are captured", async () => { + // collectRefs previously scanned task/over/when/until/eval/score/branches/with/context + // but NOT `run` (array form) or `input`. The runtime interpolates both, so a + // missing declared edge here would let `recompute` skip a phase whose stdin + // or argv genuinely depended on an upstream output. + const f = flow([ + { id: "gen", type: "agent", task: "generate code" } as Phase, + { + id: "lint", + type: "script", + run: ["eslint", "--stdin"], + input: "review this:\n{steps.gen.output}", + dependsOn: ["gen"], + final: true, + } as Phase, + ]); + const ir = await compileTaskflowToIR(f); + // {steps.gen.output} in `input` must produce a declared read on `gen`. + assert.ok( + ir.meta.declaredDeps.lint.reads.includes("gen"), + "script `input` {steps.gen.output} must register as a declared read on `gen`", + ); +}); + +test("DeclaredDeps: string `def` (inline sub-flow) interpolation refs are captured", async () => { + // A string `def` is interpolated then JSON-parsed at runtime; its {steps.X} + // refs are real dependencies. (An object `def` is used verbatim — not scanned.) + const f = flow([ + { id: "planner", type: "agent", task: "emit a sub-flow" } as Phase, + { + id: "exec", + type: "flow", + def: "{steps.planner.json}", + final: true, + } as Phase, + ]); + const ir = await compileTaskflowToIR(f); + assert.ok( + ir.meta.declaredDeps.exec.reads.includes("planner"), + "string `def` {steps.planner.json} must register as a declared read on `planner", + ); +}); + +test("DeclaredDeps: explicit `dependsOn` is merged into declared reads (observed ∪ declared)", async () => { + // A phase may depend on another SEMANTICALLY (must run after it) without + // interpolating its output — e.g. sequential scripts where A writes a file + // and B reads it from the filesystem. Previously `reads` came only from + // collectRefs (interpolation), so the explicit edge was missing from + // RunState.declaredDeps and `recompute` could skip B when A went stale. + const f = flow([ + { id: "setup", type: "script", run: "mkdir -p /tmp/build" } as Phase, + { + id: "build", + type: "script", + run: "tsc", + // No interpolation referencing `setup`, but build MUST run after it. + dependsOn: ["setup"], + final: true, + } as Phase, + ]); + const ir = await compileTaskflowToIR(f); + assert.deepEqual(ir.meta.declaredDeps.build.reads, ["setup"]); +}); + +test("Sidecar round-trip: every previously-dropped Phase field is preserved", async () => { + // SIDECAR_PHASE_FIELDS previously omitted 8 fields: agent, run, input, + // timeout, expect, reflexion, idempotent, score. A JSON → FlowIR → JSON + // round-trip silently lost them, leaving script/gate/loop phases unrunnable. + // This test pins the full field set so a future omission fails loudly. + const everyField = { + id: "kitchen-sink", + type: "agent", + agent: "executor-code", + task: "do everything", + over: "{steps.x}", + as: "row", + branches: [{ task: "b" }], + from: ["x"], + use: "lib", + def: "{steps.x}", + with: { k: "{steps.x}" }, + run: ["echo", "{steps.x}"], + input: "pipe {steps.x}", + timeout: 30000, + until: "{steps.x} == done", + maxIterations: 3, + convergence: true, + reflexion: true, + variants: 2, + judge: "pick", + judgeAgent: "final-arbiter", + mode: "best", + dependsOn: ["x"], + join: "any", + when: "{steps.x} == ok", + retry: { max: 2 }, + output: "json", + expect: { type: "object" }, + model: "strong", + thinking: "high", + tools: ["read"], + cwd: "worktree", + final: true, + optional: true, + idempotent: false, + concurrency: 4, + context: ["{steps.x}"], + contextLimit: 4000, + onBlock: "retry", + eval: ["{steps.x} contains ok"], + score: { target: "{steps.x}", scorers: [{ type: "contains", value: "ok" }], combine: "all" }, + cache: { scope: "cross-run", ttl: "7d", fingerprint: ["git:HEAD"] }, + shareContext: true, + } as Phase; + const f = flow([{ id: "x", type: "agent", task: "seed" } as Phase, everyField]); + const ir = await compileTaskflowToIR(f); + const sidecar = (ir.meta.sidecar as { phases: Record> }).phases["kitchen-sink"]; + // The 8 previously-dropped fields must all survive the projection. + for (const previouslyDropped of ["agent", "run", "input", "timeout", "expect", "reflexion", "idempotent", "score"]) { + assert.ok( + previouslyDropped in sidecar, + `sidecar dropped '${previouslyDropped}' — SIDECAR_PHASE_FIELDS regression`, + ); + } + // And nothing the author wrote should be silently lost either. + for (const key of Object.keys(everyField)) { + if (key === "id" || key === "type" || key === "when") continue; // FlowIRNode carries these + assert.ok(key in sidecar, `sidecar dropped author field '${key}'`); + } +}); From cbc94f0b1313bb6d5b4cefaeca421e9f53afd849 Mon Sep 17 00:00:00 2001 From: heggria Date: Tue, 7 Jul 2026 16:26:46 +0800 Subject: [PATCH 05/16] fix(core): surface parse errors with position in file loaders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four user-facing file loaders (readDefineFile, readFlowFile/listFlows, tryReadRunFile, readMeta/readMetaNextTo) collapsed two distinct failures — file missing vs file malformed — into a single null / "X not found or unparseable" result. The underlying JSON.parse SyntaxError (byte offset + line/column) was swallowed by the lenient safeParse and never reached the user, so a hand-authored defineFile with a stray bare newline reported "defineFile not found or unparseable" and sent authors chasing a phantom path/cwd problem. Loaders now return a discriminated LoadResult with reason "missing" | "unparseable" and a detail field carrying the original parse error. A shared describeLoadFailure(r, what) formats the user-facing message. New strict parseStrict(text, { allowFence }) in interpolate.ts preserves the SyntaxError; safeParse is unchanged so all ~25 LLM/subagent output paths keep their fail-open recovery. listFlows now warns on a corrupt saved flow instead of silently dropping it; new getFlowDiagnosed / loadRunDiagnosed distinguish corrupt-vs-missing for by-name / by-runId resolution. Breaking (pre-1.0): readDefineFile, readMeta, readMetaNextTo return LoadResult instead of T | null. The intentionally fail-open paths (index-rebuild scans, cache.ts file hashing, path-traversal rejections) are unchanged. 1392 tests pass (core 1146 + adapters 246); typecheck clean. --- CHANGELOG.md | 26 ++ .../codex-taskflow/test/mcp-server.test.ts | 2 +- packages/pi-taskflow/src/index.ts | 112 ++++---- packages/taskflow-core/src/interpolate.ts | 42 +++ packages/taskflow-core/src/library/search.ts | 6 +- packages/taskflow-core/src/store.ts | 244 ++++++++++++------ .../taskflow-core/test/define-file.test.ts | 56 +++- .../taskflow-core/test/library-search.test.ts | 2 +- .../taskflow-core/test/library-store.test.ts | 23 +- packages/taskflow-mcp-core/src/mcp/server.ts | 27 +- 10 files changed, 388 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c630df..895f912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to taskflow are documented here. This project follows [Keep ## [0.1.7] — 2026-07-07 ### Fixed +- **File loaders now report *why* a file failed, with the parse position — + instead of a merged "not found or unparseable" message.** Four user-facing + loaders (`readDefineFile`, `readFlowFile`/`listFlows`, `tryReadRunFile`, and + the library sidecar `readMeta`/`readMetaNextTo`) used to collapse two + distinct failures — *file missing* and *file malformed* — into a single + `null` / `"… not found or unparseable"` result. The underlying `JSON.parse` + `SyntaxError` (which carries the offending byte offset and, on Node ≥17, a + line/column) was swallowed by the lenient `safeParse` and never reached the + user. A hand-authored `defineFile` with a stray bare newline inside a string + literal therefore reported "defineFile not found or unparseable" and sent + authors chasing a phantom path/cwd problem. The loaders now return a + discriminated `LoadResult` (`{ ok: true, value } | { ok: false, reason: + "missing" | "unparseable", path, detail }`); `detail` carries the original + parse error (e.g. `Bad control character in string literal in JSON at + position 3979 (line 30 column 801)`), surfaced via a shared + `describeLoadFailure(r, what)` helper. New strict `parseStrict(text, + { allowFence })` (in `interpolate.ts`) preserves the `SyntaxError`; the + lenient `safeParse` is unchanged, so all ~25 LLM/subagent output paths keep + their fail-open fence/balanced-bracket recovery. `listFlows` now `console.warn`s + on a corrupt saved flow instead of silently dropping it (so `getFlow(name)` + no longer reports "not found" for a file that clearly exists); new + `getFlowDiagnosed` / `loadRunDiagnosed` distinguish corrupt-vs-missing for + by-name/by-runId resolution. **Breaking** (pre-1.0): `readDefineFile`, + `readMeta`, and `readMetaNextTo` return `LoadResult` instead of `T | null`. + The opaquely-fail-open paths (index-rebuild scans, `cache.ts` file hashing, + path-traversal rejections) are intentionally unchanged. - **The pi-taskflow "built-in agents upgrade" hint is now truly one-time.** It previously re-printed every session while `settings.json` lacked a `taskflow` key and the project had `.pi/agents/*.md`. A marker file diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index e9187c9..a17ce12 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -173,7 +173,7 @@ test("mcp: taskflow_verify with a missing defineFile returns a clear error", asy }, ]); assert.equal(res.error.code, -32602); - assert.match(res.error.message, /defineFile not found or unparseable/); + assert.match(res.error.message, /defineFile not found:/); }); test("mcp: tools/call unknown tool returns invalid-params", async () => { diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 6b27694..4f1dd3a 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -35,9 +35,11 @@ import { type UsageStats } from "taskflow-core"; import { finalPhase, resolveArgs, type Taskflow, validateTaskflow, desugar, isShorthand } from "taskflow-core"; import { getFlow, + getFlowDiagnosed, listFlows, listRuns, loadRun, + loadRunDiagnosed, newRunId, peekRun, type RunState, @@ -45,6 +47,7 @@ import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, readDefineFile, + describeLoadFailure, readMeta, saveFlowWithMeta, bumpReuseInSidecar, @@ -685,7 +688,8 @@ export default function (pi: ExtensionAPI) { const text = flows.length ? flows .map((f) => { - const meta = readMeta(ctx.cwd, f.name); + const metaR = readMeta(ctx.cwd, f.name); + const meta = metaR.ok ? metaR.value : undefined; const base = `- ${f.name} (${f.scope}): ${f.def.description ?? ""} — ${f.def.phases?.length ?? 0} phase(s)`; if (meta?.purpose) { const purpose = meta.purpose.length > 20 ? meta.purpose.slice(0, 20) + "…" : meta.purpose; @@ -737,8 +741,8 @@ export default function (pi: ExtensionAPI) { let resolvedDefine: unknown = params.define; if (resolvedDefine === undefined && typeof params.defineFile === "string" && params.defineFile.trim()) { const fromFile = readDefineFile(params.defineFile); - if (fromFile === null) return errorResult(action, `defineFile not found or unparseable: ${params.defineFile}`); - resolvedDefine = fromFile; + if (!fromFile.ok) return errorResult(action, describeLoadFailure(fromFile, "defineFile")); + resolvedDefine = fromFile.value; } if (typeof resolvedDefine === "string") { const parsed = safeParse(resolvedDefine); @@ -794,8 +798,8 @@ export default function (pi: ExtensionAPI) { let resolvedDefine: unknown = params.define; if (resolvedDefine === undefined && typeof params.defineFile === "string" && params.defineFile.trim()) { const fromFile = readDefineFile(params.defineFile); - if (fromFile === null) return errorResult(action, `defineFile not found or unparseable: ${params.defineFile}`); - resolvedDefine = fromFile; + if (!fromFile.ok) return errorResult(action, describeLoadFailure(fromFile, "defineFile")); + resolvedDefine = fromFile.value; } if (typeof resolvedDefine === "string") { const parsed = safeParse(resolvedDefine); @@ -840,8 +844,8 @@ export default function (pi: ExtensionAPI) { let resolvedDefine: unknown = params.define; if (resolvedDefine === undefined && typeof params.defineFile === "string" && params.defineFile.trim()) { const fromFile = readDefineFile(params.defineFile); - if (fromFile === null) return errorResult(action, `defineFile not found or unparseable: ${params.defineFile}`); - resolvedDefine = fromFile; + if (!fromFile.ok) return errorResult(action, describeLoadFailure(fromFile, "defineFile")); + resolvedDefine = fromFile.value; } if (typeof resolvedDefine === "string") { const parsed = safeParse(resolvedDefine); @@ -889,8 +893,9 @@ export default function (pi: ExtensionAPI) { if (action === "resume") { if (!params.runId) return errorResult(action, "action=resume requires 'runId'"); - const prev = loadRun(ctx.cwd, params.runId); - if (!prev) return errorResult(action, `Run not found: ${params.runId}`); + const prevR = loadRunDiagnosed(ctx.cwd, params.runId); + if (!prevR.ok) return errorResult(action, describeLoadFailure(prevR, `Run "${params.runId}"`)); + const prev = prevR.value; const result = await runFlow(prev.def, prev.args, ctx, signal, onUpdate as any, prev); return finalResult(action, result); } @@ -898,8 +903,9 @@ export default function (pi: ExtensionAPI) { if (action === "provenance") { if (!params.runId) return errorResult(action, "action=provenance requires 'runId'"); - const run = loadRun(ctx.cwd, params.runId); - if (!run) return errorResult(action, `Run not found: ${params.runId}`); + const runR = loadRunDiagnosed(ctx.cwd, params.runId); + if (!runR.ok) return errorResult(action, describeLoadFailure(runR, `Run "${params.runId}"`)); + const run = runR.value; return { content: [{ type: "text", text: formatProvenance(run) }], details: { action } satisfies TaskflowDetails, @@ -909,8 +915,9 @@ export default function (pi: ExtensionAPI) { if (action === "why-stale") { if (!params.runId) return errorResult(action, "action=why-stale requires 'runId'"); - const run = loadRun(ctx.cwd, params.runId); - if (!run) return errorResult(action, `Run not found: ${params.runId}`); + const runR = loadRunDiagnosed(ctx.cwd, params.runId); + if (!runR.ok) return errorResult(action, describeLoadFailure(runR, `Run "${params.runId}"`)); + const run = runR.value; const reads = readMapOf(run.phases); const declared = declaredReadMapOfDef(run.def); const seeds = params.phaseId ? [String(params.phaseId)] : []; @@ -925,8 +932,9 @@ export default function (pi: ExtensionAPI) { return errorResult(action, "action=recompute requires 'runId'"); if (!params.phaseId) return errorResult(action, "action=recompute requires 'phaseId' (the seed phase to re-run)"); - const prev = loadRun(ctx.cwd, params.runId); - if (!prev) return errorResult(action, `Run not found: ${params.runId}`); + const prevR = loadRunDiagnosed(ctx.cwd, params.runId); + if (!prevR.ok) return errorResult(action, describeLoadFailure(prevR, `Run "${params.runId}"`)); + const prev = prevR.value; // H1: the LLM-callable tool defaults to a SAFE dry-run (no tokens, no // mutation). A real recompute — which spends money and overwrites the // run — requires an explicit dryRun:false. @@ -960,8 +968,8 @@ export default function (pi: ExtensionAPI) { let resolvedDefine: unknown = params.define; if (resolvedDefine === undefined && typeof params.defineFile === "string" && params.defineFile.trim()) { const fromFile = readDefineFile(params.defineFile); - if (fromFile === null) return errorResult(action, `defineFile not found or unparseable: ${params.defineFile}`); - resolvedDefine = fromFile; + if (!fromFile.ok) return errorResult(action, describeLoadFailure(fromFile, "defineFile")); + resolvedDefine = fromFile.value; } if (typeof resolvedDefine === "string") { const parsed = safeParse(resolvedDefine); @@ -1000,15 +1008,14 @@ export default function (pi: ExtensionAPI) { if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`); def = candidate as Taskflow; } else if (params.name) { - const saved = getFlow(ctx.cwd, params.name); - if (!saved) { - const available = listFlows(ctx.cwd); - const hint = available.length - ? ` Available flows: ${available.map((f) => f.name).join(", ")}.` - : " No saved flows found. Use action=save to create one, or pass 'define' for an inline flow."; - return errorResult(action, `Saved flow '${params.name}' not found.${hint}`); + const savedR = getFlowDiagnosed(ctx.cwd, params.name); + if (!savedR.ok) { + const hint = savedR.reason === "missing" + ? (() => { const available = listFlows(ctx.cwd); return available.length ? ` Available flows: ${available.map((f) => f.name).join(", ")}.` : " No saved flows found. Use action=save to create one, or pass 'define' for an inline flow."; })() + : ""; + return errorResult(action, `${describeLoadFailure(savedR, `Saved flow '${params.name}'`)}.${hint}`); } - def = saved.def; + def = savedR.value.def; } if (!def) return errorResult( @@ -1029,7 +1036,8 @@ export default function (pi: ExtensionAPI) { // RFC library: write a sidecar .meta.json alongside the flow so search can // retrieve it. Structural fields are derived; purpose/tags/notes come // from the caller. (Phase 1: no embedding yet — embedded later.) - const prevMeta = readMeta(ctx.cwd, def.name) ?? undefined; + const prevMetaR = readMeta(ctx.cwd, def.name); + const prevMeta = prevMetaR.ok ? prevMetaR.value : undefined; const meta = deriveMeta(def, { purpose: typeof params.purpose === "string" ? params.purpose : undefined, tags: Array.isArray(params.tags) ? (params.tags as string[]).filter((t) => typeof t === "string") : undefined, @@ -1256,12 +1264,14 @@ export default function (pi: ExtensionAPI) { } if (sub === "show") { - const flow = getFlow(ctx.cwd, arg); - if (!flow) { - ctx.ui.notify(`Flow not found: ${arg}`, "error"); + const flowR = getFlowDiagnosed(ctx.cwd, arg); + if (!flowR.ok) { + ctx.ui.notify(describeLoadFailure(flowR, `Flow "${arg}"`), "error"); return; } - const meta = readMeta(ctx.cwd, arg); + const flow = flowR.value; + const metaR = readMeta(ctx.cwd, arg); + const meta = metaR.ok ? metaR.value : undefined; if (meta) { const out = { definition: flow.def, library: { purpose: meta.purpose, tags: meta.tags, notes: meta.notes, generality: meta.generality, reuseCount: meta.reuseCount, version: meta.version, phaseSignature: meta.phaseSignature } }; ctx.ui.notify(JSON.stringify(out, null, 2), "info"); @@ -1280,11 +1290,12 @@ export default function (pi: ExtensionAPI) { const parts = arg.trim().split(/\s+/); const flowName = parts[0]; const direction = parts[1]?.toLowerCase() === "lr" ? "LR" : "TD"; - const flow = getFlow(ctx.cwd, flowName); - if (!flow) { - ctx.ui.notify(`Flow not found: ${flowName}`, "error"); + const flowR = getFlowDiagnosed(ctx.cwd, flowName); + if (!flowR.ok) { + ctx.ui.notify(describeLoadFailure(flowR, `Flow "${flowName}"`), "error"); return; } + const flow = flowR.value; // Schema-validate before compiling so a malformed saved flow yields a // clean error rather than a half-rendered diagram (mirrors the tool action). const vr = validateTaskflow(flow.def, { cwd: ctx.cwd ? String(ctx.cwd) : undefined }); @@ -1304,11 +1315,12 @@ export default function (pi: ExtensionAPI) { return; } const flowName = arg.trim().split(/\s+/)[0]; - const flow = getFlow(ctx.cwd, flowName); - if (!flow) { - ctx.ui.notify(`Flow not found: ${flowName}`, "error"); + const flowR = getFlowDiagnosed(ctx.cwd, flowName); + if (!flowR.ok) { + ctx.ui.notify(describeLoadFailure(flowR, `Flow "${flowName}"`), "error"); return; } + const flow = flowR.value; // Schema-validate before compiling so a malformed saved flow yields a // clean error rather than a half-rendered report (mirrors action=ir). const vr = validateTaskflow(flow.def, { cwd: ctx.cwd ? String(ctx.cwd) : undefined }); @@ -1327,11 +1339,12 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("Usage: /tf provenance ", "warning"); return; } - const run = loadRun(ctx.cwd, arg); - if (!run) { - ctx.ui.notify(`Run not found: ${arg}`, "error"); + const runR = loadRunDiagnosed(ctx.cwd, arg); + if (!runR.ok) { + ctx.ui.notify(describeLoadFailure(runR, `Run "${arg}"`), "error"); return; } + const run = runR.value; ctx.ui.notify(formatProvenance(run), "info"); return; } @@ -1342,11 +1355,12 @@ export default function (pi: ExtensionAPI) { return; } const [rid, ...rest] = arg.trim().split(/\s+/); - const run = loadRun(ctx.cwd, rid); - if (!run) { - ctx.ui.notify(`Run not found: ${rid}`, "error"); + const runR = loadRunDiagnosed(ctx.cwd, rid); + if (!runR.ok) { + ctx.ui.notify(describeLoadFailure(runR, `Run "${rid}"`), "error"); return; } + const run = runR.value; const reads = readMapOf(run.phases); const declared = declaredReadMapOfDef(run.def); ctx.ui.notify(formatWhyStale(run.runId, run.flowName, reads, rest, declared), "info"); @@ -1362,11 +1376,12 @@ export default function (pi: ExtensionAPI) { ctx.ui.notify("Usage: /tf recompute [--apply]\n(default is a safe dry-run; --apply spends tokens)", "warning"); return; } - const prev = loadRun(ctx.cwd, rid); - if (!prev) { - ctx.ui.notify(`Run not found: ${rid}`, "error"); + const prevR = loadRunDiagnosed(ctx.cwd, rid); + if (!prevR.ok) { + ctx.ui.notify(describeLoadFailure(prevR, `Run "${rid}"`), "error"); return; } + const prev = prevR.value; const settings = readSubagentSettings(); const { agents } = discoverAgents(ctx.cwd, prev.def.agentScope ?? "user", settings.modelRoles, settings.taskflow); const deps: RuntimeDeps = { @@ -1460,11 +1475,12 @@ export default function (pi: ExtensionAPI) { return; } const [name, ...maybeArgs] = arg.split(/\s+/); - const flow = getFlow(ctx.cwd, name); - if (!flow) { - ctx.ui.notify(`Flow not found: ${name}`, "error"); + const flowR = getFlowDiagnosed(ctx.cwd, name); + if (!flowR.ok) { + ctx.ui.notify(describeLoadFailure(flowR, `Flow "${name}"`), "error"); return; } + const flow = flowR.value; if (!ctx.isIdle()) { ctx.ui.notify("Agent is busy; try again when idle.", "warning"); return; diff --git a/packages/taskflow-core/src/interpolate.ts b/packages/taskflow-core/src/interpolate.ts index b36ef26..4753888 100644 --- a/packages/taskflow-core/src/interpolate.ts +++ b/packages/taskflow-core/src/interpolate.ts @@ -214,6 +214,48 @@ export function safeParse(text: string): unknown { return undefined; } +/** + * Strict JSON parse for files the USER authored or pointed at (as opposed to + * `safeParse`, which is deliberately lenient for LLM/subagent output). + * + * Unlike `safeParse`, this THROWS on malformed input and preserves the original + * `SyntaxError` — V8's message carries the offending byte position and, since + * Node 17, a `lineNumber`/`columnNumber` pair. File loaders surface that to + * the user so they can fix a stray bare newline (or similar) in seconds + * instead of chasing a phantom "file not found". + * + * When `allowFence` is set (used by `defineFile`, which may point at a markdown + * draft wrapping the JSON in a ```json block), a fenced block is tried if the + * whole-document parse fails — but the *underlying* parse error is still + * thrown if no block parses, so the position is never lost. + */ +export function parseStrict(text: string, opts?: { allowFence?: boolean }): unknown { + const trimmed = text.trim(); + if (!trimmed) throw new SyntaxError("Cannot parse an empty document."); + // Keep the FIRST SyntaxError we see — it points at the real problem in the + // most natural location (whole-document first, then each fence body). + let firstError: unknown; + try { + return JSON.parse(trimmed); + } catch (e) { + firstError = e; + if (!opts?.allowFence) throw e; + } + // Same fence regex as safeParse (see comment there for ReDoS safety). + const fenceRe = /```([^\n`]*)\r?\n([\s\S]*?)```/g; + let fm: RegExpExecArray | null; + while ((fm = fenceRe.exec(trimmed)) !== null) { + if (fm[1].trim().toLowerCase() === "json") { + try { + return JSON.parse(fm[2].trim()); + } catch (e) { + firstError ??= e; + } + } + } + throw firstError; +} + /** Coerce a parsed value into an array for map fan-out. */ export function coerceArray(value: unknown): unknown[] | null { if (Array.isArray(value)) return value; diff --git a/packages/taskflow-core/src/library/search.ts b/packages/taskflow-core/src/library/search.ts index 733bd36..b2671ba 100644 --- a/packages/taskflow-core/src/library/search.ts +++ b/packages/taskflow-core/src/library/search.ts @@ -11,6 +11,7 @@ import { getFlow, listFlows, readMeta } from "../store.ts"; import type { Taskflow } from "../schema.ts"; +import type { FlowMeta } from "./types.ts"; import { computeGenerality, computePhaseSignature, @@ -63,7 +64,7 @@ export function resolveCandidate( def: Taskflow, scope: "user" | "project", name: string, - sidecar: ReturnType, + sidecar: FlowMeta | undefined, ): ResolvedCandidate { const freshSig = computePhaseSignature(def); const freshGen = computeGenerality(def); @@ -245,7 +246,8 @@ export async function searchLibrary(deps: LibraryDeps, input: SearchInput): Prom const scored: Array<{ cand: ResolvedCandidate; score: number; semScore?: number; structScore: number; textScore: number }> = []; for (const f of flows) { - const sidecar = readMeta(deps.cwd, f.name); + const sidecarR = readMeta(deps.cwd, f.name); + const sidecar = sidecarR.ok ? sidecarR.value : undefined; const cand = resolveCandidate(f.def, f.scope, f.name, sidecar); const qShape = { phaseSignature: input.phaseSignatureHint ?? "", diff --git a/packages/taskflow-core/src/store.ts b/packages/taskflow-core/src/store.ts index 329b4e2..1e6df7f 100644 --- a/packages/taskflow-core/src/store.ts +++ b/packages/taskflow-core/src/store.ts @@ -20,7 +20,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { parseJsonc } from "./jsonc.ts"; import { getAgentDir } from "./paths.ts"; -import { safeParse } from "./interpolate.ts"; +import { parseStrict } from "./interpolate.ts"; import type { Taskflow } from "./schema.ts"; import type { UsageStats } from "./usage.ts"; import type { DeclaredDeps } from "./flowir/meta.ts"; @@ -34,6 +34,52 @@ export interface SavedFlow { def: Taskflow; } +/** + * Outcome of loading a user-authored file from disk. Failure is discriminated + * by `reason` so callers can tell the user *why* it failed (and where): + * + * - `missing` — the file does not exist / is unreadable. + * - `unparseable`— the file exists but failed to parse; `detail` carries the + * underlying error (e.g. a V8 `SyntaxError` with byte offset + * + line/column), so flow authors can fix it in seconds. + * + * This replaces the old `T | null` contract that collapsed both cases into + * `null` and produced messages like "not found or unparseable" — which hid the + * real cause and the exact position of a malformed token. + */ +export type LoadResult = + | { ok: true; value: T } + | { ok: false; reason: "missing" | "unparseable"; path: string; detail: string }; + +/** Build a single-line, user-facing message from a failed `LoadResult`. */ +export function describeLoadFailure( + r: Extract, { ok: false }>, + what: string, +): string { + return r.reason === "missing" + ? `${what} not found: ${r.path}` + : `${what} could not be parsed — ${r.detail} (${r.path})`; +} + +/** Read+parse a user-authored file, distinguishing missing from malformed. */ +function loadFile(filePath: string, parse: (raw: string) => T): LoadResult { + let raw: string; + try { + raw = fs.readFileSync(filePath, "utf-8"); + } catch (e) { + return { ok: false, reason: "missing", path: filePath, detail: errMessage(e) }; + } + try { + return { ok: true, value: parse(raw) }; + } catch (e) { + return { ok: false, reason: "unparseable", path: filePath, detail: errMessage(e) }; + } +} + +function errMessage(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + /** @internal */ export type PhaseStatus = "pending" | "running" | "done" | "failed" | "skipped"; @@ -617,37 +663,25 @@ function findProjectFlowsDirInternal(cwd: string, create = false): string | null /** * Read a flow definition from a file on disk. Supports raw JSON or a Markdown - * document with a fenced ```json block (reuses safeParse). Used by the - * `defineFile` parameter so verify/compile/run can share one persisted draft - * (e.g. in the OS temp dir) without saving it into the project's .pi/taskflows. + * document with a fenced ```json block. Used by the `defineFile` parameter so + * verify/compile/run can share one persisted draft (e.g. in the OS temp dir) + * without saving it into the project's .pi/taskflows. * - * Returns the parsed definition (object/array/shorthand), or null if the file - * can't be read or parsed. Callers surface an explicit error in that case. + * Returns a `LoadResult`: `{ ok: true, value }` on success, or + * `{ ok: false, reason: "missing" | "unparseable", ... }` on failure. Callers + * surface an explicit error via `describeLoadFailure`. */ -export function readDefineFile(filePath: string): unknown { - let raw: string; - try { - raw = fs.readFileSync(filePath, "utf-8"); - } catch { - return null; - } - const parsed = safeParse(raw); - return parsed ?? null; +export function readDefineFile(filePath: string): LoadResult { + return loadFile(filePath, (raw) => parseStrict(raw, { allowFence: true })); } -function readFlowFile(filePath: string, scope: "user" | "project"): SavedFlow | null { - try { - const raw = fs.readFileSync(filePath, "utf-8"); - // Flow definition files are hand-authored and may contain JSONC-style - // comments (// line and /* block */) for readability. parseJsonc strips - // them before handing off to JSON.parse. LLM/subagent output is NOT - // routed through here — see interpolate.ts#safeParse (kept strict). - const def = parseJsonc(raw) as Taskflow; - if (!def?.name) return null; - return { name: def.name, scope, filePath, def }; - } catch { - return null; +function readFlowFile(filePath: string, scope: "user" | "project"): LoadResult { + const r = loadFile(filePath, (raw) => parseJsonc(raw) as Taskflow); + if (!r.ok) return r; + if (!r.value?.name) { + return { ok: false, reason: "unparseable", path: filePath, detail: "parsed OK but missing required field: name" }; } + return { ok: true, value: { name: r.value.name, scope, filePath, def: r.value } }; } /** List all saved flows (project overrides user on name collision). */ @@ -674,8 +708,17 @@ export function listFlows(cwd: string): SavedFlow[] { if (!name.endsWith(".json")) continue; // A1: sidecar .meta.json must never be scanned as a candidate flow. if (name.endsWith(".meta.json")) continue; - const flow = readFlowFile(path.join(dir, name), scope); - if (flow) map.set(flow.name, flow); // project after user → overrides + const r = readFlowFile(path.join(dir, name), scope); + if (r.ok) { + map.set(r.value.name, r.value); // project after user → overrides + } else if (r.reason === "unparseable") { + // A corrupt saved flow used to be silently dropped here, so `getFlow` + // would later report "not found" for a file that clearly exists. + // Surface it loudly instead — the detail carries line/column. + console.warn( + `[taskflow] saved flow is corrupt and was excluded from the list: ${name} — ${r.detail}`, + ); + } } } return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name)); @@ -685,6 +728,31 @@ export function getFlow(cwd: string, name: string): SavedFlow | null { return listFlows(cwd).find((f) => f.name === name) ?? null; } +/** + * Resolve a saved flow by name with diagnosable failure. Unlike `getFlow` + * (which returns `null` for both "no such flow" and "file exists but corrupt", + * because corrupt files are excluded from `listFlows`), this re-reads the + * candidate file directly so callers can report *why* a name didn't resolve. + */ +export function getFlowDiagnosed(cwd: string, name: string): LoadResult { + const found = getFlow(cwd, name); + if (found) return { ok: true, value: found }; + // Not in the list — check whether a file exists for this name but is corrupt. + const candidates: Array<{ dir: string; scope: "user" | "project" }> = [ + { dir: userFlowsDir(), scope: "user" }, + ]; + const projDir = findProjectFlowsDir(cwd); + if (projDir) candidates.push({ dir: projDir, scope: "project" }); + for (const { dir, scope } of candidates) { + const filePath = path.join(dir, `${safeFlowDirName(name)}.json`); + if (fs.existsSync(filePath)) { + const r = readFlowFile(filePath, scope); + if (!r.ok) return r; // unparseable (or a missing-in-race) — surface detail + } + } + return { ok: false, reason: "missing", path: name, detail: `no saved flow named '${name}'` }; +} + let _piCreationHinted = false; export function saveFlow( @@ -730,36 +798,39 @@ function sidecarPathIn(flowFilePath: string): string { return flowFilePath.replace(/\.json$/, ".meta.json"); } -/** Read a flow's library sidecar. Returns null if missing/unparseable. */ -export function readMeta(cwd: string, flowName: string): FlowMeta | null { +/** Read a flow's library sidecar. Returns a `LoadResult`; missing/unparseable + * are discriminated by `reason`. */ +export function readMeta(cwd: string, flowName: string): LoadResult { // Try project scope first, then user — mirrors getFlow's resolution. for (const scope of ["project", "user"] as const) { const p = sidecarPathFor(cwd, flowName, scope); - try { - if (!fs.existsSync(p)) continue; - const raw = fs.readFileSync(p, "utf-8"); - const parsed = safeParse(raw); - if (parsed && typeof parsed === "object") return parsed as FlowMeta; - return null; - } catch { - continue; - } + if (!fs.existsSync(p)) continue; + const r = loadFile(p, (raw) => { + const parsed = parseStrict(raw); + if (!parsed || typeof parsed !== "object") { + throw new Error("expected a JSON object at the top level"); + } + return parsed as FlowMeta; + }); + return r; } - return null; + return { ok: false, reason: "missing", path: flowName, detail: "no sidecar .meta.json found" }; } /** Read a sidecar next to a specific flow file (avoids name→scope lookup when * we already have the SavedFlow from listFlows). */ -export function readMetaNextTo(flowFilePath: string): FlowMeta | null { - try { - const p = sidecarPathIn(flowFilePath); - if (!fs.existsSync(p)) return null; - const raw = fs.readFileSync(p, "utf-8"); - const parsed = safeParse(raw); - return parsed && typeof parsed === "object" ? (parsed as FlowMeta) : null; - } catch { - return null; +export function readMetaNextTo(flowFilePath: string): LoadResult { + const p = sidecarPathIn(flowFilePath); + if (!fs.existsSync(p)) { + return { ok: false, reason: "missing", path: p, detail: "no sidecar .meta.json next to flow file" }; } + return loadFile(p, (raw) => { + const parsed = parseStrict(raw); + if (!parsed || typeof parsed !== "object") { + throw new Error("expected a JSON object at the top level"); + } + return parsed as FlowMeta; + }); } /** Write a flow + its sidecar atomically (same withLock critical section — R2). @@ -796,7 +867,8 @@ export function bumpReuseInSidecar(cwd: string, flowName: string): number | null const metaPath = sidecarPathIn(saved.filePath); const lockPath = saved.filePath + ".lock"; return withLock(lockPath, () => { - const existing = readMetaNextTo(saved.filePath); + const existingR = readMetaNextTo(saved.filePath); + const existing = existingR.ok ? existingR.value : undefined; const now = Date.now(); const updated: FlowMeta = existing ? { ...existing, reuseCount: (existing.reuseCount ?? 0) + 1, lastUsedAt: now } @@ -889,18 +961,36 @@ export function saveRun(state: RunState, cleanup?: { maxKeep?: number; maxAgeDay * All existing path-traversal, symlink, and realpath guards are preserved for * every path touched. */ -export function loadRun(cwd: string, runId: string): RunState | null { - if (!validateRunId(runId)) return null; - +/** + * Diagnosable variant of `loadRun`. Returns a `LoadResult` so callers can tell + * the user *why* a runId didn't resolve: a file exists for it but is corrupt + * (`reason: "unparseable"`, with the parse error in `detail`) versus genuinely + * absent (`reason: "missing"`). Used by user-facing paths (resume / show / + * provenance / why-stale / recompute). Internal polling keeps the plain + * `loadRun` (RunState | null) for API stability. + */ +export function loadRunDiagnosed(cwd: string, runId: string): LoadResult { + if (!validateRunId(runId)) { + return { ok: false, reason: "missing", path: runId, detail: "invalid runId format" }; + } const root = runsDir(cwd); + // Remember the first corrupt candidate so we can report "corrupt" rather + // than "missing" when no candidate parses cleanly. + let corrupt: Extract, { ok: false }> | null = null; + const probe = (filePath: string): RunState | undefined => { + const r = tryReadRunFile(root, filePath); + if (r.ok) return r.value; + if (r.reason === "unparseable" && !corrupt) corrupt = r; + return undefined; + }; + // ---- Try index first ---- const indexEntries = readIndex(root); const entry = indexEntries.find((e) => e.runId === runId); if (entry) { - const filePath = path.join(root, entry.relPath); - const state = tryReadRunFile(root, filePath); - if (state) return state; + const found = probe(path.join(root, entry.relPath)); + if (found) return { ok: true, value: found }; // Index entry exists but file is gone or corrupt — fall through. } @@ -911,29 +1001,36 @@ export function loadRun(cwd: string, runId: string): RunState | null { .filter((d) => d.isDirectory()) .map((d) => d.name); } catch { dirs = []; } - for (const dirName of dirs) { - const filePath = path.join(root, dirName, `${runId}.json`); - const state = tryReadRunFile(root, filePath); - if (state) return state; + const found = probe(path.join(root, dirName, `${runId}.json`)); + if (found) return { ok: true, value: found }; } // ---- Try legacy flat fallback ---- - const flatPath = path.join(root, `${runId}.json`); - const state = tryReadRunFile(root, flatPath); - if (state) return state; + const found = probe(path.join(root, `${runId}.json`)); + if (found) return { ok: true, value: found }; + + if (corrupt) return corrupt; // file exists for this runId but won't parse + return { ok: false, reason: "missing", path: runId, detail: `no run with id '${runId}'` }; +} - return null; +export function loadRun(cwd: string, runId: string): RunState | null { + const r = loadRunDiagnosed(cwd, runId); + return r.ok ? r.value : null; } /** * Safely read a run file, performing all path-traversal / symlink guards. * Returns null on any violation or read error. */ -function tryReadRunFile(runsRoot: string, filePath: string): RunState | null { +function tryReadRunFile(runsRoot: string, filePath: string): LoadResult { // Lexical traversal guard. const rel = path.relative(runsRoot, filePath); - if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null; + if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) { + // Path-traversal violation — report opaquely as "missing" (do not leak + // filesystem layout to the caller). + return { ok: false, reason: "missing", path: filePath, detail: "outside runs root" }; + } // Resolve symlinks on both runsRoot and the file so the containment check // uses consistent physical paths (macOS /var → /private/var etc.). @@ -942,15 +1039,16 @@ function tryReadRunFile(runsRoot: string, filePath: string): RunState | null { try { realDir = fs.realpathSync(runsRoot); realFilePath = fs.realpathSync(filePath); - } catch { return null; } + } catch { + return { ok: false, reason: "missing", path: filePath, detail: "unresolvable path" }; + } const realRel = path.relative(realDir, realFilePath); - if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) return null; + if (realRel === ".." || realRel.startsWith(`..${path.sep}`) || path.isAbsolute(realRel)) { + return { ok: false, reason: "missing", path: filePath, detail: "outside runs root" }; + } - try { - const raw = fs.readFileSync(realFilePath, "utf-8"); - return JSON.parse(raw) as RunState; - } catch { return null; } + return loadFile(realFilePath, (raw) => JSON.parse(raw) as RunState); } /** diff --git a/packages/taskflow-core/test/define-file.test.ts b/packages/taskflow-core/test/define-file.test.ts index 984a9e0..bfba940 100644 --- a/packages/taskflow-core/test/define-file.test.ts +++ b/packages/taskflow-core/test/define-file.test.ts @@ -4,13 +4,18 @@ * `defineFile` lets verify/compile/run share ONE persisted draft (typically in * the OS temp dir) so the agent writes the flow once and then verifies / edits * / runs it by path, instead of re-sending the whole definition each call. + * + * v0.2.0: readDefineFile now returns a `LoadResult` that distinguishes + * `missing` from `unparseable` and carries the underlying parse error's + * position (line/column) in `detail` — instead of collapsing both into `null` + * and emitting a merged "not found or unparseable" message. */ import assert from "node:assert/strict"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { test } from "node:test"; -import { readDefineFile } from "../src/store.ts"; +import { describeLoadFailure, readDefineFile, type LoadResult } from "../src/store.ts"; function tmpFile(prefix: string, content: string): string { const p = path.join(os.tmpdir(), `${prefix}-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); @@ -18,10 +23,16 @@ function tmpFile(prefix: string, content: string): string { return p; } +/** Unwrap a successful LoadResult for ergonomic assertions. */ +function valueOf(r: { ok: true; value: T } | { ok: false; reason: string; detail: string }): T { + assert.equal(r.ok, true, `expected ok, got: ${JSON.stringify(r)}`); + return (r as { ok: true; value: T }).value; +} + test("readDefineFile: parses a raw JSON flow definition", () => { const def = { name: "raw", phases: [{ id: "a", type: "agent", task: "hi", final: true }] }; const f = tmpFile("raw", JSON.stringify(def)); - assert.equal((readDefineFile(f) as { name: string }).name, "raw"); + assert.equal((valueOf(readDefineFile(f)) as { name: string }).name, "raw"); fs.unlinkSync(f); }); @@ -34,17 +45,39 @@ test("readDefineFile: extracts the flow from a fenced ```json markdown block", ( "```\n\n" + "Trailing prose.\n"; const f = tmpFile("fence", md); - assert.equal((readDefineFile(f) as { name: string }).name, "from-fence"); + assert.equal((valueOf(readDefineFile(f)) as { name: string }).name, "from-fence"); fs.unlinkSync(f); }); -test("readDefineFile: returns null for a missing file (no throw)", () => { - assert.equal(readDefineFile(path.join(os.tmpdir(), "taskflow-definitely-missing-xyz.json")), null); +test("readDefineFile: a missing file reports reason='missing' (no throw)", () => { + const r = readDefineFile(path.join(os.tmpdir(), "taskflow-definitely-missing-xyz.json")); + assert.equal(r.ok, false); + assert.equal(r.reason, "missing"); }); -test("readDefineFile: returns null for an unparseable file", () => { +test("readDefineFile: an unparseable file reports reason='unparseable' with the parse error (no throw)", () => { const f = tmpFile("junk", "this is not json {{{"); - assert.equal(readDefineFile(f), null); + const r = readDefineFile(f); + assert.equal(r.ok, false); + assert.equal(r.reason, "unparseable"); + // detail must carry the underlying SyntaxError — not a generic "unparseable". + assert.match(r.detail, /JSON|token|Unexpected/i, `detail should contain a JSON parse error, got: ${r.detail}`); + fs.unlinkSync(f); +}); + +test("readDefineFile: a bare newline inside a JSON string surfaces line/column (the 0.2.0 incident)", () => { + // Reproduces the exact bug that motivated this rewrite: a hand-authored + // defineFile with a stray bare LF inside a string literal. JSON.parse throws + // "Bad control character in string literal ... at position N (line L column C)". + // The old code swallowed that into null + "not found or unparseable"; the new + // code surfaces the position so the author can fix it in seconds. + const broken = '{\n "name": "x",\n "task": "line one\nline two"\n}\n'; + const f = tmpFile("bare-lf", broken); + const r = readDefineFile(f); + assert.equal(r.ok, false); + assert.equal(r.reason, "unparseable"); + // V8 reports a position and (Node >=17) a line/column for control chars. + assert.match(r.detail, /Bad control character/i, `expected control-char error, got: ${r.detail}`); fs.unlinkSync(f); }); @@ -52,6 +85,13 @@ test("readDefineFile: handles a JSON string define value (shorthand forms parse // A bare JSON object is the common case — confirm a minimal valid shape round-trips. const def = { task: "do something", name: "shorthand" }; const f = tmpFile("sh", JSON.stringify(def)); - assert.equal((readDefineFile(f) as { task: string }).task, "do something"); + assert.equal((valueOf(readDefineFile(f)) as { task: string }).task, "do something"); fs.unlinkSync(f); }); + +test("describeLoadFailure: formats a clear missing vs unparseable message", () => { + const missing: Extract, { ok: false }> = { ok: false, reason: "missing", path: "/tmp/x.json", detail: "ENOENT" }; + const broken: Extract, { ok: false }> = { ok: false, reason: "unparseable", path: "/tmp/x.json", detail: "Bad control character in JSON at position 3979 (line 30 column 801)" }; + assert.equal(describeLoadFailure(missing, "defineFile"), "defineFile not found: /tmp/x.json"); + assert.match(describeLoadFailure(broken, "defineFile"), /defineFile could not be parsed — Bad control character.*position 3979.*\/tmp\/x\.json/); +}); diff --git a/packages/taskflow-core/test/library-search.test.ts b/packages/taskflow-core/test/library-search.test.ts index 8d05337..5996ea8 100644 --- a/packages/taskflow-core/test/library-search.test.ts +++ b/packages/taskflow-core/test/library-search.test.ts @@ -63,7 +63,7 @@ test("resolveCandidate: no sidecar → embedding null, structural fields live-de phases: [{ id: "a", type: "agent", task: "{args.x}" }], args: { x: { default: "1" } }, } as Taskflow; - const c = resolveCandidate(def, "project", "x", null); + const c = resolveCandidate(def, "project", "x", undefined); assert.equal(c.embedding, null); assert.equal(c.embeddingStale, true); assert.equal(c.phaseSignature, "agent"); diff --git a/packages/taskflow-core/test/library-store.test.ts b/packages/taskflow-core/test/library-store.test.ts index c3d8011..2c5cf5a 100644 --- a/packages/taskflow-core/test/library-store.test.ts +++ b/packages/taskflow-core/test/library-store.test.ts @@ -18,6 +18,13 @@ import { sidecarPathFor, } from "../src/store.ts"; import { deriveMeta } from "../src/library/meta.ts"; +import type { FlowMeta } from "../src/library/types.ts"; + +/** Test helper: readMeta now returns a LoadResult; unwrap to FlowMeta | null. */ +function metaOf(cwd: string, name: string): FlowMeta | null { + const r = readMeta(cwd, name); + return r.ok ? r.value : null; +} function makeTmpCwd(): string { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "taskflow-lib-store-")); @@ -43,7 +50,7 @@ test("saveFlowWithMeta: writes flow + sidecar, readMeta recovers it", () => { assert.ok(fs.existsSync(filePath)); assert.ok(fs.existsSync(metaPath)); assert.equal(path.basename(metaPath), "audit-endpoints.meta.json"); - const back = readMeta(cwd, "audit-endpoints"); + const back = metaOf(cwd, "audit-endpoints"); assert.equal(back?.purpose, "审计鉴权"); assert.deepEqual(back?.tags, ["audit", "auth"]); assert.equal(back?.phaseSignature, "agent→reduce"); @@ -76,7 +83,7 @@ test("A1 regression guard: a sidecar that looks flow-like (has a name field) is // The listFlows filter must reject it by suffix BEFORE readFlowFile runs. saveFlowWithMeta(cwd, sampleDef, deriveMeta(sampleDef, {})); const metaPath = sidecarPathFor(cwd, "audit-endpoints"); - const malicious = JSON.stringify({ ...readMeta(cwd, "audit-endpoints"), name: "ghost-xyz" }, null, 2); + const malicious = JSON.stringify({ ...metaOf(cwd, "audit-endpoints"), name: "ghost-xyz" }, null, 2); fs.writeFileSync(metaPath, malicious, "utf-8"); const flows = listFlows(cwd); const names = flows.map((f) => f.name); @@ -90,7 +97,7 @@ test("A1 regression guard: a sidecar that looks flow-like (has a name field) is test("readMeta: returns null for missing flow", () => { const cwd = makeTmpCwd(); try { - assert.equal(readMeta(cwd, "does-not-exist"), null); + assert.equal(metaOf(cwd, "does-not-exist"), null); } finally { fs.rmSync(cwd, { recursive: true, force: true }); } @@ -100,12 +107,12 @@ test("bumpReuseInSidecar: increments reuseCount + lastUsedAt, idempotent under r const cwd = makeTmpCwd(); try { saveFlowWithMeta(cwd, sampleDef, deriveMeta(sampleDef, {})); - assert.equal(readMeta(cwd, "audit-endpoints")?.reuseCount, 0); - assert.equal(readMeta(cwd, "audit-endpoints")?.lastUsedAt, null); + assert.equal(metaOf(cwd, "audit-endpoints")?.reuseCount, 0); + assert.equal(metaOf(cwd, "audit-endpoints")?.lastUsedAt, null); const n1 = bumpReuseInSidecar(cwd, "audit-endpoints"); assert.equal(n1, 1); - const after1 = readMeta(cwd, "audit-endpoints"); + const after1 = metaOf(cwd, "audit-endpoints"); assert.equal(after1?.reuseCount, 1); assert.ok((after1?.lastUsedAt ?? 0) > 0); @@ -130,10 +137,10 @@ test("bumpReuseInSidecar: creates a minimal sidecar if save used the legacy save try { // legacy save (no sidecar) — backward compat saveFlow(cwd, sampleDef, "project"); - assert.equal(readMeta(cwd, "audit-endpoints"), null); + assert.equal(metaOf(cwd, "audit-endpoints"), null); const n = bumpReuseInSidecar(cwd, "audit-endpoints"); assert.equal(n, 1); - const m = readMeta(cwd, "audit-endpoints"); + const m = metaOf(cwd, "audit-endpoints"); assert.equal(m?.reuseCount, 1); } finally { fs.rmSync(cwd, { recursive: true, force: true }); diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index ec65edb..43557a3 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -28,7 +28,7 @@ import { renderFlowSvg, renderFlowOutline, svgToBase64 } from "./svg.ts"; import { discoverAgents, executeTaskflow, - getFlow, + getFlowDiagnosed, listFlows, newRunId, peekRun, @@ -36,6 +36,7 @@ import { DEFAULT_KEPT_RUNS, DEFAULT_RUN_AGE_DAYS, readDefineFile, + describeLoadFailure, compileTaskflow, verifyTaskflow, desugar, @@ -280,16 +281,16 @@ const TOOLS: McpTool[] = [ function resolveFlow(cwd: string, params: { name?: string; define?: unknown; defineFile?: unknown }): Taskflow { if (params.define === undefined && typeof params.defineFile === "string" && params.defineFile.trim()) { const fromFile = readDefineFile(params.defineFile); - if (fromFile === null) throw new RpcError(RPC.INVALID_PARAMS, `defineFile not found or unparseable: ${params.defineFile}`); - params = { ...params, define: fromFile }; + if (!fromFile.ok) throw new RpcError(RPC.INVALID_PARAMS, describeLoadFailure(fromFile, "defineFile")); + params = { ...params, define: fromFile.value }; } if (params.define !== undefined && params.define !== null) { return isShorthand(params.define) ? desugar(params.define) : (params.define as Taskflow); } if (params.name) { - const saved = getFlow(cwd, params.name); - if (!saved) throw new RpcError(RPC.INVALID_PARAMS, `No saved flow named "${params.name}" found from ${cwd}.`); - return saved.def; + const r = getFlowDiagnosed(cwd, params.name); + if (!r.ok) throw new RpcError(RPC.INVALID_PARAMS, describeLoadFailure(r, `Saved flow "${params.name}"`)); + return r.value.def; } throw new RpcError(RPC.INVALID_PARAMS, "Provide either `name` (a saved flow) or `define` (an inline flow)."); } @@ -388,7 +389,8 @@ export function makeToolHandlers( const flows = listFlows(cwd); if (flows.length === 0) return textContent("No saved taskflows found from this directory."); const lines = flows.map((f) => { - const meta = readMeta(cwd, f.name); + const metaR = readMeta(cwd, f.name); + const meta = metaR.ok ? metaR.value : undefined; if (meta?.purpose) { const purpose = meta.purpose.length > 20 ? meta.purpose.slice(0, 20) + "…" : meta.purpose; return `- ${f.name} (${f.scope}) — ${f.def.phases.length} phase(s) · ${purpose} · g=${meta.generality?.toFixed(2) ?? "?"} · used ${meta.reuseCount ?? 0}×`; @@ -400,9 +402,11 @@ export function makeToolHandlers( taskflow_show: async (args) => { const name = String(args.name ?? ""); - const saved = getFlow(cwd, name); - if (!saved) return textContent(`No saved flow named "${name}".`, true); - const meta = readMeta(cwd, name); + const savedR = getFlowDiagnosed(cwd, name); + if (!savedR.ok) return textContent(describeLoadFailure(savedR, `Saved flow "${name}"`), true); + const saved = savedR.value; + const metaR = readMeta(cwd, name); + const meta = metaR.ok ? metaR.value : undefined; if (meta) { const out = { definition: saved.def, library: { purpose: meta.purpose, tags: meta.tags, notes: meta.notes, generality: meta.generality, reuseCount: meta.reuseCount, version: meta.version, phaseSignature: meta.phaseSignature } }; return textContent(JSON.stringify(out, null, 2)); @@ -428,7 +432,8 @@ export function makeToolHandlers( const v = validateTaskflow(def); if (!v.ok) return textContent(`Flow is invalid:\n- ${v.errors.join("\n- ")}`, true); const scope = args.scope === "user" ? "user" : "project"; - const prevMeta = readMeta(cwd, name) ?? undefined; + const prevMetaR = readMeta(cwd, name); + const prevMeta = prevMetaR.ok ? prevMetaR.value : undefined; const meta = deriveMeta(def, { purpose: typeof args.purpose === "string" ? args.purpose : undefined, tags: Array.isArray(args.tags) ? args.tags.filter((t): t is string => typeof t === "string") : undefined, From fb74f60c935a87b7db760e50bddfdaa079d6888f Mon Sep 17 00:00:00 2001 From: heggria Date: Tue, 7 Jul 2026 16:33:06 +0800 Subject: [PATCH 06/16] chore(release): prepare v0.1.7 Bump all seven packages + internal dependency pins (taskflow-core / taskflow-hosts / taskflow-mcp-core) + plugin manifests (.mcp.json / .codex-plugin / .claude-plugin / opencode.json) + README/README.zh-CN version notes + SECURITY.md + RELEASE.md tag example to 0.1.7, and regenerate pnpm-lock.yaml. Pre-flight green: typecheck clean, 1146/1146 core tests pass, all seven packages build (dist) at 0.1.7. --- README.md | 2 +- README.zh-CN.md | 2 +- RELEASE.md | 4 ++-- SECURITY.md | 2 +- docs/claude-mcp.md | 2 +- docs/codex-mcp.md | 4 ++-- package.json | 2 +- packages/claude-taskflow/package.json | 8 +++---- packages/codex-taskflow/package.json | 8 +++---- packages/opencode-taskflow/package.json | 8 +++---- .../opencode-taskflow/plugin/opencode.json | 2 +- packages/pi-taskflow/package.json | 4 ++-- packages/taskflow-core/package.json | 2 +- packages/taskflow-hosts/package.json | 4 ++-- packages/taskflow-mcp-core/package.json | 4 ++-- pnpm-lock.yaml | 24 +++++++++---------- 16 files changed, 41 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 84b74b7..948c5e6 100644 --- a/README.md +++ b/README.md @@ -895,7 +895,7 @@ Our `self-improve` flow is a 10-phase DAG — it audits the codebase, patches de ## Status & limits -**v0.1.6** (current release) — adds **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. **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. Known boundaries (tracked, bounded — no surprises mid-flow): diff --git a/README.zh-CN.md b/README.zh-CN.md index 4f4646b..68fbd2b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -770,7 +770,7 @@ provided files. Report violations grouped by file. No fixes. ## 状态与边界 -**v0.1.6**——当前发布版。完整历史详见 [CHANGELOG](./CHANGELOG.md)。本版新增 **库 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`,加上 `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`)之上。支持内联 + 已保存流程、跨会话恢复、实时进度和上下文隔离。一次运行作为一个流式工具调用执行。 已知边界(已追踪、有限定——不会在流程中途出现意外): diff --git a/RELEASE.md b/RELEASE.md index 2719126..81e53ac 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -81,7 +81,7 @@ pnpm publish --filter opencode-taskflow --registry=https://registry.npmjs.org/ - > **Note on `taskflow-core` as a dependency.** `taskflow-mcp-core`, `taskflow-hosts`, and the host adapters > (`pi-taskflow` / `codex-taskflow` / `claude-taskflow` / `opencode-taskflow`) -> declare `"taskflow-core": "0.1.6"` (an exact version, not `workspace:*`), so the +> 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 @@ -94,7 +94,7 @@ seven 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.6 && git push origin v0.1.6 +git tag v0.1.7 && git push origin v0.1.7 ``` ## Verify after publish diff --git a/SECURITY.md b/SECURITY.md index 6f62545..3d4166d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -27,7 +27,7 @@ The runtime has intentional hardening: `realpath`-based path containment, runId | Version | Support | |---------|---------| -| v0.1.6 (latest) | ✅ Active | +| v0.1.7 (latest) | ✅ Active | | Earlier versions | ❌ Unsupported — upgrade to latest | ## Disclosure diff --git a/docs/claude-mcp.md b/docs/claude-mcp.md index 5d725d2..365b528 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.6 claude-taskflow-mcp) +claude mcp list # → taskflow … (npx -y -p claude-taskflow@0.1.7 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 c165c13..6bea4dd 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.6 codex-taskflow-mcp) +codex mcp list # → taskflow … enabled (npx -y -p codex-taskflow@0.1.7 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.6", "codex-taskflow-mcp"], + "args": ["-y", "-p", "codex-taskflow@0.1.7", "codex-taskflow-mcp"], "tool_timeout_sec": 1800 } } diff --git a/package.json b/package.json index dfa874e..cb81619 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow-monorepo", - "version": "0.1.6", + "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.", "type": "module", diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index 633dd34..0698c4b 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.1.6", + "version": "0.1.7", "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.6", - "taskflow-hosts": "0.1.6", - "taskflow-mcp-core": "0.1.6" + "taskflow-core": "0.1.7", + "taskflow-hosts": "0.1.7", + "taskflow-mcp-core": "0.1.7" } } diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index 1c61757..86affc4 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.1.6", + "version": "0.1.7", "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.6", - "taskflow-hosts": "0.1.6", - "taskflow-mcp-core": "0.1.6" + "taskflow-core": "0.1.7", + "taskflow-hosts": "0.1.7", + "taskflow-mcp-core": "0.1.7" } } diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index 5a0f103..95fb198 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.1.6", + "version": "0.1.7", "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.6", - "taskflow-hosts": "0.1.6", - "taskflow-mcp-core": "0.1.6" + "taskflow-core": "0.1.7", + "taskflow-hosts": "0.1.7", + "taskflow-mcp-core": "0.1.7" } } diff --git a/packages/opencode-taskflow/plugin/opencode.json b/packages/opencode-taskflow/plugin/opencode.json index a4015ea..f2bf197 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.6", "opencode-taskflow-mcp"], + "command": ["npx", "-y", "-p", "opencode-taskflow@0.1.7", "opencode-taskflow-mcp"], "enabled": true } }, diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index e99e61f..b9b39b8 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow", - "version": "0.1.6", + "version": "0.1.7", "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.6" + "taskflow-core": "0.1.7" }, "peerDependencies": { "@earendil-works/pi-agent-core": "*", diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index b87ea68..0033300 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-core", - "version": "0.1.6", + "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.", "keywords": [ "taskflow", diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index 03abc41..48bd100 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-hosts", - "version": "0.1.6", + "version": "0.1.7", "description": "Shared host-runner collection for taskflow — the codex, claude, and opencode 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; this package holds just the runners so a new host can be added in one place.", "type": "module", "engines": { @@ -43,7 +43,7 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json" }, "dependencies": { - "taskflow-core": "0.1.6" + "taskflow-core": "0.1.7" }, "devDependencies": { "typescript": "^6.0.3" diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index 7dd0e68..53d354e 100644 --- a/packages/taskflow-mcp-core/package.json +++ b/packages/taskflow-mcp-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-mcp-core", - "version": "0.1.6", + "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.", "keywords": [ "taskflow", @@ -60,6 +60,6 @@ "access": "public" }, "dependencies": { - "taskflow-core": "0.1.6" + "taskflow-core": "0.1.7" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac37ca2..a32ca66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,37 +33,37 @@ importers: packages/claude-taskflow: dependencies: taskflow-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-core taskflow-hosts: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-hosts taskflow-mcp-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-mcp-core packages/codex-taskflow: dependencies: taskflow-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-core taskflow-hosts: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-hosts taskflow-mcp-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-mcp-core packages/opencode-taskflow: dependencies: taskflow-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-core taskflow-hosts: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-hosts taskflow-mcp-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-mcp-core packages/pi-taskflow: @@ -81,7 +81,7 @@ importers: specifier: '*' version: 0.80.3 taskflow-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-core typebox: specifier: '*' @@ -96,7 +96,7 @@ importers: packages/taskflow-hosts: dependencies: taskflow-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-core devDependencies: typescript: @@ -106,7 +106,7 @@ importers: packages/taskflow-mcp-core: dependencies: taskflow-core: - specifier: 0.1.6 + specifier: 0.1.7 version: link:../taskflow-core website: From adaeed630b32dcad648a06cfd6a16b26104b8d8b Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 8 Jul 2026 11:53:55 +0800 Subject: [PATCH 07/16] fix(gate): parse Markdown verdicts, fail closed, enforce output format (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A genuine BLOCK was silently downgraded to PASS when models wrapped the verdict token in Markdown emphasis (VERDICT: **BLOCK**). The bare-token regex missed it and the miss fell into the fail-open default, so a blocked review could continue downstream unchecked. Three layered fixes: 1. Parser tolerance — a shared VERDICT_TOKEN_RE (scorers.ts) now tolerates `*`/`_`/`` `/`~` emphasis runs on either side of the verdict word. Used by both parseGateVerdict and parseJudgeOutput, replacing two identical bare-token regexes. 2. Fail-closed default — gate *model output* that cannot be parsed now BLOCKS instead of passing. A gate that cannot reach a verdict cannot be trusted to pass; halting is recoverable (prior phases persist, run is resumable). Config slips (unresolved score.target, malformed scorers) stay fail-open with a warning — they are authoring errors, not a judge that couldn't decide. An explicit non-blocking JSON verdict ({"verdict":"No issues found"}) is a semantic PASS, not ambiguity. 3. Auto-appended format suffix — a free-text gate whose task omits a VERDICT: instruction now gets the exact terminator auto-appended, so a model never "forgets" to emit one. Applied at all four gate-task construction sites including the onBlock:retry loop (previously missed). Skipped for gates with an output:"json" + expect contract. For maximum robustness, prefer output:"json" + expect enum ({verdict:{enum:["pass","block"]}}) which machine-validates the verdict. Breaking (pre-1.0): a gate whose model output has no parseable verdict now blocks instead of silently passing. Migration: emit VERDICT: PASS|BLOCK (auto-appended if omitted), adopt the JSON+expect contract, or mark the gate optional:true with a downstream fallback. Tests: 1148 pass (added Markdown-emphasis regression cases for both parseGateVerdict and parseJudgeOutput, the fail-closed default, and the auto-append behavior; updated the two fail-open tests to fail-closed). Docs: AGENTS.md invariant #4 + Error Handling, README, CHANGELOG, and the shared skill sources (regenerated for all four hosts). Closes #54. --- AGENTS.md | 4 +- CHANGELOG.md | 28 +++++++ README.md | 10 +-- .../plugin/skills/taskflow/SKILL.md | 45 ++++++++--- .../plugin/skills/taskflow/patterns.md | 2 +- .../plugin/skills/taskflow/SKILL.md | 45 ++++++++--- .../plugin/skills/taskflow/patterns.md | 2 +- .../plugin/skills/taskflow/SKILL.md | 45 ++++++++--- .../plugin/skills/taskflow/patterns.md | 2 +- packages/pi-taskflow/skills/taskflow/SKILL.md | 45 ++++++++--- .../pi-taskflow/skills/taskflow/patterns.md | 2 +- packages/taskflow-core/src/runtime.ts | 58 ++++++++++++--- packages/taskflow-core/src/scorers.ts | 28 +++++-- .../taskflow-core/test/gate-score.test.ts | 16 ++-- packages/taskflow-core/test/runtime.test.ts | 74 +++++++++++++++++-- skills-src/taskflow/core.md | 45 ++++++++--- skills-src/taskflow/patterns.md | 2 +- 17 files changed, 367 insertions(+), 86 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d3979f6..4fe6100 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -191,7 +191,7 @@ pnpm run test:e2e-opencode-mcp # opencode MCP stdio e2e (src; no live opencod ### Error Handling - **Fail-open for guards**: `when` parse errors → phase still runs (never silently drop). -- **Fail-open for gates**: ambiguous gate output → `PASS` (never accidentally halt). +- **Fail-closed for gate verdicts**: unparseable gate *model output* → `BLOCK` (a gate that cannot reach a verdict cannot be trusted to pass — issue #54). This is distinct from config/resolution slips (unresolved `score.target`, malformed `scorers`), which remain **fail-open** with a warning, because those are authoring errors that should degrade, not silently block. An explicit JSON verdict that is non-blocking (e.g. `{"verdict":"No issues found"}`) is a semantic PASS, not ambiguity. - **Fail-open for tournament**: unparseable winner → variant 1 (never lose work). - **Safe emit**: user callbacks (`persist`, `onProgress`) are wrapped in try/catch — a throwing callback must never replace the runtime's outcome. - **Transient retry**: rate limits, 5xx, timeouts are auto-retried up to 3 times with backoff. @@ -250,7 +250,7 @@ pnpm run test:e2e-opencode-mcp # opencode MCP stdio e2e (src; no live opencod 1. **Never leak intermediate results to the host context.** Only `finalOutput` is returned. 2. **Never let a throwing callback crash the runtime.** `safeEmit`/`safeProgress` swallow errors. 3. **Never silently drop a phase.** Parse errors in `when` → fail-open (phase runs). -4. **Never lose work.** Tournament judge failure → fallback to best variant. Gate ambiguity → PASS. +4. **Never lose work, and never rubber-stamp a gate.** Tournament judge failure → fallback to best variant (fail-open, work preserved). Gate *model output* that cannot be parsed → BLOCK (fail-closed, issue #54); config/resolution slips (unresolved `score.target`, malformed `scorers`) stay fail-open with a warning. 5. **Never hang forever.** Idle watchdog kills stalled subagents. Loops have hard iteration caps. 6. **Never break on resume.** Re-running a phase clears stale `endedAt`/`error` before starting. 7. **File operations must be atomic.** `writeFileAtomic` + file locks for all persistence. diff --git a/CHANGELOG.md b/CHANGELOG.md index 895f912..c07f375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,34 @@ All notable changes to taskflow are documented here. This project follows [Keep flag) after the first print, so subsequent sessions skip it. Best-effort: an unwritable agent dir only means the hint may show once more; it never blocks session startup. +- **Gate verdict parsing hardened — a genuine BLOCK is no longer silently + downgraded to PASS (issue #54).** Models routinely wrap verdict tokens in + Markdown emphasis (`VERDICT: **BLOCK**`, `### VERDICT: __BLOCK__`, + `VERDICT: `BLOCK``), which the bare-token regex + `/VERDICT\s*[:=]\s*(PASS|BLOCK|…)/` missed — the match fell through to the + fail-open default and the gate recorded `pass`, letting a blocked review + silently continue downstream. Three layered fixes: (1) a shared + `VERDICT_TOKEN_RE` now tolerates `*`/`_`/`` ` ``/`~` emphasis runs on either + side of the verdict word (in both `parseGateVerdict` and `parseJudgeOutput`); + (2) **gate *model output* that cannot be parsed now fails closed (BLOCK)** + instead of PASS — a gate that cannot reach a verdict cannot be trusted to + pass, while *config* slips (unresolved `score.target`, malformed `scorers`) + remain fail-open with a warning (they are authoring errors that degrade, not a + judge that couldn't decide); (3) a free-text gate whose task omits a + `VERDICT:` instruction now gets the exact format suffix **auto-appended**. + For maximum robustness, prefer `output: "json"` + `expect` enum + (`{ verdict: { enum: ["pass","block"] } }`) which machine-validates the verdict. + Regression tests added for every Markdown variant and the fail-closed default. + **Breaking** (pre-1.0): a gate whose model output contains *no* parseable + verdict (no `VERDICT:` marker and no JSON verdict object) now **blocks** the + flow instead of silently passing. Previously such gates rubber-stamped PASS. + Migration: any custom gate `task` that relied on prose-only output should + either emit `VERDICT: PASS|BLOCK` (auto-appended if omitted), adopt the + `output:"json"` + `expect` enum contract, or be marked `optional: true` with a + downstream fallback. Note that an *explicit* non-blocking JSON verdict (e.g. + `{"verdict":"No issues found"}`) still resolves to PASS — only truly + unparseable model output is affected. *Config* slips (unresolved `score.target`, + malformed `scorers`) remain fail-open with a warning. ## [0.1.6] — 2026-07-06 diff --git a/README.md b/README.md index 948c5e6..77999e4 100644 --- a/README.md +++ b/README.md @@ -506,7 +506,7 @@ for the agent-facing search→reuse→generalize→re-save workflow. - **`retry`** — `{ "max": 2, "backoffMs": 500, "factor": 2 }` retries a failing subagent with fixed or exponential backoff; usage is summed and the attempt count shows as `↻N` in the TUI. Transient provider errors (rate-limit / 5xx / timeout) **auto-retry even without an explicit policy**; hard errors don't. - **`onBlock`** — `"halt"` (default) stops the run when a gate blocks. `"retry"` retries upstream phases when a gate blocks, instead of halting — a self-healing rework loop with budget and idle-watchdog guards and a nested recursion depth cap. - **`eval`** — zero-token machine-checkable criteria that run *before* the LLM gate. If the eval check fails, the gate blocks without spawning an agent. -- **`score`** — graded, composable quality gates: deterministic scorers (`exact-match`, `contains`, `regex`, `json-schema`, `length-range`, `code-compiles`) run against a target string at **zero tokens** and combine via `all`/`any`/`weighted` against a `threshold`. Deterministic pass → auto-PASS with no LLM call **when the judge cannot veto** — no judge configured, or `weighted` where the deterministic score is a *lower bound* already clearing the threshold. With `all`/`any` + a judge, the judge always runs (its verdict is authoritative — it may check what scorers cannot, e.g. factuality). Deterministic fail → the optional LLM `judge` decides (fail-open on unparseable output), or the gate `task` runs with the scorer report appended, or — with no fallback — the gate **blocks explicitly**. The structured result is the gate's `.json` (`{steps..json.combined}`, `.json.results`), so downstream phases can route on quality, not just pass/fail. LLM-generated dynamic sub-flows may not use `code-compiles` (compiler execution) or `regex` (ReDoS) scorers — same hardening class as the `script` block. +- **`score`** — graded, composable quality gates: deterministic scorers (`exact-match`, `contains`, `regex`, `json-schema`, `length-range`, `code-compiles`) run against a target string at **zero tokens** and combine via `all`/`any`/`weighted` against a `threshold`. Deterministic pass → auto-PASS with no LLM call **when the judge cannot veto** — no judge configured, or `weighted` where the deterministic score is a *lower bound* already clearing the threshold. With `all`/`any` + a judge, the judge always runs (its verdict is authoritative — it may check what scorers cannot, e.g. factuality). Deterministic fail → the optional LLM `judge` decides (**fail-closed** on unparseable output — issue #54), or the gate `task` runs with the scorer report appended, or — with no fallback — the gate **blocks explicitly**. The structured result is the gate's `.json` (`{steps..json.combined}`, `.json.results`), so downstream phases can route on quality, not just pass/fail. LLM-generated dynamic sub-flows may not use `code-compiles` (compiler execution) or `regex` (ReDoS) scorers — same hardening class as the `script` block. - **`idempotent: false`** — side-effect classification for phases with **irreversible effects** (webhook POSTs, deploys, DB writes): the implicit transient auto-retry is suppressed (an explicit `retry{}` is still honored — it's the author's 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 ⚡). Default `true` — existing flows are unchanged. - **`approval`** — pause for a human (Approve / Reject / Edit). Reject halts the flow; Edit injects the typed note as the phase output for downstream steps. Non-interactive runs (detached / CI) **auto-reject** (safety: approval gates are never bypassed). - **`flow`** — `{ "type": "flow", "use": "deep-research", "with": { "topic": "{item}" } }` runs a **saved** flow as a phase (recursion is detected and rejected). Or **generate the sub-flow at runtime**: `{ "type": "flow", "def": "{steps.plan.json}" }` resolves an upstream phase's JSON output into a sub-flow, **validates it (cycles / dangling refs / duplicate ids / dead-ends), then runs it** — the number and shape of the generated phases is decided at runtime, not authored in advance. A malformed plan fails *open* (the phase is skipped with a `defError`, the run continues). This is how a planner decides *at runtime* what work to spawn — the declarative answer to a code-mode `for` loop, with each generated plan checked before it spends a token. Security hardening for LLM-generated sub-flows: breadth caps (100 phases, 200 map items, 16 concurrency), `cwd` containment, budget clamped to `min(child, parent)`, nesting cap (5 levels), and prototype-pollution defense (deep-cloned, `__proto__`/`constructor`/`prototype` stripped). Pair it with `loop` for **data-dependent iterative replanning** (round N's plan depends on round N-1's result). See [`examples/dynamic-plan-execute.json`](./examples/dynamic-plan-execute.json) and [`examples/iterative-replan.json`](./examples/iterative-replan.json). @@ -603,12 +603,12 @@ Every phase is already content-addressed: within a single run's **resume**, a ph ### Gate phases (quality control) -A `gate` runs an agent to review upstream output and can **block the rest of the workflow.** End the gate task by asking for a verdict the runtime can read: +A `gate` runs an agent to review upstream output and can **block the rest of the workflow.** Provide a verdict the runtime can read — **preferably via a JSON contract** (`output: "json"` + `expect: { properties: { verdict: { enum: ["pass","block"] } } }`), which machine-validates the output so a verdict can never be silently misread. Otherwise: -- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts `OK`, `FAIL`, `STOP`, `REJECT`, `HALT` — last occurrence wins), or +- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts `OK`, `FAIL`, `STOP`, `REJECT`, `HALT` — last occurrence wins; common Markdown emphasis like `VERDICT: **BLOCK**` is tolerated), or - JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}`. -On **BLOCK**, downstream phases skip and the run ends as `blocked` with the reason surfaced. **Ambiguous output fails open** (treated as PASS) — a gate never halts your flow by accident. +If a free-text gate's task doesn't already ask for a `VERDICT:` marker, the runtime **auto-appends** the exact format instruction. On **BLOCK**, downstream phases skip 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). Config slips (unresolved `score.target`, malformed `scorers`) still fail **open** with a warning. ``` Review the audit below. If any endpoint is missing auth, end with @@ -895,7 +895,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. **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)**: the verdict parser now tolerates Markdown-emphasized tokens (`VERDICT: **BLOCK**`, `### VERDICT: __BLOCK__`, `VERDICT: `BLOCK``) so a genuine BLOCK is never silently downgraded; **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 gates, use `output: "json"` + `expect` enum to machine-validate the verdict. **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. Known boundaries (tracked, bounded — no surprises mid-flow): diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index 9593aab..ea6a32a 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -219,15 +219,41 @@ For static (non-conditional) concurrency, a `parallel` phase runs fixed ### Gate phases (quality control) A `gate` phase runs an agent to review upstream output and can **block the rest -of the workflow**. End the gate task's instructions by asking the agent to emit a -verdict the runtime can read: +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: -- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or -- JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}` +**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. Ambiguous output **fails open** (treated as PASS) so a gate never -halts the flow by accident. +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 @@ -276,9 +302,10 @@ where the deterministic score is a lower bound already clearing the threshold 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-open: unparseable judge -→ PASS; unresolved `target` with no fallback → PASS + warning; malformed -`score` → the plain LLM gate. **Security:** LLM-generated dynamic sub-flows +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. diff --git a/packages/claude-taskflow/plugin/skills/taskflow/patterns.md b/packages/claude-taskflow/plugin/skills/taskflow/patterns.md index 88e02e3..c2519f5 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/patterns.md @@ -279,7 +279,7 @@ one item. | 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 | Ambiguity fails open → gate silently always passes | End every gate task with the exact verdict instruction | +| 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 | diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index 6fa0894..bac2df6 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -218,15 +218,41 @@ For static (non-conditional) concurrency, a `parallel` phase runs fixed ### Gate phases (quality control) A `gate` phase runs an agent to review upstream output and can **block the rest -of the workflow**. End the gate task's instructions by asking the agent to emit a -verdict the runtime can read: +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: -- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or -- JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}` +**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. Ambiguous output **fails open** (treated as PASS) so a gate never -halts the flow by accident. +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 @@ -275,9 +301,10 @@ where the deterministic score is a lower bound already clearing the threshold 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-open: unparseable judge -→ PASS; unresolved `target` with no fallback → PASS + warning; malformed -`score` → the plain LLM gate. **Security:** LLM-generated dynamic sub-flows +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. diff --git a/packages/codex-taskflow/plugin/skills/taskflow/patterns.md b/packages/codex-taskflow/plugin/skills/taskflow/patterns.md index 88e02e3..c2519f5 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/patterns.md @@ -279,7 +279,7 @@ one item. | 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 | Ambiguity fails open → gate silently always passes | End every gate task with the exact verdict instruction | +| 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 | diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 8b4eb70..9330478 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -219,15 +219,41 @@ For static (non-conditional) concurrency, a `parallel` phase runs fixed ### Gate phases (quality control) A `gate` phase runs an agent to review upstream output and can **block the rest -of the workflow**. End the gate task's instructions by asking the agent to emit a -verdict the runtime can read: +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: -- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or -- JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}` +**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. Ambiguous output **fails open** (treated as PASS) so a gate never -halts the flow by accident. +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 @@ -276,9 +302,10 @@ where the deterministic score is a lower bound already clearing the threshold 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-open: unparseable judge -→ PASS; unresolved `target` with no fallback → PASS + warning; malformed -`score` → the plain LLM gate. **Security:** LLM-generated dynamic sub-flows +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. diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md b/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md index 88e02e3..c2519f5 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md @@ -279,7 +279,7 @@ one item. | 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 | Ambiguity fails open → gate silently always passes | End every gate task with the exact verdict instruction | +| 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 | diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index bda41a7..74c70b4 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -210,15 +210,41 @@ For static (non-conditional) concurrency, a `parallel` phase runs fixed ### Gate phases (quality control) A `gate` phase runs an agent to review upstream output and can **block the rest -of the workflow**. End the gate task's instructions by asking the agent to emit a -verdict the runtime can read: +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: -- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or -- JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}` +**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. Ambiguous output **fails open** (treated as PASS) so a gate never -halts the flow by accident. +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 @@ -267,9 +293,10 @@ where the deterministic score is a lower bound already clearing the threshold 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-open: unparseable judge -→ PASS; unresolved `target` with no fallback → PASS + warning; malformed -`score` → the plain LLM gate. **Security:** LLM-generated dynamic sub-flows +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. diff --git a/packages/pi-taskflow/skills/taskflow/patterns.md b/packages/pi-taskflow/skills/taskflow/patterns.md index 93fbe23..fcfa3a1 100644 --- a/packages/pi-taskflow/skills/taskflow/patterns.md +++ b/packages/pi-taskflow/skills/taskflow/patterns.md @@ -276,7 +276,7 @@ use `why-stale` → `recompute` instead of a fresh run — see `advanced.md`. | 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 | Ambiguity fails open → gate silently always passes | End every gate task with the exact verdict instruction | +| 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 | diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index 096e92f..fa21021 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -37,7 +37,7 @@ const noRunnerInjected: RunTaskFn = async (_cwd, _agents, agentName, task) => ({ 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 } from "./scorers.ts"; +import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, type ScoreConfig, scoreResultJSON, type ScorerResult, scorerShapeErrors, VERDICT_TOKEN_RE } from "./scorers.ts"; import { runCodeCompilesScorer } from "./scorer-runtime.ts"; import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL, type ReflexionInput } from "./reflexion.ts"; import { hashInput, newRunId, type PhaseState, type RunState, runsDir } from "./store.ts"; @@ -1323,7 +1323,7 @@ async function executePhaseInner( if (phase.task) { const agentName = resolveAgent(phase.agent, deps, state); const text = interpolate(phase.task, freshCtx).text; - const fullTask = `${preRead}${text}\n\n---\n\n${report}`; + const fullTask = appendGateFormatSuffix(`${preRead}${text}\n\n---\n\n${report}`, phase); const ckT = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask, scoreId]); const inputHash = ckT.key; const cachedT = cachedPhase(cc, ckT); @@ -1409,7 +1409,7 @@ async function executePhaseInner( const interpM = interpolate(phase.task ?? "", ctx); const textM = interpM.text; const refWarningM = warnUnresolvedRefs(phase.id, interpM.missing); - const fullTaskM = preRead + textM; + const fullTaskM = appendGateFormatSuffix(preRead + textM, phase); const agentNameM = resolveAgent(phase.agent, deps, state); const ckM = cacheKeys(cc, [phase.id, agentNameM, phase.model ?? "", fullTaskM]); const cachedM = cachedPhase(cc, ckM); @@ -1425,7 +1425,7 @@ async function executePhaseInner( const interp = interpolate(phase.task ?? "", ctx); const text = interp.text; const refWarning = warnUnresolvedRefs(phase.id, interp.missing); - const fullTask = preRead + text; + const fullTask = appendGateFormatSuffix(preRead + text, phase); const agentName = resolveAgent(phase.agent, deps, state); const ck = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", fullTask]); const inputHash = ck.key; @@ -1487,7 +1487,7 @@ async function executePhaseInner( } const retryCtx = buildInterpolationContext(state, lastCompletedOutput(state, phase)); const retryText = interpolate(phase.task ?? "", retryCtx).text; - const retryTask = preRead + retryText; + const retryTask = appendGateFormatSuffix(preRead + retryText, phase); const retryIH = cacheKeys(cc, [phase.id, agentName, phase.model ?? "", retryTask]).key; const retryR = await runOne(agentName, retryTask, liveSink(state, phase.id, emitProgress), undefined, contractCheck); gatePs = resultToPhaseState(phase.id, retryR, retryIH, parseJson); @@ -2578,10 +2578,20 @@ function defaultAgent(deps: RuntimeDeps): string { } /** - * Parse a gate phase's output into a verdict. Blocks the flow only on an - * explicit negative signal; ambiguous output passes (fail-open). - * Accepts JSON ({continue|pass: bool} or {verdict: "..."}) or a text marker - * `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins). + * Parse a gate phase's output into a verdict. Blocks the flow on an explicit + * negative signal OR on ambiguous, unparseable model output. Accepts JSON + * ({continue|pass: bool} or {verdict: "..."}) or a text marker + * `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins). The text + * matcher tolerates common Markdown emphasis around the verdict word + * (`VERDICT: **BLOCK**`, `### VERDICT: __BLOCK__`, `VERDICT: `BLOCK``) so a + * genuine BLOCK is never silently downgraded to PASS (issue #54). + * + * **Fail-closed:** if the model produced output but no verdict could be parsed, + * the gate BLOCKS. A gate that cannot reach a verdict cannot be trusted to pass; + * halting is recoverable (prior phases persist, the run is resumable) whereas a + * rubber-stamped PASS is silent and potentially ships broken work. Note that a + * 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. */ export function parseGateVerdict(output: string): { verdict: "pass" | "block"; reason?: string } { const json = safeParse(output); @@ -2592,24 +2602,48 @@ export function parseGateVerdict(output: string): { verdict: "pass" | "block"; r if (typeof o.verdict === "string") { // Note: do NOT include standalone "no" — natural-language verdicts like // "No issues found" / "no errors" would otherwise be false-positive BLOCK. - // Fail-open covers any ambiguous text. + // An explicit non-blocking verdict word is a semantic PASS, not ambiguity: + // fail-closed below only applies when NO verdict could be parsed at all. const block = /block|fail|stop|reject|halt/i.test(o.verdict); return { verdict: block ? "block" : "pass", reason: asReason(o.reason) }; } } - const matches = [...output.matchAll(/VERDICT\s*[:=]\s*(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)/gi)]; + const matches = [...output.matchAll(VERDICT_TOKEN_RE)]; if (matches.length) { const v = matches[matches.length - 1][1].toUpperCase(); const pass = v === "PASS" || v === "OK"; return { verdict: pass ? "pass" : "block" }; } - return { verdict: "pass" }; + return { verdict: "block", reason: "unparseable gate verdict (fail-closed)" }; } function asReason(v: unknown): string | undefined { return typeof v === "string" && v.trim() ? v.trim() : undefined; } +/** + * If a gate phase relies on free-text verdict parsing (no `output:"json"` + + * `expect` contract) and its task does not already demand a `VERDICT:` marker, + * append a hard output-format suffix. This pushes the model toward the exact + * machine-readable terminator the parser expects, so a genuine verdict is not + * lost to an authoring slip or a model that "forgets" to emit one (issue #54). + * When the phase already enforces a JSON contract, no suffix is needed — the + * `expect` schema validates the output deterministically. + */ +function appendGateFormatSuffix(task: string, phase: Phase): string { + // Only free-text GATE phases need the verdict terminator. Agent/map/reduce/loop + // phases pass through untouched — they have no verdict to parse. + if (phase.type !== "gate") return task; + if (phase.output === "json" && phase.expect) return task; + // Already asks for a verdict marker (any case) — don't duplicate. + if (/VERDICT\s*[:=]/i.test(task)) return task; + return ( + `${task}\n\n--- Required output format ---\n` + + `End your response with exactly one line in this exact form (no Markdown, no bold, no extra words):\n` + + `VERDICT: PASS\nor\nVERDICT: BLOCK` + ); +} + /** * 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]. diff --git a/packages/taskflow-core/src/scorers.ts b/packages/taskflow-core/src/scorers.ts index 3cdf135..2c18e00 100644 --- a/packages/taskflow-core/src/scorers.ts +++ b/packages/taskflow-core/src/scorers.ts @@ -336,12 +336,25 @@ export function scoreResultJSON( return out; } +/** + * Shared verdict-token matcher. Matches `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` + * (last occurrence wins) AND tolerates common Markdown emphasis around the verdict + * word, because models frequently emit `VERDICT: **BLOCK**`, `VERDICT: __BLOCK__`, + * `VERDICT: `BLOCK``, or `### VERDICT: **BLOCK**` — a bare-token regex silently + * misses these and falls to the fail-closed default (issue #54). The emphasis + * chars (`*`, `_`, `~`, `` ` ``) may appear in runs on either side of the word. + */ +export const VERDICT_TOKEN_RE = + /VERDICT\s*[:=]\s*(?:[*_~`]+\s*)?(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)(?:\s*[*_~`]+)?/gi; + /** * Parse an LLM judge's output into a score + verdict. Accepts JSON * ({score: 0..1, verdict?, reason?}), bare {verdict}, or a text - * `SCORE: 0.x` / `VERDICT: PASS|BLOCK` marker. Fail-open per the project - * invariant: unparseable output PASSES with score 1 (ambiguity must not - * block the flow — same stance as parseGateVerdict). + * `SCORE: 0.x` / `VERDICT: PASS|BLOCK` marker. **Fail-closed** per the gate + * safety stance (issue #54): unparseable *model* output BLOCKS with score 0. + * Config/resolution errors (unresolved score.target, malformed scorers) are a + * different path and remain fail-open with a warning — they are authoring + * slips, not a judge that could not reach a verdict. */ export function parseJudgeOutput(output: string): { score: number; @@ -366,15 +379,18 @@ export function parseJudgeOutput(output: string): { } } const scoreMatches = [...output.matchAll(/SCORE\s*[:=]\s*([01](?:\.\d+)?)/gi)]; - const verdictMatches = [...output.matchAll(/VERDICT\s*[:=]\s*(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)/gi)]; + const verdictMatches = [...output.matchAll(VERDICT_TOKEN_RE)]; if (scoreMatches.length || verdictMatches.length) { const s = scoreMatches.length ? clamp(Number(scoreMatches[scoreMatches.length - 1][1])) : undefined; const v = verdictMatches.length ? verdictMatches[verdictMatches.length - 1][1].toUpperCase() : undefined; const blocked = v !== undefined ? !(v === "PASS" || v === "OK") : (s ?? 1) < 0.5; return { score: s ?? (blocked ? 0 : 1), verdict: blocked ? "block" : "pass", parsed: true }; } - // Fail-open: ambiguous judge output must not block the flow. - return { score: 1, verdict: "pass", reason: "unparseable judge output (fail-open pass)", parsed: false }; + // Fail-closed (issue #54): a judge that reached no readable verdict cannot be + // trusted to pass. Block with score 0 so a weighted combination drags down and + // an all/any gate halts — the run is recoverable (prior phases persist) and the + // silent rubber-stamp of a missed BLOCK is eliminated. + return { score: 0, verdict: "block", reason: "unparseable judge output (fail-closed)", parsed: false }; } function truncate(s: string, n: number): string { diff --git a/packages/taskflow-core/test/gate-score.test.ts b/packages/taskflow-core/test/gate-score.test.ts index bef475d..1092310 100644 --- a/packages/taskflow-core/test/gate-score.test.ts +++ b/packages/taskflow-core/test/gate-score.test.ts @@ -128,7 +128,7 @@ test("combine: judge weight makes deterministic combination a lower bound", () = assert.equal(final.passed, true); }); -test("parseJudgeOutput: JSON, text markers, fail-open", () => { +test("parseJudgeOutput: JSON, text markers, fail-closed", () => { assert.deepEqual( (({ score, verdict }) => ({ score, verdict }))(parseJudgeOutput('{"score": 0.9, "verdict": "pass"}')), { score: 0.9, verdict: "pass" }, @@ -136,8 +136,12 @@ test("parseJudgeOutput: JSON, text markers, fail-open", () => { assert.equal(parseJudgeOutput('{"score": 0.2}').verdict, "block"); assert.equal(parseJudgeOutput("Analysis...\nVERDICT: BLOCK").verdict, "block"); assert.equal(parseJudgeOutput("SCORE: 0.75").score, 0.75); + // Issue #54: Markdown-emphasized verdict tokens are parsed, not missed. + assert.equal(parseJudgeOutput("### VERDICT: **BLOCK**").verdict, "block"); + assert.equal(parseJudgeOutput("VERDICT: **PASS**").verdict, "pass"); const open = parseJudgeOutput("I am not sure what to say"); - assert.equal(open.verdict, "pass"); + assert.equal(open.verdict, "block", "unparseable judge output fails closed"); + assert.equal(open.score, 0, "unparseable judge output scores 0"); assert.equal(open.parsed, false); }); @@ -355,9 +359,9 @@ test("score gate: deterministic fail + judge → judge decides (verdict authorit assert.equal(json.judge?.score, 0.85); }); -test("score gate: judge unparseable → fail-open PASS", async () => { +test("score gate: judge unparseable → fail-closed BLOCK", async () => { const def = { - name: "score-judge-open", + name: "score-judge-closed", phases: [ { id: "gen", type: "agent", task: "produce" }, { @@ -372,8 +376,8 @@ test("score gate: judge unparseable → fail-open PASS", async () => { runTask: async (_c, _a, _n, task) => (task.includes("Judge it") ? ok("mumble mumble") : ok("output")), }; const result = await executeTaskflow(state, deps); - assert.equal(result.ok, true); - assert.equal(state.phases["check"]?.gate?.verdict, "pass", "ambiguous judge output must fail-open"); + assert.equal(result.ok, false, "an unparseable judge halts the flow (fail-closed)"); + assert.equal(state.phases["check"]?.gate?.verdict, "block", "ambiguous judge output must fail-closed"); }); test("score gate: deterministic fail + task fallback → gate task decides with report", async () => { diff --git a/packages/taskflow-core/test/runtime.test.ts b/packages/taskflow-core/test/runtime.test.ts index 0a4bf93..51ade46 100644 --- a/packages/taskflow-core/test/runtime.test.ts +++ b/packages/taskflow-core/test/runtime.test.ts @@ -339,7 +339,7 @@ test("runtime: concurrency cap is respected in map", async () => { assert.ok(peak <= 2, `peak concurrency ${peak} exceeded cap 2`); }); -test("parseGateVerdict: text markers, JSON, and fail-open default", () => { +test("parseGateVerdict: text markers, JSON, Markdown emphasis, and fail-closed default", () => { assert.equal(parseGateVerdict("looks good\nVERDICT: PASS").verdict, "pass"); assert.equal(parseGateVerdict("issues found\nVERDICT: BLOCK").verdict, "block"); assert.equal(parseGateVerdict("VERDICT: OK").verdict, "pass"); @@ -349,13 +349,23 @@ test("parseGateVerdict: text markers, JSON, and fail-open default", () => { assert.equal(parseGateVerdict('{"verdict": "reject"}').verdict, "block"); // F-005 regression: standalone "no" / "No issues found" must NOT be classified as BLOCK. // Natural-language verdicts like these are semantically PASS; the remaining - // block|fail|stop|reject|halt keywords cover genuine block signals, and - // fail-open handles anything ambiguous. + // block|fail|stop|reject|halt keywords cover genuine block signals. An explicit + // non-blocking JSON verdict is a semantic PASS, NOT ambiguity. assert.equal(parseGateVerdict('{"verdict": "No issues found"}').verdict, "pass"); assert.equal(parseGateVerdict('{"verdict": "no errors detected"}').verdict, "pass"); assert.equal(parseGateVerdict('{"verdict": "No"}').verdict, "pass"); - // ambiguous output → fail-open (pass), never accidentally halt - assert.equal(parseGateVerdict("just some prose with no verdict").verdict, "pass"); + // Issue #54 regression: Markdown-emphasized verdict tokens must be parsed, not + // silently downgraded to the fail-closed default. + assert.equal(parseGateVerdict("Review failed.\n\n### VERDICT: **BLOCK**").verdict, "block"); + assert.equal(parseGateVerdict("VERDICT: **BLOCK**").verdict, "block"); + assert.equal(parseGateVerdict("VERDICT: __BLOCK__").verdict, "block"); + assert.equal(parseGateVerdict("VERDICT: `BLOCK`").verdict, "block"); + assert.equal(parseGateVerdict("### VERDICT: **PASS**").verdict, "pass"); + assert.equal(parseGateVerdict("VERDICT: **PASS**").verdict, "pass"); + assert.equal(parseGateVerdict("required fix: update X\nVERDICT: ~~BLOCK~~").verdict, "block"); + // ambiguous model output (no verdict at all) → fail-closed (block), never pass. + assert.equal(parseGateVerdict("just some prose with no verdict").verdict, "block"); + assert.match(parseGateVerdict("just some prose with no verdict").reason ?? "", /fail-closed/i); }); test("runtime: gate BLOCK halts the flow and skips downstream", async () => { @@ -395,6 +405,60 @@ test("runtime: gate PASS lets the flow continue", async () => { assert.equal(res.state.phases.ship.status, "done"); }); +test("issue #54: free-text gate auto-appends VERDICT format suffix; model then emits a parseable verdict", async () => { + // A gate whose task does NOT ask for a VERDICT marker must still get a verdict + // because the runtime auto-appends the format suffix (Layer 1). The model + // complies by emitting the exact terminator, which parses correctly. + let seenTask = ""; + const def: Taskflow = { + name: "gate-autoformat", + phases: [ + { id: "work", type: "agent", agent: "a", task: "do work" }, + { id: "check", type: "gate", agent: "a", task: "Is this safe?", dependsOn: ["work"] }, + ], + }; + const deps = baseDeps( + mockRunner((t) => { + if (t.startsWith("Is this safe")) { + seenTask = t; + return "Looks fine.\nVERDICT: PASS"; + } + return `ok:${t}`; + }), + ); + const res = await executeTaskflow(mkState(def), deps); + assert.ok(/VERDICT\s*[:=]/i.test(seenTask), "gate task must be auto-suffixed with a VERDICT format instruction"); + assert.match(seenTask, /Required output format/); + assert.equal(res.state.phases.check.gate?.verdict, "pass"); +}); + +test("issue #54: a gate with output:json + expect does NOT get the VERDICT suffix (JSON contract governs)", async () => { + let seenTask = ""; + const def: Taskflow = { + name: "gate-json-contract", + phases: [ + { id: "work", type: "agent", agent: "a", task: "do work" }, + { + id: "check", type: "gate", agent: "a", task: "Is this safe?", dependsOn: ["work"], + output: "json", + expect: { type: "object", properties: { verdict: { enum: ["pass", "block"] } }, required: ["verdict"] }, + }, + ], + }; + const deps = baseDeps( + mockRunner((t) => { + if (t.startsWith("Is this safe")) { + seenTask = t; + return '{"verdict": "pass", "reason": "ok"}'; + } + return `ok:${t}`; + }), + ); + const res = await executeTaskflow(mkState(def), deps); + assert.ok(!/Required output format/.test(seenTask), "a JSON-contract gate must not receive the free-text VERDICT suffix"); + assert.equal(res.state.phases.check.gate?.verdict, "pass"); +}); + test("runtime: completed phases retain startedAt (run elapsed regression)", async () => { const def: Taskflow = { name: "timed", diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 4aa5b15..6b00f64 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -226,15 +226,41 @@ For static (non-conditional) concurrency, a `parallel` phase runs fixed ### Gate phases (quality control) A `gate` phase runs an agent to review upstream output and can **block the rest -of the workflow**. End the gate task's instructions by asking the agent to emit a -verdict the runtime can read: +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: -- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or -- JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}` +**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. Ambiguous output **fails open** (treated as PASS) so a gate never -halts the flow by accident. +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 @@ -283,9 +309,10 @@ where the deterministic score is a lower bound already clearing the threshold 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-open: unparseable judge -→ PASS; unresolved `target` with no fallback → PASS + warning; malformed -`score` → the plain LLM gate. **Security:** LLM-generated dynamic sub-flows +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. diff --git a/skills-src/taskflow/patterns.md b/skills-src/taskflow/patterns.md index b6d915b..117b366 100644 --- a/skills-src/taskflow/patterns.md +++ b/skills-src/taskflow/patterns.md @@ -283,7 +283,7 @@ use `why-stale` → `recompute` instead of a fresh run — see `advanced.md`. | 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 | Ambiguity fails open → gate silently always passes | End every gate task with the exact verdict instruction | +| 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 | From e1d81fc9eec28970e8ea2b44d1ddfa95779bcd3b Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 8 Jul 2026 14:47:55 +0800 Subject: [PATCH 08/16] docs(website): fix Google Search Console verification + SEO meta gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wire the GSC verification code (iBm6KBJ...) as the hardcoded default in [lang]/layout.tsx so the meta tag emits in every build (CI never set the GOOGLE_SITE_VERIFICATION env var, so the tag was absent in production). - Enrich the root redirect page (app/page.tsx): was a bare taskflow shell with no description/og/canonical; now has full SEO meta + the verification tag, with canonical -> /en/. - Strengthen the homepage from 'taskflow' to the keyword-rich 'taskflow — Declarative DAG Orchestration for Coding Agents' (title is the #1 on-page SEO factor); split out a field so subpages keep the short 'X | taskflow' template. - Add the missing field to taskflow-hosts/package.json so the npm page links back to the docs. Also updated via gh CLI: repo homepageUrl, description (mention all 4 hosts), and topics (+claude-code, +opencode, +mcp). --- packages/taskflow-hosts/package.json | 1 + website/app/[lang]/layout.tsx | 19 +++++++++---- website/app/page.tsx | 41 +++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index 48bd100..19b1c27 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -2,6 +2,7 @@ "name": "taskflow-hosts", "version": "0.1.7", "description": "Shared host-runner collection for taskflow — the codex, claude, and opencode 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; 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", "engines": { "node": ">=22.19.0" diff --git a/website/app/[lang]/layout.tsx b/website/app/[lang]/layout.tsx index 3c6fdae..8e83983 100644 --- a/website/app/[lang]/layout.tsx +++ b/website/app/[lang]/layout.tsx @@ -9,20 +9,27 @@ export function generateStaticParams() { const site = { en: { - title: 'taskflow', + // 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', + title: 'taskflow — 面向编程智能体的声明式 DAG 任务编排', + brand: 'taskflow', description: '面向编程智能体子代理的声明式、可验证任务节点图。支持 fan-out、gate、loop、断点续跑,并保存为命令。', }, } as const; -// Replace this with the actual content from Google Search Console HTML tag verification. -// Example: 'abc123...' -const GOOGLE_SITE_VERIFICATION = process.env.GOOGLE_SITE_VERIFICATION || ''; +// 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'; // The site is deployed under a GitHub Pages subpath (/taskflow), so every // metadata asset URL (favicon, apple-touch-icon, manifest, icon PNGs) must be @@ -43,7 +50,7 @@ export async function generateMetadata({ metadataBase: new URL('https://heggria.github.io/taskflow'), title: { default: meta.title, - template: `%s | ${meta.title}`, + template: `%s | ${meta.brand}`, }, description: meta.description, alternates: { diff --git a/website/app/page.tsx b/website/app/page.tsx index 8ebc724..d761498 100644 --- a/website/app/page.tsx +++ b/website/app/page.tsx @@ -2,8 +2,47 @@ export default function RootPage() { return ( <html lang="en"> <head> + {/* Redirect human visitors to the English landing page. The full SEO + metadata below keeps the root URL useful to crawlers and link + previews; /en/ is the canonical document (see <link canonical>). */} <meta httpEquiv="refresh" content="0; url=./en/" /> - <title>taskflow + taskflow — Declarative DAG Orchestration for Coding Agents + + + + + + + + + + + + From 75cbbd43677b9c074e21b12d782a8d23a227b30a Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 8 Jul 2026 14:48:13 +0800 Subject: [PATCH 09/16] docs(website): add comparisons + blog sections (SEO content) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long-tail, high-intent content pages that capture search traffic where taskflow is the best answer (the generic 'taskflow' keyword is unwinnable against Apache Airflow's TaskFlow API). All pages reuse the existing docs route, so each inherits full SEO automatically: descriptive title, description, canonical, hreflang alternates, and TechArticle + Breadcrumb JSON-LD. Comparisons (the 'X vs Y' queries, very high intent): - comparisons/index — overview + one-axis mental model - comparisons/taskflow-vs-workflow — declarative DAG vs imperative code-mode - comparisons/taskflow-vs-subagents — vs built-in task/tasks/chain shorthand - comparisons/taskflow-vs-langgraph — vs the general-purpose graph framework Blog (long-tail how-to / concept queries): - blog/index — listing - blog/orchestrate-codex-subagents — 'how to orchestrate codex subagents' - blog/claude-code-mcp-workflow — 'claude code mcp workflow' w/ gates - blog/what-is-a-task-graph — 'task graph DAG LLM' concept piece Bilingual: full EN + zh-CN mirrors. Adds 16 URLs (sitemap 81 -> 97). Registered in both root meta.json sidebars under new sections. --- .../docs/en/blog/claude-code-mcp-workflow.mdx | 112 ++++++++++++++ website/content/docs/en/blog/index.mdx | 20 +++ website/content/docs/en/blog/meta.json | 9 ++ .../en/blog/orchestrate-codex-subagents.mdx | 140 +++++++++++++++++ .../docs/en/blog/what-is-a-task-graph.mdx | 90 +++++++++++ website/content/docs/en/comparisons/index.mdx | 31 ++++ website/content/docs/en/comparisons/meta.json | 9 ++ .../en/comparisons/taskflow-vs-langgraph.mdx | 93 +++++++++++ .../en/comparisons/taskflow-vs-subagents.mdx | 145 ++++++++++++++++++ .../en/comparisons/taskflow-vs-workflow.mdx | 78 ++++++++++ website/content/docs/en/meta.json | 12 +- .../zh-cn/blog/claude-code-mcp-workflow.mdx | 112 ++++++++++++++ website/content/docs/zh-cn/blog/index.mdx | 20 +++ website/content/docs/zh-cn/blog/meta.json | 9 ++ .../blog/orchestrate-codex-subagents.mdx | 140 +++++++++++++++++ .../docs/zh-cn/blog/what-is-a-task-graph.mdx | 90 +++++++++++ .../content/docs/zh-cn/comparisons/index.mdx | 31 ++++ .../content/docs/zh-cn/comparisons/meta.json | 9 ++ .../comparisons/taskflow-vs-langgraph.mdx | 93 +++++++++++ .../comparisons/taskflow-vs-subagents.mdx | 145 ++++++++++++++++++ .../comparisons/taskflow-vs-workflow.mdx | 78 ++++++++++ website/content/docs/zh-cn/meta.json | 12 +- 22 files changed, 1476 insertions(+), 2 deletions(-) create mode 100644 website/content/docs/en/blog/claude-code-mcp-workflow.mdx create mode 100644 website/content/docs/en/blog/index.mdx create mode 100644 website/content/docs/en/blog/meta.json create mode 100644 website/content/docs/en/blog/orchestrate-codex-subagents.mdx create mode 100644 website/content/docs/en/blog/what-is-a-task-graph.mdx create mode 100644 website/content/docs/en/comparisons/index.mdx create mode 100644 website/content/docs/en/comparisons/meta.json create mode 100644 website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx create mode 100644 website/content/docs/en/comparisons/taskflow-vs-subagents.mdx create mode 100644 website/content/docs/en/comparisons/taskflow-vs-workflow.mdx create mode 100644 website/content/docs/zh-cn/blog/claude-code-mcp-workflow.mdx create mode 100644 website/content/docs/zh-cn/blog/index.mdx create mode 100644 website/content/docs/zh-cn/blog/meta.json create mode 100644 website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx create mode 100644 website/content/docs/zh-cn/blog/what-is-a-task-graph.mdx create mode 100644 website/content/docs/zh-cn/comparisons/index.mdx create mode 100644 website/content/docs/zh-cn/comparisons/meta.json create mode 100644 website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx create mode 100644 website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx create mode 100644 website/content/docs/zh-cn/comparisons/taskflow-vs-workflow.mdx diff --git a/website/content/docs/en/blog/claude-code-mcp-workflow.mdx b/website/content/docs/en/blog/claude-code-mcp-workflow.mdx new file mode 100644 index 0000000..b663dbf --- /dev/null +++ b/website/content/docs/en/blog/claude-code-mcp-workflow.mdx @@ -0,0 +1,112 @@ +--- +title: "Claude Code + MCP: a workflow that gates itself" +description: Run a taskflow MCP server inside Claude Code to get declarative task graphs with quality gates, budget caps, and context isolation — without leaving your Claude session. +--- + +Claude Code is a powerful coding agent on its own. But when a job needs a **repeatable pipeline** — review every changed file, gate on risk, stay under a spend cap — ad-hoc prompting runs into the same limits every agent does: the model re-derives the plan each time, intermediate transcripts fill your context, and there's no way to halt the whole thing if a reviewer finds a critical issue. This post shows how to add a **taskflow MCP server** to Claude Code and run a self-gating review pipeline from inside your session. + +## Add the taskflow MCP server to Claude Code + +taskflow ships as a Claude Code plugin that registers an MCP server exposing the `taskflow_*` tools: + +```bash +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow +``` + +That's it — the plugin wires up the MCP server (via `npx claude-taskflow-mcp`) and drops in the taskflow skill. Restart Claude Code and you'll have `taskflow_run`, `taskflow_verify`, `taskflow_resume`, and friends available as tools. + +## The job: a gated security review + +You want a pipeline that runs on every PR: discover changed files, fan out a security review per file, run an architecture review in parallel, and **halt** if anything critical turns up — returning a single merged report. + +Declare it as a graph: + +```json title="pr-security-review.json" +{ + "name": "pr-security-review", + "description": "Review a PR for security and architecture issues, gate on risk, return one report.", + "budget": { "maxUSD": 1.50 }, + "phases": [ + { + "id": "discover", + "agent": "scout", + "task": "List files changed since origin/main as a JSON array of paths." + }, + { + "id": "sec-review", + "map": "{steps.discover.json}", + "agent": "security-reviewer", + "task": "Audit {item} for vulnerabilities: injection, auth, secrets, unsafe deserialization. Return a risk level and one-line finding.", + "concurrency": 4 + }, + { + "id": "arch-review", + "agent": "reviewer", + "dependsOn": ["discover"], + "task": "Review the overall diff for architectural problems: coupling, layering, missing abstractions." + }, + { + "id": "gate", + "type": "gate", + "dependsOn": ["sec-review", "arch-review"], + "agent": "plan-arbiter", + "task": "Given the per-file security findings and the architecture review, emit VERDICT: BLOCK if any critical/high issue exists, else VERDICT: PASS." + }, + { + "id": "report", + "agent": "executor", + "dependsOn": ["gate"], + "task": "Merge the security and architecture reviews into one PR report with a verdict and action items." + } + ] +} +``` + +## Why the `gate` phase matters + +The `gate` phase is the thing ad-hoc prompting structurally can't give you. It's a phase whose **sole job** is to decide whether the flow continues: + +- If `plan-arbiter` emits `VERDICT: PASS` → the flow proceeds to `report`. +- If it emits `VERDICT: BLOCK` → the flow **halts immediately**. `report` never runs. You get the blocker finding instead of a polished report that buries it. + +That's a quality gate with teeth. The fail-open rule (ambiguous output → PASS) means a confused gate won't accidentally halt good work — but a clear `BLOCK` is final. + +## Run it from Claude Code + +```text +# verify the graph for free first +taskflow_verify pr-security-review + +# run it +taskflow_run pr-security-review +``` + +What Claude Code sees: a **single streaming tool call** that returns the final report. What it doesn't see: the dozens of per-file security-reviewer transcripts. Those stay inside the taskflow runtime — your context window gets the merged report, not the raw noise. + +## Budget caps as a first-class field + +```json +"budget": { "maxUSD": 1.50 } +``` + +If a fan-out over an unexpectedly large diff would blow past $1.50, the runtime halts the run with a budget-exceeded outcome. No surprises on the invoice. Pair this with `concurrency: 4` so the fan-out is bounded even before the budget kicks in. + +## Save it and reuse it + +Describe the pipeline once: + +```text +taskflow_save pr-security-review pr-security-review.json +``` + +Now every PR review is `taskflow_run pr-security-review` — no re-prompting, no re-deriving the plan. And because phases are cached on their input hash, re-running after a tiny prompt tweak only re-executes the changed phase and its downstream. + +## Takeaways + +- The taskflow **MCP server** brings declarative task graphs *into* Claude Code as tools. +- `gate` phases give you a real halt-on-block quality gate. +- `budget` caps spend; `map` fans out with bounded concurrency. +- 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. diff --git a/website/content/docs/en/blog/index.mdx b/website/content/docs/en/blog/index.mdx new file mode 100644 index 0000000..425aa2c --- /dev/null +++ b/website/content/docs/en/blog/index.mdx @@ -0,0 +1,20 @@ +--- +title: Blog +description: Practical guides and deep dives on orchestrating coding-agent subagents — Codex, Claude Code, OpenCode, and Pi — with declarative task graphs. +--- + +The taskflow blog covers practical patterns for orchestrating coding-agent subagents: how to fan out reviews, build reusable pipelines, control spend, and keep intermediate transcripts out of your context window. + + + + Turn ad-hoc Codex delegations into a named, verified, resumable pipeline. + + + Run a taskflow MCP server inside Claude Code with quality gates and budget caps. + + + The concept behind taskflow — why a graph of discrete tasks beats a flowing script. + + + +Most posts assume you've read [What is taskflow?](/en/docs/what-is-taskflow) and the [getting started guide](/en/docs/getting-started). The [templates gallery](/en/docs/templates) has five ready-to-run flows that pair well with these posts. diff --git a/website/content/docs/en/blog/meta.json b/website/content/docs/en/blog/meta.json new file mode 100644 index 0000000..845a26d --- /dev/null +++ b/website/content/docs/en/blog/meta.json @@ -0,0 +1,9 @@ +{ + "title": "Blog", + "pages": [ + "index", + "orchestrate-codex-subagents", + "claude-code-mcp-workflow", + "what-is-a-task-graph" + ] +} diff --git a/website/content/docs/en/blog/orchestrate-codex-subagents.mdx b/website/content/docs/en/blog/orchestrate-codex-subagents.mdx new file mode 100644 index 0000000..df05af8 --- /dev/null +++ b/website/content/docs/en/blog/orchestrate-codex-subagents.mdx @@ -0,0 +1,140 @@ +--- +title: Orchestrate Codex subagents with a task graph +description: How to turn ad-hoc OpenAI Codex subagent delegations into a named, statically verified, resumable pipeline using taskflow — fan-out review, gates, and budget caps included. +--- + +OpenAI Codex can spawn subagents to do focused work — but once you're past two or three calls, you hit a familiar wall: the model re-derives the plan every run, intermediate transcripts flood your session, a single failed call means starting over, and there's no way to *check* the plan before you pay for it. This post shows how to wrap Codex subagent work in a **taskflow** — a declarative graph that's verified before it runs, resumable across sessions, and whose intermediate results stay out of your context. + +## The wall + +You're shipping a release and want a readiness check. The Codex-natural way is to *script* it: + +```text title="ad-hoc Codex session" +You: "Check whether this branch is ready to release." +Codex: [spawns a subagent to list changed files] + [spawns a subagent per file to audit breaking changes] + [spawns a subagent to check the changelog] + [tries to summarize...] +``` + +That works once. The fourth time you do it, the problems compound: + +- **No reuse** — you re-describe the whole plan every release. +- **No verification** — you find out the plan is broken *while paying for it*. +- **No resume** — one subagent call fails on a rate limit and you start over. +- **No budget guardrail** — a fan-out over 50 files spends what it spends. + +## The taskflow version + +Install the Codex plugin: + +```bash +codex plugin marketplace add heggria/taskflow +codex plugin add taskflow@taskflow +``` + +Then declare the same job as a graph: + +```json title="release-readiness.json" +{ + "name": "release-readiness", + "description": "Verify a branch is ready to release: discover changes, fan out breaking-change review, check the changelog, gate on risk.", + "args": { + "base": { "default": "origin/main", "description": "The ref to diff against." } + }, + "budget": { "maxUSD": 2.00 }, + "phases": [ + { + "id": "discover", + "agent": "scout", + "task": "List every file changed between {args.base} and HEAD, as a JSON array of paths." + }, + { + "id": "review", + "map": "{steps.discover.json}", + "agent": "risk-reviewer", + "task": "Audit {item} for breaking changes. Return a one-line risk verdict.", + "concurrency": 4 + }, + { + "id": "changelog", + "agent": "executor", + "task": "Does CHANGELOG.md cover the changes since {args.base}? Answer yes/no with evidence." + }, + { + "id": "gate", + "type": "gate", + "dependsOn": ["review", "changelog"], + "agent": "plan-arbiter", + "task": "Based on the per-file reviews and the changelog check, emit VERDICT: PASS or VERDICT: BLOCK with reasons." + }, + { + "id": "report", + "agent": "executor", + "dependsOn": ["gate"], + "task": "Summarize the release-readiness verdict into a short report." + } + ] +} +``` + +## Verify it for free, then run it + +Before you spend a token, check the graph: + +```bash +# through the taskflow tool in your Codex session +taskflow_verify release-readiness +``` + +`verify` runs static analysis — no model calls. It catches cycles, dead-end phases, a `map` that won't produce an array, a `budget` that the phase count makes infeasible, and dangling `{steps.*}` references. You fix these *before* paying. + +Then run it: + +```text +taskflow_run release-readiness --base origin/main +``` + +The runtime: + +1. Resolves the graph and spawns phase `discover`. +2. Fans out one `risk-reviewer` per discovered file, **bounded to 4 concurrent**. +3. Runs `changelog` in parallel with the reviews. +4. Hits `gate` — if it emits `VERDICT: BLOCK`, the flow halts and `report` never runs. +5. Returns **only the final report** to your session. + +The 50 per-file transcripts never enter your Codex context. Only the merged report does. + +## What you get that ad-hoc scripting can't give you + +| Concern | Ad-hoc Codex | taskflow | +|---|---|---| +| **Reuse** | re-describe every time | run `release-readiness` by name | +| **Cost control** | none | `budget: { maxUSD: 2.00 }` halts the run | +| **Failure** | start over | per-phase `retry` + cross-session resume | +| **Quality gate** | eyeball it | `gate` phase halts on `BLOCK` | +| **Context** | every transcript returns | only the final report returns | + +## Resume across sessions + +Run it, walk away, come back tomorrow: + +```text +taskflow_resume +``` + +Phases that already completed are served from cache (keyed on their resolved inputs). If you edited `changelog`'s prompt overnight, only `changelog`, `gate`, and `report` re-run — `discover` and all the reviews are reused. See [incremental recompute](/en/docs/reference/incremental-recompute). + +## When to stay ad-hoc + +Don't reach for taskflow for a one-off "go look at X." The built-in subagent shorthand is right for that. Reach for it when the job is a **repeatable pipeline** you'll run on every release — exactly the case where re-describing it each time hurts most. + +## Takeaways + +- A taskflow is **data**, not code — so you can verify it before running. +- `map` fans out one subagent per item with bounded concurrency. +- `gate` phases give you a real quality gate that can halt the flow. +- `budget` caps spend; resume is free and cross-session. +- 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. diff --git a/website/content/docs/en/blog/what-is-a-task-graph.mdx b/website/content/docs/en/blog/what-is-a-task-graph.mdx new file mode 100644 index 0000000..5e9fc0b --- /dev/null +++ b/website/content/docs/en/blog/what-is-a-task-graph.mdx @@ -0,0 +1,90 @@ +--- +title: What is a task graph? (for LLM orchestration) +description: The concept behind taskflow — why modeling LLM work as a graph of discrete, declared task nodes (a DAG) is more verifiable, observable, and resumable than a flowing script. +--- + +"Task graph" is the core idea behind taskflow, but the term gets used loosely. This page pins down what a task graph actually is in the context of LLM orchestration, why it's different from a "workflow," and what you gain by making the graph **declarative data** instead of imperative code. + +## Task vs work + +In engineering, a **task** is a *discrete, declared unit of work* — the node of a task graph, the same kind of `task` a build system, a scheduler, or a compiler wires into a DAG. **Work**, by contrast, is *fluid and unbounded* — the continuous, imperative act of doing. + +That distinction is the whole design split: + +- A **workflow** (the dynamic, code-mode kind) is the model writing an **imperative script** that *flows*: `await agent(...)`, an `if`, a `for`, another `await`. Expressive — Turing-complete — but the graph only exists *as the code runs*. You can't see it, diff it, or prove it terminates before you pay for it. +- A **taskflow** moves the plan **out of code and into a declarative graph of task nodes.** Because the graph is *data*, the runtime can do what an imperative script structurally cannot. + +## What makes it a graph (not a chain) + +Most agent "orchestration" is actually a **chain** — step 1, step 2, step 3, linearly. A task graph generalizes this in three directions: + +### 1. Dependencies are edges, not order + +```json +{ + "phases": [ + { "id": "discover", "agent": "scout", "task": "..." }, + { "id": "review-a", "agent": "reviewer", "dependsOn": ["discover"], "task": "..." }, + { "id": "review-b", "agent": "risk-reviewer", "dependsOn": ["discover"], "task": "..." }, + { "id": "merge", "agent": "executor", "dependsOn": ["review-a", "review-b"], "task": "..." } + ] +} +``` + +`review-a` and `review-b` both depend on `discover` and nothing else — so the runtime runs them **in parallel**. `merge` waits for both. You didn't write "do these concurrently"; you declared the *edges*, and the runtime derived the schedule. This is layered concurrency for free. + +### 2. Fan-out is a first-class shape + +```json +{ + "id": "review", + "map": "{steps.discover.json}", + "agent": "security-reviewer", + "task": "Audit {item} for vulnerabilities.", + "concurrency": 4 +} +``` + +`map` creates **one task node per item** in an array — dynamically, at runtime, based on what `discover` produced. If `discover` returns 30 files, you get 30 review nodes, bounded to 4 concurrent. The graph's shape isn't known until runtime, but it's still a graph the runtime tracks, renders, and resumes. + +### 3. Routing is declarative + +```json +{ "id": "skip-if-empty", "when": "{steps.discover.json.length} > 0", "agent": "...", "task": "..." } +``` + +`when` guards skip a phase entirely. `join: "any"` lets a phase proceed as soon as *any* of its dependencies finish (OR-join) instead of all (the default AND-join). Conditional routing without imperative `if` statements. + +## What a declarative task graph buys you + +Because the graph is **data, not code**, four things become possible that an imperative script can't offer: + +### Verifiable before it runs + +The runtime can statically analyze the graph: detect cycles, dead-end phases (nothing downstream consumes them), gate-exhaustion (can every path hit a `BLOCK`?), budget feasibility (will the declared phases exceed the spend cap?), and dangling references (`{steps.typo.output}`). All **before a single subagent spawns**. An imperative script is Turing-complete; you can only find these problems by reading it like a program or running it. + +### Observable as the graph itself + +When a task graph runs, the **live progress view *is* the DAG** — every node, its status (pending / running / done / failed / blocked), its timing, its cost. The graph is a first-class object the runtime owns, so it can render it. A script's progress is whatever `console.log`s the author happened to write. + +### Resumable, phase by phase + +Each task node's result is persisted, keyed on its **resolved input hash**. Resume picks up exactly where it stopped. Re-run the whole graph after changing one node's prompt, and only that node and its downstream re-execute — everything else is served from cache. An imperative script's resume is coarser (call-cache dedup at best). + +### Safe to generate with an LLM + +A task graph is JSON. Letting a model author one means parsing JSON and **validating the structure** — there is no code path from "model emitted a string" to "something executes." An imperative script is executable code; letting a model author one is `eval`-adjacent. For plan-then-execute and iterative replanning, declarative graphs are the safe surface. + +## When a task graph is overkill + +A task graph is the wrong tool when: + +- The job is **one or two steps** with no branching — a single subagent call is simpler. +- You need **arbitrary mid-flight logic** beyond what `map`/`when`/`loop`/`gate`/`script` cover, and a code-mode workflow's expressivity is worth the verifiability trade. +- It's a **one-off** you'll never re-run — the overhead of declaring a graph doesn't pay back. + +## The short version + +A **task graph** is a DAG of discrete, declared task nodes connected by dependency edges. Making it **declarative data** (not imperative code) is what unlocks verification, observation, resume, and safe LLM authoring. That's the bet taskflow makes. + +Want to see one in action? The [templates gallery](/en/docs/templates) has five complete graphs, and [What is taskflow?](/en/docs/what-is-taskflow) walks through the motivation end to end. diff --git a/website/content/docs/en/comparisons/index.mdx b/website/content/docs/en/comparisons/index.mdx new file mode 100644 index 0000000..1e3dcf4 --- /dev/null +++ b/website/content/docs/en/comparisons/index.mdx @@ -0,0 +1,31 @@ +--- +title: Comparisons +description: How taskflow compares to imperative agent workflows, built-in subagents, LangGraph, and other orchestration tools. +--- + +Every orchestration tool makes a trade-off between **expressivity** and **verifiability**. These pages explain where taskflow sits on that axis, honestly — including what you give up. + + + + A declarative DAG vs a dynamic, code-mode script the model writes and runs. The core thesis. + + + When the agent's native `task` / `tasks` / `chain` shorthand is enough, and when you outgrow it. + + + A coding-agent task graph vs a general-purpose LLM graph framework. Different shape, different goal. + + + +## The one-axis mental model + +The closest competitors to taskflow differ on a single question: **is the plan code, or is it data?** + +- **Imperative / code-mode workflows** put the plan in executable JavaScript. Maximum expressivity; the graph only exists as the code runs. +- **taskflow** puts the plan in declarative JSON data. Bounded expressivity; the graph is a first-class object the runtime can check, render, resume, and safely let a model author. + +Neither is universally better. If your job is "do twelve arbitrary things with branching," a script wins. If your job is "run a review pipeline a hundred times and trust it," a verified graph wins. + + + New to the declarative-graph idea? Start with [What is taskflow?](/en/docs/what-is-taskflow) and the [DAG concept page](/en/docs/concepts/dag). + diff --git a/website/content/docs/en/comparisons/meta.json b/website/content/docs/en/comparisons/meta.json new file mode 100644 index 0000000..1bbd983 --- /dev/null +++ b/website/content/docs/en/comparisons/meta.json @@ -0,0 +1,9 @@ +{ + "title": "Comparisons", + "pages": [ + "index", + "taskflow-vs-workflow", + "taskflow-vs-subagents", + "taskflow-vs-langgraph" + ] +} diff --git a/website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx b/website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx new file mode 100644 index 0000000..b66d33e --- /dev/null +++ b/website/content/docs/en/comparisons/taskflow-vs-langgraph.mdx @@ -0,0 +1,93 @@ +--- +title: taskflow vs LangGraph +description: How a coding-agent task graph (taskflow) differs from LangGraph — scope, host coupling, persistence model, verification, and when each fits. +--- + +[LangGraph](https://github.com/langchain-ai/langgraph) is a popular, general-purpose framework for building **stateful, multi-actor LLM applications** as graphs. taskflow is a narrower thing: a **declarative task-DAG orchestrator built specifically for coding-agent subagents.** They overlap in the "graph of LLM calls" idea but differ sharply in scope, host coupling, and what they optimize for. + +This page is an honest comparison — including where LangGraph is clearly the better tool. + +## At a glance + +| | **LangGraph** | **taskflow** | +|---|---|---| +| **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) | +| **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) | + +## Where they genuinely overlap + +Both model multi-step LLM work as a **graph** rather than a linear chain. Both support: + +- Cycles / loops (LangGraph natively; taskflow via the `loop` phase) +- Branching and conditional edges (LangGraph conditional edges; taskflow `when` + `join: any`) +- Human-in-the-loop pauses (LangGraph interrupts; taskflow `approval` phases) +- Checkpointing / resume (LangGraph checkpointers; taskflow file-backed run store + cache) +- Fan-out (LangGraph `Send` / map-reduce; taskflow `map` phases) + +If you've used LangGraph's mental model, taskflow's phases + `dependsOn` will feel familiar. + +## Where they differ — and why it matters + +### 1. Code you write vs data you declare + +LangGraph graphs are **Python or JS code** — you define `StateGraph`, add nodes as functions, wire edges, compile. That gives you full power (a node can do anything a function can) at the cost of authoring a program. + +taskflow flows are **JSON data**. You declare `phases[]` with `dependsOn` edges; the runtime maps each phase to a subagent call. You can't put arbitrary logic in a phase — only a task prompt (plus `script` phases for shell, `flow {def}` for runtime-generated sub-graphs). The trade is the same one as [taskflow vs imperative workflows](./taskflow-vs-workflow): bounded expressivity for verifiability and LLM-safe authoring. + +### 2. Verification happens before tokens, not at runtime + +LangGraph validates the graph **when you compile/run it** — a malformed graph throws. taskflow runs **static pre-flight analysis** that checks things a runtime throw can't cheaply catch: budget feasibility (will this flow exceed the USD/token ceiling given the declared phases?), gate-exhaustion (can every reachable path hit a `BLOCK`?), dead-end phases, and dangling interpolation references. `verify` is a zero-token operation you run before committing to a flow. + +### 3. The unit of work is a subagent, not a function call + +A LangGraph node is a function you write — you choose the model, build the prompt, parse the result, handle tools. taskflow's unit of work is a **subagent invocation**: you name an agent (`security-reviewer`, `executor-code`) and give it a task; the runtime + host handle model routing, tool scoping, and the transcript. You're orchestrating **agents**, not call sites. + +This makes taskflow much more concise for the "fan out N scoped agents over N items" pattern (one `map` phase), but much less flexible when a node needs bespoke non-agent logic. + +### 4. Host-native vs bring-your-own-runtime + +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. + +### 5. Persistence shape + +LangGraph's checkpointers snapshot the **graph state** at each step — great for resuming a long-running agent with typed state. + +taskflow persists **per-phase results** keyed on each phase's resolved input hash, in a file-backed run store. Resume is phase-by-phase and **cross-run** (not just cross-session): editing one phase and re-running re-executes only that phase + downstream, serving everything else from cache. See [incremental recompute](/en/docs/reference/incremental-recompute). + +## When to pick LangGraph + + + **Pick LangGraph when** you're building a **custom LLM application** (a chatbot, a RAG pipeline, an agent product), you want fine-grained control over state and nodes as functions, you're running it in your own codebase, and you need the broader LangChain tool ecosystem. + + +LangGraph is the better choice for **product-grade agent applications** — anything you'd ship *as* a service, where you own the runtime and want typed state machines. + +## 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. + + +taskflow is the better choice for **developer workflows** — pipelines you run on your codebase, by name, from inside the agent you already use. + +## The short version + +- **Building an agent *product*?** → LangGraph. +- **Orchestrating your coding agent's *subagents* into reusable pipelines?** → taskflow. + +They're not really competitors; they optimize for different things on the same graph idea. + +## Further reading + +- [What is taskflow?](/en/docs/what-is-taskflow) +- [taskflow vs imperative workflows](./taskflow-vs-workflow) — the code-vs-data trade in depth +- [The DAG concept](/en/docs/concepts/dag) diff --git a/website/content/docs/en/comparisons/taskflow-vs-subagents.mdx b/website/content/docs/en/comparisons/taskflow-vs-subagents.mdx new file mode 100644 index 0000000..966e51c --- /dev/null +++ b/website/content/docs/en/comparisons/taskflow-vs-subagents.mdx @@ -0,0 +1,145 @@ +--- +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. + +The honest question is: **when is the built-in shorthand enough, and when do you need taskflow?** + +## When the built-in shorthand is the right call + +Use the native subagent tool directly when: + +- It's **one or two tasks** with no branching — "go look at X and tell me Y." +- You'll **never re-run it** — it's a one-shot for the current conversation. +- You **want the intermediate results in your context** — you're going to reason about them inline. +- There's **no quality gate** — you trust the output, or you'll eyeball it yourself. + +For these cases, `task` is perfect. Spinning up a taskflow definition would be overhead. + +## When you outgrow the shorthand + +You've hit the wall when **any** of these become true: + +| Need | Built-in subagent | **taskflow** | +|---|---|---| +| **Branching fan-out** over dozens of items | flat `tasks` only | **`map` — one subagent per item, bounded concurrency** | +| **Intermediate transcripts** | flood your context window | **stay in the runtime — only the final phase returns** | +| **Reusable by name** | re-described every time | **saved as `/tf:` (Pi) or run by name (other hosts)** | +| **Resumable** | ✗ — start over on any failure | **✓ phase-by-phase, cross-session, cached phases skip** | +| **Quality gate** that can halt the flow | ✗ | **`gate` phases — `VERDICT: BLOCK` stops everything** | +| **Conditional routing** | ✗ | **`when` guards + `join: any` OR-joins** | +| **Retry / fault tolerance** | ✗ | **per-phase `retry` + auto-retry on transient errors** | +| **Human approval** mid-flow | ✗ | **`approval` phases (approve / reject / edit)** | +| **Spend ceiling** | ✗ | **run-wide `budget` (USD / token caps)** | +| **Competitive selection** | ✗ | **`tournament` — N variants + a judge** | +| **Live graph view** | opaque while running | **live DAG render with timing + cost** | + +## The context-isolation difference, concretely + +This is the single most felt difference. Here's the same 12-step review job both ways: + +**Built-in subagents** — every transcript comes back to you: + +``` +You: "Review these 12 files for security issues." +Agent: [12 subagent calls → 12 full transcripts in your context] + ↓ + your context window: +47,000 tokens of transcripts +``` + +**taskflow** — only the merged report returns: + +```json title="security-review.json" +{ + "name": "security-review", + "phases": [ + { "id": "discover", "agent": "scout", "task": "List files changed since origin/main" }, + { "id": "review", "map": "{steps.discover.json.files}", "agent": "security-reviewer", + "task": "Audit {item} for vulnerabilities", "concurrency": 4 }, + { "id": "report", "agent": "executor", "dependsOn": ["review"], + "task": "Merge the per-file reviews into one report" } + ] +} +``` + +``` +You: /tf run security-review + ↓ (12 transcripts stay in the runtime) + your context window: +1 final report +``` + +For a one-off, that's nice. For a pipeline you run on every PR, it's the difference between a usable context and a flooded one. + +## The "save it and forget it" workflow + +The other big shift is **reuse**. Describe a pipeline once, save it: + +```bash +# Pi — save as a one-word command +/tf save code-audit + +# now /tf:code-audit runs the whole verified graph by name +``` + +```bash +# Codex / Claude Code / OpenCode — run a saved flow by name +taskflow_run code-audit +``` + +The built-in shorthand has no equivalent: you re-describe the plan every single run, and the model re-derives it from scratch. + +## The migration path (zero rewrite) + +You don't have to choose up front. taskflow's shorthand **is** the built-in shorthand, upgraded: + + + + +```js +// the agent's native delegation +chain([ + { agent: "scout", task: "Map the repo" }, + { agent: "executor-code", task: "Implement the plan" }, + { agent: "reviewer", task: "Review the diff" } +]) +``` + + + + +```js +// identical surface — but now tracked, resumable, saveable +// (Pi: wrapped in /tf; other hosts: taskflow_run) +``` + + + + +```json +{ + "name": "implement-and-review", + "phases": [ + { "id": "map", "agent": "scout", "task": "Map the repo" }, + { "id": "build", "agent": "executor-code", "dependsOn": ["map"], + "task": "Implement the plan" }, + { "id": "review", "agent": "reviewer", "dependsOn": ["build"], + "task": "Review the diff" }, + { "id": "gate", "type": "gate", "dependsOn": ["review"], + "agent": "plan-arbiter", + "task": "VERDICT: PASS or BLOCK based on review" } + ] +} +``` + + + + +Start with the shorthand. When you need a gate, a budget, a resume, or a fan-out over 30 items — graduate to the DSL. Your existing delegations carry over verbatim. + +## Further reading + +- [Shorthand reference](/en/docs/reference/shorthand) — `task` / `tasks` / `chain` syntax +- [Phase types](/en/docs/syntax/phase-types) — the full DSL vocabulary +- [Context isolation](/en/docs/concepts/context-isolation) — why transcripts stay out of your window diff --git a/website/content/docs/en/comparisons/taskflow-vs-workflow.mdx b/website/content/docs/en/comparisons/taskflow-vs-workflow.mdx new file mode 100644 index 0000000..1978db8 --- /dev/null +++ b/website/content/docs/en/comparisons/taskflow-vs-workflow.mdx @@ -0,0 +1,78 @@ +--- +title: taskflow vs imperative agent workflows +description: A declarative DAG (taskflow) versus a dynamic, code-mode workflow script the model writes and runs — verifiability, resume, and what you trade away. +--- + +A **`workflow`** — the dynamic, code-mode kind that several agent runtimes now ship — is the model writing an **imperative JavaScript script** that *flows*: `await agent(...)`, an `if`, a `for`, another `await`. A **`taskflow`** is the opposite: you **declare** the work as a graph of discrete task nodes connected by `dependsOn` edges, and the runtime owns execution. + +Both are real, legitimate designs. They sit at opposite ends of one axis: **expressivity vs verifiability.** + +## The core difference + +| | dynamic `workflow` (code-mode) | **`taskflow`** (declarative graph) | +|---|---|---| +| **The plan is** | imperative JS the model writes & runs | declarative JSON data the runtime executes | +| **The graph** | implicit — hidden in `if`/`for`/`await` | explicit — `phases[]` + `dependsOn`, a first-class object | +| **Verify before running** | ✗ Turing-complete; can't prove it terminates | **✓ static checks: cycles, dead-ends, budget overflow, dangling refs** | +| **See it** | ✗ the graph only exists as the code runs | **✓ the live progress render *is* the DAG** | +| **Resume** | coarse (call-cache dedup) | **✓ phase-by-phase, input-hash keyed, cross-session** | +| **Safe to LLM-generate** | risky — it's executable code | **✓ it's data — no `eval`; a runtime-generated sub-flow is structurally validated before it runs** | +| **Expressivity ceiling** | **higher** — arbitrary control flow | bounded by the DSL (`map`/`when`/`loop`/`gate`/`tournament` + `flow {def}`) | + +## What a workflow gives you that taskflow doesn't + +Be honest about this: a code-mode workflow is **Turing-complete**. Anything you can express in JavaScript, you can express in a workflow. That includes: + +- Arbitrary mid-flight computation and branching on partial results +- Calling non-LLM libraries mid-orchestration (databases, HTTP, file I/O) +- Dynamic re-planning written as ordinary code + +taskflow's DSL is **bounded**. If a job needs logic that doesn't map onto `map`/`when`/`loop`/`gate`/`tournament`, you can't express it directly. The escape hatches are: + +- **`script` phases** — run a shell command (zero tokens) for deterministic computation +- **`flow {def}` phases** — let a model author a sub-flow at runtime, which is then *structurally validated* before it runs (cycles / dangling refs / duplicate ids checked) + +But the ceiling is real. We trade raw expressivity on purpose. + +## What taskflow gives you that a workflow can't + +Because the plan is **data, not code**, the runtime can do things an imperative script structurally cannot: + +### 1. Verify before a single token is spent + +```bash +# taskflow: check the graph for free +/tf verify my-pipeline + +# workflow: ...you run it and find out +``` + +A workflow is executable code. The only way to know it terminates, stays under budget, and has no broken references is to **read it like a program** — or run it and pay the cost. taskflow's `verify` runs static analysis on the graph (cycle detection, dead-end detection, gate-exhaustion, budget feasibility, dangling `dependsOn` / interpolation refs) before any subagent spawns. + +### 2. The live progress *is* the DAG + +When a workflow runs, your window into it is whatever `console.log` statements the model happened to write. When a taskflow runs, the runtime renders the **actual graph** — every phase, its status, timing, and cost — because the graph is a first-class object it owns, not a side effect of execution. + +### 3. Phase-by-phase resume, cross-session + +A workflow's resume is coarse: dedup of identical calls via a cache. taskflow resumes **per phase, keyed on the phase's resolved input hash**, across sessions. Re-running a flow after editing one phase re-executes only that phase and its downstream; everything upstream is served from cache. See [incremental recompute](/en/docs/reference/incremental-recompute). + +### 4. Safe to generate with an LLM + +A workflow is code. Letting a model author one means running model-generated JavaScript — `eval`-adjacent. A taskflow is **data**. Letting a model author one (`flow {def}`) means the runtime parses JSON and **validates the structure** before a single phase runs. There is no code path from "model emitted a string" to "something executes." + +## When to pick which + + + **Pick a dynamic workflow when** the job is genuinely arbitrary control flow, needs mid-flight computation beyond what `script`/`flow` phases cover, or is a one-off you'll never re-run. + + + + **Pick taskflow when** the job is a repeatable pipeline (review, audit, migration, research, release) you want to run by name, verify before paying, watch as a graph, and resume across sessions. + + +## Further reading + +- [Why "taskflow" and not "workflow"?](/en/docs/what-is-taskflow) — the naming thesis +- [Declarative graph vs imperative script](/en/docs/showcase/why-taskflow) — deeper design rationale +- [Verification](/en/docs/concepts/verification) — what the static checks actually catch diff --git a/website/content/docs/en/meta.json b/website/content/docs/en/meta.json index e2274b7..3c642a9 100644 --- a/website/content/docs/en/meta.json +++ b/website/content/docs/en/meta.json @@ -35,10 +35,20 @@ "---Showcase---", "showcase/why-taskflow", "templates", + "---Comparisons---", + "comparisons/index", + "comparisons/taskflow-vs-workflow", + "comparisons/taskflow-vs-subagents", + "comparisons/taskflow-vs-langgraph", "---Reference---", "reference/shorthand", "reference/commands", "reference/incremental-recompute", - "community" + "community", + "---Blog---", + "blog/index", + "blog/orchestrate-codex-subagents", + "blog/claude-code-mcp-workflow", + "blog/what-is-a-task-graph" ] } 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 new file mode 100644 index 0000000..bdc219f --- /dev/null +++ b/website/content/docs/zh-cn/blog/claude-code-mcp-workflow.mdx @@ -0,0 +1,112 @@ +--- +title: "Claude Code + MCP:会自我门禁的 workflow" +description: 在 Claude Code 里跑一个 taskflow MCP server,获得带质量门禁、预算上限和上下文隔离的声明式任务图——不用离开你的 Claude 会话。 +--- + +Claude Code 本身是个强大的编程 agent。但当工作需要一条**可重复的流水线**——审查每个改动文件、按风险 gate、控制在花费上限内——临时提示词会遇到每个 agent 都有的限制:模型每次重新推导计划,中间逐字稿填满你的上下文,而且如果审查者发现严重问题,没法把整个流程叫停。本文讲的是如何给 Claude Code 加一个 **taskflow MCP server**,在你的会话里跑一条会自我门禁的审查流水线。 + +## 给 Claude Code 加 taskflow MCP server + +taskflow 以 Claude Code 插件形式发布,会注册一个暴露 `taskflow_*` 工具的 MCP server: + +```bash +claude plugin marketplace add heggria/taskflow +claude plugin install claude-taskflow@taskflow +``` + +就这样——插件接好 MCP server(通过 `npx claude-taskflow-mcp`)并放进 taskflow skill。重启 Claude Code,你就会有 `taskflow_run`、`taskflow_verify`、`taskflow_resume` 等工具可用。 + +## 任务:带门禁的安全审查 + +你要一条每个 PR 都跑的流水线:发现改动文件、逐文件扇出安全审查、并行跑一个架构审查,并在发现任何严重问题时**叫停**——最后返回一份合并报告。 + +把它声明成一张图: + +```json title="pr-security-review.json" +{ + "name": "pr-security-review", + "description": "审查 PR 的安全和架构问题,按风险 gate,返回一份报告。", + "budget": { "maxUSD": 1.50 }, + "phases": [ + { + "id": "discover", + "agent": "scout", + "task": "以 JSON 路径数组形式列出 origin/main 之后改动的文件。" + }, + { + "id": "sec-review", + "map": "{steps.discover.json}", + "agent": "security-reviewer", + "task": "审计 {item} 的漏洞:注入、鉴权、密钥、不安全反序列化。返回风险等级和一行发现。", + "concurrency": 4 + }, + { + "id": "arch-review", + "agent": "reviewer", + "dependsOn": ["discover"], + "task": "从整体 diff 审查架构问题:耦合、分层、缺失抽象。" + }, + { + "id": "gate", + "type": "gate", + "dependsOn": ["sec-review", "arch-review"], + "agent": "plan-arbiter", + "task": "给定逐文件安全发现和架构审查,若存在严重/高危问题则给 VERDICT: BLOCK,否则 VERDICT: PASS。" + }, + { + "id": "report", + "agent": "executor", + "dependsOn": ["gate"], + "task": "把安全和架构审查合并成一份带判定和待办的 PR 报告。" + } + ] +} +``` + +## 为什么 `gate` 阶段重要 + +`gate` 阶段是临时提示词结构上给不了你的东西。它的**唯一职责**就是决定流程是否继续: + +- 如果 `plan-arbiter` 给 `VERDICT: PASS` → 流程继续到 `report`。 +- 如果给 `VERDICT: BLOCK` → 流程**立即停止**。`report` 永不运行。你拿到的是阻塞发现,而不是一份把问题埋掉的漂亮报告。 + +这是一个有牙齿的质量门禁。fail-open 规则(输出模糊时 → PASS)意味着一个犯迷糊的 gate 不会误停好工作——但一个明确的 `BLOCK` 是终局的。 + +## 从 Claude Code 运行 + +```text +# 先免费校验这张图 +taskflow_verify pr-security-review + +# 运行 +taskflow_run pr-security-review +``` + +Claude Code 看到的是:一个**单次流式工具调用**返回最终报告。它看不到的是:几十份逐文件 security-reviewer 逐字稿。那些留在 taskflow runtime 里——你的上下文窗口拿到的是合并报告,不是原始噪声。 + +## 预算上限是一等字段 + +```json +"budget": { "maxUSD": 1.50 } +``` + +如果对意外大的 diff 扇出会超过 $1.50,运行时会以 budget-exceeded 结果叫停。账单没有惊喜。配合 `concurrency: 4`,扇出在预算生效之前就是有界的。 + +## 存起来复用 + +描述一次流水线: + +```text +taskflow_save pr-security-review pr-security-review.json +``` + +现在每次 PR 审查都是 `taskflow_run pr-security-review`——不用重新提示,不用重新推导计划。而且因为阶段按输入哈希缓存,改了一行提示词后重跑只会重新执行改动阶段及其下游。 + +## 要点 + +- taskflow **MCP server** 把声明式任务图作为工具*带进* Claude Code。 +- `gate` 阶段给你一个真能遇 BLOCK 叫停的质量门禁。 +- `budget` 封顶花费;`map` 按有界并发扇出。 +- 只有最终报告返回你的会话——逐字稿保持隔离。 + +完整的 Claude Code 接口(权限、模型路由、所有可用工具)见 [Claude Code 指南](/zh-cn/docs/guides/claude-code)。[模板库](/zh-cn/docs/templates) 还有四个可运行流水线。 diff --git a/website/content/docs/zh-cn/blog/index.mdx b/website/content/docs/zh-cn/blog/index.mdx new file mode 100644 index 0000000..3151b5c --- /dev/null +++ b/website/content/docs/zh-cn/blog/index.mdx @@ -0,0 +1,20 @@ +--- +title: 博客 +description: 关于编排编程 agent subagent(Codex、Claude Code、OpenCode、Pi)的实用指南与深度文章——用声明式任务图。 +--- + +taskflow 博客讲的是编排编程 agent subagent 的实用模式:如何扇出审查、构建可复用流水线、控制花费,以及让中间逐字稿不进你的上下文窗口。 + + + + 把临时的 Codex 委派变成有名字、可校验、可续跑的流水线。 + + + 在 Claude Code 里跑一个 taskflow MCP server,带质量门禁和预算上限。 + + + taskflow 背后的概念——为什么离散任务组成的图胜过流动的脚本。 + + + +多数文章假设你读过 [什么是 taskflow?](/zh-cn/docs/what-is-taskflow) 和 [入门指南](/zh-cn/docs/getting-started)。[模板库](/zh-cn/docs/templates) 里有五个开箱即用的流程,和这些文章搭配阅读。 diff --git a/website/content/docs/zh-cn/blog/meta.json b/website/content/docs/zh-cn/blog/meta.json new file mode 100644 index 0000000..71f57b9 --- /dev/null +++ b/website/content/docs/zh-cn/blog/meta.json @@ -0,0 +1,9 @@ +{ + "title": "博客", + "pages": [ + "index", + "orchestrate-codex-subagents", + "claude-code-mcp-workflow", + "what-is-a-task-graph" + ] +} diff --git a/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx b/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx new file mode 100644 index 0000000..fcbc69a --- /dev/null +++ b/website/content/docs/zh-cn/blog/orchestrate-codex-subagents.mdx @@ -0,0 +1,140 @@ +--- +title: 用任务图编排 Codex subagent +description: 如何用 taskflow 把临时的 OpenAI Codex subagent 委派变成有名字、静态可校验、可跨会话续跑的流水线——含扇出审查、gate 和预算上限。 +--- + +OpenAI Codex 能 spawn subagent 做聚焦的工作——但一旦超过两三个调用,你就会撞上一堵熟悉的墙:模型每次都重新推导计划,中间逐字稿淹没你的会话,一次失败的调用意味着从头来过,而且你在付钱之前根本没法*检查*这个计划。本文讲的是怎么用 **taskflow** 把 Codex subagent 工作包起来——一张运行前先校验、跨会话可续跑、中间结果不进你上下文的声明式图。 + +## 那堵墙 + +你要发布版本,想做个就绪检查。Codex 自然的玩法是*脚本化*: + +```text title="临时的 Codex 会话" +你:"检查这个分支是否可以发布了。" +Codex:[spawn 一个 subagent 列出改动文件] + [每个文件 spawn 一个 subagent 审计破坏性变更] + [spawn 一个 subagent 检查 changelog] + [试图总结……] +``` + +跑一次没问题。到第四次,问题开始累积: + +- **无法复用**——每次发布你都重新描述整个计划。 +- **无法校验**——计划坏不坏是*一边付钱一边*发现的。 +- **无法续跑**——一个 subagent 调用撞上限流,你就从头来。 +- **没有预算护栏**——对 50 个文件扇出,花多少就是多少。 + +## taskflow 版本 + +装上 Codex 插件: + +```bash +codex plugin marketplace add heggria/taskflow +codex plugin add taskflow@taskflow +``` + +然后把同样的工作声明成一张图: + +```json title="release-readiness.json" +{ + "name": "release-readiness", + "description": "验证分支是否可发布:发现改动、扇出破坏性变更审查、检查 changelog、按风险 gate。", + "args": { + "base": { "default": "origin/main", "description": "要对比的 git ref。" } + }, + "budget": { "maxUSD": 2.00 }, + "phases": [ + { + "id": "discover", + "agent": "scout", + "task": "以 JSON 路径数组形式,列出 {args.base} 与 HEAD 之间改动的所有文件。" + }, + { + "id": "review", + "map": "{steps.discover.json}", + "agent": "risk-reviewer", + "task": "审计 {item} 的破坏性变更。返回一行风险判定。", + "concurrency": 4 + }, + { + "id": "changelog", + "agent": "executor", + "task": "CHANGELOG.md 是否覆盖了 {args.base} 之后的改动?给是/否并附证据。" + }, + { + "id": "gate", + "type": "gate", + "dependsOn": ["review", "changelog"], + "agent": "plan-arbiter", + "task": "基于逐文件审查和 changelog 检查,给出 VERDICT: PASS 或 VERDICT: BLOCK 及理由。" + }, + { + "id": "report", + "agent": "executor", + "dependsOn": ["gate"], + "task": "把发布就绪判定汇总成一份简短报告。" + } + ] +} +``` + +## 免费先校验,再运行 + +花一个 token 之前,先检查这张图: + +```bash +# 在你的 Codex 会话里通过 taskflow 工具 +taskflow_verify release-readiness +``` + +`verify` 跑静态分析——不调模型。它能抓到环、死端阶段、`map` 产不出数组、阶段数让 `budget` 不可行、以及悬空的 `{steps.*}` 引用。这些你都在*付钱之前*修掉。 + +然后运行: + +```text +taskflow_run release-readiness --base origin/main +``` + +运行时会: + +1. 解析图并 spawn `discover` 阶段。 +2. 每个发现的文件扇出一个 `risk-reviewer`,**并发上限 4**。 +3. `changelog` 与审查并行跑。 +4. 命中 `gate`——如果它给 `VERDICT: BLOCK`,流程立即停止,`report` 永不运行。 +5. **只把最终报告**返回给你的会话。 + +那 50 份逐文件逐字稿永远不会进你的 Codex 上下文。只有合并后的报告会。 + +## 临时脚本给不了你的东西 + +| 关注点 | 临时 Codex | taskflow | +|---|---|---| +| **复用** | 每次重新描述 | 按名字跑 `release-readiness` | +| **成本控制** | 没有 | `budget: { maxUSD: 2.00 }` 叫停运行 | +| **失败** | 从头来 | 逐阶段 `retry` + 跨会话续跑 | +| **质量门禁** | 自己过目 | `gate` 阶段遇 `BLOCK` 叫停 | +| **上下文** | 每份逐字稿都返回 | 只有最终报告返回 | + +## 跨会话续跑 + +跑起来,走开,明天回来: + +```text +taskflow_resume +``` + +已完成的阶段从缓存复用(按解析后的输入键入)。如果你夜里改了 `changelog` 的提示词,只有 `changelog`、`gate`、`report` 会重跑——`discover` 和所有审查都复用。详见[增量重算](/zh-cn/docs/reference/incremental-recompute)。 + +## 什么时候保持临时 + +一次性的"去看看 X"别上 taskflow。内置 subagent 简写就对了。taskflow 适合**可重复的流水线**——你每次发布都要跑的那种——恰恰是每次重新描述最痛的场景。 + +## 要点 + +- taskflow 是**数据**而不是代码——所以你能在运行前校验。 +- `map` 按有界并发对每个 item 扇出一个 subagent。 +- `gate` 阶段给你一个真能叫停的质量门禁。 +- `budget` 封顶花费;续跑免费且跨会话。 +- 只有最终阶段进入你的 Codex 上下文。 + +去 [模板库](/zh-cn/docs/templates) 拿另外五个开箱即用的流程,或读 [Codex 指南](/zh-cn/docs/guides/codex) 看完整的 host 专属接口。 diff --git a/website/content/docs/zh-cn/blog/what-is-a-task-graph.mdx b/website/content/docs/zh-cn/blog/what-is-a-task-graph.mdx new file mode 100644 index 0000000..e614e42 --- /dev/null +++ b/website/content/docs/zh-cn/blog/what-is-a-task-graph.mdx @@ -0,0 +1,90 @@ +--- +title: 什么是任务图?(面向 LLM 编排) +description: taskflow 背后的概念——为什么把 LLM 工作建模成离散、声明的任务节点组成的图(DAG),比一条流动的脚本更可验证、可观察、可续跑。 +--- + +"任务图"是 taskflow 背后的核心概念,但这个词用得很泛。本页要在 LLM 编排的语境下钉死"任务图"到底是什么、它和"workflow"有什么不同、以及把图做成**声明式数据**而不是命令式代码能获得什么。 + +## 任务 vs 工作 + +在工程里,一个**任务(task)**是一个*离散、声明的工作单元*——任务图的节点,和构建系统、调度器、编译器接进 DAG 的那种 `task` 一样。**工作(work)**则相反,是*流动且无界的*——持续、命令式的"做"这个动作。 + +这个区分就是整条设计分界线: + +- 一个 **workflow**(动态、代码态那种)是模型写出一个**命令式脚本**让它"流"起来:`await agent(...)`、一个 `if`、一个 `for`、又一个 `await`。表达能力最强——图灵完备——但图只在*代码运行时*才存在。你没法看见它、给它做 diff、或在付钱之前证明它会终止。 +- 一个 **taskflow** 把计划**从代码搬进离散任务节点组成的声明式图里。** 因为图是*数据*,运行时能做命令式脚本结构上做不到的事。 + +## 是什么让它成为"图"(而不是"链") + +大多数 agent 的"编排"其实是一条**链**——第 1 步、第 2 步、第 3 步,线性的。任务图在三个方向上把它一般化: + +### 1. 依赖是边,不是顺序 + +```json +{ + "phases": [ + { "id": "discover", "agent": "scout", "task": "..." }, + { "id": "review-a", "agent": "reviewer", "dependsOn": ["discover"], "task": "..." }, + { "id": "review-b", "agent": "risk-reviewer", "dependsOn": ["discover"], "task": "..." }, + { "id": "merge", "agent": "executor", "dependsOn": ["review-a", "review-b"], "task": "..." } + ] +} +``` + +`review-a` 和 `review-b` 都只依赖 `discover`——所以运行时把它们**并行**跑。`merge` 等两者都完成。你没写"并发执行这些";你声明的是*边*,运行时推导出调度。这是白送的分层并发。 + +### 2. 扇出是一等形状 + +```json +{ + "id": "review", + "map": "{steps.discover.json}", + "agent": "security-reviewer", + "task": "审计 {item} 的漏洞。", + "concurrency": 4 +} +``` + +`map` 为数组里**每个 item 创建一个任务节点**——在运行时、根据 `discover` 的产出动态创建。如果 `discover` 返回 30 个文件,你就得到 30 个审查节点,并发上限 4。图的形状要到运行时才知道,但它仍然是运行时追踪、渲染、续跑的图。 + +### 3. 路由是声明式的 + +```json +{ "id": "skip-if-empty", "when": "{steps.discover.json.length} > 0", "agent": "...", "task": "..." } +``` + +`when` 守卫让一个阶段整个跳过。`join: "any"` 让一个阶段在依赖里*任意*一个完成时就继续(或汇合),而不是全部(默认的与汇合)。没有命令式 `if` 语句的条件路由。 + +## 声明式任务图给你什么 + +因为图是**数据而不是代码**,命令式脚本给不了的四件事变成可能: + +### 运行前可校验 + +运行时能对图做静态分析:检测环、死端阶段(下游没人消费它们)、gate 穷尽(每条路径是否都能撞 `BLOCK`?)、预算可行性(声明的阶段会不会超花费上限?)、悬空引用(`{steps.打错字.output}`)。全在**任何 subagent 启动之前**。命令式脚本图灵完备;这些问题你只能像读程序一样读它,或者跑一遍才能发现。 + +### 可观察,就是图本身 + +任务图跑起来时,**实时进度视图本身就是这张 DAG**——每个节点、状态(等待/运行/完成/失败/阻塞)、耗时、成本。图是运行时拥有的一等对象,所以它能渲染。脚本的进度只是作者碰巧写的那些 `console.log`。 + +### 可续跑,逐阶段 + +每个任务节点的结果都被持久化,按它**解析后的输入哈希**键入。续跑从停下的地方精确恢复。改了一个节点的提示词后重跑整张图,只有那个节点及其下游重新执行——其余全部从缓存复用。命令式脚本的续跑更粗(充其量调用缓存去重)。 + +### 能安全地让 LLM 生成 + +任务图是 JSON。让模型编写一个,意味着解析 JSON 并**校验结构**——从"模型吐出一个字符串"到"有东西被执行"之间没有代码路径。命令式脚本是可执行代码;让模型编写一个是接近 `eval` 的。对于"先规划再执行"和迭代重规划,声明式图是安全的表面。 + +## 什么时候任务图是大材小用 + +任务图在以下情况是错工具: + +- 工作就是**一两步**、没分支——单个 subagent 调用更简单。 +- 你需要 `map`/`when`/`loop`/`gate`/`script` 覆盖不了的**任意中途逻辑**,而代码态 workflow 的表达能力值得这个可验证性取舍。 +- 它是**一次性的**、你不会再跑——声明一张图的额外开销收不回来。 + +## 一句话版本 + +**任务图**是由离散、声明的任务节点、通过依赖边连接而成的 DAG。把它做成**声明式数据**(而不是命令式代码)正是解锁校验、观察、续跑和 LLM 安全编写的东西。这就是 taskflow 押注的方向。 + +想看一个实战?[模板库](/zh-cn/docs/templates) 有五张完整的图,[什么是 taskflow?](/zh-cn/docs/what-is-taskflow) 端到端地走了一遍动机。 diff --git a/website/content/docs/zh-cn/comparisons/index.mdx b/website/content/docs/zh-cn/comparisons/index.mdx new file mode 100644 index 0000000..6853248 --- /dev/null +++ b/website/content/docs/zh-cn/comparisons/index.mdx @@ -0,0 +1,31 @@ +--- +title: 横向对比 +description: taskflow 与命令式 workflow、内置 subagent、LangGraph 等编排工具的对比。 +--- + +每个编排工具都在**表达能力(expressivity)**与**可验证性(verifiability)**之间做取舍。本节页面诚实地解释 taskflow 处于这条轴线的哪个位置——包括你放弃了什么。 + + + + 声明式 DAG 与模型写出来并执行的动态、代码态脚本。这是 taskflow 的核心论点。 + + + agent 原生的 `task`/`tasks`/`chain` 简写什么时候够用,什么时候你会超越它。 + + + 面向编程 agent 的任务图 vs 通用 LLM 图框架。形状不同,目标也不同。 + + + +## 单轴心智模型 + +与 taskflow 最接近的竞品,区别都集中在一个问题上:**计划是代码,还是数据?** + +- **命令式 / 代码态 workflow** 把计划放进可执行的 JavaScript 里。表达能力最强;图只在代码运行时才存在。 +- **taskflow** 把计划放进声明式 JSON 数据里。表达能力受限;图是一个一等对象,运行时可以校验、渲染、续跑,并安全地让模型来编写。 + +没有哪个在所有场景下都更好。如果你的工作是"随便做十二件带分支的事",脚本赢。如果你的工作是"把一个 review 流程跑一百遍并信任它",可验证的图赢。 + + + 刚接触声明式图的概念?建议先看 [什么是 taskflow?](/zh-cn/docs/what-is-taskflow) 和 [DAG 概念页](/zh-cn/docs/concepts/dag)。 + diff --git a/website/content/docs/zh-cn/comparisons/meta.json b/website/content/docs/zh-cn/comparisons/meta.json new file mode 100644 index 0000000..f867b13 --- /dev/null +++ b/website/content/docs/zh-cn/comparisons/meta.json @@ -0,0 +1,9 @@ +{ + "title": "横向对比", + "pages": [ + "index", + "taskflow-vs-workflow", + "taskflow-vs-subagents", + "taskflow-vs-langgraph" + ] +} diff --git a/website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx b/website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx new file mode 100644 index 0000000..e8f7541 --- /dev/null +++ b/website/content/docs/zh-cn/comparisons/taskflow-vs-langgraph.mdx @@ -0,0 +1,93 @@ +--- +title: taskflow vs LangGraph +description: 面向编程 agent 的任务图(taskflow)与 LangGraph 的差异——范围、host 耦合、持久化模型、可验证性,以及各自适合什么。 +--- + +[LangGraph](https://github.com/langchain-ai/langgraph) 是一个流行的、通用的框架,用于把**有状态、多角色 LLM 应用**构建成图。taskflow 是个更窄的东西:一个**专为编程 agent subagent 构建的声明式任务 DAG 编排器**。两者在"LLM 调用组成的图"这个想法上重叠,但在范围、host 耦合和优化目标上差异很大。 + +本页是一次诚实的对比——包括 LangGraph 明显是更好工具的地方。 + +## 一览 + +| | **LangGraph** | **taskflow** | +|---|---|---| +| **是什么** | 一个用于有状态 LLM 应用图的库(Python/JS) | 面向编程 agent subagent 的声明式任务 DAG runtime | +| **你写的是** | Python 或 JS 图代码 | JSON 数据(DSL) | +| **跑在** | 你自己的应用进程里 | 你的编程 agent 里(Pi、Codex、Claude Code、OpenCode) | +| **节点是** | 任意函数(LLM 调用、工具、逻辑) | **subagent 调用**(有作用域、按模型路由的 agent 调用) | +| **状态** | 一个有类型的共享状态对象,沿边 reduce | 共享上下文树(黑板 + 监督) | +| **持久化** | checkpointer(SQLite/Postgres/内存) | 文件式运行存储 + 跨运行缓存 | +| **人在环中** | 基于 state 的中断 + 恢复 | `approval` 阶段(批准/拒绝/编辑) | +| **校验** | 运行时图校验 | **静态预检:环、死端、预算、悬空引用——在任何 token 之前** | +| **host 耦合** | 框架无关(你自己带模型 + 工具) | **agent-host 原生**(作为 Pi 扩展 / Codex/Claude/OpenCode 插件安装) | + +## 真正重叠的地方 + +两者都把多步 LLM 工作建模成**图**而不是线性链。两者都支持: + +- 环 / 循环(LangGraph 原生;taskflow 通过 `loop` 阶段) +- 分支与条件边(LangGraph 条件边;taskflow `when` + `join: any`) +- 人在环中暂停(LangGraph 中断;taskflow `approval` 阶段) +- 检查点 / 续跑(LangGraph checkpointer;taskflow 文件式运行存储 + 缓存) +- 扇出(LangGraph `Send` / map-reduce;taskflow `map` 阶段) + +如果你用过 LangGraph 的心智模型,taskflow 的阶段 + `dependsOn` 会让你觉得很熟。 + +## 差在哪里——以及为什么重要 + +### 1. 你写的是代码 vs 声明的是数据 + +LangGraph 的图是 **Python 或 JS 代码**——你定义 `StateGraph`,把节点加成函数,连边,编译。这给你全部能力(节点能做函数能做的任何事),代价是你要写一个程序。 + +taskflow 的流程是 **JSON 数据**。你声明 `phases[]` 和 `dependsOn` 边;运行时把每个阶段映射到一次 subagent 调用。你没法在阶段里放任意逻辑——只能放任务提示词(加上 `script` 阶段跑 shell,`flow {def}` 跑运行时生成的子图)。这和 [taskflow vs 命令式 workflow](./taskflow-vs-workflow) 是同一个取舍:用受限的表达能力换可验证性和 LLM 安全编写。 + +### 2. 校验发生在 token 之前,而不是运行时 + +LangGraph 在你**编译/运行图时**校验——坏图会抛异常。taskflow 跑**静态预检分析**,检查的是运行时抛异常不容易廉价抓到的东西:预算可行性(给定声明的阶段,这次流程会不会超美元/token 上限?)、gate 穷尽(每条可达路径是否都能撞上 `BLOCK`?)、死端阶段、悬空的插值引用。`verify` 是一个零 token 的操作,你在 commit 到一个流程之前先跑。 + +### 3. 工作单元是 subagent,不是函数调用 + +LangGraph 节点是你写的函数——你选模型、写提示词、解析结果、处理工具。taskflow 的工作单元是一次 **subagent 调用**:你点名一个 agent(`security-reviewer`、`executor-code`)并给它任务;运行时 + host 负责模型路由、工具作用域和逐字稿。你编排的是 **agent**,而不是调用点。 + +这让 taskflow 在"对 N 个 item 扇出 N 个有作用域的 agent"这种模式上简洁得多(一个 `map` 阶段),但在节点需要定制非 agent 逻辑时没那么灵活。 + +### 4. host 原生 vs 自带 runtime + +LangGraph **框架无关**——你把它嵌入自己的应用,自带模型客户端和工具。它是一个库。 + +taskflow 是 **agent-host 原生**:它*安装进*一个已有的编程 agent(Pi 扩展、Codex/Claude Code/OpenCode 插件),编排*那个 host 的* subagent。你不是在写一个新应用——你在声明你的 agent 要跑的流程。如果你不在这些编程 agent 里,taskflow 不是你要的工具;LangGraph 可能是。 + +### 5. 持久化的形状 + +LangGraph 的 checkpointer 在每一步快照**图状态**——很适合用有类型 state 恢复一个长时间运行的 agent。 + +taskflow 持久化**逐阶段结果**,按每个阶段解析后的输入哈希键入一个文件式运行存储。续跑是逐阶段的,而且**跨运行**(不只是跨会话):编辑某个阶段后重跑,只有那个阶段 + 下游重新执行,其余全部从缓存复用。详见[增量重算](/zh-cn/docs/reference/incremental-recompute)。 + +## 什么时候选 LangGraph + + + **选 LangGraph**:你在构建一个**自定义 LLM 应用**(聊天机器人、RAG 流水线、agent 产品),你想对 state 和"节点即函数"有细粒度控制,你在自己的代码库里跑,你需要更广的 LangChain 工具生态。 + + +LangGraph 更适合**产品级 agent 应用**——任何你会*作为*服务发布的东西,你拥有 runtime 并想要有类型的 state 机。 + +## 什么时候选 taskflow + + + **选 taskflow**:你在**编程 agent 内部**(Pi / Codex / Claude Code / OpenCode)工作,想把一个可重复的多步委派(review、审计、迁移、调研、发布)变成一条**有名字、可校验、可续跑、中间逐字稿不进你上下文**的流水线。 + + +taskflow 更适合**开发者工作流**——你在自己用的 agent 里、按名字、对自己的代码库跑的流水线。 + +## 一句话版本 + +- **在构建 agent *产品*?** → LangGraph。 +- **在把编程 agent 的 *subagent* 编排成可复用流水线?** → taskflow。 + +它们其实不算竞品;它们在同一个"图"的想法上优化不同的东西。 + +## 延伸阅读 + +- [什么是 taskflow?](/zh-cn/docs/what-is-taskflow) +- [taskflow vs 命令式 workflow](./taskflow-vs-workflow)——代码 vs 数据的取舍深入 +- [DAG 概念](/zh-cn/docs/concepts/dag) diff --git a/website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx b/website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx new file mode 100644 index 0000000..64e6847 --- /dev/null +++ b/website/content/docs/zh-cn/comparisons/taskflow-vs-subagents.mdx @@ -0,0 +1,145 @@ +--- +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。 + +诚实的问题是:**什么时候内置简写够用,什么时候你需要 taskflow?** + +## 什么时候内置简写是对的选择 + +直接用原生 subagent 工具的场景: + +- 就是**一两个任务**、没有分支——"去看看 X,告诉我 Y"。 +- 你**再也不会重跑**它——当前对话里的一次性需求。 +- 你**就想要中间结果留在上下文里**——你要对着它们推理。 +- **没有质量门禁**——你信任输出,或者你会自己过一眼。 + +这些场景 `task` 完美。为它起一个 taskflow 定义反而是负担。 + +## 什么时候你超越了简写 + +当下面**任意一条**成真时,你就撞墙了: + +| 需求 | 内置 subagent | **taskflow** | +|---|---|---| +| 对几十个 item 做**分支扇出** | 只有扁平的 `tasks` | **`map`——每 item 一个 subagent,并发有界** | +| **中间逐字稿** | 涌入你的上下文窗口 | **留在 runtime 里——只有最终阶段返回** | +| **按名字复用** | 每次重新描述 | **存成 `/tf:<名字>`(Pi)或按名字运行(其它 host)** | +| **可续跑** | ✗——任何失败都从头来 | **✓ 逐阶段、跨会话、缓存阶段自动跳过** | +| 能叫停整个流程的**质量门禁** | ✗ | **`gate` 阶段——`VERDICT: BLOCK` 全停** | +| **条件路由** | ✗ | **`when` 守卫 + `join: any` 或汇合** | +| **重试 / 容错** | ✗ | **逐阶段 `retry` + 瞬态错误自动重试** | +| 流程中途的**人工审批** | ✗ | **`approval` 阶段(批准 / 拒绝 / 编辑)** | +| **花费上限** | ✗ | **整次运行的 `budget`(美元 / token 上限)** | +| **竞争式择优** | ✗ | **`tournament`——N 个变体 + 裁判** | +| **实时图视图** | 运行时不透明 | **带耗时与成本的实时 DAG 渲染** | + +## 上下文隔离的差异,具体看 + +这是最直观能感受到的差异。同一个 12 步的 review 任务两种做法: + +**内置 subagent**——每份逐字稿都回到你这里: + +``` +你:"审查这 12 个文件的安全问题。" +Agent:[12 次 subagent 调用 → 12 份完整逐字稿进入你的上下文] + ↓ + 你的上下文窗口:+47,000 token 逐字稿 +``` + +**taskflow**——只有合并后的报告返回: + +```json title="security-review.json" +{ + "name": "security-review", + "phases": [ + { "id": "discover", "agent": "scout", "task": "列出 origin/main 之后改动的文件" }, + { "id": "review", "map": "{steps.discover.json.files}", "agent": "security-reviewer", + "task": "审计 {item} 的漏洞", "concurrency": 4 }, + { "id": "report", "agent": "executor", "dependsOn": ["review"], + "task": "把逐文件审查合并成一份报告" } + ] +} +``` + +``` +你:/tf run security-review + ↓(12 份逐字稿留在 runtime 里) + 你的上下文窗口:+1 份最终报告 +``` + +对一次性任务,这很舒服。对每个 PR 都要跑的流水线,这就是"上下文还能用"和"上下文被淹没"的差别。 + +## "存起来就忘掉它"的工作流 + +另一大转变是**复用**。描述一次流水线,存下来: + +```bash +# Pi——存成一个单词命令 +/tf save code-audit + +# 现在 /tf:code-audit 按名字运行整张校验过的图 +``` + +```bash +# Codex / Claude Code / OpenCode——按名字运行保存的流程 +taskflow_run code-audit +``` + +内置简写没有等价物:你每次都重新描述计划,模型每次从头推导。 + +## 迁移路径(零重写) + +你不必一开始就选。taskflow 的简写**就是**内置简写的升级版: + + + + +```js +// agent 的原生委派 +chain([ + { agent: "scout", task: "摸清仓库" }, + { agent: "executor-code", task: "实现计划" }, + { agent: "reviewer", task: "审查 diff" } +]) +``` + + + + +```js +// 完全相同的接口——但现在可追踪、可续跑、可保存 +//(Pi:包在 /tf 里;其它 host:taskflow_run) +``` + + + + +```json +{ + "name": "implement-and-review", + "phases": [ + { "id": "map", "agent": "scout", "task": "摸清仓库" }, + { "id": "build", "agent": "executor-code", "dependsOn": ["map"], + "task": "实现计划" }, + { "id": "review", "agent": "reviewer", "dependsOn": ["build"], + "task": "审查 diff" }, + { "id": "gate", "type": "gate", "dependsOn": ["review"], + "agent": "plan-arbiter", + "task": "VERDICT: PASS 或 BLOCK(基于审查)" } + ] +} +``` + + + + +从简写开始。当你需要 gate、预算、续跑、或对 30 个 item 扇出时——升级到 DSL。你已有的委派可以原样搬过来。 + +## 延伸阅读 + +- [简写参考](/zh-cn/docs/reference/shorthand)——`task`/`tasks`/`chain` 语法 +- [阶段类型](/zh-cn/docs/syntax/phase-types)——完整 DSL 词汇表 +- [上下文隔离](/zh-cn/docs/concepts/context-isolation)——逐字稿为何不进你的窗口 diff --git a/website/content/docs/zh-cn/comparisons/taskflow-vs-workflow.mdx b/website/content/docs/zh-cn/comparisons/taskflow-vs-workflow.mdx new file mode 100644 index 0000000..54ea8e4 --- /dev/null +++ b/website/content/docs/zh-cn/comparisons/taskflow-vs-workflow.mdx @@ -0,0 +1,78 @@ +--- +title: taskflow vs 命令式 agent workflow +description: 声明式 DAG(taskflow)与模型编写执行的动态、代码态 workflow 脚本之间的对比——可验证性、续跑、以及你为此付出的代价。 +--- + +**`workflow`**——也就是现在不少 agent runtime 提供的动态、代码态那一种——是模型写出一个**命令式 JavaScript 脚本**,让它"流"起来:`await agent(...)`、一个 `if`、一个 `for`、又一个 `await`。**`taskflow`** 正好相反:你把工作**声明**为由 `dependsOn` 边连接的离散任务节点图,运行时负责执行。 + +两者都是真实、合理的设计。它们处在同一条轴线的两端:**表达能力 vs 可验证性。** + +## 核心差异 + +| | 动态 `workflow`(代码态) | **`taskflow`**(声明式图) | +|---|---|---| +| **计划是** | 模型编写并执行的命令式 JS | 运行时执行的声明式 JSON 数据 | +| **图** | 隐式的——藏在 `if`/`for`/`await` 里 | 显式的——`phases[]` + `dependsOn`,一等对象 | +| **运行前校验** | ✗ 图灵完备;无法证明它会终止 | **✓ 静态检查:环、死端、预算溢出、悬空引用** | +| **可视化** | ✗ 图只在代码运行时才存在 | **✓ 实时进度渲染本身就是这张 DAG** | +| **续跑** | 粗粒度(调用缓存去重) | **✓ 逐阶段、按输入哈希、跨会话** | +| **能否安全地让 LLM 生成** | 有风险——它是可执行代码 | **✓ 它是数据——没有 `eval`;运行时生成的子流在执行前会被结构化校验** | +| **表达能力上限** | **更高**——任意控制流 | 受 DSL 约束(`map`/`when`/`loop`/`gate`/`tournament` + `flow {def}`) | + +## workflow 能给你、而 taskflow 给不了的 + +这一点要诚实:代码态 workflow 是**图灵完备**的。JavaScript 能表达的,workflow 都能表达,包括: + +- 运行中途对部分结果做任意计算和分支 +- 在编排过程中调用非 LLM 库(数据库、HTTP、文件 I/O) +- 用普通代码写动态重规划 + +taskflow 的 DSL 是**受限**的。如果某项工作无法映射到 `map`/`when`/`loop`/`gate`/`tournament`,你没法直接表达。逃生舱是: + +- **`script` 阶段**——运行一条 shell 命令(零 token)做确定性计算 +- **`flow {def}` 阶段**——让模型在运行时编写子流,随后在执行前被*结构化校验*(检查环 / 悬空引用 / 重复 id) + +但这个上限是真实存在的。我们是故意用表达能力换可验证性的。 + +## taskflow 能给你、而 workflow 给不了的 + +因为计划是**数据而不是代码**,运行时能做命令式脚本结构上做不到的事: + +### 1. 花一个 token 之前先校验 + +```bash +# taskflow:免费检查这张图 +/tf verify my-pipeline + +# workflow:……你跑一遍才知道 +``` + +workflow 是可执行代码。想知道它会不会终止、会不会超预算、有没有坏引用,唯一的办法是**像读程序一样读它**——或者跑一遍并付出代价。taskflow 的 `verify` 在任何 subagent 启动前对图跑静态分析(环检测、死端检测、gate 穷尽、预算可行性、悬空的 `dependsOn` / 插值引用)。 + +### 2. 实时进度本身就是这张 DAG + +workflow 跑起来时,你对它的观察只有模型碰巧写的那些 `console.log`。taskflow 跑起来时,运行时渲染**真实的图**——每个阶段、状态、耗时、成本——因为图是它拥有的一等对象,而不是执行的副产品。 + +### 3. 逐阶段续跑,跨会话 + +workflow 的续跑是粗粒度的:靠缓存去重相同调用。taskflow 按**阶段解析后的输入哈希**续跑,跨会话。编辑某个阶段后重跑整个流程,只会重新执行该阶段及其下游;上游全部从缓存复用。详见[增量重算](/zh-cn/docs/reference/incremental-recompute)。 + +### 4. 能安全地让 LLM 生成 + +workflow 是代码。让模型编写一个 workflow,意味着运行模型生成的 JavaScript——接近 `eval`。taskflow 是**数据**。让模型编写一个 taskflow(`flow {def}`),意味着运行时解析 JSON 并在**任何阶段执行前校验其结构**。从"模型吐出一个字符串"到"有东西被执行"之间没有任何代码路径。 + +## 各自适合什么场景 + + + **选动态 workflow**:工作确实是任意控制流、需要 `script`/`flow` 阶段覆盖不了的中途计算、或是一次性的、再也不会重跑的任务。 + + + + **选 taskflow**:工作是可重复的流水线(review、审计、迁移、调研、发布),你想按名字运行、运行前先校验、以图的形式观察、并跨会话续跑。 + + +## 延伸阅读 + +- [为什么叫 "taskflow" 而不是 "workflow"?](/zh-cn/docs/what-is-taskflow)——命名论点 +- [声明式图 vs 命令式脚本](/zh-cn/docs/showcase/why-taskflow)——更深入的设计理念 +- [可验证性](/zh-cn/docs/concepts/verification)——静态检查到底能抓到什么 diff --git a/website/content/docs/zh-cn/meta.json b/website/content/docs/zh-cn/meta.json index 82fbf43..b5024b7 100644 --- a/website/content/docs/zh-cn/meta.json +++ b/website/content/docs/zh-cn/meta.json @@ -35,10 +35,20 @@ "---案例展示---", "showcase/why-taskflow", "templates", + "---横向对比---", + "comparisons/index", + "comparisons/taskflow-vs-workflow", + "comparisons/taskflow-vs-subagents", + "comparisons/taskflow-vs-langgraph", "---参考---", "reference/shorthand", "reference/commands", "reference/incremental-recompute", - "community" + "community", + "---博客---", + "blog/index", + "blog/orchestrate-codex-subagents", + "blog/claude-code-mcp-workflow", + "blog/what-is-a-task-graph" ] } From 9f2548fbe141fd3dc7e7145797c05f4fecf507a6 Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 8 Jul 2026 14:59:13 +0800 Subject: [PATCH 10/16] fix(tournament,score): extend marker tolerance to WINNER/SCORE (#54) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Markdown-emphasis blind spot from #54 was not unique to VERDICT — the same bare-token regex pattern silently lost genuine signals in two more decision parsers: - parseTournamentWinner: `WINNER: **3**` missed the emphasis → silently reverted to variant 1, losing the judge's actual pick. - parseJudgeOutput SCORE marker: `SCORE: **0.8**` missed → score dropped. Refactor: a shared `markerRe(label, value)` factory in scorers.ts now emits all three emphasis-tolerant regexes (VERDICT_TOKEN_RE, SCORE_TOKEN_RE, WINNER_TOKEN_RE), tolerating `*`/`_`/`/`~` runs on either side of the captured value. parseTournamentWinner and the SCORE parse adopt it; VERDICT is rebuilt from the same factory for consistency (single source of truth). Fail stances are deliberate and now consistent with each marker's semantics: gate verdict / judge output → fail-closed (BLOCK); tournament winner → fail-open (variant 1, never lose work — the variants are already computed, so blocking would be worse than picking a safe default). Also promotes structured output as the documented default for ALL decision phases (gate verdict, tournament winner, router branch, judge score): prefer `output:"json"` + `expect` which machine-validates the decision and leaves no formatting the model can get subtly wrong. Free-text markers remain as a tolerant back-compat path. Skill sources + README + CHANGELOG updated and regenerated for all four hosts. Tests: 1149 pass. Added Markdown-emphasis regression cases for WINNER and SCORE markers; runtime-verified the factory-built regexes (clamp + emphasis + fail-open all correct). Refs #54. --- CHANGELOG.md | 24 +++++++----- README.md | 4 +- .../plugin/skills/taskflow/SKILL.md | 22 +++++++++-- .../plugin/skills/taskflow/patterns.md | 5 ++- .../plugin/skills/taskflow/SKILL.md | 22 +++++++++-- .../plugin/skills/taskflow/patterns.md | 5 ++- .../plugin/skills/taskflow/SKILL.md | 22 +++++++++-- .../plugin/skills/taskflow/patterns.md | 5 ++- packages/pi-taskflow/skills/taskflow/SKILL.md | 22 +++++++++-- .../pi-taskflow/skills/taskflow/patterns.md | 5 ++- packages/taskflow-core/src/runtime.ts | 4 +- packages/taskflow-core/src/scorers.ts | 37 ++++++++++++++----- .../taskflow-core/test/gate-score.test.ts | 4 +- .../taskflow-core/test/tournament.test.ts | 12 ++++++ skills-src/taskflow/core.md | 22 +++++++++-- skills-src/taskflow/patterns.md | 5 ++- 16 files changed, 171 insertions(+), 49 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c07f375..a00bcd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,20 +39,24 @@ All notable changes to taskflow are documented here. This project follows [Keep unwritable agent dir only means the hint may show once more; it never blocks session startup. - **Gate verdict parsing hardened — a genuine BLOCK is no longer silently - downgraded to PASS (issue #54).** Models routinely wrap verdict tokens in - Markdown emphasis (`VERDICT: **BLOCK**`, `### VERDICT: __BLOCK__`, - `VERDICT: `BLOCK``), which the bare-token regex - `/VERDICT\s*[:=]\s*(PASS|BLOCK|…)/` missed — the match fell through to the - fail-open default and the gate recorded `pass`, letting a blocked review - silently continue downstream. Three layered fixes: (1) a shared - `VERDICT_TOKEN_RE` now tolerates `*`/`_`/`` ` ``/`~` emphasis runs on either - side of the verdict word (in both `parseGateVerdict` and `parseJudgeOutput`); + downgraded to PASS (issue #54).** Models routinely wrap decision tokens in + Markdown emphasis (`VERDICT: **BLOCK**`, `### WINNER: __3__`, `SCORE: `0.8``), + which the bare-token regexes (`/VERDICT\s*[:=]\s*(…)/`, `/WINNER…(\d+)/`, + `/SCORE…([01]…)/`) missed — the match fell through to the default verdict, + so a genuine BLOCK was silently recorded as `pass` and a judge's actual pick + silently reverted to variant 1. Three layered fixes: (1) a shared `markerRe()` + factory in `scorers.ts` now emits emphasis-tolerant regexes for **all three** + decision markers — `VERDICT_TOKEN_RE`, `SCORE_TOKEN_RE`, `WINNER_TOKEN_RE` — + tolerating `*`/`_`/`` ` ``/`~` runs on either side of the captured value (used + by `parseGateVerdict`, `parseJudgeOutput`, and `parseTournamentWinner`); (2) **gate *model output* that cannot be parsed now fails closed (BLOCK)** instead of PASS — a gate that cannot reach a verdict cannot be trusted to pass, while *config* slips (unresolved `score.target`, malformed `scorers`) remain fail-open with a warning (they are authoring errors that degrade, not a - judge that couldn't decide); (3) a free-text gate whose task omits a - `VERDICT:` instruction now gets the exact format suffix **auto-appended**. + judge that couldn't decide); tournament winner stays fail-open (variant 1 — + never lose work, since the variants are already computed); (3) a free-text + gate whose task omits a `VERDICT:` instruction now gets the exact format + suffix **auto-appended**. For maximum robustness, prefer `output: "json"` + `expect` enum (`{ verdict: { enum: ["pass","block"] } }`) which machine-validates the verdict. Regression tests added for every Markdown variant and the fail-closed default. diff --git a/README.md b/README.md index 77999e4..b00ddb9 100644 --- a/README.md +++ b/README.md @@ -550,7 +550,7 @@ For open-ended work, the best result often comes from generating several candida ``` - **Competitors** — either `variants: N` copies of one `task` (diversity comes from model nondeterminism), or distinct `branches: [{task, agent?}, …]` when you want to pit *different approaches* against each other. -- **Judge** — after the fan-out, one judge agent sees every variant (numbered) plus your `judge` rubric and picks a winner via a `WINNER: ` line or `{"winner": n}`. An unreadable verdict **fails open** to variant 1; a failed judge falls back too — the work is never lost. +- **Judge** — after the fan-out, one judge agent sees every variant (numbered) plus your `judge` rubric and picks a winner. Prefer a JSON pick `{"winner": n}` (most robust); the runtime also reads a `WINNER: ` line (`#n` and Markdown emphasis like `WINNER: **n**` are tolerated — issue #54). An unreadable verdict **fails open** to variant 1; a failed judge falls back too — the work is never lost. - **`mode`** — `best` returns the winning variant **verbatim**; `aggregate` returns the judge's **synthesized** answer combining the strongest parts. - **Short-circuits:** if only one competitor survives, it wins with no judge call; if all fail, the phase fails. The TUI shows `⚑ N→#k`; usage sums variants + judge. Like `gate`, it's **excluded from `cross-run` cache**. @@ -895,7 +895,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)**: the verdict parser now tolerates Markdown-emphasized tokens (`VERDICT: **BLOCK**`, `### VERDICT: __BLOCK__`, `VERDICT: `BLOCK``) so a genuine BLOCK is never silently downgraded; **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 gates, use `output: "json"` + `expect` enum to machine-validate the verdict. **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 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. Known boundaries (tracked, bounded — no surprises mid-flow): diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index ea6a32a..5f41723 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -421,13 +421,20 @@ of several drafts, or a synthesis of diverse approaches. - `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). -- Fail-open: if the judge's pick is unparseable, variant 1 is returned (work is never lost). +- **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. End with: WINNER: .", + "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 } @@ -503,7 +510,16 @@ Interpolation also runs on a scoring gate's `score.target` and `score.judge.task 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. `verify` before `run` for anything non-trivial (zero tokens). +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) diff --git a/packages/claude-taskflow/plugin/skills/taskflow/patterns.md b/packages/claude-taskflow/plugin/skills/taskflow/patterns.md index c2519f5..51b0b47 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/patterns.md @@ -226,7 +226,7 @@ different *approaches* judged against each other. { "id": "strategy", "type": "tournament", "mode": "best", "judgeAgent": "final-arbiter", - "judge": "Judge on: correctness under concurrent access, blast radius, migration cost. Quote evidence. End with WINNER: .", + "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" }, @@ -237,7 +237,8 @@ different *approaches* judged against each other. ``` Give the judge a **rubric with named criteria**, a stronger model -(`judgeAgent`), and an exact terminator (`WINNER: `). `mode: "aggregate"` +(`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. --- diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index bac2df6..edc8a4a 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -420,13 +420,20 @@ of several drafts, or a synthesis of diverse approaches. - `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). -- Fail-open: if the judge's pick is unparseable, variant 1 is returned (work is never lost). +- **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. End with: WINNER: .", + "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 } @@ -502,7 +509,16 @@ Interpolation also runs on a scoring gate's `score.target` and `score.judge.task 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. `verify` before `run` for anything non-trivial (zero tokens). +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) diff --git a/packages/codex-taskflow/plugin/skills/taskflow/patterns.md b/packages/codex-taskflow/plugin/skills/taskflow/patterns.md index c2519f5..51b0b47 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/patterns.md @@ -226,7 +226,7 @@ different *approaches* judged against each other. { "id": "strategy", "type": "tournament", "mode": "best", "judgeAgent": "final-arbiter", - "judge": "Judge on: correctness under concurrent access, blast radius, migration cost. Quote evidence. End with WINNER: .", + "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" }, @@ -237,7 +237,8 @@ different *approaches* judged against each other. ``` Give the judge a **rubric with named criteria**, a stronger model -(`judgeAgent`), and an exact terminator (`WINNER: `). `mode: "aggregate"` +(`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. --- diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index 9330478..3e3cd09 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -421,13 +421,20 @@ of several drafts, or a synthesis of diverse approaches. - `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). -- Fail-open: if the judge's pick is unparseable, variant 1 is returned (work is never lost). +- **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. End with: WINNER: .", + "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 } @@ -503,7 +510,16 @@ Interpolation also runs on a scoring gate's `score.target` and `score.judge.task 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. `verify` before `run` for anything non-trivial (zero tokens). +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) diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md b/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md index c2519f5..51b0b47 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/patterns.md @@ -226,7 +226,7 @@ different *approaches* judged against each other. { "id": "strategy", "type": "tournament", "mode": "best", "judgeAgent": "final-arbiter", - "judge": "Judge on: correctness under concurrent access, blast radius, migration cost. Quote evidence. End with WINNER: .", + "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" }, @@ -237,7 +237,8 @@ different *approaches* judged against each other. ``` Give the judge a **rubric with named criteria**, a stronger model -(`judgeAgent`), and an exact terminator (`WINNER: `). `mode: "aggregate"` +(`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. --- diff --git a/packages/pi-taskflow/skills/taskflow/SKILL.md b/packages/pi-taskflow/skills/taskflow/SKILL.md index 74c70b4..bf245c3 100644 --- a/packages/pi-taskflow/skills/taskflow/SKILL.md +++ b/packages/pi-taskflow/skills/taskflow/SKILL.md @@ -410,13 +410,20 @@ of several drafts, or a synthesis of diverse approaches. - `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). -- Fail-open: if the judge's pick is unparseable, variant 1 is returned (work is never lost). +- **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. End with: WINNER: .", + "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 } @@ -492,7 +499,16 @@ Interpolation also runs on a scoring gate's `score.target` and `score.judge.task 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. `verify` before `run` for anything non-trivial (zero tokens). +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) diff --git a/packages/pi-taskflow/skills/taskflow/patterns.md b/packages/pi-taskflow/skills/taskflow/patterns.md index fcfa3a1..1891aba 100644 --- a/packages/pi-taskflow/skills/taskflow/patterns.md +++ b/packages/pi-taskflow/skills/taskflow/patterns.md @@ -221,7 +221,7 @@ different *approaches* judged against each other. { "id": "strategy", "type": "tournament", "mode": "best", "judgeAgent": "final-arbiter", - "judge": "Judge on: correctness under concurrent access, blast radius, migration cost. Quote evidence. End with WINNER: .", + "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" }, @@ -232,7 +232,8 @@ different *approaches* judged against each other. ``` Give the judge a **rubric with named criteria**, a stronger model -(`judgeAgent`), and an exact terminator (`WINNER: `). `mode: "aggregate"` +(`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. --- diff --git a/packages/taskflow-core/src/runtime.ts b/packages/taskflow-core/src/runtime.ts index fa21021..78e08f1 100644 --- a/packages/taskflow-core/src/runtime.ts +++ b/packages/taskflow-core/src/runtime.ts @@ -37,7 +37,7 @@ const noRunnerInjected: RunTaskFn = async (_cwd, _agents, agentName, task) => ({ 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, VERDICT_TOKEN_RE } from "./scorers.ts"; +import { combineScores, combineWithJudge, evaluatePureScorer, formatScorerReport, parseJudgeOutput, SCORE_DEFAULT_THRESHOLD, type ScoreConfig, scoreResultJSON, type ScorerResult, scorerShapeErrors, VERDICT_TOKEN_RE, WINNER_TOKEN_RE } from "./scorers.ts"; import { runCodeCompilesScorer } from "./scorer-runtime.ts"; import { buildReflexionSummary, isContractViolation, REFLEXION_SENTINEL, type ReflexionInput } from "./reflexion.ts"; import { hashInput, newRunId, type PhaseState, type RunState, runsDir } from "./store.ts"; @@ -2659,7 +2659,7 @@ export function parseTournamentWinner(output: string, count: number): { winner: 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\s{0,20}[:=]\s{0,20}#?\s{0,20}(\d+)/gi)]; + 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) }; diff --git a/packages/taskflow-core/src/scorers.ts b/packages/taskflow-core/src/scorers.ts index 2c18e00..34bbf78 100644 --- a/packages/taskflow-core/src/scorers.ts +++ b/packages/taskflow-core/src/scorers.ts @@ -337,15 +337,34 @@ export function scoreResultJSON( } /** - * Shared verdict-token matcher. Matches `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` - * (last occurrence wins) AND tolerates common Markdown emphasis around the verdict - * word, because models frequently emit `VERDICT: **BLOCK**`, `VERDICT: __BLOCK__`, - * `VERDICT: `BLOCK``, or `### VERDICT: **BLOCK**` — a bare-token regex silently - * misses these and falls to the fail-closed default (issue #54). The emphasis - * chars (`*`, `_`, `~`, `` ` ``) may appear in runs on either side of the word. + * Build an emphasis-tolerant marker regex. Decision phases parse a token the + * model emits as a trailing marker (`VERDICT: PASS`, `WINNER: 3`, `SCORE: 0.8`). + * Models routinely wrap these tokens in Markdown emphasis (`VERDICT: **BLOCK**`, + * `WINNER: __3__`, `SCORE: `0.8``), and a bare-token regex silently misses them — + * the genuine signal is lost and the phase falls to its default verdict (issue #54). + * This factory injects optional `*`/`_`/`~`/`` ` `` runs on either side of the + * captured value so the common Markdown habits are read correctly. + * + * `label` is escaped; `value` is a RegExp source for the capture group(s). + * Returns a fresh `/gi` regex (safe with `String.matchAll`, which never shares + * `lastIndex` across calls — concurrency-safe in parallel map phases). */ -export const VERDICT_TOKEN_RE = - /VERDICT\s*[:=]\s*(?:[*_~`]+\s*)?(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)(?:\s*[*_~`]+)?/gi; +function markerRe(label: string, value: string): RegExp { + // Emphasis chars (* _ ~ `) as a char-class source. Kept as a plain string + // (not a template literal) so the backtick doesn't terminate the literal below. + const emph = "[*_~`]+"; + const src = label + "\\s*[:=]\\s*(?:" + emph + "\\s*)?" + value + "(?:\\s*" + emph + ")?"; + return new RegExp(src, "gi"); +} + +/** Matches `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins). */ +export const VERDICT_TOKEN_RE = markerRe("VERDICT", "(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)"); + +/** Matches `SCORE: 0.x` (last occurrence wins), emphasis-tolerant. */ +export const SCORE_TOKEN_RE = markerRe("SCORE", "([01](?:\\.\\d+)?)"); + +/** Matches `WINNER: n` (last occurrence wins), emphasis-tolerant; `#n` allowed. */ +export const WINNER_TOKEN_RE = markerRe("WINNER", "#?(\\d+)"); /** * Parse an LLM judge's output into a score + verdict. Accepts JSON @@ -378,7 +397,7 @@ export function parseJudgeOutput(output: string): { return { score: s, verdict: s >= 0.5 ? "pass" : "block", reason, parsed: true }; } } - const scoreMatches = [...output.matchAll(/SCORE\s*[:=]\s*([01](?:\.\d+)?)/gi)]; + const scoreMatches = [...output.matchAll(SCORE_TOKEN_RE)]; const verdictMatches = [...output.matchAll(VERDICT_TOKEN_RE)]; if (scoreMatches.length || verdictMatches.length) { const s = scoreMatches.length ? clamp(Number(scoreMatches[scoreMatches.length - 1][1])) : undefined; diff --git a/packages/taskflow-core/test/gate-score.test.ts b/packages/taskflow-core/test/gate-score.test.ts index 1092310..79ae2ca 100644 --- a/packages/taskflow-core/test/gate-score.test.ts +++ b/packages/taskflow-core/test/gate-score.test.ts @@ -136,9 +136,11 @@ test("parseJudgeOutput: JSON, text markers, fail-closed", () => { assert.equal(parseJudgeOutput('{"score": 0.2}').verdict, "block"); assert.equal(parseJudgeOutput("Analysis...\nVERDICT: BLOCK").verdict, "block"); assert.equal(parseJudgeOutput("SCORE: 0.75").score, 0.75); - // Issue #54: Markdown-emphasized verdict tokens are parsed, not missed. + // Issue #54: Markdown-emphasized verdict AND score tokens are parsed, not missed. assert.equal(parseJudgeOutput("### VERDICT: **BLOCK**").verdict, "block"); assert.equal(parseJudgeOutput("VERDICT: **PASS**").verdict, "pass"); + assert.equal(parseJudgeOutput("SCORE: **0.8**").score, 0.8); + assert.equal(parseJudgeOutput("Quality is high.\nSCORE: __0.9__").score, 0.9); const open = parseJudgeOutput("I am not sure what to say"); assert.equal(open.verdict, "block", "unparseable judge output fails closed"); assert.equal(open.score, 0, "unparseable judge output scores 0"); diff --git a/packages/taskflow-core/test/tournament.test.ts b/packages/taskflow-core/test/tournament.test.ts index c3b156a..b979da0 100644 --- a/packages/taskflow-core/test/tournament.test.ts +++ b/packages/taskflow-core/test/tournament.test.ts @@ -70,6 +70,18 @@ test("parseTournamentWinner: JSON, text, clamp, fail-open", () => { assert.equal(parseTournamentWinner("I cannot decide", 3).winner, 1); // fail-open }); +test("parseTournamentWinner: Markdown-emphasized winner tokens (issue #54)", () => { + // Models wrap the winner marker in emphasis; a bare-token regex silently + // missed it and defaulted to variant 1, losing the judge's actual pick. + assert.equal(parseTournamentWinner("Variant 3 is strongest.\n### WINNER: **3**", 3).winner, 3); + assert.equal(parseTournamentWinner("WINNER: **2**", 3).winner, 2); + assert.equal(parseTournamentWinner("WINNER: __1__", 3).winner, 1); + assert.equal(parseTournamentWinner("WINNER: `3`", 3).winner, 3); + assert.equal(parseTournamentWinner("WINNER: **#2**", 3).winner, 2); // emphasis around the # form + // fail-open still holds when nothing parses + assert.equal(parseTournamentWinner("I pick option three", 3).winner, 1); +}); + // --------------------------------------------------------------------------- // validation // --------------------------------------------------------------------------- diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 6b00f64..a24ba05 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -434,13 +434,20 @@ of several drafts, or a synthesis of diverse approaches. - `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). -- Fail-open: if the judge's pick is unparseable, variant 1 is returned (work is never lost). +- **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. End with: WINNER: .", + "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 } @@ -516,7 +523,16 @@ Interpolation also runs on a scoring gate's `score.target` and `score.judge.task 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. `verify` before `run` for anything non-trivial (zero tokens). +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) diff --git a/skills-src/taskflow/patterns.md b/skills-src/taskflow/patterns.md index 117b366..a421ffd 100644 --- a/skills-src/taskflow/patterns.md +++ b/skills-src/taskflow/patterns.md @@ -226,7 +226,7 @@ different *approaches* judged against each other. { "id": "strategy", "type": "tournament", "mode": "best", "judgeAgent": "final-arbiter", - "judge": "Judge on: correctness under concurrent access, blast radius, migration cost. Quote evidence. End with WINNER: .", + "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" }, @@ -237,7 +237,8 @@ different *approaches* judged against each other. ``` Give the judge a **rubric with named criteria**, a stronger model -(`judgeAgent`), and an exact terminator (`WINNER: `). `mode: "aggregate"` +(`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. --- From 6b16b380762686444afa9f0108991babe950da35 Mon Sep 17 00:00:00 2001 From: heggria Date: Wed, 8 Jul 2026 15:16:34 +0800 Subject: [PATCH 11/16] fix(website): bump postcss to 8.5.16 via pnpm override (CVE-2026-41305) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PostCSS < 8.5.10 did not escape `` when stringifying CSS ASTs — an XSS vector when user-submitted CSS is parsed and re-embedded in HTML ` when stringifying CSS ASTs, an XSS + vector when user-submitted CSS is parsed and re-embedded in HTML `