pi-taskflow 0.0.24 → 0.0.26

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,116 @@
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.26] — 2026-06-25
6
+
7
+ > Foundation release: **the convergence roadmap's H1 lands** — a real FlowIR
8
+ > compile seam (M1), a declared dependency plane (M2), and a
9
+ > backward-compatible cache-key migration. v0.0.25 made incremental recompute
10
+ > *trustworthy*; this release makes the contract underneath it *real*: the
11
+ > recompute frontier now reasons over **observed ∪ declared** dependencies, the
12
+ > flow definition compiles through a typed IR surface instead of an inlined
13
+ > hash, and folding the definition into the cache key no longer evicts every
14
+ > pre-existing cross-run entry.
15
+
16
+ ### Added
17
+ - **FlowIR compile seam (M1).** New `extensions/flowir/{index,translate,meta}.ts`
18
+ exposes `compileTaskflowToIR(def) → { ir, meta, hash, usedFallbackHash,
19
+ warnings, errors }` — a typed, never-throwing projection of a desugared flow
20
+ into a content-addressed IR. The runtime now routes `flowDefHash` through this
21
+ seam instead of inlining it. `translate` is currently a 1:1 stub projection
22
+ (so `usedFallbackHash` is `true` and the hash equals the vendored
23
+ `flowDefHash`); it becomes the genuine overstory compiler once that kernel is
24
+ vendored, at which point the cache-key version advances `v2: → v3:`.
25
+ - **`/tf ir <flow>` command + `ir` tool action.** Renders the compiled IR plus
26
+ its hash and any structured `CompileError[]` — zero tokens, no LLM.
27
+ - **Declared dependency plane (M2).** `compileTaskflowToIR` synthesizes per-phase
28
+ `DeclaredDeps { reads, writes }` from interpolation refs
29
+ (`task`/`over`/`when`/`until`/`eval`/`branches`/`with`/`context`) and
30
+ `dependsOn`, attaches them to `ir.meta.declaredDeps`, and persists them to
31
+ `RunState`. `/tf recompute` now computes its stale frontier over
32
+ **union(observed ∪ declared)** rather than observed-only — a dependency that
33
+ was declared but never interpolated at runtime is no longer missed.
34
+ - **Tests: 753 → 802** (+49) across new suites: `flowir.test.ts`,
35
+ `flowir-declared.test.ts`, `stale-union.test.ts` (incl. a 500-iteration
36
+ property test proving the union frontier is never narrower than observed-only),
37
+ `recompute-union.test.ts`, `cache-migration.test.ts`, plus `e2e-flowir.mts`
38
+ and `e2e-cache-migration.mts`.
39
+
40
+ ### Fixed
41
+ - **Cache-key migration no longer evicts existing cross-run entries.** Folding
42
+ `flowdef:` into the key previously invalidated every pre-existing cross-run
43
+ cache entry on upgrade (a one-time miss-storm). `cacheKey` is now versioned
44
+ (`v2:flowdef:`) with a **3-tier lookup**: new key → bare `flowdef:` key →
45
+ legacy (no-flowdef) key. Old entries still hit for one release cycle; there is
46
+ no write-through on a fallback hit (legacy entries age out naturally), and
47
+ every tier still includes `flow:${name}` so two different flows can never
48
+ collide.
49
+ - **Declared plane and recompute guard now see `loop.until` and `gate.eval`.**
50
+ `collectRefs` skipped `until` (loop convergence) and `eval[]` (gate zero-token
51
+ checks), so a dependency expressed only in those fields was absent from the
52
+ declared plane and from the `dryRun:false` unobserved-dependency guard. Both
53
+ are now scanned. (Closes the two MEDIUM findings from the H1 risk review.)
54
+
55
+ ### Compatibility
56
+ - **Backward compatible.** `RunState.flowDefHash` and `RunState.declaredDeps`
57
+ are optional — pre-0.0.26 run states load unchanged. A compile/hash failure
58
+ fails open: `usedFallbackHash` stays set, cross-run cache is disabled for that
59
+ run, and the key degrades to a flow-scoped (collision-free) form. The one
60
+ observable change on upgrade is a single re-execution of in-flight phases
61
+ whose stored `inputHash` predates the `v2:` prefix.
62
+
63
+ ## [0.0.25] — 2026-06-24
64
+
65
+ > Correctness release: **incremental recompute is now trustworthy.** `/tf
66
+ > recompute` shipped in the prior line as a promising idea — force-rerun a seed,
67
+ > walk its stale frontier, let the cache cut off untouched downstreams. But the
68
+ > dependency graph it walked was a half-truth: reads observed only inside a
69
+ > `when` guard or an `eval` gate were never recorded, a loop that read its own
70
+ > output **deadlocked the scheduler**, and a `{previous.output}` chain could be
71
+ > silently skipped — each one a path where "only rerun what changed" quietly
72
+ > reused **stale** upstream state and returned a wrong answer that *looked*
73
+ > incrementally correct. This release closes all of them: the observed readSet
74
+ > is now complete, the recompute order unions declared **and** observed edges,
75
+ > and real (`dryRun:false`) recomputation refuses to run when it cannot prove
76
+ > the frontier is sound. The headline feature finally earns its safety claim —
77
+ > the difference between *looks* incremental and *provably* incremental.
78
+
79
+ ### Added
80
+ - **Safety guard for real recomputation.** `recomputeTaskflow` with `dryRun:false`
81
+ now refuses to run flows whose dependencies cannot be fully observed through
82
+ the captured readSet: Shared Context Tree (`shareContext` / `contextSharing`),
83
+ `flow` phases, `context:` file pre-reads, and interpolation placeholders such
84
+ as `{previous.output}`, `{args.X}`, or `{item.X}`. This prevents silently
85
+ reusing stale upstream state.
86
+ - **Regression tests** in `test/recompute.test.ts`:
87
+ - observed-read edges still order recomputation even without an explicit
88
+ `dependsOn` declaration;
89
+ - `{previous.output}` chains are rejected for real recomputation;
90
+ - `recomputeTaskflow` returns a fresh `RunState` and does not mutate the
91
+ caller's state.
92
+
93
+ ### Fixed
94
+ - **Loop self-read no longer deadlocks recompute.** A loop whose `until`
95
+ condition references its own prior output (e.g. `{steps.refine.output}`)
96
+ produced a self-edge in the observed-dependency graph, causing `topoLayers` to
97
+ schedule the phase with a permanently non-zero indegree. `observedDeps()` now
98
+ filters self-references so scheduling remains sound.
99
+ - **`when` condition upstream reads are captured.** Conditions are now evaluated
100
+ inside `executePhaseInner` with the same `onRead` hook used by the phase task,
101
+ so upstream refs observed only in a `when` guard are recorded in
102
+ `PhaseState.reads`.
103
+ - **Gate `eval` upstream reads are captured.** The machine-check `eval` branch
104
+ now receives the shared `onRead` hook, and the resulting readSet is persisted
105
+ when an eval-only gate skips the LLM call.
106
+ - **Recompute topo-order now unions declared and observed edges.** Previously
107
+ the recompute order only respected declared `dependsOn`, which could place a
108
+ downstream phase before its observed-but-not-declared upstream refreshed and
109
+ cause false early-cutoff. The scheduling graph now merges both edge sets.
110
+ - **Recompute no longer mutates the caller's RunState.** `recomputeTaskflow`
111
+ clones the input state via `structuredClone` before modifying it.
112
+ - **Help text accuracy.** `/tf` command and tool-action descriptions updated to
113
+ match the new `recompute` and provenance behavior.
114
+
5
115
  ## [0.0.24] — 2026-06-23
