@tagma/sdk 0.7.9 → 0.7.11

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 (44) hide show
  1. package/README.md +16 -16
  2. package/dist/completions/output-check.d.ts.map +1 -1
  3. package/dist/completions/output-check.js +19 -10
  4. package/dist/completions/output-check.js.map +1 -1
  5. package/dist/drivers/opencode.d.ts.map +1 -1
  6. package/dist/drivers/opencode.js +7 -118
  7. package/dist/drivers/opencode.js.map +1 -1
  8. package/dist/duration.d.ts +2 -0
  9. package/dist/duration.d.ts.map +1 -0
  10. package/dist/duration.js +5 -0
  11. package/dist/duration.js.map +1 -0
  12. package/dist/index.d.ts +1 -1
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +1 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/middlewares/static-context.d.ts.map +1 -1
  17. package/dist/middlewares/static-context.js +20 -1
  18. package/dist/middlewares/static-context.js.map +1 -1
  19. package/dist/pipeline-runner.d.ts +1 -1
  20. package/dist/pipeline-runner.d.ts.map +1 -1
  21. package/dist/pipeline-runner.js +15 -3
  22. package/dist/pipeline-runner.js.map +1 -1
  23. package/dist/plugins.d.ts +1 -1
  24. package/dist/plugins.d.ts.map +1 -1
  25. package/dist/plugins.js +1 -1
  26. package/dist/plugins.js.map +1 -1
  27. package/dist/schema.d.ts +1 -0
  28. package/dist/schema.d.ts.map +1 -1
  29. package/dist/schema.js +112 -8
  30. package/dist/schema.js.map +1 -1
  31. package/dist/tagma.d.ts.map +1 -1
  32. package/dist/tagma.js +7 -3
  33. package/dist/tagma.js.map +1 -1
  34. package/dist/triggers/file.d.ts.map +1 -1
  35. package/dist/triggers/file.js +13 -10
  36. package/dist/triggers/file.js.map +1 -1
  37. package/dist/triggers/manual.d.ts.map +1 -1
  38. package/dist/triggers/manual.js +11 -9
  39. package/dist/triggers/manual.js.map +1 -1
  40. package/dist/validate-raw.d.ts.map +1 -1
  41. package/dist/validate-raw.js +322 -80
  42. package/dist/validate-raw.js.map +1 -1
  43. package/dist/yaml-compiler.js.map +1 -1
  44. package/package.json +4 -4
