@relayflows/sdk 2.0.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 (231) hide show
  1. package/dist/authored-flow-error.d.ts +9 -0
  2. package/dist/authored-flow-error.d.ts.map +1 -0
  3. package/dist/authored-flow-error.js +13 -0
  4. package/dist/authored-flow-error.js.map +1 -0
  5. package/dist/authored-flow-executor.d.ts +28 -0
  6. package/dist/authored-flow-executor.d.ts.map +1 -0
  7. package/dist/authored-flow-executor.js +203 -0
  8. package/dist/authored-flow-executor.js.map +1 -0
  9. package/dist/authored-flow-lifecycle.d.ts +61 -0
  10. package/dist/authored-flow-lifecycle.d.ts.map +1 -0
  11. package/dist/authored-flow-lifecycle.js +284 -0
  12. package/dist/authored-flow-lifecycle.js.map +1 -0
  13. package/dist/authored-flow-loader.d.ts +7 -0
  14. package/dist/authored-flow-loader.d.ts.map +1 -0
  15. package/dist/authored-flow-loader.js +39 -0
  16. package/dist/authored-flow-loader.js.map +1 -0
  17. package/dist/authored-flow-operation.d.ts +43 -0
  18. package/dist/authored-flow-operation.d.ts.map +1 -0
  19. package/dist/authored-flow-operation.js +176 -0
  20. package/dist/authored-flow-operation.js.map +1 -0
  21. package/dist/authored-flow.d.ts +12 -0
  22. package/dist/authored-flow.d.ts.map +1 -0
  23. package/dist/authored-flow.js +13 -0
  24. package/dist/authored-flow.js.map +1 -0
  25. package/dist/authored-promise-graph.d.ts +67 -0
  26. package/dist/authored-promise-graph.d.ts.map +1 -0
  27. package/dist/authored-promise-graph.js +206 -0
  28. package/dist/authored-promise-graph.js.map +1 -0
  29. package/dist/backlog-picker.d.ts +55 -0
  30. package/dist/backlog-picker.d.ts.map +1 -0
  31. package/dist/backlog-picker.js +117 -0
  32. package/dist/backlog-picker.js.map +1 -0
  33. package/dist/canonical.d.ts +18 -0
  34. package/dist/canonical.d.ts.map +1 -0
  35. package/dist/canonical.js +55 -0
  36. package/dist/canonical.js.map +1 -0
  37. package/dist/cli/check.d.ts +30 -0
  38. package/dist/cli/check.d.ts.map +1 -0
  39. package/dist/cli/check.js +324 -0
  40. package/dist/cli/check.js.map +1 -0
  41. package/dist/cli/direct-run.d.ts +3 -0
  42. package/dist/cli/direct-run.d.ts.map +1 -0
  43. package/dist/cli/direct-run.js +69 -0
  44. package/dist/cli/direct-run.js.map +1 -0
  45. package/dist/cli/hn-monitor.d.ts +107 -0
  46. package/dist/cli/hn-monitor.d.ts.map +1 -0
  47. package/dist/cli/hn-monitor.js +220 -0
  48. package/dist/cli/hn-monitor.js.map +1 -0
  49. package/dist/cli/interruptible-sleep.d.ts +13 -0
  50. package/dist/cli/interruptible-sleep.d.ts.map +1 -0
  51. package/dist/cli/interruptible-sleep.js +31 -0
  52. package/dist/cli/interruptible-sleep.js.map +1 -0
  53. package/dist/cli/run.d.ts +54 -0
  54. package/dist/cli/run.d.ts.map +1 -0
  55. package/dist/cli/run.js +316 -0
  56. package/dist/cli/run.js.map +1 -0
  57. package/dist/cli/tick-runner.d.ts +151 -0
  58. package/dist/cli/tick-runner.d.ts.map +1 -0
  59. package/dist/cli/tick-runner.js +258 -0
  60. package/dist/cli/tick-runner.js.map +1 -0
  61. package/dist/cli-adapter.d.ts +29 -0
  62. package/dist/cli-adapter.d.ts.map +1 -0
  63. package/dist/cli-adapter.js +92 -0
  64. package/dist/cli-adapter.js.map +1 -0
  65. package/dist/cli-executable.d.ts +3 -0
  66. package/dist/cli-executable.d.ts.map +1 -0
  67. package/dist/cli-executable.js +10 -0
  68. package/dist/cli-executable.js.map +1 -0
  69. package/dist/cli.d.ts +9 -0
  70. package/dist/cli.d.ts.map +1 -0
  71. package/dist/cli.js +348 -0
  72. package/dist/cli.js.map +1 -0
  73. package/dist/compile.d.ts +42 -0
  74. package/dist/compile.d.ts.map +1 -0
  75. package/dist/compile.js +515 -0
  76. package/dist/compile.js.map +1 -0
  77. package/dist/demo-hn-monitor.d.ts +2 -0
  78. package/dist/demo-hn-monitor.d.ts.map +1 -0
  79. package/dist/demo-hn-monitor.js +118 -0
  80. package/dist/demo-hn-monitor.js.map +1 -0
  81. package/dist/dir-watcher-poller.d.ts +73 -0
  82. package/dist/dir-watcher-poller.d.ts.map +1 -0
  83. package/dist/dir-watcher-poller.js +80 -0
  84. package/dist/dir-watcher-poller.js.map +1 -0
  85. package/dist/direct-input.d.ts +10 -0
  86. package/dist/direct-input.d.ts.map +1 -0
  87. package/dist/direct-input.js +61 -0
  88. package/dist/direct-input.js.map +1 -0
  89. package/dist/failure-kinds.d.ts +25 -0
  90. package/dist/failure-kinds.d.ts.map +1 -0
  91. package/dist/failure-kinds.js +64 -0
  92. package/dist/failure-kinds.js.map +1 -0
  93. package/dist/gate-contract.d.ts +24 -0
  94. package/dist/gate-contract.d.ts.map +1 -0
  95. package/dist/gate-contract.js +50 -0
  96. package/dist/gate-contract.js.map +1 -0
  97. package/dist/hn-poller.d.ts +50 -0
  98. package/dist/hn-poller.d.ts.map +1 -0
  99. package/dist/hn-poller.js +79 -0
  100. package/dist/hn-poller.js.map +1 -0
  101. package/dist/index.d.ts +21 -0
  102. package/dist/index.d.ts.map +1 -0
  103. package/dist/index.js +30 -0
  104. package/dist/index.js.map +1 -0
  105. package/dist/journal-client.d.ts +122 -0
  106. package/dist/journal-client.d.ts.map +1 -0
  107. package/dist/journal-client.js +277 -0
  108. package/dist/journal-client.js.map +1 -0
  109. package/dist/json-schema-bound.d.ts +7 -0
  110. package/dist/json-schema-bound.d.ts.map +1 -0
  111. package/dist/json-schema-bound.js +353 -0
  112. package/dist/json-schema-bound.js.map +1 -0
  113. package/dist/json-schema.d.ts +5 -0
  114. package/dist/json-schema.d.ts.map +1 -0
  115. package/dist/json-schema.js +72 -0
  116. package/dist/json-schema.js.map +1 -0
  117. package/dist/json-value.d.ts +6 -0
  118. package/dist/json-value.d.ts.map +1 -0
  119. package/dist/json-value.js +94 -0
  120. package/dist/json-value.js.map +1 -0
  121. package/dist/model-name.d.ts +7 -0
  122. package/dist/model-name.d.ts.map +1 -0
  123. package/dist/model-name.js +21 -0
  124. package/dist/model-name.js.map +1 -0
  125. package/dist/output-schema.d.ts +8 -0
  126. package/dist/output-schema.d.ts.map +1 -0
  127. package/dist/output-schema.js +28 -0
  128. package/dist/output-schema.js.map +1 -0
  129. package/dist/preflight.d.ts +80 -0
  130. package/dist/preflight.d.ts.map +1 -0
  131. package/dist/preflight.js +361 -0
  132. package/dist/preflight.js.map +1 -0
  133. package/dist/protocol.d.ts +359 -0
  134. package/dist/protocol.d.ts.map +1 -0
  135. package/dist/protocol.js +15 -0
  136. package/dist/protocol.js.map +1 -0
  137. package/dist/spec.d.ts +294 -0
  138. package/dist/spec.d.ts.map +1 -0
  139. package/dist/spec.js +13 -0
  140. package/dist/spec.js.map +1 -0
  141. package/dist/step-dependencies.d.ts +2 -0
  142. package/dist/step-dependencies.d.ts.map +1 -0
  143. package/dist/step-dependencies.js +88 -0
  144. package/dist/step-dependencies.js.map +1 -0
  145. package/dist/step-fields.d.ts +17 -0
  146. package/dist/step-fields.d.ts.map +1 -0
  147. package/dist/step-fields.js +32 -0
  148. package/dist/step-fields.js.map +1 -0
  149. package/dist/tick-source.d.ts +205 -0
  150. package/dist/tick-source.d.ts.map +1 -0
  151. package/dist/tick-source.js +234 -0
  152. package/dist/tick-source.js.map +1 -0
  153. package/dist/unknown-keys.d.ts +7 -0
  154. package/dist/unknown-keys.d.ts.map +1 -0
  155. package/dist/unknown-keys.js +47 -0
  156. package/dist/unknown-keys.js.map +1 -0
  157. package/dist/validate.d.ts +7 -0
  158. package/dist/validate.d.ts.map +1 -0
  159. package/dist/validate.js +458 -0
  160. package/dist/validate.js.map +1 -0
  161. package/dist/work-package-consumer.d.ts +33 -0
  162. package/dist/work-package-consumer.d.ts.map +1 -0
  163. package/dist/work-package-consumer.js +44 -0
  164. package/dist/work-package-consumer.js.map +1 -0
  165. package/dist/work-package-validator.d.ts +15 -0
  166. package/dist/work-package-validator.d.ts.map +1 -0
  167. package/dist/work-package-validator.js +72 -0
  168. package/dist/work-package-validator.js.map +1 -0
  169. package/dist/worker-cli.d.ts +16 -0
  170. package/dist/worker-cli.d.ts.map +1 -0
  171. package/dist/worker-cli.js +77 -0
  172. package/dist/worker-cli.js.map +1 -0
  173. package/dist/worker.d.ts +57 -0
  174. package/dist/worker.d.ts.map +1 -0
  175. package/dist/worker.js +129 -0
  176. package/dist/worker.js.map +1 -0
  177. package/dist/wrapper-runtime.d.ts +10 -0
  178. package/dist/wrapper-runtime.d.ts.map +1 -0
  179. package/dist/wrapper-runtime.js +83 -0
  180. package/dist/wrapper-runtime.js.map +1 -0
  181. package/dist/wrapper-session.d.ts +16 -0
  182. package/dist/wrapper-session.d.ts.map +1 -0
  183. package/dist/wrapper-session.js +244 -0
  184. package/dist/wrapper-session.js.map +1 -0
  185. package/package.json +50 -0
  186. package/src/authored-flow-error.ts +31 -0
  187. package/src/authored-flow-executor.ts +365 -0
  188. package/src/authored-flow-lifecycle.ts +344 -0
  189. package/src/authored-flow-loader.ts +44 -0
  190. package/src/authored-flow-operation.ts +252 -0
  191. package/src/authored-flow.ts +21 -0
  192. package/src/authored-promise-graph.ts +196 -0
  193. package/src/backlog-picker.ts +153 -0
  194. package/src/canonical.ts +57 -0
  195. package/src/cli/check.ts +391 -0
  196. package/src/cli/direct-run.ts +86 -0
  197. package/src/cli/hn-monitor.ts +286 -0
  198. package/src/cli/interruptible-sleep.ts +27 -0
  199. package/src/cli/run.ts +439 -0
  200. package/src/cli/tick-runner.ts +378 -0
  201. package/src/cli-adapter.ts +115 -0
  202. package/src/cli-executable.ts +12 -0
  203. package/src/cli.ts +364 -0
  204. package/src/compile.ts +573 -0
  205. package/src/demo-hn-monitor.ts +132 -0
  206. package/src/dir-watcher-poller.ts +122 -0
  207. package/src/direct-input.ts +69 -0
  208. package/src/failure-kinds.ts +77 -0
  209. package/src/gate-contract.ts +72 -0
  210. package/src/hn-poller.ts +104 -0
  211. package/src/index.ts +195 -0
  212. package/src/journal-client.ts +379 -0
  213. package/src/json-schema-bound.ts +357 -0
  214. package/src/json-schema.ts +65 -0
  215. package/src/json-value.ts +110 -0
  216. package/src/model-name.ts +20 -0
  217. package/src/output-schema.ts +33 -0
  218. package/src/preflight.ts +482 -0
  219. package/src/protocol.ts +364 -0
  220. package/src/spec.ts +343 -0
  221. package/src/step-dependencies.ts +100 -0
  222. package/src/step-fields.ts +36 -0
  223. package/src/tick-source.ts +334 -0
  224. package/src/unknown-keys.ts +52 -0
  225. package/src/validate.ts +503 -0
  226. package/src/work-package-consumer.ts +73 -0
  227. package/src/work-package-validator.ts +90 -0
  228. package/src/worker-cli.ts +110 -0
  229. package/src/worker.ts +150 -0
  230. package/src/wrapper-runtime.ts +93 -0
  231. package/src/wrapper-session.ts +287 -0
