opencastle 0.22.0 → 0.23.1

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 (63) hide show
  1. package/dist/cli/convoy/engine.d.ts +1 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +1 -0
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/export.d.ts +1 -0
  6. package/dist/cli/convoy/export.d.ts.map +1 -1
  7. package/dist/cli/convoy/export.js +34 -0
  8. package/dist/cli/convoy/export.js.map +1 -1
  9. package/dist/cli/convoy/pipeline.d.ts +35 -0
  10. package/dist/cli/convoy/pipeline.d.ts.map +1 -0
  11. package/dist/cli/convoy/pipeline.js +353 -0
  12. package/dist/cli/convoy/pipeline.js.map +1 -0
  13. package/dist/cli/convoy/pipeline.test.d.ts +2 -0
  14. package/dist/cli/convoy/pipeline.test.d.ts.map +1 -0
  15. package/dist/cli/convoy/pipeline.test.js +778 -0
  16. package/dist/cli/convoy/pipeline.test.js.map +1 -0
  17. package/dist/cli/convoy/store.d.ts +14 -2
  18. package/dist/cli/convoy/store.d.ts.map +1 -1
  19. package/dist/cli/convoy/store.js +84 -5
  20. package/dist/cli/convoy/store.js.map +1 -1
  21. package/dist/cli/convoy/store.test.js +216 -7
  22. package/dist/cli/convoy/store.test.js.map +1 -1
  23. package/dist/cli/convoy/types.d.ts +15 -0
  24. package/dist/cli/convoy/types.d.ts.map +1 -1
  25. package/dist/cli/dashboard.d.ts.map +1 -1
  26. package/dist/cli/dashboard.js +1 -0
  27. package/dist/cli/dashboard.js.map +1 -1
  28. package/dist/cli/init.d.ts.map +1 -1
  29. package/dist/cli/init.js +8 -1
  30. package/dist/cli/init.js.map +1 -1
  31. package/dist/cli/run/schema.d.ts +5 -1
  32. package/dist/cli/run/schema.d.ts.map +1 -1
  33. package/dist/cli/run/schema.js +41 -8
  34. package/dist/cli/run/schema.js.map +1 -1
  35. package/dist/cli/run/schema.test.js +194 -5
  36. package/dist/cli/run/schema.test.js.map +1 -1
  37. package/dist/cli/run.d.ts.map +1 -1
  38. package/dist/cli/run.js +143 -3
  39. package/dist/cli/run.js.map +1 -1
  40. package/dist/cli/types.d.ts +3 -1
  41. package/dist/cli/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/cli/convoy/engine.ts +2 -0
  44. package/src/cli/convoy/export.ts +41 -0
  45. package/src/cli/convoy/pipeline.test.ts +939 -0
  46. package/src/cli/convoy/pipeline.ts +430 -0
  47. package/src/cli/convoy/store.test.ts +239 -7
  48. package/src/cli/convoy/store.ts +110 -7
  49. package/src/cli/convoy/types.ts +17 -0
  50. package/src/cli/dashboard.ts +1 -0
  51. package/src/cli/init.ts +9 -1
  52. package/src/cli/run/schema.test.ts +244 -5
  53. package/src/cli/run/schema.ts +49 -8
  54. package/src/cli/run.ts +142 -3
  55. package/src/cli/types.ts +3 -1
  56. package/src/dashboard/dist/_astro/{index.DyyaCW8L.css → index.Cq68OHaZ.css} +1 -1
  57. package/src/dashboard/dist/index.html +214 -2
  58. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  59. package/src/dashboard/src/pages/index.astro +230 -1
  60. package/src/dashboard/src/styles/dashboard.css +116 -0
  61. package/src/orchestrator/customizations/KNOWN-ISSUES.md +1 -1
  62. package/src/orchestrator/skills/decomposition/SKILL.md +1 -0
  63. package/src/orchestrator/skills/orchestration-protocols/SKILL.md +32 -1
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec, parseTaskSpecText } from './schema.js'
2
+ import { parseYaml, parseTimeout, validateSpec, applyDefaults, isConvoySpec, isPipelineSpec, parseTaskSpecText } from './schema.js'
3
3
 
