@fyeeme/pi-dynamic-workflows 1.1.0 → 2.0.2

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/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # @fyeeme/pi-dynamic-workflows
2
2
 
3
+ **2.0.0 — major release** (from 1.1.0 on npm), part of the 2.0 extensions family wave:
4
+
5
+ - **Composed sub-agent stack** — the extension factory composes [`@fyeeme/pi-subagents`](https://www.npmjs.com/package/@fyeeme/pi-subagents) 2.1.1 from its npm dependency: the live agent fleet surface and the `subagent` tool work out of the box; no manifest path wiring, no separate install.
6
+ - **Run persistence** — journaled runs under `.pi/workflows/` (journal + manifest): a re-run resumes cached agent calls keyed by `sha256(workflow + prompt + signature)` with zero re-dispatch, and the manifest tracks live state for inspection.
7
+ - **Per-call budget override** — `run_workflow` accepts a `budget` object that merges over the workflow definition's own `maxAgents`/`maxTokens`, making "halve the fan-out for large diffs" an actual parameter.
8
+ - **Authoring aids shipped** — `workflow-author` skill, `/wf-*` prompts, and seed workflows (`review-local-diff`, `review-extension`) using `import type` for load determinism.
9
+
3
10
  **Deterministic TypeScript workflow orchestration for [pi](https://github.com/earendil-works/pi-mono).**
4
11
 
5
12
  Define a workflow as a declarative list of typed steps, run it, and get resumable, budget-bounded, abortable execution. Fuses the pi-dynamic-workflows design (10 step primitives + heuristic planner + outcome collectors) with Claude Code's workflow-engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort, dynamic budget, runaway caps).
@@ -20,16 +27,20 @@ A workflow run is a list of steps (`agent` / `code` / `log` / `fan_out` / `loop_
20
27
 
21
28
  ---
22
29
 
23
- ## Install
30
+ **Batteries included**: this package's extension factory composes
31
+ [`@fyeeme/pi-subagents`](https://www.npmjs.com/package/@fyeeme/pi-subagents)
32
+ (the single below-editor fleet surface with its conversation viewer, and the `subagent` tool)
33
+ from its version-pinned dependency copy, so workflow runs are observable out
34
+ of the box. A standalone pi-subagents install is optional and coexists
35
+ (idempotent composition).
24
36
 
25
- This is a pi extension package (workspace / local), not yet published to npm. From a pi workspace:
37
+ ## Install
26
38
 
27
39
  ```bash
28
- npm install --ignore-scripts # hydrate (the package is a workspace dep)
40
+ pi install npm:@fyeeme/pi-dynamic-workflows
29
41
  ```
30
42
 
31
- This resolves [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)
32
- (`^0.5.0`, from the npm registry — no sibling-repo layout requirement).
43
+ This resolves [`@fyeeme/pi-subagents`](https://www.npmjs.com/package/@fyeeme/pi-subagents) 2.1.1 from the npm registry (the composed sub-agent stack lights up with no separate install).
33
44
 
34
45
  Then import the public API from the package root module (a TypeScript barrel; the package ships `.ts` source):
35
46
 
@@ -38,10 +49,10 @@ import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/in
38
49
  ```
39
50
 
40
51
  > The package's `pi.extensions` entry registers the `run_workflow` tool plus
41
- > the shared sub-agent UI from pi-subagent-core (live agent widget above the
42
- > editor, FleetView below it, and the `/agents` transcript viewer — every
43
- > spawned workflow agent appears there under its step id). The engine is also
44
- > fully usable via the imports shown here.
52
+ > the shared sub-agent UI from pi-subagents (the below-editor fleet roster —
53
+ > every spawned workflow agent appears there under its stable id; ↓ at an
54
+ > empty editor opens the list, Enter shows an agent's live transcript). The
55
+ > engine is also fully usable via the imports shown here.
45
56
 
46
57
  ---
47
58
 
@@ -169,6 +180,9 @@ defineWorkflow({
169
180
  // results: { candidates, winner, judges: [{winner, reason}] }
170
181
 
171
182
  // Classify input, then run the matching route's sub-steps.
183
+ // On pi 0.99+ hosts with a classifier model, the category comes from
184
+ // ModelRuntime.classify() (no classification subprocess); without one, an
185
+ // agent answers the classification. The step result records which path ran.
172
186
  defineWorkflow({
173
187
  name: "route",
174
188
  steps: [
@@ -184,7 +198,7 @@ defineWorkflow({
184
198
  },
185
199
  ],
186
200
  });
187
- // results: { category, matched, route: StepResult[], routeStatus }
201
+ // results: { category, matched, route: StepResult[], routeStatus, path }
188
202
  ```
189
203
 
190
204
  Judge/classifier JSON is parsed leniently (LLMs return `"true"`/`"0"` as strings); route nesting is depth-capped to catch cycles.
@@ -303,7 +317,7 @@ const result = await runWorkflow({ workflow: wf, cwd: tempDir, now: 1000, dispat
303
317
  | `loop_until` | `prompt(ctx,i)`, `until(ctx,i)`, `maxIterations?` | array of per-iteration outputs |
304
318
  | `adversarial` | `produce`, `rubric[]`, `judges?`, `minPass?` | `{ candidate, passed, passCount, judges }` |
305
319
  | `tournament` | `candidates`, `judges`, `produce` | `{ candidates, winner, judges }` |
306
- | `classify_route` | `classifier`, `routes: Record<cat, Step[]>`, `fallback?` | `{ category, matched, route, routeStatus }` |
320
+ | `classify_route` | `classifier`, `routes: Record<cat, Step[]>`, `fallback?` | `{ category, matched, route, routeStatus, path }` |
307
321
  | `sub_workflow` | `workflow: WorkflowDefinition`, `input?`, `inheritBudget?` | `{ steps, status, workflowName, error }` |
308
322
  | `loop_until_dry` | `agent(item, i)`, `keyOf?`, `merge?`, `maxRounds?`, `dryThreshold?` | array of discovered items |
309
323
 
package/README.zh-CN.md CHANGED
@@ -20,6 +20,12 @@
20
20
 
21
21
  ---
22
22
 
23
+ **开箱即用**:本包的扩展工厂会组合
24
+ [`@fyeeme/pi-subagents`](https://www.npmjs.com/package/@fyeeme/pi-subagents)
25
+ (实时代理 UI——编辑器上方 widget、FleetView、`/agents`——以及 `subagent`
26
+ 工具),且版本钉定在自身依赖副本上,工作流运行的可观测性开箱即得。
27
+ 单独安装 pi-subagents 是可选的,二者可共存(组合幂等)。
28
+
23
29
  ## 安装
24
30
 
25
31
  这是一个 pi 扩展包(workspace / 本地),尚未发布到 npm。在 pi workspace 中:
@@ -28,7 +34,7 @@
28
34
  npm install --ignore-scripts # 水合(本包是 workspace 依赖)
29
35
  ```
30
36
 
31
- 这会解析 npm registry 上的 [`@fyeeme/pi-subagent-core`](https://www.npmjs.com/package/@fyeeme/pi-subagent-core)(`^0.5.0`,无需保持同级仓库目录结构)。
37
+ 这会解析 npm registry 上的 [`@fyeeme/pi-subagents`](https://www.npmjs.com/package/@fyeeme/pi-subagents)(无需保持同级仓库目录结构)。
32
38
 
33
39
  随后从包根模块导入公共 API(TypeScript barrel,包直接以 `.ts` 源码分发):
34
40
 
@@ -36,7 +42,7 @@ npm install --ignore-scripts # 水合(本包是 workspace 依赖)
36
42
  import { defineWorkflow, runWorkflow } from "@fyeeme/pi-dynamic-workflows/src/index.ts";
37
43
  ```
38
44
 
39
- > 包的 `pi.extensions` 入口注册 `run_workflow` 工具,并接入 pi-subagent-core 的共享子代理 UI(编辑器上方实时 agent widget、下方 FleetView、`/agents` 转录查看器——每个工作流 agent 以其 step id 出现在其中)。引擎本身也可经上述导入直接使用。
45
+ > 包的 `pi.extensions` 入口注册 `run_workflow` 工具,并接入 pi-subagents 的共享子代理 UI(编辑器上方实时 agent widget、下方 FleetView、`/agents` 转录查看器——每个工作流 agent 以其 step id 出现在其中;需先安装 @fyeeme/pi-subagents,见上方提示)。引擎本身也可经上述导入直接使用。
40
46
 
41
47
  ---
42
48
 
@@ -179,7 +185,7 @@ defineWorkflow({
179
185
  },
180
186
  ],
181
187
  });
182
- // results: { category, matched, route: StepResult[], routeStatus }
188
+ // results: { category, matched, route: StepResult[], routeStatus, path }
183
189
  ```
184
190
 
185
191
  评判/分类的 JSON 采用宽松解析(LLM 常把 `"true"`/`"0"` 当字符串返回);路由嵌套有深度上限以防循环。
package/index.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * only adapts the agent's JSON args into the code-form WorkflowDefinition and
7
7
  * runs it with the default dispatch (real `pi --mode json` subprocesses).
8
8
  *
9
- * Live progress UI is delegated to the shared `@fyeeme/pi-subagent-core`
9
+ * Live progress UI is delegated to the shared `@fyeeme/pi-subagents`
10
10
  * extension (registered via the `pi.extensions` manifest alongside this
11
11
  * entry): every spawned workflow agent notifies the process-global monitor
12
12
  * through spawnAgent, rendering in the shared above-editor agent widget, the
@@ -15,17 +15,22 @@
15
15
  * `/wf-inspect` command were removed in favor of that shared surface.
16
16
  */
17
17
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+ import { truncateHead } from "@earendil-works/pi-coding-agent";
19
+ import { StringEnum } from "@earendil-works/pi-ai";
18
20
  import { Type } from "typebox";
21
+ import piSubagents from "@fyeeme/pi-subagents";
19
22
  import { defineWorkflow, runWorkflow } from "./src/index.ts";
23
+ import { createClassifyHook } from "./src/classifier-hook.ts";
20
24
  import type { Budget, StepContext, StepDefinition, WorkflowDefinition } from "./src/types.ts";
21
25
  import { WorkflowError } from "./src/errors.ts";
26
+ import { discoverWorkflowLibrary, loadLibraryWorkflow } from "./src/library.ts";
22
27
 
23
28
  // ---------------------------------------------------------------------------
24
29
  // Parameter schema (the JSON-serializable workflow subset)
25
30
  // ---------------------------------------------------------------------------
26
31
 
27
32
  const BudgetExhaustPolicy = Type.Optional(
28
- Type.Union([Type.Literal("throw"), Type.Literal("null")], {
33
+ StringEnum(["throw", "null"] as const, {
29
34
  description: "Budget-exhaustion policy for this step: \"throw\" (default) aborts the run; \"null\" degrades this step to a null result so siblings/downstream continue (the run result records degraded steps).",
30
35
  }),
31
36
  );
@@ -76,7 +81,7 @@ const StepSchema = Type.Union([
76
81
  Type.Object({
77
82
  id: Type.String(),
78
83
  type: Type.Literal("classify_route"),
79
- prompt: Type.String({ description: "Classifier prompt; agent should reply {category: \"...\"}" }),
84
+ prompt: Type.String({ description: "Classification prompt; routing runs on a classifier model when the host has one (pi 0.99), otherwise an agent replies with JSON {category: \"...\"}" }),
80
85
  routes: Type.Record(
81
86
  Type.String(),
82
87
  Type.Array(Type.Object({ id: Type.String(), prompt: Type.String(), model: Type.Optional(Type.String()) })),
@@ -103,7 +108,30 @@ const WorkflowSchema = Type.Object({
103
108
  });
104
109
 
105
110
  const RunWorkflowParams = Type.Object({
106
- workflow: WorkflowSchema,
111
+ source: Type.Optional(
112
+ StringEnum(["inline", "library"] as const, {
113
+ description:
114
+ 'Where the workflow comes from. "inline" (default): the `workflow` parameter (JSON subset). "library": a named workflow from the discovered library (bundled workflows/ + project .pi/workflows/lib/, project overrides bundled) — full step set incl. loop_until, loaded through the determinism guard.',
115
+ default: "inline",
116
+ }),
117
+ ),
118
+ name: Type.Optional(
119
+ Type.String({ description: 'Library workflow name (required for source: "library").' }),
120
+ ),
121
+ workflow: Type.Optional(WorkflowSchema),
122
+ budget: Type.Optional(
123
+ Type.Object(
124
+ {
125
+ maxAgents: Type.Optional(Type.Number()),
126
+ maxTokens: Type.Optional(Type.Number()),
127
+ maxDurationMs: Type.Optional(Type.Number()),
128
+ },
129
+ {
130
+ description:
131
+ "Per-call budget override (library mode only — inline workflows declare budget inside `workflow.budget`). Fields merge over the workflow definition's own budget; use it to tighten a library workflow for a large input without editing the definition. Omitted fields keep the definition's values.",
132
+ },
133
+ ),
134
+ ),
107
135
  input: Type.Optional(Type.String({ description: "Initial ctx.input (also {{input}} in prompts)" })),
108
136
  cwd: Type.Optional(Type.String({ description: "Working dir + journal base. Default: session cwd" })),
109
137
  now: Type.Optional(Type.Number({ description: "Deterministic inception ms (resume seed). Default: Date.now()" })),
@@ -287,6 +315,13 @@ function dropInvalidModel(id: string, model: string | undefined, validIds: Set<s
287
315
  }
288
316
 
289
317
  export default function (pi: ExtensionAPI): void {
318
+ // Compose the subagent stack (live agent UI — widget / FleetView /agents —
319
+ // and the `subagent` tool) from the pinned dependency copy. The tool
320
+ // registers exactly once per process (guard in pi-subagents' index.ts):
321
+ // coexists with a standalone pi-subagents install and with other consumers
322
+ // (pi-review) composing it.
323
+ piSubagents(pi);
324
+
290
325
  pi.registerTool({
291
326
  name: "run_workflow",
292
327
  label: "Run workflow",
@@ -301,44 +336,87 @@ export default function (pi: ExtensionAPI): void {
301
336
  parameters: RunWorkflowParams,
302
337
 
303
338
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
304
- // Scoped-models: when the session restricts models (--models /
305
- // enabledModels), validate step models against that scope; otherwise
306
- // fall back to the full registry. Invalid ids (e.g. "sonnet") are
307
- // dropped so the subprocess uses the default session model.
308
- const scoped = ctx.scopedModels;
309
- const validIds = scoped && scoped.length > 0
310
- ? new Set(scoped.map((s) => s.model.id))
311
- : new Set(ctx.modelRegistry.getAll().map((m) => m.id));
312
- const dropped: string[] = [];
313
- const sanitizedSteps = params.workflow.steps.map((s) => {
314
- const model = "model" in s ? dropInvalidModel(s.id, s.model, validIds, dropped) : undefined;
315
- if (s.type === "classify_route") {
316
- // Route/fallback sub-step models must be sanitized too — the schema
317
- // promises "Invalid ids are dropped", which routeStepToCode otherwise
318
- // passes straight through to the subprocess.
319
- const routes = Object.fromEntries(
320
- Object.entries(s.routes).map(([cat, rs]) => [
321
- cat,
322
- rs.map((r) => ({ ...r, model: dropInvalidModel(`${s.id}.${r.id}`, r.model, validIds, dropped) })),
323
- ]),
324
- ) as typeof s.routes;
325
- const fallback = s.fallback?.map((r) => ({ ...r, model: dropInvalidModel(`${s.id}.${r.id}`, r.model, validIds, dropped) }));
326
- return { ...s, model, routes, fallback };
339
+ // Library mode: resolve the named workflow from the discovered
340
+ // library (ast-guard + jiti via the existing loader), then run it
341
+ // through the same runner as inline mode. Unknown name → list the
342
+ // available workflows instead of spawning anything.
343
+ let workflowDef: WorkflowDefinition | undefined;
344
+ if (params.source === "library") {
345
+ if (!params.name)
346
+ throw new Error('run_workflow: source "library" requires a workflow `name`.');
347
+ try {
348
+ const entry = await loadLibraryWorkflow(params.name, params.cwd ?? ctx.cwd);
349
+ if (!entry) {
350
+ const lib = await discoverWorkflowLibrary(params.cwd ?? ctx.cwd);
351
+ const available = [...lib.values()]
352
+ .map((e) => `- ${e.name}${e.description ? ` — ${e.description}` : ""} (${e.filePath})`)
353
+ .join("\n");
354
+ throw new Error(`run_workflow: no library workflow named "${params.name}". Available:\n${available || "(none)"}`);
355
+ }
356
+ workflowDef = entry.workflow;
357
+ } catch (e) {
358
+ if (e instanceof Error && e.message.startsWith("run_workflow:")) throw e;
359
+ const msg = e instanceof Error ? e.message : String(e);
360
+ throw new Error(`run_workflow failed: ${msg}`);
327
361
  }
328
- return { ...s, model };
329
- });
330
- if (dropped.length > 0) {
331
- ctx.ui.notify(`Invalid model(s) dropped, using default: ${dropped.join(", ")}`, "warning");
362
+ } else {
363
+ if (!params.workflow)
364
+ throw new Error('run_workflow: provide either a `workflow` (inline) or `name` with source "library".');
332
365
  }
333
- const sanitizedWorkflow = { ...params.workflow, steps: sanitizedSteps };
334
- try {
335
- const workflow = buildWorkflow(sanitizedWorkflow);
366
+
367
+ try {
368
+ let workflow: WorkflowDefinition;
369
+ if (params.source === "library") {
370
+ // Library workflows are authored TS (models already validated at
371
+ // authoring time; the determinism guard ran at load). A per-call
372
+ // `budget` merges over the definition's own budget.
373
+ workflow =
374
+ workflowDef && params.budget
375
+ ? { ...workflowDef, budget: { ...workflowDef.budget, ...params.budget } }
376
+ : workflowDef!;
377
+ } else {
378
+ // Scoped-models: when the session restricts models (--models /
379
+ // enabledModels), validate step models against that scope; otherwise
380
+ // fall back to the full registry. Invalid ids (e.g. "sonnet") are
381
+ // dropped so the subprocess uses the default session model.
382
+ const scoped = ctx.scopedModels;
383
+ const validIds =
384
+ scoped && scoped.length > 0
385
+ ? new Set(scoped.map((s) => s.model.id))
386
+ : new Set(ctx.modelRegistry.getAll().map((m) => m.id));
387
+ const dropped: string[] = [];
388
+ const sanitizedSteps = params.workflow!.steps.map((s) => {
389
+ const model = "model" in s ? dropInvalidModel(s.id, s.model, validIds, dropped) : undefined;
390
+ if (s.type === "classify_route") {
391
+ // Route/fallback sub-step models must be sanitized too — the schema
392
+ // promises "Invalid ids are dropped", which routeStepToCode otherwise
393
+ // passes straight through to the subprocess.
394
+ const routes = Object.fromEntries(
395
+ Object.entries(s.routes).map(([cat, rs]) => [
396
+ cat,
397
+ rs.map((r) => ({ ...r, model: dropInvalidModel(`${s.id}.${r.id}`, r.model, validIds, dropped) })),
398
+ ]),
399
+ ) as typeof s.routes;
400
+ const fallback = s.fallback?.map((r) => ({ ...r, model: dropInvalidModel(`${s.id}.${r.id}`, r.model, validIds, dropped) }));
401
+ return { ...s, model, routes, fallback };
402
+ }
403
+ return { ...s, model };
404
+ });
405
+ if (dropped.length > 0) {
406
+ ctx.ui.notify(`Invalid model(s) dropped, using default: ${dropped.join(", ")}`, "warning");
407
+ }
408
+ const sanitizedWorkflow = { ...params.workflow!, steps: sanitizedSteps };
409
+ workflow = buildWorkflow(sanitizedWorkflow);
410
+ }
336
411
  const result = await runWorkflow({
337
412
  workflow,
338
413
  input: params.input,
339
414
  cwd: params.cwd ?? ctx.cwd,
340
415
  now: params.now ?? Date.now(),
341
416
  signal,
417
+ // pi 0.99 classifier routing: classify_route prefers ModelRuntime.classify()
418
+ // when a classifier model is available; otherwise the agent path runs.
419
+ classify: createClassifyHook(ctx.modelRegistry),
342
420
  });
343
421
 
344
422
  const lines = [
@@ -347,14 +425,34 @@ export default function (pi: ExtensionAPI): void {
347
425
  `stats: ${result.stats.agents} agent(s), ${result.stats.tokens} tokens, $${result.stats.cost.toFixed(4)}`,
348
426
  ];
349
427
  if (result.error) lines.push(`error: ${result.error}`);
428
+ if (result.journalFile) lines.push(`full run details: ${result.journalFile}`);
429
+
430
+ // Failed/aborted runs are tool errors: throw so pi sets isError and
431
+ // reports the summary to the model (returning a value never sets the
432
+ // error flag). Completed runs with degraded steps stay a normal result.
433
+ const text = truncateHead(lines.join("\n"), { maxLines: 2000, maxBytes: 50_000 }).content;
434
+ if (result.status !== "completed") {
435
+ throw new Error(text);
436
+ }
437
+ const u = result.stats.usage;
350
438
  return {
351
- content: [{ type: "text" as const, text: lines.join("\n") }],
439
+ content: [{ type: "text" as const, text }],
352
440
  details: result,
353
- isError: result.status !== "completed",
441
+ // Surface nested-agent usage so pi's footer //session totals include it.
442
+ usage: u
443
+ ? {
444
+ input: u.input,
445
+ output: u.output,
446
+ cacheRead: u.cacheRead,
447
+ cacheWrite: u.cacheWrite,
448
+ totalTokens: result.stats.tokens,
449
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: result.stats.cost },
450
+ }
451
+ : undefined,
354
452
  };
355
453
  } catch (e) {
356
454
  const msg = e instanceof Error ? e.message : String(e);
357
- return { content: [{ type: "text" as const, text: `run_workflow failed: ${msg}` }], details: { error: msg }, isError: true };
455
+ throw new Error(`run_workflow failed: ${msg}`);
358
456
  }
359
457
  },
360
458
  });
package/package.json CHANGED
@@ -1,59 +1,68 @@
1
1
  {
2
- "name": "@fyeeme/pi-dynamic-workflows",
3
- "version": "1.1.0",
4
- "description": "Deterministic TypeScript workflow orchestration for pi. Fuses the pi-dynamic-workflows design (declarative graph, 10 step primitives, heuristic planner, outcome collectors) with Claude Code's workflow engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort map, dynamic budget, runaway caps).",
5
- "type": "module",
6
- "license": "MIT",
7
- "author": "fyeeme",
8
- "engines": {
9
- "node": ">=18"
10
- },
11
- "keywords": [
12
- "pi-package",
13
- "pi",
14
- "dynamic-workflow",
15
- "workflow",
16
- "orchestration",
17
- "fan-out",
18
- "pipeline",
19
- "multi-agent"
20
- ],
21
- "files": [
22
- "*.ts",
23
- "src/**/*.ts",
24
- "sessions/**/*.ts",
25
- "README.md",
26
- "README.zh-CN.md",
27
- "LICENSE"
28
- ],
29
- "pi": {
30
- "extensions": [
31
- "./index.ts",
32
- "./node_modules/@fyeeme/pi-subagent-core/sub-agent.ts"
33
- ]
34
- },
35
- "scripts": {
36
- "test": "vitest --run",
37
- "typecheck": "tsc"
38
- },
39
- "dependencies": {
40
- "@fyeeme/pi-subagent-core": "^0.5.0"
41
- },
42
- "peerDependencies": {
43
- "@earendil-works/pi-ai": ">=0.84.1",
44
- "@earendil-works/pi-coding-agent": ">=0.84.1",
45
- "@earendil-works/pi-tui": ">=0.84.1",
46
- "jiti": ">=2.0.0",
47
- "typebox": ">=1.0.0",
48
- "typescript": ">=5.0.0"
49
- },
50
- "devDependencies": {
51
- "@earendil-works/pi-ai": "0.84.1",
52
- "@earendil-works/pi-coding-agent": "0.84.1",
53
- "@earendil-works/pi-tui": "0.84.1",
54
- "@types/node": "22.19.19",
55
- "jiti": "2.7.0",
56
- "typebox": "1.1.38",
57
- "typescript": "5.9.3"
58
- }
2
+ "name": "@fyeeme/pi-dynamic-workflows",
3
+ "version": "2.0.2",
4
+ "description": "Deterministic TypeScript workflow orchestration for pi. Fuses the pi-dynamic-workflows design (declarative graph, 10 step primitives, heuristic planner, outcome collectors) with Claude Code's workflow engine coordination mechanisms (deterministic sandbox, cache-key resume, per-agent abort map, dynamic budget, runaway caps).",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi",
14
+ "dynamic-workflow",
15
+ "workflow",
16
+ "orchestration",
17
+ "fan-out",
18
+ "pipeline",
19
+ "multi-agent"
20
+ ],
21
+ "files": [
22
+ "*.ts",
23
+ "src/**/*.ts",
24
+ "sessions/**/*.ts",
25
+ "workflows/**/*.ts",
26
+ "skills/**/*.md",
27
+ "prompts/**/*.md",
28
+ "README.md",
29
+ "README.zh-CN.md",
30
+ "LICENSE"
31
+ ],
32
+ "pi": {
33
+ "extensions": [
34
+ "./index.ts"
35
+ ],
36
+ "skills": [
37
+ "skills/workflow-author"
38
+ ],
39
+ "prompts": [
40
+ "prompts"
41
+ ]
42
+ },
43
+ "scripts": {
44
+ "test": "vitest --run",
45
+ "typecheck": "tsc"
46
+ },
47
+ "dependencies": {
48
+ "@fyeeme/pi-subagents": "2.1.1"
49
+ },
50
+ "peerDependencies": {
51
+ "@earendil-works/pi-ai": ">=0.99.0",
52
+ "@earendil-works/pi-coding-agent": ">=0.99.0",
53
+ "@earendil-works/pi-tui": ">=0.99.0",
54
+ "jiti": ">=2.0.0",
55
+ "typebox": ">=1.0.0",
56
+ "typescript": ">=5.0.0"
57
+ },
58
+ "devDependencies": {
59
+ "@earendil-works/pi-ai": "0.99.2",
60
+ "@earendil-works/pi-coding-agent": "0.99.2",
61
+ "@earendil-works/pi-agent-core": "0.99.2",
62
+ "@earendil-works/pi-tui": "0.99.2",
63
+ "@types/node": "22.19.19",
64
+ "jiti": "2.7.0",
65
+ "typebox": "1.1.38",
66
+ "typescript": "5.9.3"
67
+ }
59
68
  }
@@ -0,0 +1,29 @@
1
+ ---
2
+ description: Implement → review → fix loop via successive subagent calls
3
+ argument-hint: <what to implement>
4
+ ---
5
+ Execute this three-stage loop with the `subagent` tool — one call per stage.
6
+ You are the coordinator: pass each stage's complete output text into the next
7
+ stage's `task` yourself (chain mode no longer exists).
8
+
9
+ 1. **Implement** — call `subagent` with agent `worker`, task: $@ plus any repo
10
+ context it needs. Ask for the final implementation summary in the result.
11
+
12
+ 2. **Review** — call `subagent` with agent `reviewer`. Its task must begin:
13
+ "Review the following implementation." followed by the worker's COMPLETE
14
+ result verbatim, plus the files/areas touched.
15
+
16
+ 3. **Fix** — if and only if the reviewer reported issues, one more `worker`
17
+ call: "Apply this feedback to your earlier implementation." followed by the
18
+ reviewer's findings verbatim.
19
+
20
+ Rules:
21
+
22
+ - Forward results between stages verbatim — never paraphrase or summarize;
23
+ each subagent has zero memory of its predecessor.
24
+ - Run stages strictly sequentially; a later stage always needs the earlier
25
+ stage's full output as input.
26
+ - Skip stages 2–3 only for trivial changes (< ~20 lines, no behavior change);
27
+ state explicitly that you did so and why.
28
+ - Finish by reporting: what was implemented, the review verdict, and anything
29
+ still open.
@@ -0,0 +1,30 @@
1
+ ---
2
+ description: Heavy review pipeline via deterministic workflow (budget + resume + adversarial verify)
3
+ ---
4
+ Run the heavy review pipeline now. Target: $@
5
+
6
+ First resolve the diff yourself (run `git diff` against the appropriate
7
+ range — upstream merge-base if configured, else HEAD; pass the diff text as
8
+ the workflow `input`).
9
+
10
+ Then call `run_workflow` with `source: "library"`:
11
+
12
+ - For a plain local diff → name `review-local-diff` (5 finders at
13
+ parallelism 4, adversarial verify judges 3 / minPass 2, budget 12 agents /
14
+ 2M tokens).
15
+ - For changed packages under an extensions submodule → name
16
+ `review-extension` (one reviewer per changed package at parallelism 3).
17
+
18
+ Budget guidance: pass a per-call `budget` override on the run_workflow call —
19
+ halve `maxAgents` when the diff exceeds ~150 files; without an override the
20
+ workflow's own declared budget applies.
21
+
22
+ Resume behavior: on journal keys already present from a prior partial run
23
+ the engine skips completed steps — do not re-dispatch them yourself.
24
+
25
+ When the run completes, report the merged, verified findings via
26
+ `review_report` (if available) or as a markdown list ranked most-severe
27
+ first.
28
+
29
+ For tuning the pipeline itself (steps, rubric, determinism constraints),
30
+ load the workflow-author skill.
package/sessions/spawn.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Thin barrel over src/agent/dispatch.ts: the spawn registry + per-agent
5
5
  * abort primitives. The core spawn implementation lives in
6
- * `@fyeeme/pi-subagent-core`; skip/retry (workflows-specific) stay in
6
+ * `@fyeeme/pi-subagents`; skip/retry (workflows-specific) stay in
7
7
  * dispatch.ts.
8
8
  */
9
9
  export {
@@ -0,0 +1,69 @@
1
+ ---
2
+ name: workflow-author
3
+ description: "Author and tune deterministic workflow definitions for run_workflow: step-type selection, rubric writing, the single-file determinism constraint, budget/parallelism as declared data, and resume semantics."
4
+ ---
5
+
6
+ # Authoring workflow-library definitions
7
+
8
+ A library workflow is a single `.ts` file under `.pi/workflows/lib/`
9
+ (project) or the package's bundled `workflows/` (reference examples:
10
+ `review-local-diff`, `review-extension`). It exports a workflow and becomes
11
+ runnable by name (`run_workflow` with `source: "library"`) on the next call —
12
+ dropping the file in IS the registration; no code change, no restart.
13
+
14
+ ## The hard constraint: single-file determinism
15
+
16
+ The loader runs the ast determinism guard on the entry source and REJECTS
17
+ `Date.now()`, `Math.random()`, and `new Date()` **before** executing. This is
18
+ not a style rule — cache-key resume is only sound when the same workflow
19
+ source produces the same cache keys. The guard scans **only the entry file**:
20
+ a helper imported from another file could smuggle in non-determinism and
21
+ silently break resume. Therefore: **library workflows must stay
22
+ single-file.** Inline everything; if you need `now`, the engine passes it
23
+ (the `now` run parameter / `ctx` plumbing) — never read the clock yourself.
24
+
25
+ ## Choosing step types
26
+
27
+ | Need | Step | Notes |
28
+ |---|---|---|
29
+ | One LLM call | `agent` | prompt may be a function of `ctx.input` / `ctx.step(id)` |
30
+ | N parallel calls over a list | `fan_out` | `over` may be dynamic (`ctx => …`); declare `parallelism`; `merge` folds results |
31
+ | Produce + judge | `adversarial` | rubric criteria become judge prompts; `judges`/`minPass` (majority default) |
32
+ | Best-of-N | `tournament` | candidates × judges |
33
+ | Route by classification | `classify_route` | classifier replies `{category}`; routes + fallback |
34
+ | Iterate to convergence | `loop_until` | `until` condition + `maxIterations` (TS-only — library files only, not inline JSON) |
35
+ | Zero-token narrative | `log` | journal annotation only |
36
+
37
+ ## Writing rubrics (adversarial / tournament)
38
+
39
+ A rubric entry is a judge prompt, not a checkbox. Write each criterion as a
40
+ falsifiable, self-contained sentence: `"finding is concretely actionable
41
+ (file:line present)"` — not `"quality"`. 2–4 criteria; a judge that cannot
42
+ verify a criterion from the material in front of it will guess, and guessing
43
+ flattens the verdict distribution.
44
+
45
+ ## Budget and parallelism are data
46
+
47
+ `budget: { maxAgents, maxTokens, maxDurationMs }` at the workflow level and
48
+ `parallelism` on fan-out steps are declared fields the engine enforces
49
+ (fan-out pre-checks the batch fits; a step exceeding budget follows its
50
+ `onBudgetExhaust` policy — throw, or degrade to null). Tune them by editing
51
+ the file; orchestration prompts may also pass tighter values per call.
52
+
53
+ ## Resume semantics
54
+
55
+ Every dispatched call is cache-keyed (workflow source + prompts + inputs).
56
+ Re-running with unchanged definition and inputs skips completed steps from
57
+ the journal — do not "help" by re-dispatching; change an input only when you
58
+ want a step actually re-run. Editing the workflow source changes the keys:
59
+ that is the intended way to invalidate stale cache.
60
+
61
+ ## Checklist before shipping a library workflow
62
+
63
+ 1. Single file, and imports type-only: `import type { WorkflowDefinition } from ...` (erased at load — nothing outside the file executes, so the entry-only guard scan covers everything). NEVER value-import the engine by relative path: from `.pi/workflows/lib/` it does not resolve (bricking the whole library), and any value import puts un-scanned code on the load path. Plain `export const workflow = { name, steps }` with no import at all works too (the loader validates shape, not `defineWorkflow`).
64
+ 2. No `Date.now` / `Math.random` / `new Date` anywhere in the source.
65
+ 3. `name` is unique and descriptive (it is the invocation key); `description`
66
+ one line — it appears in the unknown-name listing.
67
+ 4. Budget and parallelism declared, sized to the worst expected input.
68
+ 5. Verified locally: `run_workflow` with `source: "library"`, then re-run to
69
+ see journal hits skip completed steps.
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * The core spawn primitive (`spawnAgent`, `mapWithConcurrencyLimit`,
5
5
  * `createSpawnRegistry`, `abortAgent`, `getPiInvocation` + the registry/
6
- * options/result types) lives in the shared `@fyeeme/pi-subagent-core`
6
+ * options/result types) lives in the shared `@fyeeme/pi-subagents`
7
7
  * package — extracted from the duplicate copies that used to live here and
8
8
  * in pi-review. This module keeps the workflows-specific layer on top:
9
9
  * `skipAgent`/`retryAgent` (with `AbortReason` semantics) and the lifecycle
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import type { AgentLifecycleListeners } from "../lifecycle.ts";
21
21
  import { notifyRetry, notifySkip } from "../lifecycle.ts";
22
- import type { AgentSpawnRegistry } from "@fyeeme/pi-subagent-core";
22
+ import type { AgentSpawnRegistry } from "@fyeeme/pi-subagents";
23
23
 
24
24
  // Re-export the core dispatch surface so existing importers of this module
25
25
  // (`../agent/dispatch.ts`) keep working unchanged.
@@ -29,7 +29,7 @@ export {
29
29
  getPiInvocation,
30
30
  mapWithConcurrencyLimit,
31
31
  spawnAgent,
32
- } from "@fyeeme/pi-subagent-core";
32
+ } from "@fyeeme/pi-subagents";
33
33
  export type {
34
34
  AgentAbortMap,
35
35
  AgentCallId,
@@ -37,7 +37,7 @@ export type {
37
37
  AgentSpawnRegistry,
38
38
  AgentSpawnResult,
39
39
  AgentUsage,
40
- } from "@fyeeme/pi-subagent-core";
40
+ } from "@fyeeme/pi-subagents";
41
41
 
42
42
  export type AbortReason = "user-skip" | "user-retry";
43
43
 
@@ -0,0 +1,56 @@
1
+ /**
2
+ * classify_route classifier hook — pi 0.99 `ModelRuntime.classify()` path.
3
+ *
4
+ * Model discovery (design D3): a step-declared `model` is honored only when it
5
+ * resolves to a classifier-type model with working credentials; otherwise the
6
+ * first available classifier on the host. None → `undefined` (the runner falls
7
+ * back to the agent path). The choice question's criteria are the route names;
8
+ * the answer is returned verbatim so undeclared categories resolve through
9
+ * fallback sub-steps exactly like the agent path (Routing equivalence).
10
+ */
11
+ import type { ClassifierApi, ClassifierContext, ClassifierModel, ClassifierResult } from "@earendil-works/pi-ai";
12
+ import type { ClassifyHook } from "./types.ts";
13
+
14
+ /** The registry surface the hook needs — satisfied by the extension context's
15
+ * ModelRegistry (pi 0.99+); tests mock this narrow slice. */
16
+ export interface ClassifierRegistrySlice {
17
+ getAvailableOfType(type: "classifier"): Promise<readonly ClassifierModel<ClassifierApi>[]>;
18
+ classify(model: ClassifierModel<ClassifierApi>, context: ClassifierContext): Promise<ClassifierResult>;
19
+ }
20
+
21
+ export function createClassifyHook(registry: ClassifierRegistrySlice): ClassifyHook {
22
+ return async (prompt, routeNames, model) => {
23
+ const available = await registry.getAvailableOfType("classifier");
24
+ if (available.length === 0) return undefined;
25
+ const chosen = (model ? available.find((m) => m.id === model) : undefined) ?? available[0]!;
26
+
27
+ const result = await registry.classify(chosen, {
28
+ state: { prompt, routes: [...routeNames] },
29
+ questions: {
30
+ route: {
31
+ type: "choice",
32
+ instructions: prompt,
33
+ criteria: Object.fromEntries(routeNames.map((name) => [name, name])),
34
+ },
35
+ },
36
+ });
37
+ if (result.stopReason !== "stop") return undefined;
38
+ const answer = result.answers.route;
39
+ if (answer?.type !== "choice") return undefined;
40
+
41
+ const u = result.usage;
42
+ return {
43
+ category: answer.choice,
44
+ usage: u
45
+ ? {
46
+ input: u.input ?? 0,
47
+ output: u.output ?? 0,
48
+ cacheRead: u.cacheRead ?? 0,
49
+ cacheWrite: u.cacheWrite ?? 0,
50
+ totalTokens: u.totalTokens ?? (u.input ?? 0) + (u.output ?? 0),
51
+ cost: u.cost?.total ?? 0,
52
+ }
53
+ : undefined,
54
+ };
55
+ };
56
+ }
package/src/format.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  * monitor `displayName` for the shared sub-agent UI). The former ANSI color
7
7
  * helpers + fmtTokens were progress-widget/`/wf-inspect` rendering aids and
8
8
  * were removed together with those surfaces (live progress is now rendered by
9
- * the shared @fyeeme/pi-subagent-core extension).
9
+ * the shared @fyeeme/pi-subagents extension).
10
10
  */
11
11
 
12
12
  /** Extract the step id from a callId of the form `${stepId}#${n}` (e.g.
package/src/library.ts ADDED
@@ -0,0 +1,143 @@
1
+ /**
2
+ * src/library.ts — named workflow library: the persistence layer for
3
+ * workflows (PR3 of the sandwich refactor).
4
+ *
5
+ * Workflows are single-file `.ts` definitions (full step set including the
6
+ * TS-only steps like loop_until) discovered from:
7
+ *
8
+ * 1. project — nearest `.pi/workflows/lib/` walking up from cwd
9
+ * 2. bundled — this package's own `workflows/` directory (seed workflows)
10
+ *
11
+ * The project entry overrides a bundled entry of the same name. Discovery and
12
+ * loading go through the existing loader (jiti + the ast determinism guard),
13
+ * so a library workflow is rejected before execution when its source
14
+ * smuggles in non-deterministic APIs — the precondition cache-key resume
15
+ * relies on.
16
+ *
17
+ * The library directory is deliberately distinct from the journal output
18
+ * tree (`.pi/workflows/runs/<runId>/`): definitions and run state never
19
+ * collide. Adding a workflow = dropping a file; no code change.
20
+ */
21
+ import * as fs from "node:fs";
22
+ import * as path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import { loadWorkflowModule } from "./loader.ts";
25
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
26
+ import type { WorkflowDefinition } from "./types.ts";
27
+
28
+ /** This file lives at <pkg>/src/ → the bundled library is <pkg>/workflows. */
29
+ const PKG_ROOT = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."));
30
+ const BUNDLED_LIB_DIR = path.join(PKG_ROOT, "workflows");
31
+
32
+ /** Project-level library directory, relative to the project root. */
33
+ const PROJECT_LIB_REL = path.join(CONFIG_DIR_NAME, "workflows", "lib");
34
+
35
+ /** One library entry, after loading the module. */
36
+ export interface LibraryEntry {
37
+ readonly name: string;
38
+ readonly description?: string;
39
+ readonly filePath: string;
40
+ readonly workflow: WorkflowDefinition;
41
+ }
42
+
43
+ function isDirectory(p: string): boolean {
44
+ try {
45
+ return fs.statSync(p).isDirectory();
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /** Nearest project library dir walking up from cwd (like agent discovery). */
52
+ export function findProjectLibDir(cwd: string): string | null {
53
+ let dir = path.resolve(cwd);
54
+ for (;;) {
55
+ const candidate = path.join(dir, PROJECT_LIB_REL);
56
+ if (isDirectory(candidate)) return candidate;
57
+ const parent = path.dirname(dir);
58
+ if (parent === dir) return null;
59
+ dir = parent;
60
+ }
61
+ }
62
+
63
+ /** Library directories in priority order (later wins on name collision). */
64
+ function libDirs(cwd: string): string[] {
65
+ const dirs = [BUNDLED_LIB_DIR];
66
+ const project = findProjectLibDir(cwd);
67
+ if (project) dirs.push(project);
68
+ return dirs;
69
+ }
70
+
71
+ function isValidWorkflow(value: unknown): value is WorkflowDefinition {
72
+ return (
73
+ !!value &&
74
+ typeof value === "object" &&
75
+ typeof (value as WorkflowDefinition).name === "string" &&
76
+ Array.isArray((value as WorkflowDefinition).steps)
77
+ );
78
+ }
79
+
80
+ /** Load one workflow file: ast-guard + jiti import + shape validation. */
81
+ async function loadEntry(filePath: string): Promise<LibraryEntry | null> {
82
+ try {
83
+ const mod = (await loadWorkflowModule({ filePath })) as {
84
+ workflow?: unknown;
85
+ default?: unknown;
86
+ };
87
+ const wf = mod.workflow ?? mod.default;
88
+ if (!isValidWorkflow(wf)) return null;
89
+ return {
90
+ name: wf.name,
91
+ description: wf.description,
92
+ filePath,
93
+ workflow: wf,
94
+ };
95
+ } catch (err) {
96
+ // Fail-fast, deliberately: a library file that fails the determinism
97
+ // guard or errors at import must NOT silently disappear from the
98
+ // available list (a poisoned entry re-appearing would break cache-key
99
+ // resume). The error names the offending file; remove or fix the file
100
+ // to restore discovery. Shape-invalid files (below) are the exception
101
+ // — they are skipped silently as non-workflows.
102
+ throw new Error(`library workflow ${path.basename(filePath)} failed to load: ${err instanceof Error ? err.message : String(err)}`);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Discover the library: load every `.ts` workflow in the library dirs and
108
+ * index by workflow name (later dirs override earlier same-name entries).
109
+ * A file whose module import or the determinism guard check FAILS aborts the
110
+ * whole discovery with an error naming the file (fail-fast — see loadEntry);
111
+ * a file that imports cleanly but is not a valid workflow shape is skipped
112
+ * silently.
113
+ */
114
+ export async function discoverWorkflowLibrary(cwd: string): Promise<Map<string, LibraryEntry>> {
115
+ const byName = new Map<string, LibraryEntry>();
116
+ for (const dir of libDirs(cwd)) {
117
+ let entries: fs.Dirent[];
118
+ try {
119
+ entries = fs.readdirSync(dir, { withFileTypes: true });
120
+ } catch {
121
+ continue; // unreadable/missing dir — skip
122
+ }
123
+ for (const entry of entries) {
124
+ if (!entry.name.endsWith(".ts") || entry.name.startsWith(".")) continue;
125
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
126
+ const loaded = await loadEntry(path.join(dir, entry.name));
127
+ if (loaded) byName.set(loaded.name, loaded);
128
+ }
129
+ }
130
+ return byName;
131
+ }
132
+
133
+ /** Resolve one workflow by name, or null when not in the library. */
134
+ export async function loadLibraryWorkflow(
135
+ name: string,
136
+ cwd: string,
137
+ ): Promise<LibraryEntry | null> {
138
+ // Fast path: only load the files that could define the name. Files export
139
+ // the workflow's name, so discovery is required either way — but skipping
140
+ // the project/bundled distinction keeps override semantics identical.
141
+ const lib = await discoverWorkflowLibrary(cwd);
142
+ return lib.get(name) ?? null;
143
+ }
@@ -14,11 +14,12 @@
14
14
  */
15
15
  import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
+ import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
17
18
  import { BudgetPool } from "../budget/index.ts";
18
19
  import { Journal, type RunManifest } from "../cache/index.ts";
19
20
  import type { AgentLifecycleListeners } from "../lifecycle.ts";
20
21
  import { generateRunId } from "../state/index.ts";
21
- import type { Budget, RunResult, WorkflowDefinition } from "../types.ts";
22
+ import type { Budget, ClassifyHook, RunResult, WorkflowDefinition } from "../types.ts";
22
23
  import {
23
24
  createSpawnRegistry,
24
25
  spawnAgent,
@@ -72,6 +73,11 @@ export interface RunWorkflowOptions {
72
73
  /** A6: policy gate invoked before the first dispatch. Return { allow: false,
73
74
  * reason } to deny the run with a policy-gate error. */
74
75
  readonly policyGate?: (workflow: WorkflowDefinition) => { readonly allow: boolean; readonly reason?: string } | Promise<{ readonly allow: boolean; readonly reason?: string }>;
76
+ /** pi 0.99 classifier hook for classify_route steps: when provided and a
77
+ * classifier model is available, route categories come from
78
+ * ModelRuntime.classify() instead of a spawned classification agent;
79
+ * unavailability and failures fall back to the agent path. */
80
+ readonly classify?: ClassifyHook;
75
81
  }
76
82
 
77
83
  export async function runWorkflow(opts: RunWorkflowOptions): Promise<RunResult> {
@@ -81,7 +87,7 @@ export async function runWorkflow(opts: RunWorkflowOptions): Promise<RunResult>
81
87
  const budget = opts.budget ?? workflow.budget ?? {};
82
88
  const journalDir =
83
89
  opts.journalDir ??
84
- path.join(cwd, ".pi", "workflows", sanitizeWorkflowName(workflow.name));
90
+ path.join(cwd, CONFIG_DIR_NAME, "workflows", sanitizeWorkflowName(workflow.name));
85
91
 
86
92
  await fs.promises.mkdir(journalDir, { recursive: true });
87
93
  const journal = new Journal({ dir: journalDir });
@@ -135,6 +141,7 @@ export async function runWorkflow(opts: RunWorkflowOptions): Promise<RunResult>
135
141
  maxPromptBytes: opts.maxPromptBytes ?? DEFAULT_MAX_PROMPT_BYTES,
136
142
  degradedStepIds: new Set(),
137
143
  allowChildRecursion: opts.allowChildRecursion ?? false,
144
+ classify: opts.classify,
138
145
  dispatched: 0,
139
146
  };
140
147
 
@@ -35,6 +35,7 @@ import type {
35
35
  AgentCallSpec,
36
36
  AgentOpts,
37
37
  AgentStep,
38
+ ClassifyHook,
38
39
  ClassifyRouteStep,
39
40
  CodeStep,
40
41
  LoopUntilDryStep,
@@ -81,6 +82,9 @@ export interface StepExecContext {
81
82
  budgetPolicy: "throw" | "null";
82
83
  /** A6: max resolved-prompt byte size; oversize throws a size-limit error. */
83
84
  maxPromptBytes: number;
85
+ /** pi 0.99 classifier hook for classify_route (undefined → agent path only).
86
+ * See ClassifyHook / spec workflow-classifier-routing. */
87
+ readonly classify?: ClassifyHook;
84
88
  /** A3: step ids that degraded to null under the "null" policy this run. */
85
89
  degradedStepIds: Set<string>;
86
90
  /** Recursion opt-in propagated to every spawned workflow sub-agent: when
@@ -555,28 +559,63 @@ async function execTournament(step: TournamentStep, ctx: StepContext, exec: Step
555
559
  async function execClassifyRoute(step: ClassifyRouteStep, ctx: StepContext, exec: StepExecContext): Promise<StepResult> {
556
560
  const start = Date.now();
557
561
  const classifyPrompt = await resolvePrompt(step.classifier, ctx);
558
- const classify = await dispatchAgentCall(`${step.id}#classify`, classifyPrompt, step.classifier, exec);
559
- if (!classify.ok) {
560
- return stepResult(
561
- step.id,
562
- "classify_route",
563
- classify.aborted ? "skipped" : "failed",
564
- undefined,
565
- withDuration(classify.stats, start),
566
- undefined,
567
- classify.aborted ? undefined : "dispatch-error",
568
- );
562
+
563
+ // pi 0.99 classifier path first (spec: workflow-classifier-routing): when a
564
+ // classifier hook is available and answers, the category comes from its
565
+ // ranked choice and no classification subprocess is spawned. Unavailable or
566
+ // failing hook → the original agent path, unchanged (fallback contract).
567
+ let classifyPath: "classifier" | "agent" = "agent";
568
+ let category = "";
569
+ let classifyStats: StepStats = zeroStats;
570
+ if (exec.classify) {
571
+ try {
572
+ const hookResult = await exec.classify(classifyPrompt, Object.keys(step.routes), step.classifier.model);
573
+ if (hookResult) {
574
+ classifyPath = "classifier";
575
+ category = parseCategoryStr(hookResult.category);
576
+ const u = hookResult.usage;
577
+ classifyStats = {
578
+ tokens: u ? u.totalTokens : 0,
579
+ cost: u ? u.cost : 0,
580
+ durationMs: 0,
581
+ agents: 0,
582
+ failures: 0,
583
+ usage: u
584
+ ? { input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheWrite }
585
+ : undefined,
586
+ };
587
+ }
588
+ } catch {
589
+ // classifier call failed → agent fallback (spec: Fallback to the agent path)
590
+ }
569
591
  }
570
592
 
571
- // A3: a degraded classifier (budget exhausted under the "null" policy)
572
- // returns value null — the step degrades to a null result per the README
573
- // contract; do not fabricate a route run from an empty category.
574
- if (classify.value === null) {
575
- return stepResult(step.id, "classify_route", "done", null, withDuration(classify.stats, start));
593
+ if (classifyPath === "agent") {
594
+ const classify = await dispatchAgentCall(`${step.id}#classify`, classifyPrompt, step.classifier, exec);
595
+ if (!classify.ok) {
596
+ return stepResult(
597
+ step.id,
598
+ "classify_route",
599
+ classify.aborted ? "skipped" : "failed",
600
+ undefined,
601
+ withDuration(classify.stats, start),
602
+ undefined,
603
+ classify.aborted ? undefined : "dispatch-error",
604
+ );
605
+ }
606
+
607
+ // A3: a degraded classifier (budget exhausted under the "null" policy)
608
+ // returns value null — the step degrades to a null result per the README
609
+ // contract; do not fabricate a route run from an empty category.
610
+ if (classify.value === null) {
611
+ return stepResult(step.id, "classify_route", "done", null, withDuration(classify.stats, start));
612
+ }
613
+
614
+ const parsed = parseFirstJson(classify.value ?? "") as { category?: unknown } | undefined;
615
+ category = parseCategoryStr(parsed?.category);
616
+ classifyStats = classify.stats;
576
617
  }
577
618
 
578
- const parsed = parseFirstJson(classify.value ?? "") as { category?: unknown } | undefined;
579
- const category = parseCategoryStr(parsed?.category);
580
619
  const routeSteps = step.routes[category] ?? step.fallback ?? [];
581
620
 
582
621
  exec.depth++;
@@ -592,8 +631,8 @@ async function execClassifyRoute(step: ClassifyRouteStep, ctx: StepContext, exec
592
631
  step.id,
593
632
  "classify_route",
594
633
  status,
595
- { category, matched: category in step.routes, route: sub.steps, routeStatus: sub.status },
596
- withDuration(addStats(classify.stats, aggregateStats(sub.steps.map((s) => s.stats), 0)), start),
634
+ { category, matched: category in step.routes, route: sub.steps, routeStatus: sub.status, path: classifyPath },
635
+ withDuration(addStats(classifyStats, aggregateStats(sub.steps.map((s) => s.stats), 0)), start),
597
636
  undefined,
598
637
  sub.errorCategory,
599
638
  );
@@ -986,7 +1025,7 @@ function dispatchOpts(
986
1025
  signal,
987
1026
  allowChildRecursion,
988
1027
  // UI display name for the shared sub-agent widget/FleetView (purely
989
- // observational metadata consumed by the pi-subagent-core monitor): the
1028
+ // observational metadata consumed by the pi-subagents monitor): the
990
1029
  // step id ("fan", "adv"), so workflow rows are distinguishable from other
991
1030
  // agents. Cache hits never spawn, so they never appear — zero dispatch.
992
1031
  displayName: stepIdOf(callId),
@@ -1018,6 +1057,12 @@ function usageStats(res: AgentSpawnResult, durationMs: number, ok: boolean): Ste
1018
1057
  durationMs,
1019
1058
  agents: 1,
1020
1059
  failures: ok ? 0 : 1,
1060
+ usage: {
1061
+ input: res.usage.input,
1062
+ output: res.usage.output,
1063
+ cacheRead: res.usage.cacheRead,
1064
+ cacheWrite: res.usage.cacheWrite,
1065
+ },
1021
1066
  };
1022
1067
  }
1023
1068
 
@@ -1028,6 +1073,20 @@ function addStats(a: StepStats, b: StepStats): StepStats {
1028
1073
  durationMs: a.durationMs + b.durationMs,
1029
1074
  agents: a.agents + b.agents,
1030
1075
  failures: a.failures + b.failures,
1076
+ usage: mergeUsage(a.usage, b.usage),
1077
+ };
1078
+ }
1079
+
1080
+ function mergeUsage(
1081
+ a: StepStats["usage"],
1082
+ b: StepStats["usage"],
1083
+ ): StepStats["usage"] {
1084
+ if (!a && !b) return undefined;
1085
+ return {
1086
+ input: (a?.input ?? 0) + (b?.input ?? 0),
1087
+ output: (a?.output ?? 0) + (b?.output ?? 0),
1088
+ cacheRead: (a?.cacheRead ?? 0) + (b?.cacheRead ?? 0),
1089
+ cacheWrite: (a?.cacheWrite ?? 0) + (b?.cacheWrite ?? 0),
1031
1090
  };
1032
1091
  }
1033
1092
 
@@ -1038,14 +1097,16 @@ export function aggregateStats(stats: readonly StepStats[], durationMs: number):
1038
1097
  let agents = 0;
1039
1098
  let failures = 0;
1040
1099
  let dur = 0;
1100
+ let usage: StepStats["usage"];
1041
1101
  for (const s of stats) {
1042
1102
  tokens += s.tokens;
1043
1103
  cost += s.cost;
1044
1104
  agents += s.agents;
1045
1105
  failures += s.failures;
1046
1106
  dur += s.durationMs;
1107
+ usage = mergeUsage(usage, s.usage);
1047
1108
  }
1048
- return { tokens, cost, durationMs: durationMs > 0 ? durationMs : dur, agents, failures };
1109
+ return { tokens, cost, durationMs: durationMs > 0 ? durationMs : dur, agents, failures, usage };
1049
1110
  }
1050
1111
 
1051
1112
  function withDuration(stats: StepStats, start: number): StepStats {
package/src/types.ts CHANGED
@@ -49,12 +49,51 @@ export interface Budget {
49
49
  // Step statistics + result
50
50
  // ---------------------------------------------------------------------------
51
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // classify_route classifier hook (pi 0.99 ModelRuntime.classify)
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /** Result of a successful classifier-path classification for classify_route.
57
+ * `category` is the classifier's chosen route name (may be undeclared — the
58
+ * runner resolves it through fallback sub-steps exactly like the agent path). */
59
+ export interface ClassifyHookResult {
60
+ readonly category: string;
61
+ /** Classifier token usage for truthful step stats; omit when unknown. */
62
+ readonly usage?: {
63
+ readonly input: number;
64
+ readonly output: number;
65
+ readonly cacheRead: number;
66
+ readonly cacheWrite: number;
67
+ readonly totalTokens: number;
68
+ readonly cost: number;
69
+ };
70
+ }
71
+
72
+ /** Optional classifier hook injected by the extension entry (spec:
73
+ * workflow-classifier-routing). Returns undefined when no classifier model is
74
+ * available; a throw also falls back to the agent path. Route sub-steps still
75
+ * run as agent dispatches either way. */
76
+ export type ClassifyHook = (
77
+ prompt: string,
78
+ routeNames: readonly string[],
79
+ model?: string,
80
+ ) => Promise<ClassifyHookResult | undefined>;
81
+
52
82
  export interface StepStats {
53
83
  readonly tokens: number;
54
84
  readonly cost: number;
55
85
  readonly durationMs: number;
56
86
  readonly agents: number;
57
87
  readonly failures: number;
88
+ /** Nested-LLM usage split, populated for agent-dispatch steps and surfaced
89
+ * as `usage` on the run_workflow tool result (pi usage accounting). Absent
90
+ * for zero-dispatch steps (code/log) and journals written before this field. */
91
+ readonly usage?: {
92
+ readonly input: number;
93
+ readonly output: number;
94
+ readonly cacheRead: number;
95
+ readonly cacheWrite: number;
96
+ };
58
97
  }
59
98
 
60
99
  export interface StepResult<T = unknown> {
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Seed library workflow: review-extension — the extensions-submodule review
3
+ * fan-out distilled from this repository's review-local-extensions-fanout
4
+ * pipeline: scope the changed packages first, then one reviewer per changed
5
+ * extension directory (parallelism 3), then merge.
6
+ *
7
+ * Input: newline-separated list of changed paths under packages/extensions
8
+ * (or a single path). Prompts use prompt functions (ctx.input /
9
+ * ctx.step(id)) — the TS API does not substitute {{...}} tokens.
10
+ * Runtime-single-file: `import type` is erased at load (see
11
+ * review-local-diff.ts).
12
+ */
13
+ import type { WorkflowDefinition } from "../src/index.ts";
14
+
15
+ export const workflow: WorkflowDefinition = {
16
+ name: "review-extension",
17
+ description: "Review each changed extension package: one reviewer per package (parallelism 3), merged findings",
18
+ budget: { maxAgents: 9, maxTokens: 2_000_000 },
19
+ steps: [
20
+ {
21
+ id: "scope",
22
+ type: "agent",
23
+ prompt: (ctx) =>
24
+ "The input lists changed paths under packages/extensions. Reduce it to the set of " +
25
+ "distinct top-level extension directories (e.g. \"pi-review\", \"pi-subagents\"). " +
26
+ "Return ONLY the directory names, one per line, no commentary.\n\n" +
27
+ String(ctx.input),
28
+ },
29
+ {
30
+ id: "reviewers",
31
+ type: "fan_out",
32
+ over: (ctx) =>
33
+ String(ctx.step("scope").results)
34
+ .split("\n")
35
+ .map((l) => l.trim())
36
+ .filter(Boolean),
37
+ parallelism: 3,
38
+ agent: (pkg, _index, ctx) => ({
39
+ prompt:
40
+ `Review the uncommitted changes in packages/extensions/${pkg} (run ` +
41
+ `\`git -C packages/extensions/${pkg} diff\` yourself; read the touched files for ` +
42
+ `context). Report findings as \`file:line\` — one-line summary — the concrete cost. ` +
43
+ `Cover correctness, reuse, simplification, efficiency, altitude. Report only.\n\n` +
44
+ `Changed paths:\n${ctx.input}`,
45
+ }),
46
+ merge: (results) => results.join("\n---\n"),
47
+ },
48
+ ],
49
+ };
50
+
51
+ export default workflow;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Seed library workflow: review-local-diff — the local-diff review pipeline
3
+ * actually run in this repository (distilled from the .pi/workflows journals
4
+ * of review-local-changes / review-local-extensions-fanout runs).
5
+ *
6
+ * Input: the unified diff to review (run input). Fan out one finder per
7
+ * angle (parallelism 4), adversarially verify a merged candidate list, and
8
+ * return the verified findings. Budget and parallelism are declared here as
9
+ * data — the engine enforces them.
10
+ *
11
+ * Prompts reference run context via prompt FUNCTIONS (ctx.input /
12
+ * ctx.step(id)) — the {{...}} template tokens are the inline-JSON tool
13
+ * surface only; the TS API never substitutes them in plain strings.
14
+ *
15
+ * Runtime-single-file: `import type` is erased at load, so nothing outside
16
+ * this file executes and the ast determinism guard's entry-only scan covers
17
+ * everything that runs (a VALUE import from another file would not be
18
+ * scanned — keep imports type-only).
19
+ */
20
+ import type { WorkflowDefinition } from "../src/index.ts";
21
+
22
+ const ANGLES = ["correctness", "reuse", "simplification", "efficiency", "altitude"] as const;
23
+
24
+ export const workflow: WorkflowDefinition = {
25
+ name: "review-local-diff",
26
+ description: "Fan out diff-scoped finders, adversarially verify the merged candidates, return verified findings",
27
+ budget: { maxAgents: 12, maxTokens: 2_000_000 },
28
+ steps: [
29
+ {
30
+ id: "finders",
31
+ type: "fan_out",
32
+ over: () => [...ANGLES],
33
+ parallelism: 4,
34
+ agent: (angle, _index, ctx) => ({
35
+ prompt:
36
+ `You are a code-review finder for the "${angle}" angle. Review the diff below. ` +
37
+ `Report each finding as \`file:line\` — one-line summary — the concrete cost ` +
38
+ `(for correctness: the input/state that triggers it → wrong output). Report only; ` +
39
+ `an empty list is a valid answer.\n\nDiff:\n${ctx.input}`,
40
+ }),
41
+ merge: (results) => results.join("\n---\n"),
42
+ },
43
+ {
44
+ id: "verify",
45
+ type: "adversarial",
46
+ produce: {
47
+ prompt: (ctx) =>
48
+ "Dedup and merge these code-review findings into a numbered candidate list " +
49
+ "(same defect + same location + same reason → keep one; different reasons for " +
50
+ "the same line are NOT duplicates — keep both). Return only the numbered list.\n\n" +
51
+ String(ctx.step("finders").results),
52
+ },
53
+ rubric: [
54
+ "finding is concretely actionable (file:line present)",
55
+ "the failure scenario or cost is stated and realistic",
56
+ "not a duplicate of another kept finding",
57
+ ],
58
+ judges: 3,
59
+ minPass: 2,
60
+ },
61
+ ],
62
+ };
63
+
64
+ export default workflow;