stageflow 0.8.0 → 0.12.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 (159) hide show
  1. package/README.md +24 -14
  2. package/dist/agent/activity.d.ts +9 -0
  3. package/dist/agent/agentBackend.d.ts +32 -0
  4. package/dist/agent/agentBackend.js +38 -0
  5. package/dist/agent/claudeActivity.d.ts +15 -0
  6. package/dist/agent/claudeActivity.js +58 -0
  7. package/dist/agent/claudeAdapter.d.ts +5 -0
  8. package/dist/agent/claudeAdapter.js +301 -0
  9. package/dist/agent/claudeSession.d.ts +18 -0
  10. package/dist/agent/claudeSession.js +54 -0
  11. package/dist/agent/claudeTools.d.ts +89 -0
  12. package/dist/agent/claudeTools.js +135 -0
  13. package/dist/agent/fakeAgent.js +24 -4
  14. package/dist/agent/piAdapter.d.ts +6 -3
  15. package/dist/agent/piAdapter.js +129 -21
  16. package/dist/agent/port.d.ts +50 -1
  17. package/dist/agent/resolveAgentPort.d.ts +26 -0
  18. package/dist/agent/resolveAgentPort.js +39 -0
  19. package/dist/cli/runCommand.js +2 -2
  20. package/dist/cli/runsCommand.d.ts +22 -0
  21. package/dist/cli/runsCommand.js +773 -0
  22. package/dist/cli/validateOutput.d.ts +2 -2
  23. package/dist/cli/validateOutput.js +2 -2
  24. package/dist/cli.d.ts +1 -1
  25. package/dist/cli.js +43 -5
  26. package/dist/config/browseCatalog.js +1 -1
  27. package/dist/config/createPipeline.d.ts +2 -1
  28. package/dist/config/createPipeline.js +34 -9
  29. package/dist/config/createStage.js +14 -9
  30. package/dist/config/loadPipeline.js +10 -1
  31. package/dist/config/loadStage.js +138 -2
  32. package/dist/config/loadStageflowManifest.js +14 -1
  33. package/dist/config/mergePipelineIncludes.d.ts +1 -0
  34. package/dist/config/mergePipelineIncludes.js +14 -1
  35. package/dist/config/normalizePipelineStageEntry.d.ts +7 -2
  36. package/dist/config/normalizePipelineStageEntry.js +49 -6
  37. package/dist/config/parseCompletionContract.d.ts +8 -0
  38. package/dist/config/parseCompletionContract.js +218 -0
  39. package/dist/config/parsePreEmitChecks.d.ts +3 -0
  40. package/dist/config/parsePreEmitChecks.js +65 -0
  41. package/dist/config/pipelineNeeds.d.ts +19 -0
  42. package/dist/config/pipelineNeeds.js +151 -0
  43. package/dist/config/pipelineStageKeys.js +16 -1
  44. package/dist/config/resolveForkEmitContext.d.ts +6 -2
  45. package/dist/config/resolveForkEmitContext.js +9 -2
  46. package/dist/config/resolvePipelineDag.d.ts +2 -1
  47. package/dist/config/resolvePipelineDag.js +182 -39
  48. package/dist/config/validateCatalog.d.ts +1 -1
  49. package/dist/config/validateCompletionContract.d.ts +4 -0
  50. package/dist/config/validateCompletionContract.js +52 -0
  51. package/dist/envelope/check.d.ts +1 -1
  52. package/dist/envelope/check.js +86 -13
  53. package/dist/envelope/cloneForks.d.ts +1 -1
  54. package/dist/envelope/cloneForks.js +24 -9
  55. package/dist/envelope/feedbackLoop.d.ts +4 -0
  56. package/dist/envelope/feedbackLoop.js +31 -0
  57. package/dist/envelope/payloadSchema.d.ts +7 -1
  58. package/dist/envelope/payloadSchema.js +170 -13
  59. package/dist/envelope/preEmitChecks.d.ts +29 -0
  60. package/dist/envelope/preEmitChecks.js +57 -0
  61. package/dist/hitl/qaTrail.d.ts +25 -0
  62. package/dist/hitl/qaTrail.js +51 -0
  63. package/dist/index.d.ts +1 -1
  64. package/dist/index.js +1 -1
  65. package/dist/mcp/catalogTools.js +4 -2
  66. package/dist/mcp/controlTools.js +72 -0
  67. package/dist/mcp/waitingGates.d.ts +2 -1
  68. package/dist/mcp/waitingGates.js +17 -1
  69. package/dist/package-meta.d.ts +1 -0
  70. package/dist/package-meta.js +1 -0
  71. package/dist/projection/projectRun.d.ts +4 -1
  72. package/dist/projection/projectRun.js +7 -0
  73. package/dist/prompt/feedbackLoopContext.d.ts +2 -0
  74. package/dist/prompt/feedbackLoopContext.js +17 -0
  75. package/dist/prompt/priorEnvelope.d.ts +2 -2
  76. package/dist/prompt/priorEnvelope.js +4 -1
  77. package/dist/runstore/pipelineDagSnapshot.d.ts +1 -0
  78. package/dist/runstore/pipelineDagSnapshot.js +51 -11
  79. package/dist/runstore/port.d.ts +185 -3
  80. package/dist/runstore/port.js +42 -3
  81. package/dist/runstore/runProjection.d.ts +7 -3
  82. package/dist/runstore/runProjection.js +30 -15
  83. package/dist/runstore/sqlite/SqliteRunStore.d.ts +25 -1
  84. package/dist/runstore/sqlite/SqliteRunStore.js +626 -8
  85. package/dist/runstore/sqlite/schema.d.ts +1 -1
  86. package/dist/runstore/sqlite/schema.js +99 -0
  87. package/dist/runstore/stageInstanceId.d.ts +1 -1
  88. package/dist/runstore/stageInstanceId.js +3 -2
  89. package/dist/runstore/trackProjection.js +59 -27
  90. package/dist/runstore/verificationHistory.d.ts +19 -0
  91. package/dist/runstore/verificationHistory.js +30 -0
  92. package/dist/runtime/cloneSchedule.d.ts +40 -4
  93. package/dist/runtime/cloneSchedule.js +212 -28
  94. package/dist/runtime/completionCheckRunner.d.ts +137 -0
  95. package/dist/runtime/completionCheckRunner.js +365 -0
  96. package/dist/runtime/completionCheckoutBaseline.d.ts +3 -0
  97. package/dist/runtime/completionCheckoutBaseline.js +44 -0
  98. package/dist/runtime/envelopeRouting.d.ts +10 -3
  99. package/dist/runtime/envelopeRouting.js +146 -5
  100. package/dist/runtime/feedbackLoopCoordinator.d.ts +74 -0
  101. package/dist/runtime/feedbackLoopCoordinator.js +446 -0
  102. package/dist/runtime/feedbackLoopDecision.d.ts +44 -0
  103. package/dist/runtime/feedbackLoopDecision.js +207 -0
  104. package/dist/runtime/feedbackLoopRoute.d.ts +5 -0
  105. package/dist/runtime/feedbackLoopRoute.js +13 -0
  106. package/dist/runtime/feedbackLoopSchedule.d.ts +38 -0
  107. package/dist/runtime/feedbackLoopSchedule.js +72 -0
  108. package/dist/runtime/forkGeneration.d.ts +72 -0
  109. package/dist/runtime/forkGeneration.js +295 -0
  110. package/dist/runtime/gitCheckoutCapability.d.ts +7 -0
  111. package/dist/runtime/gitCheckoutCapability.js +97 -0
  112. package/dist/runtime/manualRecoveryState.d.ts +40 -0
  113. package/dist/runtime/manualRecoveryState.js +73 -0
  114. package/dist/runtime/pipelineScheduler.d.ts +13 -1
  115. package/dist/runtime/pipelineScheduler.js +328 -45
  116. package/dist/runtime/replayLifecycle.d.ts +107 -0
  117. package/dist/runtime/replayLifecycle.js +168 -0
  118. package/dist/runtime/runManager.d.ts +61 -1
  119. package/dist/runtime/runManager.js +501 -7
  120. package/dist/runtime/runRetryCoordinator.d.ts +8 -1
  121. package/dist/runtime/runRetryCoordinator.js +47 -10
  122. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -1
  123. package/dist/runtime/stageAttemptBootstrap.js +79 -1
  124. package/dist/runtime/stageProcessLauncher.d.ts +1 -1
  125. package/dist/runtime/stageRecovery.js +1 -1
  126. package/dist/runtime/stageRunner.d.ts +4 -1
  127. package/dist/runtime/stageRunner.js +29 -3
  128. package/dist/runtime/stageWorker.js +69 -2
  129. package/dist/runtime/stageWorkerProtocol.d.ts +1 -1
  130. package/dist/runtime/verifiedStageExecution.d.ts +29 -0
  131. package/dist/runtime/verifiedStageExecution.js +119 -0
  132. package/dist/server/bootstrap.js +1 -0
  133. package/dist/server/http.js +114 -0
  134. package/dist/server/operatorResults.js +2 -0
  135. package/dist/tools/askOperator.d.ts +161 -5
  136. package/dist/tools/askOperator.js +58 -25
  137. package/dist/tools/emitStageEnvelope.d.ts +12 -12
  138. package/dist/tools/emitStageEnvelope.js +112 -24
  139. package/dist/types/completion.d.ts +43 -0
  140. package/dist/types/completion.js +1 -0
  141. package/dist/types/envelope.d.ts +18 -0
  142. package/dist/types/forkChoice.d.ts +9 -4
  143. package/dist/types/forkChoice.js +1 -1
  144. package/dist/types/pipeline.d.ts +39 -2
  145. package/dist/types/preEmitCheck.d.ts +18 -0
  146. package/dist/types/preEmitCheck.js +1 -0
  147. package/dist/types/stage.d.ts +13 -0
  148. package/dist/types/stageflowManifest.d.ts +2 -0
  149. package/dist/ui/assets/index-CXGz4nL7.js +118 -0
  150. package/dist/ui/assets/{index-C3N7MXAC.css → index-uVHiae8s.css} +1 -1
  151. package/dist/ui/index.html +2 -2
  152. package/package.json +2 -1
  153. package/skills/stageflow/references/control-surface.md +4 -2
  154. package/skills/stageflow-author/references/catalog-mapping.md +26 -2
  155. package/skills/stageflow-author/references/catalog-write-conventions.md +1 -1
  156. package/skills/stageflow-run/SKILL.md +56 -25
  157. package/skills/stageflow-run/references/mcp-call.md +2 -2
  158. package/skills/stageflow-run/references/native-question-ui.md +78 -0
  159. package/dist/ui/assets/index-CSoGDA3A.js +0 -118
