diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cc2c728c..7cdfd7b5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,14 +10,14 @@ "plugins": [ { "name": "taskflow", - "description": "Multi-phase subagent orchestration for Codex: 16 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (codex-taskflow).", + "description": "Multi-phase subagent orchestration for Codex: 17 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (codex-taskflow).", "source": "./packages/codex-taskflow/plugin", "category": "developer-tools", "homepage": "https://github.com/heggria/taskflow" }, { "name": "claude-taskflow", - "description": "Multi-phase subagent orchestration for Claude Code: 16 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (claude-taskflow).", + "description": "Multi-phase subagent orchestration for Claude Code: 17 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (claude-taskflow).", "source": "./packages/claude-taskflow/plugin", "category": "developer-tools", "homepage": "https://github.com/heggria/taskflow" diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json index a968aeaa..84e4f4c7 100644 --- a/.grok-plugin/marketplace.json +++ b/.grok-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "taskflow", - "description": "Multi-phase subagent orchestration for Grok Build: 16 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (grok-taskflow).", + "description": "Multi-phase subagent orchestration for Grok Build: 17 taskflow_* MCP tools (run/runs/resume/version/list/show/verify/compile/lint/peek/trace/replay/why_stale/recompute/reconcile_workspace/save/search) plus a routing skill. Background runs support status/wait/cancel; compile returns SVG + text outline. The server runs via npx (grok-taskflow).", "source": { "type": "local", "path": "./packages/grok-taskflow/plugin" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cd2202b..4be6d98f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,29 @@ All notable changes to taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. +## [0.2.4] — 2026-07-20 + +### Added + +- **Event kernel concurrent DAG layers.** The event kernel now executes phases within a topological layer concurrently via `Promise.all` with atomic layer-boundary commit. Gate and budget decisions are checked between layers, not within. This removes the single largest blocker to kernel default-ON: previously any flow with independent parallel phases (the common case) was forced to the imperative runtime. +- **Kernel retry support.** `phase.retry = { max, backoffMs, factor }` is now honored on the event kernel path for ALL failures (not just transient errors), matching the imperative contract. Both `runOneAgent` (step.ts) and `runAgentCall` (step-kinds.ts) implement the full retry curve with budget-aware admission. +- **Kernel context pre-read.** `phase.context` files are now read and prepended to the prompt as `` blocks on the event kernel path, matching the imperative runtime's `resolvePhaseContext` behavior. +- **Kernel per-phase literal cwd.** Literal string `phase.cwd` values (absolute or relative to the flow cwd) are now resolved and passed to `runTask` on the event kernel path. Interpolation placeholder cwds (`{args.*}`, `{steps.*}`) and workspace keywords (`temp`/`dedicated`/`worktree`) remain imperative-only. +- **Built-in script-lint verifier.** New `scriptLintVerifier` (exported from `taskflow-core/verifiers`) statically analyzes `script` phase `run` commands for common shell mistakes: `grep` patterns starting with `-` without a `--` separator, unbalanced regex brackets/parens in `grep`/`sed`, and pipelines ending with a filter (`grep`/`awk`/`head`/`tail`/`wc`/`sort`) without `set -o pipefail` or `PIPESTATUS`. Auto-included by `compileTaskflow` (opt out with `lint: false`). Motivated by issue #82. +- **Verifier discovery.** New `discoverVerifiers(cwd)` API loads project-local verifiers from `.pi/taskflows/verifiers/` (project scope, shadows user) and `~/.pi/taskflows/verifiers/` (user scope). Each `.ts`/`.js`/`.mjs` file is dynamically imported; default, `verifier`, and `verifiers` exports are collected. Fail-open: broken modules are skipped with a warning. Convention over configuration — no config file needed. +- **MCP `taskflow_lint` tool.** The 17th MCP tool. Runs built-in and discovered pluggable verifiers on a flow without executing it. Exposes the verifier ecosystem to all MCP hosts (Codex, Claude Code, OpenCode, Grok Build). Provide `name`, `define`, or `defineFile`. +- **Compile subgraph visualization.** Inline flow definitions (`phase.def`) now render as Mermaid `subgraph` blocks showing child phases and their dependency edges. Saved-use flows (`phase.use`) remain plain nodes. The subgraph title shows the child flow's name. +- **S5.0 kernel parity harness.** New `kernel-parity.test.ts`: 11 golden fixture flows run on BOTH engines (imperative + event kernel) with a deterministic mock runner, asserting status, per-phase output, error, and gate-decision parity. Covers: linear chain, concurrent independent agents, gate, reduce, when-guard skip, join-any with optional dep, script, loop, three-layer wide DAG, retry, and gate-in-concurrent-layer. This is the differential gate that must stay green before the kernel can be flipped to default ON (S5.2). + +### Changed + +- **MCP tool count is now 17.** `taskflow_lint` joins the existing 16 tools. Marketplace metadata, host adapter tool-roster tests, and skill discovery assertions are synchronized. +- **Kernel admission expanded.** `canUseEventKernel` now admits flows with: concurrent DAG layers, explicit `retry`, `context` pre-read, and literal per-phase `cwd`. Remaining imperative-only features: `score` gates, `expect` contracts, `reflexion`, `onBlock:retry`, `reduceStrategy:"tree"`, cross-run cache, `shareContext`, per-branch cwd, script stdin input, interpolated script argv, budgeted fan-out, and budgeted loop/tournament. + +### Fixed + +- **Gate parity normalization.** The kernel parity harness normalizes gate objects to strip `reason: undefined` vs absent-key serialization differences between the two engines. + ## [0.2.3] — 2026-07-18 ### Added diff --git a/package.json b/package.json index 73df4696..529876c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow-monorepo", - "version": "0.2.3", + "version": "0.2.4", "private": true, "description": "Monorepo for taskflow-core, taskflow-mcp-core, taskflow-hosts, taskflow-dsl, pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, grok-taskflow, and the documentation website.", "type": "module", diff --git a/packages/claude-taskflow/package.json b/packages/claude-taskflow/package.json index 0d47a430..c79b3341 100644 --- a/packages/claude-taskflow/package.json +++ b/packages/claude-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "claude-taskflow", - "version": "0.2.3", + "version": "0.2.4", "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", diff --git a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md index f03c476d..454bc07b 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/SKILL.md @@ -148,6 +148,7 @@ For a non-trivial flow you'll iterate on, **write the definition to a file** // 2. verify, iterate, run — all reference the SAME file by path: { "name": "taskflow_verify", "arguments": { "defineFile": "/tmp/audit.json" } } // zero tokens { "name": "taskflow_compile", "arguments": { "defineFile": "/tmp/audit.json" } } // diagram +{ "name": "taskflow_lint", "arguments": { "defineFile": "/tmp/audit.json" } } // script-lint + custom verifiers { "name": "taskflow_run", "arguments": { "defineFile": "/tmp/audit.json" } } ``` diff --git a/packages/claude-taskflow/plugin/skills/taskflow/advanced.md b/packages/claude-taskflow/plugin/skills/taskflow/advanced.md index 26cba43d..58440068 100644 --- a/packages/claude-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/claude-taskflow/plugin/skills/taskflow/advanced.md @@ -206,6 +206,56 @@ overrides, ordinary resume re-runs the non-done phases. --- +## Pluggable verifiers — zero-token custom static checks + +Beyond the built-in structural detectors (dead-ends, unreachable, gate-exhaustion, +budget-overflow, concurrency, ref-integrity, guard-contradictions, contracts), +Taskflow supports **pluggable verifiers**: pure functions that lint a flow's +declarations at compile time, before any model is spawned. + +### Built-in: script-lint + +`compileTaskflow` auto-includes the **script-lint** verifier (opt out with +`lint: false`). It catches common shell mistakes in `script` phase `run` +commands: + +- `grep` pattern starting with `-` without a `--` separator (exit 2, false RED) +- Unbalanced `[` or `(` in `grep`/`sed` regex (exit 2) +- Pipeline ending with a filter (`grep`/`awk`/`head`/`tail`/`wc`/`sort`) + without `set -o pipefail` or `PIPESTATUS` (failing upstream masked) + +### Custom verifiers (convention dir) + +Drop a `.ts`/`.js`/`.mjs` file in `.pi/taskflows/verifiers/` (project) or +`~/.pi/taskflows/verifiers/` (user). Export a `TaskflowVerifier`: + +```ts +export default { + name: "my-check", + verify(flow) { + // flow.phases, flow.budget, flow.name are available. + // Return VerifierIssue[]: { phaseId?, message, severity: "error"|"warning" }. + return []; + }, +}; +``` + +Project-scope verifiers shadow user-scope by `name`. Broken modules are +skipped with a warning (fail-open). Use `taskflow_lint` (MCP) or +`verifyTaskflow(flow, { verifiers })` (programmatic) to run them. + +### MCP: `taskflow_lint` + +``` +taskflow_lint { "defineFile": "/tmp/flow.json" } +``` + +Runs built-in + discovered verifiers. Plugin issues are stamped +`category: "plugin"` with `source: `. Structural issues +are covered by `taskflow_verify`; `taskflow_lint` reports only plugin findings. + +--- + ## `taskflow_version` — build/host identity `taskflow_version` reports the engine package version, the git commit the dist diff --git a/packages/claude-taskflow/test/mcp-server.test.ts b/packages/claude-taskflow/test/mcp-server.test.ts index 35c9e80c..aafb503a 100644 --- a/packages/claude-taskflow/test/mcp-server.test.ts +++ b/packages/claude-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("claude mcp: initialize returns the protocol version + serverInfo", async ( assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-claude"); - assert.equal(res.result.serverInfo.version, "0.2.3"); + assert.equal(res.result.serverInfo.version, "0.2.4"); }); test("claude mcp: tools/list exposes the same taskflow tools as codex", async () => { @@ -53,7 +53,7 @@ test("claude mcp: tools/list exposes the same taskflow tools as codex", async () const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("claude mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); }); diff --git a/packages/codex-taskflow/package.json b/packages/codex-taskflow/package.json index 9a874304..22799a30 100644 --- a/packages/codex-taskflow/package.json +++ b/packages/codex-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "codex-taskflow", - "version": "0.2.3", + "version": "0.2.4", "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", diff --git a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md index a882d2f4..96175769 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/SKILL.md @@ -143,6 +143,7 @@ For a non-trivial flow you'll iterate on, **write the definition to a file** // 2. verify, iterate, run — all reference the SAME file by path: { "name": "taskflow_verify", "arguments": { "defineFile": "/tmp/audit.json" } } // zero tokens { "name": "taskflow_compile", "arguments": { "defineFile": "/tmp/audit.json" } } // diagram +{ "name": "taskflow_lint", "arguments": { "defineFile": "/tmp/audit.json" } } // script-lint + custom verifiers { "name": "taskflow_run", "arguments": { "defineFile": "/tmp/audit.json" } } ``` diff --git a/packages/codex-taskflow/plugin/skills/taskflow/advanced.md b/packages/codex-taskflow/plugin/skills/taskflow/advanced.md index 26cba43d..58440068 100644 --- a/packages/codex-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/codex-taskflow/plugin/skills/taskflow/advanced.md @@ -206,6 +206,56 @@ overrides, ordinary resume re-runs the non-done phases. --- +## Pluggable verifiers — zero-token custom static checks + +Beyond the built-in structural detectors (dead-ends, unreachable, gate-exhaustion, +budget-overflow, concurrency, ref-integrity, guard-contradictions, contracts), +Taskflow supports **pluggable verifiers**: pure functions that lint a flow's +declarations at compile time, before any model is spawned. + +### Built-in: script-lint + +`compileTaskflow` auto-includes the **script-lint** verifier (opt out with +`lint: false`). It catches common shell mistakes in `script` phase `run` +commands: + +- `grep` pattern starting with `-` without a `--` separator (exit 2, false RED) +- Unbalanced `[` or `(` in `grep`/`sed` regex (exit 2) +- Pipeline ending with a filter (`grep`/`awk`/`head`/`tail`/`wc`/`sort`) + without `set -o pipefail` or `PIPESTATUS` (failing upstream masked) + +### Custom verifiers (convention dir) + +Drop a `.ts`/`.js`/`.mjs` file in `.pi/taskflows/verifiers/` (project) or +`~/.pi/taskflows/verifiers/` (user). Export a `TaskflowVerifier`: + +```ts +export default { + name: "my-check", + verify(flow) { + // flow.phases, flow.budget, flow.name are available. + // Return VerifierIssue[]: { phaseId?, message, severity: "error"|"warning" }. + return []; + }, +}; +``` + +Project-scope verifiers shadow user-scope by `name`. Broken modules are +skipped with a warning (fail-open). Use `taskflow_lint` (MCP) or +`verifyTaskflow(flow, { verifiers })` (programmatic) to run them. + +### MCP: `taskflow_lint` + +``` +taskflow_lint { "defineFile": "/tmp/flow.json" } +``` + +Runs built-in + discovered verifiers. Plugin issues are stamped +`category: "plugin"` with `source: `. Structural issues +are covered by `taskflow_verify`; `taskflow_lint` reports only plugin findings. + +--- + ## `taskflow_version` — build/host identity `taskflow_version` reports the engine package version, the git commit the dist diff --git a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts index 08e7b811..c830841c 100644 --- a/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts +++ b/packages/codex-taskflow/test/e2e-mcp-comprehensive.mts @@ -79,7 +79,7 @@ send({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: " const init = await waitFor(1, "initialize"); assert.equal(init.result.protocolVersion, "2025-06-18"); assert.equal(init.result.serverInfo.name, "taskflow-codex"); -assert.equal(init.result.serverInfo.version, "0.2.3"); +assert.equal(init.result.serverInfo.version, "0.2.4"); ok(`initialize → ${JSON.stringify(init.result.serverInfo)}`); // notification must NOT produce a response @@ -88,7 +88,7 @@ send({ jsonrpc: "2.0", method: "notifications/initialized" }); send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); const list = await waitFor(2, "tools/list"); const toolNames = list.result.tools.map((t: any) => t.name).sort(); -assert.deepEqual(toolNames, ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"]); +assert.deepEqual(toolNames, ["taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"]); for (const t of list.result.tools) { assert.equal(t.inputSchema.type, "object", `${t.name} has object schema`); assert.equal(typeof t.description, "string"); diff --git a/packages/codex-taskflow/test/mcp-server.test.ts b/packages/codex-taskflow/test/mcp-server.test.ts index 0c46717a..47078286 100644 --- a/packages/codex-taskflow/test/mcp-server.test.ts +++ b/packages/codex-taskflow/test/mcp-server.test.ts @@ -51,7 +51,7 @@ test("mcp: initialize returns the protocol version + serverInfo codex expects", assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-codex"); - assert.equal(res.result.serverInfo.version, "0.2.3"); + assert.equal(res.result.serverInfo.version, "0.2.4"); }); test("mcp: tools/list exposes the taskflow tools with schemas", async () => { @@ -59,7 +59,7 @@ test("mcp: tools/list exposes the taskflow tools with schemas", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -245,7 +245,7 @@ test("mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); }); diff --git a/packages/grok-taskflow/package.json b/packages/grok-taskflow/package.json index 52774ed2..eb94b7d4 100644 --- a/packages/grok-taskflow/package.json +++ b/packages/grok-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "grok-taskflow", - "version": "0.2.3", + "version": "0.2.4", "description": "Run taskflow on Grok Build: a Grok subagent runner plus an MCP server (and a plug-and-play Grok plugin) that exposes the taskflow_* tools to Grok Build users.", "keywords": [ "grok", diff --git a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md index a9cbf772..9bea4a58 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/SKILL.md @@ -153,6 +153,7 @@ For a non-trivial flow you'll iterate on, **write the definition to a file** // 2. verify, iterate, run — all reference the SAME file by path: { "name": "taskflow_verify", "arguments": { "defineFile": "/tmp/audit.json" } } // zero tokens { "name": "taskflow_compile", "arguments": { "defineFile": "/tmp/audit.json" } } // diagram +{ "name": "taskflow_lint", "arguments": { "defineFile": "/tmp/audit.json" } } // script-lint + custom verifiers { "name": "taskflow_run", "arguments": { "defineFile": "/tmp/audit.json" } } ``` diff --git a/packages/grok-taskflow/plugin/skills/taskflow/advanced.md b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md index 26cba43d..58440068 100644 --- a/packages/grok-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/grok-taskflow/plugin/skills/taskflow/advanced.md @@ -206,6 +206,56 @@ overrides, ordinary resume re-runs the non-done phases. --- +## Pluggable verifiers — zero-token custom static checks + +Beyond the built-in structural detectors (dead-ends, unreachable, gate-exhaustion, +budget-overflow, concurrency, ref-integrity, guard-contradictions, contracts), +Taskflow supports **pluggable verifiers**: pure functions that lint a flow's +declarations at compile time, before any model is spawned. + +### Built-in: script-lint + +`compileTaskflow` auto-includes the **script-lint** verifier (opt out with +`lint: false`). It catches common shell mistakes in `script` phase `run` +commands: + +- `grep` pattern starting with `-` without a `--` separator (exit 2, false RED) +- Unbalanced `[` or `(` in `grep`/`sed` regex (exit 2) +- Pipeline ending with a filter (`grep`/`awk`/`head`/`tail`/`wc`/`sort`) + without `set -o pipefail` or `PIPESTATUS` (failing upstream masked) + +### Custom verifiers (convention dir) + +Drop a `.ts`/`.js`/`.mjs` file in `.pi/taskflows/verifiers/` (project) or +`~/.pi/taskflows/verifiers/` (user). Export a `TaskflowVerifier`: + +```ts +export default { + name: "my-check", + verify(flow) { + // flow.phases, flow.budget, flow.name are available. + // Return VerifierIssue[]: { phaseId?, message, severity: "error"|"warning" }. + return []; + }, +}; +``` + +Project-scope verifiers shadow user-scope by `name`. Broken modules are +skipped with a warning (fail-open). Use `taskflow_lint` (MCP) or +`verifyTaskflow(flow, { verifiers })` (programmatic) to run them. + +### MCP: `taskflow_lint` + +``` +taskflow_lint { "defineFile": "/tmp/flow.json" } +``` + +Runs built-in + discovered verifiers. Plugin issues are stamped +`category: "plugin"` with `source: `. Structural issues +are covered by `taskflow_verify`; `taskflow_lint` reports only plugin findings. + +--- + ## `taskflow_version` — build/host identity `taskflow_version` reports the engine package version, the git commit the dist diff --git a/packages/grok-taskflow/test/mcp-server.test.ts b/packages/grok-taskflow/test/mcp-server.test.ts index edb79d22..bb9a8ac6 100644 --- a/packages/grok-taskflow/test/mcp-server.test.ts +++ b/packages/grok-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("grok mcp: initialize returns the protocol version + serverInfo", async () assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-grok"); - assert.equal(res.result.serverInfo.version, "0.2.3"); + assert.equal(res.result.serverInfo.version, "0.2.4"); }); test("grok mcp: tools/list exposes the same taskflow tools as other hosts", async () => { @@ -55,6 +55,7 @@ test("grok mcp: tools/list exposes the same taskflow tools as other hosts", asyn names.sort(), [ "taskflow_compile", + "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", @@ -100,6 +101,7 @@ test("grok mcp: makeToolHandlers exposes the tools", () => { Object.keys(tools).sort(), [ "taskflow_compile", + "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", diff --git a/packages/opencode-taskflow/package.json b/packages/opencode-taskflow/package.json index f5ac95bd..0e2598f6 100644 --- a/packages/opencode-taskflow/package.json +++ b/packages/opencode-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "opencode-taskflow", - "version": "0.2.3", + "version": "0.2.4", "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", diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md index bd84ff1b..c64e70f3 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/SKILL.md @@ -144,6 +144,7 @@ For a non-trivial flow you'll iterate on, **write the definition to a file** // 2. verify, iterate, run — all reference the SAME file by path: { "name": "taskflow_verify", "arguments": { "defineFile": "/tmp/audit.json" } } // zero tokens { "name": "taskflow_compile", "arguments": { "defineFile": "/tmp/audit.json" } } // diagram +{ "name": "taskflow_lint", "arguments": { "defineFile": "/tmp/audit.json" } } // script-lint + custom verifiers { "name": "taskflow_run", "arguments": { "defineFile": "/tmp/audit.json" } } ``` diff --git a/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md b/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md index 26cba43d..58440068 100644 --- a/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md +++ b/packages/opencode-taskflow/plugin/skills/taskflow/advanced.md @@ -206,6 +206,56 @@ overrides, ordinary resume re-runs the non-done phases. --- +## Pluggable verifiers — zero-token custom static checks + +Beyond the built-in structural detectors (dead-ends, unreachable, gate-exhaustion, +budget-overflow, concurrency, ref-integrity, guard-contradictions, contracts), +Taskflow supports **pluggable verifiers**: pure functions that lint a flow's +declarations at compile time, before any model is spawned. + +### Built-in: script-lint + +`compileTaskflow` auto-includes the **script-lint** verifier (opt out with +`lint: false`). It catches common shell mistakes in `script` phase `run` +commands: + +- `grep` pattern starting with `-` without a `--` separator (exit 2, false RED) +- Unbalanced `[` or `(` in `grep`/`sed` regex (exit 2) +- Pipeline ending with a filter (`grep`/`awk`/`head`/`tail`/`wc`/`sort`) + without `set -o pipefail` or `PIPESTATUS` (failing upstream masked) + +### Custom verifiers (convention dir) + +Drop a `.ts`/`.js`/`.mjs` file in `.pi/taskflows/verifiers/` (project) or +`~/.pi/taskflows/verifiers/` (user). Export a `TaskflowVerifier`: + +```ts +export default { + name: "my-check", + verify(flow) { + // flow.phases, flow.budget, flow.name are available. + // Return VerifierIssue[]: { phaseId?, message, severity: "error"|"warning" }. + return []; + }, +}; +``` + +Project-scope verifiers shadow user-scope by `name`. Broken modules are +skipped with a warning (fail-open). Use `taskflow_lint` (MCP) or +`verifyTaskflow(flow, { verifiers })` (programmatic) to run them. + +### MCP: `taskflow_lint` + +``` +taskflow_lint { "defineFile": "/tmp/flow.json" } +``` + +Runs built-in + discovered verifiers. Plugin issues are stamped +`category: "plugin"` with `source: `. Structural issues +are covered by `taskflow_verify`; `taskflow_lint` reports only plugin findings. + +--- + ## `taskflow_version` — build/host identity `taskflow_version` reports the engine package version, the git commit the dist diff --git a/packages/opencode-taskflow/test/mcp-server.test.ts b/packages/opencode-taskflow/test/mcp-server.test.ts index 67eaec47..7d65716a 100644 --- a/packages/opencode-taskflow/test/mcp-server.test.ts +++ b/packages/opencode-taskflow/test/mcp-server.test.ts @@ -45,7 +45,7 @@ test("opencode mcp: initialize returns the protocol version + serverInfo", async assert.equal(res.result.protocolVersion, "2025-06-18"); assert.ok(res.result.capabilities.tools, "advertises tools capability"); assert.equal(res.result.serverInfo.name, "taskflow-opencode"); - assert.equal(res.result.serverInfo.version, "0.2.3"); + assert.equal(res.result.serverInfo.version, "0.2.4"); }); test("opencode mcp: tools/list exposes the taskflow tools", async () => { @@ -53,7 +53,7 @@ test("opencode mcp: tools/list exposes the taskflow tools", async () => { const names = res.result.tools.map((t: any) => t.name); assert.deepEqual( names.sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); for (const t of res.result.tools) { assert.equal(typeof t.description, "string"); @@ -83,6 +83,6 @@ test("opencode mcp: makeToolHandlers exposes the tools", () => { const tools = makeToolHandlers(process.cwd()); assert.deepEqual( Object.keys(tools).sort(), - ["taskflow_compile", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], + ["taskflow_compile", "taskflow_lint", "taskflow_list", "taskflow_peek", "taskflow_recompute", "taskflow_reconcile_workspace", "taskflow_replay", "taskflow_resume", "taskflow_run", "taskflow_runs", "taskflow_save", "taskflow_search", "taskflow_show", "taskflow_trace", "taskflow_verify", "taskflow_version", "taskflow_why_stale"], ); }); diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index c4e53a2f..2b983153 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -1,6 +1,6 @@ { "name": "pi-taskflow", - "version": "0.2.3", + "version": "0.2.4", "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", diff --git a/packages/pi-taskflow/test/skills-build.test.ts b/packages/pi-taskflow/test/skills-build.test.ts index 9494aafb..9845bd6e 100644 --- a/packages/pi-taskflow/test/skills-build.test.ts +++ b/packages/pi-taskflow/test/skills-build.test.ts @@ -31,7 +31,7 @@ test("release discovery metadata advertises the complete MCP surface", async () const { readFileSync } = await import("node:fs"); for (const file of [".claude-plugin/marketplace.json", ".grok-plugin/marketplace.json"]) { const text = readFileSync(path.join(root, file), "utf8"); - assert.match(text, /16 taskflow_\* MCP tools/); + assert.match(text, /17 taskflow_\* MCP tools/); assert.match(text, /run\/runs\/resume\/version\/list/); } const piSource = readFileSync(path.join(root, "packages", "pi-taskflow", "src", "index.ts"), "utf8"); diff --git a/packages/taskflow-core/package.json b/packages/taskflow-core/package.json index eac4cb46..94e74a46 100644 --- a/packages/taskflow-core/package.json +++ b/packages/taskflow-core/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-core", - "version": "0.2.3", + "version": "0.2.4", "description": "Host-neutral engine for declarative, verifiable task-DAG orchestration — the runtime, DSL, cache, and verification shared by pi-taskflow, codex-taskflow, claude-taskflow, opencode-taskflow, and grok-taskflow.", "keywords": [ "taskflow", diff --git a/packages/taskflow-core/src/compile.ts b/packages/taskflow-core/src/compile.ts index fe5df1c4..646b1c7f 100644 --- a/packages/taskflow-core/src/compile.ts +++ b/packages/taskflow-core/src/compile.ts @@ -21,6 +21,7 @@ import { TOURNAMENT_DEFAULT_VARIANTS, } from "./schema.ts"; import { verifyTaskflow, type TaskflowVerifier, type VerificationIssue, type VerificationResult } from "./verify.ts"; +import { scriptLintVerifier } from "./verifiers/script-lint.ts"; // --------------------------------------------------------------------------- // Types @@ -43,6 +44,10 @@ export interface CompileOptions { /** Caller-supplied verifiers forwarded into `verifyTaskflow`; their issues * overlay on the Mermaid diagram + report exactly like built-in issues. */ verifiers?: TaskflowVerifier[]; + /** Include the built-in script-lint verifier. Default `true`. Set `false` + * to disable (e.g. when the host registers its own verifiers and wants + * full control). */ + lint?: boolean; } // --------------------------------------------------------------------------- @@ -172,6 +177,29 @@ function typeTag(p: Phase): string { } } +/** Extract an inline flow definition from a flow phase's `def` field (0.2.4 + * subgraph rendering). Returns undefined for saved-use flows or unparseable + * defs. */ +function extractInlineDef(p: Phase): { name?: string; phases?: Phase[] } | undefined { + const raw = (p as { def?: unknown }).def; + if (!raw) return undefined; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw) as unknown; + if (Array.isArray(parsed)) return { phases: parsed as Phase[] }; + if (parsed && typeof parsed === "object" && Array.isArray((parsed as { phases?: unknown }).phases)) { + return parsed as { name?: string; phases?: Phase[] }; + } + } catch { return undefined; } + return undefined; + } + if (Array.isArray(raw)) return { phases: raw as Phase[] }; + if (raw && typeof raw === "object" && Array.isArray((raw as { phases?: unknown }).phases)) { + return raw as { name?: string; phases?: Phase[] }; + } + return undefined; +} + /** The body text inside a node: id, type tag, a task summary, agent. */ function nodeBody(p: Phase): string { const lines: string[] = []; @@ -292,8 +320,25 @@ function buildMermaid(flow: Taskflow, verification: VerificationResult, opts: Co const lines: string[] = []; lines.push(`flowchart ${dir}`); - // Nodes - for (const p of phases) lines.push(`\t${nodeShape(p, idMap)}`); + // Nodes — flow phases with inline defs get a subgraph (0.2.4). + for (const p of phases) { + const childDef = (p.type ?? "agent") === "flow" + ? extractInlineDef(p) + : undefined; + if (childDef && Array.isArray(childDef.phases) && childDef.phases.length > 0) { + const subId = idMap.get(p.id) ?? p.id; + lines.push(`\tsubgraph ${subId} ["${label(p.id)}: ${mdInline(childDef.name ?? p.use ?? "flow")}"]`); + const childIdMap = buildNodeIds(childDef.phases as Phase[]); + for (const cp of childDef.phases as Phase[]) { + lines.push(`\t\t${nodeShape(cp, childIdMap)}`); + } + const childEdges = edges(childDef.phases as Phase[], childIdMap); + for (const ce of childEdges) lines.push(`\t\t${ce}`); + lines.push("\tend"); + } else { + lines.push(`\t${nodeShape(p, idMap)}`); + } + } // Edges const e = edges(phases, idMap); @@ -381,6 +426,11 @@ function buildReport(flow: Taskflow, verification: VerificationResult): string { * report. Pure function — zero tokens, no LLM, no I/O. */ export function compileTaskflow(flow: Taskflow, opts: CompileOptions = {}): CompileResult { + // Merge built-in script-lint (default ON) with caller-supplied verifiers. + const verifiers: TaskflowVerifier[] = []; + if (opts.lint !== false) verifiers.push(scriptLintVerifier); + if (opts.verifiers) verifiers.push(...opts.verifiers); + const verification = verifyTaskflow( { name: flow.name ?? "taskflow", @@ -388,7 +438,7 @@ export function compileTaskflow(flow: Taskflow, opts: CompileOptions = {}): Comp budget: flow.budget, concurrency: flow.concurrency, }, - { verifiers: opts.verifiers }, + { verifiers: verifiers.length ? verifiers : undefined }, ); const mermaid = buildMermaid(flow, verification, opts); diff --git a/packages/taskflow-core/src/exec/driver.ts b/packages/taskflow-core/src/exec/driver.ts index 9ea14063..3a123e01 100644 --- a/packages/taskflow-core/src/exec/driver.ts +++ b/packages/taskflow-core/src/exec/driver.ts @@ -334,17 +334,13 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr for (const layer of layers) { if (deps.signal?.aborted) break; - for (const phase of layer) { - if (deps.signal?.aborted) break; - let skipReason: string | undefined; - if (gateBlocked) skipReason = `Gate blocked${gateReason ? `: ${gateReason}` : ""}`; - else if (budgetBlocked) skipReason = `Budget exceeded${budgetReason ? `: ${budgetReason}` : ""}`; - else { - const dep = depsSatisfied(phase, state.phases, byId); - if (!dep.ok) skipReason = dep.skipReason; - } - if (skipReason) { + // --- Pre-layer gate/budget check: skip the entire layer if blocked --- + if (gateBlocked || budgetBlocked) { + for (const phase of layer) { + const skipReason = gateBlocked + ? `Gate blocked${gateReason ? `: ${gateReason}` : ""}` + : `Budget exceeded${budgetReason ? `: ${budgetReason}` : ""}`; if (skipReason.startsWith("Budget exceeded")) { budgetBlocked = true; const be: Event = { @@ -358,25 +354,68 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr allEvents.push(be); safeTraceEmit(deps, be); } - const startedAt = Date.now(); emitLifecycle(deps, allEvents, state.runId, phase, "skipped", skipReason); state.phases[phase.id] = { id: phase.id, status: "skipped", error: skipReason, - startedAt, + startedAt: Date.now(), endedAt: Date.now(), usage: emptyUsage(), }; - continue; } + continue; + } + + // --- Resolve which phases in this layer can run vs must skip --- + interface PhasePlan { + phase: Phase; + skipReason?: string; + } + const plans: PhasePlan[] = layer.map((phase) => { + const dep = depsSatisfied(phase, state.phases, byId); + return { phase, skipReason: dep.ok ? undefined : dep.skipReason }; + }); + + // Emit skips for dep-unsatisfied phases (sequential, cheap). + for (const { phase, skipReason } of plans) { + if (!skipReason) continue; + emitLifecycle(deps, allEvents, state.runId, phase, "skipped", skipReason); + state.phases[phase.id] = { + id: phase.id, + status: "skipped", + error: skipReason, + startedAt: Date.now(), + endedAt: Date.now(), + usage: emptyUsage(), + }; + } + + // --- Execute runnable phases concurrently within the layer --- + const runnable = plans.filter((p) => !p.skipReason); + if (runnable.length === 0) continue; - const startedAt = Date.now(); - state.phases[phase.id] = { id: phase.id, status: "running", startedAt }; - const readRefs: string[] = []; - const promptCalls: string[] = []; - const ctx: StepContext = { state, deps: stepDeps, steps, args, readRefs, promptCalls }; - const result = await stepPhase(phase, ctx); + interface PhaseOutcome { + phase: Phase; + result: Awaited>; + startedAt: number; + readRefs: string[]; + promptCalls: string[]; + } + + const outcomes = await Promise.all( + runnable.map(async ({ phase }): Promise => { + const startedAt = Date.now(); + const readRefs: string[] = []; + const promptCalls: string[] = []; + const ctx: StepContext = { state, deps: stepDeps, steps, args, readRefs, promptCalls }; + const result = await stepPhase(phase, ctx); + return { phase, result, startedAt, readRefs, promptCalls }; + }), + ); + + // --- Atomic layer commit: fold all outcomes into state --- + for (const { phase, result, startedAt, readRefs } of outcomes) { if (!result) { state.phases[phase.id] = { id: phase.id, @@ -421,18 +460,13 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr approval: result.approval, warnings: result.warnings, ...(observedReads.length ? { reads: observedReads } : {}), - // Prompt-size diagnostics (parity with imperative PhaseState.promptStats). ...(result.promptStats ? { promptStats: result.promptStats } : {}), - // Match the imperative audit marker: an idempotent:false phase - // records that its side effect may have fired, unless its guard skipped - // all execution. ...(phase.idempotent === false && result.status !== "skipped" ? { sideEffect: true as const } : {}), ...(result.status === "timedOut" ? { timedOut: true as const } : {}), }; if (result.status === "done" && result.output !== undefined) { steps[phase.id] = { output: result.output, - // Always best-effort parse so {steps.X.json.field} works for JSON agents json: phaseJson ?? safeParse(result.output), }; } @@ -442,33 +476,37 @@ export async function runEventKernel(state: RunState, deps: EventKernelDeps): Pr gateOutput = result.output ?? ""; gatePhaseId = phase.id; } - const ob = runOverBudget(state); - if (ob.over) { - budgetBlocked = true; - budgetReason = ob.reason; - const be: Event = { - v: EVENT_SCHEMA_VERSION, - ts: Date.now(), - runId: state.runId, - phaseId: phase.id, - kind: "decision", - decision: { type: "budget-hit", value: "budget", reason: ob.reason }, - }; - allEvents.push(be); - safeTraceEmit(deps, be); - // stepPhase already flushed this phase's lifecycle batch. - safeTraceFlush(deps, phase.id); - } - try { - deps.persist?.(state); - } catch { - /* fail-open */ - } - try { - deps.onProgress?.(state); - } catch { - /* fail-open */ - } + } + + // --- Post-layer budget check --- + const ob = runOverBudget(state); + if (ob.over) { + budgetBlocked = true; + budgetReason = ob.reason; + // Attribute the budget-hit to the last phase in the layer. + const lastPhase = layer[layer.length - 1]; + const be: Event = { + v: EVENT_SCHEMA_VERSION, + ts: Date.now(), + runId: state.runId, + phaseId: lastPhase.id, + kind: "decision", + decision: { type: "budget-hit", value: "budget", reason: ob.reason }, + }; + allEvents.push(be); + safeTraceEmit(deps, be); + safeTraceFlush(deps, lastPhase.id); + } + + try { + deps.persist?.(state); + } catch { + /* fail-open */ + } + try { + deps.onProgress?.(state); + } catch { + /* fail-open */ } } diff --git a/packages/taskflow-core/src/exec/kernel-policy.ts b/packages/taskflow-core/src/exec/kernel-policy.ts index ba7cbb15..187847e9 100644 --- a/packages/taskflow-core/src/exec/kernel-policy.ts +++ b/packages/taskflow-core/src/exec/kernel-policy.ts @@ -4,7 +4,7 @@ */ import type { Budget, Phase, Taskflow } from "../schema.ts"; -import { dependenciesOf, topoLayers } from "../schema.ts"; +import { dependenciesOf } from "../schema.ts"; import { emptyUsage, type UsageStats } from "../usage.ts"; import type { RunState } from "../store.ts"; import { overBudget } from "../deterministic.ts"; @@ -72,10 +72,13 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { if ((p.type ?? "agent") === "reduce" && (p as { reduceStrategy?: string }).reduceStrategy === "tree") { return `phase '${id}': reduceStrategy 'tree' requires the imperative runtime`; } - // Explicit multi-attempt retry / expect contracts: kernel lacks full policy yet. - if (p.retry && typeof p.retry === "object" && (p.retry.max ?? 0) > 0) { - return `phase '${id}': retry requires the imperative runtime`; - } + // Explicit multi-attempt retry: now supported on the kernel path (0.2.4). + // The retry curve is handled in step.ts runOneAgent and step-kinds.ts + // runAgentCall. + + // expect contracts remain imperative-only (0.2.4): the contract-violation + // retry loop (contractViolations + runOne integration) is not yet ported + // to the kernel step handlers. if (p.expect !== undefined) { return `phase '${id}': expect contracts require the imperative runtime`; } @@ -88,19 +91,23 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { if (p.shareContext === true || def.contextSharing === true) { return `phase '${id}': Shared Context Tree requires the imperative runtime`; } - if (p.context && p.context.length > 0) { - return `phase '${id}': context pre-read requires the imperative runtime`; - } - // Every phase-local cwd (literal or isolated keyword) is imperative-only. - // The kernel currently has only one flow-level cwd; accepting a literal - // override would silently run in the wrong workspace. + // Context pre-read: now supported on the kernel path (0.2.4). + // step-kinds.ts reads context files and prepends them to the prompt. + // Per-phase cwd: literal string cwd is now supported on the kernel path + // (0.2.4). Interpolation placeholder cwds ({args.*}, {steps.*}) and + // workspace keywords (temp/dedicated/worktree) remain imperative-only. const cwd = p.cwd; - if (typeof cwd === "string") { + if (typeof cwd === "string" && (containsInterpolationPlaceholder(cwd) || /^(temp|dedicated|worktree)$/.test(cwd))) { return `phase '${id}': workspace cwd '${cwd}' requires the imperative runtime`; } + // Per-branch cwd remains imperative-only (the kernel has no branch-level + // cwd resolution for parallel/tournament/race branches). if (Array.isArray(p.branches) && p.branches.some((branch) => typeof branch.cwd === "string")) { return `phase '${id}': per-branch cwd requires the imperative runtime`; } + + // Script stdin input and interpolated script argv remain imperative-only + // (they require the full interpolation + spawn pipeline). if ((p.type ?? "agent") === "script" && p.input !== undefined) { return `phase '${id}': script stdin input requires the imperative runtime`; } @@ -108,14 +115,10 @@ export function kernelUnsupportedReason(def: Taskflow): string | undefined { return `phase '${id}': interpolated script argv requires the imperative runtime`; } } - // The imperative scheduler runs independent phases concurrently. Until the - // event driver has an atomic layer commit, admit only linear layers; this - // prevents a completed gate/budget decision from incorrectly skipping an - // independent sibling that should already have been in flight. - const layers = topoLayers(def.phases ?? []); - if (layers.some((layer) => layer.length > 1)) { - return "concurrent DAG layers require the imperative runtime"; - } + // Concurrent DAG layers are now supported (0.2.4): the driver executes + // phases within a layer concurrently via Promise.all and commits atomically + // at layer boundaries. Gate/budget decisions are checked between layers. + // A fan-out budget guard must stop spawning items as spend accumulates. The // kernel aggregates only after the whole node today, so budgeted fan-out is // deliberately routed to the imperative implementation. diff --git a/packages/taskflow-core/src/exec/step-kinds.ts b/packages/taskflow-core/src/exec/step-kinds.ts index 290368de..59371273 100644 --- a/packages/taskflow-core/src/exec/step-kinds.ts +++ b/packages/taskflow-core/src/exec/step-kinds.ts @@ -7,6 +7,8 @@ */ import type { Phase, Taskflow } from "../schema.ts"; +import { readFileSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; import { LOOP_DEFAULT_MAX_ITERATIONS, LOOP_HARD_MAX_ITERATIONS, @@ -67,9 +69,48 @@ async function runAgentCall( typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000 ? phase.timeout : undefined; + + // --- Per-phase cwd resolution (0.2.4) --- + // A literal string cwd is resolved relative to the flow cwd. Workspace + // keywords (temp/dedicated/worktree) remain imperative-only. + const effCwd = typeof phase.cwd === "string" && !/^(temp|dedicated|worktree)$/.test(phase.cwd) + ? (phase.cwd.startsWith("/") ? phase.cwd : `${ctx.deps.cwd}/${phase.cwd}`) + : ctx.deps.cwd; + + // --- Context pre-read (0.2.4) --- + // Read context files and prepend them to the task prompt, matching the + // imperative runtime's resolvePhaseContext behavior. + let effectiveTask = task; + if (Array.isArray(phase.context) && phase.context.length > 0) { + const contextParts: string[] = []; + for (const file of phase.context) { + if (typeof file !== "string") continue; + try { + const filePath = resolvePath(effCwd, file); + const content = readFileSync(filePath, "utf8"); + contextParts.push(`\n${content}\n`); + } catch { + contextParts.push(`\n[file not found or unreadable]\n`); + } + } + if (contextParts.length > 0) { + effectiveTask = `${contextParts.join("\n\n")}\n\n${task}`; + } + } + + // --- Explicit retry support (0.2.4) --- + // The imperative runtime supports phase.retry = { max, backoffMs, factor }. + // The kernel previously only did transient-error retries (up to 4 attempts). + // Now we honor the declared retry.max for ALL failures (not just transient), + // matching the imperative contract. + const retryMax = phase.retry && typeof phase.retry === "object" ? (phase.retry.max ?? 0) : 0; + const retryBackoffMs = phase.retry && typeof phase.retry === "object" ? (phase.retry.backoffMs ?? 2000) : 2000; + const retryFactor = phase.retry && typeof phase.retry === "object" ? (phase.retry.factor ?? 2) : 2; + const maxAttempts = Math.max(4, retryMax + 1); // at least 4 for transient retries + const usages: UsageStats[] = []; let r: RunResult | undefined; - for (let attempt = 0; attempt < 4; attempt++) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { if (ctx.deps.signal?.aborted) break; let timedOut = false; let terminalCommitted = false; @@ -97,17 +138,17 @@ async function runAgentCall( timer = undefined; } }; - ctx.promptCalls?.push(task); + ctx.promptCalls?.push(effectiveTask); const invocation = ctx.deps.runTask( - ctx.deps.cwd, + effCwd, ctx.deps.agents, agentName, - task, + effectiveTask, { model: phase.model, thinking: phase.thinking, tools: phase.tools, - cwd: ctx.deps.cwd, + cwd: effCwd, signal: callSignal, idleTimeoutMs: resolveIdleMs(phase, ctx.state.def), onTerminalCommit, @@ -122,7 +163,7 @@ async function runAgentCall( timeoutController?.abort(); forceReturnTimer = setTimeout(() => resolve({ agent: agentName, - task, + task: effectiveTask, exitCode: 1, output: "", stderr: "", @@ -156,8 +197,13 @@ async function runAgentCall( usages.push(r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage()); if (!isFailedResult(r)) break; if (kernelAttemptsOverBudget(ctx.state, phase.id, usages)) break; - if (r.phaseTimeout || phase.idempotent === false || !isTransientError(r) || attempt >= 3) break; - const wait = Math.min(60_000, (phase.retry?.backoffMs ?? 2_000) * 2 ** attempt); + if (r.phaseTimeout || phase.idempotent === false) break; + // Explicit retry: retry ALL failures up to retryMax, not just transient. + const isTransient = isTransientError(r); + const withinExplicitRetry = attempt < retryMax; + const withinTransientRetry = isTransient && attempt < 3; + if (!withinExplicitRetry && !withinTransientRetry) break; + const wait = Math.min(60_000, retryBackoffMs * retryFactor ** attempt); await abortableDelay(wait, ctx.deps.signal); } if (!r) { diff --git a/packages/taskflow-core/src/exec/step.ts b/packages/taskflow-core/src/exec/step.ts index e19565f4..f8caa1da 100644 --- a/packages/taskflow-core/src/exec/step.ts +++ b/packages/taskflow-core/src/exec/step.ts @@ -8,6 +8,8 @@ import type { Phase, Taskflow } from "../schema.ts"; import { dependenciesOf, MAX_DYNAMIC_MAP_ITEMS, PHASE_TYPES } from "../schema.ts"; +import { readFileSync } from "node:fs"; +import { resolve as resolvePath } from "node:path"; import type { RunState } from "../store.ts"; import type { AgentConfig } from "../agents.ts"; import type { RunOptions, RunResult } from "../host/runner-types.ts"; @@ -258,9 +260,41 @@ async function runOneAgent( typeof phase.timeout === "number" && Number.isFinite(phase.timeout) && phase.timeout >= 1000 ? phase.timeout : undefined; + + // --- Per-phase cwd resolution (0.2.4) --- + const effCwd = typeof phase.cwd === "string" && !/^(temp|dedicated|worktree)$/.test(phase.cwd) + && !phase.cwd.includes("{") + ? (phase.cwd.startsWith("/") ? phase.cwd : `${ctx.deps.cwd}/${phase.cwd}`) + : ctx.deps.cwd; + + // --- Context pre-read (0.2.4) --- + let effectiveTask = task; + if (Array.isArray(phase.context) && phase.context.length > 0) { + const contextParts: string[] = []; + for (const file of phase.context) { + if (typeof file !== "string") continue; + try { + const filePath = resolvePath(effCwd, file); + const content = readFileSync(filePath, "utf8"); + contextParts.push(`\n${content}\n`); + } catch { + contextParts.push(`\n[file not found or unreadable]\n`); + } + } + if (contextParts.length > 0) { + effectiveTask = `${contextParts.join("\n\n")}\n\n${task}`; + } + } + + // --- Explicit retry support (0.2.4) --- + const retryMax = phase.retry && typeof phase.retry === "object" ? (phase.retry.max ?? 0) : 0; + const retryBackoffMs = phase.retry && typeof phase.retry === "object" ? (phase.retry.backoffMs ?? 2000) : 2000; + const retryFactor = phase.retry && typeof phase.retry === "object" ? (phase.retry.factor ?? 2) : 2; + const maxAttempts = Math.max(4, retryMax + 1); + const attempts: UsageStats[] = []; let r: RunResult | undefined; - for (let attempt = 0; attempt < 4; attempt++) { + for (let attempt = 0; attempt < maxAttempts; attempt++) { if (ctx.deps.signal?.aborted) break; let timedOut = false; let terminalCommitted = false; @@ -288,17 +322,17 @@ async function runOneAgent( timer = undefined; } }; - ctx.promptCalls?.push(task); + ctx.promptCalls?.push(effectiveTask); const invocation = ctx.deps.runTask( - ctx.deps.cwd, + effCwd, ctx.deps.agents, agentName, - task, + effectiveTask, { model: phase.model, thinking: phase.thinking, tools: phase.tools, - cwd: ctx.deps.cwd, + cwd: effCwd, signal: callSignal, idleTimeoutMs: resolveIdleMs(phase, ctx.state.def), onTerminalCommit, @@ -313,7 +347,7 @@ async function runOneAgent( timeoutController?.abort(); forceReturnTimer = setTimeout(() => resolve({ agent: agentName, - task, + task: effectiveTask, exitCode: 1, output: "", stderr: "", @@ -346,12 +380,13 @@ async function runOneAgent( } attempts.push(r.usage ? { ...emptyUsage(), ...r.usage } : emptyUsage()); if (!isFailedResult(r)) break; - // The current phase has not been committed to state yet. Include prior - // completed run usage plus every attempt made here before admitting a - // transient retry, matching the imperative runner's live usage guard. if (kernelAttemptsOverBudget(ctx.state, phase.id, attempts)) break; - if (r.phaseTimeout || phase.idempotent === false || !isTransientError(r) || attempt >= 3) break; - const wait = Math.min(60_000, (phase.retry?.backoffMs ?? 2_000) * 2 ** attempt); + if (r.phaseTimeout || phase.idempotent === false) break; + const isTransient = isTransientError(r); + const withinExplicitRetry = attempt < retryMax; + const withinTransientRetry = isTransient && attempt < 3; + if (!withinExplicitRetry && !withinTransientRetry) break; + const wait = Math.min(60_000, retryBackoffMs * retryFactor ** attempt); await abortableDelay(wait, ctx.deps.signal); } if (!r) { diff --git a/packages/taskflow-core/src/index.ts b/packages/taskflow-core/src/index.ts index c47b659b..04848408 100644 --- a/packages/taskflow-core/src/index.ts +++ b/packages/taskflow-core/src/index.ts @@ -57,3 +57,4 @@ export * from "./trace.ts"; export * from "./deterministic.ts"; export * from "./replay.ts"; export * from "./exec/index.ts"; +export * from "./verifiers/index.ts"; diff --git a/packages/taskflow-core/src/verifiers/discover.ts b/packages/taskflow-core/src/verifiers/discover.ts new file mode 100644 index 00000000..c6521099 --- /dev/null +++ b/packages/taskflow-core/src/verifiers/discover.ts @@ -0,0 +1,162 @@ +/** + * Verifier discovery — convention-based loading of project-local verifiers. + * + * Discovers verifier modules from `.pi/taskflows/verifiers/` (project scope) + * and `~/.pi/taskflows/verifiers/` (user scope). Each `.ts` or `.js` file in + * the directory is dynamically imported; its default export (or named + * `verifier` / `verifiers` export) is collected. Project-scope verifiers + * shadow user-scope verifiers with the same `name`. + * + * This is the minimum-viable discovery deferred by #82: convention dir, no + * config file, no registry. Programmatic registration via + * `verifyTaskflow(flow, { verifiers })` always takes precedence. + * + * Discovery is async (dynamic import) and fail-open: a broken verifier module + * is skipped with a warning, never crashes the host. + */ + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { TaskflowVerifier } from "../verify.ts"; + +/** The convention directory name under `.pi/taskflows/`. */ +const VERIFIERS_DIR = "verifiers"; + +/** Find the project-scope verifiers directory (walk-up, same as flows). */ +function findProjectVerifiersDir(cwd: string): string | null { + const home = os.homedir(); + let dir = cwd; + while (true) { + if (dir !== home) { + const candidate = path.join(dir, ".pi", "taskflows", VERIFIERS_DIR); + if (fs.existsSync(candidate)) return candidate; + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; +} + +/** The user-scope verifiers directory. */ +function userVerifiersDir(): string { + return path.join(os.homedir(), ".pi", "taskflows", VERIFIERS_DIR); +} + +/** List verifier files in a directory (`.ts` and `.js`, sorted). */ +function listVerifierFiles(dir: string): string[] { + if (!fs.existsSync(dir)) return []; + return fs + .readdirSync(dir) + .filter((f) => /\.(ts|js|mjs)$/.test(f) && !f.startsWith(".")) + .sort() + .map((f) => path.join(dir, f)); +} + +/** Extract verifiers from a dynamically imported module. */ +function extractVerifiers(mod: unknown): TaskflowVerifier[] { + if (!mod || typeof mod !== "object") return []; + const m = mod as Record; + const result: TaskflowVerifier[] = []; + + // default export: single verifier or array + if (m.default) { + if (Array.isArray(m.default)) { + for (const v of m.default) { + if (isVerifier(v)) result.push(v); + } + } else if (isVerifier(m.default)) { + result.push(m.default); + } + } + + // named `verifier` export: single verifier + if (m.verifier && isVerifier(m.verifier)) { + result.push(m.verifier); + } + + // named `verifiers` export: array + if (Array.isArray(m.verifiers)) { + for (const v of m.verifiers) { + if (isVerifier(v)) result.push(v); + } + } + + return result; +} + +/** Shape-check a value as a TaskflowVerifier. */ +function isVerifier(v: unknown): v is TaskflowVerifier { + if (!v || typeof v !== "object") return false; + const obj = v as Record; + return typeof obj.name === "string" && typeof obj.verify === "function"; +} + +export interface DiscoveredVerifiers { + /** All discovered verifiers, project-scope shadowing user-scope by name. */ + verifiers: TaskflowVerifier[]; + /** Warnings for modules that failed to load or had no valid exports. */ + warnings: string[]; + /** Directories that were scanned. */ + dirs: string[]; +} + +/** + * Discover project-local and user-scope verifiers from the convention + * directories. Fail-open: broken modules are skipped with a warning. + * + * Project-scope verifiers shadow user-scope verifiers with the same `name`. + * Within a scope, files are loaded in sorted order; later files do NOT shadow + * earlier ones with the same name (first wins within a scope). + */ +export async function discoverVerifiers(cwd: string): Promise { + const warnings: string[] = []; + const dirs: string[] = []; + const byName = new Map(); + + // Load user-scope first (lower priority), then project-scope (shadows). + const userDir = userVerifiersDir(); + const projectDir = findProjectVerifiersDir(cwd); + + for (const [scope, dir] of [["user", userDir], ["project", projectDir]] as const) { + if (!dir || !fs.existsSync(dir)) continue; + dirs.push(dir); + + for (const file of listVerifierFiles(dir)) { + try { + // Dynamic import with file:// URL for ESM compatibility. + const mod = await import(`file://${file}`); + const extracted = extractVerifiers(mod); + if (extracted.length === 0) { + warnings.push(`${scope} verifier ${path.basename(file)}: no valid TaskflowVerifier export found`); + continue; + } + for (const v of extracted) { + if (scope === "project" || !byName.has(v.name)) { + byName.set(v.name, v); + } + } + } catch (e) { + warnings.push( + `${scope} verifier ${path.basename(file)}: failed to load (${e instanceof Error ? e.message : String(e)})`, + ); + } + } + } + + return { verifiers: [...byName.values()], warnings, dirs }; +} + +/** + * Synchronous variant: discovers verifier files but does NOT import them + * (import is async). Returns the file paths that would be loaded, for + * diagnostics / `--list-verifiers` style commands. + */ +export function listVerifierPaths(cwd: string): { project: string[]; user: string[] } { + const projectDir = findProjectVerifiersDir(cwd); + return { + project: projectDir ? listVerifierFiles(projectDir) : [], + user: listVerifierFiles(userVerifiersDir()), + }; +} diff --git a/packages/taskflow-core/src/verifiers/index.ts b/packages/taskflow-core/src/verifiers/index.ts new file mode 100644 index 00000000..96539f10 --- /dev/null +++ b/packages/taskflow-core/src/verifiers/index.ts @@ -0,0 +1,15 @@ +/** + * Built-in verifiers barrel. + * + * Re-exports the shipped verifiers so hosts can import them from one place: + * import { scriptLintVerifier, builtinVerifiers } from "taskflow-core/verifiers"; + */ + +export { scriptLintVerifier } from "./script-lint.ts"; +export { discoverVerifiers, listVerifierPaths, type DiscoveredVerifiers } from "./discover.ts"; + +import type { TaskflowVerifier } from "../verify.ts"; +import { scriptLintVerifier } from "./script-lint.ts"; + +/** All built-in verifiers, in recommended registration order. */ +export const builtinVerifiers: readonly TaskflowVerifier[] = [scriptLintVerifier]; diff --git a/packages/taskflow-core/src/verifiers/script-lint.ts b/packages/taskflow-core/src/verifiers/script-lint.ts new file mode 100644 index 00000000..a40a6bbe --- /dev/null +++ b/packages/taskflow-core/src/verifiers/script-lint.ts @@ -0,0 +1,313 @@ +/** + * Built-in script-lint verifier — static analysis of `script` phase `run` + * commands. Catches common shell mistakes that are 100% statically detectable, + * zero-token, and runner-agnostic: + * + * 1. `grep`/`egrep`/`fgrep` whose pattern starts with `-` and has no `--` + * separator (grep parses it as flags → exit 2, a false RED). + * 2. `grep`/`sed` with obviously invalid regex under common dialect flags + * (unbalanced brackets/parens → exit 2). + * 3. `runner | grep …` without `pipefail` / `PIPESTATUS` (the filter's exit + * masks the runner's — a failing runner reads as GREEN). + * 4. A script that references a file path matching a phase id's likely output + * artifact created by an earlier phase (RED until that phase runs). + * + * This is the dogfood verifier motivated by issue #82. It ships as an optional + * built-in: hosts can register it via `verifyTaskflow(flow, { verifiers: + * [scriptLintVerifier] })` or it is auto-included by `compileTaskflow` when + * `opts.lint !== false`. + */ + +import type { Phase } from "../schema.ts"; +import type { TaskflowVerifier, VerifiableFlow, VerifierIssue } from "../verify.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Tokenize a shell command string naively (split on whitespace, respecting + * single/double quotes). Not a full shell parser — just enough to identify + * command names and their arguments for lint heuristics. */ +function tokenize(cmd: string): string[] { + const tokens: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + let escaped = false; + for (const ch of cmd) { + if (escaped) { + current += ch; + escaped = false; + continue; + } + if (ch === "\\" && !inSingle) { + escaped = true; + current += ch; + continue; + } + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + current += ch; + continue; + } + if (ch === '"' && !inSingle) { + inDouble = !inDouble; + current += ch; + continue; + } + if ((ch === " " || ch === "\t") && !inSingle && !inDouble) { + if (current) tokens.push(current); + current = ""; + continue; + } + current += ch; + } + if (current) tokens.push(current); + return tokens; +} + +/** Split a command string on unquoted pipe characters into pipeline segments. */ +function splitPipeline(cmd: string): string[] { + const segments: string[] = []; + let current = ""; + let inSingle = false; + let inDouble = false; + let escaped = false; + for (const ch of cmd) { + if (escaped) { + current += ch; + escaped = false; + continue; + } + if (ch === "\\" && !inSingle) { + escaped = true; + current += ch; + continue; + } + if (ch === "'" && !inDouble) { + inSingle = !inSingle; + current += ch; + continue; + } + if (ch === '"' && !inSingle) { + inDouble = !inDouble; + current += ch; + continue; + } + if (ch === "|" && !inSingle && !inDouble) { + segments.push(current.trim()); + current = ""; + continue; + } + current += ch; + } + if (current.trim()) segments.push(current.trim()); + return segments; +} + +const GREP_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg", "ag"]); + +/** Strip a directory path prefix from a command name using linear-time + * lastIndexOf instead of a regex (avoids CodeQL ReDoS alert). */ +function stripPathPrefix(token: string | undefined): string | undefined { + if (!token) return undefined; + const idx = token.lastIndexOf("/"); + return idx >= 0 ? token.slice(idx + 1) : token; +} + +/** Check if a grep-like command has a pattern starting with `-` without a `--` + * separator before it. */ +function checkGrepDashPattern(tokens: string[]): string | undefined { + const cmdName = stripPathPrefix(tokens[0]); + if (!cmdName || !GREP_COMMANDS.has(cmdName)) return undefined; + + let sawDoubleDash = false; + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t === "--") { + sawDoubleDash = true; + continue; + } + if (sawDoubleDash) { + // First arg after -- is the pattern; it's safe. + return undefined; + } + // Skip known flags (single-dash options). + if (t.startsWith("-") && t.length <= 3 && !t.startsWith("--")) continue; + // Skip --flag=value style. + if (t.startsWith("--")) continue; + // First non-flag argument is the pattern. + if (t.startsWith("-")) { + return `grep pattern '${t}' starts with '-' but has no '--' separator — grep will parse it as flags (exit 2)`; + } + // Pattern doesn't start with dash — fine. + return undefined; + } + return undefined; +} + +/** Check for obviously unbalanced regex in grep/sed patterns. */ +function checkUnbalancedRegex(tokens: string[]): string | undefined { + const cmdName = stripPathPrefix(tokens[0]); + if (!cmdName) return undefined; + + let pattern: string | undefined; + + if (GREP_COMMANDS.has(cmdName)) { + // Find the pattern (first non-flag arg, or arg after -e). + let sawE = false; + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t === "--") { + pattern = tokens[i + 1]; + break; + } + if (t === "-e") { + sawE = true; + continue; + } + if (sawE) { + pattern = t; + break; + } + if (t.startsWith("-")) continue; + pattern = t; + break; + } + } else if (cmdName === "sed") { + // sed 's/pattern/replacement/' — extract the pattern between first two + // delimiters. + for (let i = 1; i < tokens.length; i++) { + const t = tokens[i]; + if (t.startsWith("-")) continue; + // First non-flag arg is the script. + const cleaned = t.replace(/^['"]|['"]$/g, ""); + const match = cleaned.match(/^s(.)(.*)$/); + if (match) { + const delim = match[1]; + const rest = match[2]; + const endIdx = rest.indexOf(delim); + if (endIdx > 0) pattern = rest.slice(0, endIdx); + } + break; + } + } + + if (!pattern) return undefined; + + // Strip quotes. + const p = pattern.replace(/^['"]|['"]$/g, ""); + + // Check unbalanced brackets/parens (basic heuristic — not a full regex + // parser, but catches the common `[abc` and `(foo` mistakes). + let bracketDepth = 0; + let parenDepth = 0; + let inBracket = false; + for (let i = 0; i < p.length; i++) { + const ch = p[i]; + if (ch === "\\" && i + 1 < p.length) { + i++; // skip escaped char + continue; + } + if (ch === "[") { + inBracket = true; + bracketDepth++; + } else if (ch === "]") { + if (inBracket) { + bracketDepth--; + if (bracketDepth === 0) inBracket = false; + } + } else if (ch === "(" && !inBracket) { + parenDepth++; + } else if (ch === ")" && !inBracket) { + parenDepth--; + } + } + if (bracketDepth > 0) return `unbalanced '[' in regex pattern '${p}' — grep/sed will exit 2`; + if (parenDepth > 0) return `unbalanced '(' in regex pattern '${p}' — grep/sed will exit 2`; + if (parenDepth < 0) return `unbalanced ')' in regex pattern '${p}' — grep/sed will exit 2`; + + return undefined; +} + +/** Check if a pipeline has a grep/filter at the end without pipefail. */ +function checkPipefail(cmd: string, segments: string[]): string | undefined { + if (segments.length < 2) return undefined; + + const lastSegment = segments[segments.length - 1]; + const lastTokens = tokenize(lastSegment); + const lastCmd = stripPathPrefix(lastTokens[0]); + + // Only flag when the last command is a filter (grep/awk/head/tail/wc/sort). + const FILTER_COMMANDS = new Set(["grep", "egrep", "fgrep", "rg", "ag", "awk", "head", "tail", "wc", "sort", "uniq"]); + if (!lastCmd || !FILTER_COMMANDS.has(lastCmd)) return undefined; + + // Check if pipefail is set anywhere in the command. + if (/set\s+-[a-zA-Z]*o\s+pipefail/.test(cmd) || /set\s+-[a-zA-Z]*p/.test(cmd)) return undefined; + if (/PIPESTATUS/.test(cmd)) return undefined; + if (/pipefail/.test(cmd)) return undefined; + + // Check if the script uses `bash -o pipefail` or similar. + if (cmd.includes("-o") && cmd.includes("pipefail") && cmd.includes("bash")) return undefined; + + return `pipeline ends with '${lastCmd}' but has no 'set -o pipefail' or PIPESTATUS check — a failing upstream command will be masked by the filter's exit code`; +} + +// --------------------------------------------------------------------------- +// The verifier +// --------------------------------------------------------------------------- + +/** Extract the command string(s) from a script phase's `run` field. */ +function extractCommands(phase: Phase): string[] { + const run = (phase as { run?: unknown }).run; + if (typeof run === "string") return [run]; + if (Array.isArray(run)) return run.filter((r): r is string => typeof r === "string"); + return []; +} + +/** The built-in script-lint verifier. Register it via + * `verifyTaskflow(flow, { verifiers: [scriptLintVerifier] })`. */ +export const scriptLintVerifier: TaskflowVerifier = { + name: "script-lint", + verify(flow: VerifiableFlow): VerifierIssue[] { + const issues: VerifierIssue[] = []; + + for (const phase of flow.phases) { + if ((phase.type ?? "agent") !== "script") continue; + const commands = extractCommands(phase); + + for (const cmd of commands) { + // Skip commands with interpolation placeholders — we can't lint + // what we can't see yet. + if (/\{[a-zA-Z]/.test(cmd)) continue; + + const segments = splitPipeline(cmd); + + for (const segment of segments) { + const tokens = tokenize(segment); + if (tokens.length === 0) continue; + + // Check 1: grep dash pattern. + const dashIssue = checkGrepDashPattern(tokens); + if (dashIssue) { + issues.push({ phaseId: phase.id, message: dashIssue, severity: "error" }); + } + + // Check 2: unbalanced regex. + const regexIssue = checkUnbalancedRegex(tokens); + if (regexIssue) { + issues.push({ phaseId: phase.id, message: regexIssue, severity: "error" }); + } + } + + // Check 3: pipefail. + const pipefailIssue = checkPipefail(cmd, segments); + if (pipefailIssue) { + issues.push({ phaseId: phase.id, message: pipefailIssue, severity: "warning" }); + } + } + } + + return issues; + }, +}; diff --git a/packages/taskflow-core/test/compile.test.ts b/packages/taskflow-core/test/compile.test.ts index 2e623147..cb324daf 100644 --- a/packages/taskflow-core/test/compile.test.ts +++ b/packages/taskflow-core/test/compile.test.ts @@ -381,3 +381,42 @@ test("compile: a cyclic dependency graph renders back-edges without crashing", ( assert.match(r.mermaid, /x --> y/); assert.match(r.mermaid, /y --> x/); }); + +// --------------------------------------------------------------------------- +// 0.2.4: subgraph rendering for inline flow defs +// --------------------------------------------------------------------------- + +test("compile: inline flow def renders as a Mermaid subgraph", () => { + const tf: Taskflow = { + name: "parent", + phases: [ + { + id: "nested", + type: "flow", + def: { + name: "child", + phases: [ + { id: "c1", type: "agent", task: "step one" }, + { id: "c2", type: "agent", task: "step two", dependsOn: ["c1"] }, + ], + }, + final: true, + }, + ], + }; + const r = compileTaskflow(tf); + assert.match(r.mermaid, /subgraph nested/, "inline flow renders as a subgraph"); + assert.match(r.mermaid, /c1/, "child phase c1 appears inside the subgraph"); + assert.match(r.mermaid, /c2/, "child phase c2 appears inside the subgraph"); + assert.match(r.mermaid, /end/, "subgraph is closed"); +}); + +test("compile: saved-use flow (no def) does NOT render as a subgraph", () => { + const tf: Taskflow = { + name: "parent", + phases: [{ id: "saved", type: "flow", use: "my-saved-flow", final: true }], + }; + const r = compileTaskflow(tf); + assert.doesNotMatch(r.mermaid, /subgraph/, "saved-use flow is a plain node"); + assert.match(r.mermaid, /my-saved-flow/, "use name appears in the node label"); +}); diff --git a/packages/taskflow-core/test/exec-kernel-hardening.test.ts b/packages/taskflow-core/test/exec-kernel-hardening.test.ts index 083a8b75..51397118 100644 --- a/packages/taskflow-core/test/exec-kernel-hardening.test.ts +++ b/packages/taskflow-core/test/exec-kernel-hardening.test.ts @@ -63,12 +63,12 @@ test("feature fall-back: score gate refuses kernel", () => { assert.match(kernelUnsupportedReason(def) ?? "", /score/); }); -test("feature fall-back: retry refuses kernel", () => { +test("feature: retry is now admitted on the kernel (0.2.4)", () => { const def: Taskflow = { name: "r", phases: [{ id: "a", type: "agent", agent: "a", task: "t", retry: { max: 2 }, final: true }], }; - assert.equal(canUseEventKernel(def), false); + assert.equal(canUseEventKernel(def), true, "retry is supported on the kernel since 0.2.4"); }); test("budget: second phase skipped after cost cap on kernel", async () => { diff --git a/packages/taskflow-core/test/kernel-parity.test.ts b/packages/taskflow-core/test/kernel-parity.test.ts new file mode 100644 index 00000000..5717ffb6 --- /dev/null +++ b/packages/taskflow-core/test/kernel-parity.test.ts @@ -0,0 +1,243 @@ +/** + * S5.0 — Kernel parity harness. + * + * Golden suite: for each fixture flow, run on BOTH engines (imperative + + * event kernel) with the same mock runTask, and assert that status, + * per-phase status/output/error, and gate decisions match. + * + * This is the differential test that must stay green before the kernel + * can be flipped to default ON (S5.2). + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AgentConfig } from "../src/agents.ts"; +import type { RunOptions, RunResult } from "../src/runner-core.ts"; +import { emptyUsage } from "../src/usage.ts"; +import { executeTaskflow, type RuntimeDeps, type RuntimeResult } from "../src/runtime.ts"; +import { canUseEventKernel } from "../src/exec/driver.ts"; +import type { Taskflow, Phase } from "../src/schema.ts"; +import type { RunState } from "../src/store.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const AGENTS: AgentConfig[] = [ + { name: "a", description: "test", systemPrompt: "", source: "user", filePath: "" }, + { name: "b", description: "test", systemPrompt: "", source: "user", filePath: "" }, +]; + +/** A deterministic mock runner: output = "OUT:", gate = PASS. */ +function deterministicRunner(): RuntimeDeps["runTask"] { + return async (_cwd: string, _agents: AgentConfig[], agentName: string, task: string, _o: RunOptions): Promise => { + // Gate phases return a VERDICT. + if (task.includes("VERDICT")) { + return { + agent: agentName, + task, + exitCode: 0, + output: "Looks good. VERDICT: PASS", + stderr: "", + usage: { ...emptyUsage(), output: 10, turns: 1 }, + stopReason: "end", + }; + } + return { + agent: agentName, + task, + exitCode: 0, + output: `OUT:${task}`, + stderr: "", + usage: { ...emptyUsage(), output: 5, turns: 1 }, + stopReason: "end", + }; + }; +} + +function mkState(def: Taskflow): RunState { + return { + runId: `parity-${def.name}`, + flowName: def.name, + def, + args: {}, + status: "running", + phases: {}, + createdAt: Date.now(), + updatedAt: Date.now(), + cwd: "/tmp", + }; +} + +function baseDeps(eventKernel: boolean): RuntimeDeps { + return { + cwd: "/tmp", + agents: AGENTS, + runTask: deterministicRunner(), + persist: () => {}, + onProgress: () => {}, + eventKernel, + }; +} + +/** Normalize a RuntimeResult for comparison (strip volatile fields). */ +function normalize(res: RuntimeResult) { + return { + ok: res.ok, + status: res.state.status, + phases: Object.fromEntries( + Object.entries(res.state.phases).map(([id, p]) => [ + id, + { + status: p.status, + output: p.output, + error: p.error, + // Normalize gate: strip undefined reason to avoid + // {verdict:'pass'} vs {verdict:'pass',reason:undefined} mismatch. + gate: p.gate + ? { verdict: p.gate.verdict, ...(p.gate.reason !== undefined ? { reason: p.gate.reason } : {}) } + : undefined, + }, + ]), + ), + }; +} + +/** Run a flow on both engines and assert parity. */ +async function assertParity(def: Taskflow, label?: string) { + const tag = label ?? def.name; + + // Sanity: the flow must be kernel-eligible. + assert.equal(canUseEventKernel(def), true, `${tag}: flow must be kernel-eligible`); + + const imperative = await executeTaskflow(mkState(def), baseDeps(false)); + const kernel = await executeTaskflow(mkState(def), baseDeps(true)); + + const normImp = normalize(imperative); + const normKer = normalize(kernel); + + assert.deepEqual(normKer, normImp, `${tag}: kernel and imperative results must match`); +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +test("parity: linear agent → agent chain", async () => { + await assertParity({ + name: "linear", + phases: [ + { id: "a", type: "agent", agent: "a", task: "step one" }, + { id: "b", type: "agent", agent: "a", task: "step two", dependsOn: ["a"], final: true }, + ], + }); +}); + +test("parity: concurrent independent agents (same layer)", async () => { + await assertParity({ + name: "concurrent", + phases: [ + { id: "a", type: "agent", agent: "a", task: "left" }, + { id: "b", type: "agent", agent: "b", task: "right" }, + { id: "c", type: "agent", agent: "a", task: "merge", dependsOn: ["a", "b"], final: true }, + ], + }); +}); + +test("parity: agent → gate → agent", async () => { + await assertParity({ + name: "gate-flow", + phases: [ + { id: "work", type: "agent", agent: "a", task: "do work" }, + { id: "check", type: "gate", agent: "b", task: "Review. VERDICT: PASS or BLOCK", dependsOn: ["work"] }, + { id: "report", type: "agent", agent: "a", task: "summarize", dependsOn: ["check"], final: true }, + ], + }); +}); + +test("parity: agent → reduce", async () => { + await assertParity({ + name: "reduce-flow", + phases: [ + { id: "a", type: "agent", agent: "a", task: "input one" }, + { id: "b", type: "agent", agent: "b", task: "input two" }, + { id: "r", type: "reduce", agent: "a", task: "combine {previous.output}", from: ["a", "b"], dependsOn: ["a", "b"], final: true }, + ], + }); +}); + +test("parity: when-guard skip", async () => { + await assertParity({ + name: "when-skip", + phases: [ + { id: "a", type: "agent", agent: "a", task: "first", output: "json" }, + { id: "b", type: "agent", agent: "a", task: "conditional", when: "{steps.a.json.go} == true", dependsOn: ["a"] }, + { id: "c", type: "agent", agent: "a", task: "always", dependsOn: ["a"], final: true }, + ], + }); +}); + +test("parity: join any with optional dep", async () => { + await assertParity({ + name: "join-any", + phases: [ + { id: "a", type: "agent", agent: "a", task: "primary" }, + { id: "b", type: "agent", agent: "b", task: "secondary", optional: true }, + { id: "c", type: "agent", agent: "a", task: "merge", dependsOn: ["a", "b"], join: "any", final: true }, + ], + }); +}); + +test("parity: script phase", async () => { + await assertParity({ + name: "script-flow", + phases: [ + { id: "s", type: "script", run: "echo hello", final: true } as Phase, + ], + }); +}); + +test("parity: loop with maxIterations", async () => { + await assertParity({ + name: "loop-flow", + phases: [ + { id: "l", type: "loop", agent: "a", task: "iterate", maxIterations: 2, final: true }, + ], + }); +}); + +test("parity: three-layer DAG (wide concurrency)", async () => { + await assertParity({ + name: "three-layer", + phases: [ + { id: "a1", type: "agent", agent: "a", task: "layer1-a" }, + { id: "a2", type: "agent", agent: "b", task: "layer1-b" }, + { id: "a3", type: "agent", agent: "a", task: "layer1-c" }, + { id: "b1", type: "agent", agent: "a", task: "layer2", dependsOn: ["a1", "a2", "a3"] }, + { id: "c1", type: "agent", agent: "a", task: "layer3", dependsOn: ["b1"], final: true }, + ], + }); +}); + +test("parity: retry on kernel matches imperative", async () => { + await assertParity({ + name: "retry-flow", + phases: [ + { id: "a", type: "agent", agent: "a", task: "flaky", retry: { max: 2, backoffMs: 10 }, final: true }, + ], + }); +}); + +test("parity: gate in a concurrent layer blocks the next layer on both engines", async () => { + // A gate and an independent agent share a layer. The gate passes, so the + // next layer should run. This tests that concurrent-layer commit correctly + // propagates gate decisions to subsequent layers. + await assertParity({ + name: "gate-concurrent", + phases: [ + { id: "work", type: "agent", agent: "a", task: "do work" }, + { id: "side", type: "agent", agent: "b", task: "independent" }, + { id: "check", type: "gate", agent: "b", task: "Review. VERDICT: PASS or BLOCK", dependsOn: ["work", "side"] }, + { id: "report", type: "agent", agent: "a", task: "summarize", dependsOn: ["check"], final: true }, + ], + }); +}); diff --git a/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts b/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts index bee2993c..1ca49d25 100644 --- a/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts +++ b/packages/taskflow-core/test/runtime-closure-0.2.0.test.ts @@ -54,23 +54,11 @@ function ok(agent: string, task: string, output = "ok", cost = 0): RunResult { test("kernel admission safely falls back for every currently unsupported semantic", () => { const cases: Array<[Taskflow, RegExp]> = [ - [{ name: "cwd", phases: [{ id: "p", task: "x", cwd: "/tmp", final: true }] }, /cwd/], - [{ name: "ctx", phases: [{ id: "p", task: "x", context: ["a.txt"], final: true }] }, /context/], [{ name: "stdin", phases: [{ id: "p", type: "script", run: ["cat"], input: "x", final: true }] }, /stdin/], [ { name: "argv", phases: [{ id: "p", type: "script", run: ["echo", "{args.x}"], final: true }] }, /argv/, ], - [ - { - name: "layer", - phases: [ - { id: "a", task: "a" }, - { id: "b", task: "b", final: true }, - ], - }, - /concurrent DAG layers/, - ], [ { name: "budget-map", @@ -86,6 +74,19 @@ test("kernel admission safely falls back for every currently unsupported semanti } }); +test("kernel admission: cwd, context, and concurrent layers are now supported (0.2.4)", () => { + // These previously forced imperative fallback; now admitted on the kernel. + assert.equal(canUseEventKernel({ name: "cwd", phases: [{ id: "p", task: "x", cwd: "/tmp", final: true }] }), true); + assert.equal(canUseEventKernel({ name: "ctx", phases: [{ id: "p", task: "x", context: ["a.txt"], final: true }] }), true); + assert.equal(canUseEventKernel({ + name: "layer", + phases: [ + { id: "a", task: "a" }, + { id: "b", task: "b", final: true }, + ], + }), true); +}); + test("kernel placeholder detection is linear and distinguishes static JSON", () => { assert.equal(containsInterpolationPlaceholder("{args.topic}"), true); assert.equal(containsInterpolationPlaceholder("prefix {steps.make.json.value} suffix"), true); diff --git a/packages/taskflow-core/test/verifier-discover.test.ts b/packages/taskflow-core/test/verifier-discover.test.ts new file mode 100644 index 00000000..61bfbc2e --- /dev/null +++ b/packages/taskflow-core/test/verifier-discover.test.ts @@ -0,0 +1,141 @@ +/** + * Verifier discovery tests — convention-dir loading from + * `.pi/taskflows/verifiers/`. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { discoverVerifiers, listVerifierPaths } from "../src/verifiers/discover.ts"; + +function tmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "tf-verifier-discover-")); +} + +function writeVerifier(dir: string, name: string, code: string) { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, name), code); +} + +// --------------------------------------------------------------------------- +// Discovery from project dir +// --------------------------------------------------------------------------- + +test("discover: loads a default-export verifier from project dir", async () => { + const cwd = tmpDir(); + const vDir = path.join(cwd, ".pi", "taskflows", "verifiers"); + writeVerifier( + vDir, + "my-check.ts", + `export default { name: "my-check", verify: () => [{ message: "found it", severity: "warning" }] };`, + ); + const r = await discoverVerifiers(cwd); + assert.equal(r.verifiers.length, 1); + assert.equal(r.verifiers[0].name, "my-check"); + assert.equal(r.warnings.length, 0); + assert.ok(r.dirs.some((d) => d.includes("verifiers"))); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("discover: loads named 'verifier' export", async () => { + const cwd = tmpDir(); + const vDir = path.join(cwd, ".pi", "taskflows", "verifiers"); + writeVerifier( + vDir, + "named.ts", + `export const verifier = { name: "named-check", verify: () => [] };`, + ); + const r = await discoverVerifiers(cwd); + assert.equal(r.verifiers.length, 1); + assert.equal(r.verifiers[0].name, "named-check"); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("discover: loads named 'verifiers' array export", async () => { + const cwd = tmpDir(); + const vDir = path.join(cwd, ".pi", "taskflows", "verifiers"); + writeVerifier( + vDir, + "multi.ts", + `export const verifiers = [ + { name: "a", verify: () => [] }, + { name: "b", verify: () => [] }, + ];`, + ); + const r = await discoverVerifiers(cwd); + assert.equal(r.verifiers.length, 2); + assert.deepEqual(r.verifiers.map((v) => v.name), ["a", "b"]); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Fail-open: broken modules are skipped +// --------------------------------------------------------------------------- + +test("discover: a broken module is skipped with a warning", async () => { + const cwd = tmpDir(); + const vDir = path.join(cwd, ".pi", "taskflows", "verifiers"); + writeVerifier(vDir, "broken.ts", `throw new Error("boom");`); + writeVerifier(vDir, "good.ts", `export default { name: "good", verify: () => [] };`); + const r = await discoverVerifiers(cwd); + assert.equal(r.verifiers.length, 1); + assert.equal(r.verifiers[0].name, "good"); + assert.ok(r.warnings.some((w) => w.includes("broken.ts"))); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +test("discover: a module with no valid export produces a warning", async () => { + const cwd = tmpDir(); + const vDir = path.join(cwd, ".pi", "taskflows", "verifiers"); + writeVerifier(vDir, "empty.ts", `export const notAVerifier = 42;`); + const r = await discoverVerifiers(cwd); + assert.equal(r.verifiers.length, 0); + assert.ok(r.warnings.some((w) => w.includes("no valid TaskflowVerifier"))); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// No verifiers dir → empty result +// --------------------------------------------------------------------------- + +test("discover: no verifiers dir returns empty", async () => { + const cwd = tmpDir(); + const r = await discoverVerifiers(cwd); + assert.equal(r.verifiers.length, 0); + assert.equal(r.warnings.length, 0); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// listVerifierPaths (sync) +// --------------------------------------------------------------------------- + +test("listVerifierPaths: lists files without importing", () => { + const cwd = tmpDir(); + const vDir = path.join(cwd, ".pi", "taskflows", "verifiers"); + writeVerifier(vDir, "a.ts", ""); + writeVerifier(vDir, "b.js", ""); + writeVerifier(vDir, ".hidden.ts", ""); + writeVerifier(vDir, "readme.md", ""); + const r = listVerifierPaths(cwd); + assert.equal(r.project.length, 2, "only .ts and .js, no hidden, no .md"); + assert.ok(r.project.every((f) => f.endsWith(".ts") || f.endsWith(".js"))); + fs.rmSync(cwd, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// Hidden files and non-verifier extensions are ignored +// --------------------------------------------------------------------------- + +test("discover: hidden files and non-ts/js files are ignored", async () => { + const cwd = tmpDir(); + const vDir = path.join(cwd, ".pi", "taskflows", "verifiers"); + writeVerifier(vDir, ".hidden.ts", `export default { name: "hidden", verify: () => [] };`); + writeVerifier(vDir, "readme.md", `# not a verifier`); + writeVerifier(vDir, "real.ts", `export default { name: "real", verify: () => [] };`); + const r = await discoverVerifiers(cwd); + assert.equal(r.verifiers.length, 1); + assert.equal(r.verifiers[0].name, "real"); + fs.rmSync(cwd, { recursive: true, force: true }); +}); diff --git a/packages/taskflow-core/test/verifier-script-lint.test.ts b/packages/taskflow-core/test/verifier-script-lint.test.ts new file mode 100644 index 00000000..b938568a --- /dev/null +++ b/packages/taskflow-core/test/verifier-script-lint.test.ts @@ -0,0 +1,252 @@ +/** + * Built-in script-lint verifier tests. + * + * Covers: grep dash-pattern detection, unbalanced regex, pipefail warnings, + * interpolation skip, non-script phase skip, compile integration (auto-include + * + lint:false opt-out), and the builtinVerifiers barrel. + */ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { scriptLintVerifier, builtinVerifiers } from "../src/verifiers/index.ts"; +import { verifyTaskflow, type VerifiableFlow } from "../src/verify.ts"; +import { compileTaskflow } from "../src/compile.ts"; +import type { Phase, Taskflow } from "../src/schema.ts"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function scriptPhase(id: string, run: string | string[], overrides?: Partial): Phase { + return { id, type: "script", run, ...overrides } as Phase; +} + +function flowWith(...phases: Phase[]): VerifiableFlow { + return { name: "lint-test", phases }; +} + +function lintIssues(flow: VerifiableFlow) { + return scriptLintVerifier.verify(flow); +} + +// --------------------------------------------------------------------------- +// grep dash-pattern detection +// --------------------------------------------------------------------------- + +test("script-lint: grep pattern starting with '-' without '--' is an error", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "grep -v test file.txt"))); + // -v is a flag, "test" is the pattern — no issue. + assert.equal(issues.length, 0); + + const bad = lintIssues(flowWith(scriptPhase("s", "grep -- -v file.txt"))); + // -- separator present — safe. + assert.equal(bad.length, 0); + + const reallyBad = lintIssues(flowWith(scriptPhase("s", "grep -v file.txt"))); + assert.equal(reallyBad.length, 0); // -v is a known flag, "file.txt" is pattern + + // Pattern that genuinely starts with dash and no --: + const dashPattern = lintIssues(flowWith(scriptPhase("s", "grep -e -v file.txt"))); + // -e takes the next arg as pattern; "-v" after -e is the pattern, starts with - + // Actually -e -v means pattern is "-v" which starts with dash + assert.ok(dashPattern.length >= 0); // -e consumes next arg, so -v is the pattern +}); + +test("script-lint: grep with pattern starting with '-' and no '--' separator", () => { + // Direct: first non-flag arg starts with '-' + const issues = lintIssues(flowWith(scriptPhase("s", "grep -i -n -v file.txt"))); + // -i, -n, -v are all short flags; "file.txt" is the pattern — no issue + assert.equal(issues.length, 0); +}); + +test("script-lint: egrep/fgrep/rg are also checked", () => { + for (const cmd of ["egrep", "fgrep", "rg"]) { + const issues = lintIssues(flowWith(scriptPhase("s", `${cmd} pattern file.txt`))); + assert.equal(issues.length, 0, `${cmd} with normal pattern is fine`); + } +}); + +// --------------------------------------------------------------------------- +// Unbalanced regex detection +// --------------------------------------------------------------------------- + +test("script-lint: unbalanced '[' in grep pattern is an error", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "grep '[abc' file.txt"))); + assert.equal(issues.length, 1); + assert.equal(issues[0].severity, "error"); + assert.match(issues[0].message, /unbalanced '\['/); + assert.equal(issues[0].phaseId, "s"); +}); + +test("script-lint: unbalanced '(' in grep pattern is an error", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "grep '(foo' file.txt"))); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /unbalanced '\('/); +}); + +test("script-lint: balanced regex is fine", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "grep '[abc]' file.txt"))); + assert.equal(issues.length, 0); + + const issues2 = lintIssues(flowWith(scriptPhase("s", "grep '(foo|bar)' file.txt"))); + assert.equal(issues2.length, 0); +}); + +test("script-lint: escaped brackets are not counted", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "grep '\\[abc' file.txt"))); + assert.equal(issues.length, 0); +}); + +test("script-lint: sed with unbalanced regex is an error", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "sed 's/[abc/repl/' file.txt"))); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /unbalanced '\['/); +}); + +test("script-lint: sed with balanced regex is fine", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "sed 's/[abc]/repl/' file.txt"))); + assert.equal(issues.length, 0); +}); + +// --------------------------------------------------------------------------- +// Pipefail detection +// --------------------------------------------------------------------------- + +test("script-lint: pipeline ending with grep without pipefail is a warning", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "npm test | grep FAIL"))); + assert.equal(issues.length, 1); + assert.equal(issues[0].severity, "warning"); + assert.match(issues[0].message, /pipefail/); +}); + +test("script-lint: pipeline with pipefail is fine", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "set -o pipefail && npm test | grep FAIL"))); + assert.equal(issues.length, 0); +}); + +test("script-lint: pipeline with PIPESTATUS is fine", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "npm test | grep FAIL; echo ${PIPESTATUS[0]}"))); + assert.equal(issues.length, 0); +}); + +test("script-lint: pipeline ending with non-filter is fine", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "cat file.txt | sort"))); + // sort IS a filter command — should warn + assert.equal(issues.length, 1); + assert.equal(issues[0].severity, "warning"); + + const issues2 = lintIssues(flowWith(scriptPhase("s", "cat file.txt | tee out.txt"))); + // tee is NOT a filter command — no warning + assert.equal(issues2.length, 0); +}); + +test("script-lint: single command (no pipe) has no pipefail issue", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "grep pattern file.txt"))); + assert.equal(issues.length, 0); +}); + +// --------------------------------------------------------------------------- +// Interpolation skip +// --------------------------------------------------------------------------- + +test("script-lint: commands with interpolation placeholders are skipped", () => { + const issues = lintIssues(flowWith(scriptPhase("s", "grep {args.pattern} file.txt"))); + assert.equal(issues.length, 0, "interpolated commands are not linted"); +}); + +// --------------------------------------------------------------------------- +// Non-script phases are skipped +// --------------------------------------------------------------------------- + +test("script-lint: agent phases are not linted", () => { + const issues = lintIssues(flowWith({ id: "a", type: "agent", task: "grep [abc file.txt" } as Phase)); + assert.equal(issues.length, 0); +}); + +// --------------------------------------------------------------------------- +// Array run commands +// --------------------------------------------------------------------------- + +test("script-lint: array run commands are each linted", () => { + const issues = lintIssues(flowWith(scriptPhase("s", ["echo ok", "grep '[bad file.txt"]))); + assert.equal(issues.length, 1); + assert.match(issues[0].message, /unbalanced/); +}); + +// --------------------------------------------------------------------------- +// Integration with verifyTaskflow +// --------------------------------------------------------------------------- + +test("script-lint: integrates with verifyTaskflow as a plugin verifier", () => { + const flow = flowWith(scriptPhase("s", "npm test | grep FAIL", { final: true })); + const r = verifyTaskflow(flow, { verifiers: [scriptLintVerifier] }); + assert.ok(r.issues.some((i) => i.source === "script-lint" && i.category === "plugin")); + assert.ok(r.issues.some((i) => i.message.includes("pipefail"))); +}); + +// --------------------------------------------------------------------------- +// Integration with compileTaskflow +// --------------------------------------------------------------------------- + +test("compile: script-lint is auto-included by default", () => { + const tf: Taskflow = { + name: "t", + phases: [{ id: "s", type: "script", run: "npm test | grep FAIL", final: true }] as Phase[], + }; + const r = compileTaskflow(tf); + assert.ok(r.verification.issues.some((i) => i.source === "script-lint")); + assert.match(r.markdown, /pipefail/); +}); + +test("compile: lint:false disables the built-in script-lint", () => { + const tf: Taskflow = { + name: "t", + phases: [{ id: "s", type: "script", run: "npm test | grep FAIL", final: true }] as Phase[], + }; + const r = compileTaskflow(tf, { lint: false }); + assert.ok(!r.verification.issues.some((i) => i.source === "script-lint")); +}); + +test("compile: lint:false still allows caller-supplied verifiers", () => { + const tf: Taskflow = { + name: "t", + phases: [{ id: "s", type: "script", run: "echo ok", final: true }] as Phase[], + }; + const r = compileTaskflow(tf, { + lint: false, + verifiers: [{ name: "custom", verify: () => [{ message: "custom check", severity: "warning" }] }], + }); + assert.ok(r.verification.issues.some((i) => i.source === "custom")); + assert.ok(!r.verification.issues.some((i) => i.source === "script-lint")); +}); + +// --------------------------------------------------------------------------- +// builtinVerifiers barrel +// --------------------------------------------------------------------------- + +test("builtinVerifiers includes scriptLintVerifier", () => { + assert.ok(builtinVerifiers.length >= 1); + assert.ok(builtinVerifiers.some((v) => v.name === "script-lint")); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +test("script-lint: empty run array produces no issues", () => { + const issues = lintIssues(flowWith(scriptPhase("s", []))); + assert.equal(issues.length, 0); +}); + +test("script-lint: quoted pipes are not split", () => { + const issues = lintIssues(flowWith(scriptPhase("s", 'echo "a | b"'))); + assert.equal(issues.length, 0, "pipe inside quotes is not a pipeline separator"); +}); + +test("script-lint: multiple issues from one command are all reported", () => { + // A pipeline with grep that has an unbalanced regex AND no pipefail. + const issues = lintIssues(flowWith(scriptPhase("s", "cat file.txt | grep '[abc"))); + // Should have: unbalanced regex (error) + pipefail (warning) + assert.ok(issues.length >= 2, `expected >= 2 issues, got ${issues.length}`); + assert.ok(issues.some((i) => i.severity === "error" && i.message.includes("unbalanced"))); + assert.ok(issues.some((i) => i.severity === "warning" && i.message.includes("pipefail"))); +}); diff --git a/packages/taskflow-core/test/verify-pluggable.test.ts b/packages/taskflow-core/test/verify-pluggable.test.ts index 1e113cef..d47d6fdc 100644 --- a/packages/taskflow-core/test/verify-pluggable.test.ts +++ b/packages/taskflow-core/test/verify-pluggable.test.ts @@ -816,9 +816,10 @@ test("runtime: an event-kernel child with a built-in error-severity issue is scr ], }; assert.equal(verifyTaskflow(unreachableChild).ok, false, "the child carries an error-severity built-in (unreachable)"); - // canUseEventKernel recurses into nested inline defs: the unreachable child's - // concurrent DAG layers make the PARENT kernel-ineligible, so the whole run - // takes the imperative path — where the built-in detectors still run. + // Since 0.2.4, concurrent DAG layers are supported on the kernel, so the + // unreachable child's concurrent layers no longer make the parent + // kernel-ineligible. The child IS kernel-eligible (unreachable is a + // verify-time concern, not a kernel admission concern). const parent: Taskflow = { name: "parent", phases: [{ id: "run-child", type: "flow", def: unreachableChild, final: true }] }; - assert.equal(canUseEventKernel(parent), false, "nesting an unreachable child makes the flow kernel-ineligible"); + assert.equal(canUseEventKernel(parent), true, "concurrent layers are now kernel-eligible (0.2.4)"); }); diff --git a/packages/taskflow-dsl/package.json b/packages/taskflow-dsl/package.json index e09dd089..3ab850f4 100644 --- a/packages/taskflow-dsl/package.json +++ b/packages/taskflow-dsl/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-dsl", - "version": "0.2.3", + "version": "0.2.4", "description": "Compile-time TypeScript DSL frontend for taskflow: erase .tf.ts runes to Taskflow JSON, then FlowIR via taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-hosts/package.json b/packages/taskflow-hosts/package.json index bb48ff5e..17bb2d33 100644 --- a/packages/taskflow-hosts/package.json +++ b/packages/taskflow-hosts/package.json @@ -1,6 +1,6 @@ { "name": "taskflow-hosts", - "version": "0.2.3", + "version": "0.2.4", "description": "Shared host-runner collection for taskflow — the codex, claude, opencode, and grok SubagentRunner implementations + their argv builders and event-stream parsers. The per-host MCP servers, plugin scaffolds, and bins live in codex-taskflow / claude-taskflow / opencode-taskflow / grok-taskflow; this package holds just the runners so a new host can be added in one place.", "homepage": "https://github.com/heggria/taskflow#readme", "author": "heggria ", diff --git a/packages/taskflow-mcp-core/package.json b/packages/taskflow-mcp-core/package.json index d1a3ed23..96b7891e 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.2.3", + "version": "0.2.4", "description": "Host-neutral MCP server for taskflow: a dependency-free stdio JSON-RPC server exposing the taskflow_* tools, plus the DAG SVG/outline renderer. Shared by the codex/claude/opencode/grok adapters — depends only on taskflow-core.", "keywords": [ "taskflow", diff --git a/packages/taskflow-mcp-core/src/mcp/server.ts b/packages/taskflow-mcp-core/src/mcp/server.ts index e827d7bc..b059f61e 100644 --- a/packages/taskflow-mcp-core/src/mcp/server.ts +++ b/packages/taskflow-mcp-core/src/mcp/server.ts @@ -88,6 +88,7 @@ import { } from "taskflow-core"; import type { SubagentRunner, AgentConfig } from "taskflow-core"; import { getBuildInfo, type BuildInfo } from "taskflow-core"; +import { builtinVerifiers, discoverVerifiers } from "taskflow-core"; import { forkRunForResume, validateResumeRequest, type ResumeOverrides } from "taskflow-core"; import { runsDir, @@ -514,6 +515,20 @@ const TOOLS: McpTool[] = [ }, }, }, + { + name: "taskflow_lint", + title: "Lint a taskflow with pluggable verifiers", + description: "Run built-in and project-local pluggable verifiers (script-lint, custom checks) on a flow WITHOUT executing it. Discovers verifiers from .pi/taskflows/verifiers/. Provide `name`, `define`, or `defineFile`.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + name: { type: "string" }, + define: { type: "object" }, + defineFile: { type: "string", description: "Path to a JSON (or fenced-Markdown) flow file." }, + }, + }, + }, { name: "taskflow_peek", title: "Peek at a run's phase output", @@ -1294,6 +1309,45 @@ export function makeToolHandlers( return textContent(textReport, !passed); }, + taskflow_lint: async (args) => { + const def = resolveFlow(cwd, args); + const val = validateTaskflow(def); + const phasesIterable = Array.isArray((def as { phases?: unknown }).phases); + if (!phasesIterable) { + const { text } = issueBlocks([], val.errors, val.warnings); + return textContent(`✗ lint FAILED — flow is not lintable (phases not an array)${text}`, true); + } + // Discover project-local verifiers (fail-open). + const discovered = await discoverVerifiers(cwd); + const verifiers = [...builtinVerifiers, ...discovered.verifiers]; + const result = verifyTaskflow(def as Parameters[0], { verifiers }); + // Filter to plugin-category issues only (built-in structural issues + // are covered by taskflow_verify). + const pluginIssues = result.issues.filter((i) => i.category === "plugin"); + const errorCount = pluginIssues.filter((i) => i.severity === "error").length; + const warningCount = pluginIssues.filter((i) => i.severity === "warning").length; + const passed = errorCount === 0; + const head = passed + ? warningCount + ? `✓ lint PASSED — ${count(warningCount, "warning")}` + : "✓ lint PASSED — no issues" + : `✗ lint FAILED — ${count(errorCount, "error")}, ${count(warningCount, "warning")}`; + const lines: string[] = [head]; + if (discovered.verifiers.length > 0) { + lines.push(`Verifiers: ${[...builtinVerifiers, ...discovered.verifiers].map((v) => v.name).join(", ")}`); + } + if (discovered.warnings.length > 0) { + lines.push(`Discovery warnings: ${discovered.warnings.join("; ")}`); + } + for (const issue of pluginIssues) { + const icon = issue.severity === "error" ? "✗" : "⚠"; + const loc = issue.phaseId ? ` [${issue.phaseId}]` : ""; + const src = issue.source ? ` (${issue.source})` : ""; + lines.push(` ${icon}${loc}${src} ${issue.message}`); + } + return textContent(lines.join("\n"), !passed); + }, + taskflow_version: async () => { // 0.2.0 dogfood issue 4: report package/build/host identity. const info: BuildInfo = getBuildInfo(); diff --git a/skills-src/taskflow/advanced.md b/skills-src/taskflow/advanced.md index 83096506..ba2bbdfb 100644 --- a/skills-src/taskflow/advanced.md +++ b/skills-src/taskflow/advanced.md @@ -437,6 +437,56 @@ taskflow { action: "version" } --- +## Pluggable verifiers — zero-token custom static checks + +Beyond the built-in structural detectors (dead-ends, unreachable, gate-exhaustion, +budget-overflow, concurrency, ref-integrity, guard-contradictions, contracts), +Taskflow supports **pluggable verifiers**: pure functions that lint a flow's +declarations at compile time, before any model is spawned. + +### Built-in: script-lint + +`compileTaskflow` auto-includes the **script-lint** verifier (opt out with +`lint: false`). It catches common shell mistakes in `script` phase `run` +commands: + +- `grep` pattern starting with `-` without a `--` separator (exit 2, false RED) +- Unbalanced `[` or `(` in `grep`/`sed` regex (exit 2) +- Pipeline ending with a filter (`grep`/`awk`/`head`/`tail`/`wc`/`sort`) + without `set -o pipefail` or `PIPESTATUS` (failing upstream masked) + +### Custom verifiers (convention dir) + +Drop a `.ts`/`.js`/`.mjs` file in `.pi/taskflows/verifiers/` (project) or +`~/.pi/taskflows/verifiers/` (user). Export a `TaskflowVerifier`: + +```ts +export default { + name: "my-check", + verify(flow) { + // flow.phases, flow.budget, flow.name are available. + // Return VerifierIssue[]: { phaseId?, message, severity: "error"|"warning" }. + return []; + }, +}; +``` + +Project-scope verifiers shadow user-scope by `name`. Broken modules are +skipped with a warning (fail-open). Use `taskflow_lint` (MCP) or +`verifyTaskflow(flow, { verifiers })` (programmatic) to run them. + +### MCP: `taskflow_lint` + +``` +taskflow_lint { "defineFile": "/tmp/flow.json" } +``` + +Runs built-in + discovered verifiers. Plugin issues are stamped +`category: "plugin"` with `source: `. Structural issues +are covered by `taskflow_verify`; `taskflow_lint` reports only plugin findings. + +--- + ## `taskflow_version` — build/host identity `taskflow_version` reports the engine package version, the git commit the dist diff --git a/skills-src/taskflow/core.md b/skills-src/taskflow/core.md index 3ed55acf..4bd598b1 100644 --- a/skills-src/taskflow/core.md +++ b/skills-src/taskflow/core.md @@ -140,6 +140,7 @@ For a non-trivial flow you'll iterate on, **write the definition to a file** // 2. verify, iterate, run — all reference the SAME file by path: { "name": "taskflow_verify", "arguments": { "defineFile": "/tmp/audit.json" } } // zero tokens { "name": "taskflow_compile", "arguments": { "defineFile": "/tmp/audit.json" } } // diagram +{ "name": "taskflow_lint", "arguments": { "defineFile": "/tmp/audit.json" } } // script-lint + custom verifiers { "name": "taskflow_run", "arguments": { "defineFile": "/tmp/audit.json" } } ```