stageflow 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/README.md +2 -4
  2. package/dist/agent/claudeTools.d.ts +1 -4
  3. package/dist/agent/claudeTools.js +1 -6
  4. package/dist/agent/fakeAgent.js +4 -5
  5. package/dist/agent/piAdapter.js +1 -14
  6. package/dist/config/cloneChain.d.ts +12 -0
  7. package/dist/config/cloneChain.js +322 -0
  8. package/dist/config/createPipeline.js +101 -21
  9. package/dist/config/createStage.js +7 -0
  10. package/dist/config/loadOutcome.d.ts +1 -0
  11. package/dist/config/loadPipeline.d.ts +2 -0
  12. package/dist/config/loadPipeline.js +34 -22
  13. package/dist/config/loadStage.d.ts +2 -0
  14. package/dist/config/loadStage.js +135 -125
  15. package/dist/config/migrateYaml.js +14 -3
  16. package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
  17. package/dist/config/normalizePipelineStageEntry.js +133 -60
  18. package/dist/config/parsePreEmitChecks.js +2 -2
  19. package/dist/config/pipelineNeeds.d.ts +15 -2
  20. package/dist/config/pipelineNeeds.js +86 -4
  21. package/dist/config/pipelineRoute.d.ts +46 -0
  22. package/dist/config/pipelineRoute.js +215 -0
  23. package/dist/config/pipelineStageKeys.d.ts +2 -0
  24. package/dist/config/pipelineStageKeys.js +15 -4
  25. package/dist/config/printTargetYaml.js +18 -12
  26. package/dist/config/resolveForkEmitContext.d.ts +1 -1
  27. package/dist/config/resolveForkEmitContext.js +3 -26
  28. package/dist/config/resolvePipelineDag.d.ts +1 -3
  29. package/dist/config/resolvePipelineDag.js +143 -186
  30. package/dist/config/routeIf.d.ts +24 -0
  31. package/dist/config/routeIf.js +247 -0
  32. package/dist/config/validateCatalog.d.ts +1 -1
  33. package/dist/config/validateCatalog.js +5 -3
  34. package/dist/config/yamlDialect.d.ts +5 -8
  35. package/dist/config/yamlDialect.js +37 -29
  36. package/dist/envelope/check.d.ts +0 -2
  37. package/dist/envelope/check.js +1 -57
  38. package/dist/envelope/feedbackLoop.js +0 -3
  39. package/dist/envelope/forkChoice.js +1 -1
  40. package/dist/envelope/payloadSchema.d.ts +14 -0
  41. package/dist/envelope/payloadSchema.js +87 -2
  42. package/dist/mcp/catalogTools.js +1 -2
  43. package/dist/package-meta.d.ts +1 -1
  44. package/dist/package-meta.js +1 -1
  45. package/dist/projection/projectRun.d.ts +0 -2
  46. package/dist/projection/projectRun.js +0 -3
  47. package/dist/prompt/priorEnvelope.js +1 -1
  48. package/dist/runstore/stageInstanceId.d.ts +1 -0
  49. package/dist/runstore/stageInstanceId.js +9 -0
  50. package/dist/runtime/cloneSchedule.d.ts +16 -33
  51. package/dist/runtime/cloneSchedule.js +155 -236
  52. package/dist/runtime/envelopeRouting.js +43 -94
  53. package/dist/runtime/forkGeneration.d.ts +2 -2
  54. package/dist/runtime/forkGeneration.js +29 -15
  55. package/dist/runtime/joinReadiness.d.ts +16 -0
  56. package/dist/runtime/joinReadiness.js +156 -0
  57. package/dist/runtime/pipelineScheduler.d.ts +0 -3
  58. package/dist/runtime/pipelineScheduler.js +81 -65
  59. package/dist/runtime/routeIfEval.d.ts +3 -0
  60. package/dist/runtime/routeIfEval.js +94 -0
  61. package/dist/runtime/runRetryCoordinator.js +0 -4
  62. package/dist/runtime/stageAttemptBootstrap.js +60 -11
  63. package/dist/runtime/taskInput.js +1 -13
  64. package/dist/tools/emitStageEnvelope.d.ts +2 -3
  65. package/dist/tools/emitStageEnvelope.js +1 -65
  66. package/dist/types/envelope.d.ts +0 -2
  67. package/dist/types/forkChoice.d.ts +0 -16
  68. package/dist/types/pipeline.d.ts +86 -27
  69. package/dist/types/stage.d.ts +11 -7
  70. package/dist/ui/assets/{index-D2Wt8hwH.css → index-DsgVjLUS.css} +1 -1
  71. package/dist/ui/assets/{index-XWJovnp7.js → index-DwKGaQvQ.js} +13 -12
  72. package/dist/ui/index.html +2 -2
  73. package/package.json +2 -1
  74. package/skills/stageflow-author/SKILL.md +4 -4
  75. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
  76. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
  77. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
  78. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
  79. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
  80. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
  81. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
  82. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
  83. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
  84. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
  85. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
  86. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
  87. package/skills/stageflow-author/references/catalog-mapping.md +159 -50
  88. package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
  89. package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
  90. package/skills/stageflow-session-capture/SKILL.md +2 -2
  91. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
  92. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
  93. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
  94. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
  95. package/dist/envelope/cloneForks.d.ts +0 -2
  96. package/dist/envelope/cloneForks.js +0 -77
