@juicesharp/rpiv-workflow 1.15.0 → 1.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api.ts CHANGED
@@ -135,6 +135,51 @@ export interface FanoutUnit {
135
135
  id?: string;
136
136
  }
137
137
 
138
+ /**
139
+ * Opt-in iteration — the sequential, accumulating counterpart to `FanoutFn`.
140
+ * Where `fanout` computes all units up front and runs them blind to one
141
+ * another, `iterate` is invoked once per unit (pull model), each call
142
+ * receiving the validated `Output`s of every prior unit in this stage. Return
143
+ * the next unit, or `null`/`undefined` to terminate the stage.
144
+ *
145
+ * Each unit runs the stage's `outcome` collector like a one-shot `produces`
146
+ * stage: it validates against `outputSchema`, appends its `Output` to
147
+ * `state.named[outcome.name]`, and rolls the primary artifact forward.
148
+ *
149
+ * Invariants enforced by the runner:
150
+ * - First call returns null ⇒ stage completes as a zero-unit no-op (advances).
151
+ * - Throws ⇒ stage halts, attributed to this stage.
152
+ * - `accumulated.length` reaching the run-wide `maxIterations` cap ⇒ stage
153
+ * halts with a terminal failure (the backstop for a generator that never
154
+ * returns null).
155
+ * - Requires `kind: "produces"` + an `outcome` with a `name`; incompatible
156
+ * with `sessionPolicy: "continue"` and with `fanout`/`run` (validated at
157
+ * load + at preflight).
158
+ */
159
+ export type IterateFn = (ctx: IterateContext) => IterateUnit | null | Promise<IterateUnit | null>;
160
+
161
+ export interface IterateContext {
162
+ cwd: string;
163
+ /**
164
+ * Stage-entry primary artifact, FROZEN across every unit. Does not roll
165
+ * forward to the prior unit's output — use `accumulated` (or `state.named`)
166
+ * for that. Undefined when the iterate stage is the entry point.
167
+ *
168
+ * On a corrective back-edge re-entry the rolling primary may be a
169
+ * downstream doc; generators that must re-read their true source should
170
+ * read it from `state.named` rather than relying on this slot.
171
+ */
172
+ artifact: import("./handle.js").Artifact | undefined;
173
+ state: Readonly<RunState>;
174
+ /** Validated Outputs of this stage's already-completed units, in order. */
175
+ accumulated: readonly import("./output.js").Output[];
176
+ /** 0-based index of the unit about to run (== accumulated.length). */
177
+ index: number;
178
+ }
179
+
180
+ /** Same shape as `FanoutUnit` (prompt + label + optional stable id). */
181
+ export type IterateUnit = FanoutUnit;
182
+
138
183
  // ===========================================================================
139
184
  // Script-stage primitives — skillless TS functions in place of `/skill:<x>`
140
185
  // ===========================================================================