4
4
  // ── parseYaml ──────────────────────────────────────────────────
5
5
 
@@ -365,12 +365,18 @@ describe('validateSpec — version field', () => {
365
365
  expect(result.errors).toHaveLength(0)
366
366
  })
367
367
 
368
- it('rejects version 2', () => {
369
- const result = validateSpec({ ...validSpec, version: 2 })
368
+ it('rejects version 3', () => {
369
+ const result = validateSpec({ ...validSpec, version: 3 })
370
370
  expect(result.valid).toBe(false)
371
371
  expect(result.errors).toContainEqual(expect.stringContaining('version'))
372
372
  })
373
373
 
374
+ it('accepts version 2', () => {
375
+ const result = validateSpec({ ...validSpec, version: 2 })
376
+ expect(result.valid).toBe(true)
377
+ expect(result.errors).toHaveLength(0)
378
+ })
379
+
374
380
  it('rejects non-integer version', () => {
375
381
  const result = validateSpec({ ...validSpec, version: 1.5 })
376
382
  expect(result.valid).toBe(false)
@@ -614,14 +620,20 @@ describe('isConvoySpec', () => {
614
620
  ).toBe(true)
615
621
  })
616
622
 
623
+ it('returns true for version 2 spec', () => {
624
+ expect(
625
+ isConvoySpec({ name: 'test', version: 2, depends_on_convoy: ['other-convoy'] })
626
+ ).toBe(true)
627
+ })
628
+
617
629
  it('returns false for legacy spec without version', () => {
618
630
  expect(
619
631
  isConvoySpec({ name: 'test', tasks: [{ id: 'a', prompt: 'x' }] })
620
632
  ).toBe(false)
621
633
  })
622
634
 
623
- it('returns false for version 2', () => {
624
- expect(isConvoySpec({ name: 'test', version: 2 })).toBe(false)
635
+ it('returns false for version 3', () => {
636
+ expect(isConvoySpec({ name: 'test', version: 3 })).toBe(false)
625
637
  })
626
638
 
627
639
  it('returns false for null input', () => {
@@ -921,3 +933,230 @@ tasks:
921
933
  expect(isConvoySpec(spec)).toBe(true)
922
934
  })
923
935
  })
936
+
937
+ // ── validateSpec — depends_on_convoy / pipeline specs ──────────
938
+
939
+ describe('validateSpec — depends_on_convoy field', () => {
940
+ it('accepts depends_on_convoy as an array of strings', () => {
941
+ const result = validateSpec({
942
+ name: 'pipeline',
943
+ version: 2,
944
+ depends_on_convoy: ['phase-1', 'phase-2'],
945
+ tasks: [{ id: 'a', prompt: 'x' }],
946
+ })
947
+ expect(result.valid).toBe(true)
948
+ expect(result.errors).toHaveLength(0)
949
+ })
950
+
951
+ it('rejects depends_on_convoy as a string (not array)', () => {
952
+ const result = validateSpec({
953
+ name: 'pipeline',
954
+ version: 2,
955
+ depends_on_convoy: 'phase-1',
956
+ tasks: [{ id: 'a', prompt: 'x' }],
957
+ })
958
+ expect(result.valid).toBe(false)
959
+ expect(result.errors).toContainEqual(expect.stringContaining('depends_on_convoy'))
960
+ })
961
+
962
+ it('rejects depends_on_convoy with non-string elements', () => {
963
+ const result = validateSpec({
964
+ name: 'pipeline',
965
+ version: 2,
966
+ depends_on_convoy: ['phase-1', 42],
967
+ tasks: [{ id: 'a', prompt: 'x' }],
968
+ })
969
+ expect(result.valid).toBe(false)
970
+ expect(result.errors).toContainEqual(expect.stringContaining('depends_on_convoy'))
971
+ })
972
+
973
+ it('rejects depends_on_convoy as a non-array object', () => {
974
+ const result = validateSpec({
975
+ name: 'pipeline',
976
+ version: 2,
977
+ depends_on_convoy: { convoy: 'phase-1' },
978
+ tasks: [{ id: 'a', prompt: 'x' }],
979
+ })
980
+ expect(result.valid).toBe(false)
981
+ expect(result.errors).toContainEqual(expect.stringContaining('depends_on_convoy'))
982
+ })
983
+
984
+ it('accepts omitting depends_on_convoy entirely (optional field)', () => {
985
+ const result = validateSpec({
986
+ name: 'convoy-run',
987
+ version: 1,
988
+ tasks: [{ id: 'a', prompt: 'x' }],
989
+ })
990
+ expect(result.valid).toBe(true)
991
+ })
992
+ })
993
+
994
+ // ── validateSpec — pipeline spec (v2 tasks-optional) ──────────
995
+
996
+ describe('validateSpec — pipeline spec (version:2, no tasks)', () => {
997
+ it('pipeline spec with no tasks is valid when depends_on_convoy is set', () => {
998
+ const result = validateSpec({
999
+ name: 'pipeline',
1000
+ version: 2,
1001
+ depends_on_convoy: ['phase-1'],
1002
+ })
1003
+ expect(result.valid).toBe(true)
1004
+ expect(result.errors).toHaveLength(0)
1005
+ })
1006
+
1007
+ it('pipeline spec with tasks AND depends_on_convoy is valid', () => {
1008
+ const result = validateSpec({
1009
+ name: 'pipeline',
1010
+ version: 2,
1011
+ depends_on_convoy: ['phase-1'],
1012
+ tasks: [{ id: 'a', prompt: 'do local work' }],
1013
+ })
1014
+ expect(result.valid).toBe(true)
1015
+ expect(result.errors).toHaveLength(0)
1016
+ })
1017
+
1018
+ it('version:2 without depends_on_convoy still requires tasks', () => {
1019
+ const result = validateSpec({
1020
+ name: 'convoy-v2',
1021
+ version: 2,
1022
+ })
1023
+ expect(result.valid).toBe(false)
1024
+ expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
1025
+ })
1026
+
1027
+ it('pipeline spec with empty depends_on_convoy still requires tasks', () => {
1028
+ const result = validateSpec({
1029
+ name: 'pipeline',
1030
+ version: 2,
1031
+ depends_on_convoy: [],
1032
+ })
1033
+ expect(result.valid).toBe(false)
1034
+ expect(result.errors).toContainEqual(expect.stringContaining('tasks'))
1035
+ })
1036
+
1037
+ it('pipeline spec with explicitly empty tasks array is invalid', () => {
1038
+ const result = validateSpec({
1039
+ name: 'pipeline',
1040
+ version: 2,
1041
+ depends_on_convoy: ['phase-1'],
1042
+ tasks: [],
1043
+ })
1044
+ expect(result.valid).toBe(false)
1045
+ expect(result.errors).toContainEqual(expect.stringContaining('non-empty'))
1046
+ })
1047
+ })
1048
+
1049
+ // ── isPipelineSpec ─────────────────────────────────────────────
1050
+
1051
+ describe('isPipelineSpec', () => {
1052
+ it('returns true for version:2 spec with non-empty depends_on_convoy', () => {
1053
+ expect(
1054
+ isPipelineSpec({ name: 'pipeline', version: 2, depends_on_convoy: ['phase-1'] })
1055
+ ).toBe(true)
1056
+ })
1057
+
1058
+ it('returns true when depends_on_convoy has multiple entries', () => {
1059
+ expect(
1060
+ isPipelineSpec({ name: 'pipeline', version: 2, depends_on_convoy: ['a', 'b', 'c'] })
1061
+ ).toBe(true)
1062
+ })
1063
+
1064
+ it('returns false for v1 spec without depends_on_convoy', () => {
1065
+ expect(
1066
+ isPipelineSpec({ name: 'convoy', version: 1, tasks: [{ id: 'a', prompt: 'x' }] })
1067
+ ).toBe(false)
1068
+ })
1069
+
1070
+ it('returns false for v1 spec even with depends_on_convoy (wrong version)', () => {
1071
+ expect(
1072
+ isPipelineSpec({ name: 'convoy', version: 1, depends_on_convoy: ['phase-1'] })
1073
+ ).toBe(false)
1074
+ })
1075
+
1076
+ it('returns false for version:2 with empty depends_on_convoy', () => {
1077
+ expect(
1078
+ isPipelineSpec({ name: 'pipeline', version: 2, depends_on_convoy: [] })
1079
+ ).toBe(false)
1080
+ })
1081
+
1082
+ it('returns false for version:2 without depends_on_convoy', () => {
1083
+ expect(
1084
+ isPipelineSpec({ name: 'convoy', version: 2, tasks: [{ id: 'a', prompt: 'x' }] })
1085
+ ).toBe(false)
1086
+ })
1087
+
1088
+ it('returns false for legacy spec (no version)', () => {
1089
+ expect(
1090
+ isPipelineSpec({ name: 'legacy', tasks: [{ id: 'a', prompt: 'x' }] })
1091
+ ).toBe(false)
1092
+ })
1093
+
1094
+ it('returns false for null input', () => {
1095
+ expect(isPipelineSpec(null)).toBe(false)
1096
+ })
1097
+
1098
+ it('returns false for non-object input', () => {
1099
+ expect(isPipelineSpec('string')).toBe(false)
1100
+ })
1101
+ })
1102
+
1103
+ // ── isConvoySpec — version 1 and 2 ────────────────────────────
1104
+
1105
+ describe('isConvoySpec — version 1 and 2', () => {
1106
+ it('returns true for version 1 spec', () => {
1107
+ expect(isConvoySpec({ version: 1, tasks: [] })).toBe(true)
1108
+ })
1109
+
1110
+ it('returns true for version 2 pipeline spec', () => {
1111
+ expect(isConvoySpec({ version: 2, depends_on_convoy: ['phase-1'] })).toBe(true)
1112
+ })
1113
+
1114
+ it('returns true for version 2 spec with tasks', () => {
1115
+ expect(isConvoySpec({ version: 2, tasks: [{ id: 'a', prompt: 'x' }] })).toBe(true)
1116
+ })
1117
+
1118
+ it('returns false for legacy spec (no version)', () => {
1119
+ expect(isConvoySpec({ name: 'legacy', tasks: [] })).toBe(false)
1120
+ })
1121
+
1122
+ it('returns false for version 3', () => {
1123
+ expect(isConvoySpec({ version: 3 })).toBe(false)
1124
+ })
1125
+ })
1126
+
1127
+ // ── applyDefaults — pipeline spec (version:2, no tasks) ────────
1128
+
1129
+ describe('applyDefaults — pipeline spec (version:2, no tasks)', () => {
1130
+ it('pipeline spec with no tasks produces empty tasks array', () => {
1131
+ const spec = applyDefaults({
1132
+ name: 'pipeline',
1133
+ version: 2,
1134
+ depends_on_convoy: ['phase-1'],
1135
+ })
1136
+ expect(spec.tasks).toBeUndefined()
1137
+ expect(spec.name).toBe('pipeline')
1138
+ expect(spec.version).toBe(2)
1139
+ expect(spec.depends_on_convoy).toEqual(['phase-1'])
1140
+ })
1141
+
1142
+ it('pipeline spec with tasks applies defaults normally', () => {
1143
+ const spec = applyDefaults({
1144
+ name: 'pipeline',
1145
+ version: 2,
1146
+ depends_on_convoy: ['phase-1'],
1147
+ tasks: [{ id: 'a', prompt: 'x' }],
1148
+ })
1149
+ expect(spec.tasks).toHaveLength(1)
1150
+ expect(spec.tasks![0].agent).toBe('developer')
1151
+ expect(spec.tasks![0].timeout).toBe('30m')
1152
+ })
1153
+
1154
+ it('preserves depends_on_convoy through applyDefaults', () => {
1155
+ const spec = applyDefaults({
1156
+ name: 'pipeline',
1157
+ version: 2,
1158
+ depends_on_convoy: ['phase-1', 'phase-2'],
1159
+ })
1160
+ expect(spec.depends_on_convoy).toEqual(['phase-1', 'phase-2'])
1161
+ })
1162
+ })
@@ -43,6 +43,7 @@ interface RawSpec {
43
43
  defaults?: unknown
44
44
  gates?: unknown
45
45
  branch?: unknown
46
+ depends_on_convoy?: unknown
46
47
  }
47
48
 
48
49
  interface RawTask {
@@ -99,8 +100,18 @@ export function validateSpec(spec: unknown): ValidationResult {
99
100
 
100
101
  // version
101
102
  if (s.version !== undefined) {
102
- if (typeof s.version !== 'number' || !Number.isInteger(s.version) || s.version !== 1) {
103
- errors.push('`version` must be 1')
103
+ if (typeof s.version !== 'number' || !Number.isInteger(s.version) || (s.version !== 1 && s.version !== 2)) {
104
+ errors.push('`version` must be 1 or 2')
105
+ }
106
+ }
107
+
108
+ // depends_on_convoy
109
+ if (s.depends_on_convoy !== undefined) {
110
+ if (
111
+ !Array.isArray(s.depends_on_convoy) ||
112
+ !(s.depends_on_convoy as unknown[]).every((c) => typeof c === 'string')
113
+ ) {
114
+ errors.push('`depends_on_convoy` must be an array of strings')
104
115
  }
105
116
  }
106
117
 
@@ -148,12 +159,28 @@ export function validateSpec(spec: unknown): ValidationResult {
148
159
  errors.push('`branch` must be a string')
149
160
  }
150
161
 
151
- // Tasks are always required
152
- if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
153
- errors.push('`tasks` is required and must be a non-empty array')
162
+ // Tasks: required unless this is a version:2 pipeline spec with depends_on_convoy
163
+ const isPipeline =
164
+ s.version === 2 &&
165
+ Array.isArray(s.depends_on_convoy) &&
166
+ (s.depends_on_convoy as unknown[]).length > 0
167
+
168
+ if (!isPipeline) {
169
+ if (!s.tasks || !Array.isArray(s.tasks) || s.tasks.length === 0) {
170
+ errors.push('`tasks` is required and must be a non-empty array')
171
+ return { valid: false, errors }
172
+ }
173
+ } else if (s.tasks !== undefined && (!Array.isArray(s.tasks) || s.tasks.length === 0)) {
174
+ // Pipeline spec may omit tasks entirely, but if present they must be non-empty
175
+ errors.push('`tasks`, when provided, must be a non-empty array')
154
176
  return { valid: false, errors }
155
177
  }
156
178
 
179
+ // Skip per-task validation when pipeline spec has no tasks
180
+ if (isPipeline && !s.tasks) {
181
+ return { valid: errors.length === 0, errors }
182
+ }
183
+
157
184
  const taskIds = new Set<string>()
158
185
  const tasks = s.tasks as RawTask[]
159
186
 
@@ -293,7 +320,7 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
293
320
  // Leave adapter empty so run.ts can auto-detect the best available CLI
294
321
  s.adapter = (s.adapter as string) || ''
295
322
 
296
- const tasks = s.tasks as Array<Record<string, unknown>>
323
+ const tasks = (s.tasks as Array<Record<string, unknown>> | undefined) ?? []
297
324
  const d =
298
325
  s.version === 1 && s.defaults
299
326
  ? (s.defaults as Record<string, unknown>)
@@ -327,11 +354,25 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
327
354
  }
328
355
 
329
356
  /**
330
- * Returns true if the spec uses the Convoy Engine enhanced format (version: 1).
357
+ * Returns true if the spec uses the Convoy Engine enhanced format (version: 1 or 2).
331
358
  */
332
359
  export function isConvoySpec(spec: unknown): boolean {
333
360
  if (!spec || typeof spec !== 'object') return false
334
- return (spec as Record<string, unknown>).version === 1
361
+ const v = (spec as Record<string, unknown>).version
362
+ return v === 1 || v === 2
363
+ }
364
+
365
+ /**
366
+ * Returns true if the spec is a pipeline spec (version: 2 + non-empty depends_on_convoy).
367
+ */
368
+ export function isPipelineSpec(spec: unknown): boolean {
369
+ if (!spec || typeof spec !== 'object') return false
370
+ const s = spec as Record<string, unknown>
371
+ return (
372
+ s.version === 2 &&
373
+ Array.isArray(s.depends_on_convoy) &&
374
+ (s.depends_on_convoy as unknown[]).length > 0
375
+ )
335
376
  }
336
377
 
337
378
  /**
package/src/cli/run.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
- import { parseTaskSpecText, isConvoySpec } from './run/schema.js'
4
+ import { parseTaskSpecText, isConvoySpec, isPipelineSpec } from './run/schema.js'
5
5
  import { createExecutor, buildPhases } from './run/executor.js'
6
6
  import { getAdapter, detectAdapter } from './run/adapters/index.js'
7
7
  import { createReporter, printExecutionPlan } from './run/reporter.js'
8
8
  import type { CliContext, RunOptions } from './types.js'
9
9
  import type { ConvoyResult } from './convoy/engine.js'
10
+ import type { PipelineResult } from './convoy/pipeline.js'
10
11
 
11
12
  function formatTokens(n: number): string {
12
13
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
@@ -114,6 +115,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
114
115
  ` • copilot — https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli\n` +
115
116
  ` • claude — npm install -g @anthropic-ai/claude-code\n` +
116
117
  ` • cursor — https://cursor.com (Cursor > Install CLI)\n` +
118
+ ` • opencode — https://opencode.ai\n` +
117
119
  `\n` +
118
120
  ` Or specify an adapter explicitly: opencastle run --adapter <name>`
119
121
  )
@@ -134,7 +136,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
134
136
  ' Install OpenCode from https://opencode.ai\n' +
135
137
  ' Ensure the "opencode" command is on your PATH.',
136
138
  }
137
- const cliName = adapterName === 'claude-code' ? 'claude' : adapterName
139
+ const cliName = adapterName === 'claude-code' ? 'claude' : adapterName === 'cursor' ? 'agent' : adapterName
138
140
  const hint = hints[adapterName] ?? ''
139
141
  console.error(
140
142
  ` ✗ Adapter "${adapterName}" is not available.\n` +
@@ -164,6 +166,26 @@ function printConvoyResult(result: ConvoyResult): void {
164
166
  }
165
167
  }
166
168
 
169
+ /**
170
+ * Print a pipeline result summary.
171
+ */
172
+ function printPipelineResult(result: PipelineResult): void {
173
+ console.log(`\n ──────────────────────────────────────`)
174
+ console.log(` Pipeline ${result.status}: ${result.duration}`)
175
+ console.log(
176
+ ` Convoys: ${result.summary.completed}/${result.summary.totalConvoys} completed` +
177
+ (result.summary.failed > 0 ? ` | ${result.summary.failed} failed` : '') +
178
+ (result.summary.skipped > 0 ? ` | ${result.summary.skipped} skipped` : '')
179
+ )
180
+ for (const cr of result.convoyResults) {
181
+ const icon = cr.status === 'done' ? '✓' : cr.status === 'failed' ? '✗' : '⊘'
182
+ console.log(` ${icon} ${cr.convoyId}: ${cr.status} (${cr.duration})`)
183
+ }
184
+ if (result.cost) {
185
+ console.log(` Tokens: ${formatTokens(result.cost.total_tokens)}`)
186
+ }
187
+ }
188
+
167
189
  /**
168
190
  * CLI entry point for the `run` command.
169
191
  */
@@ -186,6 +208,38 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
186
208
  const { createConvoyStore } = await import('./convoy/store.js')
187
209
  const store = createConvoyStore(dbPath)
188
210
  try {
211
+ const pipeline = store.getLatestPipeline()
212
+ if (pipeline) {
213
+ const pipelineConvoys = store.getConvoysByPipeline(pipeline.id)
214
+ console.log(`\n Pipeline: ${pipeline.name}`)
215
+ console.log(` ID: ${pipeline.id}`)
216
+ console.log(` Status: ${pipeline.status}`)
217
+ console.log(` Branch: ${pipeline.branch ?? '(none)'}`)
218
+ console.log(` Created: ${pipeline.created_at}`)
219
+ if (pipeline.started_at) console.log(` Started: ${pipeline.started_at}`)
220
+ if (pipeline.finished_at) console.log(` Finished: ${pipeline.finished_at}`)
221
+ if (pipelineConvoys.length > 0) {
222
+ console.log(`\n Convoys:`)
223
+ let totalTasks = 0
224
+ let totalDone = 0
225
+ let totalFailed = 0
226
+ let totalTokens = 0
227
+ for (const c of pipelineConvoys) {
228
+ const tasks = store.getTasksByConvoy(c.id)
229
+ const done = tasks.filter(t => t.status === 'done').length
230
+ const failed = tasks.filter(t => t.status === 'failed').length
231
+ totalTasks += tasks.length
232
+ totalDone += done
233
+ totalFailed += failed
234
+ totalTokens += tasks.reduce((sum, t) => sum + (t.total_tokens ?? 0), 0)
235
+ console.log(` ${c.name} [${c.status}] — ${done}/${tasks.length} tasks done`)
236
+ }
237
+ console.log(`\n Tasks: ${totalDone} done | ${totalFailed} failed | ${totalTasks} total`)
238
+ if (totalTokens > 0) console.log(` Tokens: ${formatTokens(totalTokens)}`)
239
+ }
240
+ return
241
+ }
242
+
189
243
  const convoy = store.getLatestConvoy()
190
244
  if (!convoy) {
191
245
  console.log(' No convoy records found.')
@@ -237,6 +291,47 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
237
291
  }
238
292
  const { createConvoyStore } = await import('./convoy/store.js')
239
293
  const store = createConvoyStore(dbPath)
294
+ const latestPipeline = store.getLatestPipeline()
295
+ if (latestPipeline && (latestPipeline.status === 'pending' || latestPipeline.status === 'running')) {
296
+ store.close()
297
+ const resumePipelineSpec = parseTaskSpecText(latestPipeline.spec_yaml)
298
+ if (opts.concurrency !== null) resumePipelineSpec.concurrency = opts.concurrency
299
+ if (opts.adapter !== null) resumePipelineSpec.adapter = opts.adapter
300
+ if (opts.verbose) resumePipelineSpec._verbose = true
301
+
302
+ let resumePipelineDetectionFailed = false
303
+ if (!resumePipelineSpec.adapter) {
304
+ const detected = await detectAdapter()
305
+ if (detected) {
306
+ resumePipelineSpec.adapter = detected
307
+ console.log(` ℹ Auto-detected adapter: ${detected}`)
308
+ } else {
309
+ resumePipelineDetectionFailed = true
310
+ resumePipelineSpec.adapter = 'claude-code'
311
+ }
312
+ }
313
+
314
+ const resumePipelineAdapter = await getAdapter(resumePipelineSpec.adapter)
315
+ const resumePipelineAvailable = await resumePipelineAdapter.isAvailable()
316
+ if (!resumePipelineAvailable) {
317
+ printAdapterError(resumePipelineDetectionFailed, resumePipelineSpec.adapter)
318
+ process.exit(1)
319
+ }
320
+
321
+ console.log(`\n 🏰 OpenCastle Pipeline (Resume): ${latestPipeline.name}`)
322
+ console.log(` Pipeline ID: ${latestPipeline.id}`)
323
+ const { createPipelineOrchestrator } = await import('./convoy/pipeline.js')
324
+ const resumePipelineOrchestrator = createPipelineOrchestrator({
325
+ spec: resumePipelineSpec,
326
+ specYaml: latestPipeline.spec_yaml,
327
+ adapter: resumePipelineAdapter,
328
+ verbose: opts.verbose,
329
+ })
330
+ const resumePipelineResult = await resumePipelineOrchestrator.resume(latestPipeline.id)
331
+ printPipelineResult(resumePipelineResult)
332
+ process.exit(resumePipelineResult.status !== 'done' ? 1 : 0)
333
+ }
334
+
240
335
  const convoy = store.getLatestConvoy()
241
336
  store.close()
242
337
  if (!convoy) {
@@ -332,7 +427,16 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
332
427
 
333
428
  // ── Dry run ──────────────────────────────────────────────────
334
429
  if (opts.dryRun) {
335
- if (isConvoySpec(spec)) {
430
+ if (isPipelineSpec(spec)) {
431
+ console.log(`\n 🏰 Pipeline Plan: ${spec.name}`)
432
+ console.log(` Convoy chain: ${(spec.depends_on_convoy as string[]).join(' → ')}`)
433
+ if (spec.tasks?.length) {
434
+ console.log(` Plus ${spec.tasks.length} local tasks after chain completes`)
435
+ }
436
+ if (spec.branch) console.log(` Branch: ${spec.branch}`)
437
+ if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
438
+ if (!spec.tasks?.length) return
439
+ } else if (isConvoySpec(spec)) {
336
440
  console.log(`\n \uD83C\uDFF0 Convoy Plan: ${spec.name}`)
337
441
  console.log(
338
442
  ` Adapter: ${spec.adapter} | Concurrency: ${spec.concurrency} | Tasks: ${spec.tasks!.length}`
@@ -353,6 +457,41 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
353
457
  process.exit(1)
354
458
  }
355
459
 
460
+ // ── Pipeline orchestrator path (version: 2 specs with depends_on_convoy) ──
461
+ if (isPipelineSpec(spec)) {
462
+ const { createPipelineOrchestrator } = await import('./convoy/pipeline.js')
463
+ console.log(`\n 🏰 OpenCastle Pipeline: ${spec.name}`)
464
+ console.log(` Convoy chain: ${(spec.depends_on_convoy as string[]).join(' → ')}`)
465
+ if (spec.branch) console.log(` Branch: ${spec.branch}`)
466
+ if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
467
+
468
+ const { startDashboardServer } = await import('./dashboard.js')
469
+ let pipelineDashboardResult: { server: import('node:http').Server } | null = null
470
+ try {
471
+ pipelineDashboardResult = await startDashboardServer({
472
+ pkgRoot,
473
+ openBrowser: true,
474
+ convoyId: 'active',
475
+ })
476
+ } catch {
477
+ // Dashboard failure must not block pipeline
478
+ }
479
+
480
+ const pipelineOrchestrator = createPipelineOrchestrator({
481
+ spec,
482
+ specYaml: specText,
483
+ adapter,
484
+ verbose: opts.verbose,
485
+ })
486
+
487
+ const pipelineResult = await pipelineOrchestrator.run()
488
+ printPipelineResult(pipelineResult)
489
+ if (pipelineDashboardResult) {
490
+ pipelineDashboardResult.server.close()
491
+ }
492
+ process.exit(pipelineResult.status !== 'done' ? 1 : 0)
493
+ }
494
+
356
495
  // ── Convoy engine path (version: 1 specs) ────────────────────
357
496
  if (isConvoySpec(spec)) {
358
497
  const { createConvoyEngine } = await import('./convoy/engine.js')
package/src/cli/types.ts CHANGED
@@ -158,7 +158,7 @@ export interface TaskSpec {
158
158
  adapter: string;
159
159
  tasks?: Task[];
160
160
  _verbose?: boolean;
161
- /** Spec schema version (1 for Convoy Engine format). */
161
+ /** Spec schema version (1 for Convoy Engine format, 2 for pipeline chaining). */
162
162
  version?: number;
163
163
  /** Worker defaults merged into each task (Convoy Engine). */
164
164
  defaults?: TaskDefaults;
@@ -166,6 +166,8 @@ export interface TaskSpec {
166
166
  gates?: string[];
167
167
  /** Git feature branch name. */
168
168
  branch?: string;
169
+ /** Other convoy spec names to run before this one (version: 2 pipeline specs). */
170
+ depends_on_convoy?: string[];
169
171
  }
170
172
 
171
173
  /** A single task in the spec. */