opencastle 0.14.0 → 0.16.0

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 (34) hide show
  1. package/dist/cli/convoy/store.d.ts +1 -0
  2. package/dist/cli/convoy/store.d.ts.map +1 -1
  3. package/dist/cli/convoy/store.js +5 -0
  4. package/dist/cli/convoy/store.js.map +1 -1
  5. package/dist/cli/run/schema.d.ts +5 -0
  6. package/dist/cli/run/schema.d.ts.map +1 -1
  7. package/dist/cli/run/schema.js +98 -143
  8. package/dist/cli/run/schema.js.map +1 -1
  9. package/dist/cli/run/schema.test.js +53 -215
  10. package/dist/cli/run/schema.test.js.map +1 -1
  11. package/dist/cli/run.d.ts.map +1 -1
  12. package/dist/cli/run.js +202 -104
  13. package/dist/cli/run.js.map +1 -1
  14. package/dist/cli/types.d.ts +2 -58
  15. package/dist/cli/types.d.ts.map +1 -1
  16. package/package.json +1 -1
  17. package/src/cli/convoy/store.ts +7 -0
  18. package/src/cli/run/schema.test.ts +61 -241
  19. package/src/cli/run/schema.ts +105 -153
  20. package/src/cli/run.ts +216 -105
  21. package/src/cli/types.ts +2 -66
  22. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  23. package/src/orchestrator/agents/team-lead.agent.md +2 -2
  24. package/src/orchestrator/prompts/generate-task-spec.prompt.md +26 -5
  25. package/dist/cli/run/loop-executor.d.ts +0 -3
  26. package/dist/cli/run/loop-executor.d.ts.map +0 -1
  27. package/dist/cli/run/loop-executor.js +0 -155
  28. package/dist/cli/run/loop-executor.js.map +0 -1
  29. package/dist/cli/run/loop-reporter.d.ts +0 -6
  30. package/dist/cli/run/loop-reporter.d.ts.map +0 -1
  31. package/dist/cli/run/loop-reporter.js +0 -112
  32. package/dist/cli/run/loop-reporter.js.map +0 -1
  33. package/src/cli/run/loop-executor.ts +0 -199
  34. package/src/cli/run/loop-reporter.ts +0 -125
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec } from './schema.js'
2
+ import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec, parseTaskSpecText } from './schema.js'
3
3
 
4
4
  // ── parseYaml ──────────────────────────────────────────────────
5
5
 
@@ -351,246 +351,6 @@ describe('applyDefaults', () => {
351
351
  })
352
352
  })
353
353
 
