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.
@@ -781,7 +781,7 @@ export function validateTaskflow(def: unknown, opts: ValidationOptions = {}): Va
781
781
  return { ok: errors.length === 0, errors, warnings };
782
782
  }
783
783
 
784
- function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
784
+ export function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
785
785
  const steps = new Set<string>();
786
786
  const args = new Set<string>();
787
787
  const scan = (s: string | undefined) => {
@@ -795,6 +795,8 @@ function collectRefs(phase: Phase): { steps: string[]; args: string[] } {
795
795
  scan(phase.task);
796
796
  scan(phase.over);
797
797
  scan(phase.when);
798
+ scan(phase.until);
799
+ for (const e of phase.eval ?? []) scan(e);
798
800
  for (const b of phase.branches ?? []) scan(b.task);
799
801
  for (const v of Object.values(phase.with ?? {})) if (typeof v === "string") scan(v);
800
802
  for (const c of phase.context ?? []) scan(c);
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Stale-marking (M4) — conservative transitive invalidation over the observed
3
+ * readSet captured in M3.
4
+ *
5
+ * This is the "mark stale, don't rerun" half of overstory's cost-asymmetric
6
+ * reactivity (VISION §2.3): the cheap effects (figuring out what WOULD be
7
+ * invalidated) run for free; the expensive effects (actually re-running an LLM
8
+ * phase) are gated for M5. Given a run's observed readSets and a set of phases
9
+ * assumed to have changed, `computeStaleFrontier` returns the transitive
10
+ * closure of phases whose recorded dependencies are no longer trustworthy.
11
+ *
12
+ * Pure module: no IO, no Date, no randomness. Deterministic.
13
+ *
14
+ * Scope (honest): this is TOPOLOGICAL propagation only — a changed seed
15
+ * invalidates everything that (transitively) read it. The overstory
16
+ * "early cutoff" refinement (a re-run whose output HASH is unchanged does NOT
17
+ * invalidate, even if the version advanced) needs before/after content hashes,
18
+ * which only exist when a phase is actually re-run — that is the M5
19
+ * recomputation concern, deliberately out of scope here. Marking is the safe,
20
+ * conservative prerequisite that lets M5 rerun with confidence.
21
+ *
22
+ * @see docs/internal/overstory-convergence-roadmap.md §3 (M4)
23
+ */
24
+
25
+ import type { PhaseState } from "./store.ts";
26
+ import { collectRefs, type Taskflow } from "./schema.ts";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Read graph
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /** phaseId → the upstream stepIds it observed-reading (M3 PhaseState.reads). */
33
+ export type ReadMap = Map<string, readonly string[]>;
34
+
35
+ /** Fold a run's PhaseStates into a read map (drops phases with no reads). */
36
+ export function readMapOf(phases: Record<string, PhaseState>): ReadMap {
37
+ const m: ReadMap = new Map();
38
+ for (const [id, ps] of Object.entries(phases)) {
39
+ const deps = (ps.reads ?? []).map((r) => r.stepId);
40
+ if (deps.length) m.set(id, deps);
41
+ }
42
+ return m;
43
+ }
44
+
45
+ /** Phases that directly read `phaseId` (its immediate dependents).
46
+ *
47
+ * When `declared` is provided, the dependent set is the **union** of
48
+ * observed dependents (from `reads`) and declared dependents (from
49
+ * `declared`) — a declared-but-unobserved edge (e.g. a `when` ref that never
50
+ * fired) still counts as a dependency for staleness propagation (M2 union).
51
+ * `declared` undefined → observed-only (backward-compatible). */
52
+ export function dependentsOf(reads: ReadMap, phaseId: string, declared?: ReadMap): string[] {
53
+ const out = new Set<string>();
54
+ for (const [reader, deps] of reads) {
55
+ if (deps.includes(phaseId)) out.add(reader);
56
+ }
57
+ if (declared) {
58
+ for (const [reader, deps] of declared) {
59
+ if (deps.includes(phaseId)) out.add(reader);
60
+ }
61
+ }
62
+ return [...out];
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Stale frontier (transitive closure, union semantics)
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * The set of phases that are stale if `seeds` change, transitively. A reader
71
+ * is stale if ANY phase it (observed- OR declared-)reading is stale
72
+ * (union/I5: when in doubt, assume dependency). Includes the seeds themselves.
73
+ *
74
+ * When `declared` is provided, the read graph used for propagation is the
75
+ * **union** of `reads` (observed, M3) and `declared` (M2 compile-time refs):
76
+ * a declared-but-unobserved edge still propagates staleness. `declared`
77
+ * undefined → observed-only (backward-compatible, identical to pre-M2).
78
+ *
79
+ * Deterministic. O(phases + read-edges). Cycles in the read graph (which a
80
+ * correct DAG can't produce, but a pathological one could) terminate because a
81
+ * phase is enqueued at most once.
82
+ */
83
+ export function computeStaleFrontier(reads: ReadMap, seeds: Iterable<string>, declared?: ReadMap): Set<string> {
84
+ const stale = new Set<string>();
85
+ const queue: string[] = [...seeds];
86
+ while (queue.length) {
87
+ const s = queue.shift() as string;
88
+ if (stale.has(s)) continue;
89
+ stale.add(s);
90
+ for (const dep of dependentsOf(reads, s, declared)) {
91
+ if (!stale.has(dep)) queue.push(dep);
92
+ }
93
+ }
94
+ return stale;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Declared-plane derivation (M2)
99
+ // ---------------------------------------------------------------------------
100
+
101
+ /** Build a declared ReadMap from a flow definition: each phase's `collectRefs`
102
+ * `{steps.X}` refs become its declared reads (self-refs excluded so a loop
103
+ * `until` checking `{steps.thisId.output}` doesn't create a self-edge).
104
+ *
105
+ * Pure. Used by `recomputeTaskflow` and `/tf why-stale` so union (observed ∪
106
+ * declared) semantics apply to old runs too (pre-H1 runs have no persisted
107
+ * `RunState.declaredDeps` — deriving from `def` keeps recompute sound). */
108
+ export function declaredReadMapOfDef(def: Taskflow): ReadMap {
109
+ const m: ReadMap = new Map();
110
+ for (const p of def.phases) {
111
+ const refs = collectRefs(p);
112
+ const reads = refs.steps.filter((id) => id !== p.id);
113
+ if (reads.length) m.set(p.id, reads);
114
+ }
115
+ return m;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Rendering
120
+ // ---------------------------------------------------------------------------
121
+
122
+ /**
123
+ * Render either the full observed dependency graph (no seeds) or the stale
124
+ * frontier given assumed-changed seeds. Each stale phase lists the stale
125
+ * upstreams that caused it (its "why").
126
+ *
127
+ * When `declared` is provided, the frontier is the **union** (observed ∪
128
+ * declared) and a stale phase's "why" annotates edges present only in the
129
+ * declared plane (not observed at runtime) with `(declared)`. `declared`
130
+ * undefined → observed-only rendering (backward-compatible).
131
+ */
132
+ export function formatWhyStale(
133
+ runId: string,
134
+ flowName: string,
135
+ reads: ReadMap,
136
+ seeds: readonly string[],
137
+ declared?: ReadMap,
138
+ ): string {
139
+ const lines: string[] = [];
140
+ lines.push(`why-stale — run ${runId} · flow "${flowName}"`);
141
+ lines.push("");
142
+
143
+ if (seeds.length === 0) {
144
+ // No seeds → show the full observed dependency graph (who reads what).
145
+ if (reads.size === 0 && (!declared || declared.size === 0)) {
146
+ lines.push("(No observed readSets in this run — provenance is empty.)");
147
+ return lines.join("\n");
148
+ }
149
+ lines.push("Observed dependency graph (who reads what):");
150
+ lines.push("");
151
+ const allReaders = new Set<string>([...reads.keys(), ...(declared?.keys() ?? [])]);
152
+ for (const reader of allReaders) {
153
+ const obs = reads.get(reader) ?? [];
154
+ const dec = declared?.get(reader) ?? [];
155
+ const parts: string[] = [];
156
+ for (const d of obs) parts.push(d);
157
+ for (const d of dec) if (!obs.includes(d)) parts.push(`${d} (declared)`);
158
+ lines.push(`■ ${reader} reads: ${parts.join(", ") || "(none)"}`);
159
+ }
160
+ lines.push("");
161
+ lines.push("Pass a phase id to compute its stale frontier: /tf why-stale <runId> <phaseId>");
162
+ return lines.join("\n");
163
+ }
164
+
165
+ const frontier = computeStaleFrontier(reads, seeds, declared);
166
+ const seedSet = new Set(seeds);
167
+ lines.push(`Assuming changed: ${[...seedSet].join(", ")}`);
168
+ lines.push("");
169
+ if (frontier.size <= seedSet.size) {
170
+ lines.push(`Stale frontier: only the seed(s) themselves — nothing else reads them.`);
171
+ return lines.join("\n");
172
+ }
173
+ lines.push(`Stale frontier (transitive, ${frontier.size} phases):`);
174
+ // Order: seeds first, then the rest, for readability.
175
+ const ordered = [...seeds.filter((s) => frontier.has(s)), ...[...frontier].filter((s) => !seedSet.has(s))];
176
+ for (const id of ordered) {
177
+ if (seedSet.has(id)) {
178
+ lines.push(` ■ ${id} (changed — seed)`);
179
+ } else {
180
+ // Why is it stale? The stale upstreams it read (observed ∪ declared).
181
+ const obs = reads.get(id) ?? [];
182
+ const dec = declared?.get(id) ?? [];
183
+ const obsCauses = obs.filter((d) => frontier.has(d));
184
+ const decCauses = dec.filter((d) => frontier.has(d) && !obs.includes(d));
185
+ const causeStr = [
186
+ ...obsCauses,
187
+ ...decCauses.map((d) => `${d} (declared)`),
188
+ ].join(", ");
189
+ lines.push(` ■ ${id} ← reads ${causeStr || "(nothing stale?)"}`);
190
+ }
191
+ }
192
+ return lines.join("\n");
193
+ }
@@ -21,6 +21,7 @@ import * as path from "node:path";
21
21
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
22
22
  import type { Taskflow } from "./schema.ts";
23
23
  import type { UsageStats } from "./usage.ts";
24
+ import type { DeclaredDeps } from "./flowir/meta.ts";
24
25
 
25
26
  export interface SavedFlow {
26
27
  name: string;
@@ -70,6 +71,14 @@ export interface PhaseState {
70
71
  /** Non-fatal diagnostic warnings accumulated during this phase (e.g.
71
72
  * unresolved interpolation placeholders, suspicious templates). */
72
73
  warnings?: string[];
74
+ /** Observed readSet (M3): the upstream phase outputs this phase actually
75
+ * consumed at interpolation time — not what it *declared* to depend on
76
+ * (dependsOn), but what it truly *read* (`{steps.X...}`). Each entry
77
+ * carries the version (= the read phase's inputHash) it consumed, so a
78
+ * later staleness check (M4/M5) can tell whether the upstream has moved.
79
+ * This is the overstory "observed readSet@version" moat: no other
80
+ * orchestrator records what a result actually depended on. */
81
+ reads?: Array<{ stepId: string; version?: string }>;
73
82
  /** Truncated previews of interpolated strings used to execute this phase,
74
83
  * useful when diagnosing why a model saw a literal placeholder. */
75
84
  interpolation?: Array<{ source: string; text: string; missing?: string[] }>;
@@ -89,6 +98,22 @@ export interface RunState {
89
98
  pid?: number;
90
99
  /** True for runs spawned via `detach: true` (background execution). */
91
100
  detached?: boolean;
101
+ /** Content fingerprint of the desugared flow definition (overstory hash
102
+ * algorithm). Folded into every phase's cache key so a structural change
103
+ * to the flow always invalidates cross-run cache hits — and an identical
104
+ * re-run always reuses them. Filled once at run start; persisted for
105
+ * audit/resume consistency. */
106
+ flowDefHash?: string | "failed";
107
+ /** Per-phase *declared* dependency footprint (M2), synthesized at compile
108
+ * time from `{steps.X}` interpolation refs via `compileTaskflowToIR`.
109
+ * This is the *declared* plane — distinct from the *observed* readSet
110
+ * (`PhaseState.reads`, captured at runtime). Recompute staleness uses the
111
+ * **union** (observed ∪ declared) so a declared-but-unobserved edge (e.g.
112
+ * a `when` ref that never fired) still propagates. JSON-safe `Record`
113
+ * shape so it round-trips through persistence. Audit/provenance only —
114
+ * recompute derives this fresh from `def` so old runs (pre-H1) also get
115
+ * union semantics. */
116
+ declaredDeps?: Record<string, DeclaredDeps>;
92
117
  }
93
118
 
94
119
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.24",
3
+ "version": "0.0.26",
4
4
  "description": "A declarative, verifiable graph of task nodes for the Pi coding agent — not a workflow you script, but a DAG you declare: statically verified before it runs, with dynamic fan-out, gates, isolated subagent context, resumable runs, and saveable commands.",
5
5
  "keywords": [
6
6
  "pi-package",