@@ -0,0 +1,503 @@
1
+ // Spec validation — fail-closed (AGENTS.md rule 4). The compiler runs every
2
+ // spec through this before emitting JSON; a malformed spec is rejected with a
3
+ // concrete error, never silently coerced. Zero-agent flows are legal: there is
4
+ // no requirement that any step be `llm` or `agent`.
5
+
6
+ import type {
7
+ AgentStepSpec,
8
+ BudgetSpec,
9
+ DeterministicStepSpec,
10
+ FlowSpec,
11
+ LlmStepSpec,
12
+ NamedAgentSpec,
13
+ PermissionsSpec,
14
+ RecoveryMode,
15
+ StepType,
16
+ TriggerSpec,
17
+ VerificationSpec,
18
+ } from './spec.js';
19
+ import { SPEC_SCHEMA_VERSION } from './spec.js';
20
+ import { validateOutputDeclaration } from './output-schema.js';
21
+ import { modelNameError } from './model-name.js';
22
+ import { unknownKeyErrors } from './unknown-keys.js';
23
+ import { stepDependencyErrors } from './step-dependencies.js';
24
+ import {
25
+ AGENT_DECLARATION_FIELDS,
26
+ FLOW_FIELDS,
27
+ STEP_COMMON_FIELDS,
28
+ STEP_FIELDS_BY_TYPE,
29
+ } from './step-fields.js';
30
+ import { jsonSchemaError, snapshotJsonSchema } from './json-schema.js';
31
+ import { snapshotJsonValue } from './json-value.js';
32
+
33
+ export interface ValidationResult {
34
+ ok: boolean;
35
+ errors: string[];
36
+ }
37
+
38
+ const STEP_TYPES: ReadonlySet<StepType> = new Set([
39
+ 'deterministic',
40
+ 'llm',
41
+ 'agent',
42
+ ]);
43
+
44
+ const RECOVERY_MODES: ReadonlySet<RecoveryMode> = new Set([
45
+ 'reset',
46
+ 'inspect',
47
+ 'manual',
48
+ ]);
49
+
50
+ const DECIMAL_RE = /^\d+(\.\d+)?$/;
51
+
52
+ function isCanonicalPathSurface(value: unknown): value is string {
53
+ if (!isNonEmptyString(value) || value.trim() !== value) return false;
54
+ let tail = value;
55
+ if (tail.startsWith('/')) tail = tail.slice(1);
56
+ else {
57
+ const scheme = tail.indexOf('://');
58
+ if (scheme >= 0) {
59
+ if (scheme === 0 || tail.slice(0, scheme).includes('/')) return false;
60
+ tail = tail.slice(scheme + 3);
61
+ }
62
+ }
63
+ if (tail === '') return true;
64
+ return tail.split('/').every((part) => part !== '' && part !== '.' && part !== '..');
65
+ }
66
+
67
+ // Allowed keys per authoring object level. Validation is fail-closed on
68
+ // unknown keys (AGENTS.md rule 4; RFC covenant 2): a typo'd key like
69
+ // `depends_on` must be an error naming the nearest valid key, never a
70
+ // silently discarded field — silently dropping `dependsOn` loses ordering.
71
+ const BUDGET_KEYS = ['maxTokensIn', 'maxTokensOut', 'maxDollars'] as const;
72
+ const VERIFICATION_KEYS: Record<string, readonly string[]> = {
73
+ exit_code: ['type', 'expect'],
74
+ output_contains: ['type', 'value'],
75
+ json_schema: ['type', 'schema'],
76
+ };
77
+ const SURFACES_KEYS = ['workspace', 'streams', 'external'] as const;
78
+ const WORKSPACE_SURFACE_KEYS = ['surface'] as const;
79
+ const STREAM_SURFACE_KEYS = ['stream'] as const;
80
+ const PERMISSIONS_KEYS = ['fileGlobs', 'networkAllowlist', 'accessPreset'] as const;
81
+ // A trigger may also declare an event subscription. Without these keys the
82
+ // compiler rejects the very fixture the kernel advertises
83
+ // (testdata/event-triggered-flow.yaml), so the feature is unauthorable through
84
+ // the supported SDK path even though the kernel accepts it.
85
+ const TRIGGER_KEYS = [
86
+ 'id',
87
+ 'executor',
88
+ 'eventType',
89
+ 'pattern',
90
+ 'dedupeKeyTemplate',
91
+ 'staleAfterMs',
92
+ ] as const;
93
+
94
+ /**
95
+ * The kernel stores a silence budget as SQLite `INTEGER` and compares it in
96
+ * `i64` (`TriggerSpec::effective_stale_after_ms`), refusing anything wider.
97
+ * Mirror the bound here so an unrepresentable budget is a compile error rather
98
+ * than an engine error at submit time — a budget the sweep cannot represent
99
+ * fails OPEN, which is the silent death this field exists to prevent.
100
+ *
101
+ * The bound is `Number.MAX_SAFE_INTEGER`, NOT `i64::MAX`. Writing the i64
102
+ * bound as a JS literal does not express it: `9_223_372_036_854_775_807`
103
+ * rounds UP to 2^63 in a double, so `value > MAX` then ADMITTED exactly the
104
+ * one value the kernel refuses — the SDK/kernel-agreement failure this whole
105
+ * file exists to prevent, in miniature. Above 2^53 a JS number cannot name a
106
+ * specific integer at all, so any larger budget could not be transmitted
107
+ * faithfully even if the kernel would take it. 2^53 ms is ~285,000 years;
108
+ * nothing real is lost by refusing beyond it.
109
+ */
110
+ const MAX_STALE_AFTER_MS = Number.MAX_SAFE_INTEGER;
111
+
112
+ class Validator {
113
+ private errors: string[] = [];
114
+ private ids = new Set<string>();
115
+ private agentNames = new Set<string>();
116
+
117
+ fail(msg: string): void {
118
+ this.errors.push(msg);
119
+ }
120
+
121
+ /**
122
+ * Reject unknown keys at an authoring object level, suggesting the nearest
123
+ * valid key. Errors speak the author's vocabulary (RFC covenant 1):
124
+ * `unknown key "depends_on" — did you mean "dependsOn"?`.
125
+ */
126
+ private checkKeys(obj: Record<string, unknown>, allowed: readonly string[], at: string): void {
127
+ for (const error of unknownKeyErrors(obj, allowed, at)) this.fail(error);
128
+ }
129
+
130
+ result(): ValidationResult {
131
+ return { ok: this.errors.length === 0, errors: this.errors };
132
+ }
133
+
134
+ run(spec: unknown): ValidationResult {
135
+ if (!isObject(spec)) {
136
+ this.fail('spec: expected an object');
137
+ return this.result();
138
+ }
139
+ const s = spec as Record<string, unknown>;
140
+ this.checkKeys(s, FLOW_FIELDS, 'spec');
141
+
142
+ if (!isNonEmptyString(s['version'])) {
143
+ this.fail(`spec.version: expected supported version "${SPEC_SCHEMA_VERSION}"`);
144
+ } else if (s['version'] !== SPEC_SCHEMA_VERSION) {
145
+ this.fail(`spec.version: unsupported version "${s['version']}" (expected "${SPEC_SCHEMA_VERSION}")`);
146
+ }
147
+
148
+ if (s['name'] !== undefined && !isNonEmptyString(s['name'])) {
149
+ this.fail('spec.name: expected a non-empty string');
150
+ }
151
+
152
+ if (s['description'] !== undefined && typeof s['description'] !== 'string') {
153
+ this.fail('spec.description: expected a string');
154
+ }
155
+
156
+ if (s['cli'] !== undefined && !isNonEmptyString(s['cli'])) {
157
+ this.fail('spec.cli: expected a non-empty string');
158
+ }
159
+
160
+ if (s['agents'] !== undefined) this.validateAgents(s['agents']);
161
+
162
+ if (s['triggers'] !== undefined) this.validateTriggers(s['triggers']);
163
+
164
+ if (s['budget'] !== undefined) this.validateBudget(s['budget']);
165
+
166
+ if (!Array.isArray(s['steps']) || s['steps'].length === 0) {
167
+ this.fail('spec.steps: expected a non-empty array');
168
+ return this.result();
169
+ }
170
+
171
+ const steps = s['steps'] as unknown[];
172
+ for (let i = 0; i < steps.length; i++) {
173
+ this.validateStep(steps[i], i);
174
+ }
175
+
176
+ // Dependents must reference real step ids and form a DAG (no cycles).
177
+ for (const error of stepDependencyErrors(steps, this.ids)) this.fail(error);
178
+ return this.result();
179
+ }
180
+
181
+ private validateAgents(value: unknown): void {
182
+ if (!isObject(value)) {
183
+ this.fail('spec.agents: expected a map of named { cli, model } declarations');
184
+ return;
185
+ }
186
+ for (const [name, raw] of Object.entries(value)) {
187
+ const at = `spec.agents.${name}`;
188
+ if (!isNonEmptyString(name) || name !== name.trim()) {
189
+ this.fail('spec.agents: agent names must be non-empty trimmed strings');
190
+ continue;
191
+ }
192
+ this.agentNames.add(name);
193
+ if (!isObject(raw)) {
194
+ this.fail(`${at}: expected an object with cli and model`);
195
+ continue;
196
+ }
197
+ this.checkKeys(raw, AGENT_DECLARATION_FIELDS, at);
198
+ const declaration = raw as unknown as NamedAgentSpec;
199
+ if (!isNonEmptyString(declaration.cli) || declaration.cli !== declaration.cli.trim()) {
200
+ this.fail(`${at}.cli: expected a non-empty trimmed string`);
201
+ }
202
+ this.validateModel(declaration.model, at, true);
203
+ }
204
+ }
205
+
206
+ private validateBudget(b: unknown): void {
207
+ if (!isObject(b)) {
208
+ this.fail('spec.budget: expected an object');
209
+ return;
210
+ }
211
+ this.checkKeys(b, BUDGET_KEYS, 'spec.budget');
212
+ const budget = b as BudgetSpec;
213
+ if (
214
+ budget.maxTokensIn !== undefined &&
215
+ !isNonNegInt(budget.maxTokensIn)
216
+ ) {
217
+ this.fail('spec.budget.maxTokensIn: expected a non-negative integer');
218
+ }
219
+ if (
220
+ budget.maxTokensOut !== undefined &&
221
+ !isNonNegInt(budget.maxTokensOut)
222
+ ) {
223
+ this.fail('spec.budget.maxTokensOut: expected a non-negative integer');
224
+ }
225
+ if (budget.maxDollars !== undefined) {
226
+ if (typeof budget.maxDollars !== 'string' || !DECIMAL_RE.test(budget.maxDollars)) {
227
+ this.fail('spec.budget.maxDollars: expected a decimal string, e.g. "1.50"');
228
+ }
229
+ }
230
+ }
231
+
232
+ private validateTriggers(value: unknown): void {
233
+ if (!Array.isArray(value)) {
234
+ this.fail('spec.triggers: expected an array');
235
+ return;
236
+ }
237
+ const ids = new Set<string>();
238
+ for (const [index, trigger] of value.entries()) {
239
+ const at = `spec.triggers[${index}]`;
240
+ if (!isObject(trigger)) {
241
+ this.fail(`${at}: expected an object`);
242
+ continue;
243
+ }
244
+ this.checkKeys(trigger, TRIGGER_KEYS, at);
245
+ const candidate = trigger as unknown as TriggerSpec;
246
+ if (!isNonEmptyString(candidate.id)) {
247
+ this.fail(`${at}.id: expected a non-empty string`);
248
+ } else if (ids.has(candidate.id)) {
249
+ this.fail(`${at}.id: duplicate trigger id "${candidate.id}"`);
250
+ } else {
251
+ ids.add(candidate.id);
252
+ }
253
+ if (!isNonEmptyString(candidate.executor)) {
254
+ this.fail(`${at}.executor: expected a non-empty string`);
255
+ }
256
+ this.validateStaleAfterMs(candidate.staleAfterMs, `${at}.staleAfterMs`);
257
+ }
258
+ }
259
+
260
+ /**
261
+ * A silence budget must be a positive, i64-representable whole number of
262
+ * milliseconds. Zero is refused rather than treated as "no budget": a
263
+ * zero-length budget marks the subscription stale on the very next sweep,
264
+ * which reads as a permanently-broken schedule and trains an operator to
265
+ * ignore the alert.
266
+ */
267
+ private validateStaleAfterMs(value: unknown, at: string): void {
268
+ if (value === undefined) return;
269
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
270
+ this.fail(`${at}: expected an integer number of milliseconds`);
271
+ return;
272
+ }
273
+ if (value <= 0) {
274
+ this.fail(`${at}: expected a positive number of milliseconds, got ${value}`);
275
+ return;
276
+ }
277
+ if (value > MAX_STALE_AFTER_MS) {
278
+ this.fail(
279
+ `${at}: ${value} exceeds ${MAX_STALE_AFTER_MS}, the largest budget that survives the `
280
+ + `SDK -> kernel boundary exactly (the sweep stores it as i64)`,
281
+ );
282
+ }
283
+ }
284
+
285
+ private validateStep(step: unknown, index: number): void {
286
+ const at = `spec.steps[${index}]`;
287
+ if (!isObject(step)) {
288
+ this.fail(`${at}: expected an object`);
289
+ return;
290
+ }
291
+ const st = step as Record<string, unknown>;
292
+
293
+ if (!isNonEmptyString(st['id'])) {
294
+ this.fail(`${at}.id: expected a non-empty string`);
295
+ } else if (this.ids.has(st['id'] as string)) {
296
+ this.fail(`${at}.id: duplicate step id "${st['id']}"`);
297
+ } else {
298
+ this.ids.add(st['id'] as string);
299
+ }
300
+
301
+ if (!isNonEmptyString(st['type']) || !STEP_TYPES.has(st['type'] as StepType)) {
302
+ this.fail(`${at}.type: expected one of deterministic | llm | agent`);
303
+ return;
304
+ }
305
+ const type = st['type'] as StepType;
306
+ this.checkKeys(st, [...STEP_COMMON_FIELDS, ...STEP_FIELDS_BY_TYPE[type]], at);
307
+
308
+ if (st['dependsOn'] !== undefined) {
309
+ if (!Array.isArray(st['dependsOn']) || !(st['dependsOn'] as unknown[]).every(isNonEmptyString)) {
310
+ this.fail(`${at}.dependsOn: expected an array of step ids`);
311
+ }
312
+ }
313
+
314
+ if (st['verification'] !== undefined) {
315
+ this.validateVerification(st['verification'], `${at}.verification`, type);
316
+ }
317
+
318
+ if (st['maxIterations'] !== undefined && !isPosInt(st['maxIterations'])) {
319
+ this.fail(`${at}.maxIterations: expected a positive integer`);
320
+ }
321
+
322
+ if (type === 'deterministic') {
323
+ this.validateDeterministic(st as unknown as DeterministicStepSpec, at);
324
+ } else if (type === 'llm') {
325
+ this.validateLlm(st as unknown as LlmStepSpec, at);
326
+ for (const error of validateOutputDeclaration(st, at)) this.fail(error);
327
+ } else {
328
+ this.validateAgent(st as unknown as AgentStepSpec, at);
329
+ for (const error of validateOutputDeclaration(st, at)) this.fail(error);
330
+ }
331
+ }
332
+
333
+ private validateVerification(v: unknown, at: string, stepType: StepType): void {
334
+ if (!isObject(v)) {
335
+ this.fail(`${at}: expected an object`);
336
+ return;
337
+ }
338
+ const gate = v as unknown as VerificationSpec & { expect?: unknown };
339
+ const gateKeys = typeof gate.type === 'string' ? VERIFICATION_KEYS[gate.type] : undefined;
340
+ if (gateKeys !== undefined) {
341
+ this.checkKeys(v, gateKeys, at);
342
+ }
343
+ if (gate.type === 'exit_code') {
344
+ if (stepType !== 'deterministic') {
345
+ this.fail(`${at}: exit_code is supported only on deterministic steps`);
346
+ }
347
+ // v0 judges exit_code == 0 exactly (kernel DESIGN.md §4). Fail closed
348
+ // rather than compile a spec whose gate the kernel cannot enforce.
349
+ if (gate.expect !== undefined && gate.expect !== 0) {
350
+ this.fail(`${at}.expect: v0 exit_code gate judges exit_code == 0; a custom expect is not supported`);
351
+ }
352
+ } else if (gate.type === 'output_contains') {
353
+ if (typeof gate.value !== 'string' || gate.value.length === 0) {
354
+ this.fail(`${at}.value: expected a non-empty string`);
355
+ }
356
+ } else if (gate.type === 'json_schema') {
357
+ try {
358
+ const schema = snapshotJsonSchema(gate.schema, `${at}.schema`);
359
+ const error = jsonSchemaError(schema);
360
+ if (error !== undefined) {
361
+ this.fail(`${at}.schema: invalid JSON Schema: ${error}`);
362
+ }
363
+ } catch (error) {
364
+ this.fail(error instanceof Error ? error.message : `${at}.schema: expected JSON-compatible data`);
365
+ }
366
+ } else {
367
+ this.fail(`${at}.type: expected exit_code | output_contains | json_schema`);
368
+ }
369
+ }
370
+
371
+ private validateDeterministic(st: DeterministicStepSpec, at: string): void {
372
+ if (!isNonEmptyString(st.command)) {
373
+ this.fail(`${at}.command: expected a non-empty string`);
374
+ }
375
+ if (st.timeoutMs !== undefined && !isPosInt(st.timeoutMs)) {
376
+ this.fail(`${at}.timeoutMs: expected a positive integer`);
377
+ }
378
+ }
379
+
380
+ private validateLlm(st: LlmStepSpec, at: string): void {
381
+ if (!isNonEmptyString(st.prompt)) {
382
+ this.fail(`${at}.prompt: expected a non-empty string`);
383
+ }
384
+ this.validateModel(st.model, at);
385
+ this.validateCli(st.cli, at);
386
+ }
387
+
388
+ private validateAgent(st: AgentStepSpec, at: string): void {
389
+ if (!isNonEmptyString(st.instruction)) {
390
+ this.fail(`${at}.instruction: expected a non-empty string`);
391
+ }
392
+ if (st.agent !== undefined) {
393
+ if (!isNonEmptyString(st.agent)) {
394
+ this.fail(`${at}.agent: expected a non-empty named agent`);
395
+ } else if (!this.agentNames.has(st.agent)) {
396
+ this.fail(`${at}.agent: unknown named agent "${st.agent}"`);
397
+ }
398
+ }
399
+ if (st.recoveryMode !== undefined && !RECOVERY_MODES.has(st.recoveryMode)) {
400
+ this.fail(`${at}.recoveryMode: expected reset | inspect | manual`);
401
+ }
402
+ this.validateCli(st.cli, at);
403
+ this.validateModel(st.model, at);
404
+ if (st.surfaces !== undefined) this.validateSurfaces(st.surfaces, `${at}.surfaces`);
405
+ if (st.permissions !== undefined) this.validatePermissions(st.permissions, `${at}.permissions`);
406
+ }
407
+
408
+ private validateCli(cli: unknown, at: string): void {
409
+ if (cli !== undefined && !isNonEmptyString(cli)) {
410
+ this.fail(`${at}.cli: expected a non-empty string`);
411
+ }
412
+ }
413
+
414
+ private validateModel(model: unknown, at: string, required = false): void {
415
+ // Rejecting the empty string matters: it would reach the CLI as
416
+ // RELAYFLOW_MODEL='', which reads as "declared, and declared as
417
+ // nothing" — the CLI cannot tell it from a real value and would
418
+ // pass an empty --model. Absent and empty must not look alike.
419
+ if (model === undefined && !required) return;
420
+ const problem = modelNameError(model);
421
+ if (problem !== undefined) this.fail(`${at}.model: ${problem}`);
422
+ }
423
+
424
+ private validateSurfaces(surfaces: AgentStepSpec['surfaces'], at: string): void {
425
+ if (!isObject(surfaces)) {
426
+ this.fail(`${at}: expected an object`);
427
+ return;
428
+ }
429
+ const s = surfaces as Record<string, unknown>;
430
+ this.checkKeys(s, SURFACES_KEYS, at);
431
+ if (s['workspace'] !== undefined) {
432
+ if (!Array.isArray(s['workspace']) || !(s['workspace'] as unknown[]).every((w) => isObject(w) && isCanonicalPathSurface((w as Record<string, unknown>)['surface']))) {
433
+ this.fail(`${at}.workspace: expected canonical {surface: string} entries without empty, . or .. path components`);
434
+ } else {
435
+ for (const [i, w] of (s['workspace'] as Record<string, unknown>[]).entries()) {
436
+ this.checkKeys(w, WORKSPACE_SURFACE_KEYS, `${at}.workspace[${i}]`);
437
+ }
438
+ }
439
+ }
440
+ if (s['streams'] !== undefined) {
441
+ if (!Array.isArray(s['streams']) || !(s['streams'] as unknown[]).every((w) => isObject(w) && isNonEmptyString((w as Record<string, unknown>)['stream']))) {
442
+ this.fail(`${at}.streams: expected an array of {stream: string}`);
443
+ } else {
444
+ for (const [i, w] of (s['streams'] as Record<string, unknown>[]).entries()) {
445
+ this.checkKeys(w, STREAM_SURFACE_KEYS, `${at}.streams[${i}]`);
446
+ }
447
+ }
448
+ }
449
+ if (s['external'] !== undefined) {
450
+ if (!Array.isArray(s['external']) || !(s['external'] as unknown[]).every(isCanonicalPathSurface)) {
451
+ this.fail(`${at}.external: expected canonical path strings without empty, . or .. components`);
452
+ }
453
+ }
454
+ }
455
+
456
+ private validatePermissions(p: PermissionsSpec, at: string): void {
457
+ if (!isObject(p)) {
458
+ this.fail(`${at}: expected an object`);
459
+ return;
460
+ }
461
+ this.checkKeys(p as Record<string, unknown>, PERMISSIONS_KEYS, at);
462
+ if (p.accessPreset !== undefined && p.accessPreset !== 'readonly' && p.accessPreset !== 'readwrite') {
463
+ this.fail(`${at}.accessPreset: expected readonly | readwrite`);
464
+ }
465
+ if (p.fileGlobs !== undefined && !(Array.isArray(p.fileGlobs) && p.fileGlobs.every(isNonEmptyString))) {
466
+ this.fail(`${at}.fileGlobs: expected an array of strings`);
467
+ }
468
+ if (p.networkAllowlist !== undefined && !(Array.isArray(p.networkAllowlist) && p.networkAllowlist.every(isNonEmptyString))) {
469
+ this.fail(`${at}.networkAllowlist: expected an array of strings`);
470
+ }
471
+ }
472
+
473
+ }
474
+
475
+ /** Validate a parsed spec object. Returns `{ok, errors}`; never throws. */
476
+ export function validateSpec(spec: unknown): ValidationResult {
477
+ try {
478
+ return new Validator().run(snapshotJsonValue(spec, 'spec'));
479
+ } catch (error) {
480
+ return {
481
+ ok: false,
482
+ errors: [error instanceof Error ? error.message : 'spec: expected JSON-compatible data'],
483
+ };
484
+ }
485
+ }
486
+
487
+ // --- predicates -------------------------------------------------------------
488
+
489
+ function isObject(v: unknown): v is Record<string, unknown> {
490
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
491
+ }
492
+
493
+ function isNonEmptyString(v: unknown): v is string {
494
+ return typeof v === 'string' && v.length > 0;
495
+ }
496
+
497
+ function isNonNegInt(v: unknown): v is number {
498
+ return typeof v === 'number' && Number.isInteger(v) && v >= 0;
499
+ }
500
+
501
+ function isPosInt(v: unknown): v is number {
502
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
503
+ }
@@ -0,0 +1,73 @@
1
+ import { existsSync } from 'node:fs';
2
+
3
+ /** The work-package shape emitted at the SDK boundary. */
4
+ export interface EmittedWorkPackage {
5
+ title: string;
6
+ files_in_scope: string[];
7
+ definition_of_done: string[];
8
+ description?: string;
9
+ gate?: number | null;
10
+ }
11
+
12
+ export type WorkPackageRefusalReason =
13
+ | 'missing_title'
14
+ | 'missing_scope'
15
+ | 'missing_definition_of_done'
16
+ | 'nonexistent_files';
17
+
18
+ export type WorkPackageConsumption =
19
+ | { accepted: true; work: EmittedWorkPackage }
20
+ | { accepted: false; reason: WorkPackageRefusalReason };
21
+
22
+ /** Resolve a scoped path; used to check that scoped files are really there. */
23
+ export type PathExists = (path: string) => boolean;
24
+
25
+ /** Default: ask the filesystem. Injectable so the check is testable. */
26
+ const defaultPathExists: PathExists = (path) => existsSync(path);
27
+
28
+ /**
29
+ * Validate an emitted package before admitting it as runnable work.
30
+ * Refusals are data so callers must handle an unverifiable package explicitly.
31
+ *
32
+ * The existence check is ON by default — review rejected making it opt-in
33
+ * (PR #28, P1): a caller using the one-argument API would silently skip it, so
34
+ * the guard would not guard. `pathExists` defaults to the real filesystem and
35
+ * is injectable purely so the behaviour can be tested without one. A package
36
+ * scoping files that are not there is refused: the picker derives files_in_scope from prose
37
+ * in ops/BACKLOG.md, so a stale or mistyped entry produces a package that reads
38
+ * as actionable and sends whoever picks it up looking for something that does
39
+ * not exist. Checking is cheap; a wrong scope is not.
40
+ */
41
+ export function consumeWorkPackage(
42
+ input: unknown,
43
+ pathExists: PathExists = defaultPathExists,
44
+ ): WorkPackageConsumption {
45
+ if (!isRecord(input) || !isNonEmptyString(input['title'])) {
46
+ return { accepted: false, reason: 'missing_title' };
47
+ }
48
+ if (!isNonEmptyStringArray(input['files_in_scope'])) {
49
+ return { accepted: false, reason: 'missing_scope' };
50
+ }
51
+ if (isNonEmptyStringArray(input['files_in_scope'])) {
52
+ const missing = input['files_in_scope'].filter((p) => !pathExists(p));
53
+ if (missing.length > 0) {
54
+ return { accepted: false, reason: 'nonexistent_files' };
55
+ }
56
+ }
57
+ if (!isNonEmptyStringArray(input['definition_of_done'])) {
58
+ return { accepted: false, reason: 'missing_definition_of_done' };
59
+ }
60
+ return { accepted: true, work: input as unknown as EmittedWorkPackage };
61
+ }
62
+
63
+ function isRecord(value: unknown): value is Record<string, unknown> {
64
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
65
+ }
66
+
67
+ function isNonEmptyString(value: unknown): value is string {
68
+ return typeof value === 'string' && value.trim().length > 0;
69
+ }
70
+
71
+ function isNonEmptyStringArray(value: unknown): value is string[] {
72
+ return Array.isArray(value) && value.length > 0 && value.every(isNonEmptyString);
73
+ }
@@ -0,0 +1,90 @@
1
+ import { existsSync } from 'node:fs';
2
+
3
+ export type NextWorkPackageRefusalReason =
4
+ | 'nonexistent_repo_path'
5
+ | 'test_claim_without_evidence';
6
+
7
+ export type NextWorkPackageValidation =
8
+ | { accepted: true }
9
+ | { accepted: false; reason: NextWorkPackageRefusalReason };
10
+
11
+ /** Resolve a referenced path. Injectable so validation is deterministic in tests. */
12
+ export type WorkPackagePathExists = (path: string) => boolean;
13
+
14
+ const defaultPathExists: WorkPackagePathExists = (path) => existsSync(path);
15
+ const INLINE_CODE = /(?<!`)`([^`\n]+)`(?!`)/g;
16
+ const REPO_PATH = /^[A-Za-z_][A-Za-z0-9._-]*(?:\/[A-Za-z0-9._-]+)+$/;
17
+ const TEST_RESULT_CLAIM =
18
+ /(?:\b(?:all|both|one|two|three|four|five|six|seven|eight|nine|ten|\d+|tests?|test suite|checks?|build)\b[^\n]{0,60}\b(?:green|pass(?:ed|es|ing)?|succeed(?:ed|s)?|tested)\b|\b(?:is|are|was|were|has been|have been)\b[^\n]{0,60}\btested\b)/i;
19
+
20
+ /**
21
+ * Validate claims and repository references in an ops/NEXT.md work package.
22
+ * Refusals are values so a caller cannot mistake an unchecked package for work.
23
+ */
24
+ export function validateNextWorkPackage(
25
+ markdown: string,
26
+ pathExists: WorkPackagePathExists = defaultPathExists,
27
+ ): NextWorkPackageValidation {
28
+ for (const path of referencedRepoPaths(markdown)) {
29
+ if (!pathExists(path)) return { accepted: false, reason: 'nonexistent_repo_path' };
30
+ }
31
+
32
+ const lines = markdown.split(/\r?\n/);
33
+ const fencedLines = findFencedLines(lines);
34
+ for (const [index, line] of lines.entries()) {
35
+ if (fencedLines.has(index) || !TEST_RESULT_CLAIM.test(line)) continue;
36
+ // A REQUIREMENT is not a CLAIM. "npm test must be green" states what has to
37
+ // become true; "All three tests pass" asserts it already is. Only the
38
+ // second needs evidence (review, PR #50).
39
+ //
40
+ // The discriminator is MODALITY, not location. A first attempt at this
41
+ // exempted every line under a "Definition of done" heading, which let the
42
+ // PR #19 artifact through — its claim "All three tests pass." sits under
43
+ // exactly that heading and is precisely what this must catch.
44
+ //
45
+ // The modal check was already here but keyed only on "pass", so "must be
46
+ // green" and "must be clean" still tripped it.
47
+ if (/\b(?:must|should|will|needs? to|has to)\b[^\n]{0,40}\b(?:pass|passing|green|clean|succeed)\b/i.test(line)) {
48
+ continue;
49
+ }
50
+ if (!hasNearbyTranscript(lines, fencedLines, index)) {
51
+ return { accepted: false, reason: 'test_claim_without_evidence' };
52
+ }
53
+ }
54
+ return { accepted: true };
55
+ }
56
+
57
+ function referencedRepoPaths(markdown: string): string[] {
58
+ return [...markdown.matchAll(INLINE_CODE)]
59
+ .map((match) => match[1] ?? '')
60
+ .filter((candidate) => REPO_PATH.test(candidate));
61
+ }
62
+
63
+ function findFencedLines(lines: string[]): Set<number> {
64
+ const fenced = new Set<number>();
65
+ let open = false;
66
+ for (const [index, line] of lines.entries()) {
67
+ if (/^\s*```/.test(line)) {
68
+ fenced.add(index);
69
+ open = !open;
70
+ } else if (open) {
71
+ fenced.add(index);
72
+ }
73
+ }
74
+ return fenced;
75
+ }
76
+
77
+ function hasNearbyTranscript(lines: string[], fenced: Set<number>, claimIndex: number): boolean {
78
+ const nearby = lines
79
+ .map((line, index) => ({ line, index }))
80
+ .filter(({ index }) => fenced.has(index) && Math.abs(index - claimIndex) <= 10)
81
+ .map(({ line }) => line.trim())
82
+ .filter((line) => line.length > 0 && !line.startsWith('```'));
83
+ const commandIndex = nearby.findIndex(isCommandLine);
84
+ return commandIndex >= 0 && nearby.some((line, index) => index > commandIndex && !isCommandLine(line));
85
+ }
86
+
87
+ function isCommandLine(line: string): boolean {
88
+ const command = line.startsWith('$ ') ? line.slice(2) : line;
89
+ return /^(?:cd\s+\S+\s*&&\s*)?(?:npm|node|cargo|sh|pnpm|yarn|pytest|go)\b/.test(command);
90
+ }