package/README.md CHANGED
@@ -31,13 +31,12 @@ The same pipeline runs three ways without rewriting anything:
31
31
  - **Path-based CLI** — `--pipeline` and `--task` take filesystem paths (no bare-id lookup)
32
32
  - **Pi-native** — runs on `@earendil-works/pi-coding-agent`; reuse an existing Pi login (`pi_home`) or store credentials in `~/.stageflow/agent/auth.json` (`sf_owned`)
33
33
  - **Envelope handoffs** — typed stage payloads and artifacts via `write_stage_artifact` / `emit_stage_envelope`
34
- - **Verified stage execution** — pipeline-owned completion checks with durable evidence, automatic repair, and manual recovery ([guide](docs/verified-stage-execution.md))
34
+ - **Verified stage execution** — after-phase `verify` checks with durable evidence, plus `on_verify_fail` repair or manual operator recovery ([guide](docs/verified-stage-execution.md))
35
35
  - **HITL gates** — operator questions in the console; CI exits `2` when a run is waiting
36
36
  - **Operator console** — triage runs, connect providers, answer gates, inspect transcripts at `http://127.0.0.1:3847`
37
37
  - **MCP endpoint** — Streamable HTTP at `/mcp` when `sf ui` or `sf mcp` is running
38
38
  - **CI / headless** — `sf validate --strict --json`, `sf run --json` with exit codes `0` / `1` / `2`
