pi-taskflow 0.0.2 → 0.0.3
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/DESIGN.md +6 -6
- package/extensions/index.ts +21 -5
- package/extensions/render.ts +23 -4
- package/extensions/runs-view.ts +141 -0
- package/extensions/runtime.ts +51 -6
- package/extensions/store.ts +3 -1
- package/package.json +1 -1
- package/skills/taskflow/SKILL.md +21 -1
package/DESIGN.md
CHANGED
|
@@ -288,12 +288,12 @@ export async function runTaskflow(def, args, ctx): Promise<TaskflowResult>
|
|
|
288
288
|
|
|
289
289
|
## 5. 路线图
|
|
290
290
|
|
|
291
|
-
| 版本 | 范围 |
|
|
292
|
-
|
|
293
|
-
| **v0.1** | DSL + schema + runtime(agent/parallel/map/reduce)+ `taskflow` 工具 + `/tf run` + 内存隔离 + 流式进度 |
|
|
294
|
-
| **v0.2** | 保存/动态命令注册 + 跨 session 恢复 + `gate`
|
|
295
|
-
| **v0.3** | examples + SKILL.md(教 LLM 写定义)+ YAML 支持 + 发布 npm |
|
|
296
|
-
| **v0.4** | 真·后台执行(detached + 轮询)+ 成本预估/上限 + 内置 `deep-research` 工作流 |
|
|
291
|
+
| 版本 | 范围 | 状态 |
|
|
292
|
+
|------|------|------|
|
|
293
|
+
| **v0.1** | DSL + schema + runtime(agent/parallel/map/reduce)+ `taskflow` 工具 + `/tf run` + 内存隔离 + 流式进度 | ✅ 已发布 (npm 0.0.1) |
|
|
294
|
+
| **v0.2** | 保存/动态命令注册 + 跨 session 恢复 + `gate` 真门控 + run 历史交互 TUI | ✅ 已完成 (npm 0.0.3) |
|
|
295
|
+
| **v0.3** | examples + SKILL.md(教 LLM 写定义)+ YAML 支持 + 发布 npm | 🚧 examples/SKILL/npm 已做;YAML 待办 |
|
|
296
|
+
| **v0.4** | 真·后台执行(detached + 轮询)+ 成本预估/上限 + 内置 `deep-research` 工作流 | ⏳ 待办 |
|
|
297
297
|
|
|
298
298
|
---
|
|
299
299
|
|
package/extensions/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
17
17
|
import { Type } from "typebox";
|
|
18
18
|
import { type AgentScope, discoverAgents, readSubagentSettings } from "./agents.ts";
|
|
19
19
|
import { renderRunResult, summarizeRun } from "./render.ts";
|
|
20
|
+
import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts";
|
|
20
21
|
import { executeTaskflow, type RuntimeResult } from "./runtime.ts";
|
|
21
22
|
import { finalPhase, type Taskflow, validateTaskflow } from "./schema.ts";
|
|
22
23
|
import {
|
|
@@ -306,15 +307,30 @@ export default function (pi: ExtensionAPI) {
|
|
|
306
307
|
}
|
|
307
308
|
|
|
308
309
|
if (sub === "runs") {
|
|
309
|
-
const runs = listRuns(ctx.cwd);
|
|
310
|
+
const runs = listRuns(ctx.cwd, 50);
|
|
310
311
|
if (runs.length === 0) {
|
|
311
312
|
ctx.ui.notify("No taskflow runs yet.", "info");
|
|
312
313
|
return;
|
|
313
314
|
}
|
|
314
|
-
ctx.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
315
|
+
if (!ctx.hasUI) {
|
|
316
|
+
ctx.ui.notify(
|
|
317
|
+
runs.map((r) => `${r.runId} [${r.status}] ${r.flowName} — ${summarizeRun(r)}`).join("\n"),
|
|
318
|
+
"info",
|
|
319
|
+
);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const result = await ctx.ui.custom<RunHistoryResult | undefined>((_tui, theme, _kb, done) => {
|
|
323
|
+
return new RunHistoryComponent(runs, theme, (r) => done(r));
|
|
324
|
+
});
|
|
325
|
+
if (result?.action === "resume") {
|
|
326
|
+
if (ctx.isIdle()) {
|
|
327
|
+
pi.sendUserMessage(
|
|
328
|
+
`Resume the taskflow run "${result.runId}" using the taskflow tool with action="resume", runId="${result.runId}".`,
|
|
329
|
+
);
|
|
330
|
+
} else {
|
|
331
|
+
ctx.ui.notify("Agent is busy; try /tf resume when idle.", "warning");
|
|
332
|
+
}
|
|
333
|
+
}
|
|
318
334
|
return;
|
|
319
335
|
}
|
|
320
336
|
|
package/extensions/render.ts
CHANGED
|
@@ -115,7 +115,11 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
115
115
|
const type = phase.type ?? "agent";
|
|
116
116
|
if (!ps || ps.status === "pending") return theme.fg("dim", "—");
|
|
117
117
|
|
|
118
|
-
if (ps.status === "skipped")
|
|
118
|
+
if (ps.status === "skipped") {
|
|
119
|
+
const reason = (ps.error ?? "upstream failed").replace(/\s+/g, " ");
|
|
120
|
+
const snip = reason.length > 52 ? `${reason.slice(0, 52)}…` : reason;
|
|
121
|
+
return theme.fg("muted", `skipped · ${snip}`);
|
|
122
|
+
}
|
|
119
123
|
|
|
120
124
|
const isFanout = type === "map" || type === "parallel";
|
|
121
125
|
|
|
@@ -167,6 +171,18 @@ function phaseDetail(phase: Phase, ps: PhaseState | undefined, theme: Theme): st
|
|
|
167
171
|
// single-agent done
|
|
168
172
|
const model = shortModel(ps.model);
|
|
169
173
|
const u = compactUsage(ps.usage, theme);
|
|
174
|
+
if (ps.gate) {
|
|
175
|
+
const badge =
|
|
176
|
+
ps.gate.verdict === "block" ? theme.fg("error", theme.bold("BLOCK")) : theme.fg("success", "PASS");
|
|
177
|
+
let g = badge;
|
|
178
|
+
if (ps.gate.reason) {
|
|
179
|
+
const r = ps.gate.reason.replace(/\s+/g, " ");
|
|
180
|
+
g += theme.fg("dim", ` ${r.length > 44 ? `${r.slice(0, 44)}…` : r}`);
|
|
181
|
+
}
|
|
182
|
+
if (model) g += ` ${theme.fg("dim", model)}`;
|
|
183
|
+
if (time) g += ` ${time}`;
|
|
184
|
+
return g;
|
|
185
|
+
}
|
|
170
186
|
let s = "";
|
|
171
187
|
if (model) s += theme.fg("accent", model);
|
|
172
188
|
if (u) s += (s ? " " : "") + u;
|
|
@@ -187,9 +203,11 @@ function headerLine(state: RunState, theme: Theme): string {
|
|
|
187
203
|
? theme.fg("success", "✓")
|
|
188
204
|
: state.status === "failed"
|
|
189
205
|
? theme.fg("error", "✗")
|
|
190
|
-
: state.status === "
|
|
191
|
-
? theme.fg("
|
|
192
|
-
:
|
|
206
|
+
: state.status === "blocked"
|
|
207
|
+
? theme.fg("error", "⊗")
|
|
208
|
+
: state.status === "paused"
|
|
209
|
+
? theme.fg("warning", "‖")
|
|
210
|
+
: theme.fg("warning", spinnerFrame());
|
|
193
211
|
|
|
194
212
|
let line =
|
|
195
213
|
`${head} ${theme.fg("toolTitle", theme.bold("taskflow"))} ` +
|
|
@@ -197,6 +215,7 @@ function headerLine(state: RunState, theme: Theme): string {
|
|
|
197
215
|
theme.fg("muted", ` ${done}/${total}`);
|
|
198
216
|
if (running) line += theme.fg("warning", ` · ${running}▸`);
|
|
199
217
|
if (failed) line += theme.fg("error", ` · ${failed}✗`);
|
|
218
|
+
if (state.status === "blocked") line += theme.fg("error", " · blocked");
|
|
200
219
|
const cost = aggregateCost(state);
|
|
201
220
|
if (cost) line += theme.fg("muted", ` · $${cost.toFixed(3)}`);
|
|
202
221
|
const el = runElapsed(state);
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive run-history view for `/tf runs` (ctx.ui.custom).
|
|
3
|
+
* List view: navigate runs; Enter → detail; r → resume; Esc/q → close.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
8
|
+
import { renderProgress, summarizeRun } from "./render.ts";
|
|
9
|
+
import type { RunState } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export interface RunHistoryResult {
|
|
12
|
+
action: "resume";
|
|
13
|
+
runId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function statusBadge(status: RunState["status"], theme: Theme): string {
|
|
17
|
+
switch (status) {
|
|
18
|
+
case "completed":
|
|
19
|
+
return theme.fg("success", "✓ done");
|
|
20
|
+
case "failed":
|
|
21
|
+
return theme.fg("error", "✗ failed");
|
|
22
|
+
case "blocked":
|
|
23
|
+
return theme.fg("error", "⊗ blocked");
|
|
24
|
+
case "paused":
|
|
25
|
+
return theme.fg("warning", "‖ paused");
|
|
26
|
+
default:
|
|
27
|
+
return theme.fg("warning", "◐ running");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function timeAgo(ts: number): string {
|
|
32
|
+
const s = Math.floor((Date.now() - ts) / 1000);
|
|
33
|
+
if (s < 60) return `${s}s ago`;
|
|
34
|
+
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
|
35
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
|
|
36
|
+
return `${Math.floor(s / 86400)}d ago`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isResumable(r: RunState): boolean {
|
|
40
|
+
return r.status === "paused" || r.status === "failed" || r.status === "blocked";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export class RunHistoryComponent {
|
|
44
|
+
private runs: RunState[];
|
|
45
|
+
private theme: Theme;
|
|
46
|
+
private onDone: (result?: RunHistoryResult) => void;
|
|
47
|
+
private selected = 0;
|
|
48
|
+
private mode: "list" | "detail" = "list";
|
|
49
|
+
private cachedWidth?: number;
|
|
50
|
+
private cachedLines?: string[];
|
|
51
|
+
|
|
52
|
+
constructor(runs: RunState[], theme: Theme, onDone: (result?: RunHistoryResult) => void) {
|
|
53
|
+
this.runs = runs;
|
|
54
|
+
this.theme = theme;
|
|
55
|
+
this.onDone = onDone;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
handleInput(data: string): void {
|
|
59
|
+
this.invalidate();
|
|
60
|
+
if (this.mode === "detail") {
|
|
61
|
+
if (matchesKey(data, "escape")) {
|
|
62
|
+
this.mode = "list";
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (data === "r" && isResumable(this.runs[this.selected])) {
|
|
66
|
+
this.onDone({ action: "resume", runId: this.runs[this.selected].runId });
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// list mode
|
|
71
|
+
if (matchesKey(data, "escape") || data === "q" || matchesKey(data, "ctrl+c")) {
|
|
72
|
+
this.onDone();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (matchesKey(data, "up")) {
|
|
76
|
+
this.selected = (this.selected - 1 + this.runs.length) % this.runs.length;
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (matchesKey(data, "down")) {
|
|
80
|
+
this.selected = (this.selected + 1) % this.runs.length;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (matchesKey(data, "return")) {
|
|
84
|
+
this.mode = "detail";
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (data === "r" && isResumable(this.runs[this.selected])) {
|
|
88
|
+
this.onDone({ action: "resume", runId: this.runs[this.selected].runId });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
render(width: number): string[] {
|
|
93
|
+
if (this.cachedLines && this.cachedWidth === width) return this.cachedLines;
|
|
94
|
+
const th = this.theme;
|
|
95
|
+
const lines: string[] = [""];
|
|
96
|
+
|
|
97
|
+
if (this.mode === "detail") {
|
|
98
|
+
const run = this.runs[this.selected];
|
|
99
|
+
lines.push(truncateToWidth(` ${th.fg("accent", "Run ")}${th.fg("muted", run.runId)}`, width));
|
|
100
|
+
lines.push("");
|
|
101
|
+
for (const l of renderProgress(run, th).split("\n")) lines.push(truncateToWidth(l, width));
|
|
102
|
+
lines.push("");
|
|
103
|
+
const hint = isResumable(run) ? "Esc back · r resume" : "Esc back";
|
|
104
|
+
lines.push(truncateToWidth(` ${th.fg("dim", hint)}`, width));
|
|
105
|
+
lines.push("");
|
|
106
|
+
this.cachedWidth = width;
|
|
107
|
+
this.cachedLines = lines;
|
|
108
|
+
return lines;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// list mode
|
|
112
|
+
const header =
|
|
113
|
+
th.fg("borderMuted", "─".repeat(3)) +
|
|
114
|
+
th.fg("accent", " Taskflow runs ") +
|
|
115
|
+
th.fg("borderMuted", "─".repeat(Math.max(0, width - 18)));
|
|
116
|
+
lines.push(truncateToWidth(header, width));
|
|
117
|
+
lines.push("");
|
|
118
|
+
|
|
119
|
+
this.runs.forEach((run, i) => {
|
|
120
|
+
const sel = i === this.selected;
|
|
121
|
+
const marker = sel ? th.fg("accent", "❯ ") : " ";
|
|
122
|
+
const badge = statusBadge(run.status, th);
|
|
123
|
+
const name = sel ? th.fg("text", run.flowName) : th.fg("muted", run.flowName);
|
|
124
|
+
const meta = th.fg("dim", `${summarizeRun(run)} · ${timeAgo(run.updatedAt)}`);
|
|
125
|
+
lines.push(truncateToWidth(` ${marker}${badge} ${name} ${meta}`, width));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
lines.push("");
|
|
129
|
+
lines.push(truncateToWidth(` ${th.fg("dim", "↑↓ select · Enter details · r resume · q close")}`, width));
|
|
130
|
+
lines.push("");
|
|
131
|
+
|
|
132
|
+
this.cachedWidth = width;
|
|
133
|
+
this.cachedLines = lines;
|
|
134
|
+
return lines;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
invalidate(): void {
|
|
138
|
+
this.cachedWidth = undefined;
|
|
139
|
+
this.cachedLines = undefined;
|
|
140
|
+
}
|
|
141
|
+
}
|
package/extensions/runtime.ts
CHANGED
|
@@ -178,7 +178,9 @@ async function executePhase(
|
|
|
178
178
|
}
|
|
179
179
|
emitProgress();
|
|
180
180
|
});
|
|
181
|
-
|
|
181
|
+
const ps = resultToPhaseState(phase.id, r, inputHash, parseJson);
|
|
182
|
+
if (type === "gate" && ps.status === "done") ps.gate = parseGateVerdict(r.output);
|
|
183
|
+
return ps;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
186
|
if (type === "parallel") {
|
|
@@ -285,6 +287,36 @@ function defaultAgent(deps: RuntimeDeps): string {
|
|
|
285
287
|
return deps.agents[0]?.name ?? "default";
|
|
286
288
|
}
|
|
287
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Parse a gate phase's output into a verdict. Blocks the flow only on an
|
|
292
|
+
* explicit negative signal; ambiguous output passes (fail-open).
|
|
293
|
+
* Accepts JSON ({continue|pass: bool} or {verdict: "..."}) or a text marker
|
|
294
|
+
* `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins).
|
|
295
|
+
*/
|
|
296
|
+
export function parseGateVerdict(output: string): { verdict: "pass" | "block"; reason?: string } {
|
|
297
|
+
const json = safeParse(output);
|
|
298
|
+
if (json && typeof json === "object") {
|
|
299
|
+
const o = json as Record<string, unknown>;
|
|
300
|
+
if (typeof o.continue === "boolean") return { verdict: o.continue ? "pass" : "block", reason: asReason(o.reason) };
|
|
301
|
+
if (typeof o.pass === "boolean") return { verdict: o.pass ? "pass" : "block", reason: asReason(o.reason) };
|
|
302
|
+
if (typeof o.verdict === "string") {
|
|
303
|
+
const block = /block|fail|stop|reject|halt|\bno\b/i.test(o.verdict);
|
|
304
|
+
return { verdict: block ? "block" : "pass", reason: asReason(o.reason) };
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const matches = [...output.matchAll(/VERDICT\s*[:=]\s*(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)/gi)];
|
|
308
|
+
if (matches.length) {
|
|
309
|
+
const v = matches[matches.length - 1][1].toUpperCase();
|
|
310
|
+
const pass = v === "PASS" || v === "OK";
|
|
311
|
+
return { verdict: pass ? "pass" : "block" };
|
|
312
|
+
}
|
|
313
|
+
return { verdict: "pass" };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function asReason(v: unknown): string | undefined {
|
|
317
|
+
return typeof v === "string" && v.trim() ? v.trim() : undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
288
320
|
/**
|
|
289
321
|
* Execute a full taskflow. Mutates and persists `state` as it progresses.
|
|
290
322
|
*/
|
|
@@ -297,6 +329,9 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
297
329
|
deps.onProgress?.(state);
|
|
298
330
|
|
|
299
331
|
let aborted = false;
|
|
332
|
+
let gateBlocked = false;
|
|
333
|
+
let gateReason = "";
|
|
334
|
+
let gateOutput = "";
|
|
300
335
|
|
|
301
336
|
for (const layer of layers) {
|
|
302
337
|
if (deps.signal?.aborted) {
|
|
@@ -308,13 +343,13 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
308
343
|
await mapWithConcurrencyLimit(layer, layerConcurrency, async (phase) => {
|
|
309
344
|
// Snapshot prior state BEFORE marking running, so resume cache checks work.
|
|
310
345
|
const prior = state.phases[phase.id];
|
|
311
|
-
// Skip if a dependency failed
|
|
346
|
+
// Skip if a dependency failed, or an upstream gate blocked the flow.
|
|
312
347
|
const failedDep = dependenciesOf(phase).some((d) => state.phases[d]?.status === "failed");
|
|
313
|
-
if (failedDep) {
|
|
348
|
+
if (gateBlocked || failedDep) {
|
|
314
349
|
state.phases[phase.id] = {
|
|
315
350
|
id: phase.id,
|
|
316
351
|
status: "skipped",
|
|
317
|
-
error: "Upstream dependency failed",
|
|
352
|
+
error: gateBlocked ? `Gate blocked${gateReason ? `: ${gateReason}` : ""}` : "Upstream dependency failed",
|
|
318
353
|
endedAt: Date.now(),
|
|
319
354
|
usage: emptyUsage(),
|
|
320
355
|
};
|
|
@@ -333,6 +368,11 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
333
368
|
|
|
334
369
|
const ps = await executePhase(phase, state, deps, prior, () => deps.onProgress?.(state));
|
|
335
370
|
state.phases[phase.id] = ps;
|
|
371
|
+
if ((phase.type ?? "agent") === "gate" && ps.gate?.verdict === "block") {
|
|
372
|
+
gateBlocked = true;
|
|
373
|
+
gateReason = ps.gate.reason ?? "";
|
|
374
|
+
gateOutput = ps.output ?? "";
|
|
375
|
+
}
|
|
336
376
|
deps.persist?.(state);
|
|
337
377
|
deps.onProgress?.(state);
|
|
338
378
|
});
|
|
@@ -342,14 +382,19 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
|
|
|
342
382
|
const finalState = state.phases[fp.id];
|
|
343
383
|
const anyFailed = Object.values(state.phases).some((p) => p.status === "failed");
|
|
344
384
|
|
|
345
|
-
state.status = aborted ? "paused" : anyFailed ? "failed" : "completed";
|
|
385
|
+
state.status = aborted ? "paused" : gateBlocked ? "blocked" : anyFailed ? "failed" : "completed";
|
|
346
386
|
deps.persist?.(state);
|
|
347
387
|
deps.onProgress?.(state);
|
|
348
388
|
|
|
389
|
+
let finalOutput = finalState?.output ?? "(no output)";
|
|
390
|
+
if (gateBlocked && (!finalState || finalState.status === "skipped")) {
|
|
391
|
+
finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${gateOutput ? `\n\n${gateOutput}` : ""}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
349
394
|
const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
|
|
350
395
|
return {
|
|
351
396
|
state,
|
|
352
|
-
finalOutput
|
|
397
|
+
finalOutput,
|
|
353
398
|
ok: state.status === "completed",
|
|
354
399
|
totalUsage,
|
|
355
400
|
};
|
package/extensions/store.ts
CHANGED
|
@@ -37,6 +37,8 @@ export interface PhaseState {
|
|
|
37
37
|
subProgress?: { done: number; total: number; running: number; failed: number };
|
|
38
38
|
/** Latest activity line from the running subagent(s). */
|
|
39
39
|
liveText?: string;
|
|
40
|
+
/** Gate verdict (gate phases only). */
|
|
41
|
+
gate?: { verdict: "pass" | "block"; reason?: string };
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export interface RunState {
|
|
@@ -44,7 +46,7 @@ export interface RunState {
|
|
|
44
46
|
flowName: string;
|
|
45
47
|
def: Taskflow;
|
|
46
48
|
args: Record<string, unknown>;
|
|
47
|
-
status: "running" | "completed" | "failed" | "paused";
|
|
49
|
+
status: "running" | "completed" | "failed" | "paused" | "blocked";
|
|
48
50
|
phases: Record<string, PhaseState>;
|
|
49
51
|
createdAt: number;
|
|
50
52
|
updatedAt: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-taskflow",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Lightweight workflow orchestration for the Pi coding agent — declarative multi-phase taskflows with dynamic fan-out, isolated subagent context, resumable runs, and saveable commands.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -55,9 +55,29 @@ Call the `taskflow` tool. To run a brand-new flow you write inline, pass
|
|
|
55
55
|
| `agent` | one subagent runs `task` |
|
|
56
56
|
| `parallel` | run `branches[]` concurrently |
|
|
57
57
|
| `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound |
|
|
58
|
-
| `gate` | quality/review step
|
|
58
|
+
| `gate` | quality/review step that can **halt the flow** (see below) |
|
|
59
59
|
| `reduce` | aggregate `from[]` phases into one output |
|
|
60
60
|
|
|
61
|
+
### Gate phases (quality control)
|
|
62
|
+
|
|
63
|
+
A `gate` phase runs an agent to review upstream output and can **block the rest
|
|
64
|
+
of the workflow**. End the gate task's instructions by asking the agent to emit a
|
|
65
|
+
verdict the runtime can read:
|
|
66
|
+
|
|
67
|
+
- a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or
|
|
68
|
+
- JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}`
|
|
69
|
+
|
|
70
|
+
On **BLOCK**, downstream phases are skipped and the run ends as `blocked` with the
|
|
71
|
+
reason surfaced. Ambiguous output **fails open** (treated as PASS) so a gate never
|
|
72
|
+
halts the flow by accident. Example gate task:
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
Review the audit results below. If any endpoint is missing auth, end with
|
|
76
|
+
"VERDICT: BLOCK" and a one-line reason; otherwise end with "VERDICT: PASS".
|
|
77
|
+
|
|
78
|
+
{steps.audit.output}
|
|
79
|
+
```
|
|
80
|
+
|
|
61
81
|
### Interpolation
|
|
62
82
|
|
|
63
83
|
- `{args.X}` — invocation argument
|