pi-taskflow 0.0.2 → 0.0.4

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,8 +17,9 @@ 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
- import { finalPhase, type Taskflow, validateTaskflow } from "./schema.ts";
22
+ import { finalPhase, type Taskflow, validateTaskflow, desugar, isShorthand } from "./schema.ts";
22
23
  import {
23
24
  getFlow,
24
25
  listFlows,
@@ -40,6 +41,14 @@ interface TaskflowDetails {
40
41
  /** pi reads `isError` at runtime to mark tool failures; it is not in the public type. */
41
42
  type ToolResult = AgentToolResult<TaskflowDetails> & { isError?: boolean };
42
43
 
44
+ const ShorthandStep = Type.Object(
45
+ {
46
+ agent: Type.Optional(Type.String({ description: "Agent for this step (defaults to the first available agent)" })),
47
+ task: Type.String({ description: "Task prompt for this step (supports {previous.output} in chains)" }),
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
43
52
  const TaskflowParams = Type.Object({
44
53
  action: StringEnum(["run", "save", "resume", "list"] as const, {
45
54
  description: "What to do: run a flow, save a definition, resume a paused run, or list saved flows",
@@ -52,6 +61,24 @@ const TaskflowParams = Type.Object({
52
61
  "Inline taskflow definition (JSON object matching the taskflow DSL). Use to run or save a new flow.",
53
62
  }),
54
63
  ),
64
+ // --- Shorthand (non-DAG) modes, like the subagent tool. No DSL required. ---
65
+ agent: Type.Optional(
66
+ Type.String({ description: "Shorthand single mode: agent to run with `task` (like subagent single mode)" }),
67
+ ),
68
+ task: Type.Optional(
69
+ Type.String({ description: "Shorthand single mode: the task prompt (like subagent single mode)" }),
70
+ ),
71
+ tasks: Type.Optional(
72
+ Type.Array(ShorthandStep, {
73
+ description: "Shorthand parallel mode: run these tasks concurrently and merge results (like subagent parallel)",
74
+ }),
75
+ ),
76
+ chain: Type.Optional(
77
+ Type.Array(ShorthandStep, {
78
+ description:
79
+ "Shorthand chain mode: run sequentially; reference the prior step with {previous.output} (like subagent chain)",
80
+ }),
81
+ ),
55
82
  args: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Invocation arguments for the flow" })),
56
83
  runId: Type.Optional(Type.String({ description: "Run id to resume (for action=resume)" })),
57
84
  scope: Type.Optional(
@@ -174,6 +201,7 @@ export default function (pi: ExtensionAPI) {
174
201
  "Orchestrate a multi-phase workflow of subagents from a declarative definition.",
175
202
  "Phases (agent, parallel, map, gate, reduce) form a DAG; intermediate outputs stay out of your context — only the final phase output is returned.",
176
203
  "Use action=run with an inline `define` (you write the DSL) or a saved `name`.",
204
+ "For simple non-DAG delegations (like the subagent tool) skip the DSL: pass `task` (+optional `agent`) for one task, `tasks:[{task,agent?}]` to run in parallel, or `chain:[{task,agent?}]` to run sequentially (reference the prior step with {previous.output}).",
177
205
  "Use action=save to persist a definition as a reusable /tf:<name> command. action=resume continues a paused run. action=list shows saved flows.",
178
206
  "DSL: {name, args?, concurrency?, phases:[{id, type, agent, task, dependsOn?, over?(map), as?(map), branches?(parallel), from?(reduce), output?:'json', final?}]}.",
179
207
  "Interpolation: {args.X}, {steps.ID.output}, {steps.ID.json}, {item} (map), {previous.output}.",
@@ -182,7 +210,7 @@ export default function (pi: ExtensionAPI) {
182
210
  promptSnippet: "Orchestrate many subagents over a whole codebase/many items (declarative DAG with map fan-out)",
183
211
  promptGuidelines: [
184
212
  "Prefer taskflow whenever a request spans a whole project/codebase or many items — e.g. 'explore / 探索 / 审计 / analyze the project', auditing endpoints, reviewing or migrating many files/modules, or cross-checked research. It fans out to many subagents across phases and aggregates the result, keeping intermediate work out of your context.",
185
- "Choose taskflow over ad-hoc parallel subagents when the work has multiple phases (discover → work → review → report), needs dynamic fan-out over a discovered list, or should be saved and rerun. Use the plain subagent tool only for a single delegated task.",
213
+ "Choose taskflow over ad-hoc parallel subagents when the work has multiple phases (discover → work → review → report), needs dynamic fan-out over a discovered list, or should be saved and rerun. For simple single/parallel/chain delegations use the shorthand `task`/`tasks`/`chain` (no DSL) when you want the run tracked, resumable, or saveable; otherwise the plain subagent tool is fine.",
186
214
  "For taskflow map phases, have the upstream phase emit a JSON array and set output:'json'.",
187
215
  ],
188
216
 
@@ -208,18 +236,39 @@ export default function (pi: ExtensionAPI) {
208
236
  return finalResult(action, result);
209
237
  }
210
238
 
211
- // resolve the definition (inline define wins, else saved name)
239
+ // resolve the definition: inline `define` / shorthand (single|parallel|chain), else saved `name`.
212
240
  let def: Taskflow | undefined;
213
- if (params.define) {
214
- const v = validateTaskflow(params.define);
241
+
242
+ // A shorthand spec can come from `define` (no phases) or top-level params.
243
+ const shorthandSpec: unknown =
244
+ params.define ??
245
+ (params.chain
246
+ ? { chain: params.chain, name: params.name }
247
+ : params.tasks
248
+ ? { tasks: params.tasks, name: params.name }
249
+ : params.task
250
+ ? { task: params.task, agent: params.agent, name: params.name }
251
+ : undefined);
252
+
253
+ if (shorthandSpec !== undefined) {
254
+ let candidate: unknown = shorthandSpec;
255
+ if (isShorthand(candidate)) {
256
+ try {
257
+ candidate = desugar(candidate);
258
+ } catch (e) {
259
+ return errorResult(action, `Invalid shorthand: ${e instanceof Error ? e.message : String(e)}`);
260
+ }
261
+ }
262
+ const v = validateTaskflow(candidate);
215
263
  if (!v.ok) return errorResult(action, `Invalid taskflow:\n- ${v.errors.join("\n- ")}`);
216
- def = params.define as Taskflow;
264
+ def = candidate as Taskflow;
217
265
  } else if (params.name) {
218
266
  const saved = getFlow(ctx.cwd, params.name);
219
267
  if (!saved) return errorResult(action, `Saved flow not found: ${params.name}`);
220
268
  def = saved.def;
221
269
  }
222
- if (!def) return errorResult(action, "Provide 'define' (inline) or 'name' (saved).");
270
+ if (!def)
271
+ return errorResult(action, "Provide 'define' (DSL), shorthand 'task'/'tasks'/'chain', or 'name' (saved).");
223
272
 
224
273
  // save
225
274
  if (action === "save") {
@@ -255,10 +304,23 @@ export default function (pi: ExtensionAPI) {
255
304
 
256
305
  renderCall(args, theme) {
257
306
  const action = args.action ?? "run";
258
- const name = args.name || (args.define as any)?.name || "(inline)";
259
- let text = theme.fg("toolTitle", theme.bold("taskflow ")) + theme.fg("accent", `${action} `) + theme.fg("muted", name);
307
+ let label = args.name || (args.define as { name?: string } | undefined)?.name;
308
+ let suffix = "";
260
309
  const phases = (args.define as Taskflow | undefined)?.phases;
261
- if (phases) text += theme.fg("dim", ` (${phases.length} phases)`);
310
+ if (phases) suffix = ` (${phases.length} phases)`;
311
+ else if (args.chain) {
312
+ label ||= "chain";
313
+ suffix = ` (${(args.chain as unknown[]).length} steps)`;
314
+ } else if (args.tasks) {
315
+ label ||= "parallel";
316
+ suffix = ` (${(args.tasks as unknown[]).length} tasks)`;
317
+ } else if (args.task) {
318
+ label ||= "task";
319
+ }
320
+ label ||= "(inline)";
321
+ let text =
322
+ theme.fg("toolTitle", theme.bold("taskflow ")) + theme.fg("accent", `${action} `) + theme.fg("muted", label);
323
+ if (suffix) text += theme.fg("dim", suffix);
262
324
  return new Text(text, 0, 0);
263
325
  },
264
326
 
@@ -306,15 +368,30 @@ export default function (pi: ExtensionAPI) {
306
368
  }
307
369
 
308
370
  if (sub === "runs") {
309
- const runs = listRuns(ctx.cwd);
371
+ const runs = listRuns(ctx.cwd, 50);
310
372
  if (runs.length === 0) {
311
373
  ctx.ui.notify("No taskflow runs yet.", "info");
312
374
  return;
313
375
  }
314
- ctx.ui.notify(
315
- runs.map((r) => `${r.runId} [${r.status}] ${r.flowName} — ${summarizeRun(r)}`).join("\n"),
316
- "info",
317
- );
376
+ if (!ctx.hasUI) {
377
+ ctx.ui.notify(
378
+ runs.map((r) => `${r.runId} [${r.status}] ${r.flowName} — ${summarizeRun(r)}`).join("\n"),
379
+ "info",
380
+ );
381
+ return;
382
+ }
383
+ const result = await ctx.ui.custom<RunHistoryResult | undefined>((_tui, theme, _kb, done) => {
384
+ return new RunHistoryComponent(runs, theme, (r) => done(r));
385
+ });
386
+ if (result?.action === "resume") {
387
+ if (ctx.isIdle()) {
388
+ pi.sendUserMessage(
389
+ `Resume the taskflow run "${result.runId}" using the taskflow tool with action="resume", runId="${result.runId}".`,
390
+ );
391
+ } else {
392
+ ctx.ui.notify("Agent is busy; try /tf resume when idle.", "warning");
393
+ }
394
+ }
318
395
  return;
319
396
  }
320
397
 
@@ -9,7 +9,7 @@ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
9
9
  import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
10
10
  import { formatTokens, type UsageStats } from "./runner.ts";
11
11
  import type { PhaseState, RunState } from "./store.ts";
12
- import type { Phase } from "./schema.ts";
12
+ import { dependenciesOf, type Phase, topoLayers } from "./schema.ts";
13
13
 
14
14
  // Single-width glyphs (Geometric Shapes / check marks) — keep columns aligned.
15
15
  const ICON: Record<PhaseState["status"], { ch: string; color: string }> = {
@@ -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);
@@ -204,35 +223,81 @@ function headerLine(state: RunState, theme: Theme): string {
204
223
  return line;
205
224
  }
206
225
 
207
- /** The full dense progress block (header + aligned phase rows). */
226
+ /**
227
+ * Left-gutter rail glyph for a phase at `i` within a parallel group of `size`.
228
+ * A group is a topological layer with >1 phase (they run concurrently); the
229
+ * bracket (┌ ├ └) visually fans them out from the preceding layer. Single-phase
230
+ * layers get a blank gutter so the column stays quiet.
231
+ */
232
+ function railGlyph(i: number, size: number): string {
233
+ if (size <= 1) return " ";
234
+ if (i === 0) return "┌";
235
+ if (i === size - 1) return "└";
236
+ return "├";
237
+ }
238
+
239
+ /** The full dense progress block (header + DAG-ordered phase rows). */
208
240
  export function renderProgress(state: RunState, theme: Theme): string {
209
241
  const phases = state.def.phases;
210
242
  const idW = Math.max(...phases.map((p) => p.id.length), 2);
211
243
  const typeW = Math.max(...phases.map((p) => (p.type ?? "agent").length), 4);
244
+ const defIndex = new Map(phases.map((p, i) => [p.id, i]));
245
+
246
+ // Render in topological order: each layer is a set of phases that can run
247
+ // concurrently; later layers depend on earlier ones. This makes the DAG's
248
+ // flow legible top-to-bottom without drawing a full graph.
249
+ const layers = topoLayers(phases);
250
+ const rendered = new Set<string>();
212
251
 
213
252
  let text = headerLine(state, theme);
214
- for (const phase of phases) {
253
+
254
+ const renderRow = (phase: Phase, rail: string, prevLayerIds: Set<string>) => {
215
255
  const ps = state.phases[phase.id];
216
256
  const status = ps?.status ?? "pending";
217
257
  const id = phase.id.padEnd(idW);
218
258
  const type = (phase.type ?? "agent").padEnd(typeW);
219
259
  const detail = phaseDetail(phase, ps, theme);
260
+
261
+ // Annotate only "long" edges — dependencies that skip past the adjacent
262
+ // layer. Edges into the immediately-preceding layer are implied by position
263
+ // (and the rail), so showing them would just add noise.
264
+ const longEdges = dependenciesOf(phase).filter((d) => !prevLayerIds.has(d));
265
+ const dep = longEdges.length
266
+ ? theme.fg("dim", ` ↳ ${longEdges.join(", ")}`)
267
+ : "";
268
+
269
+ const gutter = rail === " " ? " " : theme.fg("borderMuted", rail);
220
270
  text +=
221
- `\n ${icon(status, theme)} ` +
271
+ `\n ${gutter} ${icon(status, theme)} ` +
222
272
  theme.fg(status === "pending" ? "dim" : "text", id) +
223
273
  " " +
224
274
  theme.fg("dim", type) +
225
275
  " " +
226
- detail;
276
+ detail +
277
+ dep;
227
278
 
228
279
  // Live activity sub-line (only while running, only if we have a message).
229
280
  if (status === "running" && ps?.liveText) {
230
- const indent = " ".repeat(2 + 2 + idW + 2);
281
+ const indent = " ".repeat(2 + 2 + 2 + idW + 2);
231
282
  const msg = ps.liveText.replace(/\s+/g, " ").trim();
232
283
  const snip = msg.length > 88 ? `${msg.slice(0, 88)}…` : msg;
233
284
  text += `\n${indent}${theme.fg("dim", "› ")}${theme.fg("muted", snip)}`;
234
285
  }
286
+ rendered.add(phase.id);
287
+ };
288
+
289
+ let prevLayerIds = new Set<string>();
290
+ for (const layer of layers) {
291
+ const ordered = [...layer].sort((a, b) => (defIndex.get(a.id) ?? 0) - (defIndex.get(b.id) ?? 0));
292
+ ordered.forEach((phase, i) => renderRow(phase, railGlyph(i, ordered.length), prevLayerIds));
293
+ prevLayerIds = new Set(ordered.map((p) => p.id));
235
294
  }
295
+
296
+ // Safety net: render any phase a malformed DAG left out of the layering.
297
+ for (const phase of phases) {
298
+ if (!rendered.has(phase.id)) renderRow(phase, " ", prevLayerIds);
299
+ }
300
+
236
301
  return text;
237
302
  }
238
303
 
@@ -289,7 +289,7 @@ export async function runAgentTask(
289
289
  }
290
290
  if (tmpPromptDir) {
291
291
  try {
292
- fs.rmdirSync(tmpPromptDir);
292
+ fs.rmSync(tmpPromptDir, { recursive: true, force: true });
293
293
  } catch {
294
294
  /* ignore */
295
295
  }
@@ -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") {
@@ -257,12 +259,20 @@ async function executePhase(
257
259
 
258
260
  /** Resolve a `{steps.x.json}`-style ref directly to its parsed value (bypassing stringify). */
259
261
  function directRef(over: string, state: RunState): unknown {
260
- const m = over.match(/^\{steps\.([a-zA-Z0-9_]+)\.(output|json)\}$/);
262
+ const m = over.match(/^\{steps\.([a-zA-Z0-9_]+)\.(output|json)(?:\.([a-zA-Z0-9_]+(?:\.[a-zA-Z0-9_]+)*))?\}$/);
261
263
  if (!m) return undefined;
262
264
  const step = state.phases[m[1]];
263
265
  if (!step || step.status !== "done") return undefined;
264
- if (m[2] === "json") return step.json ?? safeParse(step.output ?? "");
265
- return safeParse(step.output ?? "");
266
+ let value: unknown;
267
+ if (m[2] === "json") value = step.json ?? safeParse(step.output ?? "");
268
+ else value = safeParse(step.output ?? "");
269
+ if (m[3]) {
270
+ for (const key of m[3].split(".")) {
271
+ if (value == null || typeof value !== "object") return undefined;
272
+ value = (value as Record<string, unknown>)[key];
273
+ }
274
+ }
275
+ return value;
266
276
  }
267
277
 
268
278
  function lastCompletedOutput(state: RunState, phase: Phase): string | undefined {
@@ -285,6 +295,36 @@ function defaultAgent(deps: RuntimeDeps): string {
285
295
  return deps.agents[0]?.name ?? "default";
286
296
  }
287
297
 
298
+ /**
299
+ * Parse a gate phase's output into a verdict. Blocks the flow only on an
300
+ * explicit negative signal; ambiguous output passes (fail-open).
301
+ * Accepts JSON ({continue|pass: bool} or {verdict: "..."}) or a text marker
302
+ * `VERDICT: PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT` (last occurrence wins).
303
+ */
304
+ export function parseGateVerdict(output: string): { verdict: "pass" | "block"; reason?: string } {
305
+ const json = safeParse(output);
306
+ if (json && typeof json === "object") {
307
+ const o = json as Record<string, unknown>;
308
+ if (typeof o.continue === "boolean") return { verdict: o.continue ? "pass" : "block", reason: asReason(o.reason) };
309
+ if (typeof o.pass === "boolean") return { verdict: o.pass ? "pass" : "block", reason: asReason(o.reason) };
310
+ if (typeof o.verdict === "string") {
311
+ const block = /block|fail|stop|reject|halt|\bno\b/i.test(o.verdict);
312
+ return { verdict: block ? "block" : "pass", reason: asReason(o.reason) };
313
+ }
314
+ }
315
+ const matches = [...output.matchAll(/VERDICT\s*[:=]\s*(PASS|BLOCK|FAIL|STOP|OK|REJECT|HALT)/gi)];
316
+ if (matches.length) {
317
+ const v = matches[matches.length - 1][1].toUpperCase();
318
+ const pass = v === "PASS" || v === "OK";
319
+ return { verdict: pass ? "pass" : "block" };
320
+ }
321
+ return { verdict: "pass" };
322
+ }
323
+
324
+ function asReason(v: unknown): string | undefined {
325
+ return typeof v === "string" && v.trim() ? v.trim() : undefined;
326
+ }
327
+
288
328
  /**
289
329
  * Execute a full taskflow. Mutates and persists `state` as it progresses.
290
330
  */
@@ -297,6 +337,9 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
297
337
  deps.onProgress?.(state);
298
338
 
299
339
  let aborted = false;
340
+ let gateBlocked = false;
341
+ let gateReason = "";
342
+ let gateOutput = "";
300
343
 
301
344
  for (const layer of layers) {
302
345
  if (deps.signal?.aborted) {
@@ -308,13 +351,13 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
308
351
  await mapWithConcurrencyLimit(layer, layerConcurrency, async (phase) => {
309
352
  // Snapshot prior state BEFORE marking running, so resume cache checks work.
310
353
  const prior = state.phases[phase.id];
311
- // Skip if a dependency failed (unless this phase is optional).
354
+ // Skip if a dependency failed, or an upstream gate blocked the flow.
312
355
  const failedDep = dependenciesOf(phase).some((d) => state.phases[d]?.status === "failed");
313
- if (failedDep) {
356
+ if (gateBlocked || failedDep) {
314
357
  state.phases[phase.id] = {
315
358
  id: phase.id,
316
359
  status: "skipped",
317
- error: "Upstream dependency failed",
360
+ error: gateBlocked ? `Gate blocked${gateReason ? `: ${gateReason}` : ""}` : "Upstream dependency failed",
318
361
  endedAt: Date.now(),
319
362
  usage: emptyUsage(),
320
363
  };
@@ -333,6 +376,11 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
333
376
 
334
377
  const ps = await executePhase(phase, state, deps, prior, () => deps.onProgress?.(state));
335
378
  state.phases[phase.id] = ps;
379
+ if ((phase.type ?? "agent") === "gate" && ps.gate?.verdict === "block") {
380
+ gateBlocked = true;
381
+ gateReason = ps.gate.reason ?? "";
382
+ gateOutput = ps.output ?? "";
383
+ }
336
384
  deps.persist?.(state);
337
385
  deps.onProgress?.(state);
338
386
  });
@@ -342,14 +390,19 @@ export async function executeTaskflow(state: RunState, deps: RuntimeDeps): Promi
342
390
  const finalState = state.phases[fp.id];
343
391
  const anyFailed = Object.values(state.phases).some((p) => p.status === "failed");
344
392
 
345
- state.status = aborted ? "paused" : anyFailed ? "failed" : "completed";
393
+ state.status = aborted ? "paused" : gateBlocked ? "blocked" : anyFailed ? "failed" : "completed";
346
394
  deps.persist?.(state);
347
395
  deps.onProgress?.(state);
348
396
 
397
+ let finalOutput = finalState?.output ?? "(no output)";
398
+ if (gateBlocked && (!finalState || finalState.status === "skipped")) {
399
+ finalOutput = `Gate blocked the workflow.${gateReason ? `\nReason: ${gateReason}` : ""}${gateOutput ? `\n\n${gateOutput}` : ""}`;
400
+ }
401
+
349
402
  const totalUsage = aggregateUsage(Object.values(state.phases).map((p) => p.usage ?? emptyUsage()));
350
403
  return {
351
404
  state,
352
- finalOutput: finalState?.output ?? "(no output)",
405
+ finalOutput,
353
406
  ok: state.status === "completed",
354
407
  totalUsage,
355
408
  };
@@ -90,6 +90,88 @@ export type Phase = Static<typeof PhaseSchema>;
90
90
  export type Taskflow = Static<typeof TaskflowSchema>;
91
91
  export type ArgSpec = Static<typeof ArgSpecSchema>;
92
92
 
93
+ // ---------------------------------------------------------------------------
94
+ // Shorthand (non-DAG) specs — subagent-style ergonomics
95
+ // ---------------------------------------------------------------------------
96
+ //
97
+ // For simple delegations you should not have to author a phases DAG. A
98
+ // shorthand spec mirrors the subagent tool's modes and is desugared into a
99
+ // full Taskflow before validation/execution:
100
+ //
101
+ // { task, agent? } → one `agent` phase (single)
102
+ // { tasks: [{task, agent?}, ...] } → one `parallel` phase (parallel)
103
+ // { chain: [{task, agent?}, ...] } → sequential `agent` phases (chain)
104
+ //
105
+ // Chain steps reference the prior step's output with {previous.output}, exactly
106
+ // like the subagent tool's {previous} placeholder.
107
+
108
+ export interface ShorthandStep {
109
+ agent?: string;
110
+ task: string;
111
+ }
112
+
113
+ /** True when `def` is a shorthand spec (no `phases`, but a task/tasks/chain field). */
114
+ export function isShorthand(def: unknown): boolean {
115
+ if (typeof def !== "object" || def === null) return false;
116
+ const d = def as Record<string, unknown>;
117
+ if (Array.isArray(d.phases)) return false;
118
+ return Array.isArray(d.chain) || Array.isArray(d.tasks) || typeof d.task === "string";
119
+ }
120
+
121
+ function readStep(s: unknown): ShorthandStep {
122
+ if (typeof s === "string") return { task: s };
123
+ if (s && typeof s === "object") {
124
+ const o = s as Record<string, unknown>;
125
+ return { agent: typeof o.agent === "string" ? o.agent : undefined, task: String(o.task ?? "") };
126
+ }
127
+ return { task: "" };
128
+ }
129
+
130
+ /**
131
+ * Desugar a shorthand spec into a full Taskflow DAG. Throws if no recognizable
132
+ * shorthand field is present. Carries through optional name/description/
133
+ * concurrency/agentScope/args.
134
+ */
135
+ export function desugar(def: unknown): Taskflow {
136
+ if (typeof def !== "object" || def === null) throw new Error("Shorthand spec must be an object");
137
+ const d = def as Record<string, unknown>;
138
+
139
+ const meta: Partial<Taskflow> = {};
140
+ if (typeof d.description === "string") meta.description = d.description;
141
+ if (typeof d.concurrency === "number") meta.concurrency = d.concurrency;
142
+ if (d.agentScope === "user" || d.agentScope === "project" || d.agentScope === "both") meta.agentScope = d.agentScope;
143
+ if (d.args && typeof d.args === "object") meta.args = d.args as Taskflow["args"];
144
+ const nameOf = (fallback: string) => (typeof d.name === "string" && d.name.trim() ? d.name.trim() : fallback);
145
+
146
+ // chain → sequential agent phases
147
+ if (Array.isArray(d.chain) && d.chain.length > 0) {
148
+ const steps = d.chain.map(readStep);
149
+ const phases: Phase[] = steps.map((s, i) => {
150
+ const phase: Phase = { id: `step${i + 1}`, type: "agent", task: s.task };
151
+ if (s.agent) phase.agent = s.agent;
152
+ if (i > 0) phase.dependsOn = [`step${i}`];
153
+ if (i === steps.length - 1) phase.final = true;
154
+ return phase;
155
+ });
156
+ return { name: nameOf("chain"), ...meta, phases };
157
+ }
158
+
159
+ // tasks → one parallel phase (fan-out + merge), no extra aggregation agent
160
+ if (Array.isArray(d.tasks) && d.tasks.length > 0) {
161
+ const branches: ParallelTask[] = d.tasks.map(readStep).map((s) => (s.agent ? { task: s.task, agent: s.agent } : { task: s.task }));
162
+ return { name: nameOf("parallel"), ...meta, phases: [{ id: "parallel", type: "parallel", branches, final: true }] };
163
+ }
164
+
165
+ // single task → one agent phase
166
+ if (typeof d.task === "string") {
167
+ const phase: Phase = { id: "main", type: "agent", task: d.task, final: true };
168
+ if (typeof d.agent === "string") phase.agent = d.agent;
169
+ return { name: nameOf("task"), ...meta, phases: [phase] };
170
+ }
171
+
172
+ throw new Error("Shorthand spec needs one of: 'task' (single), 'tasks' (parallel), or 'chain' (sequential)");
173
+ }
174
+
93
175
  // ---------------------------------------------------------------------------
94
176
  // Validation (beyond schema: DAG integrity, phase-type requirements)
95
177
  // ---------------------------------------------------------------------------
@@ -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;
@@ -112,7 +114,7 @@ export function saveFlow(
112
114
  def: Taskflow,
113
115
  scope: "user" | "project" = "project",
114
116
  ): { filePath: string } {
115
- const dir = scope === "user" ? userFlowsDir() : findProjectFlowsDir(cwd, true)!;
117
+ const dir = scope === "user" ? userFlowsDir() : (findProjectFlowsDir(cwd, true) ?? path.join(cwd, ".pi", "taskflows"));
116
118
  fs.mkdirSync(dir, { recursive: true });
117
119
  const safe = def.name.replace(/[^\w.-]+/g, "_");
118
120
  const filePath = path.join(dir, `${safe}.json`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-taskflow",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
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",
@@ -36,7 +36,7 @@
36
36
  ],
37
37
  "scripts": {
38
38
  "typecheck": "tsc --noEmit",
39
- "test": "node --experimental-strip-types --test test/interpolate.test.ts test/schema.test.ts test/runtime.test.ts",
39
+ "test": "node --experimental-strip-types --test test/interpolate.test.ts test/schema.test.ts test/runtime.test.ts test/runner.test.ts test/store.test.ts test/agents.test.ts test/render.test.ts test/desugar.test.ts",
40
40
  "test:e2e": "PI_TASKFLOW_PI_BIN=pi node --experimental-strip-types test/e2e.mts"
41
41
  },
42
42
  "pi": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: taskflow
3
- description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use whenever a request spans a whole project or many items — deeply exploring / 探索 / auditing / 审计 / analyzing a codebase, reviewing or migrating many files or modules in parallel, cross-checked/adversarial review, codebase-wide research, or any repeatable orchestration you want to save and rerun. Prefer this over ad-hoc parallel subagents when the work has multiple phases or dynamic fan-out over a discovered list. Not for a single delegated task use the subagent tool for that.
3
+ description: Orchestrate multi-phase subagent workflows with pi-taskflow. Use whenever a request spans a whole project or many items — deeply exploring / 探索 / auditing / 审计 / analyzing a codebase, reviewing or migrating many files or modules in parallel, cross-checked/adversarial review, codebase-wide research, or any repeatable orchestration you want to save and rerun. Prefer this over ad-hoc parallel subagents when the work has multiple phases or dynamic fan-out over a discovered list. Also supports subagent-style shorthand (single / parallel / chain) for simple non-DAG delegations you want tracked, resumable, or saveable.
4
4
  ---
5
5
 
6
6
  # Taskflow
@@ -16,7 +16,36 @@ the final answer — not every step's transcript.
16
16
  - You want **cross-checked / adversarial review** before reporting.
17
17
  - You want a **repeatable** orchestration saved as a `/tf:<name>` command.
18
18
 
19
- For a single delegated task, use the `subagent` tool instead.
19
+ For a single quick delegation you can use the **shorthand modes** below (no DSL),
20
+ or the plain `subagent` tool. Use the shorthand when you want the run tracked,
21
+ resumable, or saveable as a `/tf` command.
22
+
23
+ ## Shorthand (non-DAG) — like the subagent tool
24
+
25
+ Skip the DSL entirely for simple delegations. The runtime desugars these into a
26
+ proper flow, so you still get progress, persistence, resume, and `save`.
27
+
28
+ ```jsonc
29
+ // single — one agent, one task
30
+ { "task": "Summarize the architecture of src/", "agent": "explorer" }
31
+
32
+ // parallel — run several tasks at once, outputs merged
33
+ { "tasks": [
34
+ { "task": "Audit auth in src/api", "agent": "analyst" },
35
+ { "task": "Audit input validation in src/api", "agent": "analyst" }
36
+ ] }
37
+
38
+ // chain — run sequentially; reference the prior step with {previous.output}
39
+ { "chain": [
40
+ { "task": "List the public API of src/lib", "agent": "scout" },
41
+ { "task": "Write docs for:\n{previous.output}", "agent": "writer" }
42
+ ] }
43
+ ```
44
+
45
+ - `agent` is optional (defaults to the first available agent).
46
+ - Add `name` to label the run (and to `save` it as a `/tf:<name>` command).
47
+ - Precedence if several are given: `chain` > `tasks` > `task`.
48
+ - You can pass these as top-level tool params **or** inside `define`.
20
49
 
21
50
  ## How to author a taskflow
22
51
 
@@ -55,9 +84,29 @@ Call the `taskflow` tool. To run a brand-new flow you write inline, pass
55
84
  | `agent` | one subagent runs `task` |
56
85
  | `parallel` | run `branches[]` concurrently |
57
86
  | `map` | fan out over `over` (an array) — one subagent per item, `{item}` bound |
58
- | `gate` | quality/review step (a focused agent pass) |
87
+ | `gate` | quality/review step that can **halt the flow** (see below) |
59
88
  | `reduce` | aggregate `from[]` phases into one output |
60
89
 
90
+ ### Gate phases (quality control)
91
+
92
+ A `gate` phase runs an agent to review upstream output and can **block the rest
93
+ of the workflow**. End the gate task's instructions by asking the agent to emit a
94
+ verdict the runtime can read:
95
+
96
+ - a final line `VERDICT: PASS` or `VERDICT: BLOCK` (also accepts OK/FAIL/STOP/REJECT/HALT), or
97
+ - JSON like `{"continue": false, "reason": "missing auth checks"}` / `{"verdict": "block", "reason": "..."}`
98
+
99
+ On **BLOCK**, downstream phases are skipped and the run ends as `blocked` with the
100
+ reason surfaced. Ambiguous output **fails open** (treated as PASS) so a gate never
101
+ halts the flow by accident. Example gate task:
102
+
103
+ ```
104
+ Review the audit results below. If any endpoint is missing auth, end with
105
+ "VERDICT: BLOCK" and a one-line reason; otherwise end with "VERDICT: PASS".
106
+
107
+ {steps.audit.output}
108
+ ```
109
+
61
110
  ### Interpolation
62
111
 
63
112
  - `{args.X}` — invocation argument