@tagma/sdk 0.7.0 → 0.7.3

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 (72) hide show
  1. package/README.md +84 -44
  2. package/dist/bootstrap.d.ts +20 -0
  3. package/dist/bootstrap.d.ts.map +1 -1
  4. package/dist/bootstrap.js +21 -11
  5. package/dist/bootstrap.js.map +1 -1
  6. package/dist/core/dataflow.d.ts.map +1 -1
  7. package/dist/core/dataflow.js +45 -9
  8. package/dist/core/dataflow.js.map +1 -1
  9. package/dist/core/run-context.d.ts +3 -0
  10. package/dist/core/run-context.d.ts.map +1 -1
  11. package/dist/core/run-context.js +2 -0
  12. package/dist/core/run-context.js.map +1 -1
  13. package/dist/core/task-executor.d.ts.map +1 -1
  14. package/dist/core/task-executor.js +46 -84
  15. package/dist/core/task-executor.js.map +1 -1
  16. package/dist/engine.d.ts +6 -0
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +3 -0
  19. package/dist/engine.js.map +1 -1
  20. package/dist/index.d.ts +3 -1
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +1 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/plugins.d.ts +2 -2
  25. package/dist/plugins.d.ts.map +1 -1
  26. package/dist/ports.d.ts +4 -0
  27. package/dist/ports.d.ts.map +1 -1
  28. package/dist/ports.js +27 -4
  29. package/dist/ports.js.map +1 -1
  30. package/dist/registry.d.ts +10 -4
  31. package/dist/registry.d.ts.map +1 -1
  32. package/dist/registry.js +64 -25
  33. package/dist/registry.js.map +1 -1
  34. package/dist/runtime.d.ts +9 -0
  35. package/dist/runtime.d.ts.map +1 -0
  36. package/dist/runtime.js +8 -0
  37. package/dist/runtime.js.map +1 -0
  38. package/dist/schema.d.ts.map +1 -1
  39. package/dist/schema.js +1 -7
  40. package/dist/schema.js.map +1 -1
  41. package/dist/tagma.d.ts +11 -1
  42. package/dist/tagma.d.ts.map +1 -1
  43. package/dist/tagma.js +6 -0
  44. package/dist/tagma.js.map +1 -1
  45. package/dist/validate-raw.d.ts +4 -4
  46. package/dist/validate-raw.d.ts.map +1 -1
  47. package/dist/validate-raw.js +89 -230
  48. package/dist/validate-raw.js.map +1 -1
  49. package/package.json +2 -2
  50. package/src/bootstrap.ts +23 -14
  51. package/src/core/dataflow.test.ts +8 -9
  52. package/src/core/dataflow.ts +57 -14
  53. package/src/core/run-context.test.ts +12 -0
  54. package/src/core/run-context.ts +4 -0
  55. package/src/core/task-executor.ts +75 -135
  56. package/src/engine-ports-mixed.test.ts +68 -411
  57. package/src/engine-ports.test.ts +37 -341
  58. package/src/engine.ts +8 -0
  59. package/src/index.ts +5 -0
  60. package/src/pipeline-runner.test.ts +5 -9
  61. package/src/plugin-registry.test.ts +138 -1
  62. package/src/plugins.ts +5 -2
  63. package/src/ports.test.ts +80 -0
  64. package/src/ports.ts +36 -4
  65. package/src/registry.ts +81 -26
  66. package/src/runtime.ts +20 -0
  67. package/src/schema-ports.test.ts +47 -197
  68. package/src/schema.ts +1 -7
  69. package/src/tagma.test.ts +72 -1
  70. package/src/tagma.ts +16 -1
  71. package/src/validate-raw-ports.test.ts +80 -393
  72. package/src/validate-raw.ts +90 -250
@@ -1,13 +1,13 @@
1
- // ═══ Raw Pipeline Config Validation ═══
1
+ // 鈺愨晲鈺?Raw Pipeline Config Validation 鈺愨晲鈺?
2
2
  //
3
3
  // Validates a RawPipelineConfig without resolving inheritance or executing
