opencastle 0.11.0 → 0.12.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 (45) hide show
  1. package/dist/cli/convoy/events.d.ts +10 -0
  2. package/dist/cli/convoy/events.d.ts.map +1 -0
  3. package/dist/cli/convoy/events.js +27 -0
  4. package/dist/cli/convoy/events.js.map +1 -0
  5. package/dist/cli/convoy/events.test.d.ts +2 -0
  6. package/dist/cli/convoy/events.test.d.ts.map +1 -0
  7. package/dist/cli/convoy/events.test.js +94 -0
  8. package/dist/cli/convoy/events.test.js.map +1 -0
  9. package/dist/cli/convoy/store.d.ts +23 -0
  10. package/dist/cli/convoy/store.d.ts.map +1 -0
  11. package/dist/cli/convoy/store.js +210 -0
  12. package/dist/cli/convoy/store.js.map +1 -0
  13. package/dist/cli/convoy/store.test.d.ts +2 -0
  14. package/dist/cli/convoy/store.test.d.ts.map +1 -0
  15. package/dist/cli/convoy/store.test.js +387 -0
  16. package/dist/cli/convoy/store.test.js.map +1 -0
  17. package/dist/cli/convoy/types.d.ts +56 -0
  18. package/dist/cli/convoy/types.d.ts.map +1 -0
  19. package/dist/cli/convoy/types.js +2 -0
  20. package/dist/cli/convoy/types.js.map +1 -0
  21. package/dist/cli/run/executor.test.js +1 -0
  22. package/dist/cli/run/executor.test.js.map +1 -1
  23. package/dist/cli/run/loop-executor.d.ts.map +1 -1
  24. package/dist/cli/run/loop-executor.js +1 -0
  25. package/dist/cli/run/loop-executor.js.map +1 -1
  26. package/dist/cli/run/schema.d.ts +4 -0
  27. package/dist/cli/run/schema.d.ts.map +1 -1
  28. package/dist/cli/run/schema.js +78 -2
  29. package/dist/cli/run/schema.js.map +1 -1
  30. package/dist/cli/run/schema.test.js +384 -1
  31. package/dist/cli/run/schema.test.js.map +1 -1
  32. package/dist/cli/types.d.ts +19 -0
  33. package/dist/cli/types.d.ts.map +1 -1
  34. package/package.json +3 -2
  35. package/src/cli/convoy/events.test.ts +118 -0
  36. package/src/cli/convoy/events.ts +41 -0
  37. package/src/cli/convoy/store.test.ts +446 -0
  38. package/src/cli/convoy/store.ts +308 -0
  39. package/src/cli/convoy/types.ts +68 -0
  40. package/src/cli/run/executor.test.ts +1 -0
  41. package/src/cli/run/loop-executor.ts +1 -0
  42. package/src/cli/run/schema.test.ts +462 -1
  43. package/src/cli/run/schema.ts +96 -2
  44. package/src/cli/types.ts +20 -0
  45. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseYaml, parseTimeout, validateSpec, applyDefaults } from './schema.js'
2
+ import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec } from './schema.js'
3
3
 
4
4
  // ── parseYaml ──────────────────────────────────────────────────
5
5
 
@@ -590,3 +590,464 @@ describe('applyDefaults — loop mode', () => {
590
590
  expect(spec.mode).toBe('loop')
591
591
  })
592
592
  })
