@tagma/sdk 0.6.4 → 0.6.6

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 (57) hide show
  1. package/README.md +74 -6
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +194 -21
  4. package/dist/engine.js.map +1 -1
  5. package/dist/pipeline-runner.d.ts.map +1 -1
  6. package/dist/pipeline-runner.js +3 -0
  7. package/dist/pipeline-runner.js.map +1 -1
  8. package/dist/ports.d.ts +118 -0
  9. package/dist/ports.d.ts.map +1 -0
  10. package/dist/ports.js +365 -0
  11. package/dist/ports.js.map +1 -0
  12. package/dist/prompt-doc.d.ts +35 -1
  13. package/dist/prompt-doc.d.ts.map +1 -1
  14. package/dist/prompt-doc.js +110 -0
  15. package/dist/prompt-doc.js.map +1 -1
  16. package/dist/runner.d.ts +17 -0
  17. package/dist/runner.d.ts.map +1 -1
  18. package/dist/runner.js +171 -8
  19. package/dist/runner.js.map +1 -1
  20. package/dist/schema.d.ts.map +1 -1
  21. package/dist/schema.js +8 -0
  22. package/dist/schema.js.map +1 -1
  23. package/dist/sdk.d.ts +3 -1
  24. package/dist/sdk.d.ts.map +1 -1
  25. package/dist/sdk.js +5 -1
  26. package/dist/sdk.js.map +1 -1
  27. package/dist/validate-raw.d.ts.map +1 -1
  28. package/dist/validate-raw.js +141 -0
  29. package/dist/validate-raw.js.map +1 -1
  30. package/package.json +2 -7
  31. package/src/dag.test.ts +56 -0
  32. package/src/engine-ports.test.ts +404 -0
  33. package/src/engine.ts +231 -24
  34. package/src/pipeline-runner.ts +3 -0
  35. package/src/ports.test.ts +301 -0
  36. package/src/ports.ts +442 -0
  37. package/src/prompt-doc.test.ts +174 -0
  38. package/src/prompt-doc.ts +121 -1
  39. package/src/runner.test.ts +142 -0
  40. package/src/runner.ts +198 -8
  41. package/src/schema-ports.test.ts +236 -0
  42. package/src/schema.ts +8 -0
  43. package/src/sdk.ts +14 -0
  44. package/src/validate-raw-ports.test.ts +198 -0
  45. package/src/validate-raw.ts +155 -1
  46. package/dist/plugin-registry.test.d.ts +0 -2
  47. package/dist/plugin-registry.test.d.ts.map +0 -1
  48. package/dist/plugin-registry.test.js +0 -188
  49. package/dist/plugin-registry.test.js.map +0 -1
  50. package/dist/schema.test.d.ts +0 -2
  51. package/dist/schema.test.d.ts.map +0 -1
  52. package/dist/schema.test.js +0 -94
  53. package/dist/schema.test.js.map +0 -1
  54. package/dist/task-ref.test.d.ts +0 -2
  55. package/dist/task-ref.test.d.ts.map +0 -1
  56. package/dist/task-ref.test.js +0 -364
  57. package/dist/task-ref.test.js.map +0 -1
package/README.md CHANGED
@@ -72,6 +72,7 @@ console.log(result.success ? 'Done' : 'Failed');
72
72
  - **Middleware** -- enrich prompts before execution (e.g. inject static context)
73
73
  - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
74
74
  - **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
75
+ - **Typed task ports** -- declare named, typed `inputs` / `outputs` on a task. Inputs from upstream tasks are substituted into `command` / `prompt` via `{{inputs.<name>}}` and rendered as an `[Inputs]` context block for AI tasks; outputs are extracted from the final-line JSON object on stdout (or `normalizedOutput`) and surfaced to downstream tasks
75
76
 
76
77
  ## Pipeline YAML Reference
77
78
 
@@ -199,6 +200,7 @@ Each hook value can be a single command string or an array of commands.
199
200
  | `middlewares` | `MiddlewareConfig[]` | No | Inherited from track | Middleware override. Set `[]` to disable inherited middlewares |
200
201
  | `trigger` | `TriggerConfig` | No | — | Gate that must resolve before the task runs (see Triggers) |
201
202
  | `completion` | `CompletionConfig` | No | — | Post-execution check to validate task output (see Completions) |
203
+ | `ports` | `TaskPorts` | No | — | Typed input/output ports — see Typed Ports below |
202
204
 