@@ -1,4 +1,6 @@
1
1
  import { isAllowedPipelineStageEntryKey } from "./pipelineStageKeys.js";
2
+ import { parseExecutionPolicy } from "./parseCompletionContract.js";
3
+ import { parsePipelineNeeds, predecessorEdges, toNeedEdges } from "./pipelineNeeds.js";
2
4
  const ALLOWED_FORK_KEYS = new Set(["select", "allow_none"]);
3
5
  function pipelineLabel(ctx) {
4
6
  return `Pipeline ${ctx.pipelineId} (${ctx.path})`;
@@ -9,6 +11,44 @@ function formatError(ctx, message) {
9
11
  function isPlainObject(value) {
10
12
  return typeof value === "object" && value !== null && !Array.isArray(value);
11
13
  }
14
+ const ALLOWED_FEEDBACK_LOOP_KEYS = new Set([
15
+ "target",
16
+ "max_replays",
17
+ "on_max_replays",
18
+ "replay_session",
19
+ ]);
20
+ export function parseFeedbackLoopConfig(value, stageId, ctx) {
21
+ if (!isPlainObject(value)) {
22
+ throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop must be an object`));
23
+ }
24
+ for (const key of Object.keys(value)) {
25
+ if (!ALLOWED_FEEDBACK_LOOP_KEYS.has(key)) {
26
+ throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop: unknown key "${key}"`));
27
+ }
28
+ }
29
+ if (Array.isArray(value.target)) {
30
+ throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.target must be a single stage id string, not an array`));
31
+ }
32
+ if (typeof value.target !== "string" || value.target.trim() === "") {
33
+ throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.target must be a non-empty stage id`));
34
+ }
35
+ if (!Number.isInteger(value.max_replays) || value.max_replays < 1) {
36
+ throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.max_replays must be a positive integer`));
37
+ }
38
+ if (value.on_max_replays !== "require_continue" &&
39
+ value.on_max_replays !== "wait_for_human") {
40
+ throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.on_max_replays must be "require_continue" or "wait_for_human"`));
41
+ }
42
+ if (value.replay_session !== "resume" && value.replay_session !== "new_session") {
43
+ throw new Error(formatError(ctx, `stage "${stageId}": feedback_loop.replay_session must be "resume" or "new_session"`));
44
+ }
45
+ return {
46
+ target: value.target,
47
+ max_replays: value.max_replays,
48
+ on_max_replays: value.on_max_replays,
49
+ replay_session: value.replay_session,
50
+ };
51
+ }
12
52
  export function parsePipelineStageEntries(raw, ctx) {
13
53
  if (!Array.isArray(raw)) {
14
54
  throw new Error(formatError(ctx, "stages[] is required"));
@@ -53,41 +93,69 @@ export function parsePipelineStageEntries(raw, ctx) {
53
93
  ...(entry.clonable !== undefined ? { clonable: entry.clonable } : {}),
54
94
  ...(entry.clone_cap !== undefined ? { clone_cap: entry.clone_cap } : {}),
55
95
  };
96
+ const policyOutcome = parseExecutionPolicy(entry, entry.id);
97
+ if (!policyOutcome.ok) {
98
+ throw new Error(formatError(ctx, policyOutcome.issues[0]?.message ?? "invalid execution policy"));
99
+ }
100
+ const policyFields = {
101
+ ...(policyOutcome.value.completion !== undefined
102
+ ? { completion: policyOutcome.value.completion }
103
+ : {}),
104
+ ...(policyOutcome.value.recovery !== undefined
105
+ ? { recovery: policyOutcome.value.recovery }
106
+ : {}),
107
+ };
108
+ const feedbackLoopFields = entry.feedback_loop !== undefined
109
+ ? { feedback_loop: parseFeedbackLoopConfig(entry.feedback_loop, entry.id, ctx) }
110
+ : {};
111
+ if (entry.replay_safe !== undefined && typeof entry.replay_safe !== "boolean") {
112
+ throw new Error(formatError(ctx, `stage "${entry.id}": replay_safe must be a boolean`));
113
+ }
114
+ const replaySafetyFields = entry.replay_safe !== undefined ? { replay_safe: entry.replay_safe } : {};
56
115
  if (entry.needs === undefined) {
57
116
  entries.push({
58
117
  id: entry.id,
59
118
  ...(forkValue !== undefined ? { fork: forkValue } : {}),
60
119
  ...clonableFields,
120
+ ...policyFields,
121
+ ...feedbackLoopFields,
122
+ ...replaySafetyFields,
61
123
  });
62
124
  continue;
63
125
  }
64
- if (Array.isArray(entry.needs)) {
65
- if (entry.needs.length > 1) {
66
- throw new Error(formatError(ctx, `stage "${entry.id}": fan-in not supported; needs must be a single stage id`));
67
- }
68
- throw new Error(formatError(ctx, `stage "${entry.id}": needs must be a string, not an array`));
69
- }
70
- if (typeof entry.needs !== "string" || !entry.needs) {
71
- throw new Error(formatError(ctx, `stage "${entry.id}": needs must be a non-empty string`));
126
+ const parsedNeeds = parsePipelineNeeds(entry.needs, entry.id);
127
+ if (!parsedNeeds.ok) {
128
+ throw new Error(formatError(ctx, parsedNeeds.message));
72
129
  }
73
130
  entries.push({
74
131
  id: entry.id,
75
- needs: entry.needs,
132
+ needs: parsedNeeds.value,
76
133
  ...(forkValue !== undefined ? { fork: forkValue } : {}),
77
134
  ...clonableFields,
135
+ ...policyFields,
136
+ ...feedbackLoopFields,
137
+ ...replaySafetyFields,
78
138
  });
79
139
  }
80
140
  return entries;
81
141
  }
82
142
  function normalizeToEdges(entries) {
83
- return entries.map((entry, index) => ({
84
- id: entry.id,
85
- needs: entry.needs ?? null,
86
- stageIndex: index,
87
- ...(entry.fork !== undefined ? { fork: entry.fork } : {}),
88
- ...(entry.clonable !== undefined ? { clonable: entry.clonable } : {}),
89
- ...(entry.clone_cap !== undefined ? { clone_cap: entry.clone_cap } : {}),
90
- }));
143
+ return entries.map((entry, index) => {
144
+ const needsEdges = toNeedEdges(entry.needs);
145
+ return {
146
+ id: entry.id,
147
+ needs: needsEdges.length === 1 ? needsEdges[0].id : null,
148
+ needsEdges,
149
+ stageIndex: index,
150
+ ...(entry.fork !== undefined ? { fork: entry.fork } : {}),
151
+ ...(entry.clonable !== undefined ? { clonable: entry.clonable } : {}),
152
+ ...(entry.clone_cap !== undefined ? { clone_cap: entry.clone_cap } : {}),
153
+ ...(entry.completion !== undefined ? { completion: entry.completion } : {}),
154
+ ...(entry.recovery !== undefined ? { recovery: entry.recovery } : {}),
155
+ ...(entry.feedback_loop !== undefined ? { feedback_loop: entry.feedback_loop } : {}),
156
+ ...(entry.replay_safe !== undefined ? { replay_safe: entry.replay_safe } : {}),
157
+ };
158
+ });
91
159
  }
92
160
  function detectDuplicateIds(edges, ctx) {
93
161
  const seen = new Set();
@@ -101,8 +169,10 @@ function detectDuplicateIds(edges, ctx) {
101
169
  function validateNeedsTargets(edges, ctx) {
102
170
  const declared = new Set(edges.map((edge) => edge.id));
103
171
  for (const edge of edges) {
104
- if (edge.needs !== null && !declared.has(edge.needs)) {
105
- throw new Error(formatError(ctx, `stage "${edge.id}" has unknown needs "${edge.needs}"`));
172
+ for (const parent of edge.needsEdges) {
173
+ if (!declared.has(parent.id)) {
174
+ throw new Error(formatError(ctx, `stage "${edge.id}" has unknown needs "${parent.id}"`));
175
+ }
106
176
  }
107
177
  }
108
178
  }
@@ -114,10 +184,10 @@ function detectCycle(edges, ctx) {
114
184
  children.set(edge.id, []);
115
185
  }
116
186
  for (const edge of edges) {
117
- if (edge.needs === null)
118
- continue;
119
- indegree.set(edge.id, (indegree.get(edge.id) ?? 0) + 1);
120
- children.get(edge.needs)?.push(edge.id);
187
+ for (const parent of edge.needsEdges) {
188
+ indegree.set(edge.id, (indegree.get(edge.id) ?? 0) + 1);
189
+ children.get(parent.id)?.push(edge.id);
190
+ }
121
191
  }
122
192
  const queue = [];
123
193
  for (const [id, degree] of indegree) {
@@ -140,19 +210,31 @@ function detectCycle(edges, ctx) {
140
210
  }
141
211
  }
142
212
  function computeAncestors(edges) {
143
- const needsById = new Map(edges.map((edge) => [edge.id, edge.needs]));
213
+ const parentsById = new Map(edges.map((edge) => [edge.id, edge.needsEdges]));
144
214
  const ancestorsById = new Map();
145
215
  function ancestorsFor(id) {
146
216
  const cached = ancestorsById.get(id);
147
217
  if (cached)
148
218
  return cached;
149
- const needs = needsById.get(id) ?? null;
150
- if (needs === null) {
219
+ const parents = parentsById.get(id) ?? [];
220
+ if (parents.length === 0) {
151
221
  ancestorsById.set(id, []);
152
222
  return [];
153
223
  }
154
- const parentAncestors = ancestorsFor(needs);
155
- const ancestors = [...parentAncestors, needs];
224
+ const seen = new Set();
225
+ const ancestors = [];
226
+ for (const parent of parents) {
227
+ for (const ancestor of ancestorsFor(parent.id)) {
228
+ if (seen.has(ancestor))
229
+ continue;
230
+ seen.add(ancestor);
231
+ ancestors.push(ancestor);
232
+ }
233
+ if (!seen.has(parent.id)) {
234
+ seen.add(parent.id);
235
+ ancestors.push(parent.id);
236
+ }
237
+ }
156
238
  ancestorsById.set(id, ancestors);
157
239
  return ancestors;
158
240
  }
@@ -170,16 +252,16 @@ function topologicalSort(edges) {
170
252
  children.set(edge.id, []);
171
253
  }
172
254
  for (const edge of edges) {
173
- if (edge.needs === null)
174
- continue;
175
- indegree.set(edge.id, (indegree.get(edge.id) ?? 0) + 1);
176
- children.get(edge.needs)?.push(edge.id);
255
+ for (const parent of edge.needsEdges) {
256
+ indegree.set(edge.id, (indegree.get(edge.id) ?? 0) + 1);
257
+ children.get(parent.id)?.push(edge.id);
258
+ }
177
259
  }
178
260
  for (const [, childIds] of children) {
179
261
  childIds.sort((a, b) => (byId.get(a)?.stageIndex ?? 0) - (byId.get(b)?.stageIndex ?? 0));
180
262
  }
181
263
  const roots = edges
182
- .filter((edge) => edge.needs === null)
264
+ .filter((edge) => edge.needsEdges.length === 0)
183
265
  .sort((a, b) => a.stageIndex - b.stageIndex)
184
266
  .map((edge) => edge.id);
185
267
  const sorted = [];
@@ -206,8 +288,8 @@ function buildResolvedPipelineDag(edges) {
206
288
  childrenOf[edge.id] = [];
207
289
  }
208
290
  for (const edge of edges) {
209
- if (edge.needs !== null) {
210
- childrenOf[edge.needs].push(edge.id);
291
+ for (const parent of edge.needsEdges) {
292
+ childrenOf[parent.id].push(edge.id);
211
293
  }
212
294
  }
213
295
  for (const id of Object.keys(childrenOf)) {
@@ -215,12 +297,13 @@ function buildResolvedPipelineDag(edges) {
215
297
  (edges.find((edge) => edge.id === b)?.stageIndex ?? 0));
216
298
  }
217
299
  const roots = edges
218
- .filter((edge) => edge.needs === null)
300
+ .filter((edge) => edge.needsEdges.length === 0)
219
301
  .sort((a, b) => a.stageIndex - b.stageIndex)
220
302
  .map((edge) => edge.id);
221
303
  const nodes = sortedEdges.map((edge) => ({
222
304
  id: edge.id,
223
305
  needs: edge.needs,
306
+ needsEdges: edge.needsEdges,
224
307
  ancestors: ancestorsById.get(edge.id) ?? [],
225
308
  stageIndex: edge.stageIndex,
226
309
  ...(edge.fork !== undefined
@@ -229,6 +312,10 @@ function buildResolvedPipelineDag(edges) {
229
312
  ...(edge.clonable === true
230
313
  ? { clonable: true, clone_cap: edge.clone_cap ?? 5 }
231
314
  : {}),
315
+ ...(edge.completion !== undefined ? { completion: edge.completion } : {}),
316
+ ...(edge.recovery !== undefined ? { recovery: edge.recovery } : {}),
317
+ ...(edge.feedback_loop !== undefined ? { feedback_loop: edge.feedback_loop } : {}),
318
+ ...(edge.replay_safe !== undefined ? { replay_safe: edge.replay_safe } : {}),
232
319
  }));
233
320
  return { nodes, roots, childrenOf };
234
321
  }
@@ -259,6 +346,34 @@ function validateClonableFields(edges, dag, ctx) {
259
346
  }
260
347
  }
261
348
  }
349
+ function validateFeedbackLoopFields(dag, ctx) {
350
+ const byId = new Map(dag.nodes.map((node) => [node.id, node]));
351
+ for (const source of dag.nodes) {
352
+ const policy = source.feedback_loop;
353
+ if (!policy)
354
+ continue;
355
+ if (source.clonable === true) {
356
+ throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop source cannot be clonable`));
357
+ }
358
+ const targetId = policy.target;
359
+ const target = byId.get(targetId);
360
+ if (!target) {
361
+ throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop target "${targetId}" is not declared`));
362
+ }
363
+ if (!source.ancestors.includes(targetId)) {
364
+ throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop target "${targetId}" must be an earlier ancestor`));
365
+ }
366
+ if (target.clonable === true) {
367
+ throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop target "${targetId}" cannot be clonable`));
368
+ }
369
+ const targetIndex = source.ancestors.indexOf(targetId);
370
+ const route = [...source.ancestors.slice(targetIndex), source.id];
371
+ const unsafeStage = route.find((stageId) => byId.get(stageId)?.replay_safe === false);
372
+ if (unsafeStage !== undefined) {
373
+ throw new Error(formatError(ctx, `stage "${source.id}": feedback_loop target "${targetId}" replays replay_safe: false stage "${unsafeStage}"`));
374
+ }
375
+ }
376
+ }
262
377
  export function resolvePipelineDagFromRefs(refs, ctx) {
263
378
  const edges = normalizeToEdges(refs);
264
379
  detectDuplicateIds(edges, ctx);
@@ -271,6 +386,7 @@ export function resolvePipelineDagFromRefs(refs, ctx) {
271
386
  const dag = buildResolvedPipelineDag(edges);
272
387
  validateForkFields(edges, dag, ctx);
273
388
  validateClonableFields(edges, dag, ctx);
389
+ validateFeedbackLoopFields(dag, ctx);
274
390
  return { stages, dag };
275
391
  }
276
392
  export function resolvePipelineDag(rawStages, ctx) {
@@ -293,12 +409,40 @@ export function areResolvedDagsEquivalent(a, b) {
293
409
  const nodeB = byIdB.get(id);
294
410
  if (nodeA.needs !== nodeB.needs)
295
411
  return false;
412
+ const edgesA = predecessorEdges(nodeA);
413
+ const edgesB = predecessorEdges(nodeB);
414
+ if (edgesA.length !== edgesB.length)
415
+ return false;
416
+ for (let i = 0; i < edgesA.length; i++) {
417
+ if (edgesA[i].id !== edgesB[i].id)
418
+ return false;
419
+ if (edgesA[i].on.length !== edgesB[i].on.length)
420
+ return false;
421
+ for (let j = 0; j < edgesA[i].on.length; j++) {
422
+ if (edgesA[i].on[j] !== edgesB[i].on[j])
423
+ return false;
424
+ }
425
+ }
296
426
  if (nodeA.ancestors.length !== nodeB.ancestors.length)
297
427
  return false;
298
428
  for (let i = 0; i < nodeA.ancestors.length; i++) {
299
429
  if (nodeA.ancestors[i] !== nodeB.ancestors[i])
300
430
  return false;
301
431
  }
432
+ if ((nodeA.replay_safe ?? true) !== (nodeB.replay_safe ?? true))
433
+ return false;
434
+ if (nodeA.feedback_loop?.max_replays !== nodeB.feedback_loop?.max_replays) {
435
+ return false;
436
+ }
437
+ if (nodeA.feedback_loop?.on_max_replays !==
438
+ nodeB.feedback_loop?.on_max_replays) {
439
+ return false;
440
+ }
441
+ if (nodeA.feedback_loop?.replay_session !== nodeB.feedback_loop?.replay_session) {
442
+ return false;
443
+ }
444
+ if (nodeA.feedback_loop?.target !== nodeB.feedback_loop?.target)
445
+ return false;
302
446
  }
303
447
  return true;
304
448
  }
@@ -321,9 +465,8 @@ export function extractPipelineStageIds(rawStages) {
321
465
  if (typeof entry.id !== "string" || !entry.id)
322
466
  return null;
323
467
  if (entry.needs !== undefined) {
324
- if (Array.isArray(entry.needs))
325
- return null;
326
- if (typeof entry.needs !== "string" || !entry.needs)
468
+ const parsedNeeds = parsePipelineNeeds(entry.needs, entry.id);
469
+ if (!parsedNeeds.ok)
327
470
  return null;
328
471
  }
329
472
  stageIds.push(entry.id);
@@ -2,7 +2,7 @@ import type { LoadedPipeline } from "../types/pipeline.js";
2
2
  import type { LoadIssue } from "./loadOutcome.js";
3
3
  export type ValidationSeverity = "error" | "warning";
4
4
  export type ValidationCategory = "pipeline" | "stage" | "catalog" | "task";
5
- export type ValidationFindingCode = "pipeline.invalid_shape" | "pipeline.dag_error" | "pipeline.missing_stage" | "pipeline.load_error" | "pipeline.string_stage_ref" | "pipeline.stage_uses_inline_conflict" | "pipeline.stage_missing_body" | "pipeline.stage_id_mismatch" | "pipeline.include_cycle" | "pipeline.include_invalid" | "pipeline.include_duplicate_stage" | "stage.invalid_shape" | "stage.invalid_payload_schema" | "stage.invalid_gate_kinds" | "stage.invalid_skill" | "stage.load_error" | "stage.id_filename_mismatch" | "task.invalid_shape" | "task.load_error" | "catalog.duplicate_pipeline_id" | "catalog.manifest_missing" | "catalog.manifest_invalid" | "catalog.empty_catalog" | "catalog.manifest_load_error";
5
+ export type ValidationFindingCode = "pipeline.invalid_shape" | "pipeline.dag_error" | "pipeline.missing_stage" | "pipeline.load_error" | "pipeline.string_stage_ref" | "pipeline.stage_uses_inline_conflict" | "pipeline.stage_missing_body" | "pipeline.stage_id_mismatch" | "pipeline.invalid_completion" | "pipeline.invalid_recovery" | "pipeline.include_cycle" | "pipeline.include_invalid" | "pipeline.include_duplicate_stage" | "pipeline.invalid_agent" | "stage.invalid_shape" | "stage.invalid_payload_schema" | "stage.invalid_clone_input_schema" | "stage.invalid_clone_actions" | "stage.invalid_gate_kinds" | "stage.invalid_pre_emit_checks" | "stage.invalid_timeout_ms" | "stage.invalid_skill" | "stage.invalid_agent" | "stage.load_error" | "stage.id_filename_mismatch" | "task.invalid_shape" | "task.load_error" | "catalog.duplicate_pipeline_id" | "catalog.manifest_missing" | "catalog.manifest_invalid" | "catalog.empty_catalog" | "catalog.manifest_load_error";
6
6
  export type ValidationFinding = {
7
7
  severity: ValidationSeverity;
8
8
  code: ValidationFindingCode | string;
@@ -0,0 +1,4 @@
1
+ import type { CompletionContract } from "../types/completion.js";
2
+ import type { StageConfig } from "../types/stage.js";
3
+ import { type LoadOutcome } from "./loadOutcome.js";
4
+ export declare function validateCompletionContractForStage(stage: StageConfig, completion: CompletionContract | undefined): LoadOutcome<void>;
@@ -0,0 +1,52 @@
1
+ import { loadFailure, loadSuccess } from "./loadOutcome.js";
2
+ function failure(stageId, message) {
3
+ return loadFailure([
4
+ {
5
+ code: "pipeline.invalid_completion",
6
+ message: `Stage "${stageId}" completion: ${message}`,
7
+ category: "pipeline",
8
+ },
9
+ ]);
10
+ }
11
+ function isRequiredStringArrayField(schema, field) {
12
+ if (!schema || typeof schema !== "object" || Array.isArray(schema))
13
+ return false;
14
+ const root = schema;
15
+ if (!Array.isArray(root.required) || !root.required.includes(field))
16
+ return false;
17
+ if (!root.properties || typeof root.properties !== "object" || Array.isArray(root.properties)) {
18
+ return false;
19
+ }
20
+ const property = root.properties[field];
21
+ if (!property || typeof property !== "object" || Array.isArray(property))
22
+ return false;
23
+ const arraySchema = property;
24
+ if (arraySchema.type !== "array")
25
+ return false;
26
+ const items = arraySchema.items;
27
+ return Boolean(items &&
28
+ typeof items === "object" &&
29
+ !Array.isArray(items) &&
30
+ items.type === "string");
31
+ }
32
+ export function validateCompletionContractForStage(stage, completion) {
33
+ if (!completion)
34
+ return loadSuccess(undefined);
35
+ for (const check of completion.checks) {
36
+ if (check.type === "payload_schema" && stage.payload_schema === undefined) {
37
+ return failure(stage.id, `check "${check.id}" requires the stage to declare payload_schema`);
38
+ }
39
+ if (check.type === "gate" &&
40
+ !stage.gate_kinds?.includes(check.kind)) {
41
+ return failure(stage.id, `gate check "${check.id}" requires gate_kinds to include "${check.kind}"`);
42
+ }
43
+ if (check.type === "checkout_changes") {
44
+ for (const field of check.path_fields ?? []) {
45
+ if (!isRequiredStringArrayField(stage.payload_schema, field)) {
46
+ return failure(stage.id, `checkout check "${check.id}" path field "${field}" must be a required array of strings in payload_schema`);
47
+ }
48
+ }
49
+ }
50
+ }
51
+ return loadSuccess(undefined);
52
+ }
@@ -1,5 +1,5 @@
1
1
  import { type StageEnvelope } from "../types/envelope.js";
2
2
  import type { CloneForkItem } from "../types/forkChoice.js";
3
3
  export declare function parseCloneForks(raw: unknown): CloneForkItem[];
4
- export declare function assertRequiredEnvelope(value: unknown): StageEnvelope;
4
+ export declare function assertRequiredEnvelope(value: unknown, pathPrefix?: string): StageEnvelope;
5
5
  export declare function isAdvancingEnvelope(envelope: StageEnvelope): boolean;
@@ -1,4 +1,13 @@
1
1
  import { EnvelopeError, } from "../types/envelope.js";
2
+ function qualifyEnvelopeMessage(pathPrefix, message) {
3
+ if (!pathPrefix) {
4
+ return message;
5
+ }
6
+ if (message === "envelope must be an object") {
7
+ return `${pathPrefix} must be an object`;
8
+ }
9
+ return `${pathPrefix}.${message}`;
10
+ }
2
11
  function parseCloneForkItem(raw, index) {
3
12
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
4
13
  throw new EnvelopeError(`clone_forks[${index}] must be an object`);
@@ -16,7 +25,11 @@ function parseCloneForkItem(raw, index) {
16
25
  action,
17
26
  };
18
27
  if (record.envelope !== undefined) {
19
- item.envelope = assertRequiredEnvelope(record.envelope);
28
+ const envelope = assertRequiredEnvelope(record.envelope, `clone_forks[${index}].envelope`);
29
+ if (envelope.feedback_loop !== undefined) {
30
+ throw new EnvelopeError(`clone_forks[${index}].envelope.feedback_loop is not allowed in clone assignment envelopes`);
31
+ }
32
+ item.envelope = envelope;
20
33
  }
21
34
  if (record.mode !== undefined) {
22
35
  if (record.mode !== "parallel" && record.mode !== "sequential") {
@@ -36,7 +49,11 @@ function parseCloneForkItem(raw, index) {
36
49
  if (cloneRecord.envelope === undefined) {
37
50
  throw new EnvelopeError(`clone_forks[${index}].clones[${cloneIndex}].envelope is required`);
38
51
  }
39
- return { envelope: assertRequiredEnvelope(cloneRecord.envelope) };
52
+ const envelope = assertRequiredEnvelope(cloneRecord.envelope, `clone_forks[${index}].clones[${cloneIndex}].envelope`);
53
+ if (envelope.feedback_loop !== undefined) {
54
+ throw new EnvelopeError(`clone_forks[${index}].clones[${cloneIndex}].envelope.feedback_loop is not allowed in clone assignment envelopes`);
55
+ }
56
+ return { envelope };
40
57
  });
41
58
  }
42
59
  return item;
@@ -47,26 +64,26 @@ export function parseCloneForks(raw) {
47
64
  }
48
65
  return raw.map((item, index) => parseCloneForkItem(item, index));
49
66
  }
50
- export function assertRequiredEnvelope(value) {
67
+ export function assertRequiredEnvelope(value, pathPrefix = "") {
51
68
  if (value === null || typeof value !== "object" || Array.isArray(value)) {
52
- throw new EnvelopeError("envelope must be an object");
69
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "envelope must be an object"));
53
70
  }
54
71
  const record = value;
55
72
  const status = record.status;
56
73
  if (status !== "success" && status !== "failure") {
57
- throw new EnvelopeError('status must be "success" or "failure"');
74
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, 'status must be "success" or "failure"'));
58
75
  }
59
76
  if (typeof record.summary !== "string" || record.summary.trim() === "") {
60
- throw new EnvelopeError("summary must be a non-empty string");
77
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "summary must be a non-empty string"));
61
78
  }
62
79
  if (!("artifacts" in record)) {
63
- throw new EnvelopeError("artifacts field is required");
80
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "artifacts field is required"));
64
81
  }
