@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
package/api.ts
ADDED
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public authoring surface for rpiv workflows. Canonical entry point — users
|
|
3
|
+
* import everything they need (`defineWorkflow`, `produces`, `acts`,
|
|
4
|
+
* `defineRoute`, `gate`, `STOP`, `marksReadsData`, schema adapters, plus the
|
|
5
|
+
* type vocabulary `Workflow` / `StageDef` / `EdgeFn` / `EdgeTarget` /
|
|
6
|
+
* `EdgeContext`) from `@juicesharp/rpiv-workflow`.
|
|
7
|
+
*
|
|
8
|
+
* A `Workflow` is a typed graph: a named entry point, a stage table, and an
|
|
9
|
+
* edge table that maps each stage to either another stage name, the sentinel
|
|
10
|
+
* `STOP`, or an `EdgeFn` that picks at runtime. Edges live INSIDE each
|
|
11
|
+
* workflow.
|
|
12
|
+
*
|
|
13
|
+
* Factories are pure passthroughs that apply sane defaults. Same idiom as
|
|
14
|
+
* `defineConfig` in Vite/Astro/Tailwind: zero runtime cost, exists solely
|
|
15
|
+
* for type inference + uniform shape at the call site.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec";
|
|
19
|
+
import type { Output, OutputSpec } from "./output.js";
|
|
20
|
+
import type { Predicate } from "./predicates.js";
|
|
21
|
+
import type { RunState } from "./types.js";
|
|
22
|
+
|
|
23
|
+
export type { OutputSpec } from "./output.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Schema attached to a stage's `outputSchema` / `inputSchema`. Structurally
|
|
27
|
+
* a Standard Schema v1 (the converged interface implemented by Zod, Valibot,
|
|
28
|
+
* ArkType, TypeBox, et al.) — re-exported under a name that doesn't leak the
|
|
29
|
+
* spec version into our public surface. When the spec versions, this alias
|
|
30
|
+
* picks the right one in a single line.
|
|
31
|
+
*
|
|
32
|
+
* Sync schemas are the default and the recommended shape for the 95% case
|
|
33
|
+
* (pure shape contracts: `Type.Object({ … })`, `z.object({ … })`). Async
|
|
34
|
+
* schemas are supported at both seams — the runner awaits `~standard.validate`
|
|
35
|
+
* — and are the right answer when correctness needs I/O (filesystem probes,
|
|
36
|
+
* registry lookups, async-by-default libs like ArkType). A hanging async
|
|
37
|
+
* schema is bounded by the stage's `validateTimeoutMs`. See the
|
|
38
|
+
* "Validators: sync vs async" section of the package README for the full
|
|
39
|
+
* rationale.
|
|
40
|
+
*/
|
|
41
|
+
export type StageSchema<Input = unknown, Output = Input> = StandardSchemaV1<Input, Output>;
|
|
42
|
+
|
|
43
|
+
// ===========================================================================
|
|
44
|
+
// Stage-shape primitives
|
|
45
|
+
// ===========================================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* - `"produces"` — protocol skills that write `.rpiv/artifacts/<bucket>/<file>.md`.
|
|
49
|
+
* The runner halts the chain if the path doesn't appear in the transcript.
|
|
50
|
+
* - `"side-effect"` — action skills (commit, implement) where the side effect IS
|
|
51
|
+
* the work; the chain inherits the prior `currentPrimaryArtifact(state)`.
|
|
52
|
+
*
|
|
53
|
+
* The `as const` array is the single source of truth: the literal-union type
|
|
54
|
+
* is derived via `(typeof ARRAY)[number]`, and `validate-workflow.ts` consumes
|
|
55
|
+
* the same array for the runtime enum check. Adding a variant updates both
|
|
56
|
+
* type-level and runtime arms in one edit.
|
|
57
|
+
*/
|
|
58
|
+
export const STAGE_KINDS = ["produces", "side-effect"] as const;
|
|
59
|
+
export type StageKind = (typeof STAGE_KINDS)[number];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* - `"fresh"` — wraps the stage in `ctx.newSession({ withSession })`.
|
|
63
|
+
* - `"continue"` — reuses the prior session via `host.sendUserMessage()` +
|
|
64
|
+
* `ctx.waitForIdle()`; branch sliced by `branchOffset`.
|
|
65
|
+
*/
|
|
66
|
+
export const SESSION_POLICIES = ["fresh", "continue"] as const;
|
|
67
|
+
export type SessionPolicy = (typeof SESSION_POLICIES)[number];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What happens when a stage's `outputSchema` rejects the extracted output:
|
|
71
|
+
* - `"retry"` — re-invoke the stage up to `maxRetries`, threading the
|
|
72
|
+
* schema's issues back to the agent via a retry prompt.
|
|
73
|
+
* - `"halt"` — record a terminal failure on the first rejection.
|
|
74
|
+
*/
|
|
75
|
+
export const ON_INVALID_VALUES = ["retry", "halt"] as const;
|
|
76
|
+
export type OnInvalid = (typeof ON_INVALID_VALUES)[number];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Opt-in fanout — a user-supplied function that decomposes a stage's work
|
|
80
|
+
* into N units, one Pi session per unit. The runner owns iteration +
|
|
81
|
+
* audit; the FanoutFn owns the convention (how units are detected, what
|
|
82
|
+
* each session's prompt body says, how each is labelled).
|
|
83
|
+
*
|
|
84
|
+
* rpiv-workflow ships ZERO fanout conventions: no markdown regex, no
|
|
85
|
+
* phase counter, no schema. A consumer wanting markdown-heading fanout
|
|
86
|
+
* writes the ~10 lines themselves and reuses one constant across stages.
|
|
87
|
+
*
|
|
88
|
+
* Invariants enforced by the runner:
|
|
89
|
+
* - Empty return ⇒ no fanout (fall through to the single-stage path).
|
|
90
|
+
* - Throws ⇒ stage halts, attributed to this stage.
|
|
91
|
+
* - `stage.sessionPolicy === "continue"` is incompatible with fanout
|
|
92
|
+
* (validated at load + at preflight).
|
|
93
|
+
*
|
|
94
|
+
* Cap policy: the runner does not bound `units.length`. Authors of bespoke
|
|
95
|
+
* FanoutFns own their own safety bounds — same posture as
|
|
96
|
+
* `maxBackwardJumps` (the only run-wide cap the runner enforces).
|
|
97
|
+
*/
|
|
98
|
+
export type FanoutFn = (ctx: FanoutContext) => readonly FanoutUnit[] | Promise<readonly FanoutUnit[]>;
|
|
99
|
+
|
|
100
|
+
export interface FanoutContext {
|
|
101
|
+
cwd: string;
|
|
102
|
+
/**
|
|
103
|
+
* Primary artifact inherited from the upstream stage (or undefined when
|
|
104
|
+
* the fanout stage is the entry point). FanoutFns that need to read an
|
|
105
|
+
* upstream artifact short-circuit to `[]` when undefined — the runner
|
|
106
|
+
* treats that as "no fanout" and runs the single-stage path. The
|
|
107
|
+
* handle's serialized form (path / URL / opaque id) is what most
|
|
108
|
+
* FanoutFns weave into their per-unit prompt body.
|
|
109
|
+
*/
|
|
110
|
+
artifact: import("./handle.js").Artifact | undefined;
|
|
111
|
+
state: Readonly<RunState>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface FanoutUnit {
|
|
115
|
+
/**
|
|
116
|
+
* Body sent to the skill: the runner dispatches `/skill:<stage.skill>
|
|
117
|
+
* <prompt>` once per unit. The unit owns artifact-path threading + any
|
|
118
|
+
* per-unit cue — the runner adds nothing implicit.
|
|
119
|
+
*/
|
|
120
|
+
prompt: string;
|
|
121
|
+
/**
|
|
122
|
+
* Short label woven into the status line + JSONL audit row.
|
|
123
|
+
* The audit `skill` field becomes `<stage.skill> (<id ?? label>)`;
|
|
124
|
+
* the status line shows `rpiv: stage X/Y — <stage.skill> (<label>)`.
|
|
125
|
+
* Keep it short and disambiguating (`"phase 2/5"`, `"task 3/8"`).
|
|
126
|
+
*/
|
|
127
|
+
label: string;
|
|
128
|
+
/**
|
|
129
|
+
* Optional stable identifier used in the JSONL audit row in place of
|
|
130
|
+
* `label`. Set this when `label` is a human-facing display string that
|
|
131
|
+
* may be reworded — `id` keeps the audit projection stable across
|
|
132
|
+
* label edits and matches the pattern post-hoc tooling joins on
|
|
133
|
+
* (`"phase-2"`, `"task-3"`). Omit to fall back to `label`.
|
|
134
|
+
*/
|
|
135
|
+
id?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ===========================================================================
|
|
139
|
+
// Script-stage primitives — skillless TS functions in place of `/skill:<x>`
|
|
140
|
+
// ===========================================================================
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Context handed to a script stage's `run` function. Shape mirrors
|
|
144
|
+
* `EdgeContext` / `FanoutContext`: frozen identity (`cwd`) + the chain
|
|
145
|
+
* data the function needs (`input` — upstream Output envelope) + a
|
|
146
|
+
* read-only state snapshot.
|
|
147
|
+
*
|
|
148
|
+
* Script stages cannot fanout, cannot use `sessionPolicy: "continue"`,
|
|
149
|
+
* and do not receive a Pi `RunnerCtx` — they're pure TS calls.
|
|
150
|
+
*/
|
|
151
|
+
export interface ScriptContext {
|
|
152
|
+
cwd: string;
|
|
153
|
+
/**
|
|
154
|
+
* Inherited upstream `Output` envelope. `undefined` when the script
|
|
155
|
+
* stage is the entry point, or when the upstream stage cleared the
|
|
156
|
+
* rolling primary slot (a `terminal()` ahead of it).
|
|
157
|
+
*/
|
|
158
|
+
input: Output | undefined;
|
|
159
|
+
state: Readonly<RunState>;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Script `produces` stage body — returns the `Output` envelope's
|
|
164
|
+
* value-channel fields (`kind` + `artifacts` + `data`) directly. The
|
|
165
|
+
* runner stamps `meta` (stage, stageNumber, ts, runId) — same posture
|
|
166
|
+
* as how `ArtifactParser`s return `{ kind, data }` and `finalizeOutput`
|
|
167
|
+
* fills the meta in.
|
|
168
|
+
*/
|
|
169
|
+
export type ProducesScriptFn<K extends string = string, D = unknown> = (
|
|
170
|
+
ctx: ScriptContext,
|
|
171
|
+
) => Omit<Output<K, D>, "meta"> | Promise<Omit<Output<K, D>, "meta">>;
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Script `acts` / `terminal` stage body — returns nothing. The runner
|
|
175
|
+
* builds a `SideEffectOutput`-shaped envelope (kind `"side-effect"`,
|
|
176
|
+
* empty `data`) so audit + downstream chain wiring still uniform.
|
|
177
|
+
*/
|
|
178
|
+
export type ActsScriptFn = (ctx: ScriptContext) => void | Promise<void>;
|
|
179
|
+
|
|
180
|
+
// ===========================================================================
|
|
181
|
+
// Types
|
|
182
|
+
// ===========================================================================
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Runtime context handed to an `EdgeFn`. The sole context shape for both
|
|
186
|
+
* data-reading and state-only routes (the single `defineRoute` path covers
|
|
187
|
+
* both via `opts.readsData`).
|
|
188
|
+
*/
|
|
189
|
+
export interface EdgeContext {
|
|
190
|
+
output: import("./output.js").Output | undefined;
|
|
191
|
+
state: Readonly<RunState>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Body-type alias for hand-rolled route picks. Internal — users wrap via
|
|
196
|
+
* `defineRoute`, which returns an `EdgeFn` (this alias plus a `.targets`
|
|
197
|
+
* field).
|
|
198
|
+
*/
|
|
199
|
+
type EdgePredicate = (ctx: EdgeContext) => string;
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* A function that picks the next stage name given current state + output.
|
|
203
|
+
* Optional `targets` field lets graph introspectors enumerate possible
|
|
204
|
+
* returns — `gate` and other built-in route builders populate it.
|
|
205
|
+
*/
|
|
206
|
+
export type EdgeFn = EdgePredicate & { targets?: readonly string[] };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Terminal edge sentinel. Single source of truth for the `"stop"` literal
|
|
210
|
+
* embedded in `EdgeTarget`; `validate-workflow.ts` + `routing.ts` import this rather
|
|
211
|
+
* than re-declaring the string.
|
|
212
|
+
*/
|
|
213
|
+
export const STOP = "stop" as const;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* What an `edges` entry resolves to: another stage name (auto-edge), the
|
|
217
|
+
* terminal sentinel `STOP`, or a function chosen at run-time.
|
|
218
|
+
*/
|
|
219
|
+
export type EdgeTarget = string | typeof STOP | EdgeFn;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A stage in the workflow graph. The stage's identity is the surrounding
|
|
223
|
+
* `Workflow.stages` record key. `skill` is the Pi skill body to invoke —
|
|
224
|
+
* defaulted to the record key by the runner when omitted, so the
|
|
225
|
+
* authoring-time call site usually doesn't restate the name. Set `skill`
|
|
226
|
+
* explicitly only when the stage id and the Pi skill differ (aliased
|
|
227
|
+
* stages like `implement-after-revise` invoking the `implement` skill).
|
|
228
|
+
*
|
|
229
|
+
* Pi resolves the skill at run time; there's no allowlist gate. If Pi
|
|
230
|
+
* can't load the skill, the runner halts with a clear error pointing
|
|
231
|
+
* at this stage.
|
|
232
|
+
*/
|
|
233
|
+
export interface StageDef<TIn = unknown, TOut = unknown> {
|
|
234
|
+
skill?: string;
|
|
235
|
+
kind: StageKind;
|
|
236
|
+
sessionPolicy: SessionPolicy;
|
|
237
|
+
outcome?: OutputSpec;
|
|
238
|
+
/**
|
|
239
|
+
* Standard Schema v1 validator run against `output.data` after the
|
|
240
|
+
* stage's `OutputSpec` produces it (the typed record parsed out of the
|
|
241
|
+
* agent's emitted artifact). On rejection the runner honours
|
|
242
|
+
* `onInvalid` ("retry" by default, up to `maxRetries`; "halt" to fail
|
|
243
|
+
* fast).
|
|
244
|
+
*/
|
|
245
|
+
outputSchema?: StageSchema<unknown, TOut>;
|
|
246
|
+
/**
|
|
247
|
+
* Standard Schema v1 validator run against the inherited upstream
|
|
248
|
+
* `output.data` before the stage runs. A rejection halts the
|
|
249
|
+
* chain immediately (no retry path — the upstream stage is already
|
|
250
|
+
* frozen).
|
|
251
|
+
*/
|
|
252
|
+
inputSchema?: StageSchema<unknown, TIn>;
|
|
253
|
+
onInvalid?: OnInvalid;
|
|
254
|
+
maxRetries?: number;
|
|
255
|
+
validateTimeoutMs?: number;
|
|
256
|
+
/**
|
|
257
|
+
* Opt-in fanout. When set, the runner invokes the function with a
|
|
258
|
+
* `FanoutContext`, awaits the returned units, and runs one Pi session
|
|
259
|
+
* per unit (single-stage path when the array is empty). Incompatible
|
|
260
|
+
* with `sessionPolicy: "continue"` — fanout requires per-unit session
|
|
261
|
+
* isolation.
|
|
262
|
+
*/
|
|
263
|
+
fanout?: FanoutFn;
|
|
264
|
+
/**
|
|
265
|
+
* Whether the stage inherits the chain's primary artifact from
|
|
266
|
+
* upstream `produces` stages. Default `true`. Set to `false` on a
|
|
267
|
+
* terminal side-effect — the stage's prompt receives `originalInput`
|
|
268
|
+
* instead of the upstream artifact handle, the `ensureUpstreamArtifact`
|
|
269
|
+
* preflight is bypassed, and the rolling primary slot is cleared on
|
|
270
|
+
* success so any stage following also starts without an inherited
|
|
271
|
+
* artifact.
|
|
272
|
+
*
|
|
273
|
+
* Authored via the `terminal()` factory; the flag is the underlying
|
|
274
|
+
* mechanism. Meaningless on `kind: "produces"` stages (they emit their
|
|
275
|
+
* own outcome) — `validateWorkflow` warns when set there.
|
|
276
|
+
*/
|
|
277
|
+
inheritsArtifacts?: boolean;
|
|
278
|
+
/**
|
|
279
|
+
* Skillless script stage: when present, the runner calls this
|
|
280
|
+
* function instead of dispatching `/skill:<skill>`. Presence of
|
|
281
|
+
* `run` is the skill-vs-script discriminator. Authored via
|
|
282
|
+
* `produces.script(...)`, `acts.script(...)`, or
|
|
283
|
+
* `terminal.script(...)`.
|
|
284
|
+
*
|
|
285
|
+
* Stages with `run` set CANNOT also set `skill`, `outcome`,
|
|
286
|
+
* `fanout`, or `sessionPolicy: "continue"` — rejected at load time
|
|
287
|
+
* by `validateWorkflow`.
|
|
288
|
+
*/
|
|
289
|
+
run?: ProducesScriptFn<string, TOut> | ActsScriptFn;
|
|
290
|
+
/**
|
|
291
|
+
* Names this stage consumes from `state.named` to build its prompt.
|
|
292
|
+
* When set, the runner replaces the default single-artifact prompt
|
|
293
|
+
* (`/skill:<name> <handle>`) with a labelled-flag form
|
|
294
|
+
* (`/skill:<name> --<n1> <h1> --<n2> <h2> …`), reading the most recent
|
|
295
|
+
* `Output` each name has accumulated and iterating its `artifacts` list.
|
|
296
|
+
* Empty (or unset) → default prompt behaviour preserved.
|
|
297
|
+
*
|
|
298
|
+
* Names address `state.named` slots, which are keyed by
|
|
299
|
+
* `stage.outcome?.name ?? stage.<record-key>`. Every name in `reads:`
|
|
300
|
+
* must be filled by some upstream stage's produces success (validated
|
|
301
|
+
* at load time by `validateWorkflow`; the `ensureNamedReads` preflight
|
|
302
|
+
* catches the "haven't reached the producer yet" case at runtime).
|
|
303
|
+
*/
|
|
304
|
+
reads?: ReadonlyArray<string>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A complete workflow. `name` is what users type as `/wf <name>`; `start`
|
|
309
|
+
* is the entry stage; `stages` is the lexicon; `edges` is the wiring. Every
|
|
310
|
+
* key in `edges` must exist in `stages`; every string value must exist in
|
|
311
|
+
* `stages` or be `"stop"`. Validated at load time by `validate-workflow.ts`.
|
|
312
|
+
*/
|
|
313
|
+
export interface Workflow {
|
|
314
|
+
name: string;
|
|
315
|
+
description?: string;
|
|
316
|
+
start: string;
|
|
317
|
+
stages: Record<string, StageDef>;
|
|
318
|
+
edges: Record<string, EdgeTarget>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ===========================================================================
|
|
322
|
+
// Factories — passthroughs with defaults
|
|
323
|
+
// ===========================================================================
|
|
324
|
+
|
|
325
|
+
/** Identity passthrough; reserved for future normalization / metadata hooks. */
|
|
326
|
+
export function defineWorkflow(spec: Workflow): Workflow {
|
|
327
|
+
return spec;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Options accepted by `produces.script({ run, ... })`. Subset of the
|
|
332
|
+
* skill-stage `StageDef` knobs that semantically apply to a pure TS
|
|
333
|
+
* function: validation (`inputSchema` / `outputSchema` + retry knobs)
|
|
334
|
+
* and artifact-inheritance opt-out. `kind` and `sessionPolicy` are not
|
|
335
|
+
* configurable — script stages are always `"produces"` + `"fresh"`.
|
|
336
|
+
* `skill`, `outcome`, and `fanout` are unauthorisable here.
|
|
337
|
+
*/
|
|
338
|
+
interface ProducesScriptOptions<TIn = unknown, TOut = unknown> {
|
|
339
|
+
run: ProducesScriptFn<string, TOut>;
|
|
340
|
+
outputSchema?: StageSchema<unknown, TOut>;
|
|
341
|
+
inputSchema?: StageSchema<unknown, TIn>;
|
|
342
|
+
onInvalid?: OnInvalid;
|
|
343
|
+
maxRetries?: number;
|
|
344
|
+
validateTimeoutMs?: number;
|
|
345
|
+
inheritsArtifacts?: boolean;
|
|
346
|
+
reads?: ReadonlyArray<string>;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Options accepted by `acts.script({ run, ... })` and
|
|
351
|
+
* `terminal.script({ run, ... })`. Validation surface is narrower than
|
|
352
|
+
* the produces variant: side-effect stages have no `outputSchema`
|
|
353
|
+
* (they emit no data envelope), so the retry knobs don't apply.
|
|
354
|
+
*/
|
|
355
|
+
interface ActsScriptOptions<TIn = unknown> {
|
|
356
|
+
run: ActsScriptFn;
|
|
357
|
+
inputSchema?: StageSchema<unknown, TIn>;
|
|
358
|
+
inheritsArtifacts?: boolean;
|
|
359
|
+
reads?: ReadonlyArray<string>;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function producesFn(overrides: Partial<StageDef> = {}): StageDef {
|
|
363
|
+
return {
|
|
364
|
+
kind: "produces",
|
|
365
|
+
sessionPolicy: "fresh",
|
|
366
|
+
...overrides,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function producesScript<TIn = unknown, TOut = unknown>(opts: ProducesScriptOptions<TIn, TOut>): StageDef<TIn, TOut> {
|
|
371
|
+
return {
|
|
372
|
+
kind: "produces",
|
|
373
|
+
sessionPolicy: "fresh",
|
|
374
|
+
run: opts.run as ProducesScriptFn<string, TOut>,
|
|
375
|
+
outputSchema: opts.outputSchema,
|
|
376
|
+
inputSchema: opts.inputSchema,
|
|
377
|
+
onInvalid: opts.onInvalid,
|
|
378
|
+
maxRetries: opts.maxRetries,
|
|
379
|
+
validateTimeoutMs: opts.validateTimeoutMs,
|
|
380
|
+
inheritsArtifacts: opts.inheritsArtifacts,
|
|
381
|
+
reads: opts.reads,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function actsFn(overrides: Partial<StageDef> = {}): StageDef {
|
|
386
|
+
return {
|
|
387
|
+
kind: "side-effect",
|
|
388
|
+
sessionPolicy: "fresh",
|
|
389
|
+
...overrides,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function actsScript<TIn = unknown>(opts: ActsScriptOptions<TIn>): StageDef<TIn, void> {
|
|
394
|
+
return {
|
|
395
|
+
kind: "side-effect",
|
|
396
|
+
sessionPolicy: "fresh",
|
|
397
|
+
run: opts.run,
|
|
398
|
+
inputSchema: opts.inputSchema,
|
|
399
|
+
inheritsArtifacts: opts.inheritsArtifacts,
|
|
400
|
+
reads: opts.reads,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function terminalFn(overrides: Partial<StageDef> = {}): StageDef {
|
|
405
|
+
return actsFn({ ...overrides, inheritsArtifacts: false });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function terminalScript<TIn = unknown>(opts: ActsScriptOptions<TIn>): StageDef<TIn, void> {
|
|
409
|
+
return actsScript({ ...opts, inheritsArtifacts: false });
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Artifact-producing stage: invokes a Pi skill that writes
|
|
414
|
+
* `.rpiv/artifacts/<bucket>/<file>.md`. Defaults to fresh-session. The
|
|
415
|
+
* skill body defaults to the surrounding `stages` record key — override
|
|
416
|
+
* via `{ skill: "<other>" }` only when the stage id and the Pi skill
|
|
417
|
+
* differ (e.g. a stage keyed `design-after-review` aliasing the
|
|
418
|
+
* `design` skill so it can appear twice in the same workflow's edge graph).
|
|
419
|
+
*
|
|
420
|
+
* Skillless variant: `produces.script({ run, outputSchema?, ... })` runs
|
|
421
|
+
* a pure TS function in place of a Pi skill body. The function returns
|
|
422
|
+
* the `Output<K, D>` envelope directly.
|
|
423
|
+
*/
|
|
424
|
+
export const produces = Object.assign(producesFn, { script: producesScript });
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Side-effect stage: invokes a Pi skill whose side effect IS the work
|
|
428
|
+
* (commit, implement). No artifact-emission check. Defaults to fresh-session.
|
|
429
|
+
* Like `produces`, the skill body defaults to the record key.
|
|
430
|
+
*
|
|
431
|
+
* Skillless variant: `acts.script({ run, ... })` runs a pure TS
|
|
432
|
+
* function in place of a Pi skill body; the runner synthesises a
|
|
433
|
+
* `SideEffectOutput` envelope so the chain stays uniform.
|
|
434
|
+
*/
|
|
435
|
+
export const acts = Object.assign(actsFn, { script: actsScript });
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Terminal side-effect stage: an `acts`-shaped stage that does NOT inherit
|
|
439
|
+
* the upstream primary artifact. The stage's prompt receives the run's
|
|
440
|
+
* `originalInput` instead of an upstream artifact handle; the
|
|
441
|
+
* `ensureUpstreamArtifact` preflight is bypassed; and the rolling primary
|
|
442
|
+
* slot is cleared on success so anything downstream also starts without an
|
|
443
|
+
* inherited handle.
|
|
444
|
+
*
|
|
445
|
+
* Sibling to `produces()` / `acts()`. The right answer when a final stage
|
|
446
|
+
* (cleanup, summary, post-run notification) shouldn't be coupled to the
|
|
447
|
+
* upstream chain's artifact.
|
|
448
|
+
*
|
|
449
|
+
* Desugars to `acts({ ...overrides, inheritsArtifacts: false })`. The
|
|
450
|
+
* skillless variant `terminal.script({ run, ... })` desugars to
|
|
451
|
+
* `acts.script({ ...opts, inheritsArtifacts: false })`.
|
|
452
|
+
*/
|
|
453
|
+
export const terminal = Object.assign(terminalFn, { script: terminalScript });
|
|
454
|
+
|
|
455
|
+
// ===========================================================================
|
|
456
|
+
// Route builders — common patterns
|
|
457
|
+
// ===========================================================================
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Marker attached to EdgeFns that read from `output.data`.
|
|
461
|
+
* `validate-workflow.ts:checkPredicateSchemas` warns when a stage feeds a
|
|
462
|
+
* marked route but has no `outputSchema` — routing on un-validated data
|
|
463
|
+
* is the I6-class defect from the bcc34bc review.
|
|
464
|
+
*
|
|
465
|
+
* Default is "marked": `defineRoute(targets, fn)` auto-marks. Opt out by
|
|
466
|
+
* passing `{ readsData: false }` for the rare route that consults only
|
|
467
|
+
* `state` or `output.meta`.
|
|
468
|
+
*
|
|
469
|
+
* Exported as a `Symbol.for` so it survives `import` boundaries cleanly.
|
|
470
|
+
*/
|
|
471
|
+
export const READS_DATA: unique symbol = Symbol.for("rpiv.workflow.readsData");
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* True iff `fn` was wrapped by `defineRoute(...)` with the data-reading
|
|
475
|
+
* marker attached (the default; opt out via `{ readsData: false }`). The
|
|
476
|
+
* validator uses this to decide whether an `EdgeFn`'s source stage must
|
|
477
|
+
* declare an `outputSchema` — data-reading routes need a validated output
|
|
478
|
+
* shape; state-only routes don't.
|
|
479
|
+
*
|
|
480
|
+
* Centralises the double-cast required to symbol-key into a function object
|
|
481
|
+
* so consumers don't sprinkle `as unknown as Record<symbol, …>` at every
|
|
482
|
+
* read site.
|
|
483
|
+
*/
|
|
484
|
+
export function marksReadsData(fn: EdgeFn): boolean {
|
|
485
|
+
return Boolean((fn as unknown as Record<symbol, boolean>)[READS_DATA]);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Options for `defineRoute`. */
|
|
489
|
+
export interface DefineRouteOptions {
|
|
490
|
+
/**
|
|
491
|
+
* Whether the route reads `output.data` (the default). Set to `false` for
|
|
492
|
+
* routes that consult only `state` or `output.meta` so the load-time
|
|
493
|
+
* outputSchema lint doesn't warn the source stage lacks an
|
|
494
|
+
* `outputSchema` (a state-derived route has no data-shape contract to
|
|
495
|
+
* validate).
|
|
496
|
+
*/
|
|
497
|
+
readsData?: boolean;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Promote a hand-rolled `EdgePredicate` to an `EdgeFn` by structurally
|
|
502
|
+
* attaching the set of possible returns. `validate-workflow.ts` requires every
|
|
503
|
+
* EdgeFn to carry `.targets` so reachability and load-time edge-target
|
|
504
|
+
* checks see every branch; this factory is the only blessed way to author
|
|
505
|
+
* a multi-branch route.
|
|
506
|
+
*
|
|
507
|
+
* Auto-marks the returned EdgeFn with `READS_DATA` so the outputSchema lint
|
|
508
|
+
* fires when the source stage has no `outputSchema`. If the route consults
|
|
509
|
+
* only `state` / `output.meta` and never reads `output.data`, pass
|
|
510
|
+
* `{ readsData: false }` to suppress the lint.
|
|
511
|
+
*
|
|
512
|
+
* Throws if `targets` is empty — a route that can't return anything
|
|
513
|
+
* declared is by definition a bug.
|
|
514
|
+
*/
|
|
515
|
+
export function defineRoute(targets: readonly string[], fn: EdgePredicate, opts?: DefineRouteOptions): EdgeFn {
|
|
516
|
+
if (targets.length === 0) {
|
|
517
|
+
throw new Error("defineRoute: targets must declare at least one possible return value");
|
|
518
|
+
}
|
|
519
|
+
const wrapped = fn as EdgeFn;
|
|
520
|
+
wrapped.targets = [...targets];
|
|
521
|
+
if (opts?.readsData !== false) (wrapped as unknown as Record<symbol, boolean>)[READS_DATA] = true;
|
|
522
|
+
return wrapped;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Conditional routing keyed on a numeric field in `output.data`. Each
|
|
527
|
+
* branch's predicate is evaluated against `Number(output.data[field])` in
|
|
528
|
+
* declaration order; the first matching branch wins. If no predicate
|
|
529
|
+
* matches, the last declared branch is the fallback — same posture as the
|
|
530
|
+
* prior `threshold` builder, which routed missing/non-numeric fields to its
|
|
531
|
+
* `ifBelow` branch by virtue of `NaN > anything === false`.
|
|
532
|
+
*
|
|
533
|
+
* ```ts
|
|
534
|
+
* gate("blockers_count", { revise: gt(0), commit: eq(0) })
|
|
535
|
+
* // value > 0 → "revise"
|
|
536
|
+
* // value = 0 → "commit"
|
|
537
|
+
* // value < 0 → "commit" (no match, falls to last)
|
|
538
|
+
* // missing/NaN → "commit" (no match, falls to last)
|
|
539
|
+
* ```
|
|
540
|
+
*
|
|
541
|
+
* Built on `defineRoute` so the `.targets` metadata is attached structurally
|
|
542
|
+
* and the `READS_DATA` marker auto-applies (routing reads `output.data`).
|
|
543
|
+
*/
|
|
544
|
+
export function gate(field: string, branches: Record<string, Predicate>): EdgeFn {
|
|
545
|
+
const targets = Object.keys(branches);
|
|
546
|
+
if (targets.length === 0) {
|
|
547
|
+
throw new Error("gate: branches must declare at least one possible return value");
|
|
548
|
+
}
|
|
549
|
+
const fallback = targets[targets.length - 1]!;
|
|
550
|
+
return defineRoute(targets, ({ output }) => {
|
|
551
|
+
const value = Number((output?.data as Record<string, unknown> | undefined)?.[field]);
|
|
552
|
+
for (const target of targets) {
|
|
553
|
+
if (branches[target]!(value)) return target;
|
|
554
|
+
}
|
|
555
|
+
return fallback;
|
|
556
|
+
});
|
|
557
|
+
}
|