@tagma/sdk 0.6.10 → 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.
Files changed (56) hide show
  1. package/README.md +93 -13
  2. package/dist/config-ops.d.ts +4 -2
  3. package/dist/config-ops.d.ts.map +1 -1
  4. package/dist/config-ops.js +30 -2
  5. package/dist/config-ops.js.map +1 -1
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +75 -27
  8. package/dist/engine.js.map +1 -1
  9. package/dist/pipeline-runner.d.ts +1 -1
  10. package/dist/pipeline-runner.d.ts.map +1 -1
  11. package/dist/pipeline-runner.js +20 -0
  12. package/dist/pipeline-runner.js.map +1 -1
  13. package/dist/ports.d.ts +23 -1
  14. package/dist/ports.d.ts.map +1 -1
  15. package/dist/ports.js +160 -0
  16. package/dist/ports.js.map +1 -1
  17. package/dist/schema.d.ts.map +1 -1
  18. package/dist/schema.js +47 -11
  19. package/dist/schema.js.map +1 -1
  20. package/dist/sdk.d.ts +2 -2
  21. package/dist/sdk.d.ts.map +1 -1
  22. package/dist/sdk.js +1 -1
  23. package/dist/sdk.js.map +1 -1
  24. package/dist/task-ref.d.ts.map +1 -1
  25. package/dist/task-ref.js +2 -0
  26. package/dist/task-ref.js.map +1 -1
  27. package/dist/utils.js +3 -3
  28. package/dist/utils.js.map +1 -1
  29. package/dist/validate-raw.d.ts.map +1 -1
  30. package/dist/validate-raw.js +167 -3
  31. package/dist/validate-raw.js.map +1 -1
  32. package/dist/yaml-compiler.d.ts.map +1 -1
  33. package/dist/yaml-compiler.js +23 -5
  34. package/dist/yaml-compiler.js.map +1 -1
  35. package/package.json +2 -2
  36. package/src/completions/output-check.test.ts +50 -0
  37. package/src/config-ops.test.ts +70 -0
  38. package/src/config-ops.ts +23 -2
  39. package/src/engine-ports.test.ts +66 -0
  40. package/src/engine-task-type.test.ts +56 -0
  41. package/src/engine.ts +100 -26
  42. package/src/pipeline-runner.test.ts +95 -0
  43. package/src/pipeline-runner.ts +18 -1
  44. package/src/ports.test.ts +127 -0
  45. package/src/ports.ts +224 -1
  46. package/src/schema-ports.test.ts +86 -0
  47. package/src/schema.test.ts +113 -1
  48. package/src/schema.ts +52 -13
  49. package/src/sdk.ts +4 -0
  50. package/src/task-ref.ts +1 -0
  51. package/src/utils.test.ts +28 -0
  52. package/src/utils.ts +3 -3
  53. package/src/validate-raw-ports.test.ts +66 -0
  54. package/src/validate-raw.ts +189 -4
  55. package/src/yaml-compiler.test.ts +108 -0
  56. package/src/yaml-compiler.ts +32 -5
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
@@ -13,6 +13,34 @@ const WORK_DIR = process.platform === 'win32' ? 'D:\\fake-work' : '/fake-work';
13
13
  // ─── resolveConfig preserves ports ───────────────────────────────────
14
14
 