203
205
  ### Permissions
204
206
 
@@ -218,6 +220,51 @@ Track-level `middlewares` apply to all tasks in the track. Setting task-level `m
218
220
 
219
221
  ---
220
222
 
223
+ ### Typed Ports
224
+
225
+ Tasks can declare named, typed `inputs` / `outputs`. Inputs flow in from upstream task outputs; outputs are extracted from a task's stdout (or the AI driver's `normalizedOutput`) on success.
226
+
227
+ ```yaml
228
+ - id: lookup-weather
229
+ name: Lookup weather
230
+ command: weather.sh --city "{{inputs.city}}"
231
+ ports:
232
+ inputs:
233
+ - { name: city, type: string, required: true, description: Target city }
234
+ outputs:
235
+ - { name: temperature, type: number, description: Current temperature in Celsius }
236
+ - { name: conditions, type: enum, enum: [sunny, cloudy, rain, snow] }
237
+
238
+ - id: write-report
239
+ depends_on: [lookup-weather]
240
+ prompt: 'Write a brief weather report for {{inputs.city}}.'
241
+ ports:
242
+ inputs:
243
+ - { name: city, type: string, required: true }
244
+ - { name: temperature, type: number, required: true }
245
+ - { name: conditions, type: enum, enum: [sunny, cloudy, rain, snow] }
246
+ ```
247
+
248
+ #### `PortDef` fields
249
+
250
+ | Field | Type | Required | Description |
251
+ | ------------- | ----------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
252
+ | `name` | `string` | Yes | Port name; also the substitution key (`{{inputs.<name>}}`) |
253
+ | `type` | `'string' \| 'number' \| 'boolean' \| 'enum' \| 'json'` | Yes | Coercion type. Mismatched values block the task with a typed-error diagnostic |
254
+ | `description` | `string` | No | Free-text description; rendered into the `[Inputs]` / `[Output Format]` blocks |
255
+ | `required` | `boolean` | No | Inputs only. When `true`, missing upstream value (and no `default`) blocks the task |
256
+ | `default` | `unknown` | No | Inputs only. Fallback value when no upstream produces the port |
257
+ | `enum` | `string[]` | When `type: enum` | Allowed values |
258
+ | `from` | `string` | No | Inputs only. Explicit upstream binding — bare `portName` (match by name) or `taskId.portName` (fully qualified). Unset = match by name across all direct upstreams; ambiguous matches block |
259
+
260
+ #### Substitution and AI prompt blocks
261
+
262
+ - `{{inputs.<name>}}` is expanded verbatim in `command` and `prompt` strings before execution. Quote your placeholders in command lines (`--city "{{inputs.city}}"`) — the engine does not shell-escape.
263
+ - AI tasks additionally get two prepended `PromptContextBlock`s: `[Output Format]` (instructs the model to emit a final-line JSON object matching the declared outputs) and `[Inputs]` (renders the resolved inputs as `name: value # description`). Tasks without ports get no extra blocks.
264
+ - Output extraction strategy: prefer `normalizedOutput` (AI tasks), fall back to stdout (command tasks). Find the last non-empty line that parses as a JSON object, then read each declared output key. Failures append a diagnostic to stderr; the port is absent from `outputs` and downstream tasks see it as missing.
265
+
266
+ ---
267
+
221
268
  ### Built-in Triggers
222
269
 
223
270
  #### `manual` — Human approval gate
@@ -306,6 +353,8 @@ Options:
306
353
  - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
307
354
  - `skipPluginLoading` -- skip the engine's built-in `loadPlugins(config.plugins)` call. Set this when the host has already pre-loaded plugins from a custom resolution path (e.g. the editor loading from the user's workspace `node_modules`) so the engine doesn't re-resolve them via Node's default cwd-based import.
308
355
 
356
+ > **stdout / stderr persistence.** The engine streams every task's stdout and stderr to disk under `<workDir>/.tagma/logs/<runId>/<taskId>.stdout` and `.stderr`. The `TaskResult.stdout` / `stderr` strings are bounded tails (8 MB / 4 MB by default) — long outputs are truncated from the head with a marker, and consumers that need the full bytes should read `TaskResult.stdoutPath` / `stderrPath`. Use `TaskResult.stdoutBytes` / `stderrBytes` to display "32 MB (truncated)" without re-stat'ing the file.
357
+
309
358
  ### `PipelineRunner`
310
359
 
311
360
  Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
