stageflow 0.15.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 (114) hide show
  1. package/README.md +2 -4
  2. package/dist/agent/activity.d.ts +3 -0
  3. package/dist/agent/claudeAdapter.js +84 -7
  4. package/dist/agent/claudeSession.d.ts +3 -0
  5. package/dist/agent/claudeTools.d.ts +1 -4
  6. package/dist/agent/claudeTools.js +1 -6
  7. package/dist/agent/fakeAgent.js +4 -5
  8. package/dist/agent/piAdapter.d.ts +2 -0
  9. package/dist/agent/piAdapter.js +30 -19
  10. package/dist/agent/port.d.ts +3 -0
  11. package/dist/cli/runOutput.js +32 -8
  12. package/dist/config/cloneChain.d.ts +12 -0
  13. package/dist/config/cloneChain.js +322 -0
  14. package/dist/config/createPipeline.js +101 -21
  15. package/dist/config/createStage.js +7 -0
  16. package/dist/config/loadOutcome.d.ts +1 -0
  17. package/dist/config/loadPipeline.d.ts +2 -0
  18. package/dist/config/loadPipeline.js +34 -22
  19. package/dist/config/loadStage.d.ts +2 -0
  20. package/dist/config/loadStage.js +135 -125
  21. package/dist/config/migrateYaml.js +14 -3
  22. package/dist/config/normalizePipelineStageEntry.d.ts +5 -9
  23. package/dist/config/normalizePipelineStageEntry.js +133 -60
  24. package/dist/config/parsePreEmitChecks.js +2 -2
  25. package/dist/config/pipelineNeeds.d.ts +15 -2
  26. package/dist/config/pipelineNeeds.js +86 -4
  27. package/dist/config/pipelineRoute.d.ts +46 -0
  28. package/dist/config/pipelineRoute.js +215 -0
  29. package/dist/config/pipelineStageKeys.d.ts +2 -0
  30. package/dist/config/pipelineStageKeys.js +15 -4
  31. package/dist/config/printTargetYaml.js +18 -12
  32. package/dist/config/resolveForkEmitContext.d.ts +1 -1
  33. package/dist/config/resolveForkEmitContext.js +3 -26
  34. package/dist/config/resolvePipelineDag.d.ts +1 -3
  35. package/dist/config/resolvePipelineDag.js +143 -186
  36. package/dist/config/routeIf.d.ts +24 -0
  37. package/dist/config/routeIf.js +247 -0
  38. package/dist/config/validateCatalog.d.ts +1 -1
  39. package/dist/config/validateCatalog.js +5 -3
  40. package/dist/config/yamlDialect.d.ts +5 -8
  41. package/dist/config/yamlDialect.js +37 -29
  42. package/dist/envelope/check.d.ts +0 -2
  43. package/dist/envelope/check.js +1 -57
  44. package/dist/envelope/feedbackLoop.js +0 -3
  45. package/dist/envelope/forkChoice.js +1 -1
  46. package/dist/envelope/payloadSchema.d.ts +14 -0
  47. package/dist/envelope/payloadSchema.js +87 -2
  48. package/dist/mcp/catalogTools.js +1 -2
  49. package/dist/package-meta.d.ts +1 -1
  50. package/dist/package-meta.js +1 -1
  51. package/dist/projection/projectRun.d.ts +0 -2
  52. package/dist/projection/projectRun.js +0 -3
  53. package/dist/prompt/priorEnvelope.js +1 -1
  54. package/dist/runstore/port.d.ts +10 -0
  55. package/dist/runstore/runProjection.js +9 -0
  56. package/dist/runstore/sqlite/SqliteRunStore.js +27 -4
  57. package/dist/runstore/sqlite/schema.d.ts +1 -1
  58. package/dist/runstore/sqlite/schema.js +2 -0
  59. package/dist/runstore/stageExecution.d.ts +3 -0
  60. package/dist/runstore/stageExecution.js +6 -2
  61. package/dist/runstore/stageInstanceId.d.ts +1 -0
  62. package/dist/runstore/stageInstanceId.js +9 -0
  63. package/dist/runstore/stageSnapshot.js +11 -0
  64. package/dist/runtime/cloneSchedule.d.ts +16 -33
  65. package/dist/runtime/cloneSchedule.js +155 -236
  66. package/dist/runtime/envelopeRouting.js +43 -94
  67. package/dist/runtime/forkGeneration.d.ts +2 -2
  68. package/dist/runtime/forkGeneration.js +29 -15
  69. package/dist/runtime/joinReadiness.d.ts +16 -0
  70. package/dist/runtime/joinReadiness.js +156 -0
  71. package/dist/runtime/pipelineScheduler.d.ts +0 -3
  72. package/dist/runtime/pipelineScheduler.js +81 -65
  73. package/dist/runtime/routeIfEval.d.ts +3 -0
  74. package/dist/runtime/routeIfEval.js +94 -0
  75. package/dist/runtime/runRetryCoordinator.js +0 -4
  76. package/dist/runtime/stageAttemptBootstrap.js +60 -11
  77. package/dist/runtime/stageRunner.js +2 -2
  78. package/dist/runtime/taskInput.js +1 -13
  79. package/dist/tools/emitStageEnvelope.d.ts +2 -3
  80. package/dist/tools/emitStageEnvelope.js +1 -65
  81. package/dist/types/envelope.d.ts +0 -2
  82. package/dist/types/forkChoice.d.ts +0 -16
  83. package/dist/types/pipeline.d.ts +86 -27
  84. package/dist/types/stage.d.ts +11 -7
  85. package/dist/types/usage.d.ts +16 -0
  86. package/dist/types/usage.js +24 -0
  87. package/dist/ui/assets/{index-uVHiae8s.css → index-DsgVjLUS.css} +1 -1
  88. package/dist/ui/assets/index-DwKGaQvQ.js +119 -0
  89. package/dist/ui/index.html +2 -2
  90. package/package.json +2 -1
  91. package/skills/stageflow-author/SKILL.md +4 -4
  92. package/skills/stageflow-author/assets/examples/branch-decision/hotfix.yaml +10 -2
  93. package/skills/stageflow-author/assets/examples/branch-decision/release-gate.pipeline.yaml +12 -4
  94. package/skills/stageflow-author/assets/examples/branch-decision/run-tests.yaml +6 -4
  95. package/skills/stageflow-author/assets/examples/branch-decision/ship.yaml +9 -1
  96. package/skills/stageflow-author/assets/examples/linear-review/draft.yaml +3 -0
  97. package/skills/stageflow-author/assets/examples/linear-review/publish.yaml +8 -0
  98. package/skills/stageflow-author/assets/examples/linear-review/review-loop.pipeline.yaml +5 -2
  99. package/skills/stageflow-author/assets/examples/linear-review/review.yaml +8 -0
  100. package/skills/stageflow-author/assets/examples/non-sdlc-digest/gather.yaml +3 -0
  101. package/skills/stageflow-author/assets/examples/non-sdlc-digest/research-digest.pipeline.yaml +5 -2
  102. package/skills/stageflow-author/assets/examples/non-sdlc-digest/send.yaml +8 -0
  103. package/skills/stageflow-author/assets/examples/non-sdlc-digest/summarize.yaml +8 -0
  104. package/skills/stageflow-author/references/catalog-mapping.md +159 -50
  105. package/skills/stageflow-author/references/catalog-write-conventions.md +3 -3
  106. package/skills/stageflow-author/references/stage-prompt-template.md +57 -17
  107. package/skills/stageflow-session-capture/SKILL.md +2 -2
  108. package/skills/stageflow-session-capture/assets/example-pipeline/example.pipeline.yaml +3 -1
  109. package/skills/stageflow-session-capture/assets/example-pipeline/implement.yaml +15 -0
  110. package/skills/stageflow-session-capture/assets/example-pipeline/research.yaml +3 -0
  111. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -2
  112. package/dist/envelope/cloneForks.d.ts +0 -2
  113. package/dist/envelope/cloneForks.js +0 -77
  114. package/dist/ui/assets/index-GtyiWiZg.js +0 -118