39
- - **Parallel stages** — pipeline DAG with fan-out and join (see [YAML catalog](docs/yaml-catalog.md))
40
- - **Clonable fan-out** — clone one successor N times at completion, then join (see [YAML catalog](docs/yaml-catalog.md#clonable-successors))
39
+ - **Parallel stages** — pipeline DAG with fan-out, join, and Clone Chains (one Clone Instance per Clone Array element; see [YAML catalog](docs/yaml-catalog.md#clone-chain))
41
40
  - **SQLite run store** — `<git-root>/.stageflow/` state plus per-run workspaces under `.stageflow/runs/`
42
41
 
43
42
  ## Architecture at a glance
@@ -219,7 +218,6 @@ If you want deterministic YAML routing across many agents, look at [Conductor](h
219
218
  | [hello-world](examples/hello-world/) | Single stage, domain-neutral |
220
219
  | [plan-review](examples/plan-review/) | Multi-stage with operator gate — SDLC-style **example** |
221
220
  | [conditional-fork](examples/conditional-fork/) | Exclusive fork routing with operator branch choice |
222
- | [clonable-fanout](examples/clonable-fanout/) | Clone one successor N times, then join |
223
221
  | [stage-mcp](examples/stage-mcp/) | Stage MCP via project `.mcp.json` and a local echo fixture |
224
222
  | [playwright-mcp](examples/playwright-mcp/) | One stage: open a page and save a PNG screenshot (Playwright MCP) |
225
223
  | [context7-mcp](examples/context7-mcp/) | Three stages: resolve a library, fetch docs, write a brief (Context7 MCP) |
@@ -45,10 +45,8 @@ export type AskOperatorCapture = {
45
45
  * nothing else catches one drifting from the other.
46
46
  *
47
47
  * Field presence mirrors the typebox schema's own conditionality:
48
- * `clone_forks` only exists when a stage can clone-fork at all, and
49
48
  * `fork_choice` is required (not just present) when the stage has a fork
50
- * to route. Building this statically would silently offer the model a
51
- * `clone_forks` field on stages that have nowhere to route one.
49
+ * to route.
52
50
  */
53
51
  export declare function buildEmitStageEnvelopeShape(options: {
54
52
  forkEmitContext?: ForkEmitContext;
@@ -62,7 +60,6 @@ export declare function buildEmitStageEnvelopeShape(options: {
62
60
  artifacts: z.ZodArray<z.ZodString>;
63
61
  payload: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
64
62
  fork_choice: z.ZodArray<z.ZodString> | z.ZodOptional<z.ZodArray<z.ZodString>>;
65
- clone_forks?: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>> | undefined;
66
63
  checklist_attestations: z.ZodOptional<z.ZodArray<z.ZodObject<{
67
64
  check_id: z.ZodString;
68
65
  items: z.ZodArray<z.ZodString>;
@@ -40,10 +40,8 @@ export const ASK_OPERATOR_TOOL_NAME = `mcp__${STAGEFLOW_MCP_SERVER_NAME}__ask_op
40
40
  * nothing else catches one drifting from the other.
41
41
  *
42
42
  * Field presence mirrors the typebox schema's own conditionality:
43
- * `clone_forks` only exists when a stage can clone-fork at all, and
44
43
  * `fork_choice` is required (not just present) when the stage has a fork
45
- * to route. Building this statically would silently offer the model a
46
- * `clone_forks` field on stages that have nowhere to route one.
44
+ * to route.
47
45
  */
48
46
  export function buildEmitStageEnvelopeShape(options) {
49
47
  return {
@@ -54,9 +52,6 @@ export function buildEmitStageEnvelopeShape(options) {
54
52
  fork_choice: options.forkEmitContext !== undefined
55
53
  ? z.array(z.string())
56
54
  : z.array(z.string()).optional(),
57
- ...(options.cloneEmitContext !== undefined
58
- ? { clone_forks: z.array(z.record(z.string(), z.unknown())) }
59
- : {}),
60
55
  checklist_attestations: z
61
56
  .array(z.object({
62
57
  check_id: z.string().min(1),
@@ -3,7 +3,6 @@ import path from "node:path";
3
3
  import { assertRequiredEnvelope, isAdvancingEnvelope, } from "../envelope/check.js";
4
4
  import { assertFeedbackLoopAction } from "../envelope/feedbackLoop.js";
5
5
  import { assertEnvelopePayload } from "../envelope/payloadSchema.js";
6
- import { assertCloneForks } from "../envelope/cloneForks.js";
7
6
  import { assertForkEnvelope } from "../envelope/forkChoice.js";
8
7
  import { assertAnswerMatchesPrompt, parseAskOperatorAnswer, } from "../tools/askOperator.js";
9
8
  import { tryParsePendingPrompt } from "../runtime/stageHitl.js";
@@ -154,15 +153,15 @@ export class FakeAgent {
154
153
  ? behavior.envelope
155
154
  : undefined;
156
155
  try {
157
- const envelope = assertRequiredEnvelope(envelopeValue);
156
+ let envelope = assertRequiredEnvelope(envelopeValue);
157
+ if (envelope.status === "success" && envelope.payload === undefined) {
158
+ envelope = { ...envelope, payload: {} };
159
+ }
158
160
  assertFeedbackLoopAction(envelope, input.feedbackLoopEmitContext);
159
161
  const isFeedbackSendBack = envelope.feedback_loop?.action === "send_back";
160
162
  if (!isFeedbackSendBack && input.forkEmitContext !== undefined) {
161
163
  assertForkEnvelope(envelope, input.forkEmitContext);
162
164
  }
163
- if (!isFeedbackSendBack && input.cloneEmitContext !== undefined) {
164
- assertCloneForks(envelope, input.cloneEmitContext);
165
- }
166
165
  assertEnvelopePayload(envelope, input.stage.payload_schema);
167
166
  if (!isAdvancingEnvelope(envelope)) {
168
167
  input.onActivity?.({ event: "agent_end" });
@@ -404,23 +404,10 @@ function buildUserPrompt(input, emitToolName, emitHintOverride, artifactToolName
404
404
  emitHint += `\nFailed-check evidence from attempt ${input.repairContext.prior_attempt}:\n${JSON.stringify(input.repairContext.failed_checks, null, 2)}`;
405
405
  }
406
406
  }
407
- if (input.cloneEmitContext !== undefined) {
408
- const allowedActions = input.cloneEmitContext.allowedActions ?? ["skip", "once", "fanout"];
409
- const successorLines = input.cloneEmitContext.clonableSuccessors.map((successor) => {
410
- const header = `- ${successor.successorId} (clone_cap: ${successor.cloneCap})`;
411
- if (successor.cloneInputSchema === undefined) {
412
- return header;
413
- }
414
- return `${header}\n Assignment schema (io.input.schema):\n${JSON.stringify(successor.cloneInputSchema, null, 2)}`;
415
- });
416
- emitHint += `\nClonable successors — emit clone_forks covering each of these ids exactly once:\n${successorLines.join("\n")}`;
417
- emitHint += `\nEach once or fanout envelope is a full StageEnvelope — it requires status ("success" or "failure"), summary (non-empty string), and artifacts (array of strings). io.input.schema fields belong in envelope.payload, not at the top level of the clone_forks item.`;
418
- emitHint += `\nAllowed clone actions: ${allowedActions.join(", ")}`;
419
- }
420
407
  if (input.feedbackLoopEmitContext !== undefined) {
421
408
  emitHint += `\nFeedback-loop decision: this stage is a feedback-loop source. On status=success, feedback_loop is required. Emit either {"action":"continue"} to proceed downstream or {"action":"send_back","target":"…"} to request another pass.`;
422
409
  emitHint += `\nAllowed send_back target: ${input.feedbackLoopEmitContext.target}.`;
423
- emitHint += `\nA send_back decision cannot be combined with fork_choice or clone_forks.`;
410
+ emitHint += `\nA send_back decision cannot be combined with fork_choice.`;
424
411
  }
425
412
  const attempt = input.roots.attempt ?? 1;
426
413
  const attemptArtifactsPath = `stages/${runtimeStageId(input)}/attempts/${attempt}/artifacts/`;
@@ -0,0 +1,12 @@
1
+ import type { PipelineStageRef, ResolvedPipelineDag } from "../types/pipeline.js";
2
+ import type { StageConfig } from "../types/stage.js";
3
+ import { type LoadOutcome } from "./loadOutcome.js";
4
+ export type DetectedCloneChain = {
5
+ emitterId: string;
6
+ cloneChildId: string;
7
+ joinId: string;
8
+ arrayField: string;
9
+ ref: string;
10
+ };
11
+ export declare function detectCloneChains(stages: StageConfig[], refs: PipelineStageRef[], pipelineId: string): LoadOutcome<DetectedCloneChain[]>;
12
+ export declare function applyCloneChains(stages: StageConfig[], refs: PipelineStageRef[], dag: ResolvedPipelineDag, pipelineId: string): LoadOutcome<void>;
@@ -0,0 +1,322 @@
1
+ import { loadFailure, loadSuccess } from "./loadOutcome.js";
2
+ import { toRouteEdges, toRouteLoopEntries } from "./pipelineRoute.js";
3
+ function isPlainObject(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function namedSchemaRef(schema) {
7
+ if (!isPlainObject(schema))
8
+ return undefined;
9
+ const keys = Object.keys(schema);
10
+ if (keys.length !== 1 || keys[0] !== "$ref")
11
+ return undefined;
12
+ const ref = schema.$ref;
13
+ if (typeof ref !== "string" || !ref.startsWith("#/schemas/"))
14
+ return undefined;
15
+ const name = ref.slice("#/schemas/".length);
16
+ if (!name || name.includes("/"))
17
+ return undefined;
18
+ return ref;
19
+ }
20
+ function inspectOutput(schema) {
21
+ const empty = {
22
+ namedRefArrays: [],
23
+ nonRefArrayFields: [],
24
+ isRootArray: false,
25
+ rootArrayRef: undefined,
26
+ };
27
+ if (!isPlainObject(schema))
28
+ return empty;
29
+ if (schema.type === "array") {
30
+ const ref = namedSchemaRef(schema.items);
31
+ return {
32
+ namedRefArrays: [],
33
+ nonRefArrayFields: ref === undefined ? ["<root>"] : [],
34
+ isRootArray: true,
35
+ rootArrayRef: ref,
36
+ };
37
+ }
38
+ if (schema.type !== "object" || !isPlainObject(schema.properties))
39
+ return empty;
40
+ const namedRefArrays = [];
41
+ const nonRefArrayFields = [];
42
+ for (const [field, prop] of Object.entries(schema.properties)) {
43
+ if (!isPlainObject(prop) || prop.type !== "array")
44
+ continue;
45
+ const ref = namedSchemaRef(prop.items);
46
+ if (ref !== undefined)
47
+ namedRefArrays.push({ field, ref });
48
+ else
49
+ nonRefArrayFields.push(field);
50
+ }
51
+ return {
52
+ namedRefArrays,
53
+ nonRefArrayFields,
54
+ isRootArray: false,
55
+ rootArrayRef: undefined,
56
+ };
57
+ }
58
+ function inboundSources(refs, targetId) {
59
+ return refs.filter((ref) => toRouteEdges(ref.route).some((edge) => edge.to === targetId));
60
+ }
61
+ function isSealedThreeStagePath(refs, stageById, refById, emitter, childId) {
62
+ const edges = toRouteEdges(emitter.route);
63
+ if (edges.length !== 1)
64
+ return false;
65
+ const childEdge = edges[0];
66
+ if (childEdge.to !== childId || childEdge.if !== undefined)
67
+ return false;
68
+ const childStage = stageById.get(childId);
69
+ const childRef = refById.get(childId);
70
+ if (!childStage || !childRef)
71
+ return false;
72
+ if (inboundSources(refs, childId).length !== 1)
73
+ return false;
74
+ const childEdges = toRouteEdges(childRef.route);
75
+ if (childEdges.length !== 1 || childEdges[0].if !== undefined)
76
+ return false;
77
+ const joinId = childEdges[0].to;
78
+ if (!stageById.has(joinId))
79
+ return false;
80
+ if (inboundSources(refs, joinId).length !== 1)
81
+ return false;
82
+ return true;
83
+ }
84
+ function dagError(pipelineId, message, stageId) {
85
+ return {
86
+ code: "pipeline.dag_error",
87
+ message: `Pipeline ${pipelineId}: ${message}`,
88
+ category: "pipeline",
89
+ pipelineId,
90
+ ...(stageId !== undefined ? { stageId } : {}),
91
+ };
92
+ }
93
+ function fail(pipelineId, message, stageId) {
94
+ return loadFailure([dagError(pipelineId, message, stageId)]);
95
+ }
96
+ function childOutputIsCloneArray(schema) {
97
+ const inspected = inspectOutput(schema);
98
+ return inspected.namedRefArrays.length > 0 || inspected.rootArrayRef !== undefined;
99
+ }
100
+ export function detectCloneChains(stages, refs, pipelineId) {
101
+ const stageById = new Map(stages.map((stage) => [stage.id, stage]));
102
+ const refById = new Map(refs.map((ref) => [ref.id, ref]));
103
+ const chains = [];
104
+ for (const emitter of refs) {
105
+ const emitterStage = stageById.get(emitter.id);
106
+ if (!emitterStage)
107
+ continue;
108
+ const edges = toRouteEdges(emitter.route);
109
+ const output = inspectOutput(emitterStage.payload_schema);
110
+ if (output.namedRefArrays.length >= 2 && edges.length > 0) {
111
+ return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter output must have exactly one named $ref Clone Array`, emitter.id);
112
+ }
113
+ if (output.isRootArray && edges.length > 0) {
114
+ const looksLikeChain = edges.some((edge) => {
115
+ const childStage = stageById.get(edge.to);
116
+ if (!childStage)
117
+ return false;
118
+ if (namedSchemaRef(childStage.clone_input_schema) === undefined)
119
+ return false;
120
+ return isSealedThreeStagePath(refs, stageById, refById, emitter, edge.to);
121
+ });
122
+ if (looksLikeChain) {
123
+ return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter output is a root-level array, which is not a Clone Array`, emitter.id);
124
+ }
125
+ }
126
+ if (output.namedRefArrays.length === 0 &&
127
+ output.nonRefArrayFields.length > 0 &&
128
+ edges.length > 0) {
129
+ for (const edge of edges) {
130
+ const childStage = stageById.get(edge.to);
131
+ const childRef = refById.get(edge.to);
132
+ if (!childStage || !childRef)
133
+ continue;
134
+ if (namedSchemaRef(childStage.clone_input_schema) === undefined)
135
+ continue;
136
+ if (toRouteEdges(childRef.route).length === 0)
137
+ continue;
138
+ return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter Clone Array items must be a named $ref`, emitter.id);
139
+ }
140
+ }
141
+ if (output.namedRefArrays.length !== 1 || edges.length === 0)
142
+ continue;
143
+ const array = output.namedRefArrays[0];
144
+ const childTakesNamedRef = edges.some((edge) => {
145
+ const childStage = stageById.get(edge.to);
146
+ return (childStage !== undefined &&
147
+ namedSchemaRef(childStage.clone_input_schema) !== undefined);
148
+ });
149
+ const hasClonePolicy = emitter.clone_cap !== undefined || emitter.clone_mode !== undefined;
150
+ const sealedLookalike = edges.some((edge) => isSealedThreeStagePath(refs, stageById, refById, emitter, edge.to));
151
+ if (!childTakesNamedRef && !hasClonePolicy && !sealedLookalike) {
152
+ continue;
153
+ }
154
+ const matchingChildIds = edges
155
+ .filter((edge) => {
156
+ const childStage = stageById.get(edge.to);
157
+ return (childStage !== undefined &&
158
+ namedSchemaRef(childStage.clone_input_schema) === array.ref);
159
+ })
160
+ .map((edge) => edge.to);
161
+ const uniqueMatching = [...new Set(matchingChildIds)];
162
+ if (edges.length !== 1) {
163
+ if (uniqueMatching.length >= 2) {
164
+ return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter must have exactly one clone child`, emitter.id);
165
+ }
166
+ return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter must have exactly one forward Route (the clone child)`, emitter.id);
167
+ }
168
+ const childEdge = edges[0];
169
+ const cloneChildId = childEdge.to;
170
+ if (childEdge.if !== undefined) {
171
+ return fail(pipelineId, `stage "${emitter.id}": Clone Chain inbound edge to the clone child must not have if`, emitter.id);
172
+ }
173
+ const childStage = stageById.get(cloneChildId);
174
+ const childRef = refById.get(cloneChildId);
175
+ if (!childStage || !childRef) {
176
+ return fail(pipelineId, `stage "${emitter.id}": Clone Chain emitter must have exactly one forward Route (the clone child)`, emitter.id);
177
+ }
178
+ if (namedSchemaRef(childStage.clone_input_schema) !== array.ref) {
179
+ return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child input must be exactly the named $ref of the emitter Clone Array`, cloneChildId);
180
+ }
181
+ if (inboundSources(refs, cloneChildId).length !== 1) {
182
+ return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child must have exactly one parent (the emitter)`, cloneChildId);
183
+ }
184
+ const childEdges = toRouteEdges(childRef.route);
185
+ if (childEdges.length !== 1) {
186
+ return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child must have exactly one forward Route (the Join)`, cloneChildId);
187
+ }
188
+ if (childEdges[0].if !== undefined) {
189
+ return fail(pipelineId, `stage "${cloneChildId}": Clone Chain inbound edge to the Join must not have if`, cloneChildId);
190
+ }
191
+ const joinId = childEdges[0].to;
192
+ if (!stageById.has(joinId)) {
193
+ return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child must have exactly one forward Route (the Join)`, cloneChildId);
194
+ }
195
+ if (inboundSources(refs, joinId).length !== 1) {
196
+ return fail(pipelineId, `stage "${joinId}": Clone Chain Join must have exactly one parent (the clone child)`, joinId);
197
+ }
198
+ if (childOutputIsCloneArray(childStage.payload_schema)) {
199
+ return fail(pipelineId, `stage "${cloneChildId}": Clone Chain clone child output must not be a Clone Array`, cloneChildId);
200
+ }
201
+ chains.push({
202
+ emitterId: emitter.id,
203
+ cloneChildId,
204
+ joinId,
205
+ arrayField: array.field,
206
+ ref: array.ref,
207
+ });
208
+ }
209
+ const emitterIds = new Set();
210
+ const cloneChildIds = new Set();
211
+ const joinIds = new Set();
212
+ for (const chain of chains) {
213
+ if (emitterIds.has(chain.emitterId)) {
214
+ return fail(pipelineId, `stage "${chain.emitterId}": Clone Chain emitter is shared with another Clone Chain`, chain.emitterId);
215
+ }
216
+ if (cloneChildIds.has(chain.cloneChildId)) {
217
+ return fail(pipelineId, `stage "${chain.cloneChildId}": Clone Chain clone child is shared with another Clone Chain`, chain.cloneChildId);
218
+ }
219
+ if (joinIds.has(chain.joinId)) {
220
+ return fail(pipelineId, `stage "${chain.joinId}": Clone Chain Join is shared with another Clone Chain`, chain.joinId);
221
+ }
222
+ emitterIds.add(chain.emitterId);
223
+ cloneChildIds.add(chain.cloneChildId);
224
+ joinIds.add(chain.joinId);
225
+ }
226
+ return loadSuccess(chains);
227
+ }
228
+ function validateCloneChainLoops(refs, chains, pipelineId) {
229
+ if (chains.length === 0)
230
+ return loadSuccess(undefined);
231
+ const emitterIds = new Set(chains.map((chain) => chain.emitterId));
232
+ const cloneChildIds = new Set(chains.map((chain) => chain.cloneChildId));
233
+ const forbiddenTargets = new Map();
234
+ for (const chain of chains) {
235
+ forbiddenTargets.set(chain.emitterId, "emitter");
236
+ forbiddenTargets.set(chain.cloneChildId, "clone child");
237
+ forbiddenTargets.set(chain.joinId, "Join");
238
+ }
239
+ for (const ref of refs) {
240
+ const loops = toRouteLoopEntries(ref.route);
241
+ if (loops.length === 0)
242
+ continue;
243
+ if (emitterIds.has(ref.id)) {
244
+ return loadFailure([
245
+ dagError(pipelineId, `stage "${ref.id}": Clone Chain emitter cannot originate a Loop`, ref.id),
246
+ ]);
247
+ }
248
+ if (cloneChildIds.has(ref.id)) {
249
+ return loadFailure([
250
+ dagError(pipelineId, `stage "${ref.id}": Clone Chain clone child cannot originate a Loop`, ref.id),
251
+ ]);
252
+ }
253
+ for (const loop of loops) {
254
+ const role = forbiddenTargets.get(loop.to);
255
+ if (role === undefined)
256
+ continue;
257
+ return loadFailure([
258
+ dagError(pipelineId, `stage "${ref.id}": Loop target "${loop.to}" is a Clone Chain ${role}`, ref.id),
259
+ ]);
260
+ }
261
+ }
262
+ return loadSuccess(undefined);
263
+ }
264
+ function applyCloneCapToSchema(schema, field, cap) {
265
+ if (!isPlainObject(schema) || !isPlainObject(schema.properties))
266
+ return;
267
+ const prop = schema.properties[field];
268
+ if (!isPlainObject(prop))
269
+ return;
270
+ prop.minItems = 1;
271
+ prop.maxItems = cap;
272
+ }
273
+ export function applyCloneChains(stages, refs, dag, pipelineId) {
274
+ const detected = detectCloneChains(stages, refs, pipelineId);
275
+ if (!detected.ok)
276
+ return detected;
277
+ const chains = detected.value;
278
+ const emitterIds = new Set(chains.map((chain) => chain.emitterId));
279
+ const refById = new Map(refs.map((ref) => [ref.id, ref]));
280
+ const nodeById = new Map(dag.nodes.map((node) => [node.id, node]));
281
+ for (const ref of refs) {
282
+ const isEmitter = emitterIds.has(ref.id);
283
+ if (isEmitter) {
284
+ if (ref.clone_cap === undefined || ref.clone_mode === undefined) {
285
+ return loadFailure([
286
+ dagError(pipelineId, `stage "${ref.id}": Clone Chain emitter requires clone_cap and clone_mode`, ref.id),
287
+ ]);
288
+ }
289
+ continue;
290
+ }
291
+ if (ref.clone_cap !== undefined) {
292
+ return loadFailure([
293
+ dagError(pipelineId, `stage "${ref.id}": "clone_cap" is no longer supported — use a Clone Chain instead`, ref.id),
294
+ ]);
295
+ }
296
+ if (ref.clone_mode !== undefined) {
297
+ return loadFailure([
298
+ dagError(pipelineId, `stage "${ref.id}": "clone_mode" is only valid on a Clone Chain emitter`, ref.id),
299
+ ]);
300
+ }
301
+ }
302
+ const loopsOutcome = validateCloneChainLoops(refs, chains, pipelineId);
303
+ if (!loopsOutcome.ok)
304
+ return loopsOutcome;
305
+ const stageById = new Map(stages.map((stage) => [stage.id, stage]));
306
+ for (const chain of chains) {
307
+ const ref = refById.get(chain.emitterId);
308
+ const cap = ref.clone_cap;
309
+ const mode = ref.clone_mode;
310
+ const node = nodeById.get(chain.emitterId);
311
+ if (node) {
312
+ node.clone_cap = cap;
313
+ node.clone_mode = mode;
314
+ node.clone_array_field = chain.arrayField;
315
+ }
316
+ const emitterStage = stageById.get(chain.emitterId);
317
+ if (emitterStage?.payload_schema !== undefined) {
318
+ applyCloneCapToSchema(emitterStage.payload_schema, chain.arrayField, cap);
319
+ }
320
+ }
321
+ return loadSuccess(undefined);
322
+ }
@@ -1,6 +1,6 @@
1
1
  import { access, mkdir, readdir, unlink, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { parsePipelineNeeds } from "./pipelineNeeds.js";
3
+ import { invertPredecessorEdgesToRoute, invertRouteToPredecessorEdges, parsePipelineNeeds, toPipelineNeeds, } from "./pipelineNeeds.js";
4
4
  import { STAGE_ID_PATTERN } from "./createStage.js";
5
5
  import { inferIdFromUsesPath, normalizePipelineStageEntries, toWiringRefs, } from "./normalizePipelineStageEntry.js";
6
6
  import { parseModelField } from "./modelField.js";
@@ -29,10 +29,12 @@ export function normalizeCreatePipelineStages(stages) {
29
29
  uses: stage.uses ?? (stage.inline ? undefined : defaultUsesPath(stage.id)),
30
30
  }));
31
31
  }
32
- function stageRefToRaw(ref) {
32
+ function stageRefToRaw(ref, routeInfo) {
33
33
  const raw = { id: ref.id };
34
- if (ref.needs !== undefined)
35
- raw.needs = ref.needs;
34
+ if (routeInfo?.entry)
35
+ raw.entry = true;
36
+ if (routeInfo?.route && routeInfo.route.length > 0)
37
+ raw.route = routeInfo.route;
36
38
  if (ref.uses !== undefined)
37
39
  raw.uses = ref.uses;
38
40
  if (ref.inline) {
@@ -47,16 +49,20 @@ function stageRefToRaw(ref) {
47
49
  return raw;
48
50
  }
49
51
  function toAuthoringRawEntries(stages, declaringPath) {
52
+ const routeById = invertPredecessorEdgesToRoute(stages);
50
53
  return stages.map((stage) => ({
51
- raw: stageRefToRaw(stage),
54
+ raw: stageRefToRaw(stage, routeById.get(stage.id)),
52
55
  declaringPath,
53
56
  }));
54
57
  }
55
58
  function normalizedToCreateRefs(entries) {
59
+ const inbound = invertRouteToPredecessorEdges(entries);
56
60
  return entries.map((entry) => {
57
61
  const ref = { id: entry.id };
58
- if (entry.needs !== undefined)
59
- ref.needs = entry.needs;
62
+ const edges = inbound.get(entry.id);
63
+ const needs = edges !== undefined ? toPipelineNeeds(edges) : undefined;
64
+ if (needs !== undefined)
65
+ ref.needs = needs;
60
66
  if (entry.body.kind === "uses") {
61
67
  ref.uses = entry.body.path;
62
68
  }
@@ -236,21 +242,86 @@ export function parseCreatePipelineBody(body) {
236
242
  stages: refs,
237
243
  };
238
244
  }
239
- function appendNeedsYaml(lines, needs) {
240
- if (typeof needs === "string") {
241
- lines.push(` needs: ${needs}`);
245
+ function formatRouteIfValue(value) {
246
+ if (typeof value === "string")
247
+ return JSON.stringify(value);
248
+ if (typeof value === "number" || typeof value === "boolean")
249
+ return String(value);
250
+ if (value === null)
251
+ return "null";
252
+ if (Array.isArray(value)) {
253
+ return `[${value.map((item) => formatRouteIfValue(item)).join(", ")}]`;
254
+ }
255
+ return JSON.stringify(value);
256
+ }
257
+ function appendRouteIfYamlListItem(lines, indent, predicate) {
258
+ if ("all" in predicate) {
259
+ lines.push(`${indent}- all:`);
260
+ for (const child of predicate.all) {
261
+ appendRouteIfYamlListItem(lines, `${indent} `, child);
262
+ }
242
263
  return;
243
264
  }
244
- lines.push(" needs:");
245
- for (const edge of needs) {
246
- if (edge.on.length === 1 && edge.on[0] === "succeeded") {
247
- lines.push(` - ${edge.id}`);
248
- continue;
265
+ if ("any" in predicate) {
266
+ lines.push(`${indent}- any:`);
267
+ for (const child of predicate.any) {
268
+ appendRouteIfYamlListItem(lines, `${indent} `, child);
269
+ }
270
+ return;
271
+ }
272
+ if ("not" in predicate) {
273
+ lines.push(`${indent}- not:`);
274
+ appendRouteIfYaml(lines, `${indent} `, predicate.not);
275
+ return;
276
+ }
277
+ lines.push(`${indent}- field: ${formatInlineYamlScalar(predicate.field)}`);
278
+ lines.push(`${indent} op: ${predicate.op}`);
279
+ lines.push(`${indent} value: ${formatRouteIfValue(predicate.value)}`);
280
+ }
281
+ function appendRouteIfYaml(lines, indent, predicate) {
282
+ if ("all" in predicate) {
283
+ lines.push(`${indent}all:`);
284
+ for (const child of predicate.all) {
285
+ appendRouteIfYamlListItem(lines, `${indent} `, child);
286
+ }
287
+ return;
288
+ }
289
+ if ("any" in predicate) {
290
+ lines.push(`${indent}any:`);
291
+ for (const child of predicate.any) {
292
+ appendRouteIfYamlListItem(lines, `${indent} `, child);
293
+ }
294
+ return;
295
+ }
296
+ if ("not" in predicate) {
297
+ lines.push(`${indent}not:`);
298
+ appendRouteIfYaml(lines, `${indent} `, predicate.not);
299
+ return;
300
+ }
301
+ lines.push(`${indent}field: ${formatInlineYamlScalar(predicate.field)}`);
302
+ lines.push(`${indent}op: ${predicate.op}`);
303
+ lines.push(`${indent}value: ${formatRouteIfValue(predicate.value)}`);
304
+ }
305
+ function appendRouteYaml(lines, routeInfo) {
306
+ if (routeInfo.entry) {
307
+ lines.push(" entry: true");
308
+ }
309
+ if (!routeInfo.route || routeInfo.route.length === 0) {
310
+ return;
311
+ }
312
+ lines.push(" route:");
313
+ for (const item of routeInfo.route) {
314
+ const on = item.on === undefined ? ["succeeded"] : Array.isArray(item.on) ? item.on : [item.on];
315
+ lines.push(` - to: ${item.to}`);
316
+ if (!(on.length === 1 && on[0] === "succeeded")) {
317
+ lines.push(" on:");
318
+ for (const state of on) {
319
+ lines.push(` - ${state}`);
320
+ }
249
321
  }
250
- lines.push(` - id: ${edge.id}`);
251
- lines.push(" on:");
252
- for (const state of edge.on) {
253
- lines.push(` - ${state}`);
322
+ if (item.if !== undefined) {
323
+ lines.push(" if:");
324
+ appendRouteIfYaml(lines, " ", item.if);
254
325
  }
255
326
  }
256
327
  }
@@ -266,6 +337,7 @@ function formatInlineYamlScalar(value) {
266
337
  return value;
267
338
  }
268
339
  export function pipelineConfigToYaml(pipeline, options) {
340
+ const routeById = invertPredecessorEdgesToRoute(pipeline.stages);
269
341
  const lines = [`id: ${pipeline.id}`, "stages:"];
270
342
  for (const stage of pipeline.stages) {
271
343
  if (options.format === "linear" && !stage.needs && !stage.inline && stage.uses) {
@@ -274,8 +346,9 @@ export function pipelineConfigToYaml(pipeline, options) {
274
346
  continue;
275
347
  }
276
348
  lines.push(` - id: ${stage.id}`);
277
- if (stage.needs) {
278
- appendNeedsYaml(lines, stage.needs);
349
+ const routeInfo = routeById.get(stage.id);
350
+ if (routeInfo) {
351
+ appendRouteYaml(lines, routeInfo);
279
352
  }
280
353
  if (stage.inline) {
281
354
  if (stage.inline.gate_kinds !== undefined) {
@@ -301,6 +374,13 @@ export function pipelineConfigToYaml(pipeline, options) {
301
374
  if (stage.inline.model !== undefined && stage.inline.model.length > 0) {
302
375
  lines.push(` model: ${formatInlineYamlScalar(stage.inline.model)}`);
303
376
  }
377
+ lines.push(" io:");
378
+ lines.push(" input:");
379
+ lines.push(" schema:");
380
+ lines.push(" type: object");
381
+ lines.push(" output:");
382
+ lines.push(" schema:");
383
+ lines.push(" type: object");
304
384
  }
305
385
  else if (stage.uses) {
306
386
  lines.push(` uses: ${stage.uses}`);
@@ -122,6 +122,13 @@ export function stageConfigToYaml(stage) {
122
122
  if (stage.model !== undefined && stage.model.length > 0) {
123
123
  lines.push(`model: ${formatInlineYamlScalar(stage.model)}`);
124
124
  }
125
+ lines.push("io:");
126
+ lines.push(" input:");
127
+ lines.push(" schema:");
128
+ lines.push(" type: object");
129
+ lines.push(" output:");
130
+ lines.push(" schema:");
131
+ lines.push(" type: object");
125
132
  lines.push("");
126
133
  return lines.join("\n");
127
134
  }
@@ -5,6 +5,7 @@ export type LoadIssue = {
5
5
  message: string;
6
6
  category: "pipeline";
7
7
  pipelineId?: string;
8
+ stageId?: string;
8
9
  } | {
9
10
  code: ValidationFindingCode;
10
11
  message: string;