@tagma/sdk 0.4.13 → 0.4.15

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 (59) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +569 -572
  3. package/dist/dag.d.ts.map +1 -1
  4. package/dist/dag.js +22 -56
  5. package/dist/dag.js.map +1 -1
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +63 -37
  8. package/dist/engine.js.map +1 -1
  9. package/dist/middlewares/static-context.d.ts.map +1 -1
  10. package/dist/middlewares/static-context.js +7 -3
  11. package/dist/middlewares/static-context.js.map +1 -1
  12. package/dist/prompt-doc.d.ts +36 -0
  13. package/dist/prompt-doc.d.ts.map +1 -0
  14. package/dist/prompt-doc.js +44 -0
  15. package/dist/prompt-doc.js.map +1 -0
  16. package/dist/sdk.d.ts +3 -0
  17. package/dist/sdk.d.ts.map +1 -1
  18. package/dist/sdk.js +4 -0
  19. package/dist/sdk.js.map +1 -1
  20. package/dist/task-ref.d.ts +55 -0
  21. package/dist/task-ref.d.ts.map +1 -0
  22. package/dist/task-ref.js +101 -0
  23. package/dist/task-ref.js.map +1 -0
  24. package/dist/templates.d.ts +20 -0
  25. package/dist/templates.d.ts.map +1 -0
  26. package/dist/templates.js +93 -0
  27. package/dist/templates.js.map +1 -0
  28. package/dist/validate-raw.d.ts.map +1 -1
  29. package/dist/validate-raw.js +27 -53
  30. package/dist/validate-raw.js.map +1 -1
  31. package/package.json +2 -2
  32. package/scripts/preinstall.js +31 -31
  33. package/src/adapters/stdin-approval.ts +106 -106
  34. package/src/adapters/websocket-approval.ts +224 -224
  35. package/src/approval.ts +131 -131
  36. package/src/bootstrap.ts +37 -37
  37. package/src/completions/exit-code.ts +34 -34
  38. package/src/completions/file-exists.ts +66 -66
  39. package/src/completions/output-check.ts +86 -86
  40. package/src/config-ops.ts +307 -307
  41. package/src/dag.ts +24 -54
  42. package/src/drivers/claude-code.ts +250 -250
  43. package/src/engine.ts +1137 -1098
  44. package/src/hooks.ts +187 -187
  45. package/src/logger.ts +182 -182
  46. package/src/middlewares/static-context.ts +49 -45
  47. package/src/pipeline-runner.ts +156 -156
  48. package/src/prompt-doc.ts +49 -0
  49. package/src/registry.ts +242 -242
  50. package/src/runner.ts +395 -395
  51. package/src/schema.test.ts +101 -101
  52. package/src/schema.ts +338 -338
  53. package/src/sdk.ts +111 -92
  54. package/src/task-ref.ts +120 -0
  55. package/src/triggers/file.ts +164 -164
  56. package/src/triggers/manual.ts +86 -86
  57. package/src/types.ts +18 -18
  58. package/src/utils.ts +203 -203
  59. package/src/validate-raw.ts +412 -442