@@ -11,12 +11,16 @@ import { extractInputReferences } from '@tagma/core';
11
11
  function buildQidIndex(config) {
12
12
  const idx = new Map();
13
13
  for (const track of config.tracks ?? []) {
14
- if (!track.id)
14
+ if (!track || typeof track !== 'object')
15
+ continue;
16
+ if (!isValidTaskId(track.id))
15
17
  continue;
16
18
  if (!Array.isArray(track.tasks))
17
19
  continue;
18
20
  for (const task of track.tasks ?? []) {
19
- if (!task.id)
21
+ if (!task || typeof task !== 'object')
22
+ continue;
23
+ if (!isValidTaskId(task.id))
20
24
  continue;
21
25
  idx.set(qualifyTaskId(track.id, task.id), { track, task });
22
26
  }
@@ -24,8 +28,40 @@ function buildQidIndex(config) {
24
28
  return idx;
25
29
  }
26
30
  const DURATION_RE = /^(\d*\.?\d+)\s*(s|m|h|d)$/;
27
- function isValidDuration(input) {
28
- return DURATION_RE.test(input.trim());
31
+ const MAX_TIMER_DURATION_MS = 2_147_483_647;
32
+ function validateDuration(input) {
33
+ if (typeof input !== 'string')
34
+ return { ok: false, reason: 'format' };
35
+ const match = DURATION_RE.exec(input.trim());
36
+ if (!match)
37
+ return { ok: false, reason: 'format' };
38
+ const value = parseFloat(match[1]);
39
+ const unit = match[2];
40
+ const ms = (() => {
41
+ switch (unit) {
42
+ case 's':
43
+ return value * 1000;
44
+ case 'm':
45
+ return value * 60_000;
46
+ case 'h':
47
+ return value * 3_600_000;
48
+ case 'd':
49
+ return value * 86_400_000;
50
+ default:
51
+ return Number.NaN;
52
+ }
53
+ })();
54
+ if (!Number.isFinite(ms) || ms > MAX_TIMER_DURATION_MS) {
55
+ return { ok: false, reason: 'range' };
56
+ }
57
+ return { ok: true };
58
+ }
59
+ function durationErrorMessage(input, validation) {
60
+ const label = String(input);
61
+ if (validation.reason === 'range') {
62
+ return `Duration "${label}" exceeds maximum supported timeout of ${MAX_TIMER_DURATION_MS}ms.`;
63
+ }
64
+ return `Invalid duration format "${label}". Expected e.g. "30s", "5m", "1h".`;
29
65
  }
30
66
  // D8: IDs may only contain letters, digits, underscores, and hyphens, and must
31
67
  // start with a letter or underscore. Dots are explicitly forbidden because the
@@ -35,7 +71,6 @@ function isValidDuration(input) {
35
71
  // engine.ts, editor) stays in lockstep with what we accept here.
36
72
  const isValidId = isValidTaskId;
37
73
  const VALID_ON_FAILURE = new Set(['skip_downstream', 'stop_all', 'ignore']);
38
- const VALID_REASONING_EFFORT = new Set(['low', 'medium', 'high']);
39
74
  const VALID_PIPELINE_MODES = new Set(['trusted', 'safe']);
40
75
  const PERMISSION_FIELDS = ['read', 'write', 'execute'];
41
76
  // Built-in plugin types always known to the SDK core, regardless of which
@@ -50,6 +85,168 @@ const BUILTIN_COMPLETION_TYPES = new Set([
50
85
  ]);
51
86
  const BUILTIN_MIDDLEWARE_TYPES = new Set(['static_context']);
52
87
  const BUILTIN_DRIVER_TYPES = new Set(['opencode']);
88
+ function commandConfigKind(value) {
89
+ if (typeof value === 'string')
90
+ return 'shell';
91
+ if (!value || typeof value !== 'object' || Array.isArray(value))
92
+ return null;
93
+ const raw = value;
94
+ const hasShell = 'shell' in raw;
95
+ const hasArgv = 'argv' in raw;
96
+ if (hasShell === hasArgv)
97
+ return null;
98
+ if (hasShell)
99
+ return typeof raw.shell === 'string' ? 'shell' : null;
100
+ return Array.isArray(raw.argv) && raw.argv.every((arg) => typeof arg === 'string')
101
+ ? 'argv'
102
+ : null;
103
+ }
104
+ function validateCommandConfig(value, path, label, errors) {
105
+ const kind = commandConfigKind(value);
106
+ if (kind === null) {
107
+ errors.push({
108
+ path,
109
+ message: `${label} must be a non-empty shell string, { shell: string }, or { argv: string[] }`,
110
+ });
111
+ return false;
112
+ }
113
+ if (typeof value === 'string') {
114
+ if (value.trim().length === 0) {
115
+ errors.push({ path, message: `${label} shell string must not be empty` });
116
+ return false;
117
+ }
118
+ return true;
119
+ }
120
+ const raw = value;
121
+ if (kind === 'shell') {
122
+ if (!raw.shell || raw.shell.trim().length === 0) {
123
+ errors.push({ path: `${path}.shell`, message: `${label}.shell must not be empty` });
124
+ return false;
125
+ }
126
+ return true;
127
+ }
128
+ if (!raw.argv || raw.argv.length === 0) {
129
+ errors.push({ path: `${path}.argv`, message: `${label}.argv must contain at least one argument` });
130
+ return false;
131
+ }
132
+ raw.argv.forEach((arg, index) => {
133
+ if (arg.length === 0) {
134
+ errors.push({ path: `${path}.argv[${index}]`, message: `${label}.argv entries must not be empty` });
135
+ }
136
+ });
137
+ return true;
138
+ }
139
+ function commandInputReferences(command) {
140
+ if (typeof command === 'string')
141
+ return extractInputReferences(command);
142
+ if ('shell' in command)
143
+ return extractInputReferences(command.shell);
144
+ const refs = new Set();
145
+ for (const arg of command.argv) {
146
+ for (const ref of extractInputReferences(arg))
147
+ refs.add(ref);
148
+ }
149
+ return [...refs];
150
+ }
151
+ function isRecord(value) {
152
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
153
+ }
154
+ function isNonEmptyString(value) {
155
+ return typeof value === 'string' && value.trim().length > 0;
156
+ }
157
+ function validateStringList(value, path, label, errors) {
158
+ if (value === undefined)
159
+ return [];
160
+ if (!Array.isArray(value)) {
161
+ errors.push({ path, message: `${label} must be an array of strings` });
162
+ return [];
163
+ }
164
+ const refs = [];
165
+ for (let i = 0; i < value.length; i++) {
166
+ const item = value[i];
167
+ if (typeof item !== 'string' || item.trim().length === 0) {
168
+ errors.push({ path: `${path}[${i}]`, message: `${label} entries must be non-empty strings` });
169
+ continue;
170
+ }
171
+ refs.push(item);
172
+ }
173
+ return refs;
174
+ }
175
+ function dependencyRefs(task) {
176
+ return Array.isArray(task.depends_on)
177
+ ? task.depends_on.filter((dep) => typeof dep === 'string' && dep.length > 0)
178
+ : [];
179
+ }
180
+ function validatePluginRef(value, path, label, errors) {
181
+ if (value === undefined)
182
+ return null;
183
+ if (!isRecord(value)) {
184
+ errors.push({ path, message: `${label} must be an object with a non-empty type` });
185
+ return null;
186
+ }
187
+ if (!isNonEmptyString(value.type)) {
188
+ errors.push({ path: `${path}.type`, message: `${label}.type must be a non-empty string` });
189
+ return null;
190
+ }
191
+ return value.type;
192
+ }
193
+ function validateMiddlewareList(value, path, errors) {
194
+ if (value === undefined)
195
+ return [];
196
+ if (!Array.isArray(value)) {
197
+ errors.push({ path, message: 'middlewares must be an array of objects' });
198
+ return [];
199
+ }
200
+ const types = [];
201
+ for (let i = 0; i < value.length; i++) {
202
+ const type = validatePluginRef(value[i], `${path}[${i}]`, 'middleware', errors);
203
+ if (type !== null)
204
+ types.push({ index: i, type });
205
+ }
206
+ return types;
207
+ }
208
+ const HOOK_FIELDS = [
209
+ 'pipeline_start',
210
+ 'task_start',
211
+ 'task_success',
212
+ 'task_failure',
213
+ 'pipeline_complete',
214
+ 'pipeline_error',
215
+ ];
216
+ function validateHooks(value, errors) {
217
+ if (value === undefined)
218
+ return;
219
+ if (!isRecord(value)) {
220
+ errors.push({ path: 'hooks', message: 'hooks must be an object map' });
221
+ return;
222
+ }
223
+ for (const field of HOOK_FIELDS) {
224
+ if (!(field in value))
225
+ continue;
226
+ const command = value[field];
227
+ if (!Array.isArray(command)) {
228
+ validateCommandConfig(command, `hooks.${field}`, `hooks.${field}`, errors);
229
+ continue;
230
+ }
231
+ if (command.length === 0) {
232
+ errors.push({ path: `hooks.${field}`, message: `hooks.${field} must not be empty` });
233
+ continue;
234
+ }
235
+ command.forEach((entry, index) => {
236
+ validateCommandConfig(entry, `hooks.${field}[${index}]`, `hooks.${field}[${index}]`, errors);
237
+ });
238
+ }
239
+ }
240
+ function validateReasoningEffort(value, path, errors) {
241
+ if (value === undefined)
242
+ return;
243
+ if (!isNonEmptyString(value)) {
244
+ errors.push({
245
+ path,
246
+ message: 'reasoning_effort must be a non-empty string',
247
+ });
248
+ }
249
+ }
53
250
  /**
54
251
  * Validate a raw pipeline config.
55
252
  * Checks structure, required fields, prompt/command exclusivity,
@@ -77,7 +274,7 @@ export function validateRaw(config, knownTypes) {
77
274
  ? new Set([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
78
275
  : null;
79
276
  // Top level
80
- if (!config.name?.trim()) {
277
+ if (!isNonEmptyString(config.name)) {
81
278
  errors.push({ path: 'name', message: 'Pipeline name is required' });
82
279
  }
83
280
  if (config.mode && !VALID_PIPELINE_MODES.has(config.mode)) {
@@ -86,12 +283,26 @@ export function validateRaw(config, knownTypes) {
86
283
  message: `Invalid mode "${config.mode}". Expected "trusted" or "safe".`,
87
284
  });
88
285
  }
89
- if (config.reasoning_effort && !VALID_REASONING_EFFORT.has(config.reasoning_effort)) {
90
- errors.push({
91
- path: 'reasoning_effort',
92
- message: `Invalid reasoning_effort "${config.reasoning_effort}". Expected "low", "medium", or "high".`,
93
- });
286
+ validateReasoningEffort(config.reasoning_effort, 'reasoning_effort', errors);
287
+ if (config.timeout !== undefined) {
288
+ const validation = validateDuration(config.timeout);
289
+ if (!validation.ok) {
290
+ errors.push({
291
+ path: 'timeout',
292
+ message: durationErrorMessage(config.timeout, validation),
293
+ });
294
+ }
295
+ }
296
+ if (config.max_concurrency !== undefined) {
297
+ if (!Number.isInteger(config.max_concurrency) || config.max_concurrency < 1) {
298
+ errors.push({
299
+ path: 'max_concurrency',
300
+ message: 'max_concurrency must be a positive integer',
301
+ });
302
+ }
94
303
  }
304
+ validateStringList(config.plugins, 'plugins', 'plugins', errors);
305
+ validateHooks(config.hooks, errors);
95
306
  if (knownDrivers && config.driver && !knownDrivers.has(config.driver)) {
96
307
  errors.push({
97
308
  path: 'driver',
@@ -126,7 +337,7 @@ export function validateRaw(config, knownTypes) {
126
337
  continue;
127
338
  }
128
339
  const track = maybeTrack;
129
- if (!track.id?.trim()) {
340
+ if (!isNonEmptyString(track.id)) {
130
341
  errors.push({ path: `${trackPath}.id`, message: 'Track id is required' });
131
342
  }
132
343
  else if (!isValidId(track.id)) {
@@ -141,7 +352,7 @@ export function validateRaw(config, knownTypes) {
141
352
  else {
142
353
  seenTrackIds.add(track.id);
143
354
  }
144
- if (!track.name?.trim()) {
355
+ if (!isNonEmptyString(track.name)) {
145
356
  errors.push({ path: `${trackPath}.name`, message: 'Track name is required' });
146
357
  }
147
358
  if (track.on_failure && !VALID_ON_FAILURE.has(track.on_failure)) {
@@ -150,12 +361,7 @@ export function validateRaw(config, knownTypes) {
150
361
  message: `Invalid on_failure value "${track.on_failure}". Expected "skip_downstream", "stop_all", or "ignore".`,
151
362
  });
152
363
  }
153
- if (track.reasoning_effort && !VALID_REASONING_EFFORT.has(track.reasoning_effort)) {
154
- errors.push({
155
- path: `${trackPath}.reasoning_effort`,
156
- message: `Invalid reasoning_effort "${track.reasoning_effort}". Expected "low", "medium", or "high".`,
157
- });
158
- }
364
+ validateReasoningEffort(track.reasoning_effort, `${trackPath}.reasoning_effort`, errors);
159
365
  if (knownDrivers && track.driver && !knownDrivers.has(track.driver)) {
160
366
  errors.push({
161
367
  path: `${trackPath}.driver`,
@@ -167,13 +373,13 @@ export function validateRaw(config, knownTypes) {
167
373
  // Track-level middlewares can reference a plugin that was uninstalled
168
374
  // after the YAML was written - surface a warning so the user notices
169
375
  // before hitting Run.
170
- if (knownMiddlewares && track.middlewares) {
171
- for (let mi = 0; mi < track.middlewares.length; mi++) {
172
- const mw = track.middlewares[mi];
173
- if (mw?.type && !knownMiddlewares.has(mw.type)) {
376
+ const trackMiddlewareTypes = validateMiddlewareList(track.middlewares, `${trackPath}.middlewares`, errors);
377
+ if (knownMiddlewares) {
378
+ for (const { index: mi, type } of trackMiddlewareTypes) {
379
+ if (!knownMiddlewares.has(type)) {
174
380
  errors.push({
175
381
  path: `${trackPath}.middlewares[${mi}].type`,
176
- message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference - the pipeline will fail at run time.`,
382
+ message: `Middleware type "${type}" is not registered. Install the plugin (e.g. @tagma/middleware-${type}) or remove the reference - the pipeline will fail at run time.`,
177
383
  severity: 'warning',
178
384
  });
179
385
  }
@@ -196,9 +402,14 @@ export function validateRaw(config, knownTypes) {
196
402
  // Per-task validation
197
403
  const seenTaskIds = new Set();
198
404
  for (let ki = 0; ki < track.tasks.length; ki++) {
199
- const task = track.tasks[ki];
200
405
  const taskPath = `${trackPath}.tasks[${ki}]`;
201
- if (!task.id?.trim()) {
406
+ const maybeTask = track.tasks[ki];
407
+ if (!isRecord(maybeTask)) {
408
+ errors.push({ path: taskPath, message: `Task ${ki} must be an object` });
409
+ continue;
410
+ }
411
+ const task = maybeTask;
412
+ if (!isNonEmptyString(task.id)) {
202
413
  errors.push({ path: `${taskPath}.id`, message: 'Task id is required' });
203
414
  continue; // Can't check further without an id
204
415
  }
@@ -216,46 +427,44 @@ export function validateRaw(config, knownTypes) {
216
427
  }
217
428
  seenTaskIds.add(task.id);
218
429
  const hasPromptKey = typeof task.prompt === 'string';
219
- const hasCommandKey = typeof task.command === 'string';
430
+ const hasCommandField = task.command !== undefined;
431
+ const hasCommandKey = commandConfigKind(task.command) !== null;
220
432
  const promptEmpty = hasPromptKey && task.prompt.trim().length === 0;
221
- const commandEmpty = hasCommandKey && task.command.trim().length === 0;
222
433
  if (hasPromptKey && hasCommandKey) {
223
434
  errors.push({
224
435
  path: taskPath,
225
436
  message: `Task "${task.id}": cannot have both "prompt" and "command"`,
226
437
  });
227
438
  }
228
- else if (!hasPromptKey && !hasCommandKey) {
439
+ else if (!hasPromptKey && !hasCommandField) {
229
440
  errors.push({
230
441
  path: taskPath,
231
442
  message: `Task "${task.id}": must have "prompt" or "command"`,
232
443
  });
233
444
  }
445
+ else if (hasCommandField && !hasCommandKey) {
446
+ validateCommandConfig(task.command, `${taskPath}.command`, `Task "${task.id}" command`, errors);
447
+ }
234
448
  else if (promptEmpty) {
235
449
  errors.push({
236
450
  path: taskPath,
237
451
  message: `Task "${task.id}": prompt content cannot be empty`,
238
452
  });
239
453
  }
240
- else if (commandEmpty) {
241
- errors.push({
242
- path: taskPath,
243
- message: `Task "${task.id}": command content cannot be empty`,
244
- });
454
+ else if (task.command !== undefined) {
455
+ validateCommandConfig(task.command, `${taskPath}.command`, `Task "${task.id}" command`, errors);
245
456
  }
246
457
  // Field-level validations
247
- if (task.timeout && !isValidDuration(task.timeout)) {
248
- errors.push({
249
- path: `${taskPath}.timeout`,
250
- message: `Invalid duration format "${task.timeout}". Expected e.g. "30s", "5m", "1h".`,
251
- });
252
- }
253
- if (task.reasoning_effort && !VALID_REASONING_EFFORT.has(task.reasoning_effort)) {
254
- errors.push({
255
- path: `${taskPath}.reasoning_effort`,
256
- message: `Invalid reasoning_effort "${task.reasoning_effort}". Expected "low", "medium", or "high".`,
257
- });
458
+ if (task.timeout !== undefined) {
459
+ const validation = validateDuration(task.timeout);
460
+ if (!validation.ok) {
461
+ errors.push({
462
+ path: `${taskPath}.timeout`,
463
+ message: durationErrorMessage(task.timeout, validation),
464
+ });
465
+ }
258
466
  }
467
+ validateReasoningEffort(task.reasoning_effort, `${taskPath}.reasoning_effort`, errors);
259
468
  if (knownDrivers && task.driver && !knownDrivers.has(task.driver)) {
260
469
  errors.push({
261
470
  path: `${taskPath}.driver`,
@@ -268,39 +477,46 @@ export function validateRaw(config, knownTypes) {
268
477
  // Only fire when the host supplied a `knownTypes` snapshot, so offline
269
478
  // validation stays quiet. The messages deliberately name the npm
270
479
  // scope so users can copy-paste the install command.
271
- if (knownTriggers && task.trigger?.type && !knownTriggers.has(task.trigger.type)) {
480
+ const triggerType = validatePluginRef(task.trigger, `${taskPath}.trigger`, 'trigger', errors);
481
+ const completionType = validatePluginRef(task.completion, `${taskPath}.completion`, 'completion', errors);
482
+ const taskMiddlewareTypes = validateMiddlewareList(task.middlewares, `${taskPath}.middlewares`, errors);
483
+ if (knownTriggers && triggerType !== null && !knownTriggers.has(triggerType)) {
272
484
  errors.push({
273
485
  path: `${taskPath}.trigger.type`,
274
- message: `Trigger type "${task.trigger.type}" is not registered. Install the plugin (e.g. @tagma/trigger-${task.trigger.type}) or the task will fail at run time.`,
486
+ message: `Trigger type "${triggerType}" is not registered. Install the plugin (e.g. @tagma/trigger-${triggerType}) or the task will fail at run time.`,
275
487
  severity: 'warning',
276
488
  });
277
489
  }
278
- if (knownCompletions &&
279
- task.completion?.type &&
280
- !knownCompletions.has(task.completion.type)) {
490
+ if (knownCompletions && completionType !== null && !knownCompletions.has(completionType)) {
281
491
  errors.push({
282
492
  path: `${taskPath}.completion.type`,
283
- message: `Completion type "${task.completion.type}" is not registered. Install the plugin (e.g. @tagma/completion-${task.completion.type}) or the task will fail at run time.`,
493
+ message: `Completion type "${completionType}" is not registered. Install the plugin (e.g. @tagma/completion-${completionType}) or the task will fail at run time.`,
284
494
  severity: 'warning',
285
495
  });
286
496
  }
287
- if (knownMiddlewares && task.middlewares) {
288
- for (let mi = 0; mi < task.middlewares.length; mi++) {
289
- const mw = task.middlewares[mi];
290
- if (mw?.type && !knownMiddlewares.has(mw.type)) {
497
+ if (knownMiddlewares) {
498
+ for (const { index: mi, type } of taskMiddlewareTypes) {
499
+ if (!knownMiddlewares.has(type)) {
291
500
  errors.push({
292
501
  path: `${taskPath}.middlewares[${mi}].type`,
293
- message: `Middleware type "${mw.type}" is not registered. Install the plugin (e.g. @tagma/middleware-${mw.type}) or remove the reference - the pipeline will fail at run time.`,
502
+ message: `Middleware type "${type}" is not registered. Install the plugin (e.g. @tagma/middleware-${type}) or remove the reference - the pipeline will fail at run time.`,
294
503
  severity: 'warning',
295
504
  });
296
505
  }
297
506
  }
298
507
  }
299
508
  // Port declaration checks
509
+ const deps = validateStringList(task.depends_on, `${taskPath}.depends_on`, 'task.depends_on', errors);
510
+ if (task.continue_from !== undefined && !isNonEmptyString(task.continue_from)) {
511
+ errors.push({
512
+ path: `${taskPath}.continue_from`,
513
+ message: 'task.continue_from must be a non-empty string',
514
+ });
515
+ }
300
516
  validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
301
517
  // depends_on reference checks
302
- if (task.depends_on && task.depends_on.length > 0) {
303
- for (const dep of task.depends_on) {
518
+ if (deps.length > 0) {
519
+ for (const dep of deps) {
304
520
  const resolved = resolveTaskRef(dep, track.id, index);
305
521
  if (resolved.kind === 'not_found') {
306
522
  errors.push({
@@ -317,7 +533,7 @@ export function validateRaw(config, knownTypes) {
317
533
  }
318
534
  }
319
535
  // continue_from reference check
320
- if (task.continue_from) {
536
+ if (isNonEmptyString(task.continue_from)) {
321
537
  const resolved = resolveTaskRef(task.continue_from, track.id, index);
322
538
  if (resolved.kind === 'not_found') {
323
539
  errors.push({
@@ -331,8 +547,8 @@ export function validateRaw(config, knownTypes) {
331
547
  message: `Task "${task.id}": continue_from "${task.continue_from}" is ambiguous - multiple tracks have a task with this id. Use the fully-qualified form "trackId.${task.continue_from}".`,
332
548
  });
333
549
  }
334
- else if (!task.depends_on ||
335
- !task.depends_on.some((dep) => {
550
+ else if (deps.length === 0 ||
551
+ !deps.some((dep) => {
336
552
  const depResolved = resolveTaskRef(dep, track.id, index);
337
553
  return depResolved.kind === 'resolved' && depResolved.qid === resolved.qid;
338
554
  })) {
@@ -472,7 +688,7 @@ function validateInputBindingSources(task, trackId, taskPath, index, errors) {
472
688
  const upstreamId = bindingSourceTaskId(source);
473
689
  if (!upstreamId)
474
690
  continue;
475
- const deps = task.depends_on ?? [];
691
+ const deps = dependencyRefs(task);
476
692
  const isDirectDep = deps.some((dep) => {
477
693
  const resolved = resolveTaskRef(dep, trackId, index);
478
694
  return resolved.kind === 'resolved' && resolved.qid === upstreamId;
@@ -499,7 +715,7 @@ function bindingSourceTaskId(source) {
499
715
  return null;
500
716
  }
501
717
  function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
502
- const isPromptTask = typeof task.prompt === 'string' && typeof task.command !== 'string';
718
+ const isPromptTask = typeof task.prompt === 'string' && commandConfigKind(task.command) === null;
503
719
  validateBindingMap(task.inputs, `${taskPath}.inputs`, 'inputs', errors);
504
720
  validateBindingMap(task.outputs, `${taskPath}.outputs`, 'outputs', errors);
505
721
  validateInputBindingSources(task, trackId, taskPath, index, errors);
@@ -513,8 +729,8 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
513
729
  for (const n of extractInputReferences(task.prompt))
514
730
  referenced.add(n);
515
731
  }
516
- if (typeof task.command === 'string') {
517
- for (const n of extractInputReferences(task.command))
732
+ if (commandConfigKind(task.command) !== null) {
733
+ for (const n of commandInputReferences(task.command))
518
734
  referenced.add(n);
519
735
  }
520
736
  let availableInputs;
@@ -559,7 +775,7 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
559
775
  */
560
776
  function collectUpstreamCommandOutputNames(task, trackId, qidIndex, index) {
561
777
  const names = new Set();
562
- for (const dep of task.depends_on ?? []) {
778
+ for (const dep of dependencyRefs(task)) {
563
779
  const r = resolveTaskRef(dep, trackId, index);
564
780
  if (r.kind !== 'resolved')
565
781
  continue;
@@ -567,7 +783,7 @@ function collectUpstreamCommandOutputNames(task, trackId, qidIndex, index) {
567
783
  if (!entry)
568
784
  continue;
569
785
  // Only Command tasks contribute - Prompt upstreams pass free text.
570
- if (typeof entry.task.command !== 'string')
786
+ if (commandConfigKind(entry.task.command) === null)
571
787
  continue;
572
788
  const outputs = entry.task.outputs;
573
789
  if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs))
@@ -594,12 +810,12 @@ function collectUpstreamCommandOutputNames(task, trackId, qidIndex, index) {
594
810
  function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex, index, errors) {
595
811
  // Input collision
596
812
  const producersByName = new Map();
597
- for (const dep of task.depends_on ?? []) {
813
+ for (const dep of dependencyRefs(task)) {
598
814
  const r = resolveTaskRef(dep, trackId, index);
599
815
  if (r.kind !== 'resolved')
600
816
  continue;
601
817
  const entry = qidIndex.get(r.qid);
602
- if (!entry || typeof entry.task.command !== 'string')
818
+ if (!entry || commandConfigKind(entry.task.command) === null)
603
819
  continue;
604
820
  const outputs = entry.task.outputs;
605
821
  if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs))
@@ -632,9 +848,9 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
632
848
  for (const [downstreamQid, entry] of qidIndex) {
633
849
  if (downstreamQid === taskQid)
634
850
  continue;
635
- if (typeof entry.task.command !== 'string')
851
+ if (commandConfigKind(entry.task.command) === null)
636
852
  continue; // only downstream Commands contribute
637
- const deps = entry.task.depends_on ?? [];
853
+ const deps = dependencyRefs(entry.task);
638
854
  let dependsOnUs = false;
639
855
  for (const d of deps) {
640
856
  const r = resolveTaskRef(d, entry.track.id, index);
@@ -651,24 +867,50 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
651
867
  for (const [inputName, binding] of Object.entries(inputs)) {
652
868
  if (!binding || typeof binding !== 'object' || Array.isArray(binding))
653
869
  continue;
870
+ const outputName = inferredPromptOutputName(inputName, binding, taskQid);
871
+ if (outputName === null)
872
+ continue;
654
873
  const shape = bindingShapeKey(binding);
655
- const prior = consumerShapeByName.get(inputName);
874
+ const prior = consumerShapeByName.get(outputName);
656
875
  if (!prior) {
657
- consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
876
+ consumerShapeByName.set(outputName, { shape, firstConsumer: downstreamQid });
658
877
  continue;
659
878
  }
660
- if (prior.shape !== shape && !reported.has(inputName)) {
661
- reported.add(inputName);
879
+ if (prior.shape !== shape && !reported.has(outputName)) {
880
+ reported.add(outputName);
662
881
  errors.push({
663
882
  path: taskPath,
664
883
  message: `Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
665
- `${downstreamQid} disagree on the shape of inferred output "${inputName}" - ` +
884
+ `${downstreamQid} disagree on the shape of inferred output "${outputName}" - ` +
666
885
  `a single LLM emission cannot satisfy both. Rename on one side.`,
667
886
  });
668
887
  }
669
888
  }
670
889
  }
671
890
  }
891
+ function inferredPromptOutputName(inputName, binding, promptTaskId) {
892
+ if (typeof binding.from !== 'string' || binding.from.length === 0)
893
+ return inputName;
894
+ const source = binding.from;
895
+ if (source.startsWith('outputs.'))
896
+ return source.slice('outputs.'.length);
897
+ const outputMarker = '.outputs.';
898
+ const outputIdx = source.lastIndexOf(outputMarker);
899
+ if (outputIdx > 0) {
900
+ const sourceTaskId = source.slice(0, outputIdx);
901
+ if (sourceTaskId !== promptTaskId)
902
+ return null;
903
+ return source.slice(outputIdx + outputMarker.length);
904
+ }
905
+ const dot = source.lastIndexOf('.');
906
+ if (dot > 0) {
907
+ const sourceTaskId = source.slice(0, dot);
908
+ if (sourceTaskId !== promptTaskId)
909
+ return null;
910
+ return source.slice(dot + 1);
911
+ }
912
+ return source;
913
+ }
672
914
  function bindingShapeKey(port) {
673
915
  if ((port.type ?? 'json') !== 'enum')
674
916
  return String(port.type ?? 'json');
@@ -679,21 +921,21 @@ function detectCycles(config, index) {
679
921
  // Build adjacency: qualifiedId ->[resolved dep qualifiedIds]
680
922
  const adj = new Map();
681
923
  for (const track of config.tracks) {
682
- if (!track.id)
924
+ if (!track || typeof track !== 'object' || !isValidTaskId(track.id))
683
925
  continue;
684
926
  if (!Array.isArray(track.tasks))
685
927
  continue;
686
928
  for (const task of track.tasks ?? []) {
687
- if (!task.id)
929
+ if (!task || typeof task !== 'object' || !isValidTaskId(task.id))
688
930
  continue;
689
931
  const qid = qualifyTaskId(track.id, task.id);
690
932
  const deps = [];
691
- for (const dep of task.depends_on ?? []) {
933
+ for (const dep of dependencyRefs(task)) {
692
934
  const resolved = resolveTaskRef(dep, track.id, index);
693
935
  if (resolved.kind === 'resolved')
694
936
  deps.push(resolved.qid);
695
937
  }
696
- if (task.continue_from) {
938
+ if (isNonEmptyString(task.continue_from)) {
697
939
  const resolved = resolveTaskRef(task.continue_from, track.id, index);
698
940
  if (resolved.kind === 'resolved' && !deps.includes(resolved.qid))
699
941
  deps.push(resolved.qid);