@tagma/sdk 0.6.11 → 0.6.12

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.
package/src/engine.ts CHANGED
@@ -31,11 +31,14 @@ import {
31
31
  renderOutputSchemaBlock,
32
32
  } from './prompt-doc';
33
33
  import {
34
+ extractTaskBindingOutputs,
34
35
  extractTaskOutputs,
35
36
  inferPromptPorts,
37
+ resolveTaskBindingInputs,
36
38
  resolveTaskInputs,
37
39
  substituteInputs,
38
40
  } from './ports';
41
+ import type { UpstreamBindingData } from './ports';
39
42
  import type { TaskPorts } from './types';
40
43
  import {
41
44
  executeHook,
@@ -70,6 +73,18 @@ export class TriggerTimeoutError extends Error {
70
73
  }
71
74
  }
72
75
 
76
+ function isPromptTaskConfig(
77
+ task: TaskConfig,
78
+ ): task is TaskConfig & { readonly prompt: string; readonly command?: undefined } {
79
+ return task.prompt !== undefined && task.command === undefined;
80
+ }
81
+
82
+ function isCommandTaskConfig(
83
+ task: TaskConfig,
84
+ ): task is TaskConfig & { readonly command: string; readonly prompt?: undefined } {
85
+ return task.command !== undefined && task.prompt === undefined;
86
+ }
87
+
73
88
  // ═══ Preflight Validation ═══
74
89
 
