@tagma/sdk 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/schema.ts CHANGED
@@ -1,358 +1,386 @@
1
- import yaml from 'js-yaml';
2
- import { resolve, relative } from 'path';
3
- import type {
4
- PipelineConfig, RawPipelineConfig, RawTrackConfig, RawTaskConfig,
5
- TrackConfig, TaskConfig, Permissions, MiddlewareConfig,
6
- TemplateConfig, TemplateParamDef,
7
- } from './types';
8
- import { truncateForName, validatePathParam } from './utils';
9
- import { DEFAULT_PERMISSIONS } from './types';
10
- import { buildDag } from './dag';
11
-
12
- // ═══ YAML Parsing ═══
13
-
14
- export function parseYaml(content: string): RawPipelineConfig {
15
- const doc = yaml.load(content) as { pipeline?: RawPipelineConfig };
16
- if (!doc?.pipeline) {
17
- throw new Error('YAML must contain a top-level "pipeline" key');
18
- }
19
- const p = doc.pipeline;
20
- if (!p.name) throw new Error('pipeline.name is required');
21
- if (!p.tracks || p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
22
-
23
- for (const track of p.tracks) {
24
- validateRawTrack(track);
25
- }
26
- return p;
27
- }
28
-
29
- function validateRawTrack(track: RawTrackConfig): void {
30
- if (!track.id) throw new Error('track.id is required');
31
- if (!track.name) throw new Error(`track "${track.id}": name is required`);
32
- if (!track.tasks || track.tasks.length === 0) {
33
- throw new Error(`track "${track.id}": tasks must be non-empty`);
34
- }
35
- for (const task of track.tasks) {
36
- validateRawTask(task, track.id);
37
- }
38
- }
39
-
40
- function validateRawTask(task: RawTaskConfig, trackId: string): void {
41
- if (!task.id) throw new Error(`track "${trackId}": task.id is required`);
42
- if (task.use) return; // template usage, validated later
43
-
44
- const hasPrompt = typeof task.prompt === 'string' && task.prompt.length > 0;
45
- const hasCommand = typeof task.command === 'string' && task.command.length > 0;
46
- if (!hasPrompt && !hasCommand) {
47
- throw new Error(`task "${task.id}": must have either "prompt" or "command"`);
48
- }
49
- if (hasPrompt && hasCommand) {
50
- throw new Error(`task "${task.id}": cannot have both "prompt" and "command"`);
51
- }
52
- }
53
-
54
- // ═══ Template Expansion ═══
55
-
56
- export async function expandTemplates(
57
- tasks: readonly RawTaskConfig[],
58
- instancePrefix: string,
59
- ): Promise<RawTaskConfig[]> {
60
- const result: RawTaskConfig[] = [];
61
-
62
- for (const task of tasks) {
63
- if (!task.use) {
64
- result.push(task);
65
- continue;
66
- }
67
-
68
- const template = await loadTemplate(task.use);
69
- const params = resolveTemplateParams(template, task.with ?? {}, task.id);
70
- const expanded = expandTemplateTask(template, params, task.id, instancePrefix);
71
- result.push(...expanded);
72
- }
73
-
74
- return result;
75
- }
76
-
77
- async function loadTemplate(ref: string): Promise<TemplateConfig> {
78
- // Strip version suffix for import
79
- const moduleName = ref.replace(/@v\d+$/, '');
80
- try {
81
- const mod = await import(moduleName);
82
- // Expect the module to export a template.yaml content or parsed object
83
- if (mod.template) return mod.template as TemplateConfig;
84
-
85
- // Try loading template.yaml from the package
86
- const pkgPath = require.resolve(`${moduleName}/template.yaml`);
87
- const content = await Bun.file(pkgPath).text();
88
- const doc = yaml.load(content) as { template: TemplateConfig };
89
- return doc.template;
90
- } catch {
91
- throw new Error(`Failed to load template: "${ref}". Is the package installed?`);
92
- }
93
- }
94
-
95
- function resolveTemplateParams(
96
- template: TemplateConfig,
97
- provided: Record<string, unknown>,
98
- instanceId: string,
99
- ): Record<string, unknown> {
100
- const params: Record<string, unknown> = {};
101
- const defs = template.params ?? {};
102
-
103
- for (const [key, def] of Object.entries(defs)) {
104
- const value = provided[key] ?? def.default;
105
- if (value === undefined) {
106
- throw new Error(`Template "${template.name}" instance "${instanceId}": missing required param "${key}"`);
107
- }
108
- validateParamType(key, value, def, template.name, instanceId);
109
- params[key] = value;
110
- }
111
-
112
- // Warn about unknown params
113
- for (const key of Object.keys(provided)) {
114
- if (!(key in defs)) {
115
- console.warn(`Template "${template.name}" instance "${instanceId}": unknown param "${key}"`);
116
- }
117
- }
118
-
119
- return params;
120
- }
121
-
122
- function validateParamType(
123
- key: string, value: unknown, def: TemplateParamDef,
124
- templateName: string, instanceId: string,
125
- ): void {
126
- const ctx = `Template "${templateName}" instance "${instanceId}" param "${key}"`;
127
- const ptype = def.type ?? 'string';
128
-
129
- switch (ptype) {
130
- case 'string':
131
- if (typeof value !== 'string') throw new Error(`${ctx}: expected string, got ${typeof value}`);
132
- break;
133
- case 'path':
134
- if (typeof value !== 'string') throw new Error(`${ctx}: expected path string, got ${typeof value}`);
135
- validatePathParam(value);
136
- break;
137
- case 'enum':
138
- if (!def.enum?.includes(value as string)) {
139
- throw new Error(`${ctx}: value "${value}" not in allowed values [${def.enum?.join(', ')}]`);
140
- }
141
- break;
142
- case 'number':
143
- if (typeof value !== 'number') throw new Error(`${ctx}: expected number, got ${typeof value}`);
144
- if (def.min !== undefined && value < def.min) throw new Error(`${ctx}: ${value} < min ${def.min}`);
145
- if (def.max !== undefined && value > def.max) throw new Error(`${ctx}: ${value} > max ${def.max}`);
146
- break;
147
- }
148
- }
149
-
150
- function expandTemplateTask(
151
- template: TemplateConfig,
152
- params: Record<string, unknown>,
153
- instanceId: string,
154
- instancePrefix: string,
155
- ): RawTaskConfig[] {
156
- return template.tasks.map(task => {
157
- const prefixedId = `${instanceId}.${task.id}`;
158
-
159
- // Replace ${{ params.xxx }} in string fields
160
- const interpolate = (s: string): string =>
161
- s.replace(/\$\{\{\s*params\.(\w+)\s*\}\}/g, (_, key) => String(params[key] ?? ''));
162
-
163
- const newTask: Record<string, unknown> = { ...task, id: prefixedId };
164
-
165
- // Interpolate string fields
166
- if (task.prompt) newTask.prompt = interpolate(task.prompt);
167
- if (task.command) newTask.command = interpolate(task.command);
168
-
169
- // Namespace depends_on
170
- if (task.depends_on) {
171
- newTask.depends_on = task.depends_on.map(dep => `${instanceId}.${dep}`);
172
- }
173
-
174
- // Namespace continue_from
175
- if (task.continue_from) {
176
- newTask.continue_from = `${instanceId}.${task.continue_from}`;
177
- }
178
-
179
- // Rewrite output path to instance namespace
180
- if (task.output) {
181
- const original = interpolate(task.output);
182
- newTask.output = original.replace('./tmp/', `./tmp/${instanceId}/`);
183
- }
184
-
185
- return newTask as unknown as RawTaskConfig;
186
- });
187
- }
188
-
189
- // ═══ Config Inheritance Resolution ═══
190
-
191
- export function resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig {
192
- const tracks: TrackConfig[] = raw.tracks.map(rawTrack => {
193
- const trackDriver = rawTrack.driver ?? raw.driver;
194
- const trackCwd = rawTrack.cwd ? resolve(workDir, rawTrack.cwd) : workDir;
195
-
196
- const tasks: TaskConfig[] = rawTrack.tasks.map(rawTask => {
197
- const name = rawTask.name
198
- ?? (rawTask.prompt ? truncateForName(rawTask.prompt) : rawTask.command ?? rawTask.id);
199
-
200
- return {
201
- id: rawTask.id,
202
- name,
203
- prompt: rawTask.prompt,
204
- command: rawTask.command,
205
- depends_on: rawTask.depends_on,
206
- trigger: rawTask.trigger,
207
- continue_from: rawTask.continue_from,
208
- output: rawTask.output,
209
- // Inheritance: Task > Track
210
- model_tier: rawTask.model_tier ?? rawTrack.model_tier ?? 'medium',
211
- permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
212
- driver: rawTask.driver ?? trackDriver ?? 'claude-code',
213
- timeout: rawTask.timeout,
214
- // Middleware: Task-level overrides Track (including [] to disable)
215
- middlewares: rawTask.middlewares !== undefined ? rawTask.middlewares : rawTrack.middlewares,
216
- completion: rawTask.completion,
217
- agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
218
- cwd: rawTask.cwd ? resolve(workDir, rawTask.cwd) : trackCwd,
219
- };
220
- });
221
-
222
- return {
223
- id: rawTrack.id,
224
- name: rawTrack.name,
225
- color: rawTrack.color,
226
- agent_profile: rawTrack.agent_profile,
227
- model_tier: rawTrack.model_tier ?? 'medium',
228
- permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
229
- driver: trackDriver ?? 'claude-code',
230
- cwd: trackCwd,
231
- middlewares: rawTrack.middlewares,
232
- on_failure: rawTrack.on_failure ?? 'skip_downstream',
233
- tasks,
234
- };
235
- });
236
-
237
- return {
238
- name: raw.name,
239
- driver: raw.driver,
240
- timeout: raw.timeout,
241
- plugins: raw.plugins,
242
- hooks: raw.hooks,
243
- tracks,
244
- };
245
- }
246
-
247
- // ═══ YAML Serialization ═══
248
-
249
- /**
250
- * Serialize a pipeline config back to YAML string.
251
- * Wraps the config under the top-level `pipeline` key as expected by parseYaml.
252
- */
253
- export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
254
- return yaml.dump({ pipeline: config }, { lineWidth: 120, indent: 2 });
255
- }
256
-
257
- /**
258
- * Convert a resolved PipelineConfig back to a RawPipelineConfig for serialization.
259
- * Strips injected defaults and converts absolute cwd paths back to relative so the
260
- * resulting YAML is portable across machines.
261
- *
262
- * Use this when you need to save a config that was previously loaded via
263
- * loadPipeline(). For a pure load→edit→save cycle on raw YAML, prefer
264
- * parseYaml() → edit RawPipelineConfig → serializePipeline().
265
- */
266
- export function deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig {
267
- const tracks: RawTrackConfig[] = config.tracks.map(track => {
268
- const trackCwdRel = track.cwd && track.cwd !== workDir
269
- ? relative(workDir, track.cwd)
270
- : undefined;
271
- const effectiveTrackDriver = track.driver ?? config.driver ?? 'claude-code';
272
-
273
- const tasks: RawTaskConfig[] = track.tasks.map(task => {
274
- const taskCwdRel = task.cwd && task.cwd !== track.cwd
275
- ? relative(workDir, task.cwd)
276
- : undefined;
277
-
278
- return {
279
- id: task.id,
280
- ...(task.name ? { name: task.name } : {}),
281
- ...(task.prompt !== undefined ? { prompt: task.prompt } : {}),
282
- ...(task.command !== undefined ? { command: task.command } : {}),
283
- ...(task.depends_on?.length ? { depends_on: task.depends_on } : {}),
284
- ...(task.trigger ? { trigger: task.trigger } : {}),
285
- ...(task.continue_from ? { continue_from: task.continue_from } : {}),
286
- ...(task.output ? { output: task.output } : {}),
287
- ...(taskCwdRel ? { cwd: taskCwdRel } : {}),
288
- ...(task.model_tier && task.model_tier !== 'medium' ? { model_tier: task.model_tier } : {}),
289
- ...(task.driver && task.driver !== effectiveTrackDriver ? { driver: task.driver } : {}),
290
- ...(task.timeout ? { timeout: task.timeout } : {}),
291
- ...(task.middlewares !== undefined ? { middlewares: task.middlewares } : {}),
292
- ...(task.completion ? { completion: task.completion } : {}),
293
- ...(task.agent_profile ? { agent_profile: task.agent_profile } : {}),
294
- ...(task.permissions && JSON.stringify(task.permissions) !== JSON.stringify(track.permissions)
295
- ? { permissions: task.permissions }
296
- : {}),
297
- };
298
- });
299
-
300
- return {
301
- id: track.id,
302
- name: track.name,
303
- ...(track.color ? { color: track.color } : {}),
304
- ...(track.agent_profile ? { agent_profile: track.agent_profile } : {}),
305
- ...(track.model_tier && track.model_tier !== 'medium' ? { model_tier: track.model_tier } : {}),
306
- ...(track.driver && track.driver !== (config.driver ?? 'claude-code') ? { driver: track.driver } : {}),
307
- ...(trackCwdRel ? { cwd: trackCwdRel } : {}),
308
- ...(track.middlewares?.length ? { middlewares: track.middlewares } : {}),
309
- ...(track.on_failure && track.on_failure !== 'skip_downstream' ? { on_failure: track.on_failure } : {}),
310
- ...(track.permissions && JSON.stringify(track.permissions) !== JSON.stringify(DEFAULT_PERMISSIONS)
311
- ? { permissions: track.permissions }
312
- : {}),
313
- tasks,
314
- };
315
- });
316
-
317
- return {
318
- name: config.name,
319
- ...(config.driver ? { driver: config.driver } : {}),
320
- ...(config.timeout ? { timeout: config.timeout } : {}),
321
- ...(config.plugins?.length ? { plugins: config.plugins } : {}),
322
- ...(config.hooks ? { hooks: config.hooks } : {}),
323
- tracks,
324
- };
325
- }
326
-
327
- // ═══ Offline Validation ═══
328
-
329
- /**
330
- * Validate a pipeline config without executing it.
331
- * Only checks structural/DAG correctness does not check plugin registration.
332
- * Returns an array of error messages (empty = valid).
333
- */
334
- export function validateConfig(config: PipelineConfig): string[] {
335
- const errors: string[] = [];
336
- try {
337
- buildDag(config);
338
- } catch (err) {
339
- errors.push(err instanceof Error ? err.message : String(err));
340
- }
341
- return errors;
342
- }
343
-
344
- // ═══ Full Parse Pipeline ═══
345
-
346
- export async function loadPipeline(yamlContent: string, workDir: string): Promise<PipelineConfig> {
347
- const raw = parseYaml(yamlContent);
348
-
349
- // Expand templates in each track
350
- const expandedTracks: RawTrackConfig[] = [];
351
- for (const track of raw.tracks) {
352
- const expandedTasks = await expandTemplates(track.tasks, track.id);
353
- expandedTracks.push({ ...track, tasks: expandedTasks });
354
- }
355
-
356
- const expandedRaw: RawPipelineConfig = { ...raw, tracks: expandedTracks };
357
- return resolveConfig(expandedRaw, workDir);
358
- }
1
+ import yaml from 'js-yaml';
2
+ import { resolve, relative } from 'path';
3
+ import type {
4
+ PipelineConfig, RawPipelineConfig, RawTrackConfig, RawTaskConfig,
5
+ TrackConfig, TaskConfig, Permissions, MiddlewareConfig,
6
+ TemplateConfig, TemplateParamDef,
7
+ } from './types';
8
+ import { truncateForName, validatePathParam, validatePath } from './utils';
9
+ import { DEFAULT_PERMISSIONS } from './types';
10
+ import { buildDag } from './dag';
11
+
12
+ // ═══ YAML Parsing ═══
13
+
14
+ export function parseYaml(content: string): RawPipelineConfig {
15
+ const doc = yaml.load(content) as { pipeline?: RawPipelineConfig };
16
+ if (!doc?.pipeline) {
17
+ throw new Error('YAML must contain a top-level "pipeline" key');
18
+ }
19
+ const p = doc.pipeline;
20
+ if (!p.name) throw new Error('pipeline.name is required');
21
+ if (!p.tracks || p.tracks.length === 0) throw new Error('pipeline.tracks must be non-empty');
22
+
23
+ for (const track of p.tracks) {
24
+ validateRawTrack(track);
25
+ }
26
+ return p;
27
+ }
28
+
29
+ function validateRawTrack(track: RawTrackConfig): void {
30
+ if (!track.id) throw new Error('track.id is required');
31
+ if (!track.name) throw new Error(`track "${track.id}": name is required`);
32
+ if (!track.tasks || track.tasks.length === 0) {
33
+ throw new Error(`track "${track.id}": tasks must be non-empty`);
34
+ }
35
+ for (const task of track.tasks) {
36
+ validateRawTask(task, track.id);
37
+ }
38
+ }
39
+
40
+ function validateRawTask(task: RawTaskConfig, trackId: string): void {
41
+ if (!task.id) throw new Error(`track "${trackId}": task.id is required`);
42
+ if (task.use) return; // template usage, validated later
43
+
44
+ const hasPrompt = typeof task.prompt === 'string' && task.prompt.length > 0;
45
+ const hasCommand = typeof task.command === 'string' && task.command.length > 0;
46
+ if (!hasPrompt && !hasCommand) {
47
+ throw new Error(`task "${task.id}": must have either "prompt" or "command"`);
48
+ }
49
+ if (hasPrompt && hasCommand) {
50
+ throw new Error(`task "${task.id}": cannot have both "prompt" and "command"`);
51
+ }
52
+ }
53
+
54
+ // ═══ Template Expansion ═══
55
+
56
+ export async function expandTemplates(
57
+ tasks: readonly RawTaskConfig[],
58
+ instancePrefix: string,
59
+ ): Promise<RawTaskConfig[]> {
60
+ const result: RawTaskConfig[] = [];
61
+
62
+ for (const task of tasks) {
63
+ if (!task.use) {
64
+ result.push(task);
65
+ continue;
66
+ }
67
+
68
+ const template = await loadTemplate(task.use);
69
+ const params = resolveTemplateParams(template, task.with ?? {}, task.id);
70
+ const expanded = expandTemplateTask(template, params, task.id, instancePrefix);
71
+ result.push(...expanded);
72
+ }
73
+
74
+ return result;
75
+ }
76
+
77
+ function validateTemplateRef(ref: string): void {
78
+ const stripped = ref.replace(/@v\d+$/, '');
79
+ // Reject path traversal and absolute paths before they reach import().
80
+ if (stripped.includes('..') || stripped.startsWith('/') || /^[a-zA-Z]:/.test(stripped)) {
81
+ throw new Error(
82
+ `Invalid template ref "${ref}": path traversal and absolute paths are not allowed. ` +
83
+ `Use a scoped package name, e.g. "@tagma/template-review".`
84
+ );
85
+ }
86
+ // Whitelist: only @tagma/template-* packages are allowed.
87
+ if (!stripped.startsWith('@tagma/template-')) {
88
+ throw new Error(
89
+ `Invalid template ref "${ref}": only "@tagma/template-*" packages are allowed as templates. ` +
90
+ `Example: "@tagma/template-review".`
91
+ );
92
+ }
93
+ }
94
+
95
+ async function loadTemplate(ref: string): Promise<TemplateConfig> {
96
+ validateTemplateRef(ref);
97
+ // Strip version suffix for import
98
+ const moduleName = ref.replace(/@v\d+$/, '');
99
+ try {
100
+ const mod = await import(moduleName);
101
+ // Expect the module to export a template.yaml content or parsed object
102
+ if (mod.template) return mod.template as TemplateConfig;
103
+
104
+ // Try loading template.yaml from the package
105
+ const pkgPath = require.resolve(`${moduleName}/template.yaml`);
106
+ const content = await Bun.file(pkgPath).text();
107
+ const doc = yaml.load(content) as { template: TemplateConfig };
108
+ return doc.template;
109
+ } catch (err) {
110
+ if (err instanceof Error && err.message.startsWith('Invalid template ref')) throw err;
111
+ throw new Error(`Failed to load template: "${ref}". Is the package installed?`);
112
+ }
113
+ }
114
+
115
+ function resolveTemplateParams(
116
+ template: TemplateConfig,
117
+ provided: Record<string, unknown>,
118
+ instanceId: string,
119
+ ): Record<string, unknown> {
120
+ const params: Record<string, unknown> = {};
121
+ const defs = template.params ?? {};
122
+
123
+ for (const [key, def] of Object.entries(defs)) {
124
+ const value = provided[key] ?? def.default;
125
+ if (value === undefined) {
126
+ throw new Error(`Template "${template.name}" instance "${instanceId}": missing required param "${key}"`);
127
+ }
128
+ validateParamType(key, value, def, template.name, instanceId);
129
+ params[key] = value;
130
+ }
131
+
132
+ // Warn about unknown params
133
+ for (const key of Object.keys(provided)) {
134
+ if (!(key in defs)) {
135
+ console.warn(`Template "${template.name}" instance "${instanceId}": unknown param "${key}"`);
136
+ }
137
+ }
138
+
139
+ return params;
140
+ }
141
+
142
+ function validateParamType(
143
+ key: string, value: unknown, def: TemplateParamDef,
144
+ templateName: string, instanceId: string,
145
+ ): void {
146
+ const ctx = `Template "${templateName}" instance "${instanceId}" param "${key}"`;
147
+ const ptype = def.type ?? 'string';
148
+
149
+ switch (ptype) {
150
+ case 'string':
151
+ if (typeof value !== 'string') throw new Error(`${ctx}: expected string, got ${typeof value}`);
152
+ break;
153
+ case 'path':
154
+ if (typeof value !== 'string') throw new Error(`${ctx}: expected path string, got ${typeof value}`);
155
+ validatePathParam(value);
156
+ break;
157
+ case 'enum':
158
+ if (!def.enum?.includes(value as string)) {
159
+ throw new Error(`${ctx}: value "${value}" not in allowed values [${def.enum?.join(', ')}]`);
160
+ }
161
+ break;
162
+ case 'number':
163
+ if (typeof value !== 'number') throw new Error(`${ctx}: expected number, got ${typeof value}`);
164
+ if (def.min !== undefined && value < def.min) throw new Error(`${ctx}: ${value} < min ${def.min}`);
165
+ if (def.max !== undefined && value > def.max) throw new Error(`${ctx}: ${value} > max ${def.max}`);
166
+ break;
167
+ }
168
+ }
169
+
170
+ function expandTemplateTask(
171
+ template: TemplateConfig,
172
+ params: Record<string, unknown>,
173
+ instanceId: string,
174
+ instancePrefix: string,
175
+ ): RawTaskConfig[] {
176
+ return template.tasks.map(task => {
177
+ const prefixedId = `${instanceId}.${task.id}`;
178
+
179
+ // Replace ${{ params.xxx }} in string fields
180
+ const interpolate = (s: string): string =>
181
+ s.replace(/\$\{\{\s*params\.(\w+)\s*\}\}/g, (_, key) => String(params[key] ?? ''));
182
+
183
+ const newTask: Record<string, unknown> = { ...task, id: prefixedId };
184
+
185
+ // Interpolate string fields
186
+ if (task.prompt) newTask.prompt = interpolate(task.prompt);
187
+ if (task.command) newTask.command = interpolate(task.command);
188
+
189
+ // Namespace depends_on
190
+ if (task.depends_on) {
191
+ newTask.depends_on = task.depends_on.map(dep => `${instanceId}.${dep}`);
192
+ }
193
+
194
+ // Namespace continue_from
195
+ if (task.continue_from) {
196
+ newTask.continue_from = `${instanceId}.${task.continue_from}`;
197
+ }
198
+
199
+ // Rewrite output path to instance namespace
200
+ if (task.output) {
201
+ const original = interpolate(task.output);
202
+ newTask.output = original.replace('./tmp/', `./tmp/${instanceId}/`);
203
+ }
204
+
205
+ return newTask as unknown as RawTaskConfig;
206
+ });
207
+ }
208
+
209
+ // ═══ Config Inheritance Resolution ═══
210
+
211
+ export function resolveConfig(raw: RawPipelineConfig, workDir: string): PipelineConfig {
212
+ const tracks: TrackConfig[] = raw.tracks.map(rawTrack => {
213
+ const trackDriver = rawTrack.driver ?? raw.driver;
214
+ // validatePath enforces no .. traversal and no absolute paths escaping workDir.
215
+ const trackCwd = rawTrack.cwd ? validatePath(rawTrack.cwd, workDir) : workDir;
216
+
217
+ const tasks: TaskConfig[] = rawTrack.tasks.map(rawTask => {
218
+ const name = rawTask.name
219
+ ?? (rawTask.prompt ? truncateForName(rawTask.prompt) : rawTask.command ?? rawTask.id);
220
+
221
+ return {
222
+ id: rawTask.id,
223
+ name,
224
+ prompt: rawTask.prompt,
225
+ command: rawTask.command,
226
+ depends_on: rawTask.depends_on,
227
+ trigger: rawTask.trigger,
228
+ continue_from: rawTask.continue_from,
229
+ output: rawTask.output,
230
+ // Inheritance: Task > Track
231
+ model_tier: rawTask.model_tier ?? rawTrack.model_tier ?? 'medium',
232
+ permissions: rawTask.permissions ?? rawTrack.permissions ?? DEFAULT_PERMISSIONS,
233
+ driver: rawTask.driver ?? trackDriver ?? 'claude-code',
234
+ timeout: rawTask.timeout,
235
+ // Middleware: Task-level overrides Track (including [] to disable)
236
+ middlewares: rawTask.middlewares !== undefined ? rawTask.middlewares : rawTrack.middlewares,
237
+ completion: rawTask.completion,
238
+ agent_profile: rawTask.agent_profile ?? rawTrack.agent_profile,
239
+ cwd: rawTask.cwd ? validatePath(rawTask.cwd, workDir) : trackCwd,
240
+ };
241
+ });
242
+
243
+ return {
244
+ id: rawTrack.id,
245
+ name: rawTrack.name,
246
+ color: rawTrack.color,
247
+ agent_profile: rawTrack.agent_profile,
248
+ model_tier: rawTrack.model_tier ?? 'medium',
249
+ permissions: rawTrack.permissions ?? DEFAULT_PERMISSIONS,
250
+ driver: trackDriver ?? 'claude-code',
251
+ cwd: trackCwd,
252
+ middlewares: rawTrack.middlewares,
253
+ on_failure: rawTrack.on_failure ?? 'skip_downstream',
254
+ tasks,
255
+ };
256
+ });
257
+
258
+ return {
259
+ name: raw.name,
260
+ driver: raw.driver,
261
+ timeout: raw.timeout,
262
+ plugins: raw.plugins,
263
+ hooks: raw.hooks,
264
+ tracks,
265
+ };
266
+ }
267
+
268
+ // Field-by-field permissions comparison avoids relying on JSON.stringify key order.
269
+ function permissionsEqual(a: Permissions | undefined, b: Permissions | undefined): boolean {
270
+ if (a === b) return true;
271
+ if (!a || !b) return false;
272
+ return a.read === b.read && a.write === b.write && a.execute === b.execute;
273
+ }
274
+
275
+ // ═══ YAML Serialization ═══
276
+
277
+ /**
278
+ * Serialize a pipeline config back to YAML string.
279
+ * Wraps the config under the top-level `pipeline` key as expected by parseYaml.
280
+ */
281
+ export function serializePipeline(config: PipelineConfig | RawPipelineConfig): string {
282
+ return yaml.dump({ pipeline: config }, { lineWidth: 120, indent: 2 });
283
+ }
284
+
285
+ /**
286
+ * Convert a resolved PipelineConfig back to a RawPipelineConfig for serialization.
287
+ * Strips injected defaults and converts absolute cwd paths back to relative so the
288
+ * resulting YAML is portable across machines.
289
+ *
290
+ * Use this when you need to save a config that was previously loaded via
291
+ * loadPipeline(). For a pure load→edit→save cycle on raw YAML, prefer
292
+ * parseYaml() edit RawPipelineConfig serializePipeline().
293
+ */
294
+ export function deresolvePipeline(config: PipelineConfig, workDir: string): RawPipelineConfig {
295
+ const tracks: RawTrackConfig[] = config.tracks.map(track => {
296
+ const trackCwdRel = track.cwd && track.cwd !== workDir
297
+ ? relative(workDir, track.cwd)
298
+ : undefined;
299
+ const effectiveTrackDriver = track.driver ?? config.driver ?? 'claude-code';
300
+
301
+ const tasks: RawTaskConfig[] = track.tasks.map(task => {
302
+ const taskCwdRel = task.cwd && task.cwd !== track.cwd
303
+ ? relative(workDir, task.cwd)
304
+ : undefined;
305
+
306
+ return {
307
+ id: task.id,
308
+ ...(task.name ? { name: task.name } : {}),
309
+ ...(task.prompt !== undefined ? { prompt: task.prompt } : {}),
310
+ ...(task.command !== undefined ? { command: task.command } : {}),
311
+ ...(task.depends_on?.length ? { depends_on: task.depends_on } : {}),
312
+ ...(task.trigger ? { trigger: task.trigger } : {}),
313
+ ...(task.continue_from ? { continue_from: task.continue_from } : {}),
314
+ ...(task.output ? { output: task.output } : {}),
315
+ ...(taskCwdRel ? { cwd: taskCwdRel } : {}),
316
+ ...(task.model_tier && task.model_tier !== 'medium' ? { model_tier: task.model_tier } : {}),
317
+ ...(task.driver && task.driver !== effectiveTrackDriver ? { driver: task.driver } : {}),
318
+ ...(task.timeout ? { timeout: task.timeout } : {}),
319
+ ...(task.middlewares !== undefined ? { middlewares: task.middlewares } : {}),
320
+ ...(task.completion ? { completion: task.completion } : {}),
321
+ ...(task.agent_profile ? { agent_profile: task.agent_profile } : {}),
322
+ ...(task.permissions && !permissionsEqual(task.permissions, track.permissions)
323
+ ? { permissions: task.permissions }
324
+ : {}),
325
+ };
326
+ });
327
+
328
+ return {
329
+ id: track.id,
330
+ name: track.name,
331
+ ...(track.color ? { color: track.color } : {}),
332
+ ...(track.agent_profile ? { agent_profile: track.agent_profile } : {}),
333
+ ...(track.model_tier && track.model_tier !== 'medium' ? { model_tier: track.model_tier } : {}),
334
+ ...(track.driver && track.driver !== (config.driver ?? 'claude-code') ? { driver: track.driver } : {}),
335
+ ...(trackCwdRel ? { cwd: trackCwdRel } : {}),
336
+ ...(track.middlewares?.length ? { middlewares: track.middlewares } : {}),
337
+ ...(track.on_failure && track.on_failure !== 'skip_downstream' ? { on_failure: track.on_failure } : {}),
338
+ ...(track.permissions && !permissionsEqual(track.permissions, DEFAULT_PERMISSIONS)
339
+ ? { permissions: track.permissions }
340
+ : {}),
341
+ tasks,
342
+ };
343
+ });
344
+
345
+ return {
346
+ name: config.name,
347
+ ...(config.driver ? { driver: config.driver } : {}),
348
+ ...(config.timeout ? { timeout: config.timeout } : {}),
349
+ ...(config.plugins?.length ? { plugins: config.plugins } : {}),
350
+ ...(config.hooks ? { hooks: config.hooks } : {}),
351
+ tracks,
352
+ };
353
+ }
354
+
355
+ // ═══ Offline Validation ═══
356
+
357
+ /**
358
+ * Validate a pipeline config without executing it.
359
+ * Only checks structural/DAG correctness — does not check plugin registration.
360
+ * Returns an array of error messages (empty = valid).
361
+ */
362
+ export function validateConfig(config: PipelineConfig): string[] {
363
+ const errors: string[] = [];
364
+ try {
365
+ buildDag(config);
366
+ } catch (err) {
367
+ errors.push(err instanceof Error ? err.message : String(err));
368
+ }
369
+ return errors;
370
+ }
371
+
372
+ // ═══ Full Parse Pipeline ═══
373
+
374
+ export async function loadPipeline(yamlContent: string, workDir: string): Promise<PipelineConfig> {
375
+ const raw = parseYaml(yamlContent);
376
+
377
+ // Expand templates in each track
378
+ const expandedTracks: RawTrackConfig[] = [];
379
+ for (const track of raw.tracks) {
380
+ const expandedTasks = await expandTemplates(track.tasks, track.id);
381
+ expandedTracks.push({ ...track, tasks: expandedTasks });
382
+ }
383
+
384
+ const expandedRaw: RawPipelineConfig = { ...raw, tracks: expandedTracks };
385
+ return resolveConfig(expandedRaw, workDir);
386
+ }