354
- // ── loop mode — validateSpec ───────────────────────────────────
355
-
356
- describe('validateSpec — loop mode', () => {
357
- const validLoopSpec = {
358
- name: 'build-auth',
359
- mode: 'loop',
360
- adapter: 'copilot',
361
- loop: {
362
- prompt: 'PROMPT_build.md',
363
- plan_file: 'IMPLEMENTATION_PLAN.md',
364
- max_iterations: 20,
365
- timeout: '10m',
366
- model: 'gpt-5.1',
367
- backpressure: ['npm test', 'npx tsc --noEmit'],
368
- },
369
- }
370
-
371
- it('accepts a valid minimal loop spec (only prompt required)', () => {
372
- const result = validateSpec({
373
- name: 'build-auth',
374
- mode: 'loop',
375
- loop: { prompt: 'PROMPT_build.md' },
376
- })
377
- expect(result.valid).toBe(true)
378
- expect(result.errors).toHaveLength(0)
379
- })
380
-
381
- it('accepts a full loop spec', () => {
382
- const result = validateSpec(validLoopSpec)
383
- expect(result.valid).toBe(true)
384
- expect(result.errors).toHaveLength(0)
385
- })
386
-
387
- it('does not require tasks array in loop mode', () => {
388
- const result = validateSpec({
389
- name: 'build-auth',
390
- mode: 'loop',
391
- loop: { prompt: 'PROMPT_build.md' },
392
- })
393
- expect(result.valid).toBe(true)
394
- })
395
-
396
- it('fails when loop object is missing', () => {
397
- const result = validateSpec({ name: 'build-auth', mode: 'loop' })
398
- expect(result.valid).toBe(false)
399
- expect(result.errors).toContainEqual(expect.stringContaining('`loop` is required'))
400
- })
401
-
402
- it('fails when loop.prompt is missing', () => {
403
- const result = validateSpec({
404
- name: 'build-auth',
405
- mode: 'loop',
406
- loop: { max_iterations: 10 },
407
- })
408
- expect(result.valid).toBe(false)
409
- expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'))
410
- })
411
-
412
- it('fails when loop.prompt is not a string', () => {
413
- const result = validateSpec({
414
- name: 'build-auth',
415
- mode: 'loop',
416
- loop: { prompt: 123 },
417
- })
418
- expect(result.valid).toBe(false)
419
- expect(result.errors).toContainEqual(expect.stringContaining('loop.prompt'))
420
- })
421
-
422
- it('fails when loop.max_iterations is 0', () => {
423
- const result = validateSpec({
424
- name: 'build-auth',
425
- mode: 'loop',
426
- loop: { prompt: 'PROMPT.md', max_iterations: 0 },
427
- })
428
- expect(result.valid).toBe(false)
429
- expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'))
430
- })
431
-
432
- it('fails when loop.max_iterations is a float', () => {
433
- const result = validateSpec({
434
- name: 'build-auth',
435
- mode: 'loop',
436
- loop: { prompt: 'PROMPT.md', max_iterations: 1.5 },
437
- })
438
- expect(result.valid).toBe(false)
439
- expect(result.errors).toContainEqual(expect.stringContaining('loop.max_iterations'))
440
- })
441
-
442
- it('fails when loop.timeout has invalid format', () => {
443
- const result = validateSpec({
444
- name: 'build-auth',
445
- mode: 'loop',
446
- loop: { prompt: 'PROMPT.md', timeout: 'bad' },
447
- })
448
- expect(result.valid).toBe(false)
449
- expect(result.errors).toContainEqual(expect.stringContaining('loop.timeout'))
450
- })
451
-
452
- it('accepts valid loop.timeout formats', () => {
453
- for (const t of ['5s', '10m', '2h']) {
454
- const result = validateSpec({
455
- name: 'build-auth',
456
- mode: 'loop',
457
- loop: { prompt: 'PROMPT.md', timeout: t },
458
- })
459
- expect(result.valid).toBe(true)
460
- }
461
- })
462
-
463
- it('fails when loop.backpressure is not an array', () => {
464
- const result = validateSpec({
465
- name: 'build-auth',
466
- mode: 'loop',
467
- loop: { prompt: 'PROMPT.md', backpressure: 'npm test' },
468
- })
469
- expect(result.valid).toBe(false)
470
- expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'))
471
- })
472
-
473
- it('fails when loop.backpressure contains non-strings', () => {
474
- const result = validateSpec({
475
- name: 'build-auth',
476
- mode: 'loop',
477
- loop: { prompt: 'PROMPT.md', backpressure: ['npm test', 42] },
478
- })
479
- expect(result.valid).toBe(false)
480
- expect(result.errors).toContainEqual(expect.stringContaining('loop.backpressure'))
481
- })
482
-
483
- it('fails when loop.plan_file is not a string', () => {
484
- const result = validateSpec({
485
- name: 'build-auth',
486
- mode: 'loop',
487
- loop: { prompt: 'PROMPT.md', plan_file: true },
488
- })
489
- expect(result.valid).toBe(false)
490
- expect(result.errors).toContainEqual(expect.stringContaining('loop.plan_file'))
491
- })
492
-
493
- it('fails when loop.model is not a string', () => {
494
- const result = validateSpec({
495
- name: 'build-auth',
496
- mode: 'loop',
497
- loop: { prompt: 'PROMPT.md', model: 99 },
498
- })
499
- expect(result.valid).toBe(false)
500
- expect(result.errors).toContainEqual(expect.stringContaining('loop.model'))
501
- })
502
-
503
- it('rejects unknown mode value', () => {
504
- const result = validateSpec({
505
- name: 'build-auth',
506
- mode: 'parallel',
507
- tasks: [{ id: 'a', prompt: 'x' }],
508
- })
509
- expect(result.valid).toBe(false)
510
- expect(result.errors).toContainEqual(expect.stringContaining('mode'))
511
- })
512
-
513
- it('mode: tasks still requires tasks array', () => {
514
- const result = validateSpec({ name: 'build-auth', mode: 'tasks' })
515
- expect(result.valid).toBe(false)
516
- expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
517
- })
518
-
519
- it('spec without mode field defaults to tasks behavior (requires tasks)', () => {
520
- const result = validateSpec({ name: 'build-auth' })
521
- expect(result.valid).toBe(false)
522
- expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
523
- })
524
- })
525
-
526
- // ── loop mode — applyDefaults ──────────────────────────────────
527
-
528
- describe('applyDefaults — loop mode', () => {
529
- it('applies default max_iterations', () => {
530
- const spec = applyDefaults({
531
- name: 'build-auth',
532
- mode: 'loop',
533
- loop: { prompt: 'PROMPT.md' },
534
- })
535
- expect(spec.loop?.max_iterations).toBe(20)
536
- })
537
-
538
- it('applies default plan_file', () => {
539
- const spec = applyDefaults({
540
- name: 'build-auth',
541
- mode: 'loop',
542
- loop: { prompt: 'PROMPT.md' },
543
- })
544
- expect(spec.loop?.plan_file).toBe('IMPLEMENTATION_PLAN.md')
545
- })
546
-
547
- it('applies default timeout', () => {
548
- const spec = applyDefaults({
549
- name: 'build-auth',
550
- mode: 'loop',
551
- loop: { prompt: 'PROMPT.md' },
552
- })
553
- expect(spec.loop?.timeout).toBe('10m')
554
- })
555
-
556
- it('preserves user-specified loop values', () => {
557
- const spec = applyDefaults({
558
- name: 'build-auth',
559
- mode: 'loop',
560
- loop: {
561
- prompt: 'PROMPT.md',
562
- max_iterations: 5,
563
- plan_file: 'MY_PLAN.md',
564
- timeout: '30m',
565
- model: 'gpt-5.1',
566
- backpressure: ['npm test'],
567
- },
568
- })
569
- expect(spec.loop?.max_iterations).toBe(5)
570
- expect(spec.loop?.plan_file).toBe('MY_PLAN.md')
571
- expect(spec.loop?.timeout).toBe('30m')
572
- expect(spec.loop?.model).toBe('gpt-5.1')
573
- expect(spec.loop?.backpressure).toEqual(['npm test'])
574
- })
575
-
576
- it('sets mode to tasks when not specified', () => {
577
- const spec = applyDefaults({
578
- name: 'test',
579
- tasks: [{ id: 'a', prompt: 'x' }],
580
- })
581
- expect(spec.mode).toBe('tasks')
582
- })
583
-
584
- it('preserves mode: loop', () => {
585
- const spec = applyDefaults({
586
- name: 'build-auth',
587
- mode: 'loop',
588
- loop: { prompt: 'PROMPT.md' },
589
- })
590
- expect(spec.mode).toBe('loop')
591
- })
592
- })
593
-
594
354
  // ── validateSpec — version field ───────────────────────────────