15
15
  describe('resolveConfig — ports passthrough', () => {
16
+ test('raw lightweight bindings survive onto the resolved task', () => {
17
+ const raw: RawPipelineConfig = {
18
+ name: 'p',
19
+ tracks: [
20
+ {
21
+ id: 't',
22
+ name: 'T',
23
+ tasks: [
24
+ {
25
+ id: 'a',
26
+ command: 'echo "{{inputs.city}}"',
27
+ inputs: {
28
+ city: { from: 't.plan.outputs.city', required: true },
29
+ },
30
+ outputs: {
31
+ report: { from: 'json.reportPath' },
32
+ },
33
+ },
34
+ ],
35
+ },
36
+ ],
37
+ };
38
+ const resolved = resolveConfig(raw, WORK_DIR);
39
+ const task = resolved.tracks[0]!.tasks[0]!;
40
+ expect(task.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
41
+ expect(task.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
42
+ });
43
+
16
44
  test('raw ports survive onto the resolved task', () => {
17
45
  const raw: RawPipelineConfig = {
18
46
  name: 'p',
@@ -83,6 +111,35 @@ describe('resolveConfig — ports passthrough', () => {
83
111
  // ─── deresolvePipeline preserves ports ───────────────────────────────
84
112
 
85
113
  describe('deresolvePipeline — ports round-trip', () => {
114
+ test('lightweight bindings round-trip', () => {
115
+ const raw: RawPipelineConfig = {
116
+ name: 'p',
117
+ tracks: [
118
+ {
119
+ id: 't',
120
+ name: 'T',
121
+ tasks: [
122
+ {
123
+ id: 'a',
124
+ command: 'echo "{{inputs.city}}"',
125
+ inputs: {
126
+ city: { from: 't.plan.outputs.city', required: true },
127
+ mode: { default: 'quick' },
128
+ },
129
+ outputs: {
130
+ raw: { from: 'stdout' },
131
+ },
132
+ },
133
+ ],
134
+ },
135
+ ],
136
+ };
137
+ const resolved = resolveConfig(raw, WORK_DIR);
138
+ const back = deresolvePipeline(resolved, WORK_DIR);
139
+ expect(back.tracks[0]!.tasks[0]!.inputs).toEqual(raw.tracks[0]!.tasks[0]!.inputs!);
140
+ expect(back.tracks[0]!.tasks[0]!.outputs).toEqual(raw.tracks[0]!.tasks[0]!.outputs!);
141
+ });
142
+
86
143
  test('ports with both inputs and outputs round-trip', () => {
87
144
  const raw: RawPipelineConfig = {
88
145
  name: 'p',
@@ -200,6 +257,35 @@ describe('deresolvePipeline — ports round-trip', () => {
200
257
  // ─── parseYaml accepts ports ─────────────────────────────────────────
201
258
 
202
259
  describe('parseYaml — accepts ports declarations', () => {
260
+ test('real-world YAML with lightweight bindings parses cleanly', () => {
261
+ const text = `pipeline:
262
+ name: demo
263
+ tracks:
264
+ - id: t
265
+ name: Main
266
+ tasks:
267
+ - id: build
268
+ command: bun run build
269
+ outputs:
270
+ bundlePath: { from: json.bundlePath }
271
+ - id: test
272
+ depends_on: [build]
273
+ command: 'bun test "{{inputs.bundlePath}}"'
274
+ inputs:
275
+ bundlePath:
276
+ from: t.build.outputs.bundlePath
277
+ required: true
278
+ `;
279
+ const config = parseYaml(text);
280
+ const build = config.tracks[0]!.tasks[0]!;
281
+ const testTask = config.tracks[0]!.tasks[1]!;
282
+ expect(build.outputs!.bundlePath).toEqual({ from: 'json.bundlePath' });
283
+ expect(testTask.inputs!.bundlePath).toEqual({
284
+ from: 't.build.outputs.bundlePath',
285
+ required: true,
286
+ });
287
+ });
288
+
203
289
  test('real-world YAML with ports parses cleanly', () => {
204
290
  const text = `pipeline:
205
291
  name: demo
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
2
  import yaml from 'js-yaml';
3
3
  import type { PipelineConfig, RawPipelineConfig } from './types';
4
- import { deresolvePipeline, serializePipeline } from './schema';
4
+ import { deresolvePipeline, parseYaml, resolveConfig, serializePipeline } from './schema';
5
5
 
6
6
  function parsePipelineYaml(content: string): RawPipelineConfig {
7
7
  const doc = yaml.load(content) as { pipeline: RawPipelineConfig };
@@ -56,6 +56,37 @@ describe('completion default serialization', () => {
56
56
  });
57
57
  });
58
58
 
59
+ test('serializePipeline drops continue_from from command tasks (prompt-only field)', () => {
60
+ const raw: RawPipelineConfig = {
61
+ name: 'Strip Continue From',
62
+ tracks: [
63
+ {
64
+ id: 'track_a',
65
+ name: 'Track A',
66
+ tasks: [
67
+ { id: 'upstream', prompt: 'generate something' },
68
+ // Simulates a task the user authored as `prompt` with a
69
+ // continue_from, then toggled to `command` in the editor panel.
70
+ // The field should not survive serialization.
71
+ {
72
+ id: 'downstream',
73
+ command: 'bun run build',
74
+ continue_from: 'upstream',
75
+ depends_on: ['upstream'],
76
+ },
77
+ // A prompt task keeps its continue_from as-is.
78
+ { id: 'threaded', prompt: 'refine', continue_from: 'upstream' },
79
+ ],
80
+ },
81
+ ],
82
+ };
83
+
84
+ const parsed = parsePipelineYaml(serializePipeline(raw));
85
+ expect(parsed.tracks[0].tasks[1].continue_from).toBeUndefined();
86
+ expect(parsed.tracks[0].tasks[1].depends_on).toEqual(['upstream']);
87
+ expect(parsed.tracks[0].tasks[2].continue_from).toBe('upstream');
88
+ });
89
+
59
90
  test('deresolvePipeline also omits the default exit_code completion', () => {
60
91
  const resolved: PipelineConfig = {
61
92
  name: 'Deresolve Defaults',
@@ -99,3 +130,84 @@ describe('completion default serialization', () => {
99
130
  });
100
131
  });
101
132
  });
133
+
134
+ describe('parseYaml structural validation', () => {
135
+ test('rejects non-array pipeline.tracks with a clear error', () => {
136
+ expect(() =>
137
+ parseYaml(`
138
+ pipeline:
139
+ name: Bad
140
+ tracks:
141
+ id: not-an-array
142
+ `),
143
+ ).toThrow(/pipeline\.tracks must be an array/);
144
+ });
145
+
146
+ test('rejects non-array track.tasks with a clear error', () => {
147
+ expect(() =>
148
+ parseYaml(`
149
+ pipeline:
150
+ name: Bad
151
+ tracks:
152
+ - id: t
153
+ name: T
154
+ tasks:
155
+ id: not-an-array
156
+ `),
157
+ ).toThrow(/track "t": tasks must be an array/);
158
+ });
159
+ });
160
+
161
+ describe('permissions inheritance', () => {
162
+ test('resolveConfig applies pipeline-level permissions to tracks and tasks', () => {
163
+ const raw: RawPipelineConfig = {
164
+ name: 'Pipeline Permissions',
165
+ permissions: { read: true, write: true, execute: false },
166
+ tracks: [
167
+ {
168
+ id: 'track_a',
169
+ name: 'Track A',
170
+ tasks: [{ id: 'task_1', prompt: 'hello' }],
171
+ },
172
+ ],
173
+ };
174
+
175
+ const resolved = resolveConfig(raw, 'D:/workspace');
176
+ expect(resolved.tracks[0].permissions).toEqual({ read: true, write: true, execute: false });
177
+ expect(resolved.tracks[0].tasks[0].permissions).toEqual({
178
+ read: true,
179
+ write: true,
180
+ execute: false,
181
+ });
182
+ });
183
+
184
+ test('deresolvePipeline preserves pipeline-level permissions without repeating inherited values', () => {
185
+ const resolved: PipelineConfig = {
186
+ name: 'Deresolve Permissions',
187
+ permissions: { read: true, write: true, execute: false },
188
+ tracks: [
189
+ {
190
+ id: 'track_a',
191
+ name: 'Track A',
192
+ permissions: { read: true, write: true, execute: false },
193
+ cwd: 'D:/workspace',
194
+ tasks: [
195
+ {
196
+ id: 'task_1',
197
+ name: 'Task 1',
198
+ prompt: 'hello',
199
+ permissions: { read: true, write: true, execute: false },
200
+ cwd: 'D:/workspace',
201
+ },
202
+ ],
203
+ },
204
+ ],
205
+ };
206
+
207
+ const raw = deresolvePipeline(resolved, 'D:/workspace');
208
+
209
+ expect(raw.permissions).toEqual({ read: true, write: true, execute: false });
210
+ expect(raw.tracks[0].permissions).toBeUndefined();
211
+ expect(raw.tracks[0].tasks[0].permissions).toBeUndefined();
212
+ });
213
+ });
package/src/schema.ts CHANGED
@@ -17,13 +17,17 @@ import { buildDag } from './dag';
17
17
  // ═══ YAML Parsing ═══
18
18
 
19
19
  export function parseYaml(content: string): RawPipelineConfig {
20
- const doc = yaml.load(content) as { pipeline?: RawPipelineConfig };
20
+ const doc = yaml.load(content) as { pipeline?: unknown };
21
21
  if (!doc?.pipeline) {
22
22
  throw new Error('YAML must contain a top-level "pipeline" key');
23
23
  }
24
- const p = doc.pipeline;
24
+ if (typeof doc.pipeline !== 'object' || Array.isArray(doc.pipeline)) {
25
+ throw new Error('pipeline must be an object');
26
+ }
27
+ const p = doc.pipeline as RawPipelineConfig;
25
28
  if (!p.name) throw new Error('pipeline.name is required');
26
- if (!p.tracks || p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
29
+ if (!Array.isArray(p.tracks)) throw new Error('pipeline.tracks must be an array');
30
+ if (p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
27
31
 
28
32
  // D14: Detect duplicate track IDs before per-track validation so the error
29
33
  // message is clear ("Duplicate track id") rather than a confusing DAG error
@@ -60,10 +64,16 @@ function assertValidId(id: string, label: string): void {
60
64
  }
61
65
 
62
66
  function validateRawTrack(track: RawTrackConfig): void {
67
+ if (!track || typeof track !== 'object' || Array.isArray(track)) {
68
+ throw new Error('track must be an object');
69
+ }
63
70
  if (!track.id) throw new Error('track.id is required');
64
71
  assertValidId(track.id, `track "${track.id}"`);
65
72
  if (!track.name) throw new Error(`track "${track.id}": name is required`);
66
- if (!track.tasks || track.tasks.length === 0) {
73
+ if (!Array.isArray(track.tasks)) {
74
+ throw new Error(`track "${track.id}": tasks must be an array`);
75
+ }
76
+ if (track.tasks.length === 0) {
67
77
  throw new Error(`track "${track.id}": tasks must be non-empty`);
68
78
  }
69
79
  for (const task of track.tasks) {
@@ -72,6 +82,9 @@ function validateRawTrack(track: RawTrackConfig): void {
72
82
  }
73
83
 
74
84
  function validateRawTask(task: RawTaskConfig, trackId: string): void {
85
+ if (!task || typeof task !== 'object' || Array.isArray(task)) {
86
+ throw new Error(`track "${trackId}": task must be an object`);
87
+ }
75
88
  if (!task.id) throw new Error(`track "${trackId}": task.id is required`);
76
89
  assertValidId(task.id, `task "${task.id}" in track "${trackId}"`);
77
90
 
@@ -84,7 +97,7 @@ function validateRawTask(task: RawTaskConfig, trackId: string): void {
84
97
  throw new Error(`task "${task.id}": cannot have both "prompt" and "command"`);
85
98
  }
86
99
  // Empty-content tasks (e.g. `prompt: ''`) are allowed at parse time and
87
- // flagged as non-fatal validation errors by validate-raw.ts.
100
+ // flagged as hard validation errors by validate-raw.ts.
88
101
  }
89
102
 
90
103
  // ═══ Config Inheritance Resolution ═══
@@ -140,7 +153,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
140
153
  model: rawTask.model ?? rawTrack.model ?? raw.model,
141
154
  reasoning_effort:
142
155
  rawTask.reasoning_effort ?? rawTrack.reasoning_effort ?? raw.reasoning_effort,
143
- permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
156
+ permissions: rawTask.permissions ?? rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
144
157
  driver: rawTask.driver ?? trackDriver ?? 'opencode',
145
158
  timeout: rawTask.timeout,
146
159
  // Middleware: Task-level overrides Track (including [] to disable)
@@ -148,8 +161,10 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
148
161
  completion: rawTask.completion,
149
162
  agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
150
163
  cwd: rawTask.cwd ? validatePath(rawTask.cwd, workDir) : trackCwd,
151
- // Ports: no inheritance — they describe per-task I/O contract, not
152
- // cross-task defaults. Passed through as-is (including `undefined`).
164
+ // Lightweight bindings and ports: no inheritance — they describe
165
+ // per-task data flow, not cross-task defaults.
166
+ inputs: rawTask.inputs,
167
+ outputs: rawTask.outputs,
153
168
  ports: rawTask.ports,
154
169
  };
155
170
  });
@@ -161,7 +176,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
161
176
  agent_profile: rawTrack.agent_profile,
162
177
  model: rawTrack.model ?? raw.model,
163
178
  reasoning_effort: rawTrack.reasoning_effort ?? raw.reasoning_effort,
164
- permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
179
+ permissions: rawTrack.permissions ?? raw.permissions ?? DEFAULT_PERMISSIONS,
165
180
  driver: trackDriver ?? 'opencode',
166
181
  cwd: trackCwd,
167
182
  middlewares: rawTrack.middlewares,
@@ -175,6 +190,7 @@ export function resolveConfig(raw: RawPipelineConfig, workDir: string): Pipeline
175
190
  driver: raw.driver,
176
191
  model: raw.model,
177
192
  reasoning_effort: raw.reasoning_effort,
193
+ permissions: raw.permissions,
178
194
  timeout: raw.timeout,
179
195
  plugins: raw.plugins,
180
196
  hooks: raw.hooks,
@@ -208,14 +224,32 @@ function stripDefaultTaskCompletion<T extends { completion?: CompletionConfig }>
208
224
  return rest as T;
209
225
  }
210
226
 
211
- function stripDefaultCompletionsForSerialization<T extends PipelineConfig | RawPipelineConfig>(
227
+ // `continue_from` is a prompt-only field — it tells AI drivers with
228
+ // session-resume capability to thread off an upstream prompt task's context.
229
+ // A command task runs as a plain shell subprocess and has no session to
230
+ // resume, so any `continue_from` on a command task is dead weight. Drop it
231
+ // at serialization time so YAML on disk never carries the stale field after
232
+ // a user toggles task mode from prompt → command. The tagma-yaml agent's
233
+ // system prompt (apps/editor/server/opencode-seed.ts) documents this
234
+ // stripping — keep them in sync.
235
+ function stripPromptOnlyFieldsFromCommandTask<
236
+ T extends { command?: string; continue_from?: string },
237
+ >(task: T): T {
238
+ if (task.command === undefined || task.continue_from === undefined) return task;
239
+ const { continue_from: _cf, ...rest } = task;
240
+ return rest as T;
241
+ }
242
+
243
+ function stripForSerialization<T extends PipelineConfig | RawPipelineConfig>(
212
244
  config: T,
213
245
  ): T {
214
246
  return {
215
247
  ...config,
216
248
  tracks: config.tracks.map((track) => ({
217
249
  ...track,
218
- tasks: track.tasks.map((task) => stripDefaultTaskCompletion(task)),
250
+ tasks: track.tasks.map((task) =>
251
+ stripPromptOnlyFieldsFromCommandTask(stripDefaultTaskCompletion(task)),
252
+ ),
219
253
  })),
220
254
  } as T;
221
255
  }
@@ -228,7 +262,7 @@ function stripDefaultCompletionsForSerialization<T extends PipelineConfig | RawP
228
262
  */
229
263
  export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
230
264
  return yaml.dump(
231
- { pipeline: stripDefaultCompletionsForSerialization(config) },
265
+ { pipeline: stripForSerialization(config) },
232
266
  { lineWidth: 120, indent: 2 },
233
267
  );
234
268
  }
@@ -277,6 +311,8 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
277
311
  ...(task.permissions && !permissionsEqual(task.permissions, track.permissions)
278
312
  ? { permissions: task.permissions }
279
313
  : {}),
314
+ ...(task.inputs && Object.keys(task.inputs).length > 0 ? { inputs: task.inputs } : {}),
315
+ ...(task.outputs && Object.keys(task.outputs).length > 0 ? { outputs: task.outputs } : {}),
280
316
  ...(task.ports &&
281
317
  ((task.ports.inputs && task.ports.inputs.length > 0) ||
282
318
  (task.ports.outputs && task.ports.outputs.length > 0))
@@ -302,7 +338,7 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
302
338
  ...(track.on_failure && track.on_failure !== 'skip_downstream'
303
339
  ? { on_failure: track.on_failure }
304
340
  : {}),
305
- ...(track.permissions && !permissionsEqual(track.permissions, DEFAULT_PERMISSIONS)
341
+ ...(track.permissions && !permissionsEqual(track.permissions, config.permissions ?? DEFAULT_PERMISSIONS)
306
342
  ? { permissions: track.permissions }
307
343
  : {}),
308
344
  tasks,
@@ -314,6 +350,9 @@ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawP
314
350
  ...(config.driver ? { driver: config.driver } : {}),
315
351
  ...(config.model ? { model: config.model } : {}),
316
352
  ...(config.reasoning_effort ? { reasoning_effort: config.reasoning_effort } : {}),
353
+ ...(config.permissions && !permissionsEqual(config.permissions, DEFAULT_PERMISSIONS)
354
+ ? { permissions: config.permissions }
355
+ : {}),
317
356
  ...(config.timeout ? { timeout: config.timeout } : {}),
318
357
  ...(config.plugins?.length ? { plugins: config.plugins } : {}),
319
358
  ...(config.hooks ? { hooks: config.hooks } : {}),
package/src/sdk.ts CHANGED
@@ -129,12 +129,16 @@ export {
129
129
  export {
130
130
  substituteInputs,
131
131
  extractInputReferences,
132
+ resolveTaskBindingInputs,
132
133
  resolveTaskInputs,
134
+ extractTaskBindingOutputs,
133
135
  extractTaskOutputs,
134
136
  inferPromptPorts,
135
137
  } from './ports';
136
138
  export type {
137
139
  SubstituteResult,
140
+ BindingInputResolution,
141
+ UpstreamBindingData,
138
142
  InputResolution,
139
143
  ExtractResult,
140
144
  PromptPortInference,
package/src/task-ref.ts CHANGED
@@ -68,6 +68,7 @@ export function buildTaskIndex(config: RawPipelineConfig | PipelineConfig): Task
68
68
  const bareToQualified = new Map<string, string>();
69
69
  for (const track of config.tracks ?? []) {
70
70
  if (!track?.id) continue;
71
+ if (!Array.isArray(track.tasks)) continue;
71
72
  for (const task of track.tasks ?? []) {
72
73
  if (!task?.id) continue;
73
74
  const qid = qualifyTaskId(track.id, task.id);