@tagma/sdk 0.7.0 → 0.7.1

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.
@@ -1,7 +1,7 @@
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.
@@ -27,7 +27,7 @@ interface QidEntry {
27
27
  readonly task: RawTaskConfig;
28
28
  }
29
29
 
30
- /** qid {track, task} lookup built once per validation pass. */
30
+ /** qid 鈫?{track, task} lookup built once per validation pass. */
31
31
  function buildQidIndex(config: RawPipelineConfig): Map<string, QidEntry> {
32
32
  const idx = new Map<string, QidEntry>();
33
33
  for (const track of config.tracks ?? []) {
@@ -48,7 +48,7 @@ function isValidDuration(input: string): boolean {
48
48
 
49
49
  // D8: IDs may only contain letters, digits, underscores, and hyphens, and must
50
50
  // start with a letter or underscore. Dots are explicitly forbidden because the
51
- // engine uses "trackId.taskId" as the qualified separator a dot in either
51
+ // engine uses "trackId.taskId" as the qualified separator 鈥?a dot in either
52
52
  // part creates an ambiguous qualified ID and breaks resolveRef.
53
53
  // Canonical regex and helper live in ./task-ref so every resolver (dag.ts,
54
54
  // engine.ts, editor) stays in lockstep with what we accept here.
@@ -75,7 +75,7 @@ const BUILTIN_DRIVER_TYPES: ReadonlySet<string> = new Set(['opencode']);
75
75
  * Optional second argument to `validateRaw`: the set of plugin types currently
76
76
  * registered in the SDK runtime, keyed by category. Hosts (e.g. the editor
77
77
  * server) pass this so `validateRaw` can emit a soft warning when a task
78
- * references a type that isn't loaded otherwise the Task panel would show
78
+ * references a type that isn't loaded 鈥?otherwise the Task panel would show
79
79
  * no hint and the pipeline would only blow up at run time. Callers that
80
80
  * legitimately validate a config offline (before plugins are loaded) can omit
81
81
  * this argument and no plugin warnings will be produced.
@@ -95,11 +95,11 @@ export interface ValidationError {
95
95
  message: string;
96
96
  /**
97
97
  * H8: not all "errors" are equally fatal. The DAG runtime is happy to
98
- * insert implicit `continue_from depends_on` ordering, so the matching
98
+ * insert implicit `continue_from 鈫?depends_on` ordering, so the matching
99
99
  * validate-raw check is a *style* nit, not a hard failure. Severity lets
100
100
  * the editor render it as a soft warning instead of blocking save / run.
101
101
  * Existing call sites that don't read this field still treat every entry
102
- * as fatal defaulting `severity` to undefined preserves that behaviour.
102
+ * as fatal 鈥?defaulting `severity` to undefined preserves that behaviour.
103
103
  */
104
104
  severity?: ValidationSeverity;
105
105
  }
@@ -111,7 +111,7 @@ export interface ValidationError {
111
111
  *
112
112
  * Plugin type checks: when `knownTypes` is provided, task/track references to
113
113
  * trigger/completion/middleware types that are neither built-in nor in the
114
- * supplied set produce a soft warning (severity: 'warning') these don't
114
+ * supplied set produce a soft warning (severity: 'warning') 鈥?these don't
115
115
  * block save/run but light up the Task panel so users discover the broken
116
116
  * reference in the editor instead of at run time. Omit `knownTypes` to skip
117
117
  * plugin checks entirely (offline/pre-load validation).
@@ -135,7 +135,7 @@ export function validateRaw(
135
135
  ? new Set<string>([...BUILTIN_MIDDLEWARE_TYPES, ...(knownTypes.middlewares ?? [])])
136
136
  : null;
137
137
 
138
- // ── Top level ──
138
+ // 鈹€鈹€ Top level 鈹€鈹€
139
139
  if (!config.name?.trim()) {
140
140
  errors.push({ path: 'name', message: 'Pipeline name is required' });
141
141
  }
@@ -163,16 +163,16 @@ export function validateRaw(
163
163
  return errors; // No point going further without tracks
164
164
  }
165
165
 
166
- // ── Build qualified ID sets for cross-reference checks ──
166
+ // 鈹€鈹€ Build qualified ID sets for cross-reference checks 鈹€鈹€
167
167
  // Qualified ID format: "trackId.taskId" (mirrors the engine's convention).
168
- // Shared with dag.ts so "ambiguous" / "not found" stay consistent refs
168
+ // Shared with dag.ts so "ambiguous" / "not found" stay consistent 鈥?refs
169
169
  // that buildDag later throws on will be reported here as errors first.
170
170
  const index = buildTaskIndex(config);
171
- // Full qid {track, task} index used by port-inference validation
171
+ // Full qid 鈫?{track, task} index used by port-inference validation
172
172
  // to walk a Prompt task's neighbors without re-scanning the tracks.
173
173
  const qidIndex = buildQidIndex(config);
174
174
 
175
- // ── Per-track validation ──
175
+ // 鈹€鈹€ Per-track validation 鈹€鈹€
176
176
  const seenTrackIds = new Set<string>();
177
177
  for (let ti = 0; ti < config.tracks.length; ti++) {
178
178
  const maybeTrack = config.tracks[ti] as unknown;
@@ -220,7 +220,7 @@ export function validateRaw(
220
220
  validatePermissions(track.permissions, `${trackPath}.permissions`, errors);
221
221
 
222
222
  // Track-level middlewares can reference a plugin that was uninstalled
223
- // after the YAML was written surface a warning so the user notices
223
+ // after the YAML was written 鈥?surface a warning so the user notices
224
224
  // before hitting Run.
225
225
  if (knownMiddlewares && track.middlewares) {
226
226
  for (let mi = 0; mi < track.middlewares.length; mi++) {
@@ -228,7 +228,7 @@ export function validateRaw(
228
228
  if (mw?.type && !knownMiddlewares.has(mw.type)) {
229
229
  errors.push({
230
230
  path: `${trackPath}.middlewares[${mi}].type`,
231
- 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.`,
231
+ 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.`,
232
232
  severity: 'warning',
233
233
  });
234
234
  }
@@ -250,7 +250,7 @@ export function validateRaw(
250
250
  continue;
251
251
  }
252
252
 
253
- // ── Per-task validation ──
253
+ // 鈹€鈹€ Per-task validation 鈹€鈹€
254
254
  const seenTaskIds = new Set<string>();
255
255
  for (let ki = 0; ki < track.tasks.length; ki++) {
256
256
  const task = track.tasks[ki];
@@ -302,7 +302,7 @@ export function validateRaw(
302
302
  });
303
303
  }
304
304
 
305
- // ── Field-level validations ──
305
+ // 鈹€鈹€ Field-level validations 鈹€鈹€
306
306
  if (task.timeout && !isValidDuration(task.timeout)) {
307
307
  errors.push({
308
308
  path: `${taskPath}.timeout`,
@@ -324,7 +324,7 @@ export function validateRaw(
324
324
  }
325
325
  validatePermissions(task.permissions, `${taskPath}.permissions`, errors);
326
326
 
327
- // ── Plugin type warnings (trigger / completion / middlewares) ──
327
+ // 鈹€鈹€ Plugin type warnings (trigger / completion / middlewares) 鈹€鈹€
328
328
  // Only fire when the host supplied a `knownTypes` snapshot, so offline
329
329
  // validation stays quiet. The messages deliberately name the npm
330
330
  // scope so users can copy-paste the install command.
@@ -352,46 +352,46 @@ export function validateRaw(
352
352
  if (mw?.type && !knownMiddlewares.has(mw.type)) {
353
353
  errors.push({
354
354
  path: `${taskPath}.middlewares[${mi}].type`,
355
- 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.`,
355
+ 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.`,
356
356
  severity: 'warning',
357
357
  });
358
358
  }
359
359
  }
360
360
  }
361
361
 
362
- // ── Port declaration checks ──
362
+ // 鈹€鈹€ Port declaration checks 鈹€鈹€
363
363
  validateTaskPorts(task, track.id, taskPath, qidIndex, index, errors);
364
364
 
365
- // ── depends_on reference checks ──
365
+ // 鈹€鈹€ depends_on reference checks 鈹€鈹€
366
366
  if (task.depends_on && task.depends_on.length > 0) {
367
367
  for (const dep of task.depends_on) {
368
368
  const resolved = resolveTaskRef(dep, track.id, index);
369
369
  if (resolved.kind === 'not_found') {
370
370
  errors.push({
371
371
  path: `${taskPath}.depends_on`,
372
- message: `Task "${task.id}": depends_on "${dep}" no such task found`,
372
+ message: `Task "${task.id}": depends_on "${dep}" 鈥?no such task found`,
373
373
  });
374
374
  } else if (resolved.kind === 'ambiguous') {
375
375
  errors.push({
376
376
  path: `${taskPath}.depends_on`,
377
- message: `Task "${task.id}": depends_on "${dep}" is ambiguous multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
377
+ message: `Task "${task.id}": depends_on "${dep}" is ambiguous 鈥?multiple tracks have a task with this id. Use the fully-qualified form "trackId.${dep}".`,
378
378
  });
379
379
  }
380
380
  }
381
381
  }
382
382
 
383
- // ── continue_from reference check ──
383
+ // 鈹€鈹€ continue_from reference check 鈹€鈹€
384
384
  if (task.continue_from) {
385
385
  const resolved = resolveTaskRef(task.continue_from, track.id, index);
386
386
  if (resolved.kind === 'not_found') {
387
387
  errors.push({
388
388
  path: `${taskPath}.continue_from`,
389
- message: `Task "${task.id}": continue_from "${task.continue_from}" no such task found`,
389
+ message: `Task "${task.id}": continue_from "${task.continue_from}" 鈥?no such task found`,
390
390
  });
391
391
  } else if (resolved.kind === 'ambiguous') {
392
392
  errors.push({
393
393
  path: `${taskPath}.continue_from`,
394
- 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}".`,
394
+ 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}".`,
395
395
  });
396
396
  } else if (
397
397
  !task.depends_on ||
@@ -415,7 +415,7 @@ export function validateRaw(
415
415
  }
416
416
  }
417
417
 
418
- // ── Cycle detection ──
418
+ // 鈹€鈹€ Cycle detection 鈹€鈹€
419
419
  errors.push(...detectCycles(config, index));
420
420
 
421
421
  return errors;
@@ -455,7 +455,7 @@ const VALID_PORT_TYPES: ReadonlySet<PortType> = new Set([
455
455
  'json',
456
456
  ]);
457
457
 
458
- // Identifier pattern for port names. Deliberately narrower than task IDs
458
+ // Identifier pattern for port names. Deliberately narrower than task IDs 鈥?
459
459
  // port names appear in `{{inputs.<name>}}` templates where hyphens would
460
460
  // be parsed as subtraction, so we also forbid them here to keep the
461
461
  // template grammar unambiguous.
@@ -520,7 +520,7 @@ function validatePortList(
520
520
  }
521
521
  }
522
522
  if (kind === 'outputs' && (port.required === true || port.from !== undefined)) {
523
- // `required` / `from` are input-only concepts outputs are
523
+ // `required` / `from` are input-only concepts 鈥?outputs are
524
524
  // always "produced when the task succeeds". Warn softly so the
525
525
  // YAML doesn't silently accept meaningless fields.
526
526
  errors.push({
@@ -573,6 +573,25 @@ function validateBindingMap(
573
573
  message: `task.inputs.${name}.required must be a boolean`,
574
574
  });
575
575
  }
576
+ if ('type' in binding && binding.type !== undefined && !VALID_PORT_TYPES.has(binding.type as PortType)) {
577
+ errors.push({
578
+ path: `${path}.type`,
579
+ message: `task.${kind}.${name}.type must be one of ${[...VALID_PORT_TYPES].join(', ')}`,
580
+ });
581
+ }
582
+ if (binding.type === 'enum') {
583
+ if (!Array.isArray(binding.enum) || binding.enum.length === 0) {
584
+ errors.push({
585
+ path: `${path}.enum`,
586
+ message: `task.${kind}.${name}.enum must be a non-empty string array when type is enum`,
587
+ });
588
+ } else if (!binding.enum.every((v: unknown) => typeof v === 'string')) {
589
+ errors.push({
590
+ path: `${path}.enum`,
591
+ message: `task.${kind}.${name}.enum values must all be strings`,
592
+ });
593
+ }
594
+ }
576
595
  if (kind === 'outputs' && typeof binding.from === 'string') {
577
596
  const source = binding.from;
578
597
  const ok =
@@ -685,26 +704,17 @@ function validateTaskPorts(
685
704
  validateBindingPortNameOverlap(task, taskPath, errors);
686
705
  validateInputBindingSources(task, trackId, taskPath, index, errors);
687
706
 
688
- // ─── Prompt tasks do not declare ports ──
689
- //
690
- // A Prompt Task's I/O contract is inferred from direct-neighbor
691
- // Command Tasks at runtime (see `inferPromptPorts` in ports.ts).
692
- // Declaring `ports` on a Prompt Task is always a configuration
693
- // mistake: the declared shape would be silently ignored in favour of
694
- // the inferred one, and the two drifting out of sync is the exact bug
695
- // the inference design eliminates.
696
- if (isPromptTask && ports !== undefined) {
707
+ if (ports !== undefined) {
697
708
  errors.push({
698
709
  path: `${taskPath}.ports`,
699
710
  message:
700
- `Task "${task.id}": prompt tasks do not declare ports — their I/O is ` +
701
- `inferred from direct-neighbor Command tasks. Remove the "ports" field ` +
702
- `and declare the corresponding inputs/outputs on the upstream/downstream ` +
703
- `Command tasks instead.`,
711
+ `Task "${task.id}": ports has been replaced by typed inputs/outputs. ` +
712
+ `Move ports.inputs entries to task.inputs.<name> and ports.outputs entries to task.outputs.<name>.`,
704
713
  });
714
+ return;
705
715
  }
706
716
 
707
- // ─── Collect placeholder references ──
717
+ // Collect placeholder references 鈹€鈹€
708
718
  // `{{inputs.X}}` is valid in both prompt and command text. The set of
709
719
  // names a task may legally reference differs by task kind:
710
720
  // - Command Task: its own declared `ports.inputs`
@@ -723,20 +733,16 @@ function validateTaskPorts(
723
733
  for (const name of objectKeys(task.inputs)) availableInputs.add(name);
724
734
  } else {
725
735
  // Command Task (or the pathological both-keys case, which is caught
726
- // earlier as a separate error tolerate it here).
727
- availableInputs = new Set<string>(
728
- ports && Array.isArray(ports.inputs)
729
- ? ports.inputs.filter((p): p is PortDef => !!p && typeof p === 'object').map((p) => p.name)
730
- : [],
731
- );
736
+ // earlier as a separate error 鈥?tolerate it here).
737
+ availableInputs = new Set<string>();
732
738
  for (const name of objectKeys(task.inputs)) availableInputs.add(name);
733
739
  }
734
740
 
735
741
  for (const name of referenced) {
736
742
  if (!availableInputs.has(name)) {
737
743
  const hint = isPromptTask
738
- ? `no upstream Command task exports an output port named "${name}"`
739
- : `no such input port is declared`;
744
+ ? `no upstream Command task exports an output named "${name}"`
745
+ : `no such input is declared`;
740
746
  errors.push({
741
747
  path: taskPath,
742
748
  message: `Task "${task.id}": references "{{inputs.${name}}}" but ${hint}`,
@@ -744,58 +750,7 @@ function validateTaskPorts(
744
750
  }
745
751
  }
746
752
 
747
- // ─── Structural port validation Command Tasks only ──
748
- //
749
- // Prompt tasks already errored above if they tried to declare ports;
750
- // running the per-port structural validator on the ignored object
751
- // would just produce duplicate noise.
752
- if (isCommandTask && ports) {
753
- validatePortList(ports.inputs, `${taskPath}.ports.inputs`, 'inputs', errors);
754
- validatePortList(ports.outputs, `${taskPath}.ports.outputs`, 'outputs', errors);
755
-
756
- // Warn on declared-but-unused inputs. Not fatal — a user may want
757
- // to surface an input as a data-flow hint for the editor even when
758
- // the command doesn't template it explicitly.
759
- if (typeof task.command === 'string' && Array.isArray(ports.inputs)) {
760
- for (const port of ports.inputs) {
761
- if (!port || typeof port !== 'object') continue;
762
- if (!referenced.has(port.name)) {
763
- errors.push({
764
- path: `${taskPath}.ports.inputs`,
765
- severity: 'warning',
766
- message: `Task "${task.id}": command does not reference {{inputs.${port.name}}} — declared input is unused`,
767
- });
768
- }
769
- }
770
- }
771
-
772
- // Validate that fully-qualified `from` references point to direct
773
- // dependencies. The runtime's findUpstreamValue only scans dependsOn,
774
- // so a from that skips the dependency list will always miss at run
775
- // time and block the task with a cryptic "missing required input".
776
- if (Array.isArray(ports.inputs)) {
777
- for (const port of ports.inputs) {
778
- if (!port || typeof port !== 'object' || typeof port.from !== 'string' || !port.from.includes('.')) {
779
- continue;
780
- }
781
- const dot = port.from.lastIndexOf('.');
782
- const upstreamId = port.from.slice(0, dot);
783
- const deps = task.depends_on ?? [];
784
- const isDirectDep = deps.some((dep) => {
785
- const resolved = resolveTaskRef(dep, trackId, index);
786
- return resolved.kind === 'resolved' && resolved.qid === upstreamId;
787
- });
788
- if (!isDirectDep) {
789
- errors.push({
790
- path: `${taskPath}.ports.inputs`,
791
- 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)`,
792
- });
793
- }
794
- }
795
- }
796
- }
797
-
798
- // ─── Prompt-task inferred-port conflict checks ──
753
+ // Prompt-task inferred-port conflict checks 鈹€鈹€
799
754
  //
800
755
  // Static counterparts to the runtime checks `inferPromptPorts` runs.
801
756
  // These surface problems at author-time in the editor so the user
@@ -807,8 +762,8 @@ function validateTaskPorts(
807
762
 
808
763
  /**
809
764
  * Walk the direct-upstream Commands of a Prompt Task and collect every
810
- * output port name they export. Prompt upstreams contribute nothing
811
- * they pass free text via continue_from, not structured ports so we
765
+ * output port name they export. Prompt upstreams contribute nothing 鈥?
766
+ * they pass free text via continue_from, not structured ports 鈥?so we
812
767
  * skip them. This mirrors exactly what the engine does at runtime in
813
768
  * `inferPromptPorts`, keeping the editor and runtime views aligned.
814
769
  */
@@ -824,14 +779,12 @@ function collectUpstreamCommandOutputNames(
824
779
  if (r.kind !== 'resolved') continue;
825
780
  const entry = qidIndex.get(r.qid);
826
781
  if (!entry) continue;
827
- // Only Command tasks contribute Prompt upstreams pass free text.
782
+ // Only Command tasks contribute 鈥?Prompt upstreams pass free text.
828
783
  if (typeof entry.task.command !== 'string') continue;
829
- const outputs = entry.task.ports?.outputs;
830
- if (!Array.isArray(outputs)) continue;
831
- for (const port of outputs) {
832
- if (port && typeof port === 'object' && typeof port.name === 'string') {
833
- names.add(port.name);
834
- }
784
+ const outputs = entry.task.outputs;
785
+ if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
786
+ for (const name of Object.keys(outputs)) {
787
+ names.add(name);
835
788
  }
836
789
  }
837
790
  return names;
@@ -839,11 +792,11 @@ function collectUpstreamCommandOutputNames(
839
792
 
840
793
  /**
841
794
  * Detect the two kinds of collision that would block a Prompt Task at
842
- * runtime report them at validate-time so the editor lights them up
795
+ * runtime 鈥?report them at validate-time so the editor lights them up
843
796
  * before a run is attempted.
844
797
  *
845
798
  * 1. Input collision: two direct-upstream Commands both export an
846
- * output with the same name. Command→Command would let the
799
+ * output with the same name. Command鈫扖ommand would let the
847
800
  * downstream disambiguate with `from:`; Prompt tasks have no port
848
801
  * declarations and therefore no escape hatch.
849
802
  * 2. Output collision: two direct-downstream Commands declare inputs
@@ -858,20 +811,19 @@ function validateInferredPromptPortConflicts(
858
811
  index: TaskIndex,
859
812
  errors: ValidationError[],
860
813
  ): void {
861
- // ─── Input collision ──
814
+ // 鈹€鈹€鈹€ Input collision 鈹€鈹€
862
815
  const producersByName = new Map<string, string[]>();
863
816
  for (const dep of task.depends_on ?? []) {
864
817
  const r = resolveTaskRef(dep, trackId, index);
865
818
  if (r.kind !== 'resolved') continue;
866
819
  const entry = qidIndex.get(r.qid);
867
820
  if (!entry || typeof entry.task.command !== 'string') continue;
868
- const outputs = entry.task.ports?.outputs;
869
- if (!Array.isArray(outputs)) continue;
870
- for (const port of outputs) {
871
- if (!port || typeof port !== 'object' || typeof port.name !== 'string') continue;
872
- const list = producersByName.get(port.name) ?? [];
821
+ const outputs = entry.task.outputs;
822
+ if (!outputs || typeof outputs !== 'object' || Array.isArray(outputs)) continue;
823
+ for (const name of Object.keys(outputs)) {
824
+ const list = producersByName.get(name) ?? [];
873
825
  list.push(r.qid);
874
- producersByName.set(port.name, list);
826
+ producersByName.set(name, list);
875
827
  }
876
828
  }
877
829
  for (const [name, producers] of producersByName) {
@@ -880,13 +832,13 @@ function validateInferredPromptPortConflicts(
880
832
  path: taskPath,
881
833
  message:
882
834
  `Task "${task.id}": upstream Commands ${producers.join(', ')} all export ` +
883
- `"${name}" prompt tasks cannot disambiguate (no "from:" binding available). ` +
835
+ `"${name}" 鈥?prompt tasks cannot disambiguate (no "from:" binding available). ` +
884
836
  `Rename the output on one of the upstream Commands.`,
885
837
  });
886
838
  }
887
839
  }
888
840
 
889
- // ─── Output collision ──
841
+ // 鈹€鈹€鈹€ Output collision 鈹€鈹€
890
842
  //
891
843
  // Walk every task in the pipeline once and check whether it depends on
892
844
  // us. We reuse the shared qidIndex + TaskIndex for the lookup; small
@@ -911,23 +863,23 @@ function validateInferredPromptPortConflicts(
911
863
  }
912
864
  }
913
865
  if (!dependsOnUs) continue;
914
- const inputs = entry.task.ports?.inputs;
915
- if (!Array.isArray(inputs)) continue;
916
- for (const port of inputs) {
917
- if (!port || typeof port !== 'object' || typeof port.name !== 'string') continue;
918
- const shape = portShapeKey(port);
919
- const prior = consumerShapeByName.get(port.name);
866
+ const inputs = entry.task.inputs;
867
+ if (!inputs || typeof inputs !== 'object' || Array.isArray(inputs)) continue;
868
+ for (const [inputName, binding] of Object.entries(inputs)) {
869
+ if (!binding || typeof binding !== 'object' || Array.isArray(binding)) continue;
870
+ const shape = bindingShapeKey(binding as { type?: PortType; enum?: readonly string[] });
871
+ const prior = consumerShapeByName.get(inputName);
920
872
  if (!prior) {
921
- consumerShapeByName.set(port.name, { shape, firstConsumer: downstreamQid });
873
+ consumerShapeByName.set(inputName, { shape, firstConsumer: downstreamQid });
922
874
  continue;
923
875
  }
924
- if (prior.shape !== shape && !reported.has(port.name)) {
925
- reported.add(port.name);
876
+ if (prior.shape !== shape && !reported.has(inputName)) {
877
+ reported.add(inputName);
926
878
  errors.push({
927
879
  path: taskPath,
928
880
  message:
929
881
  `Task "${task.id}": downstream Commands ${prior.firstConsumer} and ` +
930
- `${downstreamQid} disagree on the shape of inferred output "${port.name}" ` +
882
+ `${downstreamQid} disagree on the shape of inferred output "${inputName}" 鈥?` +
931
883
  `a single LLM emission cannot satisfy both. Rename on one side.`,
932
884
  });
933
885
  }
@@ -937,13 +889,17 @@ function validateInferredPromptPortConflicts(
937
889
 
938
890
  /** Minimal shape fingerprint for conflict detection: type + enum set. */
939
891
  function portShapeKey(port: PortDef): string {
940
- if (port.type !== 'enum') return String(port.type);
892
+ return bindingShapeKey(port);
893
+ }
894
+
895
+ function bindingShapeKey(port: { type?: PortType; enum?: readonly string[] }): string {
896
+ if ((port.type ?? 'json') !== 'enum') return String(port.type ?? 'json');
941
897
  const enums = Array.isArray(port.enum) ? [...port.enum].sort().join('|') : '';
942
898
  return `enum:${enums}`;
943
899
  }
944
900
 
945
901
  function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationError[] {
946
- // Build adjacency: qualifiedId [resolved dep qualifiedIds]
902
+ // Build adjacency: qualifiedId 鈫?[resolved dep qualifiedIds]
947
903
  const adj = new Map<string, string[]>();
948
904
 
949
905
  for (const track of config.tracks) {
@@ -969,7 +925,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
969
925
  const visited = new Set<string>();
970
926
  const inStack = new Set<string>();
971
927
  // Deduplicate cycles: the same cycle can be discovered from multiple entry points.
972
- // Canonical key = sorted node list joined order-independent fingerprint.
928
+ // Canonical key = sorted node list joined 鈥?order-independent fingerprint.
973
929
  const seenCycles = new Set<string>();
974
930
 
975
931
  // Use a mutable path array instead of copying at each level (O(n) vs O(n^2)).
@@ -988,7 +944,7 @@ function detectCycles(config: RawPipelineConfig, index: TaskIndex): ValidationEr
988
944
  const display = [...uniqueNodes, id]; // include start for readable display
989
945
  errors.push({
990
946
  path: 'tracks',
991
- message: `Circular dependency detected: ${display.join(' ')}`,
947
+ message: `Circular dependency detected: ${display.join(' 鈫?')}`,
992
948
  });
993
949
  }
994
950
  return;