pi-taskflow 0.0.23 → 0.0.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  All notable changes to pi-taskflow are documented here. This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format.
4
4
 
5
+ ## [0.0.24] — 2026-06-23
6
+
7
+ > Feature release: **`/tf compile`** — turn the declared DAG into a Mermaid
8
+ > diagram plus a verification overlay for 0 tokens. A picture of the plan, a
9
+ > structural audit of the plan, and a GitHub-pastable artifact — all from the
10
+ > same JSON.
11
+
12
+ ### Added
13
+ - **`compile` action** for the `taskflow` tool and the `/tf compile <name>`
14
+ command. Renders the flow as a Mermaid `flowchart`, overlays verification
15
+ issues onto the nodes (red = error, amber = warning, green border = final),
16
+ and emits a markdown document suitable for READMEs / issues / PRs.
17
+ - Distinct shapes for every phase kind: agent ▭, parallel/map/flow ⊐, reduce ▽,
18
+ gate ◇, approval ⏸, loop ↻, tournament ⬡. Guards become edge labels;
19
+ `join: "any"` becomes dotted edges.
20
+ - Reuses the existing `verifyTaskflow` graph analysis, so every dead-end,
21
+ unreachable node, gate-exhaustion, budget overflow, concurrency warning, and
22
+ guard contradiction is painted directly on the diagram.
23
+ - Zero runtime dependencies; the compiler is a pure function with no LLM calls.
24
+ - Tests: 670 → 702 (+32) in `test/compile.test.ts` — structural assertions on
25
+ the emitted Mermaid tokens (no third-party parser dependency; render-
26
+ correctness is validated by shape/edge/class assertions).
27
+
28
+ ### Fixed
29
+ - **Id collisions no longer merge nodes.** Two distinct phase ids that
30
+ sanitize to the same Mermaid token (e.g. `audit-each` and `audit_each`) are
31
+ now disambiguated with a `_2` suffix instead of collapsing into one node with
32
+ an accidental self-loop.
33
+ - **Markdown-injection hardening.** Free-form strings (flow name, description,
34
+ verification messages) are neutralized before interpolation, so a
35
+ multi-line / bracket-laden name can no longer break out of the H1 heading or
36
+ spawn a second blockquote.
37
+ - **`/tf compile <name>` now schema-validates first**, matching the tool action
38
+ — a malformed saved flow yields a clean error instead of a half-rendered
39
+ diagram. An optional `lr`/`td` suffix selects diagram direction.
40
+ - Backslashes are now escaped inside Mermaid labels.
41
+
5
42
  ## [0.0.23] — 2026-06-11
6
43
 
7
44
  > Feature release: the **Shared Context Tree** — an opt-in mechanism that gives
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-43D9AD?style=flat-square" alt="MIT license"></a>
9
9
  <a href="#whats-inside"><img src="https://img.shields.io/badge/runtime%20deps-0-43D9AD?style=flat-square" alt="zero runtime dependencies"></a>
10
10
  <a href="https://github.com/heggria/pi-taskflow/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/heggria/pi-taskflow/ci.yml?branch=main&style=flat-square&label=CI" alt="CI status"></a>
11
- <a href="#whats-inside"><img src="https://img.shields.io/badge/tests-670-6E8BFF?style=flat-square" alt="670 tests"></a>
11
+ <a href="#whats-inside"><img src="https://img.shields.io/badge/tests-702-6E8BFF?style=flat-square" alt="702 tests"></a>
12
12
  <a href="#whats-inside"><img src="https://img.shields.io/badge/dogfooded-%E2%9C%93-43D9AD?style=flat-square" alt="dogfooded"></a>
13
13
  <a href="https://pi.dev"><img src="https://img.shields.io/badge/for-Pi%20coding%20agent-B692FF?style=flat-square" alt="for the Pi coding agent"></a>
14
14
  </p>
@@ -508,12 +508,13 @@ Saved flows become CLI shortcuts. All commands run in the Pi session:
508
508
  | `/tf list` | List all saved flows |
509
509
  | `/tf run <name> [args]` | Run a saved flow (e.g. `/tf run summarize-files dir=src`) |
510
510
  | `/tf show <name>` | Print a flow's definition |
511
+ | `/tf compile <name> [lr\|td]` | **Render the flow as a Mermaid diagram + verification overlay** — 0 tokens, no LLM; paste into a README/issue/PR |
511
512
  | `/tf runs` | Browse recent run history (interactive TUI — **live auto-refreshes** while any run is active) |
