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 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` 阶段 + run 历史 TUI |
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
 
@@ -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.ui.notify(
315
- runs.map((r) => `${r.runId} [${r.status}] ${r.flowName} — ${summarizeRun(r)}`).join("\n"),
316
- "info",
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
 
@@ -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") return theme.fg("muted", "skipped · upstream failed");
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 === "paused"
191
- ? theme.fg("warning", "")
192
- : theme.fg("warning", spinnerFrame());
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
+ }
@@ -178,7 +178,9 @@ async function executePhase(
178
178
  }
179
179
  emitProgress();
180
180
  });
181
- return resultToPhaseState(phase.id, r, inputHash, parseJson);
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 (unless this phase is optional).
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: finalState?.output ?? "(no output)",
397
+ finalOutput,
353
398
  ok: state.status === "completed",
354
399
  totalUsage,
355
400
  };
@@ -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.2",
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",
@@ -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 (a focused agent pass) |
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