@fyeeme/pi-dynamic-workflows 1.1.0 → 2.0.1

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
 
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
 
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,21 @@
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";
20
23
  import type { Budget, StepContext, StepDefinition, WorkflowDefinition } from "./src/types.ts";
21
24
  import { WorkflowError } from "./src/errors.ts";
25
+ import { discoverWorkflowLibrary, loadLibraryWorkflow } from "./src/library.ts";
22
26
 
23
27
  // ---------------------------------------------------------------------------
24
28
  // Parameter schema (the JSON-serializable workflow subset)
25
29
  // ---------------------------------------------------------------------------
26
30
 
27
31
  const BudgetExhaustPolicy = Type.Optional(
28
- Type.Union([Type.Literal("throw"), Type.Literal("null")], {
32
+ StringEnum(["throw", "null"] as const, {
29
33
  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
34
  }),
31
35
  );
@@ -103,7 +107,30 @@ const WorkflowSchema = Type.Object({
103
107
  });
104
108
 
105
109
  const RunWorkflowParams = Type.Object({
106
- workflow: WorkflowSchema,
110
+ source: Type.Optional(
111
+ StringEnum(["inline", "library"] as const, {
112
+ description:
113
+ '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.',
114
+ default: "inline",
115
+ }),
116
+ ),
117
+ name: Type.Optional(
118
+ Type.String({ description: 'Library workflow name (required for source: "library").' }),
119
+ ),
120
+ workflow: Type.Optional(WorkflowSchema),
121
+ budget: Type.Optional(
122
+ Type.Object(
123
+ {
124
+ maxAgents: Type.Optional(Type.Number()),
125
+ maxTokens: Type.Optional(Type.Number()),
126
+ maxDurationMs: Type.Optional(Type.Number()),
127
+ },
128
+ {
129
+ description:
130
+ "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.",
131
+ },
132
+ ),
133
+ ),
107
134
  input: Type.Optional(Type.String({ description: "Initial ctx.input (also {{input}} in prompts)" })),
108
135
  cwd: Type.Optional(Type.String({ description: "Working dir + journal base. Default: session cwd" })),
109
136
  now: Type.Optional(Type.Number({ description: "Deterministic inception ms (resume seed). Default: Date.now()" })),
@@ -287,6 +314,13 @@ function dropInvalidModel(id: string, model: string | undefined, validIds: Set<s
287
314
  }
288
315
 
289
316
  export default function (pi: ExtensionAPI): void {
317
+ // Compose the subagent stack (live agent UI — widget / FleetView /agents —
318
+ // and the `subagent` tool) from the pinned dependency copy. The tool
319
+ // registers exactly once per process (guard in pi-subagents' index.ts):
320
+ // coexists with a standalone pi-subagents install and with other consumers
321
+ // (pi-review) composing it.
322
+ piSubagents(pi);
323
+
290
324
  pi.registerTool({
291
325
  name: "run_workflow",
292
326
  label: "Run workflow",
@@ -301,38 +335,78 @@ export default function (pi: ExtensionAPI): void {
301
335
  parameters: RunWorkflowParams,
302
336
 
303
337
  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 };
338
+ // Library mode: resolve the named workflow from the discovered
339
+ // library (ast-guard + jiti via the existing loader), then run it
340
+ // through the same runner as inline mode. Unknown name → list the
341
+ // available workflows instead of spawning anything.
342
+ let workflowDef: WorkflowDefinition | undefined;
343
+ if (params.source === "library") {
344
+ if (!params.name)
345
+ throw new Error('run_workflow: source "library" requires a workflow `name`.');
346
+ try {
347
+ const entry = await loadLibraryWorkflow(params.name, params.cwd ?? ctx.cwd);
348
+ if (!entry) {
349
+ const lib = await discoverWorkflowLibrary(params.cwd ?? ctx.cwd);
350
+ const available = [...lib.values()]
351
+ .map((e) => `- ${e.name}${e.description ? ` — ${e.description}` : ""} (${e.filePath})`)
352
+ .join("\n");
353
+ throw new Error(`run_workflow: no library workflow named "${params.name}". Available:\n${available || "(none)"}`);
354
+ }
355
+ workflowDef = entry.workflow;
356
+ } catch (e) {
357
+ if (e instanceof Error && e.message.startsWith("run_workflow:")) throw e;
358
+ const msg = e instanceof Error ? e.message : String(e);
359
+ throw new Error(`run_workflow failed: ${msg}`);
327
360
  }
328
- return { ...s, model };
329
- });
330
- if (dropped.length > 0) {
331
- ctx.ui.notify(`Invalid model(s) dropped, using default: ${dropped.join(", ")}`, "warning");
361
+ } else {
362
+ if (!params.workflow)
363
+ throw new Error('run_workflow: provide either a `workflow` (inline) or `name` with source "library".');
332
364
  }
333
- const sanitizedWorkflow = { ...params.workflow, steps: sanitizedSteps };
334
- try {
335
- const workflow = buildWorkflow(sanitizedWorkflow);
365
+
366
+ try {
367
+ let workflow: WorkflowDefinition;
368
+ if (params.source === "library") {
369
+ // Library workflows are authored TS (models already validated at
370
+ // authoring time; the determinism guard ran at load). A per-call
371
+ // `budget` merges over the definition's own budget.
372
+ workflow =
373
+ workflowDef && params.budget
374
+ ? { ...workflowDef, budget: { ...workflowDef.budget, ...params.budget } }
375
+ : workflowDef!;
376
+ } else {
377
+ // Scoped-models: when the session restricts models (--models /
378
+ // enabledModels), validate step models against that scope; otherwise
379
+ // fall back to the full registry. Invalid ids (e.g. "sonnet") are
380
+ // dropped so the subprocess uses the default session model.
381
+ const scoped = ctx.scopedModels;
382
+ const validIds =
383
+ scoped && scoped.length > 0
384
+ ? new Set(scoped.map((s) => s.model.id))
385
+ : new Set(ctx.modelRegistry.getAll().map((m) => m.id));
386
+ const dropped: string[] = [];
387
+ const sanitizedSteps = params.workflow!.steps.map((s) => {
388
+ const model = "model" in s ? dropInvalidModel(s.id, s.model, validIds, dropped) : undefined;
389
+ if (s.type === "classify_route") {
390
+ // Route/fallback sub-step models must be sanitized too — the schema
391
+ // promises "Invalid ids are dropped", which routeStepToCode otherwise
392
+ // passes straight through to the subprocess.
393
+ const routes = Object.fromEntries(
394
+ Object.entries(s.routes).map(([cat, rs]) => [
395
+ cat,
396
+ rs.map((r) => ({ ...r, model: dropInvalidModel(`${s.id}.${r.id}`, r.model, validIds, dropped) })),
397
+ ]),
398
+ ) as typeof s.routes;
399
+ const fallback = s.fallback?.map((r) => ({ ...r, model: dropInvalidModel(`${s.id}.${r.id}`, r.model, validIds, dropped) }));
400
+ return { ...s, model, routes, fallback };
401
+ }
402
+ return { ...s, model };
403
+ });
404
+ if (dropped.length > 0) {
405
+ ctx.ui.notify(`Invalid model(s) dropped, using default: ${dropped.join(", ")}`, "warning");
406
+ }
407
+ const sanitizedWorkflow = { ...params.workflow!, steps: sanitizedSteps };
408
+ workflow = buildWorkflow(sanitizedWorkflow);
409
+ }
336
410
  const result = await runWorkflow({
337
411
  workflow,
338
412
  input: params.input,
@@ -347,14 +421,34 @@ export default function (pi: ExtensionAPI): void {
347
421
  `stats: ${result.stats.agents} agent(s), ${result.stats.tokens} tokens, $${result.stats.cost.toFixed(4)}`,
348
422
  ];
349
423
  if (result.error) lines.push(`error: ${result.error}`);
424
+ if (result.journalFile) lines.push(`full run details: ${result.journalFile}`);
425
+
426
+ // Failed/aborted runs are tool errors: throw so pi sets isError and
427
+ // reports the summary to the model (returning a value never sets the
428
+ // error flag). Completed runs with degraded steps stay a normal result.
429
+ const text = truncateHead(lines.join("\n"), { maxLines: 2000, maxBytes: 50_000 }).content;
430
+ if (result.status !== "completed") {
431
+ throw new Error(text);
432
+ }
433
+ const u = result.stats.usage;
350
434
  return {
351
- content: [{ type: "text" as const, text: lines.join("\n") }],
435
+ content: [{ type: "text" as const, text }],
352
436
  details: result,
353
- isError: result.status !== "completed",
437
+ // Surface nested-agent usage so pi's footer //session totals include it.
438
+ usage: u
439
+ ? {
440
+ input: u.input,
441
+ output: u.output,
442
+ cacheRead: u.cacheRead,
443
+ cacheWrite: u.cacheWrite,
444
+ totalTokens: result.stats.tokens,
445
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: result.stats.cost },
446
+ }
447
+ : undefined,
354
448
  };
355
449
  } catch (e) {
356
450
  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 };
451
+ throw new Error(`run_workflow failed: ${msg}`);
358
452
  }
359
453
  },
360
454
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fyeeme/pi-dynamic-workflows",
3
- "version": "1.1.0",
3
+ "version": "2.0.1",
4
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
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,14 +22,22 @@
22
22
  "*.ts",
23
23
  "src/**/*.ts",
24
24
  "sessions/**/*.ts",
25
+ "workflows/**/*.ts",
26
+ "skills/**/*.md",
27
+ "prompts/**/*.md",
25
28
  "README.md",
26
29
  "README.zh-CN.md",
27
30
  "LICENSE"
28
31
  ],
29
32
  "pi": {
30
33
  "extensions": [
31
- "./index.ts",
32
- "./node_modules/@fyeeme/pi-subagent-core/sub-agent.ts"
34
+ "./index.ts"
35
+ ],
36
+ "skills": [
37
+ "skills/workflow-author"
38
+ ],
39
+ "prompts": [
40
+ "prompts"
33
41
  ]
34
42
  },
35
43
  "scripts": {
@@ -37,20 +45,20 @@
37
45
  "typecheck": "tsc"
38
46
  },
39
47
  "dependencies": {
40
- "@fyeeme/pi-subagent-core": "^0.5.0"
48
+ "@fyeeme/pi-subagents": "2.1.1"
41
49
  },
42
50
  "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",
51
+ "@earendil-works/pi-ai": ">=0.84.4",
52
+ "@earendil-works/pi-coding-agent": ">=0.84.4",
53
+ "@earendil-works/pi-tui": ">=0.84.4",
46
54
  "jiti": ">=2.0.0",
47
55
  "typebox": ">=1.0.0",
48
56
  "typescript": ">=5.0.0"
49
57
  },
50
58
  "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",
59
+ "@earendil-works/pi-ai": "0.84.4",
60
+ "@earendil-works/pi-coding-agent": "0.84.4",
61
+ "@earendil-works/pi-tui": "0.84.4",
54
62
  "@types/node": "22.19.19",
55
63
  "jiti": "2.7.0",
56
64
  "typebox": "1.1.38",
@@ -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
 
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,6 +14,7 @@
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";
@@ -81,7 +82,7 @@ export async function runWorkflow(opts: RunWorkflowOptions): Promise<RunResult>
81
82
  const budget = opts.budget ?? workflow.budget ?? {};
82
83
  const journalDir =
83
84
  opts.journalDir ??
84
- path.join(cwd, ".pi", "workflows", sanitizeWorkflowName(workflow.name));
85
+ path.join(cwd, CONFIG_DIR_NAME, "workflows", sanitizeWorkflowName(workflow.name));
85
86
 
86
87
  await fs.promises.mkdir(journalDir, { recursive: true });
87
88
  const journal = new Journal({ dir: journalDir });
@@ -986,7 +986,7 @@ function dispatchOpts(
986
986
  signal,
987
987
  allowChildRecursion,
988
988
  // UI display name for the shared sub-agent widget/FleetView (purely
989
- // observational metadata consumed by the pi-subagent-core monitor): the
989
+ // observational metadata consumed by the pi-subagents monitor): the
990
990
  // step id ("fan", "adv"), so workflow rows are distinguishable from other
991
991
  // agents. Cache hits never spawn, so they never appear — zero dispatch.
992
992
  displayName: stepIdOf(callId),
@@ -1018,6 +1018,12 @@ function usageStats(res: AgentSpawnResult, durationMs: number, ok: boolean): Ste
1018
1018
  durationMs,
1019
1019
  agents: 1,
1020
1020
  failures: ok ? 0 : 1,
1021
+ usage: {
1022
+ input: res.usage.input,
1023
+ output: res.usage.output,
1024
+ cacheRead: res.usage.cacheRead,
1025
+ cacheWrite: res.usage.cacheWrite,
1026
+ },
1021
1027
  };
1022
1028
  }
1023
1029
 
@@ -1028,6 +1034,20 @@ function addStats(a: StepStats, b: StepStats): StepStats {
1028
1034
  durationMs: a.durationMs + b.durationMs,
1029
1035
  agents: a.agents + b.agents,
1030
1036
  failures: a.failures + b.failures,
1037
+ usage: mergeUsage(a.usage, b.usage),
1038
+ };
1039
+ }
1040
+
1041
+ function mergeUsage(
1042
+ a: StepStats["usage"],
1043
+ b: StepStats["usage"],
1044
+ ): StepStats["usage"] {
1045
+ if (!a && !b) return undefined;
1046
+ return {
1047
+ input: (a?.input ?? 0) + (b?.input ?? 0),
1048
+ output: (a?.output ?? 0) + (b?.output ?? 0),
1049
+ cacheRead: (a?.cacheRead ?? 0) + (b?.cacheRead ?? 0),
1050
+ cacheWrite: (a?.cacheWrite ?? 0) + (b?.cacheWrite ?? 0),
1031
1051
  };
1032
1052
  }
1033
1053
 
@@ -1038,14 +1058,16 @@ export function aggregateStats(stats: readonly StepStats[], durationMs: number):
1038
1058
  let agents = 0;
1039
1059
  let failures = 0;
1040
1060
  let dur = 0;
1061
+ let usage: StepStats["usage"];
1041
1062
  for (const s of stats) {
1042
1063
  tokens += s.tokens;
1043
1064
  cost += s.cost;
1044
1065
  agents += s.agents;
1045
1066
  failures += s.failures;
1046
1067
  dur += s.durationMs;
1068
+ usage = mergeUsage(usage, s.usage);
1047
1069
  }
1048
- return { tokens, cost, durationMs: durationMs > 0 ? durationMs : dur, agents, failures };
1070
+ return { tokens, cost, durationMs: durationMs > 0 ? durationMs : dur, agents, failures, usage };
1049
1071
  }
1050
1072
 
1051
1073
  function withDuration(stats: StepStats, start: number): StepStats {
package/src/types.ts CHANGED
@@ -55,6 +55,15 @@ export interface StepStats {
55
55
  readonly durationMs: number;
56
56
  readonly agents: number;
57
57
  readonly failures: number;
58
+ /** Nested-LLM usage split, populated for agent-dispatch steps and surfaced
59
+ * as `usage` on the run_workflow tool result (pi usage accounting). Absent
60
+ * for zero-dispatch steps (code/log) and journals written before this field. */
61
+ readonly usage?: {
62
+ readonly input: number;
63
+ readonly output: number;
64
+ readonly cacheRead: number;
65
+ readonly cacheWrite: number;
66
+ };
58
67
  }
59
68
 
60
69
  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;