stageflow 0.9.0 → 0.13.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 (167) hide show
  1. package/README.md +6 -1
  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 +331 -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 +12 -7
  15. package/dist/agent/piAdapter.js +194 -44
  16. package/dist/agent/piIsolatedMcp.d.ts +23 -0
  17. package/dist/agent/piIsolatedMcp.js +223 -0
  18. package/dist/agent/piIsolatedMcpProbe.d.ts +15 -0
  19. package/dist/agent/piIsolatedMcpProbe.js +292 -0
  20. package/dist/agent/port.d.ts +52 -1
  21. package/dist/agent/port.js +1 -1
  22. package/dist/agent/resolveAgentPort.d.ts +26 -0
  23. package/dist/agent/resolveAgentPort.js +39 -0
  24. package/dist/cli/runCommand.js +2 -2
  25. package/dist/cli/runsCommand.d.ts +1 -1
  26. package/dist/cli/runsCommand.js +184 -2
  27. package/dist/cli.d.ts +1 -1
  28. package/dist/cli.js +11 -5
  29. package/dist/config/browseCatalog.js +1 -1
  30. package/dist/config/createPipeline.d.ts +2 -1
  31. package/dist/config/createPipeline.js +34 -9
  32. package/dist/config/createStage.js +14 -9
  33. package/dist/config/loadPipeline.js +11 -1
  34. package/dist/config/loadStage.d.ts +1 -0
  35. package/dist/config/loadStage.js +207 -2
  36. package/dist/config/loadStageflowManifest.js +14 -1
  37. package/dist/config/mergePipelineIncludes.d.ts +1 -0
  38. package/dist/config/mergePipelineIncludes.js +14 -1
  39. package/dist/config/normalizePipelineStageEntry.d.ts +7 -2
  40. package/dist/config/normalizePipelineStageEntry.js +56 -7
  41. package/dist/config/parseCompletionContract.d.ts +8 -0
  42. package/dist/config/parseCompletionContract.js +218 -0
  43. package/dist/config/parsePreEmitChecks.d.ts +3 -0
  44. package/dist/config/parsePreEmitChecks.js +65 -0
  45. package/dist/config/pipelineNeeds.d.ts +19 -0
  46. package/dist/config/pipelineNeeds.js +151 -0
  47. package/dist/config/pipelineStageKeys.js +17 -1
  48. package/dist/config/resolveForkEmitContext.d.ts +6 -2
  49. package/dist/config/resolveForkEmitContext.js +9 -2
  50. package/dist/config/resolvePipelineDag.d.ts +2 -1
  51. package/dist/config/resolvePipelineDag.js +182 -39
  52. package/dist/config/resolveStageMcpServers.d.ts +35 -0
  53. package/dist/config/resolveStageMcpServers.js +207 -0
  54. package/dist/config/validateCatalog.d.ts +1 -1
  55. package/dist/config/validateCatalog.js +51 -0
  56. package/dist/config/validateCompletionContract.d.ts +4 -0
  57. package/dist/config/validateCompletionContract.js +52 -0
  58. package/dist/envelope/check.d.ts +1 -1
  59. package/dist/envelope/check.js +86 -13
  60. package/dist/envelope/cloneForks.d.ts +1 -1
  61. package/dist/envelope/cloneForks.js +24 -9
  62. package/dist/envelope/feedbackLoop.d.ts +4 -0
  63. package/dist/envelope/feedbackLoop.js +31 -0
  64. package/dist/envelope/payloadSchema.d.ts +7 -1
  65. package/dist/envelope/payloadSchema.js +170 -13
  66. package/dist/envelope/preEmitChecks.d.ts +29 -0
  67. package/dist/envelope/preEmitChecks.js +57 -0
  68. package/dist/hitl/qaTrail.d.ts +25 -0
  69. package/dist/hitl/qaTrail.js +51 -0
  70. package/dist/mcp/catalogTools.js +4 -2
  71. package/dist/mcp/controlTools.js +72 -0
  72. package/dist/mcp/readArtifact.d.ts +2 -0
  73. package/dist/mcp/readArtifact.js +25 -1
  74. package/dist/mcp/waitingGates.d.ts +2 -1
  75. package/dist/mcp/waitingGates.js +17 -1
  76. package/dist/package-meta.d.ts +1 -1
  77. package/dist/package-meta.js +1 -1
  78. package/dist/projection/projectRun.d.ts +4 -1
  79. package/dist/projection/projectRun.js +7 -0
  80. package/dist/prompt/feedbackLoopContext.d.ts +2 -0
  81. package/dist/prompt/feedbackLoopContext.js +17 -0
  82. package/dist/prompt/priorEnvelope.d.ts +2 -2
  83. package/dist/prompt/priorEnvelope.js +4 -1
  84. package/dist/runstore/pipelineDagSnapshot.d.ts +1 -0
  85. package/dist/runstore/pipelineDagSnapshot.js +51 -11
  86. package/dist/runstore/port.d.ts +185 -3
  87. package/dist/runstore/port.js +42 -3
  88. package/dist/runstore/runProjection.d.ts +7 -3
  89. package/dist/runstore/runProjection.js +30 -15
  90. package/dist/runstore/sqlite/SqliteRunStore.d.ts +25 -1
  91. package/dist/runstore/sqlite/SqliteRunStore.js +626 -8
  92. package/dist/runstore/sqlite/schema.d.ts +1 -1
  93. package/dist/runstore/sqlite/schema.js +99 -0
  94. package/dist/runstore/stageInstanceId.d.ts +1 -1
  95. package/dist/runstore/stageInstanceId.js +3 -2
  96. package/dist/runstore/trackProjection.js +59 -27
  97. package/dist/runstore/verificationHistory.d.ts +19 -0
  98. package/dist/runstore/verificationHistory.js +30 -0
  99. package/dist/runtime/cloneSchedule.d.ts +40 -4
  100. package/dist/runtime/cloneSchedule.js +212 -28
  101. package/dist/runtime/completionCheckRunner.d.ts +137 -0
  102. package/dist/runtime/completionCheckRunner.js +365 -0
  103. package/dist/runtime/completionCheckoutBaseline.d.ts +3 -0
  104. package/dist/runtime/completionCheckoutBaseline.js +44 -0
  105. package/dist/runtime/envelopeRouting.d.ts +10 -3
  106. package/dist/runtime/envelopeRouting.js +146 -5
  107. package/dist/runtime/feedbackLoopCoordinator.d.ts +74 -0
  108. package/dist/runtime/feedbackLoopCoordinator.js +446 -0
  109. package/dist/runtime/feedbackLoopDecision.d.ts +44 -0
  110. package/dist/runtime/feedbackLoopDecision.js +207 -0
  111. package/dist/runtime/feedbackLoopRoute.d.ts +5 -0
  112. package/dist/runtime/feedbackLoopRoute.js +13 -0
  113. package/dist/runtime/feedbackLoopSchedule.d.ts +38 -0
  114. package/dist/runtime/feedbackLoopSchedule.js +72 -0
  115. package/dist/runtime/forkGeneration.d.ts +72 -0
  116. package/dist/runtime/forkGeneration.js +295 -0
  117. package/dist/runtime/gitCheckoutCapability.d.ts +7 -0
  118. package/dist/runtime/gitCheckoutCapability.js +97 -0
  119. package/dist/runtime/manualRecoveryState.d.ts +40 -0
  120. package/dist/runtime/manualRecoveryState.js +73 -0
  121. package/dist/runtime/pipelineScheduler.d.ts +13 -1
  122. package/dist/runtime/pipelineScheduler.js +328 -45
  123. package/dist/runtime/replayLifecycle.d.ts +107 -0
  124. package/dist/runtime/replayLifecycle.js +168 -0
  125. package/dist/runtime/runManager.d.ts +54 -1
  126. package/dist/runtime/runManager.js +471 -6
  127. package/dist/runtime/runRetryCoordinator.d.ts +6 -1
  128. package/dist/runtime/runRetryCoordinator.js +43 -9
  129. package/dist/runtime/stageAttemptBootstrap.d.ts +3 -1
  130. package/dist/runtime/stageAttemptBootstrap.js +117 -3
  131. package/dist/runtime/stageProcessLauncher.d.ts +1 -1
  132. package/dist/runtime/stageRecovery.js +1 -1
  133. package/dist/runtime/stageRunner.d.ts +4 -1
  134. package/dist/runtime/stageRunner.js +29 -3
  135. package/dist/runtime/stageWorker.js +69 -2
  136. package/dist/runtime/stageWorkerProtocol.d.ts +1 -1
  137. package/dist/runtime/verifiedStageExecution.d.ts +31 -0
  138. package/dist/runtime/verifiedStageExecution.js +127 -0
  139. package/dist/server/bootstrap.js +1 -0
  140. package/dist/server/http.js +137 -4
  141. package/dist/server/operatorResults.js +2 -0
  142. package/dist/server/projectMcpRoutes.d.ts +6 -0
  143. package/dist/server/projectMcpRoutes.js +38 -0
  144. package/dist/tools/askOperator.d.ts +161 -5
  145. package/dist/tools/askOperator.js +58 -25
  146. package/dist/tools/emitStageEnvelope.d.ts +12 -12
  147. package/dist/tools/emitStageEnvelope.js +112 -24
  148. package/dist/types/completion.d.ts +43 -0
  149. package/dist/types/completion.js +1 -0
  150. package/dist/types/envelope.d.ts +18 -0
  151. package/dist/types/forkChoice.d.ts +9 -4
  152. package/dist/types/forkChoice.js +1 -1
  153. package/dist/types/pipeline.d.ts +41 -2
  154. package/dist/types/preEmitCheck.d.ts +18 -0
  155. package/dist/types/preEmitCheck.js +1 -0
  156. package/dist/types/stage.d.ts +14 -0
  157. package/dist/types/stageflowManifest.d.ts +2 -0
  158. package/dist/ui/assets/index-Yzh42vhf.js +118 -0
  159. package/dist/ui/assets/{index-C3N7MXAC.css → index-uVHiae8s.css} +1 -1
  160. package/dist/ui/index.html +2 -2
  161. package/package.json +5 -1
  162. package/skills/stageflow-author/SKILL.md +20 -2
  163. package/skills/stageflow-author/references/catalog-mapping.md +88 -3
  164. package/skills/stageflow-author/references/catalog-write-conventions.md +1 -1
  165. package/skills/stageflow-author/references/stage-prompt-template.md +63 -4
  166. package/skills/stageflow-session-capture/references/catalog-authoring.md +4 -1
  167. package/dist/ui/assets/index-CSoGDA3A.js +0 -118