@@ -474,11 +523,27 @@ Validates a resolved pipeline config without executing it. Checks DAG structure
474
523
 
475
524
  Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
476
525
 
526
+ ### Typed Ports API
527
+
528
+ Pure helpers backing the `task.ports` feature (see Typed Ports above). Safe to use in editors, simulators, and custom drivers — no I/O, no side effects.
529
+
530
+ | Function | Description |
531
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
532
+ | `substituteInputs(text, inputs)` | Expand `{{inputs.<name>}}` placeholders in `text`. Returns `{ text, unresolved }`. Strings pass through, numbers/booleans coerce via `String(...)`, objects/arrays via `JSON.stringify`. Caller is responsible for shell quoting |
533
+ | `extractInputReferences(text)` | Return the set of input port names referenced by `{{inputs.<name>}}` placeholders in `text`. Use at edit time to flag undeclared references |
534
+ | `resolveTaskInputs(task, upstreamOutputs, dependsOn)` | Gather the input values a task will consume from its direct upstreams. Applies `from` bindings, defaults, and type coercion. Returns `{ kind: 'ready', inputs, missingOptional }` or `{ kind: 'blocked', missingRequired, ambiguous, typeErrors, reason }` |
535
+ | `extractTaskOutputs(ports, stdout, normalizedOutput)` | Pull declared output values from a terminated task's output. Strategy: prefer `normalizedOutput`; find the last non-empty line that parses as a JSON object; coerce each declared key. Returns `{ outputs, diagnostic }` |
536
+ | `prependContext(doc, block)` | Same shape as `appendContext` but prepends; the engine uses this to place `[Output Format]` and `[Inputs]` blocks before middleware-added context |
537
+ | `renderInputsBlock(inputsDecl, values)` | Build the `[Inputs]` `PromptContextBlock` rendered into AI prompts (`name: value # description` lines). Returns `null` when no inputs to render |
538
+ | `renderOutputSchemaBlock(outputsDecl)` | Build the `[Output Format]` `PromptContextBlock` instructing the model to emit a final-line JSON object matching the declared outputs. Returns `null` when no outputs declared |
539
+
540
+ Custom drivers that wrap the prompt in their own envelope can read `DriverContext.inputs` (resolved + coerced map keyed by port name) and call `substituteInputs` themselves — the engine has already substituted into `task.prompt` upstream, so most drivers can ignore this.
541
+
477
542
  ### `validateRaw(config: RawPipelineConfig): ValidationError[]`
478
543
 
479
544
  Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
480
545
 
481
- Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection.
546
+ Checks: required fields, `prompt`/`command` exclusivity, duplicate task IDs within a track, `depends_on`/`continue_from` reference integrity (including ambiguous bare refs that exist in multiple tracks — use `trackId.taskId` to disambiguate), circular dependency detection, port shape (name format, valid `type`, duplicate names, `enum` requires non-empty `enum` array, `required`/`from` ignored on outputs), and `{{inputs.<name>}}` references resolving to a declared input port.
482
547
 
483
548
  Does **not** check plugin registration (plugins may not be loaded at edit time).
484
549
 
@@ -562,11 +627,14 @@ Truncates `text` to at most `maxBytes` UTF-8 bytes (default 16 KB), appending a
562
627
 
563
628
  ## Related Packages
564
629
 
