pi-taskflow 0.0.22 → 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.
@@ -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
+ }