@@ -6,6 +6,7 @@ import { readYamlObject } from "./readYamlObject.js";
6
6
  import { resolveCatalogContext } from "./resolveCatalogContext.js";
7
7
  import { getCatalogScanPaths } from "./browseCatalog.js";
8
8
  import { manifestPathForProject } from "./loadStageflowManifest.js";
9
+ import { MCP_CATALOG_FILENAME, StageMcpError, assertMcpAllowlistKnown, loadMcpCatalog, mcpCatalogPath, } from "./resolveStageMcpServers.js";
9
10
  export function relPath(cwd, absPath) {
10
11
  return path.relative(cwd, absPath).replace(/\\/g, "/");
11
12
  }
@@ -171,6 +172,55 @@ async function validateStageFile(cwd, stagePath) {
171
172
  }
172
173
  return findings;
173
174
  }
175
+ function collectMcpAllowlistNames(loaded) {
176
+ const names = new Set();
177
+ for (const stage of loaded.stages) {
178
+ for (const name of stage.mcp ?? []) {
179
+ names.add(name);
180
+ }
181
+ }
182
+ return [...names];
183
+ }
184
+ async function findingsForStageMcpCatalog(cwd, loaded) {
185
+ const allowlist = collectMcpAllowlistNames(loaded);
186
+ const ctx = await resolveCatalogContext(cwd);
187
+ const projectRoot = ctx.projectRoot;
188
+ const catalogAbsPath = projectRoot
189
+ ? mcpCatalogPath(projectRoot)
190
+ : path.resolve(cwd, MCP_CATALOG_FILENAME);
191
+ if (!projectRoot) {
192
+ if (allowlist.length === 0)
193
+ return [];
194
+ return [
195
+ findingCatalog(cwd, catalogAbsPath, `MCP catalog "${MCP_CATALOG_FILENAME}" is missing`, "catalog.invalid_mcp", "error"),
196
+ ];
197
+ }
198
+ let catalog;
199
+ try {
200
+ catalog = await loadMcpCatalog(projectRoot);
201
+ }
202
+ catch (err) {
203
+ if (!(err instanceof StageMcpError))
204
+ throw err;
205
+ if (err.code === "missing_catalog" && allowlist.length === 0)
206
+ return [];
207
+ return [
208
+ findingCatalog(cwd, catalogAbsPath, err.message, "catalog.invalid_mcp", "error"),
209
+ ];
210
+ }
211
+ try {
212
+ assertMcpAllowlistKnown(catalog.servers, allowlist);
213
+ }
214
+ catch (err) {
215
+ if (err instanceof StageMcpError) {
216
+ return [
217
+ findingCatalog(cwd, catalogAbsPath, err.message, "catalog.invalid_mcp", "error"),
218
+ ];
219
+ }
220
+ throw err;
221
+ }
222
+ return [];
223
+ }
174
224
  async function runPipelineValidation(nameOrPath, options) {
175
225
  const { cwd, validateStages } = options;
176
226
  let pipelinePath;
@@ -196,6 +246,7 @@ async function runPipelineValidation(nameOrPath, options) {
196
246
  findings.push(...findingsFromLoadIssues(cwd, pipelinePath, outcome.issues));
197
247
  return { ok: false, findings };
198
248
  }
249
+ findings.push(...(await findingsForStageMcpCatalog(cwd, outcome.value)));
199
250
  if (validateStages && outcome.value.stageSources) {
200
251
  for (const source of Object.values(outcome.value.stageSources)) {
201
252
  if (source.kind === "file") {
@@ -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;
@@ -1,5 +1,7 @@
1
1
  import { parseCloneForks } from "./check.js";
2
+ import { assertCloneAssignmentPayload } from "./payloadSchema.js";
2
3
  import { EnvelopeError } from "../types/envelope.js";
4
+ import { CLONE_ACTIONS } from "../types/forkChoice.js";
3
5
  function fieldPresent(item, key) {
4
6
  return item[key] !== undefined;
5
7
  }
@@ -17,40 +19,53 @@ export function assertCloneForks(params, cloneEmitContext) {
17
19
  const expectedIds = cloneEmitContext.clonableSuccessors.map((s) => s.successorId);
18
20
  const expectedSet = new Set(expectedIds);
19
21
  const seen = new Set();
20
- for (const item of items) {
22
+ for (const [i, item] of items.entries()) {
23
+ const itemPath = `clone_forks[${i}]`;
21
24
  if (seen.has(item.successor_id)) {
22
- throw new EnvelopeError(`clone_forks contains duplicate successor_id: ${item.successor_id}`);
25
+ throw new EnvelopeError(`${itemPath} contains duplicate successor_id: ${item.successor_id}`);
23
26
  }
24
27
  if (!expectedSet.has(item.successor_id)) {
25
- throw new EnvelopeError(`clone_forks contains extra successor_id: ${item.successor_id}`);
28
+ throw new EnvelopeError(`${itemPath} contains extra successor_id: ${item.successor_id}`);
26
29
  }
27
30
  seen.add(item.successor_id);
28
31
  const capEntry = cloneEmitContext.clonableSuccessors.find((s) => s.successorId === item.successor_id);
29
32
  const cloneCap = capEntry?.cloneCap;
33
+ const allowedActions = cloneEmitContext.allowedActions ?? [...CLONE_ACTIONS];
34
+ if (!allowedActions.includes(item.action)) {
35
+ throw new EnvelopeError(`${itemPath} action "${item.action}" is not allowed for ${item.successor_id} (allowed: ${allowedActions.join(", ")})`);
36
+ }
30
37
  if (item.action === "skip") {
31
38
  if (fieldPresent(item, "envelope") ||
32
39
  fieldPresent(item, "mode") ||
33
40
  fieldPresent(item, "clones")) {
34
- throw new EnvelopeError(`clone_forks skip for ${item.successor_id} must not include envelope, mode, or clones`);
41
+ throw new EnvelopeError(`${itemPath} skip for ${item.successor_id} must not include envelope, mode, or clones`);
35
42
  }
36
43
  }
37
44
  else if (item.action === "once") {
38
45
  if (!fieldPresent(item, "envelope")) {
39
- throw new EnvelopeError(`clone_forks once for ${item.successor_id} requires envelope`);
46
+ throw new EnvelopeError(`${itemPath} once for ${item.successor_id} requires envelope`);
40
47
  }
41
48
  if (fieldPresent(item, "mode") || fieldPresent(item, "clones")) {
42
- throw new EnvelopeError(`clone_forks once for ${item.successor_id} must not include mode or clones`);
49
+ throw new EnvelopeError(`${itemPath} once for ${item.successor_id} must not include mode or clones`);
50
+ }
51
+ if (capEntry?.cloneInputSchema !== undefined) {
52
+ assertCloneAssignmentPayload(item.envelope, capEntry.cloneInputSchema, item.successor_id, `${itemPath}.envelope`);
43
53
  }
44
54
  }
45
55
  else if (item.action === "fanout") {
46
56
  if (fieldPresent(item, "envelope")) {
47
- throw new EnvelopeError(`clone_forks fanout for ${item.successor_id} must not include a top-level envelope`);
57
+ throw new EnvelopeError(`${itemPath} fanout for ${item.successor_id} must not include a top-level envelope`);
48
58
  }
49
59
  if (!fieldPresent(item, "mode") || !fieldPresent(item, "clones") || item.clones === undefined) {
50
- throw new EnvelopeError(`clone_forks fanout for ${item.successor_id} requires mode and clones`);
60
+ throw new EnvelopeError(`${itemPath} fanout for ${item.successor_id} requires mode and clones`);
51
61
  }
52
62
  if (cloneCap === undefined || item.clones.length < 2 || item.clones.length > cloneCap) {
53
- throw new EnvelopeError(`clone_forks fanout for ${item.successor_id} must have between 2 and ${cloneCap} clones`);
63
+ throw new EnvelopeError(`${itemPath} fanout for ${item.successor_id} must have between 2 and ${cloneCap} clones`);
64
+ }
65
+ if (capEntry?.cloneInputSchema !== undefined) {
66
+ for (const [j, clone] of item.clones.entries()) {
67
+ assertCloneAssignmentPayload(clone.envelope, capEntry.cloneInputSchema, item.successor_id, `${itemPath}.clones[${j}].envelope`);
68
+ }
54
69
  }
55
70
  }
56
71
  }
@@ -0,0 +1,4 @@
1
+ import { type StageEnvelope } from "../types/envelope.js";
2
+ import type { FeedbackLoopConfig } from "../types/pipeline.js";
3
+ /** Validates an action against the source stage's declared feedback-loop policy. */
4
+ export declare function assertFeedbackLoopAction(envelope: StageEnvelope, context: FeedbackLoopConfig | undefined): void;
@@ -0,0 +1,31 @@
1
+ import { EnvelopeError } from "../types/envelope.js";
2
+ /** Validates an action against the source stage's declared feedback-loop policy. */
3
+ export function assertFeedbackLoopAction(envelope, context) {
4
+ if (context === undefined) {
5
+ if (envelope.feedback_loop !== undefined) {
6
+ throw new EnvelopeError("feedback_loop is only allowed for a configured feedback-loop stage");
7
+ }
8
+ return;
9
+ }
10
+ if (envelope.status === "failure") {
11
+ if (envelope.feedback_loop !== undefined) {
12
+ throw new EnvelopeError("feedback_loop action is not allowed when status is failure");
13
+ }
14
+ return;
15
+ }
16
+ const action = envelope.feedback_loop;
17
+ if (action === undefined) {
18
+ throw new EnvelopeError("feedback_loop action is required for a successful feedback-loop stage");
19
+ }
20
+ if (action.action === "continue")
21
+ return;
22
+ if (envelope.fork_choice !== undefined) {
23
+ throw new EnvelopeError("feedback_loop send_back cannot be combined with fork_choice");
24
+ }
25
+ if (envelope.clone_forks !== undefined) {
26
+ throw new EnvelopeError("feedback_loop send_back cannot be combined with clone_forks");
27
+ }
28
+ if (action.target !== context.target) {
29
+ throw new EnvelopeError(`feedback_loop.target "${action.target}" is not an allowed feedback-loop target`);
30
+ }
31
+ }
@@ -4,7 +4,13 @@ export type CompiledPayloadSchema = TSchema;
4
4
  /**
5
5
  * Compile a JSON Schema subset used for stage payload_schema.
6
6
  * Supported: type object/string/number/integer/boolean/array,
7
- * properties, required, items, additionalProperties (boolean).
7
+ * properties, required, items, additionalProperties (boolean),
8
+ * minItems, enum, minimum, maximum. String nodes also accept
9
+ * pattern (a JS RegExp validated with the unicode flag), minLength,
10
+ * and maxLength (non-negative integers). Nested nodes accept
11
+ * nullable (boolean), compiling to a union of that type with null;
12
+ * the root object cannot be nullable. Unknown keywords are ignored.
8
13
  */
9
14
  export declare function compilePayloadSchema(raw: unknown): CompiledPayloadSchema;
10
15
  export declare function assertEnvelopePayload(envelope: StageEnvelope, payloadSchema: unknown | undefined): void;
16
+ export declare function assertCloneAssignmentPayload(envelope: StageEnvelope, cloneInputSchema: unknown, successorId: string, itemPath?: string): void;
@@ -1,6 +1,89 @@
1
1
  import { Type } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
  import { EnvelopeError } from "../types/envelope.js";
4
+ function readEnum(schema, path, valueType) {
5
+ if (schema.enum === undefined) {
6
+ return undefined;
7
+ }
8
+ if (!Array.isArray(schema.enum) || schema.enum.length === 0) {
9
+ throw new Error(`${path}: enum must be a non-empty array when present`);
10
+ }
11
+ if (valueType === "string") {
12
+ if (!schema.enum.every((value) => typeof value === "string")) {
13
+ throw new Error(`${path}: enum values must be strings`);
14
+ }
15
+ return schema.enum;
16
+ }
17
+ if (!schema.enum.every((value) => typeof value === "number" && Number.isInteger(value))) {
18
+ throw new Error(`${path}: enum values must be integers`);
19
+ }
20
+ return schema.enum;
21
+ }
22
+ function readBound(value, path, keyword) {
23
+ if (value === undefined) {
24
+ return undefined;
25
+ }
26
+ if (typeof value !== "number" || !Number.isFinite(value)) {
27
+ throw new Error(`${path}: ${keyword} must be a number when present`);
28
+ }
29
+ return value;
30
+ }
31
+ function numericOptions(schema, path) {
32
+ const minimum = readBound(schema.minimum, path, "minimum");
33
+ const maximum = readBound(schema.maximum, path, "maximum");
34
+ if (minimum === undefined && maximum === undefined) {
35
+ return undefined;
36
+ }
37
+ return {
38
+ ...(minimum !== undefined ? { minimum } : {}),
39
+ ...(maximum !== undefined ? { maximum } : {}),
40
+ };
41
+ }
42
+ function readPattern(value, path) {
43
+ if (value === undefined) {
44
+ return undefined;
45
+ }
46
+ if (typeof value !== "string") {
47
+ throw new Error(`${path}: pattern must be a string when present`);
48
+ }
49
+ try {
50
+ // eslint-disable-next-line no-new
51
+ new RegExp(value, "u");
52
+ }
53
+ catch {
54
+ throw new Error(`${path}: pattern must be a valid regular expression`);
55
+ }
56
+ return value;
57
+ }
58
+ function readNonNegativeInt(value, path, keyword) {
59
+ if (value === undefined) {
60
+ return undefined;
61
+ }
62
+ if (typeof value !== "number" ||
63
+ !Number.isInteger(value) ||
64
+ value < 0) {
65
+ throw new Error(`${path}: ${keyword} must be a non-negative integer when present`);
66
+ }
67
+ return value;
68
+ }
69
+ function stringOptions(schema, path) {
70
+ const pattern = readPattern(schema.pattern, path);
71
+ const minLength = readNonNegativeInt(schema.minLength, path, "minLength");
72
+ const maxLength = readNonNegativeInt(schema.maxLength, path, "maxLength");
73
+ if (minLength !== undefined &&
74
+ maxLength !== undefined &&
75
+ minLength > maxLength) {
76
+ throw new Error(`${path}: minLength must be <= maxLength`);
77
+ }
78
+ if (pattern === undefined && minLength === undefined && maxLength === undefined) {
79
+ return undefined;
80
+ }
81
+ return {
82
+ ...(pattern !== undefined ? { pattern } : {}),
83
+ ...(minLength !== undefined ? { minLength } : {}),
84
+ ...(maxLength !== undefined ? { maxLength } : {}),
85
+ };
86
+ }
4
87
  function compileNode(node, path) {
5
88
  if (node === null || typeof node !== "object" || Array.isArray(node)) {
6
89
  throw new Error(`${path}: schema must be an object`);
@@ -9,20 +92,58 @@ function compileNode(node, path) {
9
92
  if (typeof schema.type !== "string") {
10
93
  throw new Error(`${path}: type is required`);
11
94
  }
95
+ if (schema.nullable !== undefined && typeof schema.nullable !== "boolean") {
96
+ throw new Error(`${path}: nullable must be a boolean when present`);
97
+ }
98
+ const nullable = schema.nullable === true;
99
+ let base;
12
100
  switch (schema.type) {
13
- case "string":
14
- return Type.String();
15
- case "number":
16
- return Type.Number();
17
- case "integer":
18
- return Type.Integer();
101
+ case "string": {
102
+ const enumerated = readEnum(schema, path, "string");
103
+ const opts = stringOptions(schema, path);
104
+ base =
105
+ enumerated !== undefined
106
+ ? Type.Enum(enumerated, opts)
107
+ : opts !== undefined
108
+ ? Type.String(opts)
109
+ : Type.String();
110
+ break;
111
+ }
112
+ case "number": {
113
+ const options = numericOptions(schema, path);
114
+ base = options !== undefined ? Type.Number(options) : Type.Number();
115
+ break;
116
+ }
117
+ case "integer": {
118
+ const enumerated = readEnum(schema, path, "integer");
119
+ const options = numericOptions(schema, path);
120
+ if (enumerated !== undefined) {
121
+ base = Type.Enum(enumerated, options);
122
+ }
123
+ else {
124
+ base = options !== undefined ? Type.Integer(options) : Type.Integer();
125
+ }
126
+ break;
127
+ }
19
128
  case "boolean":
20
- return Type.Boolean();
129
+ base = Type.Boolean();
130
+ break;
21
131
  case "array": {
22
132
  if (schema.items === undefined) {
23
133
  throw new Error(`${path}: array requires items`);
24
134
  }
25
- return Type.Array(compileNode(schema.items, `${path}.items`));
135
+ if (schema.minItems !== undefined &&
136
+ (typeof schema.minItems !== "number" ||
137
+ !Number.isInteger(schema.minItems) ||
138
+ schema.minItems < 0)) {
139
+ throw new Error(`${path}: minItems must be a non-negative integer when present`);
140
+ }
141
+ const items = compileNode(schema.items, `${path}.items`);
142
+ base =
143
+ schema.minItems !== undefined
144
+ ? Type.Array(items, { minItems: schema.minItems })
145
+ : Type.Array(items);
146
+ break;
26
147
  }
27
148
  case "object": {
28
149
  const properties = schema.properties ?? {};
@@ -51,24 +172,46 @@ function compileNode(node, path) {
51
172
  const options = schema.additionalProperties === false
52
173
  ? { additionalProperties: false }
53
174
  : undefined;
54
- return Type.Object(compiledProps, options);
175
+ base = Type.Object(compiledProps, options);
176
+ break;
55
177
  }
56
178
  default:
57
179
  throw new Error(`${path}: unsupported type "${schema.type}" (supported: object, string, number, integer, boolean, array)`);
58
180
  }
181
+ return nullable ? Type.Union([base, Type.Null()]) : base;
59
182
  }
60
183
  /**
61
184
  * Compile a JSON Schema subset used for stage payload_schema.
62
185
  * Supported: type object/string/number/integer/boolean/array,
63
- * properties, required, items, additionalProperties (boolean).
186
+ * properties, required, items, additionalProperties (boolean),
187
+ * minItems, enum, minimum, maximum. String nodes also accept
188
+ * pattern (a JS RegExp validated with the unicode flag), minLength,
189
+ * and maxLength (non-negative integers). Nested nodes accept
190
+ * nullable (boolean), compiling to a union of that type with null;
191
+ * the root object cannot be nullable. Unknown keywords are ignored.
64
192
  */
65
193
  export function compilePayloadSchema(raw) {
194
+ if (raw !== null &&
195
+ typeof raw === "object" &&
196
+ !Array.isArray(raw) &&
197
+ raw.nullable === true) {
198
+ throw new Error("payload_schema: root cannot be nullable");
199
+ }
66
200
  const compiled = compileNode(raw, "payload_schema");
67
201
  if (!Type.IsObject(compiled)) {
68
202
  throw new Error("payload_schema: root type must be object");
69
203
  }
70
204
  return compiled;
71
205
  }
206
+ function formatPayloadInstancePath(instancePath) {
207
+ const translated = instancePath.replace(/\//g, ".").replace(/^\./, "");
208
+ return translated ? `payload.${translated}` : "payload";
209
+ }
210
+ function payloadSchemaMismatchDetails(schema, payload) {
211
+ return [...Value.Errors(schema, payload)]
212
+ .map((err) => `${formatPayloadInstancePath(err.instancePath)} ${err.message}`)
213
+ .join("; ");
214
+ }
72
215
  export function assertEnvelopePayload(envelope, payloadSchema) {
73
216
  if (payloadSchema === undefined) {
74
217
  return;
@@ -81,11 +224,25 @@ export function assertEnvelopePayload(envelope, payloadSchema) {
81
224
  throw new EnvelopeError("payload is required by stage payload_schema");
82
225
  }
83
226
  if (!Value.Check(schema, envelope.payload)) {
84
- const details = [...Value.Errors(schema, envelope.payload)]
85
- .map((err) => `${err.instancePath || "/"} ${err.message}`)
86
- .join("; ");
227
+ const details = payloadSchemaMismatchDetails(schema, envelope.payload);
87
228
  throw new EnvelopeError(details
88
229
  ? `payload does not match payload_schema: ${details}`
89
230
  : "payload does not match payload_schema");
90
231
  }
91
232
  }
233
+ export function assertCloneAssignmentPayload(envelope, cloneInputSchema, successorId, itemPath = "") {
234
+ if (envelope.status !== "success") {
235
+ return;
236
+ }
237
+ const schema = compilePayloadSchema(cloneInputSchema);
238
+ const pathLead = itemPath ? `${itemPath}: ` : "";
239
+ if (envelope.payload === undefined) {
240
+ throw new EnvelopeError(`${pathLead}clone assignment payload is required by clone_input_schema for ${successorId}`);
241
+ }
242
+ if (!Value.Check(schema, envelope.payload)) {
243
+ const details = payloadSchemaMismatchDetails(schema, envelope.payload);
244
+ throw new EnvelopeError(details
245
+ ? `${pathLead}clone assignment payload does not match clone_input_schema for ${successorId}: ${details}`
246
+ : `${pathLead}clone assignment payload does not match clone_input_schema for ${successorId}`);
247
+ }
248
+ }