6
116
 
7
117
  > Feature release: **`/tf compile`** — turn the declared DAG into a Mermaid
@@ -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,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
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Public entry point for the FlowIR compile seam.
3
+ *
4
+ * `compileTaskflowToIR` is the read-only, content-addressed IR projection used
5
+ * by:
6
+ * - `/tf ir <flow>` / `action=ir` — render the compiled IR + hash (0 tokens)
7
+ * - the runtime (`runTaskflowLayers`) — fold `ir.hash` into the cache key
8
+ * (== `flowDefHash` in the stub; the overstory-canonical hash once the
9
+ * genuine compiler is vendored) and persist `ir.meta.declaredDeps` to
10
+ * `RunState` (M2 declared plane).
11
+ *
12
+ * The stub hash reuses the already-vendored overstory `flowDefHash` algorithm
13
+ * (./hash.ts) so pi-taskflow and overstory share one byte-identical hashing
14
+ * contract today. `usedFallbackHash` is `true` in the stub (the genuine
15
+ * overstory `hashIR` is not yet wired); it flips to `false` once the compiler
16
+ * is vendored, at which point the cache key's `v2:` prefix advances to `v3:`
17
+ * (see docs/internal/cache-migration.md).
18
+ *
19
+ * Pure + async (Web Crypto). Never throws — a hash failure leaves `hash`
20
+ * unset and `usedFallbackHash` true; the runtime degrades to the safe
21
+ * flowName-only cache key (cross-run disabled for that run).
22
+ *
23
+ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1)
24
+ * @see docs/internal/rfc-flowir-compilation.md
25
+ */
26
+
27
+ import type { Taskflow } from "../schema.ts";
28
+ import { flowDefHash } from "./hash.ts";
29
+ import { translateTaskflow } from "./translate.ts";
30
+ import type { TaskflowIR } from "./meta.ts";
31
+
32
+ /**
33
+ * Compile a (desugared) `Taskflow` into its content-addressed IR.
34
+ *
35
+ * The returned `hash` is, in the stub, exactly `flowDefHash(def)` — the
36
+ * overstory-vendored canonical-JSON + SHA-256-truncation contract. The
37
+ * `usedFallbackHash` flag records that this is the *fallback* hash (non-IR-
38
+ * canonical): it is `true` whenever the stub cannot guarantee IR-canonicity
39
+ * (any phase with a `when`, or any hash-compute failure).
40
+ *
41
+ * Never throws. Returns structured diagnostics so `/tf ir` on a broken flow
42
+ * yields a clean error table instead of crashing.
43
+ */
44
+ export async function compileTaskflowToIR(def: Taskflow): Promise<TaskflowIR> {
45
+ const t = translateTaskflow(def);
46
+ let hash: string | undefined;
47
+ try {
48
+ hash = await flowDefHash(def);
49
+ } catch {
50
+ hash = undefined;
51
+ }
52
+ return {
53
+ ir: t.ir,
54
+ meta: t.meta,
55
+ hash,
56
+ warnings: t.warnings,
57
+ errors: t.errors,
58
+ // Stub: the fallback hash is used whenever (a) any phase has a `when`
59
+ // (translateTaskflow flags it) OR (b) the hash computation itself failed.
60
+ // Once the genuine overstory compiler is vendored, condition (a) drops.
61
+ usedFallbackHash: t.usedFallbackHash || hash === undefined,
62
+ };
63
+ }
64
+
65
+ export type {
66
+ CompileError,
67
+ CompileWarning,
68
+ DeclaredDeps,
69
+ FlowIR,
70
+ FlowIRNode,
71
+ TaskflowIR,
72
+ TaskflowIRMeta,
73
+ } from "./meta.ts";
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Type definitions for the FlowIR compile seam.
3
+ *
4
+ * This is the **stub/projection** layer of the overstory-convergence roadmap's
5
+ * M1 slice: we project a pi-taskflow `Taskflow` into a `FlowIR` shape that
6
+ * mirrors overstory's IR contract *structurally* (nodes with `inject`/`emits`)
7
+ * without yet compiling to overstory's native inject/emits model. The hash
8
+ * contract (overstory's `hashIR` algorithm) is shared via `flowDefHash` — see
9
+ * `./hash.ts`. When the genuine overstory compiler is vendored later, the
10
+ * `usedFallbackHash` flag flips to `false` and `ir` becomes the canonical IR;
11
+ * until then this seam is read-only, pure, and never throws.
12
+ *
13
+ * Pure module: no IO, no Date, no randomness. Type-only where possible.
14
+ *
15
+ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1)
16
+ * @see docs/internal/rfc-flowir-compilation.md
17
+ */
18
+
19
+ import type { Budget, Taskflow } from "../schema.ts";
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Declared dependency plane (compile-time, M2)
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * A phase's *declared* (static) dependency footprint, synthesized at compile
27
+ * time from `{steps.X}` interpolation refs (via `collectRefs`) plus `dependsOn`.
28
+ * `reads` = the upstream step ids this phase's task/when/branches/with/context
29
+ * statically reference; `writes` = the step id this phase emits (itself).
30
+ *
31
+ * This is the *declared* plane — distinct from the *observed* readSet captured
32
+ * at runtime (M3 `PhaseState.reads`). The two are reconciled by a **union**
33
+ * (`observed ∪ declared`) in `computeStaleFrontier` / `recomputeTaskflow` so a
34
+ * declared-but-unobserved edge (e.g. a `when` ref that never fired) is still
35
+ * treated as a dependency for staleness propagation. JSON-safe `Record` shape
36
+ * (not `Map`) so it round-trips through `RunState` persistence.
37
+ */
38
+ export interface DeclaredDeps {
39
+ /** Upstream step ids statically referenced by this phase's interpolation. */
40
+ reads: string[];
41
+ /** Step id(s) this phase emits — currently `[phase.id]` (1:1 projection). */
42
+ writes: string[];
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // FlowIR (1:1 projection of a Taskflow)
47
+ // ---------------------------------------------------------------------------
48
+
49
+ /**
50
+ * A single IR node — one per pi-taskflow phase. `kind` is the native phase type
51
+ * (a 1:1 projection; the overstory-native kind lowering is deferred per roadmap
52
+ * §6.1). `inject`/`emits` mirror overstory's contract: a node *injects*
53
+ * (reads) the outputs of its upstream nodes and *emits* (writes) its own.
54
+ */
55
+ export interface FlowIRNode {
56
+ id: string;
57
+ /** pi-taskflow phase type (1:1 projection; `agent`|`parallel`|`map`|…). */
58
+ kind: string;
59
+ /** Synthesized declared reads: the `{steps.X}` refs this node's task
60
+ * interpolates. (overstory-native `inject` lowering is deferred.) */
61
+ inject: string[];
62
+ /** What this node emits — currently `[id]` (1:1 projection). */
63
+ emits: string[];
64
+ /** Raw `when` guard passthrough (stub: not rewritten to IR conditions). */
65
+ when?: string;
66
+ }
67
+
68
+ /** The compiled IR: a flat list of nodes plus flow-level metadata. */
69
+ export interface FlowIR {
70
+ name: string;
71
+ nodes: FlowIRNode[];
72
+ args?: Taskflow["args"];
73
+ budget?: Budget;
74
+ concurrency?: number;
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Compile diagnostics
79
+ // ---------------------------------------------------------------------------
80
+
81
+ /** A hard compile error (none in the stub; reserved for the genuine compiler). */
82
+ export interface CompileError {
83
+ phaseId?: string;
84
+ code: string;
85
+ message: string;
86
+ }
87
+
88
+ /** A non-fatal advisory (e.g. a `{steps.X}` ref not reachable via dependsOn). */
89
+ export interface CompileWarning {
90
+ phaseId?: string;
91
+ message: string;
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Meta + composite return type
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Compile-time metadata attached to the IR. `declaredDeps` is the M2 declared
100
+ * plane (per-phase `DeclaredDeps`); `sidecar` carries every pi-taskflow-specific
101
+ * field not represented in `FlowIRNode` so the projection is lossless and can
102
+ * round-trip back to a runnable `Taskflow`.
103
+ */
104
+ export interface TaskflowIRMeta {
105
+ sourceFlowName: string;
106
+ /** Per-phase declared dependency footprint (M2). JSON-safe. */
107
+ declaredDeps: Record<string, DeclaredDeps>;
108
+ /** Pi-taskflow-specific fields preserved verbatim for round-trip. */
109
+ sidecar: Record<string, unknown>;
110
+ }
111
+
112
+ /**
113
+ * The composite compile result (RFC §5). `ir`/`hash` are present unless
114
+ * synthesis failed (stub never fails). `usedFallbackHash` is `true` whenever
115
+ * the stub cannot produce an overstory-canonical hash (always, in the stub:
116
+ * the hash is the `flowDefHash` fallback; flips to `false` once the genuine
117
+ * compiler is vendored).
118
+ */
119
+ export interface TaskflowIR {
120
+ ir?: FlowIR;
121
+ meta: TaskflowIRMeta;
122
+ hash?: string;
123
+ warnings: CompileWarning[];
124
+ errors: CompileError[];
125
+ usedFallbackHash: boolean;
126
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * FlowIR translation — the 1:1 projection of a pi-taskflow `Taskflow` into the
3
+ * `FlowIR` shape.
4
+ *
5
+ * **Stub/projection** (M1): this is a *structural* mirror, NOT a compile to
6
+ * overstory's native inject/emits model (which expects an explicit emit
7
+ * declaration pi-taskflow doesn't have — see roadmap §6.1). Each phase becomes
8
+ * one `FlowIRNode`; `inject` is synthesized from `{steps.X}` interpolation refs
9
+ * (`collectRefs`), `emits` is `[phase.id]`. The overstory-native `kind` lowering
10
+ * is deliberately deferred.
11
+ *
12
+ * Pure, synchronous, never throws. Used by `compileTaskflowToIR` (./index.ts).
13
+ *
14
+ * @see docs/internal/overstory-convergence-roadmap.md §3 (M1)
15
+ */
16
+
17
+ import { collectRefs, type Phase, type Taskflow } from "../schema.ts";
18
+ import type {
19
+ CompileError,
20
+ CompileWarning,
21
+ DeclaredDeps,
22
+ FlowIR,
23
+ FlowIRNode,
24
+ TaskflowIRMeta,
25
+ } from "./meta.ts";
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Sidecar: the pi-taskflow-specific fields not represented in FlowIRNode.
29
+ // Everything preserved verbatim so the projection is lossless and can
30
+ // round-trip back to a runnable Taskflow. Defined as a list so the sidecar
31
+ // never silently drops a field when the DSL grows (a new field is carried
32
+ // automatically through `Phase` indexing).
33
+ // ---------------------------------------------------------------------------
34
+
35
+ const SIDECAR_PHASE_FIELDS = [
36
+ "task",
37
+ "over",
38
+ "as",
39
+ "branches",
40
+ "from",
41
+ "use",
42
+ "def",
43
+ "with",
44
+ "until",
45
+ "maxIterations",
46
+ "convergence",
47
+ "variants",
48
+ "judge",
49
+ "judgeAgent",
50
+ "mode",
51
+ "dependsOn",
52
+ "join",
53
+ "when",
54
+ "retry",
55
+ "output",
56
+ "model",
57
+ "thinking",
58
+ "tools",
59
+ "cwd",
60
+ "context",
61
+ "contextLimit",
62
+ "onBlock",
63
+ "eval",
64
+ "cache",
65
+ "shareContext",
66
+ "optional",
67
+ "final",
68
+ "concurrency",
69
+ ] as const;
70
+
71
+ /** Build the per-phase sidecar record (verbatim copy of non-IR fields). */
72
+ function sidecarForPhase(phase: Phase): Record<string, unknown> {
73
+ const out: Record<string, unknown> = {};
74
+ const rec = phase as Record<string, unknown>;
75
+ for (const k of SIDECAR_PHASE_FIELDS) {
76
+ if (k in rec && rec[k] !== undefined) out[k] = rec[k];
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /**
82
+ * Translate a desugared `Taskflow` into a 1:1 `FlowIR` projection + declared
83
+ * dependency metadata. Never throws: malformed input yields warnings/errors in
84
+ * the return value, not an exception (so `/tf ir` on a broken flow still
85
+ * produces a structured diagnostic rather than crashing the tool).
86
+ *
87
+ * `usedFallbackHash` is `true` unconditionally in the stub: the hash produced
88
+ * is `flowDefHash` (the definition fingerprint), NOT the overstory-IR-canonical
89
+ * hash, so callers can never mistake a stub hash for a canonical one. It flips
90
+ * to `false` only once the genuine overstory compiler is vendored and the hash
91
+ * is IR-canonical; a `when` guard remains a *future* fallback driver then.
92
+ */
93
+ export function translateTaskflow(def: Taskflow): {
94
+ ir: FlowIR;
95
+ meta: TaskflowIRMeta;
96
+ warnings: CompileWarning[];
97
+ errors: CompileError[];
98
+ usedFallbackHash: boolean;
99
+ } {
100
+ const warnings: CompileWarning[] = [];
101
+ const errors: CompileError[] = [];
102
+ const declaredDeps: Record<string, DeclaredDeps> = {};
103
+ const sidecarPhases: Record<string, unknown> = {};
104
+
105
+ // In the stub the hash is ALWAYS the fallback (flowDefHash — the definition
106
+ // fingerprint, not the overstory-IR-canonical hash). The `when` guard is a
107
+ // *future* driver (the genuine compiler can't lower conditions → fallback);
108
+ // today the stub unconditionally uses the fallback so callers can never
109
+ // mistake a stub hash for a canonical one. Flips to `false` only once the
110
+ // genuine overstory compiler is vendored and the hash is IR-canonical.
111
+ const usedFallbackHash = true;
112
+
113
+ const nodes: FlowIRNode[] = def.phases.map((phase) => {
114
+ const refs = collectRefs(phase);
115
+ // declared reads: the {steps.X} refs this phase statically references.
116
+ const reads = refs.steps.filter((id) => id !== phase.id);
117
+ declaredDeps[phase.id] = { reads, writes: [phase.id] };
118
+
119
+ // Advisory: a {steps.X} ref whose target doesn't exist (mirrors the
120
+ // validation check but non-fatal here — validation is the source of
121
+ // truth; this is a read-only diagnostic).
122
+ const knownIds = new Set(def.phases.map((p) => p.id));
123
+ for (const r of refs.steps) {
124
+ if (r !== phase.id && !knownIds.has(r)) {
125
+ warnings.push({
126
+ phaseId: phase.id,
127
+ message: `references {steps.${r}.*} but no phase '${r}' exists`,
128
+ });
129
+ }
130
+ }
131
+
132
+ if (phase.when !== undefined) {
133
+ // `when` is a future fallback driver; today the stub is always fallback.
134
+ // (Kept as a structural marker on the node for round-trip.)
135
+ }
136
+
137
+ sidecarPhases[phase.id] = sidecarForPhase(phase);
138
+
139
+ return {
140
+ id: phase.id,
141
+ kind: phase.type ?? "agent",
142
+ inject: reads,
143
+ emits: [phase.id],
144
+ when: phase.when,
145
+ } satisfies FlowIRNode;
146
+ });
147
+
148
+ const ir: FlowIR = {
149
+ name: def.name,
150
+ nodes,
151
+ args: def.args,
152
+ budget: def.budget,
153
+ concurrency: def.concurrency,
154
+ };
155
+
156
+ const meta: TaskflowIRMeta = {
157
+ sourceFlowName: def.name,
158
+ declaredDeps,
159
+ sidecar: { phases: sidecarPhases },
160
+ };
161
+
162
+ return { ir, meta, warnings, errors, usedFallbackHash };
163
+ }