565
- | Package | Description |
566
- | ------------------------------------------------------------------------------ | -------------------------- |
567
- | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
568
- | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
569
- | [@tagma/driver-claude-code](https://www.npmjs.com/package/@tagma/driver-claude-code) | Claude Code CLI driver plugin |
630
+ | Package | Description |
631
+ | ---------------------------------------------------------------------------------------- | --------------------------------------------- |
632
+ | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
633
+ | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
634
+ | [@tagma/driver-claude-code](https://www.npmjs.com/package/@tagma/driver-claude-code) | Claude Code CLI driver plugin |
635
+ | [@tagma/middleware-lightrag](https://www.npmjs.com/package/@tagma/middleware-lightrag) | LightRAG knowledge-graph retrieval middleware |
636
+ | [@tagma/trigger-webhook](https://www.npmjs.com/package/@tagma/trigger-webhook) | HTTP webhook trigger plugin |
637
+ | [@tagma/completion-llm-judge](https://www.npmjs.com/package/@tagma/completion-llm-judge) | LLM-as-judge completion plugin |
570
638
 
571
639
  ## License
572
640
 
@@ -1 +1 @@
1
- {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EAEd,SAAS,EAaT,eAAe,EAEhB,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,YAAY,CAAC;AAelE,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAM3E,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAG,iBAAiB,CAAU;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAG,iBAAiB,CAAU;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AA6ED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;CACjD;AAWD,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAuC/C,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,eAAe,CAAC,EAAE,eAAe,CAAC;IAC3C;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,CAAC;CACpC;AAWD,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,YAAY,CAAC,CAo5BvB"}
1
+ {"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../src/engine.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,cAAc,EAEd,SAAS,EAaT,eAAe,EAEhB,MAAM,SAAS,CAAC;AAEjB,OAAO,EAAmB,KAAK,cAAc,EAAE,MAAM,YAAY,CAAC;AAsBlE,OAAO,EAA2B,KAAK,eAAe,EAAE,MAAM,YAAY,CAAC;AAM3E,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAG,iBAAiB,CAAU;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAC5C,QAAQ,CAAC,IAAI,EAAG,iBAAiB,CAAU;gBAC/B,OAAO,EAAE,MAAM;CAI5B;AA6ED,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,OAAO,EAAE;QAChB,KAAK,EAAE,MAAM,CAAC;QACd,OAAO,EAAE,MAAM,CAAC;QAChB,MAAM,EAAE,MAAM,CAAC;QACf,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;KACjB,CAAC;IACF,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;CACjD;AAWD,YAAY,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AA8C/C,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,eAAe,CAAC,EAAE,eAAe,CAAC;IAC3C;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B;;;;OAIG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,WAAW,CAAC;IAC9B;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IACpD;;;;;OAKG;IACH,QAAQ,CAAC,iBAAiB,CAAC,EAAE,OAAO,CAAC;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,cAAc,CAAC;CACpC;AAWD,wBAAsB,WAAW,CAC/B,MAAM,EAAE,cAAc,EACtB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,YAAY,CAAC,CAqlCvB"}
package/dist/engine.js CHANGED
@@ -4,7 +4,8 @@ import { buildDag } from './dag';
4
4
  import { defaultRegistry } from './registry';
5
5
  import { runSpawn, runCommand } from './runner';
6
6
  import { parseDuration, nowISO, generateRunId } from './utils';
7
- import { promptDocumentFromString, serializePromptDocument } from './prompt-doc';
7
+ import { promptDocumentFromString, serializePromptDocument, prependContext, renderInputsBlock, renderOutputSchemaBlock, } from './prompt-doc';
8
+ import { extractTaskOutputs, resolveTaskInputs, substituteInputs } from './ports';
8
9
  import { executeHook, buildPipelineStartContext, buildTaskContext, buildPipelineCompleteContext, buildPipelineErrorContext, } from './hooks';
9
10
  import { Logger, tailLines, clip } from './logger';
10
11
  import { InMemoryApprovalGateway } from './approval';
@@ -102,12 +103,19 @@ function toRunTaskState(taskId, trackId, taskName, state) {
102
103
  exitCode: result?.exitCode ?? null,
103
104
  stdout: result?.stdout ?? '',
104
105
  stderr: result?.stderr ?? '',
106
+ stdoutPath: result?.stdoutPath ?? null,
105
107
  stderrPath: result?.stderrPath ?? null,
108
+ stdoutBytes: result?.stdoutBytes ?? null,
109
+ stderrBytes: result?.stderrBytes ?? null,
106
110
  sessionId: result?.sessionId ?? null,
107
111
  normalizedOutput: result?.normalizedOutput ?? null,
108
112
  resolvedDriver: cfg.driver ?? null,
109
113
  resolvedModel: cfg.model ?? null,
110
114
  resolvedPermissions: cfg.permissions ?? null,
115
+ // Ports not yet wired through the engine's event surface. Null placeholder
116
+ // keeps the wire type honest until the ports extraction pass lands.
117
+ outputs: result?.outputs ?? null,
118
+ inputs: null,
111
119
  logs: [],
112
120
  totalLogCount: 0,
113
121
  };
@@ -218,6 +226,17 @@ export async function runPipeline(config, workDir, options = {}) {
218
226
  emit({ type: 'run_start', runId, tasks: runStartTasks });
219
227
  const sessionMap = new Map();
220
228
  const normalizedMap = new Map();
229
+ // Extracted port outputs keyed by fully-qualified task id. Populated
230
+ // after a task succeeds when its `ports.outputs` is declared; read by
231
+ // downstream tasks via `resolveTaskInputs` to assemble their inputs.
232
+ // Kept separate from normalizedMap so the continue_from text handoff
233
+ // and the typed-port data handoff don't pollute each other — they
234
+ // solve different problems and have different lifetimes.
235
+ const outputValuesMap = new Map();
236
+ // Resolved port inputs keyed by fully-qualified task id. Written once,
237
+ // just before a task runs, so every subsequent task_update event can
238
+ // echo them to the UI without re-resolving.
239
+ const resolvedInputsMap = new Map();
221
240
  // Pipeline timeout + abort reason tracking.
222
241
  //
223
242
  // `abortReason` replaces the previous `pipelineAborted: boolean`: it
@@ -314,9 +333,14 @@ export async function runPipeline(config, workDir, options = {}) {
314
333
  exitCode: result?.exitCode,
315
334
  stdout: result?.stdout,
316
335
  stderr: result?.stderr,
336
+ stdoutPath: result?.stdoutPath ?? null,
317
337
  stderrPath: result?.stderrPath ?? null,
338
+ stdoutBytes: result?.stdoutBytes ?? null,
339
+ stderrBytes: result?.stderrBytes ?? null,
318
340
  sessionId: result?.sessionId ?? null,
319
341
  normalizedOutput: result?.normalizedOutput ?? null,
342
+ inputs: resolvedInputsMap.get(taskId) ?? null,
343
+ outputs: outputValuesMap.get(taskId) ?? null,
320
344
  resolvedDriver: cfg.driver ?? null,
321
345
  resolvedModel: cfg.model ?? null,
322
346
  resolvedPermissions: cfg.permissions ?? null,
@@ -413,20 +437,28 @@ export async function runPipeline(config, workDir, options = {}) {
413
437
  log.debug(`[task:${taskId}]`, `trigger wait: type=${task.trigger.type} ${JSON.stringify(task.trigger)}`);
414
438
  try {
415
439
  const triggerPlugin = registry.getHandler('triggers', task.trigger.type);
416
- // R6: race the plugin's watch() against the pipeline's abort signal.
417
- // Third-party triggers may forget to wire up ctx.signal — without
418
- // this race, an aborted pipeline would hang forever waiting for the
419
- // plugin's watch promise to resolve. The race resolves on whichever
420
- // path settles first, and the cleanup paths in finally never run on
421
- // the orphaned plugin promise (it's allowed to leak a watcher; the
422
- // pipeline is being torn down anyway).
440
+ // R6: race the plugin's watch() against the pipeline's abort signal
441
+ // AND the task-level timeout. Third-party triggers may forget to
442
+ // wire up ctx.signal without the abort race, an aborted pipeline
443
+ // would hang forever waiting for the plugin's watch promise to
444
+ // resolve. And without the timeout race, a buggy watch() that never
445
+ // settles would ignore the user's `task.timeout` (which the spawn
446
+ // path at step 4 already honours) — a task could wedge the whole
447
+ // pipeline until pipeline-level timeout fires (or forever, if none
448
+ // is set). Honouring task.timeout here makes the two stages
449
+ // symmetric. The cleanup paths in finally never run on the orphaned
450
+ // plugin promise (it's allowed to leak a watcher; the pipeline is
451
+ // being torn down anyway).
452
+ const triggerTimeoutMs = task.timeout ? parseDuration(task.timeout) : 0;
423
453
  await new Promise((resolve, reject) => {
424
454
  let settled = false;
455
+ let timer = null;
425
456
  const onAbort = () => {
426
457
  if (settled)
427
458
  return;
428
459
  settled = true;
429
- abortController.signal.removeEventListener('abort', onAbort);
460
+ if (timer !== null)
461
+ clearTimeout(timer);
430
462
  reject(new Error('Pipeline aborted'));
431
463
  };
432
464
  if (abortController.signal.aborted) {
@@ -434,6 +466,15 @@ export async function runPipeline(config, workDir, options = {}) {
434
466
  return;
435
467
  }
436
468
  abortController.signal.addEventListener('abort', onAbort, { once: true });
469
+ if (triggerTimeoutMs > 0) {
470
+ timer = setTimeout(() => {
471
+ if (settled)
472
+ return;
473
+ settled = true;
474
+ abortController.signal.removeEventListener('abort', onAbort);
475
+ reject(new TriggerTimeoutError(`Trigger "${task.trigger.type}" did not settle within ${task.timeout} (task-level timeout)`));
476
+ }, triggerTimeoutMs);
477
+ }
437
478
  triggerPlugin
438
479
  .watch(task.trigger, {
439
480
  taskId: node.taskId,
@@ -446,12 +487,16 @@ export async function runPipeline(config, workDir, options = {}) {
446
487
  if (settled)
447
488
  return;
448
489
  settled = true;
490
+ if (timer !== null)
491
+ clearTimeout(timer);
449
492
  abortController.signal.removeEventListener('abort', onAbort);
450
493
  resolve(v);
451
494
  }, (e) => {
452
495
  if (settled)
453
496
  return;
454
497
  settled = true;
498
+ if (timer !== null)
499
+ clearTimeout(timer);
455
500
  abortController.signal.removeEventListener('abort', onAbort);
456
501
  reject(e);
457
502
  });
@@ -510,6 +555,49 @@ export async function runPipeline(config, workDir, options = {}) {
510
555
  }
511
556
  return;
512
557
  }
558
+ // 3.5. Resolve port inputs from upstream outputs. This is the last
559
+ // gate before execution: missing-required inputs block the task
560
+ // without ever spawning a process, so the caller sees a clear
561
+ // "blocked: missing input X" rather than a cryptic runtime error
562
+ // from a command that expanded a placeholder to the empty string.
563
+ // Resolution runs even for tasks that declare no ports — the call
564
+ // is cheap and returns `{kind: 'ready', inputs: {}}` in that case,
565
+ // which downstream code handles uniformly.
566
+ const inputResolution = resolveTaskInputs(task, outputValuesMap, node.dependsOn);
567
+ if (inputResolution.kind === 'blocked') {
568
+ log.error(`[task:${taskId}]`, `blocked — cannot resolve port inputs:\n${inputResolution.reason}`);
569
+ state.result = {
570
+ exitCode: -1,
571
+ stdout: '',
572
+ stderr: `[engine] port input resolution failed:\n${inputResolution.reason}`,
573
+ stdoutPath: null,
574
+ stderrPath: null,
575
+ durationMs: 0,
576
+ sessionId: null,
577
+ normalizedOutput: null,
578
+ failureKind: 'spawn_error',
579
+ outputs: null,
580
+ };
581
+ state.finishedAt = nowISO();
582
+ setTaskStatus(taskId, 'blocked');
583
+ try {
584
+ await fireHook(taskId, 'task_failure');
585
+ }
586
+ catch (hookErr) {
587
+ log.error(`[task:${taskId}]`, `hook execution failed: ${hookErr instanceof Error ? hookErr.message : String(hookErr)}`);
588
+ }
589
+ if (getOnFailure(taskId) === 'stop_all')
590
+ applyStopAll(node.track.id);
591
+ return;
592
+ }
593
+ const resolvedInputs = inputResolution.inputs;
594
+ resolvedInputsMap.set(taskId, resolvedInputs);
595
+ if (inputResolution.missingOptional.length > 0) {
596
+ log.debug(`[task:${taskId}]`, `optional inputs unresolved (empty in placeholders): ${inputResolution.missingOptional.join(', ')}`);
597
+ }
598
+ if (task.ports?.inputs && task.ports.inputs.length > 0) {
599
+ log.debug(`[task:${taskId}]`, `resolved inputs: ${JSON.stringify(resolvedInputs)}`);
600
+ }
513
601
  // 4. Mark running — set startedAt before emitting so subscribers see a
514
602
  // complete task_update (startedAt non-null) on the status transition.
515
603
  state.startedAt = nowISO();
@@ -531,17 +619,60 @@ export async function runPipeline(config, workDir, options = {}) {
531
619
  try {
532
620
  let result;
533
621
  const timeoutMs = task.timeout ? parseDuration(task.timeout) : undefined;
534
- const runOpts = { timeoutMs, signal: abortController.signal };
622
+ // Stream child stdout/stderr directly to disk in the logger's run dir
623
+ // and keep only a bounded tail in the returned TaskResult. Filenames
624
+ // mirror the existing `.stderr` naming — dots in task ids are replaced
625
+ // so hierarchical ids (e.g. `track1.task2`) map cleanly to a flat dir.
626
+ const fsSafeTaskId = taskId.replace(/\./g, '_');
627
+ const stdoutPath = resolve(log.dir, `${fsSafeTaskId}.stdout`);
628
+ const stderrPath = resolve(log.dir, `${fsSafeTaskId}.stderr`);
629
+ const runOpts = {
630
+ timeoutMs,
631
+ signal: abortController.signal,
632
+ stdoutPath,
633
+ stderrPath,
634
+ };
535
635
  if (task.command) {
536
- log.debug(`[task:${taskId}]`, `command: ${task.command}`);
537
- result = await runCommand(task.command, task.cwd ?? workDir, runOpts);
636
+ // Substitute `{{inputs.X}}` placeholders into the command
637
+ // string. Tasks with no declared inputs always produce the same
638
+ // string back (no placeholders to match). Unresolved references
639
+ // render empty — validate-raw flags undeclared references as
640
+ // errors, so the only way to land here with an unresolved is an
641
+ // optional input that had no upstream producer and no default,
642
+ // which we surface in the log.
643
+ const { text: expandedCommand, unresolved } = substituteInputs(task.command, resolvedInputs);
644
+ if (unresolved.length > 0) {
645
+ log.debug(`[task:${taskId}]`, `command placeholders rendered empty: ${unresolved.join(', ')}`);
646
+ }
647
+ log.debug(`[task:${taskId}]`, `command: ${expandedCommand}`);
648
+ result = await runCommand(expandedCommand, task.cwd ?? workDir, runOpts);
538
649
  }
539
650
  else {
540
651
  // AI task: apply middleware chain against a structured PromptDocument.
541
652
  const driverName = task.driver ?? track.driver ?? config.driver ?? 'opencode';
542
653
  const driver = registry.getHandler('drivers', driverName);
543
- const originalLen = task.prompt.length;
544
- let doc = promptDocumentFromString(task.prompt);
654
+ // Substitute placeholders in the user-authored prompt before
655
+ // wrapping into a PromptDocument so middlewares see the
656
+ // already-resolved task text.
657
+ const { text: expandedPrompt, unresolved } = substituteInputs(task.prompt, resolvedInputs);
658
+ if (unresolved.length > 0) {
659
+ log.debug(`[task:${taskId}]`, `prompt placeholders rendered empty: ${unresolved.join(', ')}`);
660
+ }
661
+ const originalLen = expandedPrompt.length;
662
+ let doc = promptDocumentFromString(expandedPrompt);
663
+ // Prepend port-related context blocks so the model sees them
664
+ // before any middleware-added retrieval / memory blocks. Order
665
+ // matters: [Output Format] first (sets the deliverable), then
666
+ // [Inputs] (the concrete data to operate on). Empty blocks are
667
+ // filtered out — tasks without ports get no extra blocks at all.
668
+ const outputFormatBlock = renderOutputSchemaBlock(task.ports?.outputs);
669
+ if (outputFormatBlock) {
670
+ doc = prependContext(doc, outputFormatBlock);
671
+ }
672
+ const inputsBlock = renderInputsBlock(task.ports?.inputs, resolvedInputs);
673
+ if (inputsBlock) {
674
+ doc = prependContext(doc, inputsBlock);
675
+ }
545
676
  const mws = task.middlewares !== undefined ? task.middlewares : track.middlewares;
546
677
  if (mws && mws.length > 0) {
547
678
  log.debug(`[task:${taskId}]`, `middleware chain: ${mws.map((m) => m.type).join(' → ')}`);
@@ -625,6 +756,13 @@ export async function runPipeline(config, workDir, options = {}) {
625
756
  // contexts and task). Drivers that read task.prompt see the
626
757
  // default serialization and need no changes.
627
758
  promptDoc: doc,
759
+ // Ports feature: resolved input values keyed by port name,
760
+ // already coerced to the declared port type. Drivers that
761
+ // need to re-substitute placeholders inside a custom envelope
762
+ // can read this and call `substituteInputs`; most drivers can
763
+ // ignore it because the engine has already expanded
764
+ // `{{inputs.X}}` into `task.prompt` upstream.
765
+ inputs: resolvedInputs,
628
766
  };
629
767
  const spec = await driver.buildCommand(enrichedTask, track, driverCtx);
630
768
  log.debug(`[task:${taskId}]`, `driver=${driverName}`);
@@ -672,6 +810,33 @@ export async function runPipeline(config, workDir, options = {}) {
672
810
  else {
673
811
  terminalStatus = 'success';
674
812
  }
813
+ // Extract declared port outputs from the task's output stream.
814
+ // Only meaningful on success — a failed task's output is whatever
815
+ // the child happened to emit before exiting, and downstream tasks
816
+ // shouldn't receive partial data. `extractTaskOutputs` is a no-op
817
+ // when the task has no declared outputs, so this is free for
818
+ // pre-ports tasks. Diagnostics are appended to stderr so users
819
+ // see *why* a downstream input is missing without having to dig
820
+ // through driver-specific logs.
821
+ let extractedOutputs = null;
822
+ if (terminalStatus === 'success') {
823
+ const extraction = extractTaskOutputs(task.ports, result.stdout, result.normalizedOutput);
824
+ if (task.ports?.outputs && task.ports.outputs.length > 0) {
825
+ extractedOutputs = extraction.outputs;
826
+ outputValuesMap.set(taskId, extraction.outputs);
827
+ log.debug(`[task:${taskId}]`, `extracted outputs: ${JSON.stringify(extraction.outputs)}`);
828
+ if (extraction.diagnostic) {
829
+ log.error(`[task:${taskId}]`, extraction.diagnostic);
830
+ const note = `\n[engine] ${extraction.diagnostic}`;
831
+ result = { ...result, stderr: result.stderr + note };
832
+ }
833
+ }
834
+ }
835
+ // Attach outputs to the result (null when task has no declared
836
+ // outputs or extraction failed entirely). Consumers of TaskResult
837
+ // — hooks, wire events, test assertions — all go through this
838
+ // one field rather than re-running extraction.
839
+ result = { ...result, outputs: extractedOutputs };
675
840
  // Store normalized text separately (in-memory) for continue_from handoff.
676
841
  // R15: clip oversized values so a runaway parseResult can't accumulate
677
842
  // hundreds of MB across tasks.
@@ -682,11 +847,9 @@ export async function runPipeline(config, workDir, options = {}) {
682
847
  : result.normalizedOutput;
683
848
  normalizedMap.set(taskId, clipped);
684
849
  }
685
- if (result.stderr) {
686
- const stderrPath = resolve(log.dir, `${taskId.replace(/\./g, '_')}.stderr`);
687
- await Bun.write(stderrPath, result.stderr);
688
- result = { ...result, stderrPath };
689
- }
850
+ // Note: stderr is already persisted by runner.ts as it streams; the
851
+ // old "write full string after the fact" block is gone — that's what
852
+ // the streaming rewrite fixed (unbounded in-memory buffering).
690
853
  if (result.sessionId) {
691
854
  // H1: qualified-only key.
692
855
  sessionMap.set(taskId, result.sessionId);
@@ -707,11 +870,18 @@ export async function runPipeline(config, workDir, options = {}) {
707
870
  log.error(`[task:${taskId}]`, `stderr tail:\n${tail}`);
708
871
  }
709
872
  }
710
- // File-only: full stdout/stderr dump (clipped) + extracted metadata
711
- log.debug(`[task:${taskId}]`, `stdout: ${result.stdout.length} chars, stderr: ${result.stderr.length} chars`);
873
+ // File-only: byte counts (prefer full totals from the runner over the
874
+ // bounded tail length so oversized outputs show their real size) +
875
+ // paths to the on-disk full copies.
876
+ const stdoutSize = result.stdoutBytes ?? result.stdout.length;
877
+ const stderrSize = result.stderrBytes ?? result.stderr.length;
878
+ log.debug(`[task:${taskId}]`, `stdout: ${stdoutSize} bytes, stderr: ${stderrSize} bytes`);
712
879
  if (result.sessionId) {
713
880
  log.debug(`[task:${taskId}]`, `sessionId: ${result.sessionId}`);
714
881
  }
882
+ if (result.stdoutPath) {
883
+ log.debug(`[task:${taskId}]`, `wrote stdout: ${result.stdoutPath}`);
884
+ }
715
885
  if (result.stderrPath) {
716
886
  log.debug(`[task:${taskId}]`, `wrote stderr: ${result.stderrPath}`);
717
887
  }
@@ -732,7 +902,10 @@ export async function runPipeline(config, workDir, options = {}) {
732
902
  exitCode: -1,
733
903
  stdout: '',
734
904
  stderr: errMsg,
905
+ stdoutPath: null,
735
906
  stderrPath: null,
907
+ stdoutBytes: 0,
908
+ stderrBytes: errMsg.length,
736
909
  durationMs: 0,
737
910
  sessionId: null,
738
911
  normalizedOutput: null,