595
355
 
596
356
  describe('validateSpec — version field', () => {
@@ -1051,3 +811,63 @@ describe('backward compatibility — legacy specs', () => {
1051
811
  expect(spec.tasks![0].model).toBeUndefined()
1052
812
  })
1053
813
  })
814
+
815
+ // ── parseTaskSpecText ──────────────────────────────────────────
816
+
817
+ describe('parseTaskSpecText', () => {
818
+ it('parses a valid YAML string and returns a TaskSpec', () => {
819
+ const yaml = `
820
+ name: test-run
821
+ tasks:
822
+ - id: task-1
823
+ prompt: Do something
824
+ `
825
+ const spec = parseTaskSpecText(yaml)
826
+ expect(spec.name).toBe('test-run')
827
+ expect(spec.tasks).toHaveLength(1)
828
+ expect(spec.tasks![0].id).toBe('task-1')
829
+ })
830
+
831
+ it('throws on empty string', () => {
832
+ expect(() => parseTaskSpecText('')).toThrow('empty')
833
+ })
834
+
835
+ it('throws on whitespace-only string', () => {
836
+ expect(() => parseTaskSpecText(' \n ')).toThrow('empty')
837
+ })
838
+
839
+ it('throws on invalid YAML', () => {
840
+ expect(() => parseTaskSpecText(': invalid: yaml: {')).toThrow(/YAML parse error/)
841
+ })
842
+
843
+ it('throws on invalid spec (missing tasks)', () => {
844
+ expect(() => parseTaskSpecText('name: test')).toThrow(/Invalid task spec/)
845
+ })
846
+
847
+ it('applies defaults when parsing', () => {
848
+ const yaml = `
849
+ name: test-run
850
+ tasks:
851
+ - id: task-1
852
+ prompt: Do something
853
+ `
854
+ const spec = parseTaskSpecText(yaml)
855
+ expect(spec.concurrency).toBe(1)
856
+ expect(spec.on_failure).toBe('continue')
857
+ expect(spec.tasks![0].agent).toBe('developer')
858
+ expect(spec.tasks![0].timeout).toBe('30m')
859
+ })
860
+
861
+ it('parses a convoy spec (version: 1)', () => {
862
+ const yaml = `
863
+ name: convoy-run
864
+ version: 1
865
+ tasks:
866
+ - id: task-1
867
+ prompt: Do something
868
+ `
869
+ const spec = parseTaskSpecText(yaml)
870
+ expect(spec.version).toBe(1)
871
+ expect(isConvoySpec(spec)).toBe(true)
872
+ })
873
+ })
@@ -39,8 +39,6 @@ interface RawSpec {
39
39
  on_failure?: unknown
40
40
  adapter?: unknown
41
41
  tasks?: unknown
42
- mode?: unknown
43
- loop?: unknown
44
42
  version?: unknown
45
43
  defaults?: unknown
46
44
  gates?: unknown
@@ -146,132 +144,87 @@ export function validateSpec(spec: unknown): ValidationResult {
146
144
  errors.push('`branch` must be a string')
147
145
  }
148
146
 
149
- // mode
150
- const mode = s.mode !== undefined ? s.mode : 'tasks'
151
- if (mode !== 'tasks' && mode !== 'loop') {
152
- errors.push('`mode` must be one of: tasks, loop')
147
+ // Tasks are always required
148
+ if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
149
+ errors.push('`tasks` is required and must be a non-empty array')
150
+ return { valid: false, errors }
153
151
  }
154
152
 
155
- if (mode === 'loop') {
156
- // Loop validation tasks array is NOT required
157
- const loop = s.loop as Record<string, unknown> | undefined
158
- if (!loop || typeof loop !== 'object') {
159
- errors.push('`loop` is required when mode is "loop"')
160
- } else {
161
- if (!loop.prompt || typeof loop.prompt !== 'string') {
162
- errors.push('`loop.prompt` is required and must be a string')
163
- }
164
- if (loop.max_iterations !== undefined) {
165
- const mi = Number(loop.max_iterations)
166
- if (!Number.isInteger(mi) || mi < 1) {
167
- errors.push('`loop.max_iterations` must be an integer >= 1')
168
- }
169
- }
170
- if (loop.timeout !== undefined) {
171
- if (isNaN(parseTimeout(loop.timeout as string))) {
172
- errors.push(
173
- '`loop.timeout` must be in format: <number><s|m|h> (e.g. "10m")'
174
- )
175
- }
176
- }
177
- if (loop.backpressure !== undefined) {
178
- if (
179
- !Array.isArray(loop.backpressure) ||
180
- !(loop.backpressure as unknown[]).every((b) => typeof b === 'string')
181
- ) {
182
- errors.push('`loop.backpressure` must be an array of strings')
183
- }
184
- }
185
- if (loop.plan_file !== undefined && typeof loop.plan_file !== 'string') {
186
- errors.push('`loop.plan_file` must be a string')
187
- }
188
- if (loop.model !== undefined && typeof loop.model !== 'string') {
189
- errors.push('`loop.model` must be a string')
190
- }
191
- }
192
- } else {
193
- // Tasks mode — tasks array is required
194
- if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
195
- errors.push('`tasks` is required and must be a non-empty array')
196
- return { valid: false, errors }
197
- }
198
-
199
- const taskIds = new Set<string>()
200
- const tasks = s.tasks as RawTask[]
153
+ const taskIds = new Set<string>()
154
+ const tasks = s.tasks as RawTask[]
201
155
 
202
- for (let i = 0; i < tasks.length; i++) {
156
+ for (let i = 0; i < tasks.length; i++) {
203
157
  const task = tasks[i]
204
158
  const prefix = `tasks[${i}]`
205
159
 
206
160
  if (!task || typeof task !== 'object') {
207
161
  errors.push(`${prefix}: must be an object`)
208
- continue
209
- }
162
+ continue
163
+ }
210
164
 
211
- // id
212
- if (!task.id || typeof task.id !== 'string') {
213
- errors.push(`${prefix}: \`id\` is required and must be a string`)
214
- } else if (taskIds.has(task.id)) {
215
- errors.push(`${prefix}: duplicate task id "${task.id}"`)
216
- } else {
217
- taskIds.add(task.id)
218
- }
165
+ // id
166
+ if (!task.id || typeof task.id !== 'string') {
167
+ errors.push(`${prefix}: \`id\` is required and must be a string`)
168
+ } else if (taskIds.has(task.id)) {
169
+ errors.push(`${prefix}: duplicate task id "${task.id}"`)
170
+ } else {
171
+ taskIds.add(task.id)
172
+ }
219
173
 
220
- // prompt
221
- if (!task.prompt || typeof task.prompt !== 'string') {
222
- errors.push(`${prefix}: \`prompt\` is required and must be a string`)
223
- }
174
+ // prompt
175
+ if (!task.prompt || typeof task.prompt !== 'string') {
176
+ errors.push(`${prefix}: \`prompt\` is required and must be a string`)
177
+ }
224
178
 
225
- // timeout
226
- if (task.timeout !== undefined) {
227
- if (isNaN(parseTimeout(task.timeout as string))) {
228
- errors.push(
229
- `${prefix}: \`timeout\` must be in format: <number><s|m|h> (e.g. "10m")`
230
- )
231
- }
179
+ // timeout
180
+ if (task.timeout !== undefined) {
181
+ if (isNaN(parseTimeout(task.timeout as string))) {
182
+ errors.push(
183
+ `${prefix}: \`timeout\` must be in format: <number><s|m|h> (e.g. "10m")`
184
+ )
232
185
  }
186
+ }
233
187
 
234
- // depends_on
235
- if (task.depends_on !== undefined) {
236
- if (!Array.isArray(task.depends_on)) {
237
- errors.push(`${prefix}: \`depends_on\` must be an array`)
238
- } else {
239
- for (const dep of task.depends_on as string[]) {
240
- if (!taskIds.has(dep) && !tasks.some((t) => t && t.id === dep)) {
241
- errors.push(
242
- `${prefix}: \`depends_on\` references unknown task "${dep}"`
243
- )
244
- }
188
+ // depends_on
189
+ if (task.depends_on !== undefined) {
190
+ if (!Array.isArray(task.depends_on)) {
191
+ errors.push(`${prefix}: \`depends_on\` must be an array`)
192
+ } else {
193
+ for (const dep of task.depends_on as string[]) {
194
+ if (!taskIds.has(dep) && !tasks.some((t) => t && t.id === dep)) {
195
+ errors.push(
196
+ `${prefix}: \`depends_on\` references unknown task "${dep}"`
197
+ )
245
198
  }
246
199
  }
247
200
  }
201
+ }
248
202
 
249
- // files
250
- if (task.files !== undefined && !Array.isArray(task.files)) {
251
- errors.push(`${prefix}: \`files\` must be an array`)
252
- }
203
+ // files
204
+ if (task.files !== undefined && !Array.isArray(task.files)) {
205
+ errors.push(`${prefix}: \`files\` must be an array`)
206
+ }
253
207
 
254
- // model
255
- if (task.model !== undefined && typeof task.model !== 'string') {
256
- errors.push(`${prefix}: \`model\` must be a string`)
257
- }
208
+ // model
209
+ if (task.model !== undefined && typeof task.model !== 'string') {
210
+ errors.push(`${prefix}: \`model\` must be a string`)
211
+ }
258
212
 
259
- // max_retries
260
- if (task.max_retries !== undefined) {
261
- const mr = Number(task.max_retries)
262
- if (!Number.isInteger(mr) || mr < 0) {
263
- errors.push(
264
- `${prefix}: \`max_retries\` must be a non-negative integer`
265
- )
266
- }
213
+ // max_retries
214
+ if (task.max_retries !== undefined) {
215
+ const mr = Number(task.max_retries)
216
+ if (!Number.isInteger(mr) || mr < 0) {
217
+ errors.push(
218
+ `${prefix}: \`max_retries\` must be a non-negative integer`
219
+ )
267
220
  }
268
221
  }
222
+ }
269
223
 
270
- // DAG cycle detection
271
- if (errors.length === 0) {
272
- const cycleErr = detectCycles(tasks as Array<{ id: string; depends_on?: string[] }>)
273
- if (cycleErr) errors.push(cycleErr)
274
- }
224
+ // DAG cycle detection
225
+ if (errors.length === 0) {
226
+ const cycleErr = detectCycles(tasks as Array<{ id: string; depends_on?: string[] }>)
227
+ if (cycleErr) errors.push(cycleErr)
275
228
  }
276
229
 
277
230
  return { valid: errors.length === 0, errors }
@@ -330,39 +283,30 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
330
283
  s.on_failure = (s.on_failure as string) || 'continue'
331
284
  // Leave adapter empty so run.ts can auto-detect the best available CLI
332
285
  s.adapter = (s.adapter as string) || ''
333
- s.mode = (s.mode as string) || 'tasks'
334
-
335
- if (s.mode === 'loop') {
336
- const loop = ((s.loop ?? {}) as Record<string, unknown>)
337
- loop.max_iterations = loop.max_iterations !== undefined ? Number(loop.max_iterations) : 20
338
- loop.plan_file = (loop.plan_file as string) || 'IMPLEMENTATION_PLAN.md'
339
- loop.timeout = (loop.timeout as string) || '10m'
340
- s.loop = loop
341
- } else {
342
- const tasks = s.tasks as Array<Record<string, unknown>>
343
- const d =
344
- s.version === 1 && s.defaults
345
- ? (s.defaults as Record<string, unknown>)
346
- : {}
347
- for (const task of tasks) {
348
- task.agent =
349
- (task.agent as string) || (d.agent as string | undefined) || 'developer'
350
- task.timeout =
351
- (task.timeout as string) ||
352
- (d.timeout as string | undefined) ||
353
- '30m'
354
- task.depends_on = (task.depends_on as string[]) || []
355
- task.files = (task.files as string[]) || []
356
- task.description = (task.description as string) || (task.id as string)
357
- // model: task-level overrides defaults (no hardcoded fallback)
358
- if (task.model === undefined && d.model !== undefined) {
359
- task.model = d.model
360
- }
361
- // max_retries: task-level overrides defaults, fallback to 1
362
- if (task.max_retries === undefined) {
363
- task.max_retries =
364
- d.max_retries !== undefined ? Number(d.max_retries) : 1
365
- }
286
+
287
+ const tasks = s.tasks as Array<Record<string, unknown>>
288
+ const d =
289
+ s.version === 1 && s.defaults
290
+ ? (s.defaults as Record<string, unknown>)
291
+ : {}
292
+ for (const task of tasks) {
293
+ task.agent =
294
+ (task.agent as string) || (d.agent as string | undefined) || 'developer'
295
+ task.timeout =
296
+ (task.timeout as string) ||
297
+ (d.timeout as string | undefined) ||
298
+ '30m'
299
+ task.depends_on = (task.depends_on as string[]) || []
300
+ task.files = (task.files as string[]) || []
301
+ task.description = (task.description as string) || (task.id as string)
302
+ // model: task-level overrides defaults (no hardcoded fallback)
303
+ if (task.model === undefined && d.model !== undefined) {
304
+ task.model = d.model
305
+ }
306
+ // max_retries: task-level overrides defaults, fallback to 1
307
+ if (task.max_retries === undefined) {
308
+ task.max_retries =
309
+ d.max_retries !== undefined ? Number(d.max_retries) : 1
366
310
  }
367
311
  }
368
312
 
@@ -378,21 +322,10 @@ export function isConvoySpec(spec: unknown): boolean {
378
322
  }
379
323
 
380
324
  /**
381
- * Read, parse, validate, and return a typed task spec from a YAML file.
382
- * @throws If file cannot be read, parsed, or spec is invalid
325
+ * Parse, validate, and return a typed task spec from a YAML string.
326
+ * @throws If the text is empty, cannot be parsed, or spec is invalid
383
327
  */
384
- export async function parseTaskSpec(filePath: string): Promise<TaskSpec> {
385
- let text: string
386
- try {
387
- text = await readFile(filePath, 'utf8')
388
- } catch (err: unknown) {
389
- const e = err as Error & { code?: string }
390
- if (e.code === 'ENOENT') {
391
- throw new Error(`Task spec file not found: ${filePath}`)
392
- }
393
- throw new Error(`Cannot read task spec file: ${e.message}`)
394
- }
395
-
328
+ export function parseTaskSpecText(text: string): TaskSpec {
396
329
  if (!text.trim()) {
397
330
  throw new Error('Task spec file is empty')
398
331
  }
@@ -411,3 +344,22 @@ export async function parseTaskSpec(filePath: string): Promise<TaskSpec> {
411
344
 
412
345
  return applyDefaults(spec)
413
346
  }
347
+
348
+ /**
349
+ * Read, parse, validate, and return a typed task spec from a YAML file.
350
+ * @throws If file cannot be read, parsed, or spec is invalid
351
+ */
352
+ export async function parseTaskSpec(filePath: string): Promise<TaskSpec> {
353
+ let text: string
354
+ try {
355
+ text = await readFile(filePath, 'utf8')
356
+ } catch (err: unknown) {
357
+ const e = err as Error & { code?: string }
358
+ if (e.code === 'ENOENT') {
359
+ throw new Error(`Task spec file not found: ${filePath}`)
360
+ }
361
+ throw new Error(`Cannot read task spec file: ${e.message}`)
362
+ }
363
+
364
+ return parseTaskSpecText(text)
365
+ }