512
513
  | `/tf resume <runId>` | Continue a paused/failed run — cached phases skip automatically |
513
514
  | `/tf init` | **Interactively map model roles** to your enabled models (writes `~/.pi/agent/settings.json`) |
514
515
  | `/tf:<name> [args]` | Shortcut — runs the flow in one tap |
515
516
 
516
- Tool actions (used by the model): `run` (inline `define` or saved `name`), `save`, `resume`, `list`, `agents`, `init`, `verify`, `cache-clear`.
517
+ Tool actions (used by the model): `run` (inline `define` or saved `name`), `save`, `resume`, `list`, `agents`, `init`, `verify`, `compile`, `cache-clear`.
517
518
 
518
519
  ## Background (detached) execution
519
520
 
@@ -727,12 +728,12 @@ Copy one into `.pi/taskflows/<name>.json` (or `~/.pi/agent/taskflows/`) and it r
727
728
 
728
729
  <div align="center">
729
730
 
730
- **0 runtime dependencies** · **670 tests** · **9 phase types** · **shared context tree** · **cross-session resume** · **cross-run memoization** · **detached execution** · **~9k LOC runtime**
731
+ **0 runtime dependencies** · **702 tests** · **9 phase types** · **shared context tree** · **cross-session resume** · **cross-run memoization** · **detached execution** · **`compile` Mermaid renderer** · **~9k LOC runtime**
731
732
 
732
733
  </div>
733
734
 
734
735
  - **Zero runtime dependencies.** No `dependencies` field — the runtime is built entirely on Node built-ins (`fs` / `path` / `os` / `child_process` / `crypto`). The file lock is `fs.openSync("wx")`, not a third-party library.
735
- - **670 tests across 33 test files** covering concurrency, atomic file locking (8-process race regressions), path-traversal hardening, cross-session resume, cross-run cache freshness (flow/thinking/tools key isolation, fingerprint invalidation, TTL/LRU eviction), gate verdicts, budget caps, retry/backoff, approval flows, loop termination, tournament judging, sub-flow composition, the shared context tree (blackboard reuse, supervision spawn, subflow validation/nesting), workspace isolation (temp/dedicated/worktree lifecycle, fail-open degrade, dynamic-flow rejection), dynamic sub-flow security hardening, detached execution (PID persistence, stale detection, crash→failed, resume after failure), live run-history refresh, callback isolation, the idle watchdog, model-role init config, parseModelFromLabel with parenthesized-model-name regression, and multi-fence `safeParse` recovery.
736
+ - **702 tests across 34 test files** covering concurrency, atomic file locking (8-process race regressions), path-traversal hardening, cross-session resume, cross-run cache freshness (flow/thinking/tools key isolation, fingerprint invalidation, TTL/LRU eviction), gate verdicts, budget caps, retry/backoff, approval flows, loop termination, tournament judging, sub-flow composition, the shared context tree (blackboard reuse, supervision spawn, subflow validation/nesting), workspace isolation (temp/dedicated/worktree lifecycle, fail-open degrade, dynamic-flow rejection), dynamic sub-flow security hardening, detached execution (PID persistence, stale detection, crash→failed, resume after failure), live run-history refresh, callback isolation, the idle watchdog, model-role init config, parseModelFromLabel with parenthesized-model-name regression, and multi-fence `safeParse` recovery, plus the `compile` Mermaid renderer (id-collision disambiguation, markdown-injection hardening, and full verify-overlay category coverage).
736
737
  - **Hardened by design.** Path-traversal defense (lexical + `realpath` containment check), runId validation, HTML/error sanitization, atomic writes, stale-lock stealing via `rename`, and an idle watchdog that kills wedged subagents (SIGTERM → SIGKILL after 5 minutes of silence). Dynamic sub-flows additionally get breadth caps, `cwd` containment, budget clamping, nesting depth caps, and prototype-pollution defense.
737
738
  - **Dogfooded.** Every new feature has to survive the project's own `self-improve` taskflow before it ships.
738
739
 
package/README.zh-CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
  <a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-43D9AD?style=flat-square" alt="MIT license"></a>
9
9
  <a href="#whats-inside"><img src="https://img.shields.io/badge/runtime%20deps-0-43D9AD?style=flat-square" alt="zero runtime dependencies"></a>
10
10
  <a href="https://github.com/heggria/pi-taskflow/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/heggria/pi-taskflow/ci.yml?branch=main&style=flat-square&label=CI" alt="CI status"></a>
