@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.
- package/LICENSE +21 -0
- package/README.md +449 -0
- package/api.ts +557 -0
- package/audit.ts +217 -0
- package/built-ins.ts +65 -0
- package/command.ts +137 -0
- package/docs/cover.png +0 -0
- package/docs/cover.svg +120 -0
- package/docs/workflow-authoring.md +629 -0
- package/docs/workflow-basics.md +122 -0
- package/docs-protocol.ts +106 -0
- package/fanout.ts +96 -0
- package/host.ts +97 -0
- package/index.ts +230 -0
- package/internal-utils.ts +69 -0
- package/internal.ts +27 -0
- package/layers.ts +33 -0
- package/lifecycle.ts +274 -0
- package/load/cache.test.ts +82 -0
- package/load/cache.ts +40 -0
- package/load/index.ts +159 -0
- package/load/merge.ts +136 -0
- package/load/normalize.ts +73 -0
- package/load/paths.ts +32 -0
- package/load/resolve-default.ts +43 -0
- package/load/shape-guards.test.ts +74 -0
- package/load/shape-guards.ts +42 -0
- package/messages.ts +185 -0
- package/outcomes/collectors/directory-path.test.ts +64 -0
- package/outcomes/collectors/directory-path.ts +40 -0
- package/outcomes/collectors/index.ts +21 -0
- package/outcomes/collectors/tool-call.test.ts +110 -0
- package/outcomes/collectors/tool-call.ts +63 -0
- package/outcomes/collectors/transcript-path.test.ts +70 -0
- package/outcomes/collectors/transcript-path.ts +53 -0
- package/outcomes/collectors/union.test.ts +59 -0
- package/outcomes/collectors/union.ts +55 -0
- package/outcomes/collectors/url.test.ts +67 -0
- package/outcomes/collectors/url.ts +45 -0
- package/outcomes/collectors/workspace-diff.test.ts +107 -0
- package/outcomes/collectors/workspace-diff.ts +123 -0
- package/outcomes/git-commit.test.ts +194 -0
- package/outcomes/git-commit.ts +192 -0
- package/outcomes/index.ts +22 -0
- package/outcomes/parsers/index.ts +11 -0
- package/outcomes/parsers/json-body.test.ts +80 -0
- package/outcomes/parsers/json-body.ts +50 -0
- package/outcomes/side-effect.ts +26 -0
- package/output-spec.ts +170 -0
- package/output.ts +98 -0
- package/package.json +83 -0
- package/preview.ts +120 -0
- package/routing.ts +79 -0
- package/runner/chain-advance.ts +185 -0
- package/runner/index.ts +7 -0
- package/runner/runner.ts +356 -0
- package/runner/script-stage.ts +240 -0
- package/runner/stage-lifecycle.ts +447 -0
- package/sessions/extraction.ts +297 -0
- package/sessions/index.ts +7 -0
- package/sessions/sessions.ts +269 -0
- package/sessions/spawn.ts +135 -0
- package/state/index.ts +27 -0
- package/state/paths.ts +46 -0
- package/state/reads.ts +190 -0
- package/state/state.ts +115 -0
- package/state/writes.ts +58 -0
- package/transcript.ts +156 -0
- package/triggers.ts +27 -0
- package/typebox-adapter.ts +48 -0
- package/types.ts +237 -0
- package/validate-output.ts +120 -0
- package/validate-workflow.ts +491 -0
|
@@ -0,0 +1,629 @@
|
|
|
1
|
+
# Workflow Authoring Reference
|
|
2
|
+
|
|
3
|
+
Complete reference for the `@juicesharp/rpiv-workflow` authoring DSL. A workflow is a typed graph: named entry point, a `stages` record, and an `edges` table that maps each stage to another stage name, `"stop"`, or a predicate function.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [defineWorkflow](#defineworkflow)
|
|
8
|
+
- [Stage factories](#stage-factories)
|
|
9
|
+
- [produces](#produces)
|
|
10
|
+
- [acts](#acts)
|
|
11
|
+
- [terminal](#terminal)
|
|
12
|
+
- [Script stages](#script-stages)
|
|
13
|
+
- [Edge targets](#edge-targets)
|
|
14
|
+
- [Conditional routing](#conditional-routing)
|
|
15
|
+
- [gate](#gate)
|
|
16
|
+
- [defineRoute](#defineroute)
|
|
17
|
+
- [Predicate helpers](#predicate-helpers)
|
|
18
|
+
- [Outcomes](#outcomes)
|
|
19
|
+
- [Collector catalog](#collector-catalog)
|
|
20
|
+
- [Parser catalog](#parser-catalog)
|
|
21
|
+
- [Custom outcomes](#custom-outcomes)
|
|
22
|
+
- [Analyzing skills before wiring](#analyzing-skills-before-wiring)
|
|
23
|
+
- [Multi-input stages](#multi-input-stages)
|
|
24
|
+
- [Carrying knowledge across stages](#carrying-knowledge-across-stages)
|
|
25
|
+
- [Validators](#validators)
|
|
26
|
+
- [Complete example](#complete-example)
|
|
27
|
+
- [Validation rules](#validation-rules)
|
|
28
|
+
|
|
29
|
+
## defineWorkflow
|
|
30
|
+
|
|
31
|
+
Identity passthrough for type inference. Same idiom as `defineConfig` in Vite/Astro — zero runtime cost.
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import { defineWorkflow } from "@juicesharp/rpiv-workflow";
|
|
35
|
+
|
|
36
|
+
export default defineWorkflow({
|
|
37
|
+
name: "my-workflow", // What users type: /wf my-workflow
|
|
38
|
+
description: "...", // Optional: shown in /wf preview
|
|
39
|
+
start: "research", // Entry stage name
|
|
40
|
+
stages: { /* ... */ }, // Stage record (key = stage name)
|
|
41
|
+
edges: { /* ... */ }, // Edge table (key = stage name)
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Stage factories
|
|
46
|
+
|
|
47
|
+
Three factories for two stage kinds. Each factory returns a `StageDef` — pass overrides as needed.
|
|
48
|
+
|
|
49
|
+
### produces
|
|
50
|
+
|
|
51
|
+
`kind: "produces"`. The skill writes a file the next stage reads. Halts the chain if the path doesn't appear in the transcript. **Requires an `outcome`** — load-time validation rejects a `produces` stage without one.
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { produces, typeboxSchema } from "@juicesharp/rpiv-workflow";
|
|
55
|
+
import { Type } from "@sinclair/typebox";
|
|
56
|
+
|
|
57
|
+
// Basic — just declare the outcome
|
|
58
|
+
produces({ outcome: myOutcome })
|
|
59
|
+
|
|
60
|
+
// With output schema (enables gate routing on output.data)
|
|
61
|
+
produces({
|
|
62
|
+
outcome: myOutcome,
|
|
63
|
+
outputSchema: typeboxSchema(Type.Object({ blockers_count: Type.Integer() })),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
// With fanout (one Pi session per unit)
|
|
67
|
+
produces({
|
|
68
|
+
outcome: myOutcome,
|
|
69
|
+
fanout: myFanoutFn,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
// With validation retry
|
|
73
|
+
produces({
|
|
74
|
+
outcome: myOutcome,
|
|
75
|
+
outputSchema: typeboxSchema(Type.Object({ planPath: Type.String() })),
|
|
76
|
+
onInvalid: "retry", // default; "halt" to fail fast
|
|
77
|
+
maxRetries: 3,
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Stage options:**
|
|
82
|
+
|
|
83
|
+
| Option | Default | Description |
|
|
84
|
+
|--------|---------|-------------|
|
|
85
|
+
| `skill` | record key | Pi skill to invoke. Override when stage id ≠ skill name. |
|
|
86
|
+
| `outcome` | (required) | `OutputSpec` — how the runtime collects + parses the artifact. |
|
|
87
|
+
| `outputSchema` | none | Standard Schema v1 validator for `output.data`. Enables gate routing. |
|
|
88
|
+
| `inputSchema` | none | Standard Schema v1 validator for inherited upstream `output.data`. Rejection halts immediately. |
|
|
89
|
+
| `reads` | none | `ReadonlyArray<string>` — names this stage consumes from `state.named`. Switches the prompt to the labelled-flag form. See [Multi-input stages](#multi-input-stages). |
|
|
90
|
+
| `onInvalid` | `"retry"` | `"retry"` (re-invoke up to `maxRetries`) or `"halt"` (fail fast). |
|
|
91
|
+
| `maxRetries` | — | Max retries on schema rejection. |
|
|
92
|
+
| `validateTimeoutMs` | — | Timeout for async schemas. |
|
|
93
|
+
| `fanout` | none | `FanoutFn` — decomposes work into N units, one Pi session per unit. |
|
|
94
|
+
| `sessionPolicy` | `"fresh"` | `"fresh"` (new session) or `"continue"` (reuse prior session). |
|
|
95
|
+
|
|
96
|
+
### acts
|
|
97
|
+
|
|
98
|
+
`kind: "side-effect"`. The skill's side effect IS the work (commit, implement). The next stage inherits the prior artifact list forward.
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
import { acts } from "@juicesharp/rpiv-workflow";
|
|
102
|
+
|
|
103
|
+
// Basic side-effect
|
|
104
|
+
acts()
|
|
105
|
+
|
|
106
|
+
// With a different skill name
|
|
107
|
+
acts({ skill: "implement" })
|
|
108
|
+
|
|
109
|
+
// With fanout
|
|
110
|
+
acts({ fanout: phaseFanout })
|
|
111
|
+
|
|
112
|
+
// With outcome (e.g., git commit detection)
|
|
113
|
+
acts({ outcome: gitCommitOutcome })
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
**Stage options:** Same as `produces` except `outcome` is optional and `kind` is `"side-effect"`.
|
|
117
|
+
|
|
118
|
+
### terminal
|
|
119
|
+
|
|
120
|
+
`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) instead of an upstream artifact handle. The rolling primary slot is cleared on success so anything downstream also starts without an inherited handle.
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
import { terminal } from "@juicesharp/rpiv-workflow";
|
|
124
|
+
|
|
125
|
+
// Final notification stage
|
|
126
|
+
terminal()
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
The right answer for a final cleanup / summary / post-run notification stage that shouldn't be coupled to the upstream chain.
|
|
130
|
+
|
|
131
|
+
### Script stages
|
|
132
|
+
|
|
133
|
+
Some stages don't need an LLM. The `.script` accessor on each factory runs a pure TS function in place of a Pi skill body. No `/skill:<name>` dispatch, no session.
|
|
134
|
+
|
|
135
|
+
**`produces.script`** — returns the `Output` envelope's value-channel fields directly:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { produces, fs, type ScriptContext } from "@juicesharp/rpiv-workflow";
|
|
139
|
+
|
|
140
|
+
const merge = produces.script({
|
|
141
|
+
outputSchema: typeboxSchema(Type.Object({ planPath: Type.String() })),
|
|
142
|
+
run: async (ctx: ScriptContext) => {
|
|
143
|
+
const upstream = ctx.input?.artifacts ?? [];
|
|
144
|
+
const bodies = await Promise.all(
|
|
145
|
+
upstream
|
|
146
|
+
.filter((a) => a.handle.kind === "fs")
|
|
147
|
+
.map((a) => readFile(join(ctx.cwd, (a.handle as { path: string }).path), "utf-8")),
|
|
148
|
+
);
|
|
149
|
+
const planPath = `plans/${Date.now()}.md`;
|
|
150
|
+
await writeFile(join(ctx.cwd, planPath), bodies.join("\n\n---\n\n"));
|
|
151
|
+
return {
|
|
152
|
+
kind: "plan",
|
|
153
|
+
artifacts: [{ handle: fs(planPath), role: "primary" }],
|
|
154
|
+
data: { planPath },
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**`acts.script`** — returns `void`:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { acts, type ScriptContext } from "@juicesharp/rpiv-workflow";
|
|
164
|
+
|
|
165
|
+
const bumpVersion = acts.script({
|
|
166
|
+
run: async (ctx: ScriptContext) => {
|
|
167
|
+
const path = join(ctx.cwd, "package.json");
|
|
168
|
+
const pkg = JSON.parse(await readFile(path, "utf-8")) as { version: string };
|
|
169
|
+
const [major, minor, patch] = pkg.version.split(".").map(Number);
|
|
170
|
+
pkg.version = `${major}.${minor}.${(patch ?? 0) + 1}`;
|
|
171
|
+
await writeFile(path, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
**`terminal.script`** — like `acts.script` but clears the rolling primary slot:
|
|
177
|
+
|
|
178
|
+
```typescript
|
|
179
|
+
import { terminal, type ScriptContext } from "@juicesharp/rpiv-workflow";
|
|
180
|
+
|
|
181
|
+
const notifySlack = terminal.script({
|
|
182
|
+
run: async (ctx: ScriptContext) => {
|
|
183
|
+
await fetch(process.env.SLACK_WEBHOOK!, {
|
|
184
|
+
method: "POST",
|
|
185
|
+
body: JSON.stringify({ text: `Run ${ctx.state.originalInput} complete.` }),
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**Constraints on script stages:**
|
|
192
|
+
- Cannot declare `skill`, `outcome`, `fanout`, or `sessionPolicy: "continue"` — load-time validation rejects the combination.
|
|
193
|
+
- `produces.script` may declare `outputSchema`, `maxRetries`, `onInvalid`.
|
|
194
|
+
- `acts.script` / `terminal.script` may declare `inputSchema`.
|
|
195
|
+
|
|
196
|
+
## Edge targets
|
|
197
|
+
|
|
198
|
+
Each edge maps a stage name to one of:
|
|
199
|
+
|
|
200
|
+
```typescript
|
|
201
|
+
// 1. Another stage name
|
|
202
|
+
edges: { research: "implement" }
|
|
203
|
+
|
|
204
|
+
// 2. The terminal sentinel
|
|
205
|
+
edges: { commit: "stop" }
|
|
206
|
+
|
|
207
|
+
// 3. A predicate function (via gate or defineRoute)
|
|
208
|
+
edges: { "code-review": gate("blockers_count", { revise: gt(0), commit: eq(0) }) }
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**`STOP`** (or `"stop"`) is the terminal edge sentinel. Every workflow path should eventually reach `"stop"`.
|
|
212
|
+
|
|
213
|
+
## Conditional routing
|
|
214
|
+
|
|
215
|
+
### gate
|
|
216
|
+
|
|
217
|
+
Conditional routing keyed on a numeric field in `output.data`. Branches evaluated against `Number(output.data[field])` in declaration order; first matching predicate wins. Last declared branch is the fallback when no predicate matches.
|
|
218
|
+
|
|
219
|
+
```typescript
|
|
220
|
+
import { gate, gt, eq } from "@juicesharp/rpiv-workflow";
|
|
221
|
+
|
|
222
|
+
edges: {
|
|
223
|
+
"code-review": gate("blockers_count", {
|
|
224
|
+
revise: gt(0), // value > 0 → "revise"
|
|
225
|
+
commit: eq(0), // value = 0 → "commit"
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
// value < 0 → "commit" (no match, falls to last)
|
|
229
|
+
// missing/NaN → "commit" (no match, falls to last)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### defineRoute
|
|
233
|
+
|
|
234
|
+
Hand-rolled multi-branch routing. Returns an `EdgeFn` with `.targets` metadata for graph introspection. Auto-marks the route as reading `output.data` — pass `{ readsData: false }` for state-only routes.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
import { defineRoute } from "@juicesharp/rpiv-workflow";
|
|
238
|
+
|
|
239
|
+
edges: {
|
|
240
|
+
"decide": defineRoute(
|
|
241
|
+
["fast-path", "slow-path"], // All possible returns (required)
|
|
242
|
+
({ output }) => {
|
|
243
|
+
const data = output?.data as { complexity: string };
|
|
244
|
+
return data?.complexity === "high" ? "slow-path" : "fast-path";
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
}
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### Predicate helpers
|
|
251
|
+
|
|
252
|
+
| Helper | Returns true when |
|
|
253
|
+
|--------|-------------------|
|
|
254
|
+
| `gt(n)` | value > n |
|
|
255
|
+
| `gte(n)` | value >= n |
|
|
256
|
+
| `lt(n)` | value < n |
|
|
257
|
+
| `lte(n)` | value <= n |
|
|
258
|
+
| `eq(n)` | value === n |
|
|
259
|
+
|
|
260
|
+
## Outcomes
|
|
261
|
+
|
|
262
|
+
Each `produces` stage wires an `OutputSpec` with an optional `name` (the publish key in `state.named` — see [Multi-input stages](#multi-input-stages)), a collector (enumerate what the stage produced) and optional parser (interpret into typed data):
|
|
263
|
+
|
|
264
|
+
```typescript
|
|
265
|
+
interface OutputSpec<Snapshot, Kind, Data> {
|
|
266
|
+
name?: string; // CATEGORISE — publish slot in state.named
|
|
267
|
+
collector: ArtifactCollector<Snapshot>; // ENUMERATE
|
|
268
|
+
parser?: ArtifactParser<Snapshot, Kind, Data>; // INTERPRET (optional)
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
There is no framework default — load-time validation rejects a `produces` stage without an outcome.
|
|
273
|
+
|
|
274
|
+
### Collector catalog
|
|
275
|
+
|
|
276
|
+
Grouped by discovery model:
|
|
277
|
+
|
|
278
|
+
**Scan the agent's text:**
|
|
279
|
+
|
|
280
|
+
| Collector | Signature | What it does |
|
|
281
|
+
|-----------|-----------|--------------|
|
|
282
|
+
| `transcriptPathCollector` | `({ pattern: RegExp })` | Scans assistant text for the last regex match; emits one `fs` artifact. |
|
|
283
|
+
| `directoryPathCollector` | `({ dir, ext? })` | Wrapper over `transcriptPathCollector` for `<dir>/<file>.<ext>`. |
|
|
284
|
+
| `urlCollector` | `({ pattern? })` | Scans for `https?://…`; emits a `url` handle. |
|
|
285
|
+
|
|
286
|
+
**Observe tool use:**
|
|
287
|
+
|
|
288
|
+
| Collector | Signature | What it does |
|
|
289
|
+
|-----------|-----------|--------------|
|
|
290
|
+
| `toolCallCollector` | `({ match, toArtifact })` | Walks every `tool_use` part; emits N artifacts via author's mappers. |
|
|
291
|
+
|
|
292
|
+
**Diff the filesystem:**
|
|
293
|
+
|
|
294
|
+
| Collector | Signature | What it does |
|
|
295
|
+
|-----------|-----------|--------------|
|
|
296
|
+
| `workspaceDiffCollector` | `({ filter? })` | `git status --porcelain` pre-stage, diffs post-stage. One `fs` artifact per touched file. |
|
|
297
|
+
|
|
298
|
+
**Git:**
|
|
299
|
+
|
|
300
|
+
| Collector | Signature | What it does |
|
|
301
|
+
|-----------|-----------|--------------|
|
|
302
|
+
| `gitCommitCollector` | — | Detects new HEAD commit vs. pre-stage snapshot; emits `opaque(sha)`. |
|
|
303
|
+
|
|
304
|
+
**Composition + empty:**
|
|
305
|
+
|
|
306
|
+
| Collector | Signature | What it does |
|
|
307
|
+
|-----------|-----------|--------------|
|
|
308
|
+
| `unionCollectors(...cs)` | — | Run N collectors, concatenate artifacts. Fatal only when every sub-collector fataled. |
|
|
309
|
+
| `noopCollector` | — | Always returns `{ kind: "ok", artifacts: [] }`. |
|
|
310
|
+
|
|
311
|
+
### Parser catalog
|
|
312
|
+
|
|
313
|
+
| Parser | Output `kind` | Output `data` |
|
|
314
|
+
|--------|---------------|---------------|
|
|
315
|
+
| `jsonBodyParser` | `"json"` | `JSON.parse` of the primary `fs` artifact's body. |
|
|
316
|
+
| `gitCommitParser` | `"git-commit"` | `GitCommitData` (sha, prevSha, subject, filesChanged). |
|
|
317
|
+
|
|
318
|
+
### Custom outcomes
|
|
319
|
+
|
|
320
|
+
Use `defineCollector` and `defineParser` to build your own:
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import { defineCollector, opaque } from "@juicesharp/rpiv-workflow";
|
|
324
|
+
|
|
325
|
+
export const myCollector = defineCollector((ctx) => {
|
|
326
|
+
// ctx.branch, ctx.cwd, etc.
|
|
327
|
+
const id = parseIdFromBranch(ctx.branch);
|
|
328
|
+
if (!id) return { kind: "fatal", message: "stage did not emit an id" };
|
|
329
|
+
return { kind: "ok", artifacts: [{ handle: opaque(id), role: "primary" }] };
|
|
330
|
+
});
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Handle constructors: `fs(path)`, `url(href)`, `opaque(id)`, `inline(bytes, mime?)`.
|
|
334
|
+
|
|
335
|
+
Composite outcomes: `sideEffectOutcome` (built from `noopCollector`), `gitCommitOutcome` (built from `gitCommitCollector` + `gitCommitParser`).
|
|
336
|
+
|
|
337
|
+
## Analyzing skills before wiring
|
|
338
|
+
|
|
339
|
+
Before writing any workflow DSL, analyze each skill you plan to chain. The runner only sees what collectors enumerate — everything else (transcript reasoning, session state) is lost across `fresh` session boundaries. Bad collector/parser/session choices are silent: the workflow runs but downstream stages receive nothing useful.
|
|
340
|
+
|
|
341
|
+
### The four questions per skill
|
|
342
|
+
|
|
343
|
+
Answer these before writing DSL for any stage:
|
|
344
|
+
|
|
345
|
+
**Q1 — Input contract.** What does this skill require to start?
|
|
346
|
+
- Free-text prompt only (the run's original input or a composed prompt)?
|
|
347
|
+
- A specific file path it reads explicitly?
|
|
348
|
+
- A typed upstream artifact (structured data the prior stage produced)?
|
|
349
|
+
|
|
350
|
+
This determines what the stage's prompt should provide and whether to wire `inputSchema` on the downstream stage to validate the handoff.
|
|
351
|
+
|
|
352
|
+
**Q2 — Output locus.** Where does the knowledge live when the skill finishes?
|
|
353
|
+
- Files on disk at a predictable path
|
|
354
|
+
- Files on disk at an unpredictable path announced in the transcript
|
|
355
|
+
- Every file the skill touched (diffable via git)
|
|
356
|
+
- Files written via specific tool calls
|
|
357
|
+
- Narrative text only in the transcript (rationale, decisions, analysis)
|
|
358
|
+
- A URL emitted in the transcript
|
|
359
|
+
- A new git commit
|
|
360
|
+
- Multiple of the above simultaneously
|
|
361
|
+
- Session memory only (nothing observable after the stage ends)
|
|
362
|
+
- Nothing (pure side effect, nothing to extract)
|
|
363
|
+
|
|
364
|
+
This determines which collector to wire (or whether to author a custom one).
|
|
365
|
+
|
|
366
|
+
**Q3 — Downstream need.** What does the next stage actually consume from this one?
|
|
367
|
+
- File paths only (the downstream reads the files itself)
|
|
368
|
+
- Structured fields for routing (numeric counts, categories, pass/fail)
|
|
369
|
+
- Narrative rationale (why decisions were made)
|
|
370
|
+
- The full conversation (questions asked, context built)
|
|
371
|
+
- Nothing (the downstream is independent)
|
|
372
|
+
|
|
373
|
+
This determines session policy and whether you need a parser + `outputSchema`.
|
|
374
|
+
|
|
375
|
+
**Q4 — Session requirement.** Can downstream start fresh, or does it need the prior conversation?
|
|
376
|
+
- Fresh is fine when all knowledge is captured in files or transcript markers.
|
|
377
|
+
- Continue is needed when reasoning isn't recoverable from disk + transcript alone.
|
|
378
|
+
|
|
379
|
+
This determines `sessionPolicy`.
|
|
380
|
+
|
|
381
|
+
### Translation table: output locus → collector
|
|
382
|
+
|
|
383
|
+
| Knowledge lives in… | Stage kind | Collector | When downstream routes, add parser… |
|
|
384
|
+
|---|---|---|---|
|
|
385
|
+
| Files at a predictable path (directory + extension) | `produces` | `directoryPathCollector` | `jsonBodyParser` if body is JSON; custom otherwise |
|
|
386
|
+
| Files at a path announced in transcript | `produces` | `transcriptPathCollector` with a path-matching regex | Custom, keyed to the extracted path |
|
|
387
|
+
| Every file the stage touched (deliverable IS files) | `produces` or `acts` with outcome | `workspaceDiffCollector` | Custom, over the diff artifact list |
|
|
388
|
+
| Files written via specific tool calls | `produces` or `acts` with outcome | `toolCallCollector` | Custom, over the tool-call artifact list |
|
|
389
|
+
| Narrative section in transcript | `produces` | `transcriptPathCollector` with a section-scoped regex | Custom, parsing the materialized text |
|
|
390
|
+
| A URL in transcript | `produces` | `urlCollector` | — |
|
|
391
|
+
| A new git commit | `acts` with outcome | `gitCommitCollector` (or composite `gitCommitOutcome`) | `gitCommitParser` (included in `gitCommitOutcome`) |
|
|
392
|
+
| Multiple of the above | `produces` or `acts` | `unionCollectors(collA, collB, ...)` | Per sub-outcome, composed |
|
|
393
|
+
| None of the built-ins fit efficiently | `produces` or `acts` | Custom `defineCollector` | Custom `defineParser` as needed |
|
|
394
|
+
| Pure side effect, nothing to extract | `acts()` without outcome | — (no outcome needed) | — |
|
|
395
|
+
| Nothing, and downstream must not inherit | `terminal()` | — | — |
|
|
396
|
+
| Session memory only | Upstream `acts`, downstream `sessionPolicy: "continue"` | — (no outcome on upstream) | — |
|
|
397
|
+
|
|
398
|
+
**When to author a custom collector.** The built-in collectors cover transcript scanning, tool-call observation, filesystem diffing, and git state. If the skill's output pattern doesn't map cleanly to any of these — for example, it writes a structured artifact with frontmatter you want to parse, or it produces an identifier embedded in a branch name — author a custom collector via `defineCollector` and an optional `defineParser`. Custom collectors are first-class; they receive the same `CollectCtx` and emit the same artifact shapes as built-ins.
|
|
399
|
+
|
|
400
|
+
### Validation decision rules
|
|
401
|
+
|
|
402
|
+
When to add schemas:
|
|
403
|
+
|
|
404
|
+
- **Add `outputSchema`** whenever a downstream edge uses `gate` or a `defineRoute` that reads `output.data`. The load-time validator enforces this — but fix it at authoring time, not after.
|
|
405
|
+
- **Add `outputSchema`** when you want the runner to retry the stage on shape drift (`onInvalid: "retry"`, the default). This is useful when the skill's output is non-deterministic and might need a second attempt.
|
|
406
|
+
- **Add `inputSchema`** when the downstream stage genuinely cannot proceed without specific upstream fields. A rejection on `inputSchema` halts immediately (no retry) — use it as a hard contract, not a soft warning.
|
|
407
|
+
- **Default to sync** schemas for pure shape contracts. **Reach for async** only when correctness needs I/O (file existence checks, endpoint validation).
|
|
408
|
+
|
|
409
|
+
### Session-policy decision rules
|
|
410
|
+
|
|
411
|
+
- **Default to `"fresh"`.** It is compatible with `fanout`, script stages, and keeps context bounded.
|
|
412
|
+
- **Use `"continue"` only when Q3 demands reasoning that isn't capturable on disk or via a transcript marker.** `"continue"` is incompatible with `fanout` and script stages — load-time validation rejects the combination.
|
|
413
|
+
- Every `"continue"` stage grows context monotonically. Long chains of continued sessions become expensive and fragile.
|
|
414
|
+
|
|
415
|
+
### Authoring protocol
|
|
416
|
+
|
|
417
|
+
Follow this sequence when composing a new workflow:
|
|
418
|
+
|
|
419
|
+
**1. List skills in execution order.** Write down each skill name and a one-line description. Don't write DSL yet.
|
|
420
|
+
|
|
421
|
+
**2. Answer Q1–Q4 for every skill.** Record in a table:
|
|
422
|
+
|
|
423
|
+
| Skill | Input (Q1) | Output locus (Q2) | Downstream need (Q3) | Fresh? (Q4) |
|
|
424
|
+
|-------|-----------|-------------------|---------------------|------------|
|
|
425
|
+
| ... | ... | ... | ... | ... |
|
|
426
|
+
|
|
427
|
+
**3. Assign stage kind per skill.** Based on Q2: `produces` when the skill's primary output is a file the next stage reads; `acts` when the side effect IS the work; `terminal` when nothing should carry forward.
|
|
428
|
+
|
|
429
|
+
**4. Pick collector + optional parser.** Use the translation table above. If no built-in fits cleanly, reach for `defineCollector`.
|
|
430
|
+
|
|
431
|
+
**5. Add `outputSchema` where needed.** Required for `gate`/`defineRoute` routing; recommended when the collector/parser pair produces structured data you want to validate.
|
|
432
|
+
|
|
433
|
+
**6. Set `sessionPolicy`.** Default `"fresh"`. Document the justification for any `"continue"`.
|
|
434
|
+
|
|
435
|
+
**7. Draw edges.** Linear chains → string targets. Branching logic → `gate` (numeric field) or `defineRoute` (arbitrary). Every path must eventually reach `"stop"`.
|
|
436
|
+
|
|
437
|
+
**8. Validate.** Run `validateWorkflow()` before shipping — it catches missing outcomes, dangling edges, and schema/routing mismatches.
|
|
438
|
+
|
|
439
|
+
### Common pitfalls
|
|
440
|
+
|
|
441
|
+
| Smell | Fix |
|
|
442
|
+
|-------|-----|
|
|
443
|
+
| `acts()` with no outcome, but the skill clearly writes files | The side effect IS the artifact. Add `outcome: workspaceDiffCollector(...)` or switch to `produces`. |
|
|
444
|
+
| `produces` with `noopCollector` | `produces` exists to extract something. If there's nothing to extract, use `acts`. |
|
|
445
|
+
| `sessionPolicy: "continue"` on every stage | Revisit Q3 for each stage. Context grows monotonically with continued sessions; default to fresh. |
|
|
446
|
+
| `transcriptPathCollector` regex never tested against real output | Test the regex against a sample transcript before wiring it. A non-matching regex produces a fatal collector result silently. |
|
|
447
|
+
| `gate` without `outputSchema` on the source stage | Caught by the validator, but cheaper to fix at authoring time. |
|
|
448
|
+
| `terminal()` chosen because "it's the last stage" | `terminal` clears the rolling primary slot. If post-run inspection needs the artifact, use `acts` instead. |
|
|
449
|
+
| Custom collector that doesn't handle the "nothing found" case | Return `{ kind: "fatal", message: "..." }` — the runner halts and surfaces the message. Don't silently return an empty artifact list from a `produces` stage. |
|
|
450
|
+
| `reads:` references a name no `produces` stage publishes | Load-time validator catches the typo. Confirm the upstream stage's `outcome.name ?? <record-key>` matches the name you're reading. |
|
|
451
|
+
| Two stages publishing under different names when you wanted them to converge | Give both stages the same `OutputSpec.name` (typically via a shared outcome). The named-publish registry collapses convergent producers into one slot — there is no per-stage `publishes:` override knob. |
|
|
452
|
+
|
|
453
|
+
## Multi-input stages
|
|
454
|
+
|
|
455
|
+
The default prompt to a stage is `/skill:<name> <handle>` — exactly one positional arg, the upstream rolling primary artifact. When a stage needs more than one upstream artifact (the canonical case: a "revise plan based on review" step that needs both the plan and the review), declare `reads:` against names in the named-publish registry:
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
revise: produces({
|
|
459
|
+
outcome: planOutcome,
|
|
460
|
+
reads: ["plans", "reviews"],
|
|
461
|
+
})
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
When `reads:` is set the runner replaces the default prompt with a labelled-flag form:
|
|
465
|
+
|
|
466
|
+
```
|
|
467
|
+
/skill:revise --plans .rpiv/artifacts/plans/p.md --reviews .rpiv/artifacts/reviews/r.md
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
Multi-artifact stages get flag repetition: an upstream with two `fs` artifacts expands to `--plans <a> --plans <b>` so skill arg-parsers collect repeated flags into arrays the same way `argparse`/`clap`/shell utilities do.
|
|
471
|
+
|
|
472
|
+
### The named-publish registry — `state.named`
|
|
473
|
+
|
|
474
|
+
Every `produces` stage APPENDS its full `Output` envelope onto `state.named[key]` after each successful run. The key is computed once at write time:
|
|
475
|
+
|
|
476
|
+
```
|
|
477
|
+
key = stage.outcome?.name ?? stage.<record-key>
|
|
478
|
+
```
|
|
479
|
+
|
|
480
|
+
Two layers, no override knob:
|
|
481
|
+
|
|
482
|
+
- **Outcome carries a name.** Multiple stages wiring the same outcome converge — both stages append onto the same slot, latest-wins on read. This is how a workflow expresses "two stages both produce the canonical plan" without restating the name on each stage.
|
|
483
|
+
- **Outcome has no name.** Stages publish under their record key. Downstream `reads: ["blueprint", "code-review"]` references stage record keys directly.
|
|
484
|
+
|
|
485
|
+
Slots are **arrays** — iteration history is preserved across backward-jump loops; the default read resolves to `array.at(-1)`. Side-effect stages don't write to the registry. The slot is never cleared by `terminal()` either: it's an additive channel orthogonal to the rolling primary.
|
|
486
|
+
|
|
487
|
+
### Validation + preflight
|
|
488
|
+
|
|
489
|
+
- **Load-time** (`validateWorkflow`) — every `reads:` reference must match some `produces` stage's publish key. Catches typos and rename drift before the workflow runs.
|
|
490
|
+
- **Runtime** (`ensureNamedReads` preflight) — halts the chain when a `reads:` name's slot is empty (the producer hasn't fired yet on this path). Distinct from the typo case: the workflow is well-formed but the stage was placed before its producer in the edge graph.
|
|
491
|
+
|
|
492
|
+
### Interaction with the rolling primary
|
|
493
|
+
|
|
494
|
+
A stage with `reads:` opts out of the rolling-primary contract entirely — `ensureUpstreamArtifact` is skipped, the labelled-flag prompt replaces the single-handle prompt, and `state.primaryArtifact` is ignored for prompt construction. The stage's own produces output (if any) still updates `state.primaryArtifact` for downstream stages that DO use the rolling chain.
|
|
495
|
+
|
|
496
|
+
## Carrying knowledge across stages
|
|
497
|
+
|
|
498
|
+
A fresh-session stage starts a clean Pi conversation. It only sees (1) the rolling primary artifact, (2) the inherited artifact list, (3) `output.data` when an `outputSchema` is declared, and (4) any named slots wired via `reads:`. Anything the upstream stage only *spoke* in its transcript is lost. Author the handoff deliberately — five paths:
|
|
499
|
+
|
|
500
|
+
| # | Mechanism | What downstream sees | Trade-off |
|
|
501
|
+
|---|-----------|----------------------|-----------|
|
|
502
|
+
| 1 | `sessionPolicy: "continue"` on the downstream stage | Full prior Pi conversation (messages + tool calls) | Incompatible with `fanout` and script stages. Context grows monotonically. |
|
|
503
|
+
| 2 | `workspaceDiffCollector` outcome on the upstream stage | Every file the stage touched, as `fs` artifacts | Free when the work IS files on disk. Captures *what*, not *why*. |
|
|
504
|
+
| 3 | `transcriptPathCollector` outcome on the upstream stage | The last regex-matched chunk of assistant text, written to disk | Captures narrative knowledge. Needs the skill to emit a recognizable marker. |
|
|
505
|
+
| 4 | Custom collector / parser (+ optional `outputSchema`) | Author-defined typed shape | Most precise; most authoring effort. Enables gate routing. |
|
|
506
|
+
| 5 | `reads:` on the downstream stage referencing named-publish slots | Latest `Output` per declared name, woven into a labelled-flag prompt | Reaches further back than the rolling primary; survives intermediate produces stages overwriting the chain. See [Multi-input stages](#multi-input-stages). |
|
|
507
|
+
|
|
508
|
+
**Picking between them — where does the knowledge live after the stage finishes?**
|
|
509
|
+
|
|
510
|
+
- **On disk (the stage's deliverable IS files)** → path 2. Frame the stage as `produces({ outcome: workspaceDiffCollector(...) })` or `acts({ outcome: workspaceDiffCollector(...) })`. "Side-effect with no outcome" is a smell here — the side effect IS the artifact.
|
|
511
|
+
- **Only in the assistant's words (rationale, decisions)** → path 3 to materialize it, or path 1 to keep the conversation alive.
|
|
512
|
+
- **Both, and the downstream stage needs the full conversation, not just files** → path 1 is the only honest answer. Fresh + a diff collector gives the next stage filenames but no reasoning.
|
|
513
|
+
|
|
514
|
+
**Combining mechanisms.** `unionCollectors` lets path 2 and path 3 coexist:
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
import {
|
|
518
|
+
acts, unionCollectors,
|
|
519
|
+
workspaceDiffCollector, transcriptPathCollector,
|
|
520
|
+
} from "@juicesharp/rpiv-workflow";
|
|
521
|
+
|
|
522
|
+
acts({
|
|
523
|
+
skill: "frontend-design",
|
|
524
|
+
outcome: unionCollectors(
|
|
525
|
+
workspaceDiffCollector({ filter: (p) => /\.(tsx?|css|md)$/.test(p) }),
|
|
526
|
+
transcriptPathCollector({ pattern: /## Design Notes\n([\s\S]+?)(?=\n##|$)/ }),
|
|
527
|
+
),
|
|
528
|
+
})
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
The fresh downstream session now receives the touched files *and* a notes file capturing the rationale.
|
|
532
|
+
|
|
533
|
+
**What `acts` without an outcome actually does.** The rolling primary slot from the last upstream `produces` is passed through unchanged — downstream still receives that prior artifact, it just learns nothing about what this stage did. If no `produces` stage has run yet upstream, `ensureUpstreamArtifact` halts the next non-terminal stage with `MSG_MISSING_ARTIFACT`. Use `terminal()` for stages that should explicitly carry nothing forward.
|
|
534
|
+
|
|
535
|
+
## Validators
|
|
536
|
+
|
|
537
|
+
`inputSchema` and `outputSchema` are Standard Schema v1 values (Zod, Valibot, ArkType, TypeBox via `typeboxSchema`). The runner awaits `~standard.validate` at both seams.
|
|
538
|
+
|
|
539
|
+
**Default to sync** for pure shape contracts. **Reach for async** when correctness needs I/O (file existence checks, endpoint validation).
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
import { typeboxSchema } from "@juicesharp/rpiv-workflow";
|
|
543
|
+
import { Type } from "@sinclair/typebox";
|
|
544
|
+
|
|
545
|
+
outputSchema: typeboxSchema(Type.Object({
|
|
546
|
+
blockers_count: Type.Integer({ minimum: 0 }),
|
|
547
|
+
}))
|
|
548
|
+
```
|
|
549
|
+
|
|
550
|
+
A schema rejection on `outputSchema` honours `onInvalid` (`"retry"` by default, `"halt"` to fail fast). A rejection on `inputSchema` halts immediately (no retry — the upstream stage is already frozen).
|
|
551
|
+
|
|
552
|
+
## Complete example
|
|
553
|
+
|
|
554
|
+
A full workflow with custom outcomes and conditional routing. This example uses only `@juicesharp/rpiv-workflow` primitives — no external convention packages:
|
|
555
|
+
|
|
556
|
+
```typescript
|
|
557
|
+
import {
|
|
558
|
+
defineWorkflow, produces, acts, gate, gt, eq,
|
|
559
|
+
typeboxSchema, gitCommitOutcome,
|
|
560
|
+
directoryPathCollector, jsonBodyParser, transcriptPathCollector,
|
|
561
|
+
toolCallCollector, fs,
|
|
562
|
+
} from "@juicesharp/rpiv-workflow";
|
|
563
|
+
import { Type } from "@sinclair/typebox";
|
|
564
|
+
|
|
565
|
+
// Custom outcome: detect a markdown file the agent writes to a plans directory
|
|
566
|
+
const planOutcome = {
|
|
567
|
+
collector: directoryPathCollector({ dir: "plans", ext: "md" }),
|
|
568
|
+
parser: jsonBodyParser,
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
// Custom outcome: detect files the agent writes or edits via tool calls
|
|
572
|
+
const writeFileOutcome = {
|
|
573
|
+
collector: toolCallCollector({
|
|
574
|
+
match: (tc) => tc.name === "write" || tc.name === "edit",
|
|
575
|
+
toArtifact: (tc) => ({ handle: fs(String(tc.input.path ?? tc.input.target_file ?? "")) }),
|
|
576
|
+
}),
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const REVIEW_SCHEMA = typeboxSchema(
|
|
580
|
+
Type.Object({ blockers_count: Type.Integer({ minimum: 0 }) }, { additionalProperties: true }),
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
export default defineWorkflow({
|
|
584
|
+
name: "mid",
|
|
585
|
+
start: "research",
|
|
586
|
+
stages: {
|
|
587
|
+
research: produces({ outcome: planOutcome }),
|
|
588
|
+
blueprint: produces({ outcome: planOutcome }),
|
|
589
|
+
implement: acts(),
|
|
590
|
+
validate: produces({ outcome: writeFileOutcome }),
|
|
591
|
+
"code-review": produces({
|
|
592
|
+
outcome: writeFileOutcome,
|
|
593
|
+
outputSchema: REVIEW_SCHEMA,
|
|
594
|
+
}),
|
|
595
|
+
revise: produces({ outcome: writeFileOutcome }),
|
|
596
|
+
"implement-after-revise": acts({ skill: "implement" }),
|
|
597
|
+
commit: acts({ outcome: gitCommitOutcome }),
|
|
598
|
+
},
|
|
599
|
+
edges: {
|
|
600
|
+
research: "blueprint",
|
|
601
|
+
blueprint: "implement",
|
|
602
|
+
implement: "validate",
|
|
603
|
+
validate: "code-review",
|
|
604
|
+
"code-review": gate("blockers_count", {
|
|
605
|
+
revise: gt(0),
|
|
606
|
+
commit: eq(0),
|
|
607
|
+
}),
|
|
608
|
+
revise: "implement-after-revise",
|
|
609
|
+
"implement-after-revise": "commit",
|
|
610
|
+
commit: "stop",
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
## Validation rules
|
|
616
|
+
|
|
617
|
+
Generated workflows must pass `validateWorkflow()` before writing. The validator checks:
|
|
618
|
+
|
|
619
|
+
- `start` references a declared stage
|
|
620
|
+
- Every edge key exists in `stages`
|
|
621
|
+
- Every edge target exists in `stages` or is `"stop"`
|
|
622
|
+
- Stage kinds are valid (`"produces"` or `"side-effect"`)
|
|
623
|
+
- `produces` stages have an `outcome`
|
|
624
|
+
- `gate` / data-reading `defineRoute` source stages have `outputSchema`
|
|
625
|
+
- Every `reads:` name is published by some `produces` stage in the workflow (publish key = `outcome.name ?? stage.<record-key>`)
|
|
626
|
+
- Fanout is incompatible with `sessionPolicy: "continue"`
|
|
627
|
+
- Script stages cannot declare `skill`, `outcome`, `fanout`, or `sessionPolicy: "continue"`
|
|
628
|
+
|
|
629
|
+
> **Important:** The `/wf` command blocks execution on any `severity: "error"` issue. Always validate before writing.
|