593
+
594
+ // ── validateSpec — version field ───────────────────────────────
595
+
596
+ describe('validateSpec — version field', () => {
597
+ const validSpec = {
598
+ name: 'test-run',
599
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
600
+ }
601
+
602
+ it('accepts version 1', () => {
603
+ const result = validateSpec({ ...validSpec, version: 1 })
604
+ expect(result.valid).toBe(true)
605
+ expect(result.errors).toHaveLength(0)
606
+ })
607
+
608
+ it('rejects version 2', () => {
609
+ const result = validateSpec({ ...validSpec, version: 2 })
610
+ expect(result.valid).toBe(false)
611
+ expect(result.errors).toContainEqual(expect.stringContaining('version'))
612
+ })
613
+
614
+ it('rejects non-integer version', () => {
615
+ const result = validateSpec({ ...validSpec, version: 1.5 })
616
+ expect(result.valid).toBe(false)
617
+ expect(result.errors).toContainEqual(expect.stringContaining('version'))
618
+ })
619
+
620
+ it('rejects string version', () => {
621
+ const result = validateSpec({ ...validSpec, version: '1' })
622
+ expect(result.valid).toBe(false)
623
+ expect(result.errors).toContainEqual(expect.stringContaining('version'))
624
+ })
625
+
626
+ it('omitting version is valid (legacy spec)', () => {
627
+ const result = validateSpec(validSpec)
628
+ expect(result.valid).toBe(true)
629
+ })
630
+ })
631
+
632
+ // ── validateSpec — defaults block ──────────────────────────────
633
+
634
+ describe('validateSpec — defaults block', () => {
635
+ const validSpec = {
636
+ name: 'test-run',
637
+ version: 1,
638
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
639
+ }
640
+
641
+ it('accepts a fully specified defaults block', () => {
642
+ const result = validateSpec({
643
+ ...validSpec,
644
+ defaults: { timeout: '10m', model: 'gpt-4', max_retries: 2, agent: 'developer' },
645
+ })
646
+ expect(result.valid).toBe(true)
647
+ })
648
+
649
+ it('accepts partial defaults block', () => {
650
+ const result = validateSpec({ ...validSpec, defaults: { timeout: '5m' } })
651
+ expect(result.valid).toBe(true)
652
+ })
653
+
654
+ it('rejects defaults as a non-object', () => {
655
+ const result = validateSpec({ ...validSpec, defaults: 'invalid' })
656
+ expect(result.valid).toBe(false)
657
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults'))
658
+ })
659
+
660
+ it('rejects defaults as an array', () => {
661
+ const result = validateSpec({ ...validSpec, defaults: ['timeout', '10m'] })
662
+ expect(result.valid).toBe(false)
663
+ expect(result.errors).toContainEqual(expect.stringContaining('defaults'))
664
+ })
665
+
666
+ it('rejects defaults with invalid timeout format', () => {
667
+ const result = validateSpec({ ...validSpec, defaults: { timeout: 'bad' } })
668
+ expect(result.valid).toBe(false)
669
+ expect(result.errors).toContainEqual(
670
+ expect.stringContaining('defaults.timeout')
671
+ )
672
+ })
673
+
674
+ it('rejects defaults with non-string model', () => {
675
+ const result = validateSpec({ ...validSpec, defaults: { model: 42 } })
676
+ expect(result.valid).toBe(false)
677
+ expect(result.errors).toContainEqual(
678
+ expect.stringContaining('defaults.model')
679
+ )
680
+ })
681
+
682
+ it('rejects defaults with negative max_retries', () => {
683
+ const result = validateSpec({ ...validSpec, defaults: { max_retries: -1 } })
684
+ expect(result.valid).toBe(false)
685
+ expect(result.errors).toContainEqual(
686
+ expect.stringContaining('defaults.max_retries')
687
+ )
688
+ })
689
+
690
+ it('rejects defaults with non-integer max_retries', () => {
691
+ const result = validateSpec({ ...validSpec, defaults: { max_retries: 1.5 } })
692
+ expect(result.valid).toBe(false)
693
+ expect(result.errors).toContainEqual(
694
+ expect.stringContaining('defaults.max_retries')
695
+ )
696
+ })
697
+
698
+ it('accepts defaults.max_retries of 0', () => {
699
+ const result = validateSpec({ ...validSpec, defaults: { max_retries: 0 } })
700
+ expect(result.valid).toBe(true)
701
+ })
702
+
703
+ it('rejects defaults with non-string agent', () => {
704
+ const result = validateSpec({ ...validSpec, defaults: { agent: 99 } })
705
+ expect(result.valid).toBe(false)
706
+ expect(result.errors).toContainEqual(
707
+ expect.stringContaining('defaults.agent')
708
+ )
709
+ })
710
+ })
711
+
712
+ // ── validateSpec — gates field ─────────────────────────────────
713
+
714
+ describe('validateSpec — gates field', () => {
715
+ const validSpec = {
716
+ name: 'test-run',
717
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
718
+ }
719
+
720
+ it('accepts a valid gates array', () => {
721
+ const result = validateSpec({
722
+ ...validSpec,
723
+ gates: ['npm test', 'npx tsc --noEmit'],
724
+ })
725
+ expect(result.valid).toBe(true)
726
+ })
727
+
728
+ it('accepts an empty gates array', () => {
729
+ const result = validateSpec({ ...validSpec, gates: [] })
730
+ expect(result.valid).toBe(true)
731
+ })
732
+
733
+ it('rejects gates as a string', () => {
734
+ const result = validateSpec({ ...validSpec, gates: 'npm test' })
735
+ expect(result.valid).toBe(false)
736
+ expect(result.errors).toContainEqual(expect.stringContaining('gates'))
737
+ })
738
+
739
+ it('rejects gates with non-string items', () => {
740
+ const result = validateSpec({ ...validSpec, gates: ['npm test', 42] })
741
+ expect(result.valid).toBe(false)
742
+ expect(result.errors).toContainEqual(expect.stringContaining('gates'))
743
+ })
744
+ })
745
+
746
+ // ── validateSpec — branch field ────────────────────────────────
747
+
748
+ describe('validateSpec — branch field', () => {
749
+ const validSpec = {
750
+ name: 'test-run',
751
+ tasks: [{ id: 'task-1', prompt: 'Do something' }],
752
+ }
753
+
754
+ it('accepts a valid branch string', () => {
755
+ const result = validateSpec({ ...validSpec, branch: 'feat/my-feature' })
756
+ expect(result.valid).toBe(true)
757
+ })
758
+
759
+ it('rejects non-string branch', () => {
760
+ const result = validateSpec({ ...validSpec, branch: 123 })
761
+ expect(result.valid).toBe(false)
762
+ expect(result.errors).toContainEqual(expect.stringContaining('branch'))
763
+ })
764
+
765
+ it('rejects boolean branch', () => {
766
+ const result = validateSpec({ ...validSpec, branch: true })
767
+ expect(result.valid).toBe(false)
768
+ expect(result.errors).toContainEqual(expect.stringContaining('branch'))
769
+ })
770
+ })
771
+
772
+ // ── validateSpec — per-task model and max_retries ──────────────
773
+
774
+ describe('validateSpec — per-task model and max_retries', () => {
775
+ it('accepts valid task model string', () => {
776
+ const result = validateSpec({
777
+ name: 'test',
778
+ tasks: [{ id: 'a', prompt: 'x', model: 'claude-3.5-sonnet' }],
779
+ })
780
+ expect(result.valid).toBe(true)
781
+ })
782
+
783
+ it('rejects non-string task model', () => {
784
+ const result = validateSpec({
785
+ name: 'test',
786
+ tasks: [{ id: 'a', prompt: 'x', model: 123 }],
787
+ })
788
+ expect(result.valid).toBe(false)
789
+ expect(result.errors).toContainEqual(expect.stringContaining('model'))
790
+ })
791
+
792
+ it('accepts valid task max_retries', () => {
793
+ const result = validateSpec({
794
+ name: 'test',
795
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 3 }],
796
+ })
797
+ expect(result.valid).toBe(true)
798
+ })
799
+
800
+ it('accepts zero max_retries', () => {
801
+ const result = validateSpec({
802
+ name: 'test',
803
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 0 }],
804
+ })
805
+ expect(result.valid).toBe(true)
806
+ })
807
+
808
+ it('rejects negative max_retries', () => {
809
+ const result = validateSpec({
810
+ name: 'test',
811
+ tasks: [{ id: 'a', prompt: 'x', max_retries: -1 }],
812
+ })
813
+ expect(result.valid).toBe(false)
814
+ expect(result.errors).toContainEqual(expect.stringContaining('max_retries'))
815
+ })
816
+
817
+ it('rejects non-integer max_retries', () => {
818
+ const result = validateSpec({
819
+ name: 'test',
820
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 1.5 }],
821
+ })
822
+ expect(result.valid).toBe(false)
823
+ expect(result.errors).toContainEqual(expect.stringContaining('max_retries'))
824
+ })
825
+ })
826
+
827
+ // ── isConvoySpec ───────────────────────────────────────────────
828
+
829
+ describe('isConvoySpec', () => {
830
+ it('returns true for version 1 spec', () => {
831
+ expect(
832
+ isConvoySpec({ name: 'test', version: 1, tasks: [{ id: 'a', prompt: 'x' }] })
833
+ ).toBe(true)
834
+ })
835
+
836
+ it('returns false for legacy spec without version', () => {
837
+ expect(
838
+ isConvoySpec({ name: 'test', tasks: [{ id: 'a', prompt: 'x' }] })
839
+ ).toBe(false)
840
+ })
841
+
842
+ it('returns false for version 2', () => {
843
+ expect(isConvoySpec({ name: 'test', version: 2 })).toBe(false)
844
+ })
845
+
846
+ it('returns false for null input', () => {
847
+ expect(isConvoySpec(null)).toBe(false)
848
+ })
849
+
850
+ it('returns false for non-object input', () => {
851
+ expect(isConvoySpec('string')).toBe(false)
852
+ })
853
+
854
+ it('returns false for undefined', () => {
855
+ expect(isConvoySpec(undefined)).toBe(false)
856
+ })
857
+ })
858
+
859
+ // ── applyDefaults — convoy spec (version: 1) ───────────────────
860
+
861
+ describe('applyDefaults — convoy spec (version: 1)', () => {
862
+ it('merges defaults.agent into tasks when not specified', () => {
863
+ const spec = applyDefaults({
864
+ name: 'test',
865
+ version: 1,
866
+ defaults: { agent: 'ui-ux-expert' },
867
+ tasks: [{ id: 'a', prompt: 'x' }],
868
+ })
869
+ expect(spec.tasks![0].agent).toBe('ui-ux-expert')
870
+ })
871
+
872
+ it('task-level agent overrides default', () => {
873
+ const spec = applyDefaults({
874
+ name: 'test',
875
+ version: 1,
876
+ defaults: { agent: 'ui-ux-expert' },
877
+ tasks: [{ id: 'a', prompt: 'x', agent: 'api-designer' }],
878
+ })
879
+ expect(spec.tasks![0].agent).toBe('api-designer')
880
+ })
881
+
882
+ it('merges defaults.timeout into tasks', () => {
883
+ const spec = applyDefaults({
884
+ name: 'test',
885
+ version: 1,
886
+ defaults: { timeout: '15m' },
887
+ tasks: [{ id: 'a', prompt: 'x' }],
888
+ })
889
+ expect(spec.tasks![0].timeout).toBe('15m')
890
+ })
891
+
892
+ it('task-level timeout overrides defaults.timeout', () => {
893
+ const spec = applyDefaults({
894
+ name: 'test',
895
+ version: 1,
896
+ defaults: { timeout: '15m' },
897
+ tasks: [{ id: 'a', prompt: 'x', timeout: '5m' }],
898
+ })
899
+ expect(spec.tasks![0].timeout).toBe('5m')
900
+ })
901
+
902
+ it('merges defaults.model into tasks', () => {
903
+ const spec = applyDefaults({
904
+ name: 'test',
905
+ version: 1,
906
+ defaults: { model: 'gpt-4' },
907
+ tasks: [{ id: 'a', prompt: 'x' }],
908
+ })
909
+ expect(spec.tasks![0].model).toBe('gpt-4')
910
+ })
911
+
912
+ it('task-level model overrides defaults.model', () => {
913
+ const spec = applyDefaults({
914
+ name: 'test',
915
+ version: 1,
916
+ defaults: { model: 'gpt-4' },
917
+ tasks: [{ id: 'a', prompt: 'x', model: 'claude-3.5-sonnet' }],
918
+ })
919
+ expect(spec.tasks![0].model).toBe('claude-3.5-sonnet')
920
+ })
921
+
922
+ it('no model set when no defaults.model and no task model', () => {
923
+ const spec = applyDefaults({
924
+ name: 'test',
925
+ version: 1,
926
+ tasks: [{ id: 'a', prompt: 'x' }],
927
+ })
928
+ expect(spec.tasks![0].model).toBeUndefined()
929
+ })
930
+
931
+ it('merges defaults.max_retries into tasks', () => {
932
+ const spec = applyDefaults({
933
+ name: 'test',
934
+ version: 1,
935
+ defaults: { max_retries: 3 },
936
+ tasks: [{ id: 'a', prompt: 'x' }],
937
+ })
938
+ expect(spec.tasks![0].max_retries).toBe(3)
939
+ })
940
+
941
+ it('task-level max_retries overrides defaults.max_retries', () => {
942
+ const spec = applyDefaults({
943
+ name: 'test',
944
+ version: 1,
945
+ defaults: { max_retries: 3 },
946
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 0 }],
947
+ })
948
+ expect(spec.tasks![0].max_retries).toBe(0)
949
+ })
950
+
951
+ it('propagates version and defaults fields through to spec', () => {
952
+ const spec = applyDefaults({
953
+ name: 'test',
954
+ version: 1,
955
+ defaults: { model: 'gpt-4' },
956
+ gates: ['npm test'],
957
+ branch: 'feat/convoy',
958
+ tasks: [{ id: 'a', prompt: 'x' }],
959
+ })
960
+ expect(spec.version).toBe(1)
961
+ expect(spec.gates).toEqual(['npm test'])
962
+ expect(spec.branch).toBe('feat/convoy')
963
+ })
964
+ })
965
+
966
+ // ── applyDefaults — max_retries default always applied ─────────
967
+
968
+ describe('applyDefaults — max_retries always applied', () => {
969
+ it('applies max_retries default of 1 for legacy spec', () => {
970
+ const spec = applyDefaults({
971
+ name: 'test',
972
+ tasks: [{ id: 'a', prompt: 'x' }],
973
+ })
974
+ expect(spec.tasks![0].max_retries).toBe(1)
975
+ })
976
+
977
+ it('applies max_retries default of 1 when version:1 has no defaults block', () => {
978
+ const spec = applyDefaults({
979
+ name: 'test',
980
+ version: 1,
981
+ tasks: [{ id: 'a', prompt: 'x' }],
982
+ })
983
+ expect(spec.tasks![0].max_retries).toBe(1)
984
+ })
985
+
986
+ it('preserves explicit task max_retries in legacy spec', () => {
987
+ const spec = applyDefaults({
988
+ name: 'test',
989
+ tasks: [{ id: 'a', prompt: 'x', max_retries: 5 }],
990
+ })
991
+ expect(spec.tasks![0].max_retries).toBe(5)
992
+ })
993
+ })
994
+
995
+ // ── backward compatibility ─────────────────────────────────────
996
+
997
+ describe('backward compatibility — legacy specs', () => {
998
+ it('legacy spec validates identically without version field', () => {
999
+ const result = validateSpec({
1000
+ name: 'test-run',
1001
+ tasks: [
1002
+ { id: 'task-1', prompt: 'Do something' },
1003
+ { id: 'task-2', prompt: 'Do another thing', depends_on: ['task-1'] },
1004
+ ],
1005
+ })
1006
+ expect(result.valid).toBe(true)
1007
+ expect(result.errors).toHaveLength(0)
1008
+ })
1009
+
1010
+ it('legacy spec applyDefaults produces same agent/timeout/depends_on/files as before', () => {
1011
+ const spec = applyDefaults({
1012
+ name: 'test',
1013
+ tasks: [{ id: 'a', prompt: 'x' }],
1014
+ })
1015
+ const task = spec.tasks![0]
1016
+ expect(task.agent).toBe('developer')
1017
+ expect(task.timeout).toBe('30m')
1018
+ expect(task.depends_on).toEqual([])
1019
+ expect(task.files).toEqual([])
1020
+ })
1021
+
1022
+ it('user-specified legacy task values are preserved', () => {
1023
+ const spec = applyDefaults({
1024
+ name: 'test',
1025
+ tasks: [{
1026
+ id: 'a',
1027
+ prompt: 'x',
1028
+ agent: 'ui-ux-expert',
1029
+ timeout: '5m',
1030
+ depends_on: ['b'],
1031
+ files: ['src/'],
1032
+ }],
1033
+ })
1034
+ const task = spec.tasks![0]
1035
+ expect(task.agent).toBe('ui-ux-expert')
1036
+ expect(task.timeout).toBe('5m')
1037
+ expect(task.depends_on).toEqual(['b'])
1038
+ expect(task.files).toEqual(['src/'])
1039
+ })
1040
+
1041
+ it('defaults block is ignored without version:1', () => {
1042
+ // Without version:1, the defaults block should not be merged
1043
+ const spec = applyDefaults({
1044
+ name: 'test',
1045
+ defaults: { agent: 'ui-ux-expert', model: 'gpt-4' },
1046
+ tasks: [{ id: 'a', prompt: 'x' }],
1047
+ })
1048
+ // agent falls back to hardcoded 'developer', not defaults.agent
1049
+ expect(spec.tasks![0].agent).toBe('developer')
1050
+ // model is not set
1051
+ expect(spec.tasks![0].model).toBeUndefined()
1052
+ })
1053
+ })
@@ -41,6 +41,10 @@ interface RawSpec {
41
41
  tasks?: unknown
42
42
  mode?: unknown
43
43
  loop?: unknown
44
+ version?: unknown
45
+ defaults?: unknown
46
+ gates?: unknown
47
+ branch?: unknown
44
48
  }