@@ -177,6 +222,28 @@ export type ProducesScriptFn<K extends string = string, D = unknown> = (
177
222
  */
178
223
  export type ActsScriptFn = (ctx: ScriptContext) => void | Promise<void>;
179
224
 
225
+ /**
226
+ * Raw-prompt dispatch body — the third dispatch alongside `skill`
227
+ * (`/skill:<name> <args>`) and script `run` (pure TS, no model). Returns the
228
+ * COMPLETE user message sent into the stage's session: no `/skill:` prefix and
229
+ * no implicit upstream-artifact arg appended. The dynamic form receives the
230
+ * same `ScriptContext` script stages get, so a prompt can weave in the upstream
231
+ * `Output` (`ctx.input`) or the named registry (`ctx.state.named`):
232
+ *
233
+ * acts({ prompt: "Implement the design spec discussed above.", sessionPolicy: "continue" })
234
+ * produces({ prompt: ({ input }) =>
235
+ * `Summarise ${handleToString(input!.artifacts[0]!.handle)} in 3 bullets.`, outcome })
236
+ *
237
+ * A plain `string` is sugar for `() => string`. Unlike a skill stage, a prompt
238
+ * stage skips the skill-registry preflight (there is no skill to register).
239
+ * Mutually exclusive with `skill` (explicit), `run`, `reads`, and — in v1 —
240
+ * `fanout`/`iterate`. Composes with `kind` (a `produces` prompt stage runs the
241
+ * `outcome` collector and publishes; a `side-effect` prompt stage just talks)
242
+ * and `sessionPolicy` (`continue` = a follow-up turn on a session a prior stage
243
+ * populated). (validated at load + preflight.)
244
+ */
245
+ export type PromptFn = (ctx: ScriptContext) => string | Promise<string>;
246
+
180
247
  // ===========================================================================
181
248
  // Types
182
249
  // ===========================================================================
@@ -261,6 +328,17 @@ export interface StageDef<TIn = unknown, TOut = unknown> {
261
328
  * isolation.
262
329
  */
263
330
  fanout?: FanoutFn;
331
+ /**
332
+ * Opt-in sequential accumulation — the dual of `fanout`. When set, the
333
+ * runner pulls units one at a time, feeding each generator call the prior
334
+ * units' `Output`s; every unit runs the stage's `outcome` like a one-shot
335
+ * `produces` stage and accumulates into `state.named[outcome.name]`.
336
+ *
337
+ * Mutually exclusive with `fanout` and `run`. Requires `kind: "produces"`,
338
+ * an `outcome` with a `name`, and `sessionPolicy` other than `"continue"`.
339
+ * (validated at load + at preflight.)
340
+ */
341
+ iterate?: IterateFn;
264
342
  /**
265
343
  * Whether the stage inherits the chain's primary artifact from
266
344
  * upstream `produces` stages. Default `true`. Set to `false` on a
@@ -287,6 +365,18 @@ export interface StageDef<TIn = unknown, TOut = unknown> {
287
365
  * by `validateWorkflow`.
288
366
  */
289
367
  run?: ProducesScriptFn<string, TOut> | ActsScriptFn;
368
+ /**
369
+ * Raw-prompt dispatch: when set, the runner sends this text (resolved per
370
+ * the `PromptFn`, or the literal string) into the stage's session instead
371
+ * of `/skill:<skill>`. The stage runs the model with no skill body and no
372
+ * skill-registry check. Presence of `prompt` is the third dispatch
373
+ * discriminator alongside `run`.
374
+ *
375
+ * Mutually exclusive with `skill` (explicit), `run`, `reads`, and — in v1 —
376
+ * `fanout`/`iterate`. Composes with `kind` and `sessionPolicy`. (validated
377
+ * at load + preflight.)
378
+ */
379
+ prompt?: string | PromptFn;
290
380
  /**
291
381
  * Names this stage consumes from `state.named` to build its prompt.
292
382
  * When set, the runner replaces the default single-artifact prompt
@@ -359,6 +449,40 @@ interface ActsScriptOptions<TIn = unknown> {
359
449
  reads?: ReadonlyArray<string>;
360
450
  }
361
451
 
452
+ /**
453
+ * Options accepted by `produces.prompt({ prompt, outcome, ... })` — the typed
454
+ * builder for a raw-prompt `produces` stage. The dispatch-conflicting fields
455
+ * (`skill`, `run`, `fanout`, `iterate`, `reads`) are STRUCTURALLY ABSENT, so an
456
+ * object-literal call site that sets one fails TypeScript's excess-property
457
+ * check — the load-time exclusion becomes compile-time for the idiomatic path.
458
+ * `outcome` is required (a `produces` stage always needs one).
459
+ */
460
+ interface ProducesPromptOptions<TIn = unknown, TOut = unknown> {
461
+ prompt: string | PromptFn;
462
+ outcome: OutputSpec;
463
+ outputSchema?: StageSchema<unknown, TOut>;
464
+ inputSchema?: StageSchema<unknown, TIn>;
465
+ onInvalid?: OnInvalid;
466
+ maxRetries?: number;
467
+ validateTimeoutMs?: number;
468
+ /** `"continue"` makes this a follow-up turn on a session a prior stage populated. */
469
+ sessionPolicy?: SessionPolicy;
470
+ }
471
+
472
+ /**
473
+ * Options accepted by `acts.prompt({ prompt, ... })` — the typed builder for a
474
+ * raw-prompt side-effect stage (a pure chat turn). Narrower than the produces
475
+ * variant: no `outcome` (nothing collected). Dispatch-conflicting fields are
476
+ * structurally absent. For a collecting side-effect prompt stage, use the bare
477
+ * `acts({ prompt, outcome })` field form instead.
478
+ */
479
+ interface ActsPromptOptions<TIn = unknown> {
480
+ prompt: string | PromptFn;
481
+ inputSchema?: StageSchema<unknown, TIn>;
482
+ /** `"continue"` makes this a follow-up turn on a session a prior stage populated. */
483
+ sessionPolicy?: SessionPolicy;
484
+ }
485
+
362
486
  function producesFn(overrides: Partial<StageDef> = {}): StageDef {
363
487
  return {
364
488
  kind: "produces",
@@ -382,6 +506,20 @@ function producesScript<TIn = unknown, TOut = unknown>(opts: ProducesScriptOptio
382
506
  };
383
507
  }
384
508
 
509
+ function producesPrompt<TIn = unknown, TOut = unknown>(opts: ProducesPromptOptions<TIn, TOut>): StageDef<TIn, TOut> {
510
+ return {
511
+ kind: "produces",
512
+ sessionPolicy: opts.sessionPolicy ?? "fresh",
513
+ prompt: opts.prompt,
514
+ outcome: opts.outcome,
515
+ outputSchema: opts.outputSchema,
516
+ inputSchema: opts.inputSchema,
517
+ onInvalid: opts.onInvalid,
518
+ maxRetries: opts.maxRetries,
519
+ validateTimeoutMs: opts.validateTimeoutMs,
520
+ };
521
+ }
522
+
385
523
  function actsFn(overrides: Partial<StageDef> = {}): StageDef {
386
524
  return {
387
525
  kind: "side-effect",
@@ -390,6 +528,15 @@ function actsFn(overrides: Partial<StageDef> = {}): StageDef {
390
528
  };
391
529
  }
392
530
 
531
+ function actsPrompt<TIn = unknown>(opts: ActsPromptOptions<TIn>): StageDef<TIn, void> {
532
+ return {
533
+ kind: "side-effect",
534
+ sessionPolicy: opts.sessionPolicy ?? "fresh",
535
+ prompt: opts.prompt,
536
+ inputSchema: opts.inputSchema,
537
+ };
538
+ }
539
+
393
540
  function actsScript<TIn = unknown>(opts: ActsScriptOptions<TIn>): StageDef<TIn, void> {
394
541
  return {
395
542
  kind: "side-effect",
@@ -420,8 +567,13 @@ function terminalScript<TIn = unknown>(opts: ActsScriptOptions<TIn>): StageDef<T
420
567
  * Skillless variant: `produces.script({ run, outputSchema?, ... })` runs
421
568
  * a pure TS function in place of a Pi skill body. The function returns
422
569
  * the `Output<K, D>` envelope directly.
570
+ *
571
+ * Raw-prompt variant: `produces.prompt({ prompt, outcome, ... })` dispatches
572
+ * author-owned text (no `/skill:` prefix) and collects the reply via `outcome`.
573
+ * The typed options omit the dispatch-conflicting fields so invalid combos are
574
+ * un-typable on a literal call site.
423
575
  */
424
- export const produces = Object.assign(producesFn, { script: producesScript });
576
+ export const produces = Object.assign(producesFn, { script: producesScript, prompt: producesPrompt });
425
577
 
426
578
  /**
427
579
  * Side-effect stage: invokes a Pi skill whose side effect IS the work
@@ -431,8 +583,12 @@ export const produces = Object.assign(producesFn, { script: producesScript });
431
583
  * Skillless variant: `acts.script({ run, ... })` runs a pure TS
432
584
  * function in place of a Pi skill body; the runner synthesises a
433
585
  * `SideEffectOutput` envelope so the chain stays uniform.
586
+ *
587
+ * Raw-prompt variant: `acts.prompt({ prompt, sessionPolicy? })` dispatches
588
+ * author-owned text as a pure chat turn (no artifact collected) — the typed
589
+ * builder for the continue follow-up shape.
434
590
  */
435
- export const acts = Object.assign(actsFn, { script: actsScript });
591
+ export const acts = Object.assign(actsFn, { script: actsScript, prompt: actsPrompt });
436
592
 
437
593
  /**
438
594
  * Terminal side-effect stage: an `acts`-shaped stage that does NOT inherit
package/audit.ts CHANGED
@@ -56,6 +56,18 @@ export type AuditCtx = Pick<
56
56
  */
57
57
  export const fanoutRowStage = (s: FanoutSession): string => `${s.stageName} (${s.id ?? s.label})`;
58
58
 
59
+ /**
60
+ * JSONL `WorkflowStage.stage` value for iterate-unit rows. Same projection as
61
+ * `fanoutRowStage` — the parent stage's record key suffixed with the unit's
62
+ * `id ?? label` (e.g. `"blueprint (phase-2)"`) so post-hoc readers can tell
63
+ * the accumulated units apart. Takes the decorated parts directly (rather than
64
+ * a session) because the iterate executor synthesizes the `StageSession` after
65
+ * building this label. Decoration is SAFE for named keying: iterate mandates
66
+ * `outcome.name`, so `resolvePublishName` ignores the decorated `stageName`
67
+ * and every unit publishes to the same `state.named` slot.
68
+ */
69
+ export const iterateRowStage = (stageName: string, tag: string): string => `${stageName} (${tag})`;
70
+
59
71
  /**
60
72
  * Allocates the next `stageNumber`, attempts the append, and returns the
61
73
  * assigned number on success (or undefined on I/O failure). `lastAllocatedStageNumber`
@@ -7,9 +7,11 @@ Complete reference for the `@juicesharp/rpiv-workflow` authoring DSL. A workflow
7
7
  - [defineWorkflow](#defineworkflow)
8
8
  - [Stage factories](#stage-factories)
9
9
  - [produces](#produces)
10
+ - [iterate (sequential accumulation)](#iterate-sequential-accumulation)
10
11
  - [acts](#acts)
11
12
  - [terminal](#terminal)
12
13
  - [Script stages](#script-stages)
14
+ - [Prompt stages (raw-text dispatch)](#prompt-stages-raw-text-dispatch)
13
15
  - [Edge targets](#edge-targets)
14
16
  - [Conditional routing](#conditional-routing)
15
17
  - [gate](#gate)
@@ -63,12 +65,18 @@ produces({
63
65
  outputSchema: typeboxSchema(Type.Object({ blockers_count: Type.Integer() })),
64
66
  })
65
67
 
66
- // With fanout (one Pi session per unit)
68
+ // With fanout (one Pi session per unit, all units known up front)
67
69
  produces({
68
70
  outcome: myOutcome,
69
71
  fanout: myFanoutFn,
70
72
  })
71
73
 
74
+ // With iterate (sequential, accumulating — see below)
75
+ produces({
76
+ outcome: myOutcome, // outcome MUST carry a `name`
77
+ iterate: myIterateFn,
78
+ })
79
+
72
80
  // With validation retry
73
81
  produces({
74
82
  outcome: myOutcome,
@@ -90,9 +98,64 @@ produces({
90
98
  | `onInvalid` | `"retry"` | `"retry"` (re-invoke up to `maxRetries`) or `"halt"` (fail fast). |
91
99
  | `maxRetries` | — | Max retries on schema rejection. |
92
100
  | `validateTimeoutMs` | — | Timeout for async schemas. |
93
- | `fanout` | none | `FanoutFn` — decomposes work into N units, one Pi session per unit. |
101
+ | `fanout` | none | `FanoutFn` — decomposes work into N units, one Pi session per unit. All units computed up front. |
102
+ | `iterate` | none | `IterateFn` — sequential, accumulating units pulled one at a time, each seeing prior units' outputs. Requires `outcome.name`. See [iterate](#iterate-sequential-accumulation). |
94
103
  | `sessionPolicy` | `"fresh"` | `"fresh"` (new session) or `"continue"` (reuse prior session). |
95
104
 
105
+ ### iterate (sequential accumulation)
106
+
107
+ `iterate` is a field on a `produces` stage — the **sequential, accumulating** dual of `fanout`. Where `fanout` computes every unit up front and runs them blind to one another, `iterate` pulls one unit at a time: the runner calls your `IterateFn` per unit, feeding it the validated outputs of every prior unit in the same stage. Return the next unit, or `null`/`undefined` to terminate.
108
+
109
+ Each unit runs the stage's `outcome` collector exactly like a one-shot `produces` pass — it validates against `outputSchema`, appends its `Output` to `state.named[outcome.name]`, and rolls the primary artifact forward. So a downstream stage (or a downstream `fanout`) can read every accumulated output straight from `state`.
110
+
111
+ ```typescript
112
+ import type { IterateFn } from "@juicesharp/rpiv-workflow";
113
+
114
+ // One blueprint pass per review phase, each building on the plans already produced.
115
+ const perPhase: IterateFn = ({ artifact, accumulated, index, cwd, state }) => {
116
+ if (artifact?.handle.kind !== "fs") return null;
117
+ const phases = readPhases(artifact.handle.path, cwd);
118
+ if (index >= phases.length) return null; // terminator
119
+ const prior = accumulated.flatMap((o) => o.artifacts).map((a) => pathOf(a));
120
+ return {
121
+ prompt: `${artifact.handle.path} Phase ${phases[index].n}` +
122
+ (prior.length ? `\nPrior plans (build on them): ${prior.join(", ")}` : ""),
123
+ label: `phase ${index + 1}/${phases.length}`,
124
+ id: `phase-${phases[index].n}`, // stable audit key
125
+ };
126
+ };
127
+
128
+ produces({ outcome: rpivBucketOutcome("plans"), iterate: perPhase })
129
+ ```
130
+
131
+ **`IterateContext` (what each call receives):**
132
+
133
+ | Field | Meaning |
134
+ |-------|---------|
135
+ | `cwd` | Run working directory. |
136
+ | `artifact` | Stage-entry primary, **FROZEN** across every unit (does NOT roll to the prior unit's output). Undefined when iterate is the entry stage. On a corrective back-edge re-entry the rolling primary may be a downstream doc — source your true input from `state.named` in that case. |
137
+ | `state` | Read-only `RunState`. `state.named[outcome.name]` is the global accumulation channel. |
138
+ | `accumulated` | This stage's already-completed units' validated `Output`s, in order. The primary accumulation channel. |
139
+ | `index` | 0-based index of the unit about to run (`== accumulated.length`). |
140
+
141
+ **Invariants (enforced at load + preflight):**
142
+
143
+ - Requires `kind: "produces"` and an `outcome` with a `name` (every unit publishes to the same named slot; the per-unit audit row is decorated, so a name is mandatory to keep the slot from splitting).
144
+ - Mutually exclusive with `fanout` (push vs pull) and with `run` (script stages write their own loop).
145
+ - Incompatible with `sessionPolicy: "continue"` (each unit needs an isolated session).
146
+ - Generator `null` on the first call → zero-unit no-op: nothing published, primary unchanged, chain advances (with a warning).
147
+ - A run-wide `maxIterations` cap (default 32, configurable via `RunWorkflowOptions.maxIterations`) backstops a generator that never returns `null` — the stage halts with a terminal failure.
148
+
149
+ **iterate vs fanout** — duals, not substitutes:
150
+
151
+ | | `fanout` | `iterate` |
152
+ |---|---|---|
153
+ | Generation | push (once, all units) | pull (per unit, sees prior) |
154
+ | Stage kind | any (often `acts`) | requires `produces` + named `outcome` |
155
+ | Collector per unit | no (bare audit row) | yes (full produces path) |
156
+ | Count known up front | yes | no (generator-terminated) |
157
+ | Use when | independent side-effect units (e.g. `implement` per plan phase) | each unit must build on the last (e.g. `blueprint` per review phase) |
158
+
96
159
  ### acts
97
160
 
98
161
  `kind: "side-effect"`. The skill's side effect IS the work (commit, implement). The next stage inherits the prior artifact list forward.
@@ -189,10 +252,78 @@ const notifySlack = terminal.script({
189
252
  ```
190
253
 
191
254
  **Constraints on script stages:**
192
- - Cannot declare `skill`, `outcome`, `fanout`, or `sessionPolicy: "continue"` — load-time validation rejects the combination.
255
+ - Cannot declare `skill`, `outcome`, `fanout`, `iterate`, or `sessionPolicy: "continue"` — load-time validation rejects the combination.
193
256
  - `produces.script` may declare `outputSchema`, `maxRetries`, `onInvalid`.
194
257
  - `acts.script` / `terminal.script` may declare `inputSchema`.
195
258
 
259
+ ### Prompt stages (raw-text dispatch)
260
+
261
+ A stage has three **dispatch** options — orthogonal to its `kind`:
262
+
263
+ | Dispatch | What runs | Set via |
264
+ |----------|-----------|---------|
265
+ | **skill** (default) | `/skill:<name> <args>` — the full skill body | nothing (or `skill:`) |
266
+ | **script** | a pure TS function, no model call | `run:` |
267
+ | **prompt** | raw text sent straight to the model — a "chat turn" | `prompt:` |
268
+
269
+ A `prompt` stage sends author-owned text as the user message — no `/skill:`
270
+ prefix, no implicit upstream-artifact arg appended. Use it for a focused,
271
+ one-off instruction that doesn't warrant a whole skill.
272
+
273
+ **Prefer the typed builders** `produces.prompt({ … })` / `acts.prompt({ … })`
274
+ (mirroring `.script`). Their options structurally omit `skill`/`run`/`fanout`/
275
+ `iterate`/`reads`, so an invalid combo fails to compile instead of only failing
276
+ load validation:
277
+
278
+ ```typescript
279
+ // side-effect chat turn (no artifact collected)
280
+ acts.prompt({ prompt: "Implement the design spec discussed above.", sessionPolicy: "continue" })
281
+
282
+ // produces chat turn — its reply runs the outcome collector like any produces stage
283
+ produces.prompt({ prompt: "Write a one-paragraph summary to .rpiv/artifacts/summary/s.md", outcome: myOutcome })
284
+
285
+ // dynamic — weave in the upstream Output (same ScriptContext script stages get)
286
+ produces.prompt({
287
+ prompt: ({ input }) => `Refine ${handleToString(input!.artifacts[0]!.handle)} for clarity.`,
288
+ outcome: myOutcome,
289
+ })
290
+
291
+ // acts.prompt({ prompt, skill: "x" }) — does NOT compile; the builder omits `skill`.
292
+ ```
293
+
294
+ The bare-field form (`acts({ prompt })` / `produces({ prompt })`) still works —
295
+ it's what the runner reads and what programmatic embedders construct — but the
296
+ builders are the recommended authoring surface.
297
+
298
+ **The killer use — the continue follow-up turn.** With `sessionPolicy: "continue"`,
299
+ a prompt stage sends a follow-up into a session a prior stage already populated,
300
+ *without re-invoking a skill*. This is the only way to build on a stage whose
301
+ output is conversation-only (e.g. a `frontend-design` pass that emits no
302
+ artifact): the downstream `implement` step leans on the shared context.
303
+
304
+ ```typescript
305
+ stages: {
306
+ discover: produces({ outcome: rpivBucketOutcome("research") }), // fresh, writes a spec
307
+ design: acts({ skill: "frontend-design", sessionPolicy: "continue" }), // same session, no artifact
308
+ implement: acts.prompt({ prompt: "Implement the design spec.", sessionPolicy: "continue" }),
309
+ }
310
+ ```
311
+
312
+ **Constraints (load + preflight):**
313
+ - Mutually exclusive with an explicit `skill`, with `run`, with `reads`, and —
314
+ in v1 — with `fanout`/`iterate`. (Read `state.named` from the `PromptFn` itself
315
+ instead of `reads`.)
316
+ - `produces` + `prompt` still requires an `outcome`. `side-effect` + `prompt` is
317
+ a pure chat turn.
318
+ - A prompt stage skips the skill-registry check (no skill to register) and the
319
+ upstream-artifact check (it owns its whole message).
320
+ - A `continue` prompt stage as the workflow **start** warns — there is no prior
321
+ session to continue.
322
+
323
+ > **When NOT to use it.** Prompt text in a workflow definition isn't versioned,
324
+ > localized, or independently testable the way a `SKILL.md` is. Keep prompt
325
+ > stages short and glue-like; anything reusable belongs in a skill.
326
+
196
327
  ## Edge targets
197
328
 
198
329
  Each edge maps a stage name to one of:
@@ -624,6 +755,8 @@ Generated workflows must pass `validateWorkflow()` before writing. The validator
624
755
  - `gate` / data-reading `defineRoute` source stages have `outputSchema`
625
756
  - Every `reads:` name is published by some `produces` stage in the workflow (publish key = `outcome.name ?? stage.<record-key>`)
626
757
  - Fanout is incompatible with `sessionPolicy: "continue"`
627
- - Script stages cannot declare `skill`, `outcome`, `fanout`, or `sessionPolicy: "continue"`
758
+ - Iterate requires `kind: "produces"` + an `outcome` with a `name`; it is mutually exclusive with `fanout` and `run`, and incompatible with `sessionPolicy: "continue"`
759
+ - Prompt stages cannot also set `skill`, `run`, `reads`, `fanout`, or `iterate`; a `produces` prompt stage still needs an `outcome`; an empty prompt string is rejected
760
+ - Script stages cannot declare `skill`, `outcome`, `fanout`, `iterate`, `prompt`, or `sessionPolicy: "continue"`
628
761
 
629
762
  > **Important:** The `/wf` command blocks execution on any `severity: "error"` issue. Always validate before writing.
package/index.ts CHANGED
@@ -133,10 +133,14 @@ export {
133
133
  type FanoutFn,
134
134
  type FanoutUnit,
135
135
  gate,
136
+ type IterateContext,
137
+ type IterateFn,
138
+ type IterateUnit,
136
139
  marksReadsData,
137
140
  ON_INVALID_VALUES,
138
141
  type OnInvalid,
139
142
  type ProducesScriptFn,
143
+ type PromptFn,
140
144
  produces,
141
145
  READS_DATA,
142
146
  type ScriptContext,
package/iterate.ts ADDED
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Iterate iteration — the sequential, accumulating dual of `fanout.ts`. When a
3
+ * stage opts in via `StageDef.iterate`, the runner pulls units one at a time
4
+ * (calling the user's `IterateFn` per unit, feeding it the prior units'
5
+ * validated `Output`s) and runs one Pi session per unit. Unlike fanout, each
6
+ * unit runs the stage's `outcome` collector — it reuses `runStageSession`
7
+ * verbatim, so every unit gets the same collector → validate → record →
8
+ * accumulate path as a one-shot `produces` stage.
9
+ *
10
+ * `runner.ts` (via `stage-lifecycle.ts`) injects the runner primitives through
11
+ * `IterateDeps` so this module never imports back (cycle-free), mirroring how
12
+ * `runFanout` receives `FanoutDeps`.
13
+ *
14
+ * Two run-wide bounds are the only safety nets: the generator's own `null`
15
+ * terminator, and `run.maxIterations` (the backstop for a generator that never
16
+ * returns null). No markdown regex, no per-convention counter — rpiv-workflow
17
+ * stays convention-agnostic; the `IterateFn` body owns the convention.
18
+ */
19
+
20
+ import type { StageDef } from "./api.js";
21
+ import { iterateRowStage } from "./audit.js";
22
+ import type { Artifact } from "./handle.js";
23
+ import { MSG_ITERATE_ZERO_UNITS, MSG_STAGE_COMPLETE, STATUS_ITERATE_UNIT, STATUS_KEY } from "./messages.js";
24
+ import type { Output } from "./output.js";
25
+ import type { RunContext, RunnerCtx, StageSession } from "./types.js";
26
+
27
+ export interface IterateDeps {
28
+ /**
29
+ * Dispatch one unit through the standard stage-session path (collector →
30
+ * validate → record → accumulate). The same `runStageSession` a normal
31
+ * `produces` stage uses — iterate adds no bespoke session machinery.
32
+ */
33
+ runStageSession: (ctx: RunnerCtx, s: StageSession) => Promise<void>;
34
+ /**
35
+ * Resume the chain after the iterate node's units finish (generator
36
+ * returned null). Receives the iterate node's REAL name so routing looks up
37
+ * the outgoing edge from it — the per-unit audit decoration never leaks
38
+ * into routing.
39
+ */
40
+ advanceAfter: (curCtx: RunnerCtx, completedName: string, completedIdx: number, run: RunContext) => Promise<void>;
41
+ /** Re-capture the outcome's pre-stage snapshot per unit (each unit is its own produces pass). */
42
+ captureSnapshot: (def: StageDef, idx: number, run: RunContext) => Promise<unknown>;
43
+ /** Record the terminal failure when the `maxIterations` backstop trips. */
44
+ haltIterations: (curCtx: RunnerCtx, run: RunContext, stageName: string, count: number) => Promise<void>;
45
+ }
46
+
47
+ /**
48
+ * `skill` is the bundled skill body (threaded by the runner), not the node
49
+ * name — aliased nodes tag unit rows + prompts with the skill body so audit
50
+ * consumers don't see two labels for the same work.
51
+ *
52
+ * `currentName` is the iterate node's REAL name in the workflow — passed to
53
+ * `advanceAfter` once the generator terminates, and used (undecorated) for
54
+ * `state.named` keying via `resolvePublishName`.
55
+ *
56
+ * `entryArtifact` is the stage-entry primary, FROZEN across every unit (the
57
+ * rolling primary advances to each unit's output, but the generator keeps
58
+ * seeing its true source — see `IterateContext.artifact`).
59
+ *
60
+ * `accumulated` carries this stage's prior validated `Output`s in order. The
61
+ * continuation-style self-call appends the unit just produced (read from
62
+ * `run.state.output`, which `tryRecordStage` sets immediately before
63
+ * `onSuccess`).
64
+ */
65
+ export async function runIterate(
66
+ curCtx: RunnerCtx,
67
+ stageIdx: number,
68
+ currentName: string,
69
+ skill: string,
70
+ def: StageDef,
71
+ entryArtifact: Artifact | undefined,
72
+ accumulated: readonly Output[],
73
+ run: RunContext,
74
+ deps: IterateDeps,
75
+ ): Promise<void> {
76
+ const unit = await def.iterate!({
77
+ cwd: run.cwd,
78
+ artifact: entryArtifact,
79
+ state: run.state,
80
+ accumulated,
81
+ index: accumulated.length,
82
+ });
83
+
84
+ // Generator terminated — complete the stage and resume the chain from the
85
+ // real node name. A first-call null is the zero-unit no-op: nothing is
86
+ // published, the primary stays at the entry artifact — warn (not error) so
87
+ // the author notices the empty input. A null after ≥1 unit is a normal
88
+ // completion.
89
+ if (!unit) {
90
+ if (accumulated.length === 0) curCtx.ui.notify(MSG_ITERATE_ZERO_UNITS(skill), "warning");
91
+ else curCtx.ui.notify(MSG_STAGE_COMPLETE(skill), "info");
92
+ await deps.advanceAfter(curCtx, currentName, stageIdx, run);
93
+ return;
94
+ }
95
+
96
+ // Backstop: the generator wants another unit but we've hit the run-wide
97
+ // cap. Halt with a terminal failure (mirrors the backward-jump guard) so a
98
+ // runaway generator can't loop forever.
99
+ if (accumulated.length >= run.maxIterations) {
100
+ await deps.haltIterations(curCtx, run, currentName, accumulated.length);
101
+ return;
102
+ }
103
+
104
+ curCtx.ui.setStatus(STATUS_KEY, STATUS_ITERATE_UNIT(stageIdx + 1, run.totalStages, skill, unit.label));
105
+ const snapshot = await deps.captureSnapshot(def, stageIdx, run);
106
+
107
+ await deps.runStageSession(curCtx, {
108
+ cwd: run.cwd,
109
+ runId: run.runId,
110
+ state: run.state,
111
+ prompt: `/skill:${skill} ${unit.prompt}`,
112
+ // Decorated for the JSONL row + status; named keying still resolves to
113
+ // outcome.name (mandatory for iterate), so the decoration never splits
114
+ // the accumulation slot.
115
+ stageName: iterateRowStage(currentName, unit.id ?? unit.label),
116
+ skill,
117
+ lifecycle: run.lifecycle,
118
+ runIdentity: { workflow: run.workflow.name, totalStages: run.totalStages, trigger: run.trigger },
119
+ stage: def,
120
+ stageIndex: stageIdx,
121
+ snapshot,
122
+ branchOffset: undefined,
123
+ onFailure: undefined,
124
+ onSuccess: (freshCtx) => {
125
+ // `tryRecordStage` set `state.output` to this unit's validated Output
126
+ // (and `maybeAdvancePrimary` already pushed it onto state.named) before
127
+ // onSuccess fired. Thread it into `accumulated` for the next pull.
128
+ const produced = run.state.output!;
129
+ return runIterate(
130
+ freshCtx,
131
+ stageIdx,
132
+ currentName,
133
+ skill,
134
+ def,
135
+ entryArtifact,
136
+ [...accumulated, produced],
137
+ run,
138
+ deps,
139
+ );
140
+ },
141
+ });
142
+ }
package/messages.ts CHANGED
@@ -17,6 +17,14 @@ export const STATUS_STAGE = (stage: number, total: number, skill: string) => `rp
17
17
  export const STATUS_FANOUT_UNIT = (stage: number, total: number, skill: string, label: string) =>
18
18
  `rpiv: stage ${stage}/${total} — ${skill} (${label})`;
19
19
 
20
+ /**
21
+ * Status line for an iterate unit. Same shape as `STATUS_FANOUT_UNIT` — the
22
+ * status denominator is the reachable-stage count, so the stage number repeats
23
+ * across units and `label` (e.g. `"phase 2/3 — Vocabulary"`) disambiguates.
24
+ */
25
+ export const STATUS_ITERATE_UNIT = (stage: number, total: number, skill: string, label: string) =>
26
+ `rpiv: stage ${stage}/${total} — ${skill} (${label})`;
27
+
20
28
  export const MSG_STAGE_COMPLETE = (skill: string) => `✓ ${skill} completed`;
21
29
  export const MSG_STAGE_FAILED = (skill: string) => `✗ ${skill} failed — stopping workflow`;
22
30
  export const MSG_STAGE_ABORTED = (skill: string) => `⏸ ${skill} aborted (ESC) — stopping workflow`;
@@ -91,6 +99,27 @@ export const MSG_BACKWARD_JUMP_EXHAUSTED = (jumps: number, max: number) =>
91
99
  export const ERR_BACKWARD_JUMP_EXHAUSTED = (jumps: number, max: number) =>
92
100
  `Backward-jump limit exceeded: ${jumps} backward jumps (max ${max})`;
93
101
 
102
+ /**
103
+ * An `iterate` stage's generator kept returning units past the run-wide
104
+ * `maxIterations` safety cap (the backstop for a generator that never returns
105
+ * `null`). Stops the stage with a terminal failure, mirroring the
106
+ * backward-jump guard.
107
+ */
108
+ export const MSG_ITERATIONS_EXHAUSTED = (count: number, max: number) =>
109
+ `rpiv: iterate limit exceeded (${count}/${max}) — stopping workflow to prevent an unbounded generator`;
110
+
111
+ export const ERR_ITERATIONS_EXHAUSTED = (count: number, max: number) =>
112
+ `Iterate limit exceeded: generator produced ${count} units (max ${max})`;
113
+
114
+ /**
115
+ * An `iterate` stage's generator returned null on its FIRST call — the stage
116
+ * produced zero units. Not an error (a legitimately empty input is valid), but
117
+ * the stage published nothing and left the primary artifact untouched, so warn
118
+ * the author rather than silently advancing.
119
+ */
120
+ export const MSG_ITERATE_ZERO_UNITS = (skill: string) =>
121
+ `rpiv: ${skill} iterate produced zero units — nothing published, advancing`;
122
+
94
123
  export const MSG_AUDIT_WRITE_FAILED = (skill: string) =>
95
124
  `✗ ${skill} completed but audit row could not be written — stopping workflow`;
96
125
  export const ERR_AUDIT_WRITE_FAILED = (skill: string) =>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-workflow",
3
- "version": "1.15.0",
3
+ "version": "1.16.1",
4
4
  "description": "Pi extension. Chain skills into typed multi-stage workflows with audited JSONL state, predicate routing, and per-stage output validation. Skill-agnostic — bring your own skills.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -42,6 +42,7 @@
42
42
  "handle.ts",
43
43
  "host.ts",
44
44
  "internal-utils.ts",
45
+ "iterate.ts",
45
46
  "layers.ts",
46
47
  "lifecycle.ts",
47
48
  "load",
@@ -76,7 +77,7 @@
76
77
  ]
77
78
  },
78
79
  "dependencies": {
79
- "@juicesharp/rpiv-config": "^1.15.0",
80
+ "@juicesharp/rpiv-config": "^1.16.1",
80
81
  "jiti": "^2.7.0"
81
82
  },
82
83
  "peerDependencies": {
package/runner/runner.ts CHANGED
@@ -51,6 +51,14 @@ import { runStage, StagePreflightError } from "./stage-lifecycle.js";
51
51
  */
52
52
  export const MAX_BACKWARD_JUMPS = 2;
53
53
 
54
+ /**
55
+ * Run-wide safety cap on `iterate`-stage units — the backstop for a generator
56
+ * that never returns `null`. Mirrors rpiv-pi's `MAX_PHASES` (the convention
57
+ * cap a fanout author would self-impose); 32 is comfortably above any
58
+ * realistic per-stage unit count while still halting a runaway loop.
59
+ */
60
+ export const MAX_ITERATIONS = 32;
61
+
54
62
  // ---------------------------------------------------------------------------
55
63
  // Public surface
56
64
  // ---------------------------------------------------------------------------
@@ -64,6 +72,8 @@ export interface RunWorkflowOptions {
64
72
  host?: WorkflowHost;
65
73
  /** Defaults to MAX_BACKWARD_JUMPS. */
66
74
  maxBackwardJumps?: number;
75
+ /** Run-wide safety cap on iterate-stage units. Defaults to MAX_ITERATIONS. */
76
+ maxIterations?: number;
67
77
  /**
68
78
  * What triggered this run. `/wf` sets `{ kind: "command", name: "wf" }`;
69
79
  * programmatic embedders default to `DEFAULT_TRIGGER`. Recorded in the
@@ -175,6 +185,7 @@ export async function runWorkflow(ctx: WorkflowContext, options: RunWorkflowOpti
175
185
  };
176
186
 
177
187
  const maxBackwardJumps = options.maxBackwardJumps ?? MAX_BACKWARD_JUMPS;
188
+ const maxIterations = options.maxIterations ?? MAX_ITERATIONS;
178
189
  const lifecycle = new LifecycleDispatcher(options.lifecycle);
179
190
 
180
191
  // Snapshot the skill registry BEFORE any stage opens a fresh session.
@@ -194,6 +205,7 @@ export async function runWorkflow(ctx: WorkflowContext, options: RunWorkflowOpti
194
205
  registeredSkills,
195
206
  continueHost: options.host,
196
207
  maxBackwardJumps,
208
+ maxIterations,
197
209
  trigger,
198
210
  lifecycle,
199
211
  };
@@ -9,19 +9,22 @@
9
9
  * catches `StagePreflightError` and records the JSONL row.
10
10
  */
11
11
 
12
- import type { StageDef } from "../api.js";
13
- import { notifyPartialArtifacts } from "../audit.js";
12
+ import type { PromptFn, StageDef } from "../api.js";
13
+ import { notifyPartialArtifacts, recordTerminalFailure } from "../audit.js";
14
14
  import { runFanout } from "../fanout.js";
15
15
  import { handleToString } from "../handle.js";
16
16
  import { currentPrimaryArtifact, withTimeout } from "../internal-utils.js";
17
+ import { runIterate } from "../iterate.js";
17
18
  import { skillStageRef } from "../lifecycle.js";
18
19
  import {
19
20
  ERR_INPUT_VALIDATION_FAILED,
21
+ ERR_ITERATIONS_EXHAUSTED,
20
22
  ERR_MISSING_ARTIFACT,
21
23
  ERR_MISSING_NAMED_READ,
22
24
  ERR_SCHEMA_TIMEOUT,
23
25
  ERR_SKILL_NOT_REGISTERED,
24
26
  MSG_INPUT_VALIDATION_FAILED,
27
+ MSG_ITERATIONS_EXHAUSTED,
25
28
  MSG_MISSING_ARTIFACT,
26
29
  MSG_MISSING_NAMED_READ,
27
30
  MSG_SKILL_NOT_REGISTERED,
@@ -101,6 +104,18 @@ function buildPrompt(skill: string, inputForStage: string): string {
101
104
  return `/skill:${skill} ${inputForStage}`;
102
105
  }
103
106
 
107
+ /**
108
+ * Resolve a `prompt`-dispatch stage's text. The whole user message is
109
+ * author-owned — no `/skill:` prefix, no implicit arg. The dynamic form gets
110
+ * the same `ScriptContext` script stages get, so it can weave in the upstream
111
+ * `Output` (`run.state.output`) or `state.named`. A throw here propagates to
112
+ * `runStageOrRecordFailure`, which records a terminal failure for the stage.
113
+ */
114
+ async function resolvePrompt(prompt: string | PromptFn, run: RunContext): Promise<string> {
115
+ if (typeof prompt === "string") return prompt;
116
+ return prompt({ cwd: run.cwd, input: run.state.output, state: run.state });
117
+ }
118
+
104
119
  /**
105
120
  * The arg string the stage's `/skill:<name> <args>` prompt carries. Four
106
121
  * cases (checked in order):
@@ -153,6 +168,9 @@ function formatNamedInputs(names: ReadonlyArray<string>, run: RunContext): strin
153
168
  * 1. tryFanout — shortcut: the stage's FanoutFn returned
154
169
  * units, runner ran them; subsequent
155
170
  * slots skipped for this stage.
171
+ * 1b. tryIterate — shortcut: the stage's IterateFn pulls units
172
+ * sequentially (each a produces pass);
173
+ * subsequent slots skipped for this stage.
156
174
  * 2. PRE_PROMPT_CHECKS — preflights that don't need prompt prep.
157
175
  * a. ensureUpstreamArtifact — halt: missing inherited artifact.
158
176
  * b. enforceSessionInvariants — invariant: authoring-time-knowable
@@ -172,6 +190,7 @@ export async function runStage(curCtx: RunnerCtx, currentName: string, idx: numb
172
190
  const stage = resolveStage(currentName, idx, run);
173
191
 
174
192
  if (await tryFanout(curCtx, stage, idx, run)) return;
193
+ if (await tryIterate(curCtx, stage, idx, run)) return;
175
194
 
176
195
  // Script stages (`stage.def.run` set) skip the entire skill pipeline —
177
196
  // no `/skill:<name>` prompt to build, no skill-registry check, no
@@ -187,7 +206,15 @@ export async function runStage(curCtx: RunnerCtx, currentName: string, idx: numb
187
206
 
188
207
  for (const check of PRE_PROMPT_CHECKS) await check.run(stage, run);
189
208
 
190
- const prompt = buildPrompt(stage.skill, inputForStage(stage, run));
209
+ // Dispatch: a `prompt` stage sends author-owned raw text; a skill stage
210
+ // sends `/skill:<name> <inputForStage>`. `stage.skill` already equals the
211
+ // record key for a prompt stage (it cannot set an explicit skill — load
212
+ // validation forbids it), so the status/session/audit labels are correct
213
+ // for both without a separate label.
214
+ const prompt =
215
+ stage.def.prompt !== undefined
216
+ ? await resolvePrompt(stage.def.prompt, run)
217
+ : buildPrompt(stage.skill, inputForStage(stage, run));
191
218
  curCtx.ui.setStatus(STATUS_KEY, STATUS_STAGE(stage.stageNumber, run.totalStages, stage.skill));
192
219
  const branchOffset = computeBranchOffset(curCtx, stage.def);
193
220
 
@@ -264,6 +291,77 @@ async function tryFanout(curCtx: RunnerCtx, stage: ResolvedStage, idx: number, r
264
291
  return true;
265
292
  }
266
293
 
294
+ /**
295
+ * A stage that opts into iteration via `StageDef.iterate` expands into one Pi
296
+ * session per unit pulled from the user's `IterateFn` — the sequential,
297
+ * accumulating dual of fanout. Unlike fanout, each unit runs the stage's
298
+ * `outcome` collector (it reuses `runStageSession`), so units accumulate into
299
+ * `state.named[outcome.name]` and roll the primary artifact forward. Returns
300
+ * true iff this is an iterate stage (always — even a zero-unit no-op handles
301
+ * its own chain advance inside `runIterate`), so the caller returns without
302
+ * running the single-stage path.
303
+ *
304
+ * `onStageStart` fires here, ONCE, matching the "stage expands to N units"
305
+ * mental model; `onStageEnd` fires per unit via `recordStageSuccess`. The
306
+ * stage-entry primary is frozen and threaded as `entryArtifact` so the
307
+ * generator keeps seeing its true source even as the rolling primary advances.
308
+ */
309
+ async function tryIterate(curCtx: RunnerCtx, stage: ResolvedStage, idx: number, run: RunContext): Promise<boolean> {
310
+ if (!stage.def.iterate) return false;
311
+ // Runtime mirror of the load-time invariant — defense-in-depth for embedders
312
+ // that bypass validateWorkflow. iterate dispatches via runStageSession, which
313
+ // honors sessionPolicy; a "continue" policy would replay the prior unit's
314
+ // branch into the next unit's session, so reject before any dispatch. (This
315
+ // lives here, not in enforceSessionInvariants, because the iterate shortcut
316
+ // returns before PRE_PROMPT_CHECKS run.)
317
+ if (stage.def.sessionPolicy === "continue") {
318
+ const reason =
319
+ `runStage: stage "${stage.name}" cannot combine iterate with sessionPolicy "continue" — ` +
320
+ "each unit requires an isolated session";
321
+ throw new StagePreflightError("invariant", stage.name, MSG_STAGE_THREW(stage.name, reason), reason, false);
322
+ }
323
+ const entryArtifact = currentPrimaryArtifact(run.state); // frozen for the whole loop
324
+ await run.lifecycle.fire(
325
+ curCtx,
326
+ "onStageStart",
327
+ skillStageRef(stage.name, stage.stageNumber, stage.skill),
328
+ lifecycleCtxFor(run),
329
+ );
330
+ await runIterate(curCtx, idx, stage.name, stage.skill, stage.def, entryArtifact, [], run, {
331
+ runStageSession,
332
+ advanceAfter: (freshCtx, name, completedIdx, ctx) => advanceChain(freshCtx, name, completedIdx, ctx),
333
+ captureSnapshot: (def, i, r) => captureStageSnapshot(def, i, r),
334
+ haltIterations,
335
+ });
336
+ return true;
337
+ }
338
+
339
+ /**
340
+ * Record the terminal failure when an iterate stage's generator blows past the
341
+ * run-wide `maxIterations` cap. Mirrors `checkBackwardJumpGuard`'s terminal
342
+ * path (chain-advance.ts): attribution targets the iterate node's real name.
343
+ */
344
+ async function haltIterations(curCtx: RunnerCtx, run: RunContext, stageName: string, count: number): Promise<void> {
345
+ await recordTerminalFailure(
346
+ curCtx,
347
+ {
348
+ cwd: run.cwd,
349
+ runId: run.runId,
350
+ state: run.state,
351
+ stageName,
352
+ skill: stageName,
353
+ lifecycle: run.lifecycle,
354
+ runIdentity: { workflow: run.workflow.name, totalStages: run.totalStages, trigger: run.trigger },
355
+ },
356
+ {
357
+ status: "failed",
358
+ notifyMsg: MSG_ITERATIONS_EXHAUSTED(count, run.maxIterations),
359
+ notifyLevel: "error",
360
+ errMsg: ERR_ITERATIONS_EXHAUSTED(count, run.maxIterations),
361
+ },
362
+ );
363
+ }
364
+
267
365
  /**
268
366
  * Verify `stage.skill` resolves to a Pi-registered skill BEFORE the prompt
269
367
  * is dispatched. The workflow runner emits `/skill:<name>` text via
@@ -287,6 +385,9 @@ async function tryFanout(curCtx: RunnerCtx, stage: ResolvedStage, idx: number, r
287
385
  * surface).
288
386
  */
289
387
  function ensureSkillRegistered(stage: ResolvedStage, run: RunContext): void {
388
+ // A prompt stage dispatches raw text, not /skill:<name> — there is no skill
389
+ // to verify. (Mirrors how the script-stage path skips this check entirely.)
390
+ if (stage.def.prompt !== undefined) return;
290
391
  if (!run.registeredSkills) return;
291
392
  if (run.registeredSkills.has(stage.skill)) return;
292
393
 
@@ -315,6 +416,10 @@ function ensureUpstreamArtifact(stage: ResolvedStage, run: RunContext): void {
315
416
  if (stage.name === run.workflow.start) return;
316
417
  if (stage.def.inheritsArtifacts === false) return;
317
418
  if (stage.def.reads?.length) return;
419
+ // A prompt stage builds its own text and never consumes the rolling primary
420
+ // as an arg, so it doesn't require an upstream artifact (a continue chat
421
+ // turn typically leans on session context, not a handle).
422
+ if (stage.def.prompt !== undefined) return;
318
423
  if (currentPrimaryArtifact(run.state)) return;
319
424
  throw new StagePreflightError(
320
425
  "halt",
package/types.ts CHANGED
@@ -151,6 +151,14 @@ export interface RunContext {
151
151
  */
152
152
  continueHost?: WorkflowHost;
153
153
  maxBackwardJumps: number;
154
+ /**
155
+ * Run-wide safety cap on `iterate`-stage units. The generator is
156
+ * loop-terminated (returns `null`), not array-bounded like `fanout`, so the
157
+ * runner backstops a runaway generator: when `accumulated.length` reaches
158
+ * this, the stage halts with a terminal failure. Defaults to
159
+ * `MAX_ITERATIONS`.
160
+ */
161
+ maxIterations: number;
154
162
  /** What triggered the run; defaulted at `runWorkflow` entry. */
155
163
  trigger: RunTrigger;
156
164
  /** Lifecycle event dispatcher — see `lifecycle.ts`. Threaded by reference. */
@@ -182,6 +182,8 @@ function checkStageSemantics(w: Workflow, issues: WorkflowValidationIssue[]): vo
182
182
  checkTimeoutBounds(w, name, stage, issues);
183
183
  checkStageEnums(w, name, stage, issues);
184
184
  checkFanoutContinueInvariant(w, name, stage, issues);
185
+ checkIterateInvariants(w, name, stage, issues);
186
+ checkPromptInvariants(w, name, stage, issues);
185
187
  checkInheritsArtifactsKind(w, name, stage, issues);
186
188
  checkScriptStageInvariants(w, name, stage, issues);
187
189
  }
@@ -272,6 +274,125 @@ function checkFanoutContinueInvariant(
272
274
  }
273
275
  }
274
276
 
277
+ /**
278
+ * `iterate` is the sequential, accumulating dual of `fanout`. Its invariants
279
+ * are stricter because each unit runs the stage's collector and publishes to a
280
+ * single named slot:
281
+ *
282
+ * - mutually exclusive with `fanout` (push vs pull — a stage is one or the
283
+ * other) and with `run` (a script stage writes its own loop);
284
+ * - incompatible with `sessionPolicy: "continue"` — like fanout, each unit
285
+ * needs an isolated session (also mirrored at preflight);
286
+ * - requires `kind: "produces"` — units run an `outcome` collector;
287
+ * - requires `outcome.name` — every unit publishes to the SAME
288
+ * `state.named` slot via `resolvePublishName`, and the per-unit audit row
289
+ * is decorated, so without an explicit name the decoration would split the
290
+ * slot across units.
291
+ *
292
+ * The `fanout`/`run`/`kind`/`outcome.name` rules are authoring-time-knowable,
293
+ * so load validation is the primary gate; only the `continue` rule needs a
294
+ * runtime mirror (embedders may bypass load validation).
295
+ */
296
+ function checkIterateInvariants(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
297
+ if (!stage.iterate) return;
298
+ if (stage.fanout) {
299
+ issues.push(error(w.name, name, `stage "${name}": iterate and fanout are mutually exclusive (pull vs push)`));
300
+ }
301
+ // iterate + run is reported by checkScriptStageInvariants (mirrors fanout + run).
302
+ if (stage.sessionPolicy === "continue") {
303
+ issues.push(
304
+ error(
305
+ w.name,
306
+ name,
307
+ `stage "${name}" cannot combine iterate with sessionPolicy "continue" — each unit requires an isolated session`,
308
+ ),
309
+ );
310
+ }
311
+ if (stage.kind !== "produces") {
312
+ issues.push(
313
+ error(w.name, name, `stage "${name}": iterate requires kind "produces" — each unit runs an outcome collector`),
314
+ );
315
+ }
316
+ if (!stage.outcome?.name) {
317
+ issues.push(
318
+ error(
319
+ w.name,
320
+ name,
321
+ `stage "${name}": iterate requires an \`outcome\` with a \`name\` so accumulated units publish to a stable named slot`,
322
+ ),
323
+ );
324
+ }
325
+ }
326
+
327
+ /**
328
+ * `prompt` is the raw-text dispatch — the third option alongside skill
329
+ * (`/skill:<name>`) and script `run`. Its invariants keep the dispatch
330
+ * discriminator unambiguous and the input model single:
331
+ *
332
+ * - mutually exclusive with an explicit `skill` (you're either invoking a
333
+ * skill or sending raw text — `skill` defaulting to the record key does
334
+ * NOT trip this, only an explicitly-set `skill`);
335
+ * - mutually exclusive with `fanout`/`iterate` in v1 (chat fan-out is a
336
+ * deferred composition — see the design's §3 non-goals);
337
+ * - mutually exclusive with `reads` — a skill stage's `reads` auto-builds a
338
+ * labelled-flag arg, but a prompt stage's text is author-owned; rather than
339
+ * give `reads` two meanings, require the prompt to read `state.named`
340
+ * itself via its `PromptFn`;
341
+ * - a literal empty/whitespace string is a no-op dispatch → author error.
342
+ *
343
+ * (`prompt` + `run` is reported by checkScriptStageInvariants, mirroring
344
+ * fanout/iterate + run. `produces` + `prompt` with no `outcome` is already
345
+ * caught by the produces-requires-outcome rule — `prompt`, unlike `run`, is not
346
+ * carved out of it.)
347
+ *
348
+ * A `continue` prompt stage used as the workflow START gets a WARNING: a
349
+ * follow-up turn with no prior context to lean on is almost certainly an
350
+ * authoring mistake (the continue session would have nothing to continue).
351
+ */
352
+ function checkPromptInvariants(w: Workflow, name: string, stage: StageDef, issues: WorkflowValidationIssue[]): void {
353
+ if (stage.prompt === undefined) return;
354
+ if (stage.skill !== undefined) {
355
+ issues.push(
356
+ error(
357
+ w.name,
358
+ name,
359
+ `stage "${name}": a prompt stage cannot also set \`skill\` — it dispatches raw text, not /skill:<skill>`,
360
+ ),
361
+ );
362
+ }
363
+ if (stage.fanout) {
364
+ issues.push(
365
+ error(w.name, name, `stage "${name}": prompt and fanout are mutually exclusive (chat fan-out is deferred)`),
366
+ );
367
+ }
368
+ if (stage.iterate) {
369
+ issues.push(
370
+ error(w.name, name, `stage "${name}": prompt and iterate are mutually exclusive (chat iterate is deferred)`),
371
+ );
372
+ }
373
+ if (stage.reads?.length) {
374
+ issues.push(
375
+ error(
376
+ w.name,
377
+ name,
378
+ `stage "${name}": a prompt stage cannot set \`reads\` — read state.named from the PromptFn instead`,
379
+ ),
380
+ );
381
+ }
382
+ if (typeof stage.prompt === "string" && stage.prompt.trim() === "") {
383
+ issues.push(error(w.name, name, `stage "${name}": prompt is an empty string — nothing would be dispatched`));
384
+ }
385
+ if (stage.sessionPolicy === "continue" && name === w.start) {
386
+ issues.push(
387
+ warning(
388
+ w.name,
389
+ name,
390
+ `stage "${name}": a continue prompt stage is the workflow start — there is no prior session to continue; it will open a fresh one`,
391
+ ),
392
+ );
393
+ }
394
+ }
395
+
275
396
  /**
276
397
  * `inheritsArtifacts: false` is the `terminal()` factory's mechanism — it
277
398
  * tells the runner to bypass upstream-artifact inheritance for a
@@ -351,6 +472,16 @@ function checkScriptStageInvariants(
351
472
  error(w.name, name, `stage "${name}": script stages cannot fanout — write a loop inside run() instead`),
352
473
  );
353
474
  }
475
+ if (stage.iterate) {
476
+ issues.push(
477
+ error(w.name, name, `stage "${name}": script stages cannot iterate — write a loop inside run() instead`),
478
+ );
479
+ }
480
+ if (stage.prompt !== undefined) {
481
+ issues.push(
482
+ error(w.name, name, `stage "${name}": script stages cannot set a raw prompt — the run function IS the work`),
483
+ );
484
+ }
354
485
  if (stage.sessionPolicy === "continue") {
355
486
  issues.push(
356
487
  error(