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 +37 -0
- package/README.md +5 -4
- package/README.zh-CN.md +1 -1
- package/extensions/cache.ts +6 -1
- package/extensions/compile.ts +371 -0
- package/extensions/flowir/hash.ts +97 -0
- package/extensions/index.ts +257 -6
- package/extensions/interpolate.ts +17 -0
- package/extensions/runtime.ts +326 -27
- package/extensions/stale.ts +137 -0
- package/extensions/store.ts +14 -0
- package/package.json +1 -1
- package/skills/taskflow/SKILL.md +2 -2
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-
|
|
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** · **
|
|
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
|
-
- **
|
|
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-
|
|
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>
|
package/extensions/cache.ts
CHANGED
|
@@ -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, "&")
|
|
102
|
+
.replace(/"/g, """)
|
|
103
|
+
.replace(/</g, "<")
|
|
104
|
+
.replace(/>/g, ">")
|
|
105
|
+
.replace(/\\/g, "\")
|
|
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, "<")
|
|
132
|
+
.replace(/>/g, ">");
|
|
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
|
+
}
|