45
49
 
46
50
  interface RawTask {
@@ -51,6 +55,8 @@ interface RawTask {
51
55
  depends_on?: unknown
52
56
  files?: unknown
53
57
  description?: unknown
58
+ model?: unknown
59
+ max_retries?: unknown
54
60
  }
55
61
 
56
62
  /**
@@ -92,6 +98,54 @@ export function validateSpec(spec: unknown): ValidationResult {
92
98
  errors.push('`adapter` must be a string')
93
99
  }
94
100
 
101
+ // version
102
+ if (s.version !== undefined) {
103
+ if (typeof s.version !== 'number' || !Number.isInteger(s.version) || s.version !== 1) {
104
+ errors.push('`version` must be 1')
105
+ }
106
+ }
107
+
108
+ // defaults
109
+ if (s.defaults !== undefined) {
110
+ if (!s.defaults || typeof s.defaults !== 'object' || Array.isArray(s.defaults)) {
111
+ errors.push('`defaults` must be an object')
112
+ } else {
113
+ const d = s.defaults as Record<string, unknown>
114
+ if (d.timeout !== undefined && isNaN(parseTimeout(d.timeout as string))) {
115
+ errors.push(
116
+ '`defaults.timeout` must be in format: <number><s|m|h> (e.g. "10m")'
117
+ )
118
+ }
119
+ if (d.model !== undefined && typeof d.model !== 'string') {
120
+ errors.push('`defaults.model` must be a string')
121
+ }
122
+ if (d.max_retries !== undefined) {
123
+ const mr = Number(d.max_retries)
124
+ if (!Number.isInteger(mr) || mr < 0) {
125
+ errors.push('`defaults.max_retries` must be a non-negative integer')
126
+ }
127
+ }
128
+ if (d.agent !== undefined && typeof d.agent !== 'string') {
129
+ errors.push('`defaults.agent` must be a string')
130
+ }
131
+ }
132
+ }
133
+
134
+ // gates
135
+ if (s.gates !== undefined) {
136
+ if (
137
+ !Array.isArray(s.gates) ||
138
+ !(s.gates as unknown[]).every((g) => typeof g === 'string')
139
+ ) {
140
+ errors.push('`gates` must be an array of strings')
141
+ }
142
+ }
143
+
144
+ // branch
145
+ if (s.branch !== undefined && typeof s.branch !== 'string') {
146
+ errors.push('`branch` must be a string')
147
+ }
148
+
95
149
  // mode
96
150
  const mode = s.mode !== undefined ? s.mode : 'tasks'
97
151
  if (mode !== 'tasks' && mode !== 'loop') {
@@ -196,6 +250,21 @@ export function validateSpec(spec: unknown): ValidationResult {
196
250
  if (task.files !== undefined && !Array.isArray(task.files)) {
197
251
  errors.push(`${prefix}: \`files\` must be an array`)
198
252
  }
253
+
254
+ // model
255
+ if (task.model !== undefined && typeof task.model !== 'string') {
256
+ errors.push(`${prefix}: \`model\` must be a string`)
257
+ }
258
+
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
+ }
267
+ }
199
268
  }
200
269
 
201
270
  // DAG cycle detection
@@ -271,18 +340,43 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
271
340
  s.loop = loop
272
341
  } else {
273
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
+ : {}
274
347
  for (const task of tasks) {
275
- task.agent = (task.agent as string) || 'developer'
276
- task.timeout = (task.timeout as string) || '30m'
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'
277
354
  task.depends_on = (task.depends_on as string[]) || []
278
355
  task.files = (task.files as string[]) || []
279
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
+ }
280
366
  }
281
367
  }
282
368
 
283
369
  return s as unknown as TaskSpec
284
370
  }
285
371
 
372
+ /**
373
+ * Returns true if the spec uses the Convoy Engine enhanced format (version: 1).
374
+ */
375
+ export function isConvoySpec(spec: unknown): boolean {
376
+ if (!spec || typeof spec !== 'object') return false
377
+ return (spec as Record<string, unknown>).version === 1
378
+ }
379
+
286
380
  /**
287
381
  * Read, parse, validate, and return a typed task spec from a YAML file.
288
382
  * @throws If file cannot be read, parsed, or spec is invalid
package/src/cli/types.ts CHANGED
@@ -126,6 +126,14 @@ export const IDE_LABELS: Record<IdeChoice, string> = {
126
126
 
127
127
  // ── Run command types ──────────────────────────────────────────
128
128
 
129
+ /** Default values merged into each task for Convoy Engine (version: 1) specs. */
130
+ export interface TaskDefaults {
131
+ timeout?: string;
132
+ model?: string;
133
+ max_retries?: number;
134
+ agent?: string;
135
+ }
136
+
129
137
  /** Loop execution configuration. */
130
138
  export interface LoopConfig {
131
139
  /** Maximum number of agent iterations (default 20). */
@@ -152,6 +160,14 @@ export interface TaskSpec {
152
160
  mode?: 'tasks' | 'loop';
153
161
  loop?: LoopConfig;
154
162
  _verbose?: boolean;
163
+ /** Spec schema version (1 for Convoy Engine format). */
164
+ version?: number;
165
+ /** Worker defaults merged into each task (Convoy Engine). */
166
+ defaults?: TaskDefaults;
167
+ /** Shell commands run after all tasks complete; each must exit 0. */
168
+ gates?: string[];
169
+ /** Git feature branch name. */
170
+ branch?: string;
155
171
  }
156
172
 
157
173
  /** A single task in the spec. */
@@ -164,6 +180,10 @@ export interface Task {
164
180
  files: string[];
165
181
  description: string;
166
182
  _process?: ChildProcess;
183
+ /** Model override for this task. */
184
+ model?: string;
185
+ /** Max retry attempts (default: 1). */
186
+ max_retries: number;
167
187
  }
168
188
 
169
189
  /** Task execution status. */
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "87401706",
2
+ "hash": "7398c476",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "56be8082",
5
- "browserHash": "8ccc6e37",
4
+ "lockfileHash": "35ec1b3d",
5
+ "browserHash": "6a8b9ff7",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "7e157996",
10
+ "fileHash": "ca3e0c44",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "021f979d",
16
+ "fileHash": "9cd2129a",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "f1a04609",
22
+ "fileHash": "cba29d71",
23
23
  "needsInterop": true
24
24
  }
25
25
  },