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 +6 -6
- package/extensions/index.ts +92 -15
- package/extensions/render.ts +75 -10
- package/extensions/runner.ts +1 -1
- package/extensions/runs-view.ts +141 -0
- package/extensions/runtime.ts +62 -9
- package/extensions/schema.ts +82 -0
- package/extensions/store.ts +4 -2
- package/package.json +2 -2
- package/skills/taskflow/SKILL.md +52 -3
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,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.
|
|
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
|
|
239
|
+
// resolve the definition: inline `define` / shorthand (single|parallel|chain), else saved `name`.
|
|
212
240
|
let def: Taskflow | undefined;
|
|
213
|
-
|
|
214
|
-
|
|
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 =
|
|
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)
|
|
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
|
-
|
|
259
|
-
let
|
|
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)
|
|
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.
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
package/extensions/render.ts
CHANGED
|
@@ -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
|
|
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")
|
|
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);
|
|
@@ -204,35 +223,81 @@ function headerLine(state: RunState, theme: Theme): string {
|
|
|
204
223
|
return line;
|
|
205
224
|
}
|
|
206
225
|
|
|
207
|
-
/**
|
|
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
|
-
|
|
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
|
|
package/extensions/runner.ts
CHANGED
|
@@ -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") {
|
|
@@ -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
|
-
|
|
265
|
-
|
|
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
|
|
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
|
|
405
|
+
finalOutput,
|
|
353
406
|
ok: state.status === "completed",
|
|
354
407
|
totalUsage,
|
|
355
408
|
};
|
package/extensions/schema.ts
CHANGED
|
@@ -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
|
// ---------------------------------------------------------------------------
|
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;
|
|
@@ -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.
|
|
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": {
|
package/skills/taskflow/SKILL.md
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
|
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
|