75
90
  function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry): void {
@@ -81,7 +96,7 @@ function preflight(config: PipelineConfig, dag: Dag, registry: PluginRegistry):
81
96
  const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
82
97
 
83
98
  // Pure command tasks don't use a driver — skip driver registration check.
84
- const isCommandOnly = task.command && !task.prompt;
99
+ const isCommandOnly = isCommandTaskConfig(task);
85
100
 
86
101
  if (!isCommandOnly && !registry.hasHandler('drivers', driverName)) {
87
102
  errors.push(`Task "${node.taskId}": driver "${driverName}" not registered`);
@@ -319,7 +334,7 @@ export async function runPipeline(
319
334
  log.section('DAG topology');
320
335
  for (const [id, node] of dag.nodes) {
321
336
  const deps = node.dependsOn.length ? node.dependsOn.join(', ') : '(root)';
322
- const kind = node.task.prompt ? 'ai' : 'cmd';
337
+ const kind = isPromptTaskConfig(node.task) ? 'ai' : 'cmd';
323
338
  log.quiet(` • ${id} [${kind}] track=${node.track.id} deps=[${deps}]`);
324
339
  }
325
340
  log.quiet('');
@@ -388,13 +403,12 @@ export async function runPipeline(
388
403
 
389
404
  const sessionMap = new Map<string, string>();
390
405
  const normalizedMap = new Map<string, string>();
391
- // Extracted port outputs keyed by fully-qualified task id. Populated
392
- // after a task succeeds when its `ports.outputs` is declared; read by
393
- // downstream tasks via `resolveTaskInputs` to assemble their inputs.
394
- // Kept separate from normalizedMap so the continue_from text handoff
395
- // and the typed-port data handoff don't pollute each other — they
396
- // solve different problems and have different lifetimes.
406
+ // Published structured outputs keyed by fully-qualified task id.
407
+ // Includes lightweight task.outputs and strict ports.outputs.
397
408
  const outputValuesMap = new Map<string, Readonly<Record<string, unknown>>>();
409
+ // Full upstream result data for lightweight input bindings such as
410
+ // `taskId.stdout` and `taskId.outputs.name`.
411
+ const bindingDataMap = new Map<string, UpstreamBindingData>();
398
412
  // Resolved port inputs keyed by fully-qualified task id. Written once,
399
413
  // just before a task runs, so every subsequent task_update event can
400
414
  // echo them to the UI without re-resolving.
@@ -577,7 +591,7 @@ export async function runPipeline(
577
591
  return {
578
592
  id: taskId,
579
593
  name: state.config.name,
580
- type: state.config.prompt ? 'ai' : 'command',
594
+ type: isPromptTaskConfig(state.config) ? 'ai' : 'command',
581
595
  status: state.status,
582
596
  exit_code: state.result?.exitCode ?? null,
583
597
  duration_ms: state.result?.durationMs ?? null,
@@ -614,7 +628,7 @@ export async function runPipeline(
614
628
  log.section(`Task ${taskId}`, taskId);
615
629
  log.debug(
616
630
  `[task:${taskId}]`,
617
- `type=${task.prompt ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`,
631
+ `type=${isPromptTaskConfig(task) ? 'ai' : 'cmd'} track=${track.id} deps=[${node.dependsOn.join(', ') || '(root)'}]`,
618
632
  );
619
633
 
620
634
  // 1. Check dependencies
@@ -781,7 +795,7 @@ export async function runPipeline(
781
795
  // pipeline the Command path uses. Collisions that a Prompt can't
782
796
  // disambiguate (same input name on two upstreams, incompatible
783
797
  // downstream output types) block the task with a clear message.
784
- const isPromptTask = task.prompt !== undefined && task.command === undefined;
798
+ const isPromptTask = isPromptTaskConfig(task);
785
799
  let effectivePorts: TaskPorts | undefined = task.ports;
786
800
  let promptInferenceBlockReason: string | null = null;
787
801
 
@@ -789,7 +803,7 @@ export async function runPipeline(
789
803
  const inference = inferPromptPorts({
790
804
  upstreams: node.dependsOn.map((upstreamId) => {
791
805
  const upstream = dag.nodes.get(upstreamId);
792
- const isUpstreamCommand = !!upstream?.task.command;
806
+ const isUpstreamCommand = upstream ? isCommandTaskConfig(upstream.task) : false;
793
807
  return {
794
808
  taskId: upstreamId,
795
809
  outputs: isUpstreamCommand ? upstream?.task.ports?.outputs : undefined,
@@ -797,7 +811,7 @@ export async function runPipeline(
797
811
  }),
798
812
  downstreams: (directDownstreams.get(taskId) ?? []).map((downstreamId) => {
799
813
  const downstream = dag.nodes.get(downstreamId);
800
- const isDownstreamCommand = !!downstream?.task.command;
814
+ const isDownstreamCommand = downstream ? isCommandTaskConfig(downstream.task) : false;
801
815
  return {
802
816
  taskId: downstreamId,
803
817
  inputs: isDownstreamCommand ? downstream?.task.ports?.inputs : undefined,
@@ -844,6 +858,44 @@ export async function runPipeline(
844
858
  return;
845
859
  }
846
860
 
861
+ const bindingResolution = resolveTaskBindingInputs(task, bindingDataMap, node.dependsOn);
862
+ if (bindingResolution.kind === 'blocked') {
863
+ log.error(
864
+ `[task:${taskId}]`,
865
+ `blocked — cannot resolve task input bindings:\n${bindingResolution.reason}`,
866
+ );
867
+ state.result = {
868
+ exitCode: -1,
869
+ stdout: '',
870
+ stderr: `[engine] task input binding resolution failed:\n${bindingResolution.reason}`,
871
+ stdoutPath: null,
872
+ stderrPath: null,
873
+ durationMs: 0,
874
+ sessionId: null,
875
+ normalizedOutput: null,
876
+ failureKind: 'spawn_error',
877
+ outputs: null,
878
+ };
879
+ state.finishedAt = nowISO();
880
+ setTaskStatus(taskId, 'blocked');
881
+ try {
882
+ await fireHook(taskId, 'task_failure');
883
+ } catch (hookErr) {
884
+ log.error(
885
+ `[task:${taskId}]`,
886
+ `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`,
887
+ );
888
+ }
889
+ if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
890
+ return;
891
+ }
892
+ if (bindingResolution.missingOptional.length > 0) {
893
+ log.debug(
894
+ `[task:${taskId}]`,
895
+ `optional input bindings unresolved (empty in placeholders): ${bindingResolution.missingOptional.join(', ')}`,
896
+ );
897
+ }
898
+
847
899
  // Feed effective ports into `resolveTaskInputs` by shallow-cloning
848
900
  // the task. Prompt tasks get the inferred ports; Command tasks are
849
901
  // unchanged (effectivePorts === task.ports).
@@ -880,7 +932,7 @@ export async function runPipeline(
880
932
  if (getOnFailure(taskId) === 'stop_all') applyStopAll(node.track.id);
881
933
  return;
882
934
  }
883
- const resolvedInputs = inputResolution.inputs;
935
+ const resolvedInputs = { ...bindingResolution.inputs, ...inputResolution.inputs };
884
936
  resolvedInputsMap.set(taskId, resolvedInputs);
885
937
  if (inputResolution.missingOptional.length > 0) {
886
938
  log.debug(
@@ -902,7 +954,7 @@ export async function runPipeline(
902
954
  setTaskStatus(taskId, 'running');
903
955
  log.info(
904
956
  `[task:${taskId}]`,
905
- task.command ? `running: ${task.command}` : `running (driver task)`,
957
+ isCommandTaskConfig(task) ? `running: ${task.command}` : `running (driver task)`,
906
958
  );
907
959
 
908
960
  // File-only: resolved config for this task
@@ -940,7 +992,7 @@ export async function runPipeline(
940
992
  stderrPath,
941
993
  };
942
994
 
943
- if (task.command) {
995
+ if (isCommandTaskConfig(task)) {
944
996
  // Substitute `{{inputs.X}}` placeholders into the command
945
997
  // string. Tasks with no declared inputs always produce the same
946
998
  // string back (no placeholders to match). Unresolved references
@@ -1168,16 +1220,29 @@ export async function runPipeline(
1168
1220
  terminalStatus = 'success';
1169
1221
  }
1170
1222
 
1171
- // Extract declared port outputs from the task's output stream.
1172
- // Only meaningful on success — a failed task's output is whatever
1173
- // the child happened to emit before exiting, and downstream tasks
1174
- // shouldn't receive partial data. `extractTaskOutputs` is a no-op
1175
- // when the task has no declared outputs, so this is free for
1176
- // pre-ports tasks. Diagnostics are appended to stderr so users
1177
- // see *why* a downstream input is missing without having to dig
1178
- // through driver-specific logs.
1223
+ // Extract declared outputs from the task's output stream. Only
1224
+ // meaningful on success — a failed task's output is whatever the
1225
+ // child happened to emit before exiting, and downstream tasks
1226
+ // shouldn't receive partial data.
1179
1227
  let extractedOutputs: Readonly<Record<string, unknown>> | null = null;
1180
1228
  if (terminalStatus === 'success') {
1229
+ const looseExtraction = extractTaskBindingOutputs(
1230
+ task.outputs,
1231
+ result.stdout,
1232
+ result.stderr,
1233
+ result.normalizedOutput,
1234
+ );
1235
+ if (task.outputs && Object.keys(task.outputs).length > 0) {
1236
+ extractedOutputs = looseExtraction.outputs;
1237
+ log.debug(
1238
+ `[task:${taskId}]`,
1239
+ `extracted binding outputs: ${JSON.stringify(looseExtraction.outputs)}`,
1240
+ );
1241
+ if (looseExtraction.diagnostic) {
1242
+ log.debug(`[task:${taskId}]`, looseExtraction.diagnostic);
1243
+ }
1244
+ }
1245
+
1181
1246
  // Prompt tasks use inferred ports (from direct-downstream Command
1182
1247
  // inputs); Command tasks use their declared ports. Either way,
1183
1248
  // `extractTaskOutputs` is a no-op when there are no declared
@@ -1188,8 +1253,7 @@ export async function runPipeline(
1188
1253
  result.normalizedOutput,
1189
1254
  );
1190
1255
  if (effectivePorts?.outputs && effectivePorts.outputs.length > 0) {
1191
- extractedOutputs = extraction.outputs;
1192
- outputValuesMap.set(taskId, extraction.outputs);
1256
+ extractedOutputs = { ...(extractedOutputs ?? {}), ...extraction.outputs };
1193
1257
  log.debug(
1194
1258
  `[task:${taskId}]`,
1195
1259
  `extracted outputs: ${JSON.stringify(extraction.outputs)}` +
@@ -1207,6 +1271,16 @@ export async function runPipeline(
1207
1271
  // — hooks, wire events, test assertions — all go through this
1208
1272
  // one field rather than re-running extraction.
1209
1273
  result = { ...result, outputs: extractedOutputs };
1274
+ if (extractedOutputs !== null) {
1275
+ outputValuesMap.set(taskId, extractedOutputs);
1276
+ }
1277
+ bindingDataMap.set(taskId, {
1278
+ outputs: extractedOutputs,
1279
+ stdout: result.stdout,
1280
+ stderr: result.stderr,
1281
+ normalizedOutput: result.normalizedOutput,
1282
+ exitCode: result.exitCode,
1283
+ });
1210
1284
 
1211
1285
  // Store normalized text separately (in-memory) for continue_from handoff.
1212
1286
  // R15: clip oversized values so a runaway parseResult can't accumulate
package/src/ports.test.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import {
3
3
  extractInputReferences,
4
+ extractTaskBindingOutputs,
4
5
  extractTaskOutputs,
5
6
  inferPromptPorts,
7
+ resolveTaskBindingInputs,
6
8
  resolveTaskInputs,
7
9
  substituteInputs,
8
10
  } from './ports';
@@ -244,6 +246,87 @@ describe('resolveTaskInputs', () => {
244
246
  });
245
247
  });
246
248
 
249
+ // ─── resolveTaskBindingInputs ────────────────────────────────────────
250
+
251
+ describe('resolveTaskBindingInputs', () => {
252
+ test('resolves literal values and defaults without requiring ports', () => {
253
+ const t = task({
254
+ id: 'downstream',
255
+ command: 'echo',
256
+ inputs: {
257
+ city: { value: 'Shanghai' },
258
+ mode: { from: 't.up.outputs.missing', default: 'quick' },
259
+ },
260
+ });
261
+ const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
262
+ expect(res).toEqual({
263
+ kind: 'ready',
264
+ inputs: { city: 'Shanghai', mode: 'quick' },
265
+ missingOptional: [],
266
+ });
267
+ });
268
+
269
+ test('resolves values from a direct upstream output and stdout', () => {
270
+ const t = task({
271
+ id: 'downstream',
272
+ command: 'echo',
273
+ inputs: {
274
+ city: { from: 't.up.outputs.city' },
275
+ raw: { from: 't.up.stdout' },
276
+ },
277
+ });
278
+ const upstream = new Map([
279
+ [
280
+ 't.up',
281
+ {
282
+ outputs: { city: 'Shanghai' },
283
+ stdout: 'raw text\n',
284
+ stderr: '',
285
+ normalizedOutput: null,
286
+ exitCode: 0,
287
+ },
288
+ ],
289
+ ]);
290
+ const res = resolveTaskBindingInputs(t, upstream, ['t.up']);
291
+ expect(res.kind).toBe('ready');
292
+ if (res.kind !== 'ready') return;
293
+ expect(res.inputs).toEqual({ city: 'Shanghai', raw: 'raw text\n' });
294
+ });
295
+
296
+ test('blocks required missing bindings with a readable reason', () => {
297
+ const t = task({
298
+ id: 'downstream',
299
+ command: 'echo',
300
+ inputs: {
301
+ city: { from: 't.up.outputs.city', required: true },
302
+ },
303
+ });
304
+ const res = resolveTaskBindingInputs(t, new Map(), ['t.up']);
305
+ expect(res.kind).toBe('blocked');
306
+ if (res.kind !== 'blocked') return;
307
+ expect(res.missingRequired).toEqual(['city']);
308
+ expect(res.reason).toContain('missing required binding input(s): city');
309
+ });
310
+
311
+ test('detects ambiguous loose output name matches', () => {
312
+ const t = task({
313
+ id: 'downstream',
314
+ command: 'echo',
315
+ inputs: {
316
+ val: { from: 'outputs.val', required: true },
317
+ },
318
+ });
319
+ const upstream = new Map([
320
+ ['t.a', { outputs: { val: 'a' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
321
+ ['t.b', { outputs: { val: 'b' }, stdout: '', stderr: '', normalizedOutput: null, exitCode: 0 }],
322
+ ]);
323
+ const res = resolveTaskBindingInputs(t, upstream, ['t.a', 't.b']);
324
+ expect(res.kind).toBe('blocked');
325
+ if (res.kind !== 'blocked') return;
326
+ expect(res.ambiguous[0]).toEqual({ input: 'val', producers: ['t.a', 't.b'] });
327
+ });
328
+ });
329
+
247
330
  // ─── extractTaskOutputs ──────────────────────────────────────────────
248
331
 
249
332
  describe('extractTaskOutputs', () => {
@@ -301,6 +384,50 @@ describe('extractTaskOutputs', () => {
301
384
  });
302
385
  });
303
386
 
387
+ // ─── extractTaskBindingOutputs ───────────────────────────────────────
388
+
389
+ describe('extractTaskBindingOutputs', () => {
390
+ test('extracts loose outputs from final-line JSON by default', () => {
391
+ const r = extractTaskBindingOutputs(
392
+ {
393
+ city: {},
394
+ temp: { from: 'json.temperature' },
395
+ },
396
+ 'log\n{"city":"Shanghai","temperature":23}\n',
397
+ '',
398
+ null,
399
+ );
400
+ expect(r.outputs).toEqual({ city: 'Shanghai', temp: 23 });
401
+ expect(r.diagnostic).toBeNull();
402
+ });
403
+
404
+ test('can publish whole stdout and normalizedOutput as named outputs', () => {
405
+ const r = extractTaskBindingOutputs(
406
+ {
407
+ raw: { from: 'stdout' },
408
+ normalized: { from: 'normalizedOutput' },
409
+ },
410
+ 'raw text\n',
411
+ '',
412
+ 'normalized text',
413
+ );
414
+ expect(r.outputs).toEqual({ raw: 'raw text\n', normalized: 'normalized text' });
415
+ });
416
+
417
+ test('uses defaults for missing loose outputs without failing extraction', () => {
418
+ const r = extractTaskBindingOutputs(
419
+ {
420
+ city: { default: 'Unknown' },
421
+ },
422
+ 'not json\n',
423
+ '',
424
+ null,
425
+ );
426
+ expect(r.outputs).toEqual({ city: 'Unknown' });
427
+ expect(r.diagnostic).toBeNull();
428
+ });
429
+ });
430
+
304
431
  // ─── inferPromptPorts ───────────────────────────────────────────────
305
432
 
306
433
  describe('inferPromptPorts', () => {
package/src/ports.ts CHANGED
@@ -34,7 +34,13 @@
34
34
  // Everything here is pure / deterministic so it can be reused by the CLI,
35
35
  // the editor (for preview/simulation), and the engine without side effects.
36
36
 
37
- import type { PortDef, PortType, TaskConfig, TaskPorts } from './types';
37
+ import type {
38
+ PortDef,
39
+ PortType,
40
+ TaskConfig,
41
+ TaskOutputBindings,
42
+ TaskPorts,
43
+ } from './types';
38
44
 
39
45
  // ─── Template substitution ────────────────────────────────────────────
40
46
 
@@ -244,6 +250,157 @@ export function resolveTaskInputs(
244
250
  return { kind: 'ready', inputs, missingOptional };
245
251
  }
246
252
 
253
+ // ─── Lightweight binding resolution ──────────────────────────────────
254
+
255
+ export interface UpstreamBindingData {
256
+ readonly outputs?: Readonly<Record<string, unknown>> | null;
257
+ readonly stdout?: string;
258
+ readonly stderr?: string;
259
+ readonly normalizedOutput?: string | null;
260
+ readonly exitCode?: number | null;
261
+ }
262
+
263
+ export type BindingInputResolution =
264
+ | {
265
+ readonly kind: 'ready';
266
+ readonly inputs: Readonly<Record<string, unknown>>;
267
+ readonly missingOptional: readonly string[];
268
+ }
269
+ | {
270
+ readonly kind: 'blocked';
271
+ readonly missingRequired: readonly string[];
272
+ readonly ambiguous: readonly { input: string; producers: readonly string[] }[];
273
+ readonly reason: string;
274
+ };
275
+
276
+ export function resolveTaskBindingInputs(
277
+ task: Pick<TaskConfig, 'inputs'>,
278
+ upstreamData: ReadonlyMap<string, UpstreamBindingData>,
279
+ dependsOn: readonly string[],
280
+ ): BindingInputResolution {
281
+ const bindings = task.inputs;
282
+ if (!bindings || Object.keys(bindings).length === 0) {
283
+ return { kind: 'ready', inputs: {}, missingOptional: [] };
284
+ }
285
+
286
+ const inputs: Record<string, unknown> = {};
287
+ const missingRequired: string[] = [];
288
+ const missingOptional: string[] = [];
289
+ const ambiguous: { input: string; producers: string[] }[] = [];
290
+
291
+ for (const [name, binding] of Object.entries(bindings)) {
292
+ let value: unknown;
293
+ let present = false;
294
+
295
+ if ('value' in binding) {
296
+ value = binding.value;
297
+ present = true;
298
+ } else if (binding.from) {
299
+ const found = resolveBindingSource(binding.from, upstreamData, dependsOn);
300
+ if (found.kind === 'ambiguous') {
301
+ ambiguous.push({ input: name, producers: found.producers });
302
+ continue;
303
+ }
304
+ if (found.kind === 'hit') {
305
+ value = found.value;
306
+ present = true;
307
+ }
308
+ }
309
+
310
+ if (!present && 'default' in binding) {
311
+ value = binding.default;
312
+ present = true;
313
+ }
314
+
315
+ if (!present || value === undefined || value === null) {
316
+ if (binding.required === true) {
317
+ missingRequired.push(name);
318
+ } else {
319
+ missingOptional.push(name);
320
+ }
321
+ continue;
322
+ }
323
+
324
+ inputs[name] = value;
325
+ }
326
+
327
+ if (missingRequired.length > 0 || ambiguous.length > 0) {
328
+ const lines: string[] = [];
329
+ if (missingRequired.length > 0) {
330
+ lines.push(`missing required binding input(s): ${missingRequired.join(', ')}`);
331
+ }
332
+ for (const amb of ambiguous) {
333
+ lines.push(
334
+ `binding input "${amb.input}" is produced by multiple upstreams ` +
335
+ `(${amb.producers.join(', ')}) — use "taskId.outputs.${amb.input}"`,
336
+ );
337
+ }
338
+ return { kind: 'blocked', missingRequired, ambiguous, reason: lines.join('\n') };
339
+ }
340
+
341
+ return { kind: 'ready', inputs, missingOptional };
342
+ }
343
+
344
+ type BindingLookup =
345
+ | { kind: 'hit'; producer: string; value: unknown }
346
+ | { kind: 'miss' }
347
+ | { kind: 'ambiguous'; producers: string[] };
348
+
349
+ function resolveBindingSource(
350
+ source: string,
351
+ upstreamData: ReadonlyMap<string, UpstreamBindingData>,
352
+ dependsOn: readonly string[],
353
+ ): BindingLookup {
354
+ if (source.startsWith('outputs.')) {
355
+ return findOutputByName(source.slice('outputs.'.length), upstreamData, dependsOn);
356
+ }
357
+
358
+ const outputMarker = '.outputs.';
359
+ const outputIdx = source.lastIndexOf(outputMarker);
360
+ if (outputIdx > 0) {
361
+ const upstreamId = source.slice(0, outputIdx);
362
+ const outputName = source.slice(outputIdx + outputMarker.length);
363
+ if (!dependsOn.includes(upstreamId)) return { kind: 'miss' };
364
+ const upstream = upstreamData.get(upstreamId);
365
+ if (upstream?.outputs && outputName in upstream.outputs) {
366
+ return { kind: 'hit', producer: upstreamId, value: upstream.outputs[outputName] };
367
+ }
368
+ return { kind: 'miss' };
369
+ }
370
+
371
+ for (const field of ['stdout', 'stderr', 'normalizedOutput', 'exitCode'] as const) {
372
+ const suffix = `.${field}`;
373
+ if (!source.endsWith(suffix)) continue;
374
+ const upstreamId = source.slice(0, -suffix.length);
375
+ if (!dependsOn.includes(upstreamId)) return { kind: 'miss' };
376
+ const upstream = upstreamData.get(upstreamId);
377
+ if (!upstream) return { kind: 'miss' };
378
+ const value = upstream[field];
379
+ return value === undefined || value === null
380
+ ? { kind: 'miss' }
381
+ : { kind: 'hit', producer: upstreamId, value };
382
+ }
383
+
384
+ return { kind: 'miss' };
385
+ }
386
+
387
+ function findOutputByName(
388
+ name: string,
389
+ upstreamData: ReadonlyMap<string, UpstreamBindingData>,
390
+ dependsOn: readonly string[],
391
+ ): BindingLookup {
392
+ const hits: { producer: string; value: unknown }[] = [];
393
+ for (const upstreamId of dependsOn) {
394
+ const upstream = upstreamData.get(upstreamId);
395
+ if (upstream?.outputs && name in upstream.outputs) {
396
+ hits.push({ producer: upstreamId, value: upstream.outputs[name] });
397
+ }
398
+ }
399
+ if (hits.length === 0) return { kind: 'miss' };
400
+ if (hits.length === 1) return { kind: 'hit', producer: hits[0]!.producer, value: hits[0]!.value };
401
+ return { kind: 'ambiguous', producers: hits.map((h) => h.producer) };
402
+ }
403
+
247
404
  type UpstreamLookup =
248
405
  | { kind: 'hit'; producer: string; value: unknown }
249
406
  | { kind: 'miss' }
@@ -416,6 +573,72 @@ export function extractTaskOutputs(
416
573
  return { outputs, diagnostic };
417
574
  }
418
575
 
576
+ export function extractTaskBindingOutputs(
577
+ bindings: TaskOutputBindings | undefined,
578
+ stdout: string,
579
+ stderr: string,
580
+ normalizedOutput: string | null,
581
+ ): ExtractResult {
582
+ if (!bindings || Object.keys(bindings).length === 0) {
583
+ return { outputs: {}, diagnostic: null };
584
+ }
585
+
586
+ const outputs: Record<string, unknown> = {};
587
+ const missing: string[] = [];
588
+ let record: Record<string, unknown> | null | undefined;
589
+
590
+ for (const [name, binding] of Object.entries(bindings)) {
591
+ let value: unknown;
592
+ let present = false;
593
+
594
+ if ('value' in binding) {
595
+ value = binding.value;
596
+ present = true;
597
+ } else {
598
+ const source = binding.from ?? `json.${name}`;
599
+ if (source === 'stdout') {
600
+ value = stdout;
601
+ present = true;
602
+ } else if (source === 'stderr') {
603
+ value = stderr;
604
+ present = true;
605
+ } else if (source === 'normalizedOutput') {
606
+ if (normalizedOutput !== null) {
607
+ value = normalizedOutput;
608
+ present = true;
609
+ }
610
+ } else if (source.startsWith('json.')) {
611
+ if (record === undefined) {
612
+ const jsonSource = (normalizedOutput ?? '').length > 0 ? normalizedOutput! : stdout;
613
+ record = parseJsonTail(jsonSource);
614
+ }
615
+ const key = source.slice('json.'.length);
616
+ if (record && key in record) {
617
+ value = record[key];
618
+ present = true;
619
+ }
620
+ }
621
+ }
622
+
623
+ if (!present && 'default' in binding) {
624
+ value = binding.default;
625
+ present = true;
626
+ }
627
+
628
+ if (!present || value === undefined || value === null) {
629
+ missing.push(name);
630
+ continue;
631
+ }
632
+
633
+ outputs[name] = value;
634
+ }
635
+
636
+ return {
637
+ outputs,
638
+ diagnostic: missing.length > 0 ? `outputs: unresolved binding output(s): ${missing.join(', ')}` : null,
639
+ };
640
+ }
641
+
419
642
  /**
420
643
  * Find the last non-empty line that parses as a JSON object. Returns
421
644
  * null when no such line exists. Also tries the whole source as a