pi-taskflow 0.0.23 → 0.0.25

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>
@@ -17,7 +17,7 @@ import { execFileSync } from "node:child_process";
17
17
  import * as crypto from "node:crypto";
18
18
  import * as fs from "node:fs";
19
19
  import * as path from "node:path";
20
- import { cacheDir, withLock, writeFileAtomic } from "./store.ts";
20
+ import { cacheDir, withLock, writeFileAtomic, type PhaseState } from "./store.ts";
21
21
 
22
22
  // ---------------------------------------------------------------------------
23
23
  // Fingerprint resolution
@@ -144,6 +144,11 @@ export interface CacheEntry {
144
144
  output?: string;
145
145
  json?: unknown;
146
146
  model?: string;
147
+ /** Full PhaseState payload preserved so cross-run reuse is semantically
148
+ * equivalent to within-run resume. Storing only output/json would drop
149
+ * `gate`, `approval`, `reads`, `loop`, `tournament`, `warnings`, etc.,
150
+ * breaking recompute soundness and gate-block detection. */
151
+ state?: PhaseState;
147
152
  /** Provenance for audit / cleanup. */
148
153
  flowName?: string;
149
154
  phaseId?: string;
@@ -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
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Content-addressed hashing for flow definitions.
3
+ *
4
+ * The canonical-JSON + SHA-256-truncation algorithm here is **vendored from
5
+ * overstory `packages/core/src/ir/hash.ts`** (pinned commit) so that
6
+ * pi-taskflow and overstory share one byte-identical hashing contract. This is
7
+ * the `M1` slice of the overstory-convergence roadmap: we are *not* compiling
8
+ * to overstory FlowIR yet (the IR compiler expects an explicit inject/emits
9
+ * model pi-taskflow doesn't have), but we share the **hash algorithm** now —
10
+ * the cheapest, lowest-risk piece of the contract — and put it to immediate
11
+ * work folding the flow *definition* into the cross-run cache key (M2).
12
+ *
13
+ * Why this matters: previously the cache key folded only the flow **name**
14
+ * (`flow:${flowName}`), so two structurally-different flows that happened to
15
+ * share a name + phase id + task could collide in the cross-run cache, and a
16
+ * flow that changed structure (but not name) could serve a stale hit. Folding
17
+ * `flowDefHash` (a content fingerprint of the desugared definition) closes
18
+ * that hole and is the foundation of "identical re-run is free ($0.00)".
19
+ *
20
+ * Pure module: no IO. Uses Web Crypto (`globalThis.crypto.subtle`) — therefore
21
+ * async — exactly like overstory's `hashIR`, so the contract is identical.
22
+ *
23
+ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1, "cut B")
24
+ * @see docs/internal/rfc-flowir-compilation.md
25
+ */
26
+
27
+ import type { Taskflow } from "../schema.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Canonical JSON (vendored from overstory ir/hash.ts — byte-identical)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ /**
34
+ * Deterministic JSON: recursively key-sorted (UTF-16 code units), no
35
+ * whitespace, `undefined` values dropped. Arrays keep their order (the
36
+ * desugared Taskflow is already in a canonical shape). Byte-identical to
37
+ * overstory's `canonicalJson` — do not diverge without bumping the contract
38
+ * and updating the parity test.
39
+ */
40
+ export function canonicalJson(value: unknown): string {
41
+ if (value === null || typeof value === "number" || typeof value === "boolean") {
42
+ return JSON.stringify(value);
43
+ }
44
+ if (typeof value === "string") {
45
+ return JSON.stringify(value);
46
+ }
47
+ if (Array.isArray(value)) {
48
+ return `[${value.map((item) => canonicalJson(item === undefined ? null : item)).join(",")}]`;
49
+ }
50
+ if (typeof value === "object") {
51
+ const record = value as Record<string, unknown>;
52
+ const keys = Object.keys(record)
53
+ .filter((key) => record[key] !== undefined)
54
+ .sort();
55
+ const body = keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`);
56
+ return `{${body.join(",")}}`;
57
+ }
58
+ // undefined / function / symbol at the top level — not representable.
59
+ return "null";
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Hashing (vendored from overstory ir/hash.ts — byte-identical)
64
+ // ---------------------------------------------------------------------------
65
+
66
+ /** SHA-256 of the canonical serialization, first 16 bytes, lowercase hex.
67
+ * Same shape as overstory's `hashCanonical` / RFC-001 content hashes. */
68
+ export async function hashCanonical(canonical: string): Promise<string> {
69
+ const bytes = new TextEncoder().encode(canonical);
70
+ const digest = await globalThis.crypto.subtle.digest("SHA-256", bytes);
71
+ const view = new Uint8Array(digest).slice(0, 16);
72
+ let hex = "";
73
+ for (const byte of view) {
74
+ hex += byte.toString(16).padStart(2, "0");
75
+ }
76
+ return hex;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Flow-definition fingerprint
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Content fingerprint of a desugared `Taskflow` definition.
85
+ *
86
+ * Hashes the **definition** (structure + task text + declared deps), NOT the
87
+ * runtime `args` values — args vary per invocation and are already folded into
88
+ * each phase's `inputHash` via the interpolated task. `flowDefHash` answers a
89
+ * different question: "did the flow *itself* change?" Two flows are
90
+ * definitionally identical ⟺ this hash matches (key order / whitespace /
91
+ * optional-field presence do not affect it).
92
+ *
93
+ * Deterministic and async (Web Crypto), matching overstory's `hashIR` shape.
94
+ */
95
+ export async function flowDefHash(def: Taskflow): Promise<string> {
96
+ return hashCanonical(canonicalJson(def));
97
+ }