@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 +158 -2
- package/audit.ts +12 -0
- package/docs/workflow-authoring.md +137 -4
- package/index.ts +4 -0
- package/iterate.ts +142 -0
- package/messages.ts +29 -0
- package/package.json +3 -2
- package/runner/runner.ts +12 -0
- package/runner/stage-lifecycle.ts +108 -3
- package/types.ts +8 -0
- package/validate-workflow.ts +131 -0
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
|
-
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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. */
|
package/validate-workflow.ts
CHANGED
|
@@ -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(
|