@juicesharp/rpiv-workflow 1.14.0

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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/api.ts +557 -0
  4. package/audit.ts +217 -0
  5. package/built-ins.ts +65 -0
  6. package/command.ts +137 -0
  7. package/docs/cover.png +0 -0
  8. package/docs/cover.svg +120 -0
  9. package/docs/workflow-authoring.md +629 -0
  10. package/docs/workflow-basics.md +122 -0
  11. package/docs-protocol.ts +106 -0
  12. package/fanout.ts +96 -0
  13. package/host.ts +97 -0
  14. package/index.ts +230 -0
  15. package/internal-utils.ts +69 -0
  16. package/internal.ts +27 -0
  17. package/layers.ts +33 -0
  18. package/lifecycle.ts +274 -0
  19. package/load/cache.test.ts +82 -0
  20. package/load/cache.ts +40 -0
  21. package/load/index.ts +159 -0
  22. package/load/merge.ts +136 -0
  23. package/load/normalize.ts +73 -0
  24. package/load/paths.ts +32 -0
  25. package/load/resolve-default.ts +43 -0
  26. package/load/shape-guards.test.ts +74 -0
  27. package/load/shape-guards.ts +42 -0
  28. package/messages.ts +185 -0
  29. package/outcomes/collectors/directory-path.test.ts +64 -0
  30. package/outcomes/collectors/directory-path.ts +40 -0
  31. package/outcomes/collectors/index.ts +21 -0
  32. package/outcomes/collectors/tool-call.test.ts +110 -0
  33. package/outcomes/collectors/tool-call.ts +63 -0
  34. package/outcomes/collectors/transcript-path.test.ts +70 -0
  35. package/outcomes/collectors/transcript-path.ts +53 -0
  36. package/outcomes/collectors/union.test.ts +59 -0
  37. package/outcomes/collectors/union.ts +55 -0
  38. package/outcomes/collectors/url.test.ts +67 -0
  39. package/outcomes/collectors/url.ts +45 -0
  40. package/outcomes/collectors/workspace-diff.test.ts +107 -0
  41. package/outcomes/collectors/workspace-diff.ts +123 -0
  42. package/outcomes/git-commit.test.ts +194 -0
  43. package/outcomes/git-commit.ts +192 -0
  44. package/outcomes/index.ts +22 -0
  45. package/outcomes/parsers/index.ts +11 -0
  46. package/outcomes/parsers/json-body.test.ts +80 -0
  47. package/outcomes/parsers/json-body.ts +50 -0
  48. package/outcomes/side-effect.ts +26 -0
  49. package/output-spec.ts +170 -0
  50. package/output.ts +98 -0
  51. package/package.json +83 -0
  52. package/preview.ts +120 -0
  53. package/routing.ts +79 -0
  54. package/runner/chain-advance.ts +185 -0
  55. package/runner/index.ts +7 -0
  56. package/runner/runner.ts +356 -0
  57. package/runner/script-stage.ts +240 -0
  58. package/runner/stage-lifecycle.ts +447 -0
  59. package/sessions/extraction.ts +297 -0
  60. package/sessions/index.ts +7 -0
  61. package/sessions/sessions.ts +269 -0
  62. package/sessions/spawn.ts +135 -0
  63. package/state/index.ts +27 -0
  64. package/state/paths.ts +46 -0
  65. package/state/reads.ts +190 -0
  66. package/state/state.ts +115 -0
  67. package/state/writes.ts +58 -0
  68. package/transcript.ts +156 -0
  69. package/triggers.ts +27 -0
  70. package/typebox-adapter.ts +48 -0
  71. package/types.ts +237 -0
  72. package/validate-output.ts +120 -0
  73. package/validate-workflow.ts +491 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 juicesharp
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,449 @@
1
+ # @juicesharp/rpiv-workflow
2
+
3
+ <div align="center">
4
+ <a href="https://github.com/juicesharp/rpiv-mono/tree/main/packages/rpiv-workflow">
5
+ <picture>
6
+ <img src="https://raw.githubusercontent.com/juicesharp/rpiv-mono/main/packages/rpiv-workflow/docs/cover.png" alt="rpiv-workflow cover" width="50%">
7
+ </picture>
8
+ </a>
9
+ </div>
10
+
11
+ Pi extension. Chain Pi skills into typed multi-stage workflows with audited JSONL state, predicate routing, and per-stage output validation.
12
+
13
+ **Skill-agnostic.** The runner sends `/skill:<name>` via Pi's native dispatch — it doesn't know or care who shipped the skill. Install on its own and write workflows over your own `~/.pi/agent/skills/`, or pair with [`@juicesharp/rpiv-pi`](../rpiv-pi) to use rpiv-pi's bundled `mid`, `large`, `small` workflows over rpiv-pi's bundled skills.
14
+
15
+ ## Pick your lane
16
+
17
+ This package serves four overlapping audiences. Find yours, then jump to the matching section:
18
+
19
+ - **I want to run workflows over my own Pi skills.** → [Install](#install) → [Use](#use) → [Configure](#configure).
20
+ - **I'm shipping a workflow pack others install.** → [Configure](#configure) (the "config vs pack" split is what makes packs safe) → [Authoring DSL](#authoring-dsl).
21
+ - **I'm bundling workflows inside my own Pi extension.** → [Programmatic registration](#programmatic-registration).
22
+ - **I'm embedding the runtime in a non-Pi host.** → [Host boundary](#host-boundary) + [`runWorkflow`](#programmatic-runner).
23
+
24
+ ## Install
25
+
26
+ ```sh
27
+ pi install @juicesharp/rpiv-workflow
28
+ ```
29
+
30
+ ## Use
31
+
32
+ ```
33
+ /wf # preview every loaded workflow
34
+ /wf <name> # preview one workflow's stage graph
35
+ /wf <name> <input> # run a workflow with <input> piped to the start stage
36
+ ```
37
+
38
+ ## Configure
39
+
40
+ The loader merges workflows from three layers (each later layer overrides earlier by workflow name):
41
+
42
+ ```
43
+ built-in (programmatic — registered by sibling packages like rpiv-pi)
44
+ ← user packs (~/.config/rpiv-workflow/workflows/*.ts, alpha-sorted)
45
+ ← user config (~/.config/rpiv-workflow/workflows.config.ts)
46
+ ← project packs (<cwd>/.rpiv-workflow/workflows/*.ts, alpha-sorted)
47
+ ← project config (<cwd>/.rpiv-workflow/workflows.config.ts)
48
+ ```
49
+
50
+ Two file roles per layer:
51
+
52
+ - **Config file** — the one TypeScript file you hand-edit. Accepts three default-export shapes:
53
+
54
+ ```ts
55
+ // 1. A single Workflow
56
+ import { defineWorkflow, produces, acts } from "@juicesharp/rpiv-workflow";
57
+ export default defineWorkflow({
58
+ name: "ship",
59
+ start: "implement",
60
+ stages: { implement: acts(), commit: acts() },
61
+ edges: { implement: "commit", commit: "stop" },
62
+ });
63
+
64
+ // 2. A Workflow[] with a single entry
65
+ export default [ /* one workflow */ ];
66
+
67
+ // 3. The envelope form — required when shipping multiple workflows
68
+ export default {
69
+ workflows: [ /* many */ ],
70
+ default: "ship", // which one `/wf <input>` runs without a name
71
+ };
72
+ ```
73
+
74
+ - **Pack files** (`workflows/*.ts`) — installable bundles others can drop in. Accept only `Workflow | Workflow[]`. Packs **cannot** set `default` — that lives in the config file. This is what makes installable workflow packs safe: a pack contributes new workflows without overriding the user's default.
75
+
76
+ ## Authoring DSL
77
+
78
+ A workflow is a typed graph: named entry point, a `stages` record, and an `edges` table that maps each stage to another stage name, the sentinel `"stop"`, or a predicate function that chooses at runtime.
79
+
80
+ Three factories for the two stage kinds:
81
+
82
+ - `produces(overrides?)` — `kind: "produces"`. The skill writes a file the next stage reads. Halts the chain if the path doesn't appear in the transcript. Without a Pi session, use [`produces.script`](#script-stages-no-pi-session).
83
+ - `acts(overrides?)` — `kind: "side-effect"`. The skill's side effect IS the work (commit, implement). The next stage inherits the prior artifact list forward — the stage's prompt receives the upstream primary artifact's handle. Without a Pi session, use [`acts.script`](#script-stages-no-pi-session).
84
+ - `terminal(overrides?)` — `kind: "side-effect"` with `inheritsArtifacts: false`. A side-effect stage that does NOT inherit the upstream artifact: its prompt receives `originalInput` (the run's brief), the upstream-artifact preflight is bypassed, and the rolling primary slot is cleared on success so anything downstream also starts without an inherited handle. The right answer for a final cleanup / summary / post-run notification stage that shouldn't be coupled to the upstream chain. Without a Pi session, use [`terminal.script`](#script-stages-no-pi-session).
85
+
86
+ ### Multi-input stages — `reads:` and the named-publish registry
87
+
88
+ A stage that needs more than one upstream artifact (the canonical example: a "revise plan based on review" step that consumes both the plan and the review) declares `reads:` against names it expects in the registry:
89
+
90
+ ```ts
91
+ revise: produces({ outcome: planOutcome, reads: ["plans", "reviews"] })
92
+ ```
93
+
94
+ When set, the runner builds a labelled-flag prompt — `/skill:revise --plans <plan-path> --reviews <review-path>` — replacing the default single-artifact form. Multi-artifact stages get flag repetition (`--plans <a> --plans <b>`).
95
+
96
+ Names address `state.named`, an accumulating registry every `produces` stage appends onto on each successful run. The slot key is `outcome.name ?? stage.<record-key>`:
97
+
98
+ - **Outcome carries a name.** Multiple stages wiring the same outcome converge — both stages append onto the same `state.named[name]` slot, latest-wins on read. This is how to express "two stages both produce the canonical plan" without restating the name on each stage.
99
+ - **Outcome carries no name.** Stages publish under their record key. Downstream `reads: ["blueprint", "code-review"]` references stage names directly.
100
+
101
+ Slots are arrays — iteration history is preserved across backward-jump loops; the default read resolves to `array.at(-1)`. Load-time validation rejects `reads:` references whose name no produces stage publishes; the `ensureNamedReads` preflight halts at runtime when a name's slot is empty (the producer hasn't fired yet on this path).
102
+
103
+ Conditional routing uses `gate(field, branches)` with the bundled predicate helpers (`gt` / `gte` / `lt` / `lte` / `eq`):
104
+
105
+ ```ts
106
+ import { gate, gt, eq } from "@juicesharp/rpiv-workflow";
107
+ edges: { "code-review": gate("blockers_count", { revise: gt(0), commit: eq(0) }) }
108
+ ```
109
+
110
+ Branches are evaluated against `Number(output.data[field])` in declaration order; the first matching predicate wins, and the last declared branch is the fallback when no predicate matches (so missing or non-numeric fields route to the last branch).
111
+
112
+ Hand-rolled routes use `defineRoute(targets, fn, opts?)` — by default `opts.readsData` is `true` (reads `output.data`, requires the source stage to declare an `outputSchema`); pass `{ readsData: false }` for a route that consults only `state` / `output.meta`.
113
+
114
+ ## Script stages (no Pi session)
115
+
116
+ Some stages don't need an LLM — they merge two upstream artifacts, bump a version field, or fire a post-run notification. The `.script` accessor on each factory runs a pure TS function in place of a Pi skill body. No `/skill:<name>` dispatch, no session, no transcript scan: the function gets a `ScriptContext` (`cwd`, `input` — upstream `Output` envelope, `state` — `Readonly<RunState>`) and either returns the new envelope (`produces.script`) or returns `void` (`acts.script` / `terminal.script`).
117
+
118
+ Stages with `.run` set CANNOT also declare `skill`, `outcome`, `fanout`, or `sessionPolicy: "continue"` — load-time validation rejects the combination. `produces.script` may declare `outputSchema` + `maxRetries` + `onInvalid` (same retry semantics as skill stages); `acts.script` / `terminal.script` may declare `inputSchema` for the upstream-data contract.
119
+
120
+ ### `produces.script` — merge two upstream artifacts into a new Output
121
+
122
+ ```ts
123
+ import { produces, fs, type ScriptContext } from "@juicesharp/rpiv-workflow";
124
+ import { readFile, writeFile } from "node:fs/promises";
125
+ import { join } from "node:path";
126
+
127
+ const merge = produces.script({
128
+ outputSchema: typeboxSchema(Type.Object({ planPath: Type.String() })),
129
+ run: async (ctx: ScriptContext) => {
130
+ const upstream = ctx.input?.artifacts ?? [];
131
+ const bodies = await Promise.all(
132
+ upstream
133
+ .filter((a) => a.handle.kind === "fs")
134
+ .map((a) => readFile(join(ctx.cwd, (a.handle as { path: string }).path), "utf-8")),
135
+ );
136
+ const planPath = `.rpiv/artifacts/plans/${Date.now()}.md`;
137
+ await writeFile(join(ctx.cwd, planPath), bodies.join("\n\n---\n\n"));
138
+ return {
139
+ kind: "plan",
140
+ artifacts: [{ handle: fs(planPath), role: "primary" }],
141
+ data: { planPath },
142
+ };
143
+ },
144
+ });
145
+ ```
146
+
147
+ The runner stamps `meta` (`stage`, `stageNumber`, `ts`, `runId`) on the returned envelope — authors only fill `kind` + `artifacts` + `data`. The first artifact (if any) becomes the rolling primary so downstream stages inherit it via `ctx.input.artifacts[0]` and `ctx.state.primaryArtifact`.
148
+
149
+ ### `acts.script` — bump a file's version field
150
+
151
+ ```ts
152
+ import { acts, type ScriptContext } from "@juicesharp/rpiv-workflow";
153
+ import { readFile, writeFile } from "node:fs/promises";
154
+ import { join } from "node:path";
155
+
156
+ const bumpVersion = acts.script({
157
+ run: async (ctx: ScriptContext) => {
158
+ const path = join(ctx.cwd, "package.json");
159
+ const pkg = JSON.parse(await readFile(path, "utf-8")) as { version: string };
160
+ const [major, minor, patch] = pkg.version.split(".").map(Number);
161
+ pkg.version = `${major}.${minor}.${(patch ?? 0) + 1}`;
162
+ await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`);
163
+ },
164
+ });
165
+ ```
166
+
167
+ The runner synthesises a `{ kind: "side-effect", artifacts: [], data: {} }` envelope for the audit row. The rolling primary artifact slot is preserved — a downstream stage still inherits whatever the upstream `produces` stage set, same posture as a skill-based `acts(...)`.
168
+
169
+ ### `terminal.script` — post-run cleanup that doesn't see the artifact
170
+
171
+ ```ts
172
+ import { terminal, type ScriptContext } from "@juicesharp/rpiv-workflow";
173
+
174
+ const notifySlack = terminal.script({
175
+ run: async (ctx: ScriptContext) => {
176
+ // ctx.input is the upstream row's envelope — `ctx.state.primaryArtifact`
177
+ // is NOT consulted (terminal.* opts out of inheritance). Any stage that
178
+ // runs after also starts without an inherited handle.
179
+ await fetch(process.env.SLACK_WEBHOOK!, {
180
+ method: "POST",
181
+ body: JSON.stringify({ text: `Run ${ctx.state.originalInput} complete.` }),
182
+ });
183
+ },
184
+ });
185
+ ```
186
+
187
+ Like `terminal()`, the script variant desugars to `acts.script({ ...opts, inheritsArtifacts: false })`: the rolling primary slot is cleared on success so downstream stages start from a clean inheritance state. The right answer for a final cleanup / summary / post-run notification stage that shouldn't be coupled to the upstream chain.
188
+
189
+ ### Lifecycle parity
190
+
191
+ Script stages fire the same lifecycle events as skill stages — `onStageStart` → (`onStageRetry`?) → `onStageEnd` | `onStageError` — with a `StageRef` discriminator of `kind: "script"` (vs `kind: "skill"`). The JSONL audit row carries the stage's record key in `stage` and omits the `skill` field entirely, so post-hoc readers distinguish the two paths by `row.skill === undefined`.
192
+
193
+ A thrown `run()` body records a terminal failure attributed to the stage via `MSG_SCRIPT_THREW`; the run halts and `onStageError` fires.
194
+
195
+ ## Programmatic registration
196
+
197
+ Sibling packages contribute workflows at extension load:
198
+
199
+ ```ts
200
+ import { registerBuiltIns, type WorkflowHost } from "@juicesharp/rpiv-workflow";
201
+ import { myWorkflows } from "./my-workflows.js";
202
+
203
+ export default function (host: WorkflowHost): void {
204
+ registerBuiltIns(myWorkflows);
205
+ }
206
+ ```
207
+
208
+ You can keep using `ExtensionAPI` from `@earendil-works/pi-coding-agent` in the
209
+ signature instead — it structurally satisfies `WorkflowHost`. Either choice
210
+ works; the published types name only the workflow-owned port.
211
+
212
+ These workflows are merged into the lowest layer (`built-in`); user/project overlays still override by name.
213
+
214
+ ### Cross-package lifecycle (`registerLifecycle`)
215
+
216
+ When another extension needs to observe every workflow run in the process — typically an overlay widget that visualises in-flight stages, a metrics emitter, or a side-effect bridge — register a listener bundle at extension load:
217
+
218
+ ```ts
219
+ import { registerLifecycle, type WorkflowHost } from "@juicesharp/rpiv-workflow";
220
+
221
+ export default function (host: WorkflowHost): void {
222
+ const dispose = registerLifecycle({
223
+ onWorkflowStart: (ctx) => widget.open(ctx.runId, ctx.workflow, ctx.totalStages),
224
+ onStageStart: (stage, ctx) => widget.markActive(ctx.runId, stage.name),
225
+ onStageEnd: (stage, _o, ctx) => widget.markDone(ctx.runId, stage.name),
226
+ onStageError: (stage, err, ctx)=> widget.markFailed(ctx.runId, stage.name, err),
227
+ onWorkflowEnd: (result, ctx) => widget.close(ctx.runId, result.success),
228
+ });
229
+ // dispose() removes the bundle if the extension ever unloads.
230
+ }
231
+ ```
232
+
233
+ Every fired event walks the registry in registration order, then the per-call bundle (if any) the embedder passed to `runWorkflow({ lifecycle })`. Multiple bundles coexist; one bundle throwing does not affect siblings or halt the run (throws are caught and logged via `ctx.ui.notify(..., "warning")`).
234
+
235
+ The registry is anchored on `Symbol.for("@juicesharp/rpiv-workflow:lifecycle")`, mirroring the `registerBuiltIns` pattern, so cross-package module resolution still shares one slot. Snapshot semantics: each event observes the registry as it stands at that instant — a registration made mid-event applies to subsequent events, not the in-flight one.
236
+
237
+ `@juicesharp/rpiv-pi` is the reference consumer (overlay widget that visualises in-flight runs).
238
+
239
+ ## Host boundary
240
+
241
+ `rpiv-workflow`'s public type surface names **zero** `@earendil-works/pi-coding-agent`
242
+ types. The runtime declares two workflow-owned port interfaces in
243
+ `./host.js`:
244
+
245
+ - `WorkflowHost` — registry-level host (default export, continue-policy
246
+ sends, skill-registration preflight).
247
+ - `WorkflowContext` — per-command ctx passed to `runWorkflow`; also the
248
+ replacement ctx delivered to `newSession`'s `withSession` callback.
249
+ `sendUserMessage` is optional at the type level (the outer command ctx
250
+ Pi delivers to `/wf` doesn't carry one) — the runtime guarantees it is
251
+ present inside `withSession`.
252
+
253
+ Pi's `ExtensionAPI` / `ExtensionCommandContext` / `ReplacedSessionContext`
254
+ structurally satisfy these ports, so existing embedders pass their Pi
255
+ handles through unchanged. A
256
+ compile-time tripwire (`host.test.ts`) fails immediately if Pi's API ever
257
+ drifts below the port shape. A future non-Pi host implements the three
258
+ port interfaces and drives the runtime without any pi-coding-agent
259
+ dependency.
260
+
261
+ ## Programmatic runner
262
+
263
+ Embedders drive workflows from outside `/wf`:
264
+
265
+ ```ts
266
+ import { runWorkflow } from "@juicesharp/rpiv-workflow";
267
+
268
+ const result = await runWorkflow({
269
+ workflow: myFlow,
270
+ input: "task description",
271
+ host: piHost, // any WorkflowHost-shaped value
272
+ });
273
+ ```
274
+
275
+ Returns `{ runId, stagesCompleted, success }`. Past-run inspection uses `listRuns(cwd)` / `readHeader` / `readLastStage` / `listArtifacts`.
276
+
277
+ ### Lifecycle
278
+
279
+ Pass `lifecycle` to observe stage progress in-process without re-reading the JSONL:
280
+
281
+ ```ts
282
+ import { runWorkflow } from "@juicesharp/rpiv-workflow";
283
+
284
+ const result = await runWorkflow({
285
+ workflow: myFlow,
286
+ input: "task description",
287
+ host: piHost,
288
+ trigger: { kind: "external", source: "webhook", ref: "evt_42" },
289
+ lifecycle: {
290
+ onStageStart: (stage, ctx) => console.log("→", stage.name, ctx.runId),
291
+ onStageEnd: (stage, output) => console.log("✓", stage.name, output.kind),
292
+ onWorkflowEnd: (result) => console.log("done:", result.success),
293
+ },
294
+ });
295
+ ```
296
+
297
+ Every callback receives a `LifecycleContext` with `runId`, `workflow`, `totalStages`, the `trigger` metadata, and a `Readonly<RunState>` snapshot. Events fire AFTER their JSONL row lands on disk, so a listener that calls `readLastStage(cwd, ctx.runId)` is guaranteed to see the just-recorded row. Callbacks may be async — the runner awaits them before advancing, giving back-pressure for free. Throws are caught + surfaced through `ctx.ui.notify(..., "warning")`; they never halt the run.
298
+
299
+ Available callbacks: `onWorkflowStart`, `onStageStart`, `onStageEnd`, `onStageRetry`, `onStageError`, `onRoute`, `onFanoutStart`, `onFanoutUnitStart`, `onFanoutUnitEnd`, `onWorkflowEnd`. See the `LifecycleListeners` JSDoc for the per-event payload.
300
+
301
+ ### Trigger
302
+
303
+ `trigger` defaults to `{ kind: "programmatic" }`. Set it explicitly when spawning a run from a cron job, webhook handler, or sibling extension — the value lands in the JSONL header (`WorkflowHeader.trigger`), surfaces on `RunSummary.trigger` for past-run readers, and is threaded into every `LifecycleContext`:
304
+
305
+ ```ts
306
+ type RunTrigger =
307
+ | { kind: "command"; name: string; meta?: Record<string, unknown> }
308
+ | { kind: "programmatic"; source?: string; meta?: Record<string, unknown> }
309
+ | { kind: "external"; source: string; ref?: string; meta?: Record<string, unknown> };
310
+ ```
311
+
312
+ `/wf` sets `{ kind: "command", name: "wf" }` itself — embedders only set this field for non-`/wf` entry points. Pi is single-active-session: external trigger sources MUST gate their own spawning if a run is already in flight; the runtime does not enforce a process-wide mutex.
313
+
314
+ ## Outcomes — collectors and parsers
315
+
316
+ Each `produces` stage wires an `OutputSpec` that tells the runtime three things:
317
+
318
+ ```ts
319
+ interface OutputSpec<Snapshot, Kind, Data> {
320
+ name?: string; // CATEGORISE — the publish slot in state.named (optional)
321
+ collector: ArtifactCollector<Snapshot>; // ENUMERATE — what did the stage produce?
322
+ parser?: ArtifactParser<Snapshot, Kind, Data>; // INTERPRET — what's the typed data channel?
323
+ }
324
+ ```
325
+
326
+ `collector.collect(ctx)` returns the artifacts the stage emitted. `parser.parse(ctx)` (optional) turns them into the typed `output.data` downstream stages narrow on. With no parser, `output.data` is the artifact list itself (`kind = "artifacts"`). When `name` is set, every stage wired with this outcome publishes onto `state.named[name]` — the convergence mechanism behind [`reads:`](#multi-input-stages--reads-and-the-named-publish-registry); when omitted, stages publish under their record key.
327
+
328
+ There is no framework default for `produces` — load-time validation rejects a stage without an outcome. The `.rpiv/artifacts/<bucket>/<file>.md` layout is an rpiv convention, not a framework truth; pair with [`@juicesharp/rpiv-pi`](../rpiv-pi) for `rpivArtifactMdOutcome`, or wire your own.
329
+
330
+ ### Authoring a collector
331
+
332
+ The collector is the user-supplyable primitive — one method, one return type:
333
+
334
+ ```ts
335
+ import { defineCollector, opaque, type Artifact } from "@juicesharp/rpiv-workflow";
336
+
337
+ export const linearTicketCollector = defineCollector((ctx) => {
338
+ const id = parseLinearIdFromBranch(ctx.branch);
339
+ if (!id) return { kind: "fatal", message: "stage did not emit a Linear ticket id" };
340
+ return { kind: "ok", artifacts: [{ handle: opaque(id), role: "ticket" }] };
341
+ });
342
+ ```
343
+
344
+ Collectors that need a pre-stage snapshot declare a `snapshot` hook — its return value lands on `ctx.snapshot` for the matching `collect` call. Compose the bundled `gitHeadSnapshot` into any snapshot:
345
+
346
+ ```ts
347
+ import { defineCollector, fs, gitHeadSnapshot, type GitHeadSnapshot } from "@juicesharp/rpiv-workflow";
348
+
349
+ const codegenCollector = defineCollector<GitHeadSnapshot | undefined>({
350
+ snapshot: gitHeadSnapshot,
351
+ collect: async (ctx) => {
352
+ if (!ctx.snapshot) return { kind: "ok", artifacts: [] };
353
+ const files = await diffWorkspace(ctx.cwd, ctx.snapshot.baselineSha);
354
+ return { kind: "ok", artifacts: files.map((p) => ({ handle: fs(p), role: "generated" })) };
355
+ },
356
+ });
357
+ ```
358
+
359
+ ### Bundled collector catalog
360
+
361
+ The framework ships only host-agnostic primitives — no Pi tool-name defaults, no `.rpiv/artifacts/` defaults, no domain helpers. Wrap them or compose with `unionCollectors` to build your own conventions. Grouped by discovery model:
362
+
363
+ **Scan the agent's text**
364
+
365
+ | Collector | Signature | What it does |
366
+ | --- | --- | --- |
367
+ | `transcriptPathCollector` | `({ pattern: RegExp })` | Scans assistant text for the last regex match; emits one `fs` artifact. Pattern is required — no framework default. |
368
+ | `directoryPathCollector` | `({ dir, ext? })` | Ergonomic wrapper over `transcriptPathCollector` for the `<dir>/<file>.<ext>` shape. |
369
+ | `urlCollector` | `({ pattern? })` | Scans for `https?://…`; emits a `url` handle. Default pattern is RFC-3986-flavoured; override for narrower hosts. |
370
+
371
+ **Observe tool use**
372
+
373
+ | Collector | Signature | What it does |
374
+ | --- | --- | --- |
375
+ | `toolCallCollector` | `({ match, toArtifact })` | Walks every `tool_use` part; emits N artifacts via the author's mappers. Universal across any Pi tool name. |
376
+
377
+ **Diff the filesystem**
378
+
379
+ | Collector | Signature | What it does |
380
+ | --- | --- | --- |
381
+ | `workspaceDiffCollector` | `({ filter? })` | Captures `git status --porcelain` pre-stage, diffs post-stage. One `fs` artifact per file the stage touched. Fail-soft when not a git repo. |
382
+
383
+ **Git**
384
+
385
+ | Collector | Signature | What it does |
386
+ | --- | --- | --- |
387
+ | `gitCommitCollector` | — | Detects a new HEAD commit vs. the pre-stage snapshot; emits an `opaque(sha)` artifact tagged `role: "commit"`. |
388
+
389
+ **Composition + empty**
390
+
391
+ | Collector | Signature | What it does |
392
+ | --- | --- | --- |
393
+ | `unionCollectors(...cs)` | — | Run N collectors, concatenate artifacts. Fatal only when every sub-collector fataled. |
394
+ | `noopCollector` | — | Always returns `{ kind: "ok", artifacts: [] }`. The primitive `sideEffectOutcome` is built directly from it: `sideEffectOutcome = { collector: noopCollector }`. |
395
+
396
+ Handle constructors: `fs(path)`, `url(href)`, `opaque(id)`, `inline(bytes, mime?)` — replace the verbose `{ kind: …, … }` literal at call sites. Serialise any handle to its canonical string with `handleToString`.
397
+
398
+ ### Bundled parser catalog
399
+
400
+ | Parser | Output `kind` | Output `data` |
401
+ | --- | --- | --- |
402
+ | `jsonBodyParser` | `"json"` | `JSON.parse` of the primary `fs` artifact's body (`unknown` — narrow via `outputSchema`). |
403
+ | `gitCommitParser` | `"git-commit"` | `GitCommitData` (sha, prevSha, subject, filesChanged) parsed from `git`. |
404
+
405
+ Format-specific parsers (markdown frontmatter, YAML, TOML, …) live in the convention layer that owns them — rpiv-pi ships its own `frontmatterParser`.
406
+
407
+ ### Wiring an outcome onto a stage
408
+
409
+ ```ts
410
+ import {
411
+ produces, defineWorkflow,
412
+ toolCallCollector, jsonBodyParser, fs,
413
+ } from "@juicesharp/rpiv-workflow";
414
+
415
+ const writeFileCollector = toolCallCollector({
416
+ match: (tc) => tc.name === "write_file" || tc.name === "edit",
417
+ toArtifact: (tc) => ({ handle: fs(String(tc.input.path ?? tc.input.target_file)) }),
418
+ });
419
+
420
+ export default defineWorkflow({
421
+ name: "scaffold",
422
+ start: "generate",
423
+ stages: {
424
+ generate: produces({
425
+ outcome: { collector: writeFileCollector, parser: jsonBodyParser },
426
+ }),
427
+ },
428
+ edges: { generate: "stop" },
429
+ });
430
+ ```
431
+
432
+ ## Validators: sync vs async
433
+
434
+ `inputSchema` and `outputSchema` are [Standard Schema v1](https://standardschema.dev) values — Zod, Valibot, ArkType, TypeBox (via `typeboxSchema`), or hand-rolled `{ "~standard": { validate } }` objects. The runner awaits the schema's `~standard.validate` at both seams, so it works with sync and async schemas alike.
435
+
436
+ **Default to sync.** Pure shape contracts (`Type.Object({ … })`, `z.object({ … })`) resolve in one microtask, give the agent precise retry diagnostics, and have no failure mode beyond "this isn't the shape you said." For 95% of stages this is the right answer.
437
+
438
+ **Reach for async when correctness needs I/O.** Examples that don't fit the sync model:
439
+ - "the path in the output must actually exist on disk" — `fs.access` is async.
440
+ - "the spec the agent emitted must validate against a live endpoint" — `fetch` is async.
441
+ - you're already on an async-by-default schema lib (ArkType's deeply-async paths).
442
+
443
+ The contract is identical — author an async `~standard.validate` and the runner awaits it. A schema whose Promise never settles is bounded by the stage's `validateTimeoutMs` (default 5 min); a rejected Promise surfaces as a clean stage halt, attributed to the stage, with the same error class as a shape-failure halt. No opt-in flag, no parallel code path.
444
+
445
+ > Keep validation separate from the collector + parser. The collector's job is "what did the agent produce?" (enumerate); the parser's job is "parse it into typed data" (shape). The validator's job is "is the result correct?" (check + verify). With async validators available you don't have to push I/O verification into a custom collector/parser — keep them pure and put correctness checks on `outputSchema`.
446
+
447
+ ## Architecture
448
+
449
+ See [`.rpiv/guidance/packages/rpiv-workflow/architecture.md`](../../.rpiv/guidance/packages/rpiv-workflow/architecture.md).