@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/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
+ }