@tagma/sdk 0.4.8 → 0.4.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/registry.ts CHANGED
@@ -1,214 +1,214 @@
1
- import type {
2
- PluginCategory, DriverPlugin, TriggerPlugin,
3
- CompletionPlugin, MiddlewarePlugin, PluginManifest,
4
- } from './types';
5
-
6
- type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
7
-
8
- const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
9
- 'drivers', 'triggers', 'completions', 'middlewares',
10
- ]);
11
-
12
- const registries = {
13
- drivers: new Map<string, DriverPlugin>(),
14
- triggers: new Map<string, TriggerPlugin>(),
15
- completions: new Map<string, CompletionPlugin>(),
16
- middlewares: new Map<string, MiddlewarePlugin>(),
17
- };
18
-
19
- /**
20
- * Minimal contract enforcement so a malformed plugin fails fast at
21
- * registration time rather than crashing the engine mid-run.
22
- *
23
- * For drivers we materialize `capabilities` and assert each field is a
24
- * boolean — otherwise a plugin author can write
25
- * get capabilities() { throw new Error('boom') }
26
- * and pass the basic typeof check, then crash preflight when the engine
27
- * touches `driver.capabilities.sessionResume`. (R8)
28
- */
29
- function validateContract(category: PluginCategory, handler: unknown): void {
30
- if (!handler || typeof handler !== 'object') {
31
- throw new Error(`Plugin handler for category "${category}" must be an object`);
32
- }
33
- const h = handler as Record<string, unknown>;
34
- if (typeof h.name !== 'string' || h.name.length === 0) {
35
- throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
36
- }
37
- switch (category) {
38
- case 'drivers': {
39
- if (typeof h.buildCommand !== 'function') {
40
- throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
41
- }
42
- // Materialize capabilities — this triggers any throwing getter NOW
43
- // instead of during preflight.
44
- let caps: unknown;
45
- try {
46
- caps = h.capabilities;
47
- } catch (err) {
48
- throw new Error(
49
- `drivers plugin "${h.name}" capabilities accessor threw: ` +
50
- (err instanceof Error ? err.message : String(err))
51
- );
52
- }
53
- if (!caps || typeof caps !== 'object') {
54
- throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
55
- }
56
- const c = caps as Record<string, unknown>;
57
- for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
58
- if (typeof c[field] !== 'boolean') {
59
- throw new Error(
60
- `drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`
61
- );
62
- }
63
- }
64
- // Optional methods, but if present must be functions.
65
- for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
66
- if (h[opt] !== undefined && typeof h[opt] !== 'function') {
67
- throw new Error(
68
- `drivers plugin "${h.name}".${opt} must be a function or undefined`
69
- );
70
- }
71
- }
72
- break;
73
- }
74
- case 'triggers':
75
- if (typeof h.watch !== 'function') {
76
- throw new Error(`triggers plugin "${h.name}" must export watch()`);
77
- }
78
- break;
79
- case 'completions':
80
- if (typeof h.check !== 'function') {
81
- throw new Error(`completions plugin "${h.name}" must export check()`);
82
- }
83
- break;
84
- case 'middlewares':
85
- if (typeof h.enhance !== 'function') {
86
- throw new Error(`middlewares plugin "${h.name}" must export enhance()`);
87
- }
88
- break;
89
- }
90
- }
91
-
92
- export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
93
-
94
- /**
95
- * Register a plugin under (category, type). Returns:
96
- * - 'registered' on first registration
97
- * - 'replaced' when an existing entry was overwritten with a different handler
98
- * - 'unchanged' when the same handler instance was already present
99
- *
100
- * Throws if `category` is unknown, `type` is empty, or `handler` violates the
101
- * minimum interface contract for the category.
102
- */
103
- export function registerPlugin<T extends PluginType>(
104
- category: PluginCategory, type: string, handler: T,
105
- ): RegisterResult {
106
- if (!VALID_CATEGORIES.has(category)) {
107
- throw new Error(`Unknown plugin category "${category}"`);
108
- }
109
- if (typeof type !== 'string' || type.length === 0) {
110
- throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
111
- }
112
- validateContract(category, handler);
113
- const registry = registries[category] as Map<string, T>;
114
- const existing = registry.get(type);
115
- if (existing === handler) return 'unchanged';
116
- const wasReplaced = existing !== undefined;
117
- registry.set(type, handler);
118
- return wasReplaced ? 'replaced' : 'registered';
119
- }
120
-
121
- /**
122
- * Remove a plugin from the in-process registry. Returns true if a plugin
123
- * was actually removed. Note: ESM module caching is not affected, so
124
- * re-importing the same file after unregister will yield the cached module —
125
- * callers wanting a fresh load must restart the host process.
126
- */
127
- export function unregisterPlugin(category: PluginCategory, type: string): boolean {
128
- if (!VALID_CATEGORIES.has(category)) return false;
129
- return registries[category].delete(type);
130
- }
131
-
132
- export function getHandler<T extends PluginType>(
133
- category: PluginCategory, type: string,
134
- ): T {
135
- const handler = registries[category].get(type);
136
- if (!handler) {
137
- throw new Error(
138
- `${category} type "${type}" not registered.\n` +
139
- `Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`
140
- );
141
- }
142
- return handler as T;
143
- }
144
-
145
- export function hasHandler(category: PluginCategory, type: string): boolean {
146
- return registries[category].has(type);
147
- }
148
-
149
- // Plugin name must be a scoped npm package or a tagma-prefixed package.
150
- // Reject absolute/relative paths and suspicious patterns to prevent
151
- // arbitrary code execution via crafted YAML configs.
152
- export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
153
-
154
- export function isValidPluginName(name: unknown): name is string {
155
- return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
156
- }
157
-
158
- /**
159
- * Parse and validate the `tagmaPlugin` field of a `package.json` blob.
160
- *
161
- * Returns the strongly-typed manifest if the field is present and
162
- * well-formed (`category` is one of the four known categories and `type`
163
- * is a non-empty string). Returns `null` if the field is absent — that
164
- * is the host's signal that the package is a library, not a plugin.
165
- *
166
- * Throws if the field is present but malformed: that's a packaging bug
167
- * the plugin author should hear about loudly, not a silent skip.
168
- *
169
- * Hosts use this during auto-discovery to decide whether to load a
170
- * package as a plugin without having to dynamically `import()` it.
171
- */
172
- export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
173
- if (!pkgJson || typeof pkgJson !== 'object') return null;
174
- const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
175
- if (raw === undefined) return null;
176
- if (!raw || typeof raw !== 'object') {
177
- throw new Error('tagmaPlugin field must be an object with { category, type }');
178
- }
179
- const m = raw as Record<string, unknown>;
180
- const category = m.category;
181
- const type = m.type;
182
- if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
183
- throw new Error(
184
- `tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`
185
- );
186
- }
187
- if (typeof type !== 'string' || type.length === 0) {
188
- throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
189
- }
190
- return { category: category as PluginCategory, type };
191
- }
192
-
193
- export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
194
- for (const name of pluginNames) {
195
- if (!isValidPluginName(name)) {
196
- throw new Error(
197
- `Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
198
- `(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
199
- `Relative/absolute paths are not allowed.`
200
- );
201
- }
202
- const mod = await import(name);
203
- if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
204
- throw new Error(
205
- `Plugin "${name}" must export pluginCategory, pluginType, and default`
206
- );
207
- }
208
- registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
209
- }
210
- }
211
-
212
- export function listRegistered(category: PluginCategory): string[] {
213
- return [...registries[category].keys()];
214
- }
1
+ import type {
2
+ PluginCategory, DriverPlugin, TriggerPlugin,
3
+ CompletionPlugin, MiddlewarePlugin, PluginManifest,
4
+ } from './types';
5
+
6
+ type PluginType = DriverPlugin | TriggerPlugin | CompletionPlugin | MiddlewarePlugin;
7
+
8
+ const VALID_CATEGORIES: ReadonlySet<PluginCategory> = new Set([
9
+ 'drivers', 'triggers', 'completions', 'middlewares',
10
+ ]);
11
+
12
+ const registries = {
13
+ drivers: new Map<string, DriverPlugin>(),
14
+ triggers: new Map<string, TriggerPlugin>(),
15
+ completions: new Map<string, CompletionPlugin>(),
16
+ middlewares: new Map<string, MiddlewarePlugin>(),
17
+ };
18
+
19
+ /**
20
+ * Minimal contract enforcement so a malformed plugin fails fast at
21
+ * registration time rather than crashing the engine mid-run.
22
+ *
23
+ * For drivers we materialize `capabilities` and assert each field is a
24
+ * boolean — otherwise a plugin author can write
25
+ * get capabilities() { throw new Error('boom') }
26
+ * and pass the basic typeof check, then crash preflight when the engine
27
+ * touches `driver.capabilities.sessionResume`. (R8)
28
+ */
29
+ function validateContract(category: PluginCategory, handler: unknown): void {
30
+ if (!handler || typeof handler !== 'object') {
31
+ throw new Error(`Plugin handler for category "${category}" must be an object`);
32
+ }
33
+ const h = handler as Record<string, unknown>;
34
+ if (typeof h.name !== 'string' || h.name.length === 0) {
35
+ throw new Error(`Plugin handler for category "${category}" must declare a non-empty "name"`);
36
+ }
37
+ switch (category) {
38
+ case 'drivers': {
39
+ if (typeof h.buildCommand !== 'function') {
40
+ throw new Error(`drivers plugin "${h.name}" must export buildCommand()`);
41
+ }
42
+ // Materialize capabilities — this triggers any throwing getter NOW
43
+ // instead of during preflight.
44
+ let caps: unknown;
45
+ try {
46
+ caps = h.capabilities;
47
+ } catch (err) {
48
+ throw new Error(
49
+ `drivers plugin "${h.name}" capabilities accessor threw: ` +
50
+ (err instanceof Error ? err.message : String(err))
51
+ );
52
+ }
53
+ if (!caps || typeof caps !== 'object') {
54
+ throw new Error(`drivers plugin "${h.name}" must declare capabilities object`);
55
+ }
56
+ const c = caps as Record<string, unknown>;
57
+ for (const field of ['sessionResume', 'systemPrompt', 'outputFormat'] as const) {
58
+ if (typeof c[field] !== 'boolean') {
59
+ throw new Error(
60
+ `drivers plugin "${h.name}".capabilities.${field} must be a boolean (got ${typeof c[field]})`
61
+ );
62
+ }
63
+ }
64
+ // Optional methods, but if present must be functions.
65
+ for (const opt of ['parseResult', 'resolveModel', 'resolveTools'] as const) {
66
+ if (h[opt] !== undefined && typeof h[opt] !== 'function') {
67
+ throw new Error(
68
+ `drivers plugin "${h.name}".${opt} must be a function or undefined`
69
+ );
70
+ }
71
+ }
72
+ break;
73
+ }
74
+ case 'triggers':
75
+ if (typeof h.watch !== 'function') {
76
+ throw new Error(`triggers plugin "${h.name}" must export watch()`);
77
+ }
78
+ break;
79
+ case 'completions':
80
+ if (typeof h.check !== 'function') {
81
+ throw new Error(`completions plugin "${h.name}" must export check()`);
82
+ }
83
+ break;
84
+ case 'middlewares':
85
+ if (typeof h.enhance !== 'function') {
86
+ throw new Error(`middlewares plugin "${h.name}" must export enhance()`);
87
+ }
88
+ break;
89
+ }
90
+ }
91
+
92
+ export type RegisterResult = 'registered' | 'replaced' | 'unchanged';
93
+
94
+ /**
95
+ * Register a plugin under (category, type). Returns:
96
+ * - 'registered' on first registration
97
+ * - 'replaced' when an existing entry was overwritten with a different handler
98
+ * - 'unchanged' when the same handler instance was already present
99
+ *
100
+ * Throws if `category` is unknown, `type` is empty, or `handler` violates the
101
+ * minimum interface contract for the category.
102
+ */
103
+ export function registerPlugin<T extends PluginType>(
104
+ category: PluginCategory, type: string, handler: T,
105
+ ): RegisterResult {
106
+ if (!VALID_CATEGORIES.has(category)) {
107
+ throw new Error(`Unknown plugin category "${category}"`);
108
+ }
109
+ if (typeof type !== 'string' || type.length === 0) {
110
+ throw new Error(`Plugin type must be a non-empty string (category="${category}")`);
111
+ }
112
+ validateContract(category, handler);
113
+ const registry = registries[category] as Map<string, T>;
114
+ const existing = registry.get(type);
115
+ if (existing === handler) return 'unchanged';
116
+ const wasReplaced = existing !== undefined;
117
+ registry.set(type, handler);
118
+ return wasReplaced ? 'replaced' : 'registered';
119
+ }
120
+
121
+ /**
122
+ * Remove a plugin from the in-process registry. Returns true if a plugin
123
+ * was actually removed. Note: ESM module caching is not affected, so
124
+ * re-importing the same file after unregister will yield the cached module —
125
+ * callers wanting a fresh load must restart the host process.
126
+ */
127
+ export function unregisterPlugin(category: PluginCategory, type: string): boolean {
128
+ if (!VALID_CATEGORIES.has(category)) return false;
129
+ return registries[category].delete(type);
130
+ }
131
+
132
+ export function getHandler<T extends PluginType>(
133
+ category: PluginCategory, type: string,
134
+ ): T {
135
+ const handler = registries[category].get(type);
136
+ if (!handler) {
137
+ throw new Error(
138
+ `${category} type "${type}" not registered.\n` +
139
+ `Install the plugin: bun add @tagma/${category.replace(/s$/, '')}-${type}`
140
+ );
141
+ }
142
+ return handler as T;
143
+ }
144
+
145
+ export function hasHandler(category: PluginCategory, type: string): boolean {
146
+ return registries[category].has(type);
147
+ }
148
+
149
+ // Plugin name must be a scoped npm package or a tagma-prefixed package.
150
+ // Reject absolute/relative paths and suspicious patterns to prevent
151
+ // arbitrary code execution via crafted YAML configs.
152
+ export const PLUGIN_NAME_RE = /^(@[a-z0-9-]+\/[a-z0-9._-]+|tagma-plugin-[a-z0-9._-]+)$/;
153
+
154
+ export function isValidPluginName(name: unknown): name is string {
155
+ return typeof name === 'string' && PLUGIN_NAME_RE.test(name);
156
+ }
157
+
158
+ /**
159
+ * Parse and validate the `tagmaPlugin` field of a `package.json` blob.
160
+ *
161
+ * Returns the strongly-typed manifest if the field is present and
162
+ * well-formed (`category` is one of the four known categories and `type`
163
+ * is a non-empty string). Returns `null` if the field is absent — that
164
+ * is the host's signal that the package is a library, not a plugin.
165
+ *
166
+ * Throws if the field is present but malformed: that's a packaging bug
167
+ * the plugin author should hear about loudly, not a silent skip.
168
+ *
169
+ * Hosts use this during auto-discovery to decide whether to load a
170
+ * package as a plugin without having to dynamically `import()` it.
171
+ */
172
+ export function readPluginManifest(pkgJson: unknown): PluginManifest | null {
173
+ if (!pkgJson || typeof pkgJson !== 'object') return null;
174
+ const raw = (pkgJson as Record<string, unknown>).tagmaPlugin;
175
+ if (raw === undefined) return null;
176
+ if (!raw || typeof raw !== 'object') {
177
+ throw new Error('tagmaPlugin field must be an object with { category, type }');
178
+ }
179
+ const m = raw as Record<string, unknown>;
180
+ const category = m.category;
181
+ const type = m.type;
182
+ if (typeof category !== 'string' || !VALID_CATEGORIES.has(category as PluginCategory)) {
183
+ throw new Error(
184
+ `tagmaPlugin.category must be one of ${[...VALID_CATEGORIES].join(', ')}, got ${JSON.stringify(category)}`
185
+ );
186
+ }
187
+ if (typeof type !== 'string' || type.length === 0) {
188
+ throw new Error(`tagmaPlugin.type must be a non-empty string, got ${JSON.stringify(type)}`);
189
+ }
190
+ return { category: category as PluginCategory, type };
191
+ }
192
+
193
+ export async function loadPlugins(pluginNames: readonly string[]): Promise<void> {
194
+ for (const name of pluginNames) {
195
+ if (!isValidPluginName(name)) {
196
+ throw new Error(
197
+ `Plugin "${name}" rejected: plugin names must be scoped npm packages ` +
198
+ `(e.g. @tagma/trigger-xyz) or tagma-plugin-* packages. ` +
199
+ `Relative/absolute paths are not allowed.`
200
+ );
201
+ }
202
+ const mod = await import(name);
203
+ if (!mod.pluginCategory || !mod.pluginType || !mod.default) {
204
+ throw new Error(
205
+ `Plugin "${name}" must export pluginCategory, pluginType, and default`
206
+ );
207
+ }
208
+ registerPlugin(mod.pluginCategory, mod.pluginType, mod.default);
209
+ }
210
+ }
211
+
212
+ export function listRegistered(category: PluginCategory): string[] {
213
+ return [...registries[category].keys()];
214
+ }
package/src/runner.ts CHANGED
@@ -111,7 +111,6 @@ function failResult(stderr: string, durationMs: number): TaskResult {
111
111
  exitCode: -1,
112
112
  stdout: '',
113
113
  stderr,
114
- outputPath: null,
115
114
  stderrPath: null,
116
115
  durationMs,
117
116
  sessionId: null,
@@ -289,7 +288,6 @@ export async function runSpawn(
289
288
  exitCode: -1,
290
289
  stdout,
291
290
  stderr,
292
- outputPath: null,
293
291
  stderrPath: null,
294
292
  durationMs,
295
293
  sessionId: null,
@@ -339,7 +337,6 @@ export async function runSpawn(
339
337
  exitCode,
340
338
  stdout,
341
339
  stderr: stderr + note,
342
- outputPath: null,
343
340
  stderrPath: null,
344
341
  durationMs,
345
342
  sessionId: null,
@@ -362,7 +359,6 @@ export async function runSpawn(
362
359
  exitCode: exitCode === 0 ? 1 : exitCode,
363
360
  stdout,
364
361
  stderr: stderr + (stderr.endsWith('\n') ? '' : '\n') + `[driver] ${forcedFailureMessage}`,
365
- outputPath: null,
366
362
  stderrPath: null,
367
363
  durationMs,
368
364
  sessionId,
@@ -374,7 +370,6 @@ export async function runSpawn(
374
370
  exitCode,
375
371
  stdout,
376
372
  stderr,
377
- outputPath: null,
378
373
  stderrPath: null,
379
374
  durationMs,
380
375
  sessionId,
@@ -1,97 +1,97 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import yaml from 'js-yaml';
3
- import type { PipelineConfig, RawPipelineConfig } from './types';
4
- import { parseYaml, deresolvePipeline, serializePipeline } from './schema';
5
-
6
- function parsePipelineYaml(content: string): RawPipelineConfig {
7
- const doc = yaml.load(content) as { pipeline: RawPipelineConfig };
8
- return doc.pipeline;
9
- }
10
-
11
- describe('completion default serialization', () => {
12
- test('serializePipeline omits default exit_code completions from raw configs', () => {
13
- const raw: RawPipelineConfig = {
14
- name: 'Serialize Defaults',
15
- tracks: [
16
- {
17
- id: 'track_a',
18
- name: 'Track A',
19
- tasks: [
20
- { id: 'task_1', prompt: 'hello', completion: { type: 'exit_code' } },
21
- { id: 'task_2', prompt: 'world', completion: { type: 'exit_code', expect: 0 } },
22
- { id: 'task_3', prompt: 'keep me', completion: { type: 'exit_code', expect: 2 } },
23
- ],
24
- },
25
- ],
26
- };
27
-
28
- const parsed = parsePipelineYaml(serializePipeline(raw));
29
- expect(parsed.tracks[0].tasks[0].completion).toBeUndefined();
30
- expect(parsed.tracks[0].tasks[1].completion).toBeUndefined();
31
- expect(parsed.tracks[0].tasks[2].completion).toEqual({ type: 'exit_code', expect: 2 });
32
- });
33
-
34
- test('serializePipeline preserves non-default completion plugins', () => {
35
- const raw: RawPipelineConfig = {
36
- name: 'Serialize Explicit',
37
- tracks: [
38
- {
39
- id: 'track_a',
40
- name: 'Track A',
41
- tasks: [
42
- { id: 'task_1', prompt: 'check file', completion: { type: 'file_exists', path: './out.txt' } },
43
- ],
44
- },
45
- ],
46
- };
47
-
48
- const parsed = parsePipelineYaml(serializePipeline(raw));
49
- expect(parsed.tracks[0].tasks[0].completion).toEqual({
50
- type: 'file_exists',
51
- path: './out.txt',
52
- });
53
- });
54
-
55
- test('deresolvePipeline also omits the default exit_code completion', () => {
56
- const resolved: PipelineConfig = {
57
- name: 'Deresolve Defaults',
58
- tracks: [
59
- {
60
- id: 'track_a',
61
- name: 'Track A',
62
- driver: 'claude-code',
63
- permissions: { read: true, write: false, execute: false },
64
- on_failure: 'skip_downstream',
65
- cwd: 'D:/workspace',
66
- tasks: [
67
- {
68
- id: 'task_1',
69
- name: 'Task 1',
70
- prompt: 'hello',
71
- driver: 'claude-code',
72
- permissions: { read: true, write: false, execute: false },
73
- cwd: 'D:/workspace',
74
- completion: { type: 'exit_code', expect: 0 },
75
- },
76
- {
77
- id: 'task_2',
78
- name: 'Task 2',
79
- prompt: 'custom',
80
- driver: 'claude-code',
81
- permissions: { read: true, write: false, execute: false },
82
- cwd: 'D:/workspace',
83
- completion: { type: 'output_check', check: 'test -f ./done.txt' },
84
- },
85
- ],
86
- },
87
- ],
88
- };
89
-
90
- const raw = deresolvePipeline(resolved, 'D:/workspace');
91
- expect(raw.tracks[0].tasks[0].completion).toBeUndefined();
92
- expect(raw.tracks[0].tasks[1].completion).toEqual({
93
- type: 'output_check',
94
- check: 'test -f ./done.txt',
95
- });
96
- });
97
- });
1
+ import { describe, expect, test } from 'bun:test';
2
+ import yaml from 'js-yaml';
3
+ import type { PipelineConfig, RawPipelineConfig } from './types';
4
+ import { parseYaml, deresolvePipeline, serializePipeline } from './schema';
5
+
6
+ function parsePipelineYaml(content: string): RawPipelineConfig {
7
+ const doc = yaml.load(content) as { pipeline: RawPipelineConfig };
8
+ return doc.pipeline;
9
+ }
10
+
11
+ describe('completion default serialization', () => {
12
+ test('serializePipeline omits default exit_code completions from raw configs', () => {
13
+ const raw: RawPipelineConfig = {
14
+ name: 'Serialize Defaults',
15
+ tracks: [
16
+ {
17
+ id: 'track_a',
18
+ name: 'Track A',
19
+ tasks: [
20
+ { id: 'task_1', prompt: 'hello', completion: { type: 'exit_code' } },
21
+ { id: 'task_2', prompt: 'world', completion: { type: 'exit_code', expect: 0 } },
22
+ { id: 'task_3', prompt: 'keep me', completion: { type: 'exit_code', expect: 2 } },
23
+ ],
24
+ },
25
+ ],
26
+ };
27
+
28
+ const parsed = parsePipelineYaml(serializePipeline(raw));
29
+ expect(parsed.tracks[0].tasks[0].completion).toBeUndefined();
30
+ expect(parsed.tracks[0].tasks[1].completion).toBeUndefined();
31
+ expect(parsed.tracks[0].tasks[2].completion).toEqual({ type: 'exit_code', expect: 2 });
32
+ });
33
+
34
+ test('serializePipeline preserves non-default completion plugins', () => {
35
+ const raw: RawPipelineConfig = {
36
+ name: 'Serialize Explicit',
37
+ tracks: [
38
+ {
39
+ id: 'track_a',
40
+ name: 'Track A',
41
+ tasks: [
42
+ { id: 'task_1', prompt: 'check file', completion: { type: 'file_exists', path: './out.txt' } },
43
+ ],
44
+ },
45
+ ],
46
+ };
47
+
48
+ const parsed = parsePipelineYaml(serializePipeline(raw));
49
+ expect(parsed.tracks[0].tasks[0].completion).toEqual({
50
+ type: 'file_exists',
51
+ path: './out.txt',
52
+ });
53
+ });
54
+
55
+ test('deresolvePipeline also omits the default exit_code completion', () => {
56
+ const resolved: PipelineConfig = {
57
+ name: 'Deresolve Defaults',
58
+ tracks: [
59
+ {
60
+ id: 'track_a',
61
+ name: 'Track A',
62
+ driver: 'claude-code',
63
+ permissions: { read: true, write: false, execute: false },
64
+ on_failure: 'skip_downstream',
65
+ cwd: 'D:/workspace',
66
+ tasks: [
67
+ {
68
+ id: 'task_1',
69
+ name: 'Task 1',
70
+ prompt: 'hello',
71
+ driver: 'claude-code',
72
+ permissions: { read: true, write: false, execute: false },
73
+ cwd: 'D:/workspace',
74
+ completion: { type: 'exit_code', expect: 0 },
75
+ },
76
+ {
77
+ id: 'task_2',
78
+ name: 'Task 2',
79
+ prompt: 'custom',
80
+ driver: 'claude-code',
81
+ permissions: { read: true, write: false, execute: false },
82
+ cwd: 'D:/workspace',
83
+ completion: { type: 'output_check', check: 'test -f ./done.txt' },
84
+ },
85
+ ],
86
+ },
87
+ ],
88
+ };
89
+
90
+ const raw = deresolvePipeline(resolved, 'D:/workspace');
91
+ expect(raw.tracks[0].tasks[0].completion).toBeUndefined();
92
+ expect(raw.tracks[0].tasks[1].completion).toEqual({
93
+ type: 'output_check',
94
+ check: 'test -f ./done.txt',
95
+ });
96
+ });
97
+ });