package/README.md CHANGED
@@ -1,572 +1,569 @@
1
- # @tagma/sdk
2
-
3
- > ## ⚠️ Bun-only runtime
4
- >
5
- > ```bash
6
- > bun add @tagma/sdk
7
- > ```
8
- >
9
- > This package is published as pre-built JavaScript (`dist/`), but the runtime
10
- > uses Bun-only APIs (`Bun.spawn`, `Bun.file`, `Bun.serve`). It will install
11
- > under `npm` / `yarn` / `pnpm` without error, but **crash at runtime on Node**
12
- > the first time a pipeline spawns a task. Get Bun at <https://bun.sh>.
13
- >
14
- > _(The `npm i @tagma/sdk` line in the npmjs.com sidebar is auto-generated by
15
- > the npm registry website and cannot be removed — please ignore it and use
16
- > the command above.)_
17
-
18
- A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (Claude Code, Codex, OpenCode) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
19
-
20
- ## Install
21
-
22
- **Requires Bun ≥ 1.3.** Node/npm are not supported.
23
-
24
- ```bash
25
- bun add @tagma/sdk
26
- ```
27
-
28
- ## Quick Start
29
-
30
- **1. Define a pipeline** (`pipeline.yaml`)
31
-
32
- ```yaml
33
- pipeline:
34
- name: build-and-test
35
- tracks:
36
- - id: backend
37
- name: Backend
38
- driver: claude-code
39
- permissions: { read: true, write: true, execute: false }
40
- tasks:
41
- - id: implement
42
- name: Implement feature
43
- prompt: 'Add a /health endpoint to src/server.ts'
44
- output: ./output/implement.txt
45
- - id: test
46
- name: Run tests
47
- command: 'bun test'
48
- depends_on: [implement]
49
- ```
50
-
51
- **2. Run it programmatically**
52
-
53
- ```ts
54
- import { bootstrapBuiltins, loadPipeline, runPipeline, InMemoryApprovalGateway } from '@tagma/sdk';
55
-
56
- // Register built-in drivers, triggers, completions
57
- bootstrapBuiltins();
58
-
59
- const yaml = await Bun.file('pipeline.yaml').text();
60
- const config = await loadPipeline(yaml, process.cwd());
61
-
62
- const result = await runPipeline(config, process.cwd());
63
- console.log(result.success ? 'Done' : 'Failed');
64
- ```
65
-
66
- ## Features
67
-
68
- - **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
69
- - **Driver plugins** -- built-in `claude-code` driver; install `@tagma/driver-codex` or `@tagma/driver-opencode` for other agents
70
- - **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
71
- - **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
72
- - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
73
- - **Middleware** -- enrich prompts before execution (e.g. inject static context)
74
- - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
75
- - **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
76
-
77
- ## Pipeline YAML Reference
78
-
79
- ### Full Structure
80
-
81
- ```yaml
82
- pipeline:
83
- name: my-pipeline
84
- driver: claude-code
85
- timeout: 30m
86
- plugins:
87
- - '@tagma/driver-codex'
88
- hooks:
89
- pipeline_start: 'echo starting'
90
- task_start: 'echo task begin'
91
- task_success: 'echo task ok'
92
- task_failure: 'notify-slack.sh'
93
- pipeline_complete: 'echo done'
94
- pipeline_error: 'alert.sh'
95
- tracks:
96
- - id: track-1
97
- name: Track One
98
- color: '#3b82f6'
99
- driver: claude-code
100
- model: claude-sonnet-4-6
101
- agent_profile: senior
102
- cwd: ./services/backend
103
- permissions:
104
- read: true
105
- write: true
106
- execute: false
107
- on_failure: skip_downstream
108
- middlewares:
109
- - type: static_context
110
- file: ./context.md
111
- label: Architecture Guide
112
- tasks:
113
- - id: task-a
114
- name: Do something
115
- prompt: 'Your prompt here'
116
- output: ./output/task-a.txt
117
- timeout: 10m
118
- driver: claude-code
119
- model: claude-sonnet-4-6
120
- agent_profile: senior
121
- cwd: ./src
122
- permissions:
123
- read: true
124
- write: true
125
- execute: false
126
- middlewares:
127
- - type: static_context
128
- file: ./ref.md
129
- trigger:
130
- type: manual
131
- message: 'Approve before running'
132
- timeout: 5m
133
- completion:
134
- type: exit_code
135
- expect: 0
136
- - id: task-b
137
- name: Follow up
138
- prompt: 'Continue the work'
139
- continue_from: task-a
140
- depends_on: [task-a]
141
- ```
142
-
143
- ### Pipeline Fields
144
-
145
- | Field | Type | Required | Description |
146
- | --------- | --------------- | -------- | ------------------------------------------------------------------------------------------ |
147
- | `name` | `string` | Yes | Pipeline name, used in logs and run IDs |
148
- | `driver` | `string` | No | Default driver for all tracks/tasks (inherited). Built-in: `claude-code` |
149
- | `model` | `string` | No | Default model for all tracks/tasks (inherited). Exact model name, e.g. `claude-sonnet-4-6` |
150
- | `timeout` | `string` | No | Pipeline-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
151
- | `plugins` | `string[]` | No | External plugin packages to load, e.g. `["@tagma/driver-codex"]` |
152
- | `hooks` | `HooksConfig` | No | Shell commands to run at lifecycle events (see Hooks below) |
153
- | `tracks` | `TrackConfig[]` | Yes | List of parallel execution tracks |
154
-
155
- ### Hooks Fields
156
-
157
- Each hook value can be a single command string or an array of commands.
158
-
159
- | Field | Type | Description |
160
- | ------------------- | -------------------- | -------------------------------------------- |
161
- | `pipeline_start` | `string \| string[]` | Runs when the pipeline begins |
162
- | `task_start` | `string \| string[]` | Runs before each task starts |
163
- | `task_success` | `string \| string[]` | Runs after a task succeeds |
164
- | `task_failure` | `string \| string[]` | Runs after a task fails |
165
- | `pipeline_complete` | `string \| string[]` | Runs when the pipeline finishes successfully |
166
- | `pipeline_error` | `string \| string[]` | Runs when the pipeline finishes with errors |
167
-
168
- ### Track Fields
169
-
170
- | Field | Type | Required | Default | Description |
171
- | --------------- | -------------------- | -------- | ----------------------- | -------------------------------------------------------------------- |
172
- | `id` | `string` | Yes | — | Unique track identifier |
173
- | `name` | `string` | Yes | | Display name |
174
- | `color` | `string` | No | | Color hint for UI rendering (e.g. `"#3b82f6"`) |
175
- | `driver` | `string` | No | Inherited from pipeline | Driver for all tasks in this track |
176
- | `model` | `string` | No | Inherited from pipeline | Exact model name passed to the driver CLI (e.g. `claude-sonnet-4-6`) |
177
- | `agent_profile` | `string` | No | | Named agent configuration profile |
178
- | `cwd` | `string` | No | Pipeline workDir | Working directory for tasks in this track (relative path) |
179
- | `permissions` | `Permissions` | No | Inherited from pipeline | Default permissions for tasks (see Permissions) |
180
- | `on_failure` | `OnFailure` | No | `skip_downstream` | Failure strategy: `skip_downstream`, `stop_all`, `ignore` |
181
- | `middlewares` | `MiddlewareConfig[]` | No | — | Middlewares applied to all tasks (task-level overrides track-level) |
182
- | `tasks` | `TaskConfig[]` | Yes | — | Ordered list of tasks in this track |
183
-
184
- ### Task Fields
185
-
186
- | Field | Type | Required | Default | Description |
187
- | --------------- | -------------------- | -------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
188
- | `id` | `string` | Yes | — | Unique task identifier (unique within the pipeline) |
189
- | `name` | `string` | No | — | Display name |
190
- | `prompt` | `string` | No\* | — | AI prompt to send to the driver. \*Mutually exclusive with `command` |
191
- | `command` | `string` | No\* | — | Shell command to execute directly. \*Mutually exclusive with `prompt` |
192
- | `depends_on` | `string[]` | No | — | Task IDs that must complete before this task runs. Cross-track refs use `trackId.taskId` |
193
- | `continue_from` | `string` | No | — | Task ID whose output/session to continue from (session handoff). Cross-track refs use `trackId.taskId` |
194
- | `output` | `string` | No | — | File path to write task stdout to (relative to workDir) |
195
- | `driver` | `string` | No | Inherited from track | Driver override for this task |
196
- | `model` | `string` | No | Inherited from track | Model name override for this task |
197
- | `agent_profile` | `string` | No | Inherited from track | Agent profile override |
198
- | `cwd` | `string` | No | Inherited from track | Working directory override (relative path) |
199
- | `timeout` | `string` | No | — | Task-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
200
- | `permissions` | `Permissions` | No | Inherited from track | Permission override (see Permissions) |
201
- | `middlewares` | `MiddlewareConfig[]` | No | Inherited from track | Middleware override. Set `[]` to disable inherited middlewares |
202
- | `trigger` | `TriggerConfig` | No | — | Gate that must resolve before the task runs (see Triggers) |
203
- | `completion` | `CompletionConfig` | No | — | Post-execution check to validate task output (see Completions) |
204
-
205
- ### Permissions
206
-
207
- | Field | Type | Default | Description |
208
- | --------- | --------- | ------- | ----------------------------------- |
209
- | `read` | `boolean` | — | Allow the agent to read files |
210
- | `write` | `boolean` | — | Allow the agent to write files |
211
- | `execute` | `boolean` | — | Allow the agent to execute commands |
212
-
213
- ### Inheritance
214
-
215
- Fields are inherited top-down: **pipeline → track → task**. A value set at a lower level overrides the inherited value.
216
-
217
- Inherited fields: `driver`, `model`, `permissions`, `cwd`, `middlewares`.
218
-
219
- Track-level `middlewares` apply to all tasks in the track. Setting task-level `middlewares` **replaces** (not appends) the track-level list. Use `middlewares: []` to disable all inherited middlewares for a task.
220
-
221
- ---
222
-
223
- ### Built-in Triggers
224
-
225
- #### `manual` Human approval gate
226
-
227
- | Field | Type | Required | Default | Description |
228
- | ---------- | ---------- | -------- | ------------------------------------------------------ | ------------------------------------------------- |
229
- | `type` | `"manual"` | Yes | — | Trigger type |
230
- | `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
231
- | `timeout` | `string` | No | | How long to wait for a decision before timing out |
232
- | `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
233
-
234
- #### `file` File watcher gate
235
-
236
- | Field | Type | Required | Default | Description |
237
- | --------- | -------- | -------- | ------- | ---------------------------------------------- |
238
- | `type` | `"file"` | Yes | — | Trigger type |
239
- | `path` | `string` | Yes | — | File path to watch (relative to workDir) |
240
- | `timeout` | `string` | No | — | How long to wait for the file to appear/change |
241
-
242
- ---
243
-
244
- ### Built-in Completions
245
-
246
- #### `exit_code` Exit code check
247
-
248
- | Field | Type | Required | Default | Description |
249
- | -------- | -------------------- | -------- | ------- | ------------------------------------------------------------------ |
250
- | `type` | `"exit_code"` | Yes | — | Completion type |
251
- | `expect` | `number \| number[]` | No | `0` | Expected exit code(s). Pass an array for multiple acceptable codes |
252
-
253
- #### `file_exists` File existence check
254
-
255
- | Field | Type | Required | Default | Description |
256
- | ---------- | -------------------------- | -------- | ------- | ----------------------------------------------------- |
257
- | `type` | `"file_exists"` | Yes | — | Completion type |
258
- | `path` | `string` | Yes | — | File or directory path to check (relative to workDir) |
259
- | `kind` | `"file" \| "dir" \| "any"` | No | `"any"` | Entity type constraint |
260
- | `min_size` | `number` | No | — | Minimum file size in bytes (files only) |
261
-
262
- #### `output_check` Command-based output validation
263
-
264
- | Field | Type | Required | Default | Description |
265
- | --------- | ---------------- | -------- | ------- | ----------------------------------------------------------------------- |
266
- | `type` | `"output_check"` | Yes | — | Completion type |
267
- | `check` | `string` | Yes | — | Shell command to run. Task stdout is piped to its stdin; exits 0 = pass |
268
- | `timeout` | `string` | No | `"30s"` | Max time to wait for the check command |
269
-
270
- ---
271
-
272
- ### Built-in Middlewares
273
-
274
- #### `static_context` Prepend file content to prompt
275
-
276
- | Field | Type | Required | Default | Description |
277
- | ------- | ------------------ | -------- | ------------------------- | ---------------------------------------------- |
278
- | `type` | `"static_context"` | Yes | — | Middleware type |
279
- | `file` | `string` | Yes | — | Path to the context file (relative to workDir) |
280
- | `label` | `string` | No | `"Reference: {filename}"` | Label for the injected context section |
281
-
282
- ## API
283
-
284
- ### `bootstrapBuiltins()`
285
-
286
- Registers all built-in plugins (claude-code driver, file/manual triggers, completion checks, static-context middleware).
287
-
288
- ### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
289
-
290
- Parses YAML, resolves inheritance, and validates the configuration.
291
-
292
- ### `runPipeline(config, workDir, options?): Promise<EngineResult>`
293
-
294
- Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
295
-
296
- Options:
297
-
298
- - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
299
- - `signal` -- `AbortSignal` to cancel the run externally
300
- - `onEvent` -- callback for real-time `PipelineEvent` updates:
301
- - `pipeline_start` — pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
302
- - `task_status_change` a task changed status; includes `state: TaskState` (complete snapshot at the time of change: `startedAt` is populated before the `running` event; `result` and `finishedAt` are populated before any terminal-status event)
303
- - `task_log` a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
304
- - `pipeline_end` — pipeline finished; includes `success: boolean`
305
- - `runId` -- caller-supplied run ID. When provided the engine uses this instead of generating its own, keeping the caller and the SDK log directories aligned on the same ID
306
- - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
307
-
308
- ### `PipelineRunner`
309
-
310
- Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
311
-
312
- ```ts
313
- const runner = new PipelineRunner(config, workDir);
314
-
315
- // Subscribe before start — handler is called for every PipelineEvent
316
- const unsubscribe = runner.subscribe((event) => {
317
- tauriEmit('pipeline_event', { id: runner.instanceId, event });
318
- });
319
-
320
- runner.start(); // returns Promise<EngineResult>, idempotent
321
-
322
- // Cancel from IPC
323
- runner.abort();
324
-
325
- // Available from the first pipeline_start event onward (not just after completion)
326
- // Returns null only if the pipeline has never started
327
- const states = runner.getStates(); // ReadonlyMap<taskId, TaskState> | null
328
- ```
329
-
330
- Properties:
331
-
332
- - `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
333
- - `runId` engine-assigned run ID, available after the first `pipeline_start` event (`null` until then)
334
- - `status` — `'idle' | 'running' | 'done' | 'aborted'`
335
-
336
- ### `TriggerBlockedError` / `TriggerTimeoutError`
337
-
338
- Typed error classes for trigger plugin error classification. The engine uses `instanceof` checks on these to set the correct task status (`blocked` or `timeout`) instead of matching on error message substrings.
339
-
340
- Built-in triggers (`manual`, `file`) throw these automatically. Third-party trigger plugins should throw `TriggerBlockedError` for user/policy rejections and `TriggerTimeoutError` for genuine wait timeouts. Plugins that throw plain `Error` still work — the engine falls back to string matching for backward compatibility, but typed errors are preferred to avoid misclassification from coincidental substrings.
341
-
342
- ```ts
343
- import { TriggerBlockedError, TriggerTimeoutError } from '@tagma/sdk';
344
-
345
- // In a custom trigger plugin:
346
- throw new TriggerBlockedError('Access denied by policy');
347
- throw new TriggerTimeoutError('File did not appear within 30s');
348
- ```
349
-
350
- ### `loadPlugins(names: string[]): Promise<void>`
351
-
352
- Dynamically loads and registers external plugin packages.
353
-
354
- ### `registerPlugin(category, type, handler): void`
355
-
356
- Registers a plugin handler manually. Idempotent — duplicate registrations are silently ignored.
357
-
358
- Plugin handlers (`TriggerPlugin`, `CompletionPlugin`, `MiddlewarePlugin`) may optionally expose a declarative `schema: PluginSchema` field so visual editors can render a typed form for the plugin's config instead of a raw key/value editor:
359
-
360
- ```ts
361
- import type { TriggerPlugin } from '@tagma/types';
362
-
363
- export const HttpTrigger: TriggerPlugin = {
364
- name: 'http',
365
- schema: {
366
- description: 'Wait for an HTTP endpoint to return 2xx before the task runs.',
367
- fields: {
368
- url: { type: 'string', required: true, placeholder: 'https://...' },
369
- method: { type: 'enum', enum: ['GET', 'POST'], default: 'GET' },
370
- timeout: { type: 'duration', description: 'Give up after this long.' },
371
- },
372
- },
373
- async watch(config, ctx) {
374
- /* ... */
375
- },
376
- };
377
- ```
378
-
379
- The schema is purely descriptive — plugins still perform their own runtime validation. Supported field types: `string`, `number`, `boolean`, `enum`, `path`, `duration`, `number-or-list`. Each field can declare `required`, `default`, `description`, `enum`, `min`/`max`, `placeholder`. Built-in plugins (`file`/`manual` triggers; `exit_code`/`file_exists`/`output_check` completions; `static_context` middleware) all ship with schemas so editors can generate forms out of the box.
380
-
381
- ### `getHandler(category, type): PluginType`
382
-
383
- Retrieves a registered plugin handler. Throws if the plugin is not registered.
384
-
385
- ### `hasHandler(category, type): boolean`
386
-
387
- Returns `true` if a handler is registered for the given category and type.
388
-
389
- ### `listRegistered(category): string[]`
390
-
391
- Lists all registered handler type names for a plugin category (`'drivers'`, `'triggers'`, `'completions'`, `'middlewares'`).
392
-
393
- ### `resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig`
394
-
395
- Resolves a raw pipeline config into a fully resolved `PipelineConfig` — applies inheritance (pipeline → track → task) for driver, model, permissions, and cwd. Validates and resolves all file paths against `workDir`.
396
-
397
- Use `loadPipeline` for the common parse-and-resolve flow. Use `resolveConfig` directly when you need to manipulate the raw config between parsing and resolution.
398
-
399
- ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
400
-
401
- Attaches an interactive stdin-based approval handler.
402
-
403
- ### `attachWebSocketApprovalAdapter(gateway, options?): WebSocketApprovalAdapter`
404
-
405
- Starts a WebSocket server for remote approval decisions.
406
-
407
- ### Config CRUD (`config-ops`)
408
-
409
- Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
410
-
411
- ```ts
412
- import {
413
- createEmptyPipeline,
414
- setPipelineField,
415
- upsertTrack,
416
- removeTrack,
417
- moveTrack,
418
- updateTrack,
419
- upsertTask,
420
- removeTask,
421
- moveTask,
422
- transferTask,
423
- serializePipeline,
424
- } from '@tagma/sdk';
425
-
426
- // Build a config programmatically
427
- let config = createEmptyPipeline('my-pipeline');
428
- config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
429
- config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
430
-
431
- // Sync back to YAML
432
- const yaml = serializePipeline(config);
433
- ```
434
-
435
- | Function | Description |
436
- | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
437
- | `createEmptyPipeline(name)` | Create a minimal pipeline config |
438
- | `setPipelineField(config, fields)` | Update top-level pipeline fields |
439
- | `upsertTrack(config, track)` | Insert or replace a track by id |
440
- | `removeTrack(config, trackId)` | Remove a track |
441
- | `moveTrack(config, trackId, toIndex)` | Reorder a track |
442
- | `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
443
- | `upsertTask(config, trackId, task)` | Insert or replace a task |
444
- | `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references. Only refs that resolve to the deleted task are removed — same-named tasks in other tracks are unaffected |
445
- | `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
446
- | `transferTask(config, fromTrackId, taskId, toTrackId, qualifyRefs?)` | Move a task across tracks. When `qualifyRefs` is `true` (default), bare `depends_on` / `continue_from` references to the moved task are converted to fully-qualified form (`toTrackId.taskId`) so same-track resolution stays correct |
447
-
448
- ### `parseYaml(content: string): RawPipelineConfig`
449
-
450
- Parses a YAML string and returns the raw (unresolved) pipeline config. Use this when you need to edit and re-save YAML without losing relative paths or user-authored formatting — pass the result to `serializePipeline()` rather than going through `loadPipeline()`.
451
-
452
- ### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
453
-
454
- Converts a resolved `PipelineConfig` back to a `RawPipelineConfig` suitable for serialization. Strips injected defaults and converts absolute `cwd` paths back to relative so the output YAML is portable across machines.
455
-
456
- Use this when you have a programmatically modified resolved config and need to save it back to YAML:
457
-
458
- ```ts
459
- // Correct: load modify resolved config → deresolve → save
460
- const config = await loadPipeline(yaml, workDir);
461
- const modified = { ...config, name: 'renamed' };
462
- const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
463
-
464
- // Also correct: work entirely in raw space (preferred for visual editors)
465
- const raw = parseYaml(yaml);
466
- const updatedRaw = setPipelineField(raw, { name: 'renamed' });
467
- const savedYaml = serializePipeline(updatedRaw);
468
- ```
469
-
470
- ### `validateConfig(config: PipelineConfig): string[]`
471
-
472
- Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
473
-
474
- Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
475
-
476
- ### `validateRaw(config: RawPipelineConfig): ValidationError[]`
477
-
478
- Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
479
-
480
- 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.
481
-
482
- Does **not** check plugin registration (plugins may not be loaded at edit time).
483
-
484
- ```ts
485
- const errors = validateRaw(draftConfig);
486
- if (errors.length > 0) {
487
- errors.forEach((e) => highlightNode(e.path, e.message));
488
- }
489
- ```
490
-
491
- ### `buildRawDag(config: RawPipelineConfig): RawDag`
492
-
493
- Extracts the topology of a raw (unresolved) pipeline config as a graph — no `workDir` or plugin registration required. Intended for the visual editor to render the flow graph during editing.
494
-
495
- Returns `{ nodes: ReadonlyMap<taskId, RawDagNode>, edges: { from, to }[] }` where each edge represents a dependency (from must complete before to). Unresolvable refs are silently skipped.
496
-
497
- ```ts
498
- const { nodes, edges } = buildRawDag(draftConfig);
499
- // nodes — keyed by "trackId.taskId"
500
- // edges [{ from: "track.taskA", to: "track.taskB" }, ...]
501
- ```
502
-
503
- Use `buildDag` instead when you have a fully resolved `PipelineConfig` and need topological sort order.
504
-
505
- ### `Logger`
506
-
507
- Dual-channel logger console + file. Creates a per-run log file at `<workDir>/.tagma/logs/<runId>/pipeline.log`.
508
-
509
- ```ts
510
- const logger = new Logger(workDir, runId);
511
- logger.info('[track]', 'message'); // console + file
512
- logger.warn('[track]', 'message'); // console + file
513
- logger.error('[track]', 'message'); // console + file
514
- logger.debug('[track]', 'message'); // file only
515
- logger.section('Title'); // file only — visual separator
516
- logger.quiet(bulkText); // file only bulk payload
517
- logger.path; // log file path
518
- logger.dir; // run artifact directory
519
- logger.close(); // close the persistent file handle (called automatically by runPipeline at run completion)
520
- ```
521
-
522
- Pass an optional third argument to stream every appended line out as a
523
- structured `LogRecord` `runPipeline` uses this to emit `task_log` events:
524
-
525
- ```ts
526
- import { Logger, type LogRecord } from '@tagma/sdk';
527
-
528
- const logger = new Logger(workDir, runId, (record: LogRecord) => {
529
- // record = { level, taskId, timestamp, text }
530
- // level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
531
- // taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
532
- forwardToUI(record);
533
- });
534
- ```
535
-
536
- `section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
537
- line logically belongs to a task — the extractor cannot infer one otherwise:
538
-
539
- ```ts
540
- logger.section(`Task ${taskId}`, taskId);
541
- logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
542
- ```
543
-
544
- ### `tailLines(text: string, n: number): string`
545
-
546
- Returns the last `n` non-empty lines of `text`, joined with newlines.
547
-
548
- ### `clip(text: string, maxBytes?: number): string`
549
-
550
- Truncates `text` to at most `maxBytes` UTF-8 bytes (default 16 KB), appending a `…[truncated N bytes]` marker when truncation occurs. Multi-byte characters (CJK, emoji) are counted correctly.
551
-
552
- ### Utilities
553
-
554
- | Function | Description |
555
- | ------------------------------------- | --------------------------------------------------------- |
556
- | `parseDuration(input)` | Parses `"30s"`, `"5m"`, `"2h"` → milliseconds |
557
- | `validatePath(filePath, projectRoot)` | Resolves path, throws if it escapes project root |
558
- | `generateRunId()` | Generates a unique run ID (`run_<ts>_<seq>_<rand>`) |
559
- | `nowISO()` | Returns `new Date().toISOString()` |
560
- | `truncateForName(text, maxLen?)` | Truncates first line to `maxLen` (default 40) for display |
561
-
562
- ## Related Packages
563
-
564
- | Package | Description |
565
- | ------------------------------------------------------------------------------ | -------------------------- |
566
- | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
567
- | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
568
- | [@tagma/driver-opencode](https://www.npmjs.com/package/@tagma/driver-opencode) | OpenCode CLI driver plugin |
569
-
570
- ## License
571
-
572
- MIT
1
+ # @tagma/sdk
2
+
3
+ > ## ⚠️ Bun-only runtime
4
+ >
5
+ > ```bash
6
+ > bun add @tagma/sdk
7
+ > ```
8
+ >
9
+ > This package is published as pre-built JavaScript (`dist/`), but the runtime
10
+ > uses Bun-only APIs (`Bun.spawn`, `Bun.file`, `Bun.serve`). It will install
11
+ > under `npm` / `yarn` / `pnpm` without error, but **crash at runtime on Node**
12
+ > the first time a pipeline spawns a task. Get Bun at <https://bun.sh>.
13
+ >
14
+ > _(The `npm i @tagma/sdk` line in the npmjs.com sidebar is auto-generated by
15
+ > the npm registry website and cannot be removed — please ignore it and use
16
+ > the command above.)_
17
+
18
+ A local AI task orchestration SDK for [Bun](https://bun.sh). Define multi-track pipelines in YAML, run AI coding agents (Claude Code, Codex, OpenCode) and shell commands in parallel with dependency resolution, approval gates, and lifecycle hooks.
19
+
20
+ ## Install
21
+
22
+ **Requires Bun ≥ 1.3.** Node/npm are not supported.
23
+
24
+ ```bash
25
+ bun add @tagma/sdk
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ **1. Define a pipeline** (`pipeline.yaml`)
31
+
32
+ ```yaml
33
+ pipeline:
34
+ name: build-and-test
35
+ tracks:
36
+ - id: backend
37
+ name: Backend
38
+ driver: claude-code
39
+ permissions: { read: true, write: true, execute: false }
40
+ tasks:
41
+ - id: implement
42
+ name: Implement feature
43
+ prompt: 'Add a /health endpoint to src/server.ts'
44
+ - id: test
45
+ name: Run tests
46
+ command: 'bun test'
47
+ depends_on: [implement]
48
+ ```
49
+
50
+ **2. Run it programmatically**
51
+
52
+ ```ts
53
+ import { bootstrapBuiltins, loadPipeline, runPipeline, InMemoryApprovalGateway } from '@tagma/sdk';
54
+
55
+ // Register built-in drivers, triggers, completions
56
+ bootstrapBuiltins();
57
+
58
+ const yaml = await Bun.file('pipeline.yaml').text();
59
+ const config = await loadPipeline(yaml, process.cwd());
60
+
61
+ const result = await runPipeline(config, process.cwd());
62
+ console.log(result.success ? 'Done' : 'Failed');
63
+ ```
64
+
65
+ ## Features
66
+
67
+ - **Multi-track DAG execution** -- tasks run in parallel across tracks, respecting `depends_on` ordering
68
+ - **Driver plugins** -- built-in `claude-code` driver; install `@tagma/driver-codex` or `@tagma/driver-opencode` for other agents
69
+ - **Session handoff** -- `continue_from` passes context between tasks (session resume or text injection)
70
+ - **Approval gates** -- trigger-based approval with stdin and WebSocket adapters
71
+ - **Lifecycle hooks** -- `pipeline_start`, `task_start`, `task_success`, `task_failure`, `pipeline_complete`, `pipeline_error`
72
+ - **Middleware** -- enrich prompts before execution (e.g. inject static context)
73
+ - **Completion checks** -- validate task output with `exit_code`, `file_exists`, or `output_check` plugins
74
+ - **Plugin schemas** -- triggers/completions/middlewares can declare a `PluginSchema` so visual editors render typed forms for their config
75
+
76
+ ## Pipeline YAML Reference
77
+
78
+ ### Full Structure
79
+
80
+ ```yaml
81
+ pipeline:
82
+ name: my-pipeline
83
+ driver: claude-code
84
+ timeout: 30m
85
+ plugins:
86
+ - '@tagma/driver-codex'
87
+ hooks:
88
+ pipeline_start: 'echo starting'
89
+ task_start: 'echo task begin'
90
+ task_success: 'echo task ok'
91
+ task_failure: 'notify-slack.sh'
92
+ pipeline_complete: 'echo done'
93
+ pipeline_error: 'alert.sh'
94
+ tracks:
95
+ - id: track-1
96
+ name: Track One
97
+ color: '#3b82f6'
98
+ driver: claude-code
99
+ model: claude-sonnet-4-6
100
+ agent_profile: senior
101
+ cwd: ./services/backend
102
+ permissions:
103
+ read: true
104
+ write: true
105
+ execute: false
106
+ on_failure: skip_downstream
107
+ middlewares:
108
+ - type: static_context
109
+ file: ./context.md
110
+ label: Architecture Guide
111
+ tasks:
112
+ - id: task-a
113
+ name: Do something
114
+ prompt: 'Your prompt here'
115
+ timeout: 10m
116
+ driver: claude-code
117
+ model: claude-sonnet-4-6
118
+ agent_profile: senior
119
+ cwd: ./src
120
+ permissions:
121
+ read: true
122
+ write: true
123
+ execute: false
124
+ middlewares:
125
+ - type: static_context
126
+ file: ./ref.md
127
+ trigger:
128
+ type: manual
129
+ message: 'Approve before running'
130
+ timeout: 5m
131
+ completion:
132
+ type: exit_code
133
+ expect: 0
134
+ - id: task-b
135
+ name: Follow up
136
+ prompt: 'Continue the work'
137
+ continue_from: task-a
138
+ depends_on: [task-a]
139
+ ```
140
+
141
+ ### Pipeline Fields
142
+
143
+ | Field | Type | Required | Description |
144
+ | --------- | --------------- | -------- | ------------------------------------------------------------------------------------------ |
145
+ | `name` | `string` | Yes | Pipeline name, used in logs and run IDs |
146
+ | `driver` | `string` | No | Default driver for all tracks/tasks (inherited). Built-in: `claude-code` |
147
+ | `model` | `string` | No | Default model for all tracks/tasks (inherited). Exact model name, e.g. `claude-sonnet-4-6` |
148
+ | `timeout` | `string` | No | Pipeline-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
149
+ | `plugins` | `string[]` | No | External plugin packages to load, e.g. `["@tagma/driver-codex"]` |
150
+ | `hooks` | `HooksConfig` | No | Shell commands to run at lifecycle events (see Hooks below) |
151
+ | `tracks` | `TrackConfig[]` | Yes | List of parallel execution tracks |
152
+
153
+ ### Hooks Fields
154
+
155
+ Each hook value can be a single command string or an array of commands.
156
+
157
+ | Field | Type | Description |
158
+ | ------------------- | -------------------- | -------------------------------------------- |
159
+ | `pipeline_start` | `string \| string[]` | Runs when the pipeline begins |
160
+ | `task_start` | `string \| string[]` | Runs before each task starts |
161
+ | `task_success` | `string \| string[]` | Runs after a task succeeds |
162
+ | `task_failure` | `string \| string[]` | Runs after a task fails |
163
+ | `pipeline_complete` | `string \| string[]` | Runs when the pipeline finishes successfully |
164
+ | `pipeline_error` | `string \| string[]` | Runs when the pipeline finishes with errors |
165
+
166
+ ### Track Fields
167
+
168
+ | Field | Type | Required | Default | Description |
169
+ | --------------- | -------------------- | -------- | ----------------------- | -------------------------------------------------------------------- |
170
+ | `id` | `string` | Yes | | Unique track identifier |
171
+ | `name` | `string` | Yes | | Display name |
172
+ | `color` | `string` | No | — | Color hint for UI rendering (e.g. `"#3b82f6"`) |
173
+ | `driver` | `string` | No | Inherited from pipeline | Driver for all tasks in this track |
174
+ | `model` | `string` | No | Inherited from pipeline | Exact model name passed to the driver CLI (e.g. `claude-sonnet-4-6`) |
175
+ | `agent_profile` | `string` | No | | Named agent configuration profile |
176
+ | `cwd` | `string` | No | Pipeline workDir | Working directory for tasks in this track (relative path) |
177
+ | `permissions` | `Permissions` | No | Inherited from pipeline | Default permissions for tasks (see Permissions) |
178
+ | `on_failure` | `OnFailure` | No | `skip_downstream` | Failure strategy: `skip_downstream`, `stop_all`, `ignore` |
179
+ | `middlewares` | `MiddlewareConfig[]` | No | — | Middlewares applied to all tasks (task-level overrides track-level) |
180
+ | `tasks` | `TaskConfig[]` | Yes | | Ordered list of tasks in this track |
181
+
182
+ ### Task Fields
183
+
184
+ | Field | Type | Required | Default | Description |
185
+ | --------------- | -------------------- | -------- | -------------------- | ------------------------------------------------------------------------------------------------------ |
186
+ | `id` | `string` | Yes | | Unique task identifier (unique within the pipeline) |
187
+ | `name` | `string` | No | | Display name |
188
+ | `prompt` | `string` | No\* | — | AI prompt to send to the driver. \*Mutually exclusive with `command` |
189
+ | `command` | `string` | No\* | — | Shell command to execute directly. \*Mutually exclusive with `prompt` |
190
+ | `depends_on` | `string[]` | No | — | Task IDs that must complete before this task runs. Cross-track refs use `trackId.taskId` |
191
+ | `continue_from` | `string` | No | — | Task ID whose output/session to continue from (session handoff). Cross-track refs use `trackId.taskId` |
192
+ | `driver` | `string` | No | Inherited from track | Driver override for this task |
193
+ | `model` | `string` | No | Inherited from track | Model name override for this task |
194
+ | `agent_profile` | `string` | No | Inherited from track | Agent profile override |
195
+ | `cwd` | `string` | No | Inherited from track | Working directory override (relative path) |
196
+ | `timeout` | `string` | No | | Task-level timeout. Format: `"30s"`, `"5m"`, `"2h"` |
197
+ | `permissions` | `Permissions` | No | Inherited from track | Permission override (see Permissions) |
198
+ | `middlewares` | `MiddlewareConfig[]` | No | Inherited from track | Middleware override. Set `[]` to disable inherited middlewares |
199
+ | `trigger` | `TriggerConfig` | No | — | Gate that must resolve before the task runs (see Triggers) |
200
+ | `completion` | `CompletionConfig` | No | — | Post-execution check to validate task output (see Completions) |
201
+
202
+ ### Permissions
203
+
204
+ | Field | Type | Default | Description |
205
+ | --------- | --------- | ------- | ----------------------------------- |
206
+ | `read` | `boolean` | — | Allow the agent to read files |
207
+ | `write` | `boolean` | | Allow the agent to write files |
208
+ | `execute` | `boolean` | | Allow the agent to execute commands |
209
+
210
+ ### Inheritance
211
+
212
+ Fields are inherited top-down: **pipeline → track → task**. A value set at a lower level overrides the inherited value.
213
+
214
+ Inherited fields: `driver`, `model`, `permissions`, `cwd`, `middlewares`.
215
+
216
+ Track-level `middlewares` apply to all tasks in the track. Setting task-level `middlewares` **replaces** (not appends) the track-level list. Use `middlewares: []` to disable all inherited middlewares for a task.
217
+
218
+ ---
219
+
220
+ ### Built-in Triggers
221
+
222
+ #### `manual` — Human approval gate
223
+
224
+ | Field | Type | Required | Default | Description |
225
+ | ---------- | ---------- | -------- | ------------------------------------------------------ | ------------------------------------------------- |
226
+ | `type` | `"manual"` | Yes | — | Trigger type |
227
+ | `message` | `string` | No | `"Manual confirmation required for task \"{taskId}\""` | Message shown to the approver |
228
+ | `timeout` | `string` | No | | How long to wait for a decision before timing out |
229
+ | `metadata` | `object` | No | — | Arbitrary metadata passed to the approval gateway |
230
+
231
+ #### `file` — File watcher gate
232
+
233
+ | Field | Type | Required | Default | Description |
234
+ | --------- | -------- | -------- | ------- | ---------------------------------------------- |
235
+ | `type` | `"file"` | Yes | — | Trigger type |
236
+ | `path` | `string` | Yes | | File path to watch (relative to workDir) |
237
+ | `timeout` | `string` | No | | How long to wait for the file to appear/change |
238
+
239
+ ---
240
+
241
+ ### Built-in Completions
242
+
243
+ #### `exit_code` — Exit code check
244
+
245
+ | Field | Type | Required | Default | Description |
246
+ | -------- | -------------------- | -------- | ------- | ------------------------------------------------------------------ |
247
+ | `type` | `"exit_code"` | Yes | — | Completion type |
248
+ | `expect` | `number \| number[]` | No | `0` | Expected exit code(s). Pass an array for multiple acceptable codes |
249
+
250
+ #### `file_exists` File existence check
251
+
252
+ | Field | Type | Required | Default | Description |
253
+ | ---------- | -------------------------- | -------- | ------- | ----------------------------------------------------- |
254
+ | `type` | `"file_exists"` | Yes | — | Completion type |
255
+ | `path` | `string` | Yes | | File or directory path to check (relative to workDir) |
256
+ | `kind` | `"file" \| "dir" \| "any"` | No | `"any"` | Entity type constraint |
257
+ | `min_size` | `number` | No | — | Minimum file size in bytes (files only) |
258
+
259
+ #### `output_check` Command-based output validation
260
+
261
+ | Field | Type | Required | Default | Description |
262
+ | --------- | ---------------- | -------- | ------- | ----------------------------------------------------------------------- |
263
+ | `type` | `"output_check"` | Yes | — | Completion type |
264
+ | `check` | `string` | Yes | | Shell command to run. Task stdout is piped to its stdin; exits 0 = pass |
265
+ | `timeout` | `string` | No | `"30s"` | Max time to wait for the check command |
266
+
267
+ ---
268
+
269
+ ### Built-in Middlewares
270
+
271
+ #### `static_context` — Prepend file content to prompt
272
+
273
+ | Field | Type | Required | Default | Description |
274
+ | ------- | ------------------ | -------- | ------------------------- | ---------------------------------------------- |
275
+ | `type` | `"static_context"` | Yes | — | Middleware type |
276
+ | `file` | `string` | Yes | | Path to the context file (relative to workDir) |
277
+ | `label` | `string` | No | `"Reference: {filename}"` | Label for the injected context section |
278
+
279
+ ## API
280
+
281
+ ### `bootstrapBuiltins()`
282
+
283
+ Registers all built-in plugins (claude-code driver, file/manual triggers, completion checks, static-context middleware).
284
+
285
+ ### `loadPipeline(yaml: string, workDir: string): Promise<PipelineConfig>`
286
+
287
+ Parses YAML, resolves inheritance, and validates the configuration.
288
+
289
+ ### `runPipeline(config, workDir, options?): Promise<EngineResult>`
290
+
291
+ Executes the pipeline. Returns `{ success, runId, logPath, summary, states }`.
292
+
293
+ Options:
294
+
295
+ - `approvalGateway` -- custom `ApprovalGateway` instance (defaults to `InMemoryApprovalGateway`)
296
+ - `signal` -- `AbortSignal` to cancel the run externally
297
+ - `onEvent` -- callback for real-time `PipelineEvent` updates:
298
+ - `pipeline_start` pipeline began; includes `states: ReadonlyMap<taskId, TaskState>` (initial snapshot of all tasks at `waiting`)
299
+ - `task_status_change` a task changed status; includes `state: TaskState` (complete snapshot at the time of change: `startedAt` is populated before the `running` event; `result` and `finishedAt` are populated before any terminal-status event)
300
+ - `task_log` a structured log line was written to `pipeline.log`. Mirrors every `Logger` call (info/warn/error/debug/section/quiet) and carries `{ taskId: string | null, level, timestamp, text }`. `taskId` is non-null for lines tagged with a `[task:<id>]` prefix (or passed explicitly to `section`/`quiet`) and `null` for pipeline-wide messages such as the configuration dump and DAG topology. Use this to stream the full run process into UIs without tailing the log file.
301
+ - `pipeline_end` — pipeline finished; includes `success: boolean`
302
+ - `runId` -- caller-supplied run ID. When provided the engine uses this instead of generating its own, keeping the caller and the SDK log directories aligned on the same ID
303
+ - `maxLogRuns` -- number of per-run log directories to keep under `<workDir>/.tagma/logs/` (default: 20)
304
+
305
+ ### `PipelineRunner`
306
+
307
+ Higher-level wrapper for managing multiple concurrent pipeline runs — designed for sidecar / Tauri IPC scenarios where the frontend controls pipeline lifecycle by ID.
308
+
309
+ ```ts
310
+ const runner = new PipelineRunner(config, workDir);
311
+
312
+ // Subscribe before start — handler is called for every PipelineEvent
313
+ const unsubscribe = runner.subscribe((event) => {
314
+ tauriEmit('pipeline_event', { id: runner.instanceId, event });
315
+ });
316
+
317
+ runner.start(); // returns Promise<EngineResult>, idempotent
318
+
319
+ // Cancel from IPC
320
+ runner.abort();
321
+
322
+ // Available from the first pipeline_start event onward (not just after completion)
323
+ // Returns null only if the pipeline has never started
324
+ const states = runner.getStates(); // ReadonlyMap<taskId, TaskState> | null
325
+ ```
326
+
327
+ Properties:
328
+
329
+ - `instanceId` — stable ID assigned at construction, safe to use as a Map key before `start()`
330
+ - `runId` — engine-assigned run ID, available after the first `pipeline_start` event (`null` until then)
331
+ - `status` — `'idle' | 'running' | 'done' | 'aborted'`
332
+
333
+ ### `TriggerBlockedError` / `TriggerTimeoutError`
334
+
335
+ Typed error classes for trigger plugin error classification. The engine uses `instanceof` checks on these to set the correct task status (`blocked` or `timeout`) instead of matching on error message substrings.
336
+
337
+ Built-in triggers (`manual`, `file`) throw these automatically. Third-party trigger plugins should throw `TriggerBlockedError` for user/policy rejections and `TriggerTimeoutError` for genuine wait timeouts. Plugins that throw plain `Error` still work — the engine falls back to string matching for backward compatibility, but typed errors are preferred to avoid misclassification from coincidental substrings.
338
+
339
+ ```ts
340
+ import { TriggerBlockedError, TriggerTimeoutError } from '@tagma/sdk';
341
+
342
+ // In a custom trigger plugin:
343
+ throw new TriggerBlockedError('Access denied by policy');
344
+ throw new TriggerTimeoutError('File did not appear within 30s');
345
+ ```
346
+
347
+ ### `loadPlugins(names: string[]): Promise<void>`
348
+
349
+ Dynamically loads and registers external plugin packages.
350
+
351
+ ### `registerPlugin(category, type, handler): void`
352
+
353
+ Registers a plugin handler manually. Idempotent — duplicate registrations are silently ignored.
354
+
355
+ Plugin handlers (`TriggerPlugin`, `CompletionPlugin`, `MiddlewarePlugin`) may optionally expose a declarative `schema: PluginSchema` field so visual editors can render a typed form for the plugin's config instead of a raw key/value editor:
356
+
357
+ ```ts
358
+ import type { TriggerPlugin } from '@tagma/types';
359
+
360
+ export const HttpTrigger: TriggerPlugin = {
361
+ name: 'http',
362
+ schema: {
363
+ description: 'Wait for an HTTP endpoint to return 2xx before the task runs.',
364
+ fields: {
365
+ url: { type: 'string', required: true, placeholder: 'https://...' },
366
+ method: { type: 'enum', enum: ['GET', 'POST'], default: 'GET' },
367
+ timeout: { type: 'duration', description: 'Give up after this long.' },
368
+ },
369
+ },
370
+ async watch(config, ctx) {
371
+ /* ... */
372
+ },
373
+ };
374
+ ```
375
+
376
+ The schema is purely descriptive — plugins still perform their own runtime validation. Supported field types: `string`, `number`, `boolean`, `enum`, `path`, `duration`, `number-or-list`. Each field can declare `required`, `default`, `description`, `enum`, `min`/`max`, `placeholder`. Built-in plugins (`file`/`manual` triggers; `exit_code`/`file_exists`/`output_check` completions; `static_context` middleware) all ship with schemas so editors can generate forms out of the box.
377
+
378
+ ### `getHandler(category, type): PluginType`
379
+
380
+ Retrieves a registered plugin handler. Throws if the plugin is not registered.
381
+
382
+ ### `hasHandler(category, type): boolean`
383
+
384
+ Returns `true` if a handler is registered for the given category and type.
385
+
386
+ ### `listRegistered(category): string[]`
387
+
388
+ Lists all registered handler type names for a plugin category (`'drivers'`, `'triggers'`, `'completions'`, `'middlewares'`).
389
+
390
+ ### `resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig`
391
+
392
+ Resolves a raw pipeline config into a fully resolved `PipelineConfig` — applies inheritance (pipeline → track → task) for driver, model, permissions, and cwd. Validates and resolves all file paths against `workDir`.
393
+
394
+ Use `loadPipeline` for the common parse-and-resolve flow. Use `resolveConfig` directly when you need to manipulate the raw config between parsing and resolution.
395
+
396
+ ### `attachStdinApprovalAdapter(gateway): StdinApprovalAdapter`
397
+
398
+ Attaches an interactive stdin-based approval handler.
399
+
400
+ ### `attachWebSocketApprovalAdapter(gateway, options?): WebSocketApprovalAdapter`
401
+
402
+ Starts a WebSocket server for remote approval decisions.
403
+
404
+ ### Config CRUD (`config-ops`)
405
+
406
+ Pure, immutable helper functions for building and editing `RawPipelineConfig` in a visual editor. No runtime dependencies — safe to use in renderer processes.
407
+
408
+ ```ts
409
+ import {
410
+ createEmptyPipeline,
411
+ setPipelineField,
412
+ upsertTrack,
413
+ removeTrack,
414
+ moveTrack,
415
+ updateTrack,
416
+ upsertTask,
417
+ removeTask,
418
+ moveTask,
419
+ transferTask,
420
+ serializePipeline,
421
+ } from '@tagma/sdk';
422
+
423
+ // Build a config programmatically
424
+ let config = createEmptyPipeline('my-pipeline');
425
+ config = upsertTrack(config, { id: 'backend', name: 'Backend', tasks: [] });
426
+ config = upsertTask(config, 'backend', { id: 'implement', prompt: 'Add /health endpoint' });
427
+
428
+ // Sync back to YAML
429
+ const yaml = serializePipeline(config);
430
+ ```
431
+
432
+ | Function | Description |
433
+ | -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
434
+ | `createEmptyPipeline(name)` | Create a minimal pipeline config |
435
+ | `setPipelineField(config, fields)` | Update top-level pipeline fields |
436
+ | `upsertTrack(config, track)` | Insert or replace a track by id |
437
+ | `removeTrack(config, trackId)` | Remove a track |
438
+ | `moveTrack(config, trackId, toIndex)` | Reorder a track |
439
+ | `updateTrack(config, trackId, fields)` | Patch track fields (not tasks) |
440
+ | `upsertTask(config, trackId, task)` | Insert or replace a task |
441
+ | `removeTask(config, trackId, taskId, cleanRefs?)` | Remove a task; pass `cleanRefs: true` to also strip dangling `depends_on` / `continue_from` references. Only refs that resolve to the deleted task are removed — same-named tasks in other tracks are unaffected |
442
+ | `moveTask(config, trackId, taskId, toIndex)` | Reorder a task within its track |
443
+ | `transferTask(config, fromTrackId, taskId, toTrackId, qualifyRefs?)` | Move a task across tracks. When `qualifyRefs` is `true` (default), bare `depends_on` / `continue_from` references to the moved task are converted to fully-qualified form (`toTrackId.taskId`) so same-track resolution stays correct |
444
+
445
+ ### `parseYaml(content: string): RawPipelineConfig`
446
+
447
+ Parses a YAML string and returns the raw (unresolved) pipeline config. Use this when you need to edit and re-save YAML without losing relative paths or user-authored formatting — pass the result to `serializePipeline()` rather than going through `loadPipeline()`.
448
+
449
+ ### `deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig`
450
+
451
+ Converts a resolved `PipelineConfig` back to a `RawPipelineConfig` suitable for serialization. Strips injected defaults and converts absolute `cwd` paths back to relative so the output YAML is portable across machines.
452
+
453
+ Use this when you have a programmatically modified resolved config and need to save it back to YAML:
454
+
455
+ ```ts
456
+ // Correct: load modify resolved config deresolve save
457
+ const config = await loadPipeline(yaml, workDir);
458
+ const modified = { ...config, name: 'renamed' };
459
+ const savedYaml = serializePipeline(deresolvePipeline(modified, workDir));
460
+
461
+ // Also correct: work entirely in raw space (preferred for visual editors)
462
+ const raw = parseYaml(yaml);
463
+ const updatedRaw = setPipelineField(raw, { name: 'renamed' });
464
+ const savedYaml = serializePipeline(updatedRaw);
465
+ ```
466
+
467
+ ### `validateConfig(config: PipelineConfig): string[]`
468
+
469
+ Validates a resolved pipeline config without executing it. Checks DAG structure (cycles, missing dependencies). Returns an array of error message strings — empty means valid.
470
+
471
+ Use `validateRaw` for editing raw configs in a UI; use `validateConfig` after `resolveConfig` for a final pre-run check.
472
+
473
+ ### `validateRaw(config: RawPipelineConfig): ValidationError[]`
474
+
475
+ Validates a raw pipeline config without resolving inheritance or executing anything. Returns a flat list of `{ path, message }` objects — empty array means valid.
476
+
477
+ 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.
478
+
479
+ Does **not** check plugin registration (plugins may not be loaded at edit time).
480
+
481
+ ```ts
482
+ const errors = validateRaw(draftConfig);
483
+ if (errors.length > 0) {
484
+ errors.forEach((e) => highlightNode(e.path, e.message));
485
+ }
486
+ ```
487
+
488
+ ### `buildRawDag(config: RawPipelineConfig): RawDag`
489
+
490
+ Extracts the topology of a raw (unresolved) pipeline config as a graph — no `workDir` or plugin registration required. Intended for the visual editor to render the flow graph during editing.
491
+
492
+ Returns `{ nodes: ReadonlyMap<taskId, RawDagNode>, edges: { from, to }[] }` where each edge represents a dependency (from must complete before to). Unresolvable refs are silently skipped.
493
+
494
+ ```ts
495
+ const { nodes, edges } = buildRawDag(draftConfig);
496
+ // nodes — keyed by "trackId.taskId"
497
+ // edges — [{ from: "track.taskA", to: "track.taskB" }, ...]
498
+ ```
499
+
500
+ Use `buildDag` instead when you have a fully resolved `PipelineConfig` and need topological sort order.
501
+
502
+ ### `Logger`
503
+
504
+ Dual-channel logger — console + file. Creates a per-run log file at `<workDir>/.tagma/logs/<runId>/pipeline.log`.
505
+
506
+ ```ts
507
+ const logger = new Logger(workDir, runId);
508
+ logger.info('[track]', 'message'); // console + file
509
+ logger.warn('[track]', 'message'); // console + file
510
+ logger.error('[track]', 'message'); // console + file
511
+ logger.debug('[track]', 'message'); // file only
512
+ logger.section('Title'); // file only — visual separator
513
+ logger.quiet(bulkText); // file only — bulk payload
514
+ logger.path; // log file path
515
+ logger.dir; // run artifact directory
516
+ logger.close(); // close the persistent file handle (called automatically by runPipeline at run completion)
517
+ ```
518
+
519
+ Pass an optional third argument to stream every appended line out as a
520
+ structured `LogRecord` — `runPipeline` uses this to emit `task_log` events:
521
+
522
+ ```ts
523
+ import { Logger, type LogRecord } from '@tagma/sdk';
524
+
525
+ const logger = new Logger(workDir, runId, (record: LogRecord) => {
526
+ // record = { level, taskId, timestamp, text }
527
+ // level = 'info' | 'warn' | 'error' | 'debug' | 'section' | 'quiet'
528
+ // taskId is extracted from a '[task:<id>]' prefix, or null for untagged lines
529
+ forwardToUI(record);
530
+ });
531
+ ```
532
+
533
+ `section` and `quiet` carry no prefix, so pass an explicit `taskId` when the
534
+ line logically belongs to a task — the extractor cannot infer one otherwise:
535
+
536
+ ```ts
537
+ logger.section(`Task ${taskId}`, taskId);
538
+ logger.quiet(`--- stdout (${taskId}) ---\n${body}\n--- end stdout ---`, taskId);
539
+ ```
540
+
541
+ ### `tailLines(text: string, n: number): string`
542
+
543
+ Returns the last `n` non-empty lines of `text`, joined with newlines.
544
+
545
+ ### `clip(text: string, maxBytes?: number): string`
546
+
547
+ Truncates `text` to at most `maxBytes` UTF-8 bytes (default 16 KB), appending a `…[truncated N bytes]` marker when truncation occurs. Multi-byte characters (CJK, emoji) are counted correctly.
548
+
549
+ ### Utilities
550
+
551
+ | Function | Description |
552
+ | ------------------------------------- | --------------------------------------------------------- |
553
+ | `parseDuration(input)` | Parses `"30s"`, `"5m"`, `"2h"` → milliseconds |
554
+ | `validatePath(filePath, projectRoot)` | Resolves path, throws if it escapes project root |
555
+ | `generateRunId()` | Generates a unique run ID (`run_<ts>_<seq>_<rand>`) |
556
+ | `nowISO()` | Returns `new Date().toISOString()` |
557
+ | `truncateForName(text, maxLen?)` | Truncates first line to `maxLen` (default 40) for display |
558
+
559
+ ## Related Packages
560
+
561
+ | Package | Description |
562
+ | ------------------------------------------------------------------------------ | -------------------------- |
563
+ | [@tagma/types](https://www.npmjs.com/package/@tagma/types) | Shared TypeScript types |
564
+ | [@tagma/driver-codex](https://www.npmjs.com/package/@tagma/driver-codex) | Codex CLI driver plugin |
565
+ | [@tagma/driver-opencode](https://www.npmjs.com/package/@tagma/driver-opencode) | OpenCode CLI driver plugin |
566
+
567
+ ## License
568
+
569
+ MIT