65
82
  if (!Array.isArray(record.artifacts)) {
66
- throw new EnvelopeError("artifacts must be an array");
83
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "artifacts must be an array"));
67
84
  }
68
85
  if (!record.artifacts.every((item) => typeof item === "string")) {
69
- throw new EnvelopeError("artifacts must be an array of strings");
86
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "artifacts must be an array of strings"));
70
87
  }
71
88
  const envelope = {
72
89
  status,
@@ -76,30 +93,86 @@ export function assertRequiredEnvelope(value) {
76
93
  if (record.fork_choice !== undefined) {
77
94
  if (!Array.isArray(record.fork_choice) ||
78
95
  !record.fork_choice.every((item) => typeof item === "string")) {
79
- throw new EnvelopeError("fork_choice must be an array of strings");
96
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "fork_choice must be an array of strings"));
80
97
  }
81
98
  envelope.fork_choice = record.fork_choice;
82
99
  }
83
100
  if (record.clone_forks !== undefined) {
84
101
  envelope.clone_forks = parseCloneForks(record.clone_forks);
85
102
  }
103
+ if (record.feedback_loop !== undefined) {
104
+ if (record.feedback_loop === null ||
105
+ typeof record.feedback_loop !== "object" ||
106
+ Array.isArray(record.feedback_loop)) {
107
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "feedback_loop must be an object"));
108
+ }
109
+ const feedbackLoop = record.feedback_loop;
110
+ if (feedbackLoop.action === "continue") {
111
+ for (const key of Object.keys(feedbackLoop)) {
112
+ if (key !== "action" && key !== "target") {
113
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, `feedback_loop: unknown key "${key}"`));
114
+ }
115
+ }
116
+ if (feedbackLoop.target !== undefined) {
117
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "feedback_loop.target is not allowed for action continue"));
118
+ }
119
+ envelope.feedback_loop = { action: "continue" };
120
+ }
121
+ else if (feedbackLoop.action === "send_back") {
122
+ for (const key of Object.keys(feedbackLoop)) {
123
+ if (key !== "action" && key !== "target") {
124
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, `feedback_loop: unknown key "${key}"`));
125
+ }
126
+ }
127
+ if (typeof feedbackLoop.target !== "string" || feedbackLoop.target.trim() === "") {
128
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "feedback_loop.target must be a non-empty string for action send_back"));
129
+ }
130
+ envelope.feedback_loop = { action: "send_back", target: feedbackLoop.target };
131
+ }
132
+ else {
133
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, 'feedback_loop.action must be "continue" or "send_back"'));
134
+ }
135
+ }
136
+ if (record.checklist_attestations !== undefined) {
137
+ if (!Array.isArray(record.checklist_attestations)) {
138
+ throw new EnvelopeError("checklist_attestations must be an array");
139
+ }
140
+ const ids = new Set();
141
+ envelope.checklist_attestations = record.checklist_attestations.map((value, index) => {
142
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
143
+ throw new EnvelopeError(`checklist_attestations[${index}] must be an object`);
144
+ }
145
+ const attestation = value;
146
+ if (typeof attestation.check_id !== "string" || attestation.check_id.trim() === "") {
147
+ throw new EnvelopeError(`checklist_attestations[${index}].check_id must be a non-empty string`);
148
+ }
149
+ if (ids.has(attestation.check_id)) {
150
+ throw new EnvelopeError(`checklist_attestations contains duplicate check_id "${attestation.check_id}"`);
151
+ }
152
+ ids.add(attestation.check_id);
153
+ if (!Array.isArray(attestation.items) || !attestation.items.every((item) => typeof item === "string")) {
154
+ throw new EnvelopeError(`checklist_attestations[${index}].items must be an array of strings`);
155
+ }
156
+ return { check_id: attestation.check_id, items: attestation.items };
157
+ });
158
+ }
86
159
  if (record.payload !== undefined) {
87
160
  if (record.payload === null ||
88
161
  typeof record.payload !== "object" ||
89
162
  Array.isArray(record.payload)) {
90
- throw new EnvelopeError("payload must be an object when present");
163
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "payload must be an object when present"));
91
164
  }
92
165
  envelope.payload = record.payload;
93
166
  }
94
167
  if (record.stage_id !== undefined) {
95
168
  if (typeof record.stage_id !== "string") {
96
- throw new EnvelopeError("stage_id must be a string when present");
169
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "stage_id must be a string when present"));
97
170
  }
98
171
  envelope.stage_id = record.stage_id;
99
172
  }
100
173
  if (record.notes !== undefined) {
101
174
  if (typeof record.notes !== "string") {
102
- throw new EnvelopeError("notes must be a string when present");
175
+ throw new EnvelopeError(qualifyEnvelopeMessage(pathPrefix, "notes must be a string when present"));
103
176
  }
104
177
  envelope.notes = record.notes;
105
178
  }
@@ -1,2 +1,2 @@
1
- import type { CloneEmitContext } from "../types/forkChoice.js";
1
+ import { type CloneEmitContext } from "../types/forkChoice.js";
2
2
  export declare function assertCloneForks(params: unknown, cloneEmitContext: CloneEmitContext): void;