4
- // anything intended for real-time feedback in a visual editor (e.g. drag
4
+ // anything 鈥?intended for real-time feedback in a visual editor (e.g. drag
5
5
  // to add a task, live error highlighting).
6
6
  //
7
7
  // Returns a flat list of ValidationError objects. An empty array means valid.
8
8
  import { isValidTaskId, qualifyTaskId, buildTaskIndex, resolveTaskRef, } from './task-ref';
9
9
  import { extractInputReferences } from './ports';
10
- /** qid {track, task} lookup built once per validation pass. */
10
+ /** qid 鈫?{track, task} lookup built once per validation pass. */
11
11
  function buildQidIndex(config) {
12
12
  const idx = new Map();
13
13
  for (const track of config.tracks ?? []) {
@@ -29,7 +29,7 @@ function isValidDuration(input) {
29
29
  }
30
30
  // D8: IDs may only contain letters, digits, underscores, and hyphens, and must
31
31
  // start with a letter or underscore. Dots are explicitly forbidden because the
32
- // engine uses "trackId.taskId" as the qualified separator a dot in either
32
+ // engine uses "trackId.taskId" as the qualified separator 鈥?a dot in either
33
33
  // part creates an ambiguous qualified ID and breaks resolveRef.
34
34
  // Canonical regex and helper live in ./task-ref so every resolver (dag.ts,
35
35
  // engine.ts, editor) stays in lockstep with what we accept here.
@@ -56,7 +56,7 @@ const BUILTIN_DRIVER_TYPES = new Set(['opencode']);
56
56
  *
57
57
  * Plugin type checks: when `knownTypes` is provided, task/track references to
58
58
  * trigger/completion/middleware types that are neither built-in nor in the
59
- * supplied set produce a soft warning (severity: 'warning') these don't
59
+ * supplied set produce a soft warning (severity: 'warning') 鈥?these don't
60
60
  * block save/run but light up the Task panel so users discover the broken
61
61
  * reference in the editor instead of at run time. Omit `knownTypes` to skip
62
62
  * plugin checks entirely (offline/pre-load validation).
@@ -75,7 +75,7 @@ export function validateRaw(config, knownTypes) {
75
75
  const knownMiddlewares = knownTypes
76
76
  ? new Set([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
77
77
  : null;
78
- // ── Top level ──
78
+ // 鈹€鈹€ Top level 鈹€鈹€
79
79
  if (!config.name?.trim()) {
80
80
  errors.push({ path: 'name', message: 'Pipeline name is required' });
81
81
  }
@@ -101,15 +101,15 @@ export function validateRaw(config, knownTypes) {
101
101
  errors.push({ path: 'tracks', message: 'At least one track is required' });
102
102
  return errors; // No point going further without tracks
103
103
  }
104
- // ── Build qualified ID sets for cross-reference checks ──
104
+ // 鈹€鈹€ Build qualified ID sets for cross-reference checks 鈹€鈹€
105
105
  // Qualified ID format: "trackId.taskId" (mirrors the engine's convention).
106
- // Shared with dag.ts so "ambiguous" / "not found" stay consistent refs
106
+ // Shared with dag.ts so "ambiguous" / "not found" stay consistent 鈥?refs
107
107
  // that buildDag later throws on will be reported here as errors first.
108
108
  const index = buildTaskIndex(config);
109
- // Full qid {track, task} index used by port-inference validation
109
+ // Full qid 鈫?{track, task} index used by port-inference validation
110
110
  // to walk a Prompt task's neighbors without re-scanning the tracks.
111
111
  const qidIndex = buildQidIndex(config);
112
- // ── Per-track validation ──
112
+ // 鈹€鈹€ Per-track validation 鈹€鈹€
113
113
  const seenTrackIds = new Set();
114
114
  for (let ti = 0; ti < config.tracks.length; ti++) {
115
115
  const maybeTrack = config.tracks[ti];
@@ -158,7 +158,7 @@ export function validateRaw(config, knownTypes) {
158
158
  }
159
159
  validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
160
160
  // Track-level middlewares can reference a plugin that was uninstalled
161
- // after the YAML was written surface a warning so the user notices
161
+ // after the YAML was written 鈥?surface a warning so the user notices
162
162
  // before hitting Run.
163
163
  if (knownMiddlewares && track.middlewares) {
164
164
  for (let mi = 0; mi < track.middlewares.length; mi++) {
@@ -166,7 +166,7 @@ export function validateRaw(config, knownTypes) {
166
166
  if (mw?.type && !knownMiddlewares.has(mw.type)) {
167
167
  errors.push({
168
168
  path: `${trackPath}.middlewares[${mi}].type`,
169
- 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.`,
169
+ 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.`,
170
170
  severity: 'warning',
171
171
  });
172
172
  }
@@ -186,7 +186,7 @@ export function validateRaw(config, knownTypes) {
186
186
  });
187
187
  continue;
188
188
  }
189
- // ── Per-task validation ──
189
+ // 鈹€鈹€ Per-task validation 鈹€鈹€
190
190
  const seenTaskIds = new Set();
191
191
  for (let ki = 0; ki < track.tasks.length; ki++) {
192
192
  const task = track.tasks[ki];
@@ -236,7 +236,7 @@ export function validateRaw(config, knownTypes) {
236
236
  message: `Task "${task.id}": command content cannot be empty`,
237
237
  });
238
238
  }
239
- // ── Field-level validations ──
239
+ // 鈹€鈹€ Field-level validations 鈹€鈹€
240
240
  if (task.timeout && !isValidDuration(task.timeout)) {
241
241
  errors.push({
242
242
  path: `${taskPath}.timeout`,
@@ -257,7 +257,7 @@ export function validateRaw(config, knownTypes) {
257
257
  });
258
258
  }
259
259
  validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
260
- // ── Plugin type warnings (trigger / completion / middlewares) ──
260
+ // 鈹€鈹€ Plugin type warnings (trigger / completion / middlewares) 鈹€鈹€
261
261
  // Only fire when the host supplied a `knownTypes` snapshot, so offline
262
262
  // validation stays quiet. The messages deliberately name the npm
263
263
  // scope so users can copy-paste the install command.
@@ -283,45 +283,45 @@ export function validateRaw(config, knownTypes) {
283
283
  if (mw?.type && !knownMiddlewares.has(mw.type)) {
284
284
  errors.push({
285
285
  path: `${taskPath}.middlewares[${mi}].type`,
286
- 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.`,
286
+ 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.`,
287
287
  severity: 'warning',
288
288
  });
289
289
  }
290
290
  }
291
291
  }
292
- // ── Port declaration checks ──
292
+ // 鈹€鈹€ Port declaration checks 鈹€鈹€
293
293
  validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
294
- // ── depends_on reference checks ──
294
+ // 鈹€鈹€ depends_on reference checks 鈹€鈹€
295
295
  if (task.depends_on && task.depends_on.length > 0) {
296
296
  for (const dep of task.depends_on) {
297
297
  const resolved = resolveTaskRef(dep, track.id, index);
298
298
  if (resolved.kind === 'not_found') {
299
299
  errors.push({
300
300
  path: `${taskPath}.depends_on`,
301
- message: `Task "${task.id}": depends_on "${dep}" no such task found`,
301
+ message: `Task "${task.id}": depends_on "${dep}" 鈥?no such task found`,
302
302
  });
303
303
  }
304
304
  else if (resolved.kind === 'ambiguous') {
305
305
  errors.push({
306
306
  path: `${taskPath}.depends_on`,
307
- message: `Task "${task.id}": depends_on "${dep}" is ambiguous multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
307
+ message: `Task "${task.id}": depends_on "${dep}" is ambiguous 鈥?multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
308
308
  });
309
309
  }
310
310
  }
311
311
  }
312
- // ── continue_from reference check ──
312
+ // 鈹€鈹€ continue_from reference check 鈹€鈹€
313
313
  if (task.continue_from) {
314
314
  const resolved = resolveTaskRef(task.continue_from, track.id, index);
315
315
  if (resolved.kind === 'not_found') {
316
316
  errors.push({
317
317
  path: `${taskPath}.continue_from`,
318
- message: `Task "${task.id}": continue_from "${task.continue_from}" no such task found`,
318
+ message: `Task "${task.id}": continue_from "${task.continue_from}" 鈥?no such task found`,
319
319
  });
320
320
  }
321
321
  else if (resolved.kind === 'ambiguous') {
322
322
  errors.push({
323
323
  path: `${taskPath}.continue_from`,
324
- 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}".`,
324
+ 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}".`,
325
325
  });
326
326
  }
327
327
  else if (!task.depends_on ||
@@ -343,7 +343,7 @@ export function validateRaw(config, knownTypes) {
343
343
  }
344
344
  }
345
345
  }
346
- // ── Cycle detection ──
346
+ // 鈹€鈹€ Cycle detection 鈹€鈹€
347
347
  errors.push(...detectCycles(config, index));
348
348
  return errors;
349
349
  }
@@ -376,84 +376,11 @@ const VALID_PORT_TYPES = new Set([
376
376
  'enum',
377
377
  'json',
378
378
  ]);
379
- // Identifier pattern for port names. Deliberately narrower than task IDs
379
+ // Identifier pattern for port names. Deliberately narrower than task IDs 鈥?
380
380
  // port names appear in `{{inputs.<name>}}` templates where hyphens would
381
381
  // be parsed as subtraction, so we also forbid them here to keep the
382
382
  // template grammar unambiguous.
383
383
  const PORT_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
384
- function validatePortList(list, basePath, kind, errors) {
385
- if (!list)
386
- return;
387
- if (!Array.isArray(list)) {
388
- errors.push({
389
- path: basePath,
390
- message: `ports.${kind} must be an array`,
391
- });
392
- return;
393
- }
394
- const seen = new Set();
395
- for (let i = 0; i < list.length; i++) {
396
- const port = list[i];
397
- const path = `${basePath}[${i}]`;
398
- if (!port || typeof port !== 'object') {
399
- errors.push({ path, message: `ports.${kind}[${i}] must be an object` });
400
- continue;
401
- }
402
- if (typeof port.name !== 'string' || !port.name.trim()) {
403
- errors.push({ path: `${path}.name`, message: 'port.name is required' });
404
- continue;
405
- }
406
- if (!PORT_NAME_RE.test(port.name)) {
407
- errors.push({
408
- path: `${path}.name`,
409
- message: `port name "${port.name}" is invalid. Must match /^[A-Za-z_][A-Za-z0-9_]*$/ (letters, digits, underscores; starts with letter/underscore).`,
410
- });
411
- }
412
- if (seen.has(port.name)) {
413
- errors.push({
414
- path,
415
- message: `Duplicate ports.${kind} name "${port.name}"`,
416
- });
417
- }
418
- seen.add(port.name);
419
- if (!VALID_PORT_TYPES.has(port.type)) {
420
- errors.push({
421
- path: `${path}.type`,
422
- message: `port "${port.name}": type must be one of ${[...VALID_PORT_TYPES].join(', ')} (got ${JSON.stringify(port.type)})`,
423
- });
424
- }
425
- if (port.type === 'enum') {
426
- if (!Array.isArray(port.enum) || port.enum.length === 0) {
427
- errors.push({
428
- path: `${path}.enum`,
429
- message: `port "${port.name}": enum type requires a non-empty "enum" array`,
430
- });
431
- }
432
- else if (port.enum.some((v) => typeof v !== 'string')) {
433
- errors.push({
434
- path: `${path}.enum`,
435
- message: `port "${port.name}": enum values must all be strings`,
436
- });
437
- }
438
- }
439
- if (kind === 'outputs' && (port.required === true || port.from !== undefined)) {
440
- // `required` / `from` are input-only concepts — outputs are
441
- // always "produced when the task succeeds". Warn softly so the
442
- // YAML doesn't silently accept meaningless fields.
443
- errors.push({
444
- path,
445
- severity: 'warning',
446
- message: `port "${port.name}": "required" and "from" are input-only; ignored on outputs`,
447
- });
448
- }
449
- if (port.from !== undefined && typeof port.from !== 'string') {
450
- errors.push({
451
- path: `${path}.from`,
452
- message: `port "${port.name}": "from" must be a string (got ${typeof port.from})`,
453
- });
454
- }
455
- }
456
- }
457
384
  function validateBindingMap(value, basePath, kind, errors) {
458
385
  if (value === undefined)
459
386
  return;
@@ -484,6 +411,26 @@ function validateBindingMap(value, basePath, kind, errors) {
484
411
  message: `task.inputs.${name}.required must be a boolean`,
485
412
  });
486
413
  }
414
+ if ('type' in binding && binding.type !== undefined && !VALID_PORT_TYPES.has(binding.type)) {
415
+ errors.push({
416
+ path: `${path}.type`,
417
+ message: `task.${kind}.${name}.type must be one of ${[...VALID_PORT_TYPES].join(', ')}`,
418
+ });
419
+ }
420
+ if (binding.type === 'enum') {
421
+ if (!Array.isArray(binding.enum) || binding.enum.length === 0) {
422
+ errors.push({
423
+ path: `${path}.enum`,
424
+ message: `task.${kind}.${name}.enum must be a non-empty string array when type is enum`,
425
+ });
426
+ }
427
+ else if (!binding.enum.every((v) => typeof v === 'string')) {
428
+ errors.push({
429
+ path: `${path}.enum`,
430
+ message: `task.${kind}.${name}.enum values must all be strings`,
431
+ });
432
+ }
433
+ }
487
434
  if (kind === 'outputs' && typeof binding.from === 'string') {
488
435
  const source = binding.from;
489
436
  const ok = source === 'stdout' ||
@@ -499,28 +446,6 @@ function validateBindingMap(value, basePath, kind, errors) {
499
446
  }
500
447
  }
501
448
  }
502
- function validateBindingPortNameOverlap(task, taskPath, errors) {
503
- const looseInputs = objectKeys(task.inputs);
504
- const looseOutputs = objectKeys(task.outputs);
505
- const strictInputs = new Set(Array.isArray(task.ports?.inputs) ? task.ports.inputs.map((p) => p?.name) : []);
506
- const strictOutputs = new Set(Array.isArray(task.ports?.outputs) ? task.ports.outputs.map((p) => p?.name) : []);
507
- for (const name of looseInputs) {
508
- if (strictInputs.has(name)) {
509
- errors.push({
510
- path: `${taskPath}.inputs.${name}`,
511
- message: `task input binding "${name}" duplicates strict ports.inputs; choose one layer for this name`,
512
- });
513
- }
514
- }
515
- for (const name of looseOutputs) {
516
- if (strictOutputs.has(name)) {
517
- errors.push({
518
- path: `${taskPath}.outputs.${name}`,
519
- message: `task output binding "${name}" duplicates strict ports.outputs; choose one layer for this name`,
520
- });
521
- }
522
- }
523
- }
524
449
  function objectKeys(value) {
525
450
  if (!value || typeof value !== 'object' || Array.isArray(value))
526
451
  return [];
@@ -570,29 +495,19 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
570
495
  const isCommandTask = typeof task.command === 'string' && typeof task.prompt !== 'string';
571
496
  validateBindingMap(task.inputs, `${taskPath}.inputs`, 'inputs', errors);
572
497
  validateBindingMap(task.outputs, `${taskPath}.outputs`, 'outputs', errors);
573
- validateBindingPortNameOverlap(task, taskPath, errors);
574
498
  validateInputBindingSources(task, trackId, taskPath, index, errors);
575
- // ─── Prompt tasks do not declare ports ──
576
- //
577
- // A Prompt Task's I/O contract is inferred from direct-neighbor
578
- // Command Tasks at runtime (see `inferPromptPorts` in ports.ts).
579
- // Declaring `ports` on a Prompt Task is always a configuration
580
- // mistake: the declared shape would be silently ignored in favour of
581
- // the inferred one, and the two drifting out of sync is the exact bug
582
- // the inference design eliminates.
583
- if (isPromptTask && ports !== undefined) {
499
+ if (ports !== undefined) {
584
500
  errors.push({
585
501
  path: `${taskPath}.ports`,
586
- message: `Task "${task.id}": prompt tasks do not declare ports — their I/O is ` +
587
- `inferred from direct-neighbor Command tasks. Remove the "ports" field ` +
588
- `and declare the corresponding inputs/outputs on the upstream/downstream ` +
589
- `Command tasks instead.`,
502
+ message: `Task "${task.id}": ports has been replaced by typed inputs/outputs. ` +
503
+ `Move ports.inputs entries to task.inputs.<name> and ports.outputs entries to task.outputs.<name>.`,
590
504
  });
505
+ return;
591
506
  }
592
- // ─── Collect placeholder references ──
507
+ // Collect placeholder references 鈹€鈹€
593
508
  // `{{inputs.X}}` is valid in both prompt and command text. The set of
594
509
  // names a task may legally reference differs by task kind:
595
- // - Command Task: its own declared `ports.inputs`
510
+ // - Command Task: its own declared `inputs`
596
511
  // - Prompt Task: the union of direct-upstream Command outputs
597
512
  const referenced = new Set();
598
513
  if (typeof task.prompt === 'string') {
@@ -611,74 +526,23 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
611
526
  }
612
527
  else {
613
528
  // Command Task (or the pathological both-keys case, which is caught
614
- // earlier as a separate error tolerate it here).
615
- availableInputs = new Set(ports && Array.isArray(ports.inputs)
616
- ? ports.inputs.filter((p) => !!p && typeof p === 'object').map((p) => p.name)
617
- : []);
529
+ // earlier as a separate error 鈥?tolerate it here).
530
+ availableInputs = new Set();
618
531
  for (const name of objectKeys(task.inputs))
619
532
  availableInputs.add(name);
620
533
  }
621
534
  for (const name of referenced) {
622
535
  if (!availableInputs.has(name)) {
623
536
  const hint = isPromptTask
624
- ? `no upstream Command task exports an output port named "${name}"`
625
- : `no such input port is declared`;
537
+ ? `no upstream Command task exports an output named "${name}"`
538
+ : `no such input is declared`;
626
539
  errors.push({
627
540
  path: taskPath,
628
541
  message: `Task "${task.id}": references "{{inputs.${name}}}" but ${hint}`,
629
542
  });
630
543
  }
631
544
  }
632
- // ─── Structural port validation Command Tasks only ──
633
- //
634
- // Prompt tasks already errored above if they tried to declare ports;
635
- // running the per-port structural validator on the ignored object
636
- // would just produce duplicate noise.
637
- if (isCommandTask && ports) {
638
- validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
639
- validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
640
- // Warn on declared-but-unused inputs. Not fatal — a user may want
641
- // to surface an input as a data-flow hint for the editor even when
642
- // the command doesn't template it explicitly.
643
- if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
644
- for (const port of ports.inputs) {
645
- if (!port || typeof port !== 'object')
646
- continue;
647
- if (!referenced.has(port.name)) {
648
- errors.push({
649
- path: `${taskPath}.ports.inputs`,
650
- severity: 'warning',
651
- message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} — declared input is unused`,
652
- });
653
- }
654
- }
655
- }
656
- // Validate that fully-qualified `from` references point to direct
657
- // dependencies. The runtime's findUpstreamValue only scans dependsOn,
658
- // so a from that skips the dependency list will always miss at run
659
- // time and block the task with a cryptic "missing required input".
660
- if (Array.isArray(ports.inputs)) {
661
- for (const port of ports.inputs) {
662
- if (!port || typeof port !== 'object' || typeof port.from !== 'string' || !port.from.includes('.')) {
663
- continue;
664
- }
665
- const dot = port.from.lastIndexOf('.');
666
- const upstreamId = port.from.slice(0, dot);
667
- const deps = task.depends_on ?? [];
668
- const isDirectDep = deps.some((dep) => {
669
- const resolved = resolveTaskRef(dep, trackId, index);
670
- return resolved.kind === 'resolved' && resolved.qid === upstreamId;
671
- });
672
- if (!isDirectDep) {
673
- errors.push({
674
- path: `${taskPath}.ports.inputs`,
675
- message: `Task "${task.id}": port "${port.name}" from "${port.from}" references task "${upstreamId}" which is not a direct dependency (must be listed in depends_on)`,
676
- });
677
- }
678
- }
679
- }
680
- }
681
- // ─── Prompt-task inferred-port conflict checks ──
545
+ // Prompt-task inferred-port conflict checks 鈹€鈹€
682
546
  //
683
547
  // Static counterparts to the runtime checks `inferPromptPorts` runs.
684
548
  // These surface problems at author-time in the editor so the user
@@ -689,8 +553,8 @@ function validateTaskPorts(task, trackId, taskPath, qidIndex, index, errors) {
689
553
  }
690
554
  /**
691
555
  * Walk the direct-upstream Commands of a Prompt Task and collect every
692
- * output port name they export. Prompt upstreams contribute nothing
693
- * they pass free text via continue_from, not structured ports so we
556
+ * output port name they export. Prompt upstreams contribute nothing 鈥?
557
+ * they pass free text via continue_from, not structured ports 鈥?so we
694
558
  * skip them. This mirrors exactly what the engine does at runtime in
695
559
  * `inferPromptPorts`, keeping the editor and runtime views aligned.
696
560
  */
@@ -703,27 +567,25 @@ function collectUpstreamCommandOutputNames(task, trackId, qidIndex, index) {
703
567
  const entry = qidIndex.get(r.qid);
704
568
  if (!entry)
705
569
  continue;
706
- // Only Command tasks contribute Prompt upstreams pass free text.
570
+ // Only Command tasks contribute 鈥?Prompt upstreams pass free text.
707
571
  if (typeof entry.task.command !== 'string')
708
572
  continue;
709
- const outputs = entry.task.ports?.outputs;
710
- if (!Array.isArray(outputs))
573
+ const outputs = entry.task.outputs;
574
+ if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs))
711
575
  continue;
712
- for (const port of outputs) {
713
- if (port && typeof port === 'object' && typeof port.name === 'string') {
714
- names.add(port.name);
715
- }
576
+ for (const name of Object.keys(outputs)) {
577
+ names.add(name);
716
578
  }
717
579
  }
718
580
  return names;
719
581
  }
720
582
  /**
721
583
  * Detect the two kinds of collision that would block a Prompt Task at
722
- * runtime report them at validate-time so the editor lights them up
584
+ * runtime 鈥?report them at validate-time so the editor lights them up
723
585
  * before a run is attempted.
724
586
  *
725
587
  * 1. Input collision: two direct-upstream Commands both export an
726
- * output with the same name. Command→Command would let the
588
+ * output with the same name. Command鈫扖ommand would let the
727
589
  * downstream disambiguate with `from:`; Prompt tasks have no port
728
590
  * declarations and therefore no escape hatch.
729
591
  * 2. Output collision: two direct-downstream Commands declare inputs
@@ -731,7 +593,7 @@ function collectUpstreamCommandOutputNames(task, trackId, qidIndex, index) {
731
593
  * different enum sets). A single LLM emission cannot satisfy both.
732
594
  */
733
595
  function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex, index, errors) {
734
- // ─── Input collision ──
596
+ // 鈹€鈹€鈹€ Input collision 鈹€鈹€
735
597
  const producersByName = new Map();
736
598
  for (const dep of task.depends_on ?? []) {
737
599
  const r = resolveTaskRef(dep, trackId, index);
@@ -740,15 +602,13 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
740
602
  const entry = qidIndex.get(r.qid);
741
603
  if (!entry || typeof entry.task.command !== 'string')
742
604
  continue;
743
- const outputs = entry.task.ports?.outputs;
744
- if (!Array.isArray(outputs))
605
+ const outputs = entry.task.outputs;
606
+ if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs))
745
607
  continue;
746
- for (const port of outputs) {
747
- if (!port || typeof port !== 'object' || typeof port.name !== 'string')
748
- continue;
749
- const list = producersByName.get(port.name) ?? [];
608
+ for (const name of Object.keys(outputs)) {
609
+ const list = producersByName.get(name) ?? [];
750
610
  list.push(r.qid);
751
- producersByName.set(port.name, list);
611
+ producersByName.set(name, list);
752
612
  }
753
613
  }
754
614
  for (const [name, producers] of producersByName) {
@@ -756,12 +616,12 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
756
616
  errors.push({
757
617
  path: taskPath,
758
618
  message: `Task "${task.id}": upstream Commands ${producers.join(', ')} all export ` +
759
- `"${name}" prompt tasks cannot disambiguate (no "from:" binding available). ` +
619
+ `"${name}" 鈥?prompt tasks cannot disambiguate (no "from:" binding available). ` +
760
620
  `Rename the output on one of the upstream Commands.`,
761
621
  });
762
622
  }
763
623
  }
764
- // ─── Output collision ──
624
+ // 鈹€鈹€鈹€ Output collision 鈹€鈹€
765
625
  //
766
626
  // Walk every task in the pipeline once and check whether it depends on
767
627
  // us. We reuse the shared qidIndex + TaskIndex for the lookup; small
@@ -786,39 +646,38 @@ function validateInferredPromptPortConflicts(task, trackId, taskPath, qidIndex,
786
646
  }
787
647
  if (!dependsOnUs)
788
648
  continue;
789
- const inputs = entry.task.ports?.inputs;
790
- if (!Array.isArray(inputs))
649
+ const inputs = entry.task.inputs;
650
+ if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs))
791
651
  continue;
792
- for (const port of inputs) {
793
- if (!port || typeof port !== 'object' || typeof port.name !== 'string')
652
+ for (const [inputName, binding] of Object.entries(inputs)) {
653
+ if (!binding || typeof binding !== 'object' || Array.isArray(binding))
794
654
  continue;
795
- const shape = portShapeKey(port);
796
- const prior = consumerShapeByName.get(port.name);
655
+ const shape = bindingShapeKey(binding);
656
+ const prior = consumerShapeByName.get(inputName);
797
657
  if (!prior) {
798
- consumerShapeByName.set(port.name, { shape, firstConsumer: downstreamQid });
658
+ consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
799
659
  continue;
800
660
  }
801
- if (prior.shape !== shape && !reported.has(port.name)) {
802
- reported.add(port.name);
661
+ if (prior.shape !== shape && !reported.has(inputName)) {
662
+ reported.add(inputName);
803
663
  errors.push({
804
664
  path: taskPath,
805
665
  message: `Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
806
- `${downstreamQid} disagree on the shape of inferred output "${port.name}" ` +
666
+ `${downstreamQid} disagree on the shape of inferred output "${inputName}" 鈥?` +
807
667
  `a single LLM emission cannot satisfy both. Rename on one side.`,
808
668
  });
809
669
  }
810
670
  }
811
671
  }
812
672
  }
813
- /** Minimal shape fingerprint for conflict detection: type + enum set. */
814
- function portShapeKey(port) {
815
- if (port.type !== 'enum')
816
- return String(port.type);
673
+ function bindingShapeKey(port) {
674
+ if ((port.type ?? 'json') !== 'enum')
675
+ return String(port.type ?? 'json');
817
676
  const enums = Array.isArray(port.enum) ? [...port.enum].sort().join('|') : '';
818
677
  return `enum:${enums}`;
819
678
  }
820
679
  function detectCycles(config, index) {
821
- // Build adjacency: qualifiedId [resolved dep qualifiedIds]
680
+ // Build adjacency: qualifiedId 鈫?[resolved dep qualifiedIds]
822
681
  const adj = new Map();
823
682
  for (const track of config.tracks) {
824
683
  if (!track.id)
@@ -847,7 +706,7 @@ function detectCycles(config, index) {
847
706
  const visited = new Set();
848
707
  const inStack = new Set();
849
708
  // Deduplicate cycles: the same cycle can be discovered from multiple entry points.
850
- // Canonical key = sorted node list joined order-independent fingerprint.
709
+ // Canonical key = sorted node list joined 鈥?order-independent fingerprint.
851
710
  const seenCycles = new Set();
852
711
  // Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
853
712
  const pathStack = [];
@@ -864,7 +723,7 @@ function detectCycles(config, index) {
864
723
  const display = [...uniqueNodes, id]; // include start for readable display
865
724
  errors.push({
866
725
  path: 'tracks',
867
- message: `Circular dependency detected: ${display.join(' ')}`,
726
+ message: `Circular dependency detected: ${display.join(' 鈫?')}`,
868
727
  });
869
728
  }
870
729
  return;