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.
@@ -0,0 +1,137 @@
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
+
27
+ // ---------------------------------------------------------------------------
28
+ // Read graph
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** phaseId → the upstream stepIds it observed-reading (M3 PhaseState.reads). */
32
+ export type ReadMap = Map<string, readonly string[]>;
33
+
34
+ /** Fold a run's PhaseStates into a read map (drops phases with no reads). */
35
+ export function readMapOf(phases: Record<string, PhaseState>): ReadMap {
36
+ const m: ReadMap = new Map();
37
+ for (const [id, ps] of Object.entries(phases)) {
38
+ const deps = (ps.reads ?? []).map((r) => r.stepId);
39
+ if (deps.length) m.set(id, deps);
40
+ }
41
+ return m;
42
+ }
43
+
44
+ /** Phases that directly read `phaseId` (its immediate dependents). */
45
+ export function dependentsOf(reads: ReadMap, phaseId: string): string[] {
46
+ const out: string[] = [];
47
+ for (const [reader, deps] of reads) {
48
+ if (deps.includes(phaseId)) out.push(reader);
49
+ }
50
+ return out;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Stale frontier (transitive closure, union semantics)
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * The set of phases that are stale if `seeds` change, transitively. A reader
59
+ * is stale if ANY phase it observed-reading is stale (union/I5: when in doubt,
60
+ * assume dependency). Includes the seeds themselves.
61
+ *
62
+ * Deterministic. O(phases + read-edges). Cycles in the read graph (which a
63
+ * correct DAG can't produce, but a pathological one could) terminate because a
64
+ * phase is enqueued at most once.
65
+ */
66
+ export function computeStaleFrontier(reads: ReadMap, seeds: Iterable<string>): Set<string> {
67
+ const stale = new Set<string>();
68
+ const queue: string[] = [...seeds];
69
+ while (queue.length) {
70
+ const s = queue.shift() as string;
71
+ if (stale.has(s)) continue;
72
+ stale.add(s);
73
+ for (const dep of dependentsOf(reads, s)) {
74
+ if (!stale.has(dep)) queue.push(dep);
75
+ }
76
+ }
77
+ return stale;
78
+ }
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // Rendering
82
+ // ---------------------------------------------------------------------------
83
+
84
+ /**
85
+ * Render either the full observed dependency graph (no seeds) or the stale
86
+ * frontier given assumed-changed seeds. Each stale phase lists the stale
87
+ * upstreams that caused it (its "why").
88
+ */
89
+ export function formatWhyStale(
90
+ runId: string,
91
+ flowName: string,
92
+ reads: ReadMap,
93
+ seeds: readonly string[],
94
+ ): string {
95
+ const lines: string[] = [];
96
+ lines.push(`why-stale — run ${runId} · flow "${flowName}"`);
97
+ lines.push("");
98
+
99
+ if (seeds.length === 0) {
100
+ // No seeds → show the full observed dependency graph (who reads what).
101
+ if (reads.size === 0) {
102
+ lines.push("(No observed readSets in this run — provenance is empty.)");
103
+ return lines.join("\n");
104
+ }
105
+ lines.push("Observed dependency graph (who reads what):");
106
+ lines.push("");
107
+ for (const [reader, deps] of reads) {
108
+ lines.push(`■ ${reader} reads: ${deps.join(", ")}`);
109
+ }
110
+ lines.push("");
111
+ lines.push("Pass a phase id to compute its stale frontier: /tf why-stale <runId> <phaseId>");
112
+ return lines.join("\n");
113
+ }
114
+
115
+ const frontier = computeStaleFrontier(reads, seeds);
116
+ const seedSet = new Set(seeds);
117
+ lines.push(`Assuming changed: ${[...seedSet].join(", ")}`);
118
+ lines.push("");
119
+ if (frontier.size <= seedSet.size) {
120
+ lines.push(`Stale frontier: only the seed(s) themselves — nothing else observed-reading them.`);
121
+ return lines.join("\n");
122
+ }
123
+ lines.push(`Stale frontier (transitive, ${frontier.size} phases):`);
124
+ // Order: seeds first, then the rest, for readability.
125
+ const ordered = [...seeds.filter((s) => frontier.has(s)), ...[...frontier].filter((s) => !seedSet.has(s))];
126
+ for (const id of ordered) {
127
+ if (seedSet.has(id)) {
128
+ lines.push(` ■ ${id} (changed — seed)`);
129
+ } else {
130
+ // Why is it stale? The stale upstreams it read.
131
+ const deps = reads.get(id) ?? [];
132
+ const causes = deps.filter((d) => frontier.has(d));
133
+ lines.push(` ■ ${id} ← reads ${causes.length ? causes.join(", ") : "(nothing stale?)"}`);
134
+ }
135
+ }
136
+ return lines.join("\n");
137
+ }
@@ -70,6 +70,14 @@ export interface PhaseState {
70
70
  /** Non-fatal diagnostic warnings accumulated during this phase (e.g.
71
71
  * unresolved interpolation placeholders, suspicious templates). */
72
72
  warnings?: string[];
73
+ /** Observed readSet (M3): the upstream phase outputs this phase actually
74
+ * consumed at interpolation time — not what it *declared* to depend on
75
+ * (dependsOn), but what it truly *read* (`{steps.X...}`). Each entry
76
+ * carries the version (= the read phase's inputHash) it consumed, so a
77
+ * later staleness check (M4/M5) can tell whether the upstream has moved.
78
+ * This is the overstory "observed readSet@version" moat: no other
79
+ * orchestrator records what a result actually depended on. */
80
+ reads?: Array<{ stepId: string; version?: string }>;
73
81
  /** Truncated previews of interpolated strings used to execute this phase,
74
82
  * useful when diagnosing why a model saw a literal placeholder. */
75
83
  interpolation?: Array<{ source: string; text: string; missing?: string[] }>;
@@ -89,6 +97,12 @@ export interface RunState {
89
97
  pid?: number;
90
98
  /** True for runs spawned via `detach: true` (background execution). */
91
99
  detached?: boolean;
100
+ /** Content fingerprint of the desugared flow definition (overstory hash
101
+ * algorithm). Folded into every phase's cache key so a structural change
102
+ * to the flow always invalidates cross-run cache hits — and an identical
103
+ * re-run always reuses them. Filled once at run start; persisted for
104
+ * audit/resume consistency. */
105
+ flowDefHash?: string | "failed";
92
106
  }
93
107
 
94
108
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
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",
@@ -558,7 +558,7 @@ Quick reference:
558
558
  - `action: "run"` — run an inline `define` (a one-off DAG) **or** a saved `name` (with optional `args`). Use `define` for an ad-hoc flow; use `name` to invoke something previously saved. Add `detach: true` to run in the background (returns immediately with the runId; poll the store for status).
559
559
  - `action: "save"` — persist `define` (scope `project` — default, committed/shared — or `user`); it becomes `/tf:<name>`. On a name collision, project overrides user.
560
560
  - `action: "resume"` — continue a paused/failed run by `runId`.
561
- - `action: "list"` — list saved flows. `action: "verify"` — static-check a `define` (zero tokens). `action: "agents"` — list available agents.
561
+ - `action: "list"` — list saved flows. `action: "verify"` — static-check a `define` (zero tokens). `action: "compile"` — render a saved or inline flow as a Mermaid diagram + verification report (zero tokens, no LLM). `action: "agents"` — list available agents.
562
562
 
563
563
  ## Background (detached) runs
564
564
 
@@ -580,5 +580,5 @@ A run moves through: **running →** `completed` (a `final` phase produced outpu
580
580
 
581
581
  ## User commands
582
582
 
583
- - `/tf list` · `/tf run <name> [args]` · `/tf show <name>` · `/tf runs` · `/tf resume <runId>`
583
+ - `/tf list` · `/tf run <name> [args]` · `/tf show <name>` · `/tf compile <name> [lr|td]` · `/tf runs` · `/tf resume <runId>`
584
584
  - `/tf:<name> [args]` — shortcut for each saved flow