pi-taskflow 0.0.21 → 0.0.22
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 +14 -0
- package/README.md +3 -3
- package/extensions/approval-view.ts +11 -57
- package/extensions/index.ts +7 -3
- package/extensions/interpolate.ts +18 -7
- package/extensions/runs-view.ts +69 -3
- package/extensions/runtime.ts +21 -3
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.22] — 2026-06-10
|
|
6
|
+
|
|
7
|
+
> Dogfooding release. The `dogfood-full` self-audit taskflow (which itself
|
|
8
|
+
> exercises all 9 phase types + when/join/retry/budget/cache/eval/flow-def/
|
|
9
|
+
> loop/tournament/approval) ran against the codebase and surfaced these fixes.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Live auto-refresh for the `/tf runs` panel.** The run-history panel was a static snapshot taken when opened, so a background (detached) run's progress never updated while watching. It now polls run state on a 1s interval and re-renders only when a run's status/`updatedAt` actually changes — phase progress (including `map`/`parallel` `subProgress` like `24/24`) updates live. The user's selection follows the same `runId` across refreshes, a green `● live` tag shows while any run is running, and the refresh timer is cleared on close (`dispose()`) and `unref`'d so it never keeps the event loop alive. Fully backward-compatible: without live hooks the panel renders statically as before.
|
|
13
|
+
- 5 new tests (`test/runs-view.test.ts`): refresh-on-change, no-render-when-unchanged, dispose-stops-timer, selection-follows-runId, back-compat-no-hooks.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- **`safeParse` now prefers a `json`-tagged fence in multi-fence output.** When an LLM phase emitted an evidence block (e.g. ```` ```typescript ````) *before* the ```` ```json ```` payload, the old single-match regex grabbed the first fence, failed to parse, and the balanced-bracket fallback was misled by braces in the prose — `safeParse` returned `undefined` and any downstream `map` phase failed with `'over' did not resolve to an array`. It now scans every fenced block and tries `json`-tagged ones first, then untagged. (3 new multi-fence tests.)
|
|
17
|
+
- **Unresolved interpolation refs are surfaced as phase warnings.** `interpolate()` returns `missing[]` (placeholders with no source), but the runtime discarded it on the main task path — so `{args.typo}` or a `{steps.x.output}` without `dependsOn` was silently left intact in the dispatched task. The `interpolate.ts` doc comment promised "a recorded warning" that no code produced. The runtime now logs `[taskflow] phase X: unresolved refs ...` and attaches the message to `PhaseState.warnings` (persisted in the run record, visible in `/tf runs`). Doc comment corrected to match.
|
|
18
|
+
|
|
5
19
|
## [0.0.21] — 2026-06-10
|
|
6
20
|
|
|
7
21
|
### Added
|
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-608-6E8BFF?style=flat-square" alt="608 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>
|
|
@@ -608,12 +608,12 @@ Copy one into `.pi/taskflows/<name>.json` (or `~/.pi/agent/taskflows/`) and it r
|
|
|
608
608
|
|
|
609
609
|
<div align="center">
|
|
610
610
|
|
|
611
|
-
**0 runtime dependencies** · **
|
|
611
|
+
**0 runtime dependencies** · **608 tests** · **9 phase types** · **cross-session resume** · **cross-run memoization** · **~7.7k LOC runtime**
|
|
612
612
|
|
|
613
613
|
</div>
|
|
614
614
|
|
|
615
615
|
- **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.
|
|
616
|
-
- **
|
|
616
|
+
- **608 tests across 26 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, live run-history refresh, callback isolation, the idle watchdog, model-role init config, and parseModelFromLabel with parenthesized-model-name regression.
|
|
617
617
|
- **Hardened by design.** Path-traversal defense (lexical + `realpath`), runId validation, HTML/error sanitization, atomic writes, stale-lock stealing via `rename`, and an idle watchdog that kills wedged subagents.
|
|
618
618
|
- **Dogfooded.** Every new feature has to survive the project's own `self-improve` taskflow before it ships.
|
|
619
619
|
|
|
@@ -6,11 +6,15 @@
|
|
|
6
6
|
* before deciding. Every line is padded to the full dialog width so the
|
|
7
7
|
* overlay composites cleanly (no see-through, no ghosting in scrollback).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* Mouse tracking is intentionally NOT used here. Enabling terminal-level
|
|
10
|
+
* SGR mouse reporting (DECSET 1000h/1006h) to capture wheel events would
|
|
11
|
+
* interfere with the terminal's native scrollback after the dialog closes,
|
|
12
|
+
* because the restore sequence depends on the overlay framework reliably
|
|
13
|
+
* calling dispose — which is not guaranteed across all lifecycle paths.
|
|
14
|
+
* Keyboard scrolling (↑↓/PgUp/PgDn/Home/End/j/k/g/G) covers the same
|
|
15
|
+
* ground without risking a stuck mouse-tracking mode.
|
|
12
16
|
*
|
|
13
|
-
* Keys:
|
|
17
|
+
* Keys: ↑↓ scroll · PgUp/PgDn page · Home/End jump ·
|
|
14
18
|
* a/Enter approve · e edit (guidance) · r/Esc reject.
|
|
15
19
|
*/
|
|
16
20
|
|
|
@@ -28,31 +32,16 @@ export interface ApprovalViewOptions {
|
|
|
28
32
|
upstream?: string;
|
|
29
33
|
}
|
|
30
34
|
|
|
31
|
-
/** Minimal writer used to toggle terminal mouse reporting. */
|
|
32
|
-
export interface TerminalWriter {
|
|
33
|
-
write(data: string): void;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
35
|
const FALLBACK_ROWS = 24;
|
|
37
|
-
/** Wheel ticks scroll this many lines. */
|
|
38
|
-
const WHEEL_STEP = 3;
|
|
39
|
-
/** SGR mouse sequence: ESC [ < B ; X ; Y (M|m) */
|
|
40
|
-
const MOUSE_SGR = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
|
|
41
|
-
/** Enable basic mouse tracking + SGR encoding. */
|
|
42
|
-
const MOUSE_ON = "\x1b[?1000h\x1b[?1006h";
|
|
43
|
-
/** Restore: disable SGR encoding + mouse tracking. */
|
|
44
|
-
const MOUSE_OFF = "\x1b[?1006l\x1b[?1000l";
|
|
45
36
|
|
|
46
37
|
export class ApprovalViewComponent {
|
|
47
38
|
private theme: Theme;
|
|
48
39
|
private opts: ApprovalViewOptions;
|
|
49
40
|
private onDone: (choice: ApprovalChoice) => void;
|
|
50
41
|
private getRows: () => number;
|
|
51
|
-
private term?: TerminalWriter;
|
|
52
42
|
private scrollOffset = 0;
|
|
53
43
|
private cachedWidth?: number;
|
|
54
44
|
private cachedBody?: string[];
|
|
55
|
-
private mouseEnabled = false;
|
|
56
45
|
private decided = false;
|
|
57
46
|
|
|
58
47
|
constructor(
|
|
@@ -60,43 +49,19 @@ export class ApprovalViewComponent {
|
|
|
60
49
|
opts: ApprovalViewOptions,
|
|
61
50
|
onDone: (choice: ApprovalChoice) => void,
|
|
62
51
|
getRows?: () => number,
|
|
63
|
-
term?: TerminalWriter,
|
|
64
52
|
) {
|
|
65
53
|
this.theme = theme;
|
|
66
54
|
this.opts = opts;
|
|
67
55
|
this.onDone = onDone;
|
|
68
56
|
this.getRows = getRows ?? (() => FALLBACK_ROWS);
|
|
69
|
-
this.term = term;
|
|
70
|
-
this.enableMouse();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
private enableMouse(): void {
|
|
74
|
-
if (this.term && !this.mouseEnabled) {
|
|
75
|
-
try {
|
|
76
|
-
this.term.write(MOUSE_ON);
|
|
77
|
-
this.mouseEnabled = true;
|
|
78
|
-
} catch {
|
|
79
|
-
// non-tty / closed stream — wheel support is best-effort
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
57
|
}
|
|
83
58
|
|
|
84
|
-
/**
|
|
85
|
-
dispose(): void {
|
|
86
|
-
if (this.term && this.mouseEnabled) {
|
|
87
|
-
this.mouseEnabled = false;
|
|
88
|
-
try {
|
|
89
|
-
this.term.write(MOUSE_OFF);
|
|
90
|
-
} catch {
|
|
91
|
-
// ignore
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
59
|
+
/** No-op — kept for compatibility with Pi TUI overlay dispose contract. */
|
|
60
|
+
dispose(): void {}
|
|
95
61
|
|
|
96
62
|
private decide(choice: ApprovalChoice): void {
|
|
97
63
|
if (this.decided) return;
|
|
98
64
|
this.decided = true;
|
|
99
|
-
this.dispose();
|
|
100
65
|
this.onDone(choice);
|
|
101
66
|
}
|
|
102
67
|
|
|
@@ -155,17 +120,6 @@ export class ApprovalViewComponent {
|
|
|
155
120
|
}
|
|
156
121
|
|
|
157
122
|
handleInput(data: string): void {
|
|
158
|
-
// Mouse events (SGR) — wheel scrolls, everything else is swallowed.
|
|
159
|
-
const mouse = MOUSE_SGR.exec(data);
|
|
160
|
-
if (mouse) {
|
|
161
|
-
const b = Number(mouse[1]);
|
|
162
|
-
if (b & 64) {
|
|
163
|
-
// Wheel: low two bits 0 = up, 1 = down.
|
|
164
|
-
if ((b & 3) === 0) this.clampScroll(-WHEEL_STEP);
|
|
165
|
-
else if ((b & 3) === 1) this.clampScroll(WHEEL_STEP);
|
|
166
|
-
}
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
123
|
// Decisions
|
|
170
124
|
if (matchesKey(data, "return") || data === "a" || data === "y") {
|
|
171
125
|
this.decide("approve");
|
|
@@ -251,7 +205,7 @@ export class ApprovalViewComponent {
|
|
|
251
205
|
|
|
252
206
|
// Key hints
|
|
253
207
|
lines.push(this.hrule(width, "├", "┤"));
|
|
254
|
-
const scrollHint = cap > 0 ? "
|
|
208
|
+
const scrollHint = cap > 0 ? "↑↓/PgUp/PgDn scroll · " : "";
|
|
255
209
|
lines.push(this.row(th.fg("dim", `${scrollHint}a/Enter approve · e edit · r/Esc reject`), width));
|
|
256
210
|
lines.push(this.hrule(width, "╰", "╯"));
|
|
257
211
|
return lines;
|
package/extensions/index.ts
CHANGED
|
@@ -206,7 +206,6 @@ async function runFlow(
|
|
|
206
206
|
},
|
|
207
207
|
done,
|
|
208
208
|
() => tui.terminal.rows,
|
|
209
|
-
tui.terminal,
|
|
210
209
|
);
|
|
211
210
|
const onAbort = () => done("reject");
|
|
212
211
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
@@ -799,8 +798,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
799
798
|
);
|
|
800
799
|
return;
|
|
801
800
|
}
|
|
802
|
-
const result = await ctx.ui.custom<RunHistoryResult | undefined>((
|
|
803
|
-
|
|
801
|
+
const result = await ctx.ui.custom<RunHistoryResult | undefined>((tui, theme, _kb, done) => {
|
|
802
|
+
const comp = new RunHistoryComponent(runs, theme, (r) => done(r), {
|
|
803
|
+
refresh: () => listRuns(ctx.cwd, 50),
|
|
804
|
+
requestRender: () => tui.requestRender(),
|
|
805
|
+
intervalMs: 1000,
|
|
806
|
+
});
|
|
807
|
+
return comp;
|
|
804
808
|
});
|
|
805
809
|
if (result?.action === "resume") {
|
|
806
810
|
if (ctx.isIdle()) {
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* {previous.output} alias for the immediately-preceding completed phase output
|
|
9
9
|
* {item} / {item.f} map loop variable (or custom name via phase.as)
|
|
10
10
|
*
|
|
11
|
-
* Unknown placeholders are left intact
|
|
12
|
-
*
|
|
11
|
+
* Unknown placeholders are left intact rather than throwing, so a
|
|
12
|
+
* partially-specified task still runs. The unresolved refs are returned in
|
|
13
|
+
* `missing[]`; the runtime surfaces them as a phase warning (see
|
|
14
|
+
* `warnUnresolvedRefs` in runtime.ts) — logged and persisted to
|
|
15
|
+
* `PhaseState.warnings`.
|
|
13
16
|
*/
|
|
14
17
|
|
|
15
18
|
export interface InterpolationContext {
|
|
@@ -123,13 +126,21 @@ export function safeParse(text: string): unknown {
|
|
|
123
126
|
} catch {
|
|
124
127
|
// noop
|
|
125
128
|
}
|
|
126
|
-
// Extract from
|
|
127
|
-
|
|
128
|
-
|
|
129
|
+
// Extract from fenced blocks. Outputs often contain multiple fences
|
|
130
|
+
// (e.g. a ```typescript evidence block before the ```json payload), so try
|
|
131
|
+
// every fence — json-tagged blocks first, then untagged/other blocks.
|
|
132
|
+
const fenceRe = /```(\w*)[ \t]*\r?\n?([\s\S]*?)```/g;
|
|
133
|
+
const fenced: { lang: string; body: string }[] = [];
|
|
134
|
+
let fm: RegExpExecArray | null;
|
|
135
|
+
while ((fm = fenceRe.exec(trimmed)) !== null) {
|
|
136
|
+
fenced.push({ lang: fm[1].toLowerCase(), body: fm[2].trim() });
|
|
137
|
+
}
|
|
138
|
+
const ordered = [...fenced.filter((b) => b.lang === "json"), ...fenced.filter((b) => b.lang !== "json")];
|
|
139
|
+
for (const block of ordered) {
|
|
129
140
|
try {
|
|
130
|
-
return JSON.parse(
|
|
141
|
+
return JSON.parse(block.body);
|
|
131
142
|
} catch {
|
|
132
|
-
// noop
|
|
143
|
+
// noop — try the next fence
|
|
133
144
|
}
|
|
134
145
|
}
|
|
135
146
|
// Extract the first balanced [...] or {...}
|
package/extensions/runs-view.ts
CHANGED
|
@@ -40,6 +40,19 @@ function isResumable(r: RunState): boolean {
|
|
|
40
40
|
return r.status === "paused" || r.status === "failed";
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Detect whether a refreshed run list differs from the current one in any way
|
|
44
|
+
* the panel renders (status, updatedAt, phase progress, membership). */
|
|
45
|
+
function hasChanged(prev: RunState[], next: RunState[]): boolean {
|
|
46
|
+
if (prev.length !== next.length) return true;
|
|
47
|
+
const byId = new Map(prev.map((r) => [r.runId, r]));
|
|
48
|
+
for (const n of next) {
|
|
49
|
+
const p = byId.get(n.runId);
|
|
50
|
+
if (!p) return true;
|
|
51
|
+
if (p.status !== n.status || p.updatedAt !== n.updatedAt) return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
export class RunHistoryComponent {
|
|
44
57
|
private runs: RunState[];
|
|
45
58
|
private theme: Theme;
|
|
@@ -48,14 +61,62 @@ export class RunHistoryComponent {
|
|
|
48
61
|
private mode: "list" | "detail" = "list";
|
|
49
62
|
private cachedWidth?: number;
|
|
50
63
|
private cachedLines?: string[];
|
|
64
|
+
/** Live-refresh wiring: re-read run state from disk while the panel is open
|
|
65
|
+
* so background (detached) runs show live progress without reopening. */
|
|
66
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
67
|
+
private refresh?: () => RunState[];
|
|
68
|
+
private requestRender?: () => void;
|
|
51
69
|
|
|
52
|
-
constructor(
|
|
70
|
+
constructor(
|
|
71
|
+
runs: RunState[],
|
|
72
|
+
theme: Theme,
|
|
73
|
+
onDone: (result?: RunHistoryResult) => void,
|
|
74
|
+
/** Optional live-refresh hooks. When both are provided the panel polls
|
|
75
|
+
* `refresh()` on an interval and calls `requestRender()` if anything changed. */
|
|
76
|
+
live?: { refresh: () => RunState[]; requestRender: () => void; intervalMs?: number },
|
|
77
|
+
) {
|
|
53
78
|
if (!runs.length) {
|
|
54
79
|
throw new Error("RunHistoryComponent requires at least one run");
|
|
55
80
|
}
|
|
56
81
|
this.runs = runs;
|
|
57
82
|
this.theme = theme;
|
|
58
83
|
this.onDone = onDone;
|
|
84
|
+
if (live) {
|
|
85
|
+
this.refresh = live.refresh;
|
|
86
|
+
this.requestRender = live.requestRender;
|
|
87
|
+
const intervalMs = Math.max(250, live.intervalMs ?? 1000);
|
|
88
|
+
this.timer = setInterval(() => this.poll(), intervalMs);
|
|
89
|
+
// Don't keep the event loop alive just for the panel refresh.
|
|
90
|
+
(this.timer as { unref?: () => void }).unref?.();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Re-read run state; if anything changed, refresh the cached render. */
|
|
95
|
+
private poll(): void {
|
|
96
|
+
if (!this.refresh) return;
|
|
97
|
+
let next: RunState[];
|
|
98
|
+
try {
|
|
99
|
+
next = this.refresh();
|
|
100
|
+
} catch {
|
|
101
|
+
return; // transient read/lock error — try again next tick
|
|
102
|
+
}
|
|
103
|
+
if (!next.length) return;
|
|
104
|
+
if (!hasChanged(this.runs, next)) return;
|
|
105
|
+
// Preserve the user's selection by runId across refreshes.
|
|
106
|
+
const selectedId = this.runs[this.selected]?.runId;
|
|
107
|
+
this.runs = next;
|
|
108
|
+
const idx = next.findIndex((r) => r.runId === selectedId);
|
|
109
|
+
this.selected = idx >= 0 ? idx : Math.min(this.selected, next.length - 1);
|
|
110
|
+
this.invalidate();
|
|
111
|
+
this.requestRender?.();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stop the refresh timer when the panel closes. */
|
|
115
|
+
dispose(): void {
|
|
116
|
+
if (this.timer) {
|
|
117
|
+
clearInterval(this.timer);
|
|
118
|
+
this.timer = undefined;
|
|
119
|
+
}
|
|
59
120
|
}
|
|
60
121
|
|
|
61
122
|
handleInput(data: string): void {
|
|
@@ -104,7 +165,8 @@ export class RunHistoryComponent {
|
|
|
104
165
|
for (const l of renderProgress(run, th).split("\n")) lines.push(truncateToWidth(l, width));
|
|
105
166
|
lines.push("");
|
|
106
167
|
const hint = isResumable(run) ? "Esc back · r resume" : "Esc back";
|
|
107
|
-
|
|
168
|
+
const liveTag = this.timer && run.status === "running" ? th.fg("success", " ● live") : "";
|
|
169
|
+
lines.push(truncateToWidth(` ${th.fg("dim", hint)}${liveTag}`, width));
|
|
108
170
|
lines.push("");
|
|
109
171
|
this.cachedWidth = width;
|
|
110
172
|
this.cachedLines = lines;
|
|
@@ -129,7 +191,11 @@ export class RunHistoryComponent {
|
|
|
129
191
|
});
|
|
130
192
|
|
|
131
193
|
lines.push("");
|
|
132
|
-
|
|
194
|
+
const anyRunning = this.runs.some((r) => r.status === "running");
|
|
195
|
+
const liveHint = this.timer && anyRunning ? th.fg("success", " ● live") : "";
|
|
196
|
+
lines.push(
|
|
197
|
+
truncateToWidth(` ${th.fg("dim", "↑↓ select · Enter details · r resume · q close")}${liveHint}`, width),
|
|
198
|
+
);
|
|
133
199
|
lines.push("");
|
|
134
200
|
|
|
135
201
|
this.cachedWidth = width;
|
package/extensions/runtime.ts
CHANGED
|
@@ -87,8 +87,7 @@ function buildInterpolationContext(
|
|
|
87
87
|
return { args: state.args, steps, previousOutput, locals };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
function resultToPhaseState(id: string, r: RunResult, inputHash: string, parseJson: boolean): PhaseState {
|
|
91
|
-
const failed = isFailed(r);
|
|
90
|
+
function resultToPhaseState(id: string, r: RunResult, inputHash: string, parseJson: boolean): PhaseState { const failed = isFailed(r);
|
|
92
91
|
const attempts = attemptsOf(r);
|
|
93
92
|
// For failed phases, embed the error info in the output so downstream
|
|
94
93
|
// phases (and the user) can see what went wrong. The raw r.output is
|
|
@@ -110,6 +109,22 @@ function resultToPhaseState(id: string, r: RunResult, inputHash: string, parseJs
|
|
|
110
109
|
};
|
|
111
110
|
}
|
|
112
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Surface unresolved interpolation placeholders (the `missing[]` from
|
|
114
|
+
* `interpolate()`). Without this they are silently left intact in the task —
|
|
115
|
+
* the doc comment in interpolate.ts promises "a recorded warning". We both
|
|
116
|
+
* log to the console and return a string to attach to PhaseState.warnings so
|
|
117
|
+
* the warning is persisted in the run record and visible in `/tf runs`.
|
|
118
|
+
* Returns undefined when nothing is missing.
|
|
119
|
+
*/
|
|
120
|
+
function warnUnresolvedRefs(phaseId: string, missing: string[]): string | undefined {
|
|
121
|
+
if (!missing.length) return undefined;
|
|
122
|
+
const unique = Array.from(new Set(missing));
|
|
123
|
+
const msg = `unresolved refs in task: ${unique.map((m) => `{${m}}`).join(", ")} — left intact (check dependsOn / placeholder spelling)`;
|
|
124
|
+
console.warn(`[taskflow] phase '${phaseId}': ${msg}`);
|
|
125
|
+
return msg;
|
|
126
|
+
}
|
|
127
|
+
|
|
113
128
|
/** Attempts recorded by the retry wrapper (defaults to 1). */
|
|
114
129
|
function attemptsOf(r: RunResult): number {
|
|
115
130
|
const a = r.attempts;
|
|
@@ -582,7 +597,9 @@ async function executePhase(
|
|
|
582
597
|
return ps;
|
|
583
598
|
}
|
|
584
599
|
}
|
|
585
|
-
const
|
|
600
|
+
const interp = interpolate(phase.task ?? "", ctx);
|
|
601
|
+
const text = interp.text;
|
|
602
|
+
const refWarning = warnUnresolvedRefs(phase.id, interp.missing);
|
|
586
603
|
const fullTask = preRead + text;
|
|
587
604
|
const agentName = resolveAgent(phase.agent, deps, state);
|
|
588
605
|
const inputHash = cacheKey(cc, [phase.id, agentName, phase.model ?? "", fullTask]);
|
|
@@ -591,6 +608,7 @@ async function executePhase(
|
|
|
591
608
|
|
|
592
609
|
const r = await runOne(agentName, fullTask, liveSink(state, phase.id, emitProgress));
|
|
593
610
|
const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
611
|
+
if (refWarning) ps.warnings = [...(ps.warnings ?? []), refWarning];
|
|
594
612
|
if (type === "gate" && ps.status === "done") ps.gate = parseGateVerdict(r.output);
|
|
595
613
|
|
|
596
614
|
// onBlock:retry — re-execute upstream + gate until pass or max attempts.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.22",
|
|
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",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"scripts": {
|
|
39
39
|
"typecheck": "tsc --noEmit",
|
|
40
|
-
"test": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/init.test.ts test/render.test.ts test/approval-view.test.ts test/desugar.test.ts test/cache.test.ts test/loop.test.ts test/tournament.test.ts test/verify.test.ts test/gate-eval.test.ts test/transient-error.test.ts test/runtime-branches.test.ts test/interpolate-extended.test.ts test/store-extended.test.ts test/flow-def.test.ts test/detached.test.ts",
|
|
40
|
+
"test": "PI_TASKFLOW_BUILTIN_AGENTS_DIR= node --experimental-strip-types --test test/interpolate.test.ts test/condition.test.ts test/schema.test.ts test/usage.test.ts test/runtime.test.ts test/features.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/init.test.ts test/render.test.ts test/approval-view.test.ts test/desugar.test.ts test/cache.test.ts test/loop.test.ts test/tournament.test.ts test/verify.test.ts test/gate-eval.test.ts test/transient-error.test.ts test/runtime-branches.test.ts test/interpolate-extended.test.ts test/store-extended.test.ts test/flow-def.test.ts test/detached.test.ts test/runs-view.test.ts",
|
|
41
41
|
"test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts",
|
|
42
42
|
"test:dogfood-cache": "node --experimental-strip-types test/dogfood-cache.mts"
|
|
43
43
|
},
|