11
- <a href="#whats-inside"><img src="https://img.shields.io/badge/tests-535-6E8BFF?style=flat-square" alt="535 tests"></a>
11
+ <a href="#whats-inside"><img src="https://img.shields.io/badge/tests-702-6E8BFF?style=flat-square" alt="702 tests"></a>
12
12
  <a href="#whats-inside"><img src="https://img.shields.io/badge/dogfooded-%E2%9C%93-43D9AD?style=flat-square" alt="dogfooded"></a>
13
13
  <a href="https://pi.dev"><img src="https://img.shields.io/badge/for-Pi%20coding%20agent-B692FF?style=flat-square" alt="for the Pi coding agent"></a>
14
14
  </p>
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Compile a taskflow DAG to portable artifacts — zero-token, no LLM.
3
+ *
4
+ * `compileTaskflow` turns the declarative graph into:
5
+ * - a **Mermaid `flowchart`** that GitHub / GitLab / many markdown viewers
6
+ * render natively, so the DAG you declared can be screenshotted, pasted
7
+ * into a PR/issue/README, and diffed as text;
8
+ * - a **verification report** (the existing `verifyTaskflow` passes) whose
9
+ * issues are *overlaid onto the diagram* — a phase with an error is painted
10
+ * red, a warning amber — so the picture and the problems are one artifact.
11
+ *
12
+ * This is the visualization leg of the project thesis ("the plan is data, so it
13
+ * can be verified, visualized, and replayed"): the same JSON renders a graph and
14
+ * a structural audit without spending a token. It is a pure function — no I/O.
15
+ */
16
+
17
+ import type { Phase, Taskflow } from "./schema.ts";
18
+ import {
19
+ LOOP_DEFAULT_MAX_ITERATIONS,
20
+ TOURNAMENT_DEFAULT_VARIANTS,
21
+ } from "./schema.ts";
22
+ import { verifyTaskflow, type VerificationIssue, type VerificationResult } from "./verify.ts";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Types
26
+ // ---------------------------------------------------------------------------
27
+
28
+ export interface CompileResult {
29
+ /** A Mermaid `flowchart` source block (no fences). */
30
+ mermaid: string;
31
+ /** The static verification result the diagram is annotated with. */
32
+ verification: VerificationResult;
33
+ /** A self-contained markdown document: diagram (fenced) + verification report. */
34
+ markdown: string;
35
+ }
36
+
37
+ export interface CompileOptions {
38
+ /** Diagram direction. Default "TD" (top-down). "LR" for wide fan-outs. */
39
+ direction?: "TD" | "LR";
40
+ /** Document title (defaults to the flow name). */
41
+ title?: string;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Label / id sanitization
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /** Mermaid node ids must be free of spaces and syntax chars. Phase ids are
49
+ * already `[A-Za-z0-9_-]`-ish, but we defensively map anything else to `_`. */
50
+ function nodeId(phaseId: string): string {
51
+ const cleaned = phaseId.replace(/[^A-Za-z0-9_]/g, "_");
52
+ // A leading digit is legal in Mermaid ids, but prefix to avoid edge-case
53
+ // parsers and keep ids stable/unique.
54
+ return /^[A-Za-z_]/.test(cleaned) ? cleaned : `p_${cleaned}`;
55
+ }
56
+
57
+ /** Build a stable, collision-free mapping from raw phase ids to Mermaid node
58
+ * ids. Two distinct raw ids can sanitize to the same node id (e.g.
59
+ * `audit-each` and `audit_each` both collapse to `audit_each`); we
60
+ * disambiguate by appending `_<n>` so every phase renders as its own node and
61
+ * edges never form accidental self-loops. The map covers every phase id plus
62
+ * every id referenced in `dependsOn`, so an edge resolves to the same node as
63
+ * the definition. Deterministic — input order is preserved. */
64
+ function buildNodeIds(phases: Phase[]): Map<string, string> {
65
+ const idMap = new Map<string, string>();
66
+ const used = new Set<string>();
67
+ const ordered: string[] = [];
68
+ const seen = new Set<string>();
69
+ for (const p of phases) {
70
+ if (seen.has(p.id)) continue;
71
+ seen.add(p.id);
72
+ ordered.push(p.id);
73
+ }
74
+ for (const p of phases) {
75
+ for (const d of p.dependsOn ?? []) {
76
+ if (!seen.has(d)) {
77
+ seen.add(d);
78
+ ordered.push(d);
79
+ }
80
+ }
81
+ }
82
+ for (const raw of ordered) {
83
+ const base = nodeId(raw);
84
+ let safe = base;
85
+ let n = 2;
86
+ while (used.has(safe)) {
87
+ safe = `${base}_${n}`;
88
+ n++;
89
+ }
90
+ used.add(safe);
91
+ idMap.set(raw, safe);
92
+ }
93
+ return idMap;
94
+ }
95
+
96
+ /** Escape text for use inside a Mermaid double-quoted label. Mermaid uses HTML
97
+ * entities inside quoted strings; quotes and angle brackets must be encoded,
98
+ * and newlines become `<br/>`. */
99
+ function label(text: string): string {
100
+ return text
101
+ .replace(/&/g, "&amp;")
102
+ .replace(/"/g, "&quot;")
103
+ .replace(/</g, "&lt;")
104
+ .replace(/>/g, "&gt;")
105
+ .replace(/\\/g, "&#92;")
106
+ .replace(/\r?\n/g, "<br/>");
107
+ }
108
+
109
+ /** Truncate a task prompt to a single readable line for the node body. */
110
+ function summarize(text: string | undefined, max = 48): string {
111
+ if (!text) return "";
112
+ const firstLine = text.replace(/\s+/g, " ").trim();
113
+ return firstLine.length > max ? `${firstLine.slice(0, max - 1)}…` : firstLine;
114
+ }
115
+
116
+ /** Escape a free-form string for use as inline markdown text (titles,
117
+ * descriptions, report fields). Collapses whitespace to a single space so a
118
+ * multi-line name/description can't break out of a heading or blockquote, and
119
+ * neutralizes characters that start markdown constructs: backticks (code
120
+ * spans), brackets (links/images), angle brackets (raw HTML), and backslashes
121
+ * (escape sequences). */
122
+ function mdInline(text: string | undefined): string {
123
+ if (!text) return "";
124
+ return text
125
+ .replace(/\s+/g, " ")
126
+ .trim()
127
+ .replace(/\\/g, "\\\\")
128
+ .replace(/`/g, "\\`")
129
+ .replace(/\[/g, "\\[")
130
+ .replace(/\]/g, "\\]")
131
+ .replace(/</g, "&lt;")
132
+ .replace(/>/g, "&gt;");
133
+ }
134
+
135
+ // ---------------------------------------------------------------------------
136
+ // Per-type node rendering
137
+ // ---------------------------------------------------------------------------
138
+
139
+ /** A short type tag shown above the task summary so the shape is unambiguous
140
+ * even in monochrome renders. */
141
+ function typeTag(p: Phase): string {
142
+ switch (p.type) {
143
+ case "parallel":
144
+ return `▦ parallel ×${p.branches?.length ?? 0}`;
145
+ case "map":
146
+ return `⇉ map over ${p.over ?? "?"}`;
147
+ case "gate":
148
+ return p.eval?.length ? `◇ gate (+${p.eval.length} eval)` : "◇ gate";
149
+ case "reduce":
150
+ return `▽ reduce ←${p.from?.length ?? 0}`;
151
+ case "approval":
152
+ return "⏸ approval";
153
+ case "flow":
154
+ return p.use ? `⊞ flow: ${p.use}` : "⊞ flow (inline)";
155
+ case "loop":
156
+ return `↻ loop ≤${p.maxIterations ?? LOOP_DEFAULT_MAX_ITERATIONS}`;
157
+ case "tournament":
158
+ return `🏆 tournament ×${p.variants ?? (p.branches?.length || TOURNAMENT_DEFAULT_VARIANTS)}`;
159
+ default:
160
+ return "▸ agent";
161
+ }
162
+ }
163
+
164
+ /** The body text inside a node: id, type tag, a task summary, agent. */
165
+ function nodeBody(p: Phase): string {
166
+ const lines: string[] = [];
167
+ lines.push(`<b>${label(p.id)}</b>${p.final ? " ★" : ""}`);
168
+ lines.push(label(typeTag(p)));
169
+ const summary =
170
+ p.type === "reduce" || p.type === "parallel"
171
+ ? summarize(p.task) // may be empty for these
172
+ : summarize(p.task ?? p.judge);
173
+ if (summary) lines.push(label(summary));
174
+ if (p.agent) lines.push(label(`@${p.agent}`));
175
+ return lines.join("<br/>");
176
+ }
177
+
178
+ /** Wrap the body in the Mermaid shape that matches the phase kind. Distinct
179
+ * shapes make the control-flow role readable at a glance:
180
+ * - agent → rectangle
181
+ * - parallel → subroutine (parallel fan-out)
182
+ * - map → subroutine (dynamic fan-out)
183
+ * - flow → subroutine (nested DAG)
184
+ * - reduce → trapezoid (many → one)
185
+ * - gate → rhombus (decision)
186
+ * - approval → double-circle (human stop)
187
+ * - loop → stadium (cyclic)
188
+ * - tournament → hexagon (compete)
189
+ */
190
+ function nodeShape(p: Phase, idMap: Map<string, string>): string {
191
+ const id = idMap.get(p.id) ?? p.id;
192
+ const body = `"${nodeBody(p)}"`;
193
+ switch (p.type) {
194
+ case "parallel":
195
+ case "map":
196
+ case "flow":
197
+ return `${id}[[${body}]]`;
198
+ case "reduce":
199
+ return `${id}[/${body}\\]`;
200
+ case "gate":
201
+ return `${id}{${body}}`;
202
+ case "approval":
203
+ return `${id}(((${body})))`;
204
+ case "loop":
205
+ return `${id}([${body}])`;
206
+ case "tournament":
207
+ return `${id}{{${body}}}`;
208
+ default:
209
+ return `${id}[${body}]`;
210
+ }
211
+ }
212
+
213
+ // ---------------------------------------------------------------------------
214
+ // Edge rendering
215
+ // ---------------------------------------------------------------------------
216
+
217
+ /** Build the directed edges from `dependsOn`. A `when` guard becomes an edge
218
+ * label; a `join: "any"` dependency is drawn dotted (races, not waits-all). */
219
+ function edges(phases: Phase[], idMap: Map<string, string>): string[] {
220
+ const known = new Set(phases.map((p) => p.id));
221
+ const out: string[] = [];
222
+ for (const p of phases) {
223
+ const deps = p.dependsOn ?? [];
224
+ for (const d of deps) {
225
+ if (!known.has(d)) continue; // dangling ref — schema/verify reports it
226
+ const from = idMap.get(d) ?? d;
227
+ const to = idMap.get(p.id) ?? p.id;
228
+ const guard = p.when ? `|"${label(summarize(p.when, 40))}"|` : "";
229
+ // 'any' join: this phase fires on the FIRST dep — draw dotted so the
230
+ // race semantics are visible.
231
+ const arrow = p.join === "any" ? "-.->" : "-->";
232
+ out.push(`${from} ${arrow}${guard} ${to}`);
233
+ }
234
+ }
235
+ return out;
236
+ }
237
+
238
+ // ---------------------------------------------------------------------------
239
+ // Issue overlay
240
+ // ---------------------------------------------------------------------------
241
+
242
+ const CLASS_ERROR = "tfError";
243
+ const CLASS_WARN = "tfWarn";
244
+ const CLASS_FINAL = "tfFinal";
245
+
246
+ /** Map each phase id to its worst severity so the node can be painted. */
247
+ function severityByPhase(issues: VerificationIssue[]): Map<string, "error" | "warning"> {
248
+ const m = new Map<string, "error" | "warning">();
249
+ for (const i of issues) {
250
+ if (!i.phaseId) continue;
251
+ const prev = m.get(i.phaseId);
252
+ if (i.severity === "error" || prev === undefined) m.set(i.phaseId, i.severity);
253
+ }
254
+ return m;
255
+ }
256
+
257
+ // ---------------------------------------------------------------------------
258
+ // Mermaid assembly
259
+ // ---------------------------------------------------------------------------
260
+
261
+ function buildMermaid(flow: Taskflow, verification: VerificationResult, opts: CompileOptions): string {
262
+ const phases = flow.phases ?? [];
263
+ const dir = opts.direction ?? "TD";
264
+ const idMap = buildNodeIds(phases);
265
+ const sev = severityByPhase(verification.issues);
266
+
267
+ const lines: string[] = [];
268
+ lines.push(`flowchart ${dir}`);
269
+
270
+ // Nodes
271
+ for (const p of phases) lines.push(`\t${nodeShape(p, idMap)}`);
272
+
273
+ // Edges
274
+ const e = edges(phases, idMap);
275
+ if (e.length) {
276
+ lines.push("");
277
+ for (const edge of e) lines.push(`\t${edge}`);
278
+ }
279
+
280
+ // Class definitions (issue overlay + final marker). Colors are chosen to
281
+ // read on both light and dark GitHub themes.
282
+ lines.push("");
283
+ lines.push(`\tclassDef ${CLASS_ERROR} fill:#3b0d0d,stroke:#ef4444,stroke-width:2px,color:#fecaca;`);
284
+ lines.push(`\tclassDef ${CLASS_WARN} fill:#3a2e05,stroke:#f59e0b,stroke-width:2px,color:#fde68a;`);
285
+ lines.push(`\tclassDef ${CLASS_FINAL} stroke:#43d9ad,stroke-width:3px;`);
286
+
287
+ // Final phases get a distinct border (unless they already carry an issue,
288
+ // where the issue color wins — a final node that's broken should read red).
289
+ const finals = phases.filter((p) => p.final && !sev.has(p.id)).map((p) => idMap.get(p.id) ?? p.id);
290
+ if (finals.length) lines.push(`\tclass ${finals.join(",")} ${CLASS_FINAL};`);
291
+
292
+ const errNodes = [...sev].filter(([, s]) => s === "error").map(([id]) => idMap.get(id) ?? id);
293
+ const warnNodes = [...sev].filter(([, s]) => s === "warning").map(([id]) => idMap.get(id) ?? id);
294
+ if (errNodes.length) lines.push(`\tclass ${errNodes.join(",")} ${CLASS_ERROR};`);
295
+ if (warnNodes.length) lines.push(`\tclass ${warnNodes.join(",")} ${CLASS_WARN};`);
296
+
297
+ return lines.join("\n");
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Verification report (markdown)
302
+ // ---------------------------------------------------------------------------
303
+
304
+ function buildReport(flow: Taskflow, verification: VerificationResult): string {
305
+ const lines: string[] = [];
306
+ const errors = verification.issues.filter((i) => i.severity === "error");
307
+ const warnings = verification.issues.filter((i) => i.severity === "warning");
308
+ const phaseCount = flow.phases?.length ?? 0;
309
+
310
+ lines.push(`**Phases:** ${phaseCount} · **Errors:** ${errors.length} · **Warnings:** ${warnings.length} · **Status:** ${verification.ok ? "✅ PASS" : "❌ FAIL"}`);
311
+
312
+ if (verification.issues.length === 0) {
313
+ lines.push("");
314
+ lines.push("✅ No structural issues found — the DAG is well-formed (no cycles, dead-ends, gate exhaustion, ref or budget problems).");
315
+ return lines.join("\n");
316
+ }
317
+
318
+ if (errors.length) {
319
+ lines.push("");
320
+ lines.push(`### ❌ Errors (${errors.length})`);
321
+ for (const e of errors)
322
+ lines.push(`- **${e.category}**${e.phaseId ? ` \`${mdInline(e.phaseId)}\`` : ""}: ${mdInline(e.message)}`);
323
+ }
324
+ if (warnings.length) {
325
+ lines.push("");
326
+ lines.push(`### ⚠️ Warnings (${warnings.length})`);
327
+ for (const w of warnings)
328
+ lines.push(`- **${w.category}**${w.phaseId ? ` \`${mdInline(w.phaseId)}\`` : ""}: ${mdInline(w.message)}`);
329
+ }
330
+ return lines.join("\n");
331
+ }
332
+
333
+ // ---------------------------------------------------------------------------
334
+ // Entry point
335
+ // ---------------------------------------------------------------------------
336
+
337
+ /**
338
+ * Compile a (already schema-valid) taskflow into a Mermaid diagram + verification
339
+ * report. Pure function — zero tokens, no LLM, no I/O.
340
+ */
341
+ export function compileTaskflow(flow: Taskflow, opts: CompileOptions = {}): CompileResult {
342
+ const verification = verifyTaskflow({
343
+ name: flow.name ?? "taskflow",
344
+ phases: flow.phases ?? [],
345
+ budget: flow.budget,
346
+ concurrency: flow.concurrency,
347
+ });
348
+
349
+ const mermaid = buildMermaid(flow, verification, opts);
350
+ const report = buildReport(flow, verification);
351
+ const title = opts.title ?? flow.name ?? "taskflow";
352
+
353
+ const mdLines: string[] = [];
354
+ mdLines.push(`# Taskflow: ${mdInline(title)}`);
355
+ mdLines.push("");
356
+ if (flow.description) mdLines.push(`> ${mdInline(flow.description)}`);
357
+ mdLines.push("```mermaid");
358
+ mdLines.push(mermaid);
359
+ mdLines.push("```");
360
+ mdLines.push("");
361
+ mdLines.push("## Verification");
362
+ mdLines.push("");
363
+ mdLines.push(report);
364
+ mdLines.push("");
365
+ mdLines.push(
366
+ "> Legend: ▸ agent · ▦ parallel · ⇉ map · ◇ gate · ▽ reduce · ⏸ approval · ⊞ flow · ↻ loop · 🏆 tournament · ★ final. Red = error, amber = warning, green border = final. Dotted edge = `join:any`. Generated by `pi-taskflow compile` — 0 tokens.",
367
+ );
368
+ const markdown = mdLines.join("\n");
369
+
370
+ return { mermaid, verification, markdown };
371
+ }
@@ -83,8 +83,8 @@ const ShorthandStep = Type.Object(
83
83
  );
84
84
 
85
85
  const TaskflowParams = Type.Object({
86
- action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "cache-clear"] as const, {
87
- description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, or clear the cross-run memoization cache",
86
+ action: StringEnum(["run", "save", "resume", "list", "agents", "init", "verify", "compile", "cache-clear"] as const, {
87
+ description: "What to do: run a flow, save a definition, resume a paused run, list saved flows, list available agents, init model role configuration, verify the DAG, compile the DAG to a Mermaid diagram + verification report, or clear the cross-run memoization cache",
88
88
  default: "run",
89
89
  }),
90
90
  name: Type.Optional(Type.String({ description: "Name of a saved flow (for run/save without inline define)" })),
@@ -402,6 +402,7 @@ export default function (pi: ExtensionAPI) {
402
402
  "Every delegation is tracked (runId), resumable across sessions, and saveable as /tf:<name> via action=save.",
403
403
  "Use action=agents to list the 18 built-in agents (executor, scout, planner, analyst, critic, reviewer, risk-reviewer, security-reviewer, plan-arbiter, final-arbiter, test-engineer, doc-writer, executor-code, executor-fast, executor-ui, recover, verifier, visual-explorer). Do NOT invent agent names.",
404
404
  "Phase types: agent, parallel (static branches), map (dynamic fan-out over array), gate (VERDICT: PASS/BLOCK), reduce (aggregate from N), approval (human-in-the-loop), flow (run saved sub-flow), loop (iterate until condition/convergence/cap), tournament (N variants, judge picks best/aggregate).",
405
+ "Use action=compile to generate a Mermaid diagram + verification report from a saved or inline flow — 0 tokens.",
405
406
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
406
407
  ].join(" "),
407
408
  parameters: TaskflowParams,
@@ -570,6 +571,46 @@ export default function (pi: ExtensionAPI) {
570
571
  return { content: [{ type: "text", text: lines.join("\n") }], details: { action } satisfies TaskflowDetails };
571
572
  }
572
573
 
574
+ if (action === "compile") {
575
+ const { compileTaskflow } = await import("./compile.ts");
576
+ // Resolve definition: inline define (object or JSON/fenced string) then saved name.
577
+ let def: Taskflow | undefined;
578
+ let resolvedDefine: unknown = params.define;
579
+ if (typeof resolvedDefine === "string") {
580
+ const parsed = safeParse(resolvedDefine);
581
+ if (parsed && typeof parsed === "object") resolvedDefine = parsed;
582
+ }
583
+ if (resolvedDefine) {
584
+ const d = resolvedDefine as Record<string, unknown>;
585
+ if (typeof d === "object" && d !== null && Array.isArray(d.phases)) {
586
+ def = d as unknown as Taskflow;
587
+ } else if (isShorthand(resolvedDefine)) {
588
+ try {
589
+ def = desugar(resolvedDefine) as Taskflow;
590
+ } catch (e) {
591
+ return errorResult(action, `Invalid shorthand: ${e instanceof Error ? e.message : String(e)}`);
592
+ }
593
+ }
594
+ } else if (params.name) {
595
+ const saved = getFlow(ctx.cwd, params.name);
596
+ if (saved) def = saved.def;
597
+ }
598
+ if (!def) {
599
+ return errorResult(action, "Provide 'define' (DSL) or 'name' (saved flow) to compile.");
600
+ }
601
+ // Schema validation first so a malformed graph gives a clean error
602
+ // rather than a half-rendered diagram.
603
+ const vr = validateTaskflow(def, { cwd: ctx.cwd ? String(ctx.cwd) : undefined });
604
+ if (!vr.ok) {
605
+ return errorResult(action, `Schema validation failed:\n${vr.errors.join("\n")}`);
606
+ }
607
+ const compiled = compileTaskflow(def);
608
+ return {
609
+ content: [{ type: "text", text: compiled.markdown }],
610
+ details: { action } satisfies TaskflowDetails,
611
+ };
612
+ }
613
+
573
614
  if (action === "cache-clear") {
574
615
  const removed = new CacheStore(ctx.cwd).clear();
575
616
  return {
@@ -779,9 +820,9 @@ export default function (pi: ExtensionAPI) {
779
820
 
780
821
  // ---- The /tf user command ----
781
822
  pi.registerCommand("tf", {
782
- description: "Taskflow: list | run <name> | show <name> | runs | init",
823
+ description: "Taskflow: list | run <name> | show <name> | compile <name> | runs | init",
783
824
  getArgumentCompletions: (prefix) => {
784
- const subs = ["list", "run", "show", "runs", "resume", "init", "save", "verify"];
825
+ const subs = ["list", "run", "show", "runs", "resume", "init", "save", "verify", "compile"];
785
826
  const items = subs.map((s) => ({ value: s, label: s }));
786
827
  const filtered = items.filter((i) => i.value.startsWith(prefix));
787
828
  return filtered.length > 0 ? filtered : null;
@@ -810,6 +851,33 @@ export default function (pi: ExtensionAPI) {
810
851
  return;
811
852
  }
812
853
 
854
+ if (sub === "compile") {
855
+ if (!arg) {
856
+ ctx.ui.notify("Usage: /tf compile <name> [lr|td]", "warning");
857
+ return;
858
+ }
859
+ // `arg` may carry an optional direction suffix: "<name> lr" / "<name> td".
860
+ const parts = arg.trim().split(/\s+/);
861
+ const flowName = parts[0];
862
+ const direction = parts[1]?.toLowerCase() === "lr" ? "LR" : "TD";
863
+ const flow = getFlow(ctx.cwd, flowName);
864
+ if (!flow) {
865
+ ctx.ui.notify(`Flow not found: ${flowName}`, "error");
866
+ return;
867
+ }
868
+ // Schema-validate before compiling so a malformed saved flow yields a
869
+ // clean error rather than a half-rendered diagram (mirrors the tool action).
870
+ const vr = validateTaskflow(flow.def, { cwd: ctx.cwd ? String(ctx.cwd) : undefined });
871
+ if (!vr.ok) {
872
+ ctx.ui.notify(`Schema validation failed:\n${vr.errors.join("\n")}`, "error");
873
+ return;
874
+ }
875
+ const { compileTaskflow } = await import("./compile.ts");
876
+ const compiled = compileTaskflow(flow.def, { direction });
877
+ ctx.ui.notify(compiled.markdown, compiled.verification.ok ? "info" : "warning");
878
+ return;
879
+ }
880
+
813
881
  if (sub === "runs") {
814
882
  const runs = listRuns(ctx.cwd, 50);
815
883
  if (runs.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.23",
3
+ "version": "0.0.24",
4
4
  "description": "A declarative, verifiable graph of task nodes for the Pi coding agent — not a workflow you script, but a DAG you declare: statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -558,7 +558,7 @@ Quick reference:
558
558
  - `action: "run"` — run an inline `define` (a one-off DAG) **or** a saved `name` (with optional `args`). Use `define` for an ad-hoc flow; use `name` to invoke something previously saved. Add `detach: true` to run in the background (returns immediately with the runId; poll the store for status).
559
559
  - `action: "save"` — persist `define` (scope `project` — default, committed/shared — or `user`); it becomes `/tf:<name>`. On a name collision, project overrides user.
560
560
  - `action: "resume"` — continue a paused/failed run by `runId`.
561
- - `action: "list"` — list saved flows. `action: "verify"` — static-check a `define` (zero tokens). `action: "agents"` — list available agents.
561
+ - `action: "list"` — list saved flows. `action: "verify"` — static-check a `define` (zero tokens). `action: "compile"` — render a saved or inline flow as a Mermaid diagram + verification report (zero tokens, no LLM). `action: "agents"` — list available agents.
562
562
 
563
563
  ## Background (detached) runs
564
564
 
@@ -580,5 +580,5 @@ A run moves through: **running →** `completed` (a `final` phase produced outpu
580
580
 
581
581
  ## User commands
582
582
 
583
- - `/tf list` · `/tf run <name> [args]` · `/tf show <name>` · `/tf runs` · `/tf resume <runId>`
583
+ - `/tf list` · `/tf run <name> [args]` · `/tf show <name>` · `/tf compile <name> [lr|td]` · `/tf runs` · `/tf resume <runId>`
584
584
  - `/tf:<name> [args]` — shortcut for each saved flow