@@ -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;
@@ -6,8 +6,10 @@ export declare function resolvePipelinePath(pipelinePath: string, cwd: string):
6
6
  export declare function loadPipelineOutcome(nameOrPath: string, options?: {
7
7
  cwd?: string;
8
8
  projectRoot?: string;
9
+ requireIo?: boolean;
9
10
  }): Promise<LoadOutcome<LoadedPipeline>>;
10
11
  export declare function loadPipeline(nameOrPath: string, options?: {
11
12
  cwd?: string;
12
13
  projectRoot?: string;
14
+ requireIo?: boolean;
13
15
  }): Promise<LoadedPipeline>;
@@ -10,6 +10,8 @@ import { predecessorEdges } from "./pipelineNeeds.js";
10
10
  import { resolvePipelineDagFromRefs } from "./resolvePipelineDag.js";
11
11
  import { recoveryRequiresCompletionIssue } from "./parseCompletionContract.js";
12
12
  import { validateCompletionContractForStage } from "./validateCompletionContract.js";
13
+ import { applyCloneChains } from "./cloneChain.js";
14
+ import { collectRouteIfSchemaIssues, collectRouteIfIllegalCombos, collectRouteAllGatedWarnings, } from "./routeIf.js";
13
15
  function schemaCompileIssue(stageId, field, err, pipelineId) {
14
16
  const message = err instanceof Error ? err.message : String(err);
15
17
  if (err instanceof UnresolvedSchemaRefError) {
@@ -59,26 +61,23 @@ function checkSequentialIoCompatibility(stages, dag, pipelineId, schemas) {
59
61
  const node = nodeById.get(child.id);
60
62
  if (!node)
61
63
  continue;
62
- const parents = predecessorEdges(node);
63
- if (parents.length !== 1)
64
- continue;
65
- if (node.clonable)
66
- continue;
67
- const parentNode = nodeById.get(parents[0].id);
68
- if (parentNode?.clonable)
69
- continue;
70
- const parent = stageById.get(parents[0].id);
71
- if (!parent?.payload_schema || child.clone_input_schema === undefined)
72
- continue;
73
- if (!isPayloadSchemaSubset(child.clone_input_schema, parent.payload_schema, options)) {
74
- return loadFailure([
75
- {
76
- code: "pipeline.io_incompatible",
77
- message: `Pipeline ${pipelineId}: stage "${child.id}" io.input is not a structural subset of "${parent.id}" io.output`,
78
- category: "pipeline",
79
- pipelineId,
80
- },
81
- ]);
64
+ for (const parentEdge of predecessorEdges(node)) {
65
+ const parentNode = nodeById.get(parentEdge.id);
66
+ if (parentNode?.clone_array_field !== undefined)
67
+ continue;
68
+ const parent = stageById.get(parentEdge.id);
69
+ if (!parent?.payload_schema || child.clone_input_schema === undefined)
70
+ continue;
71
+ if (!isPayloadSchemaSubset(child.clone_input_schema, parent.payload_schema, options)) {
72
+ return loadFailure([
73
+ {
74
+ code: "pipeline.io_incompatible",
75
+ message: `Pipeline ${pipelineId}: stage "${child.id}" io.input is not a structural subset of "${parent.id}" io.output`,
76
+ category: "pipeline",
77
+ pipelineId,
78
+ },
79
+ ]);
80
+ }
82
81
  }
83
82
  }
84
83
  return loadSuccess(undefined);
@@ -93,7 +92,7 @@ export async function resolvePipelinePath(pipelinePath, cwd) {
93
92
  throw new Error(`Pipeline file not found: ${resolved}. Pass a filesystem path to --pipeline (e.g. pipelines/hello.pipeline.yaml), not a bare pipeline id.`);
94
93
  }
95
94
  }
96
- async function loadPipelineFromPath(pipelinePath, cwd, projectRoot = cwd) {
95
+ async function loadPipelineFromPath(pipelinePath, cwd, projectRoot = cwd, requireIo = true) {
97
96
  const normalizedPipelinePath = path.normalize(path.resolve(pipelinePath));
98
97
  const mergeOutcome = await mergePipelineStages(normalizedPipelinePath);
99
98
  if (!mergeOutcome.ok) {
@@ -147,6 +146,7 @@ async function loadPipelineFromPath(pipelinePath, cwd, projectRoot = cwd) {
147
146
  entryId: entry.id,
148
147
  declaringPath: entry.declaringPath,
149
148
  deferSchemaRefs: true,
149
+ requireIo,
150
150
  });
151
151
  if (!inlineOutcome.ok) {
152
152
  return loadFailure(inlineOutcome.issues);
@@ -157,6 +157,7 @@ async function loadPipelineFromPath(pipelinePath, cwd, projectRoot = cwd) {
157
157
  }
158
158
  const stageOutcome = await loadStageOutcome(entry.body.absolutePath, {
159
159
  deferSchemaRefs: true,
160
+ requireIo,
160
161
  });
161
162
  if (!stageOutcome.ok) {
162
163
  if (stageOutcome.issues.some((issue) => issue.code === "catalog.mixed_yaml_dialect")) {
@@ -206,9 +207,20 @@ async function loadPipelineFromPath(pipelinePath, cwd, projectRoot = cwd) {
206
207
  stages.push(stage);
207
208
  stageSources[stageId] = { kind: "file", path: entry.body.absolutePath };
208
209
  }
210
+ const cloneChainOutcome = applyCloneChains(stages, wiringRefs, dag, pipelineId);
211
+ if (!cloneChainOutcome.ok)
212
+ return cloneChainOutcome;
209
213
  const schemaOutcome = attachPipelineSchemas(stages, pipelineSchemas, pipelineId);
210
214
  if (!schemaOutcome.ok)
211
215
  return schemaOutcome;
216
+ const routeIfIssues = [
217
+ ...collectRouteIfSchemaIssues(stages, wiringRefs, pipelineId),
218
+ ...collectRouteIfIllegalCombos(wiringRefs, pipelineId),
219
+ ];
220
+ if (routeIfIssues.length > 0) {
221
+ return loadFailure(routeIfIssues);
222
+ }
223
+ warnings.push(...collectRouteAllGatedWarnings(wiringRefs, pipelineId));
212
224
  const ioOutcome = checkSequentialIoCompatibility(stages, dag, pipelineId, pipelineSchemas);
213
225
  if (!ioOutcome.ok)
214
226
  return ioOutcome;
@@ -283,7 +295,7 @@ export async function loadPipelineOutcome(nameOrPath, options = {}) {
283
295
  },
284
296
  ]);
285
297
  }
286
- return loadPipelineFromPath(pipelinePath, cwd, projectRoot);
298
+ return loadPipelineFromPath(pipelinePath, cwd, projectRoot, options.requireIo !== false);
287
299
  }
288
300
  export async function loadPipeline(nameOrPath, options = {}) {
289
301
  const outcome = await loadPipelineOutcome(nameOrPath, options);
@@ -6,11 +6,13 @@ export declare function afterCompletionForStage(stage: StageConfig): CompletionC
6
6
  export declare function parseStageMcp(raw: unknown, label: string, stageId?: string): LoadOutcome<string[] | undefined>;
7
7
  export type LoadStageOptions = {
8
8
  deferSchemaRefs?: boolean;
9
+ requireIo?: boolean;
9
10
  };
10
11
  export declare function loadStageFromObjectOutcome(raw: Record<string, unknown>, ctx: {
11
12
  entryId: string;
12
13
  declaringPath: string;
13
14
  deferSchemaRefs?: boolean;
15
+ requireIo?: boolean;
14
16
  }): LoadOutcome<StageConfig>;
15
17
  export declare function loadStageOutcome(filePath: string, options?: LoadStageOptions): Promise<LoadOutcome<StageConfig>>;
16
18
  export declare function loadStage(filePath: string): Promise<StageConfig>;