opencastle 0.29.0 → 0.30.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.
- package/dist/cli/convoy/engine.js +1 -1
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/pipeline.d.ts +17 -0
- package/dist/cli/pipeline.d.ts.map +1 -1
- package/dist/cli/pipeline.js +238 -19
- package/dist/cli/pipeline.js.map +1 -1
- package/dist/cli/pipeline.test.d.ts +2 -0
- package/dist/cli/pipeline.test.d.ts.map +1 -0
- package/dist/cli/pipeline.test.js +178 -0
- package/dist/cli/pipeline.test.js.map +1 -0
- package/package.json +1 -1
- package/src/cli/convoy/engine.ts +1 -1
- package/src/cli/pipeline.test.ts +191 -0
- package/src/cli/pipeline.ts +302 -21
- package/src/dashboard/dist/index.html +398 -5
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/src/pages/index.astro +490 -4
- package/src/orchestrator/agents/team-lead.agent.md +13 -0
- package/src/orchestrator/prompts/fix-prd.prompt.md +58 -0
- package/src/orchestrator/prompts/generate-convoy.prompt.md +23 -0
- package/src/orchestrator/prompts/generate-prd.prompt.md +32 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { parseComplexityAssessment } from './pipeline.js'
|
|
3
|
+
|
|
4
|
+
const SINGLE_PRD = `# My Feature — PRD
|
|
5
|
+
|
|
6
|
+
## Overview
|
|
7
|
+
Some overview.
|
|
8
|
+
|
|
9
|
+
## Risks & Open Questions
|
|
10
|
+
None identified.
|
|
11
|
+
|
|
12
|
+
## Complexity Assessment
|
|
13
|
+
|
|
14
|
+
\`\`\`json
|
|
15
|
+
{
|
|
16
|
+
"total_tasks": 4,
|
|
17
|
+
"total_phases": 2,
|
|
18
|
+
"domains": ["api", "frontend"],
|
|
19
|
+
"estimated_duration_minutes": 60,
|
|
20
|
+
"complexity": "medium",
|
|
21
|
+
"recommended_strategy": "single",
|
|
22
|
+
"chain_rationale": "",
|
|
23
|
+
"convoy_groups": [
|
|
24
|
+
{
|
|
25
|
+
"name": "full-implementation",
|
|
26
|
+
"description": "All phases in a single convoy",
|
|
27
|
+
"phases": [1, 2],
|
|
28
|
+
"depends_on": []
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
\`\`\`
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
const CHAIN_PRD = `# Big Feature — PRD
|
|
36
|
+
|
|
37
|
+
## Overview
|
|
38
|
+
Big feature.
|
|
39
|
+
|
|
40
|
+
## Risks & Open Questions
|
|
41
|
+
None identified.
|
|
42
|
+
|
|
43
|
+
## Complexity Assessment
|
|
44
|
+
|
|
45
|
+
\`\`\`json
|
|
46
|
+
{
|
|
47
|
+
"total_tasks": 12,
|
|
48
|
+
"total_phases": 4,
|
|
49
|
+
"domains": ["database", "api", "frontend", "testing"],
|
|
50
|
+
"estimated_duration_minutes": 240,
|
|
51
|
+
"complexity": "high",
|
|
52
|
+
"recommended_strategy": "chain",
|
|
53
|
+
"chain_rationale": "Database schema changes have no frontend dependencies and can be validated independently.",
|
|
54
|
+
"convoy_groups": [
|
|
55
|
+
{
|
|
56
|
+
"name": "database-setup",
|
|
57
|
+
"description": "Schema changes and migrations",
|
|
58
|
+
"phases": [1],
|
|
59
|
+
"depends_on": []
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
"name": "api-integration",
|
|
63
|
+
"description": "API routes and server logic",
|
|
64
|
+
"phases": [2],
|
|
65
|
+
"depends_on": ["database-setup"]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"name": "frontend-testing",
|
|
69
|
+
"description": "UI components and test suite",
|
|
70
|
+
"phases": [3, 4],
|
|
71
|
+
"depends_on": ["api-integration"]
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
\`\`\`
|
|
76
|
+
`
|
|
77
|
+
|
|
78
|
+
const NO_SECTION_PRD = `# Feature — PRD
|
|
79
|
+
|
|
80
|
+
## Overview
|
|
81
|
+
Plain prd with no complexity section.
|
|
82
|
+
|
|
83
|
+
## Risks & Open Questions
|
|
84
|
+
None.
|
|
85
|
+
`
|
|
86
|
+
|
|
87
|
+
const MALFORMED_JSON_PRD = `# Feature — PRD
|
|
88
|
+
|
|
89
|
+
## Complexity Assessment
|
|
90
|
+
|
|
91
|
+
\`\`\`json
|
|
92
|
+
{ "total_tasks": 3, "broken":
|
|
93
|
+
\`\`\`
|
|
94
|
+
`
|
|
95
|
+
|
|
96
|
+
const UNFENCED_JSON_PRD = `# Feature — PRD
|
|
97
|
+
|
|
98
|
+
## Complexity Assessment
|
|
99
|
+
|
|
100
|
+
The complexity is medium.
|
|
101
|
+
|
|
102
|
+
total_tasks: 3, total_phases: 2
|
|
103
|
+
`
|
|
104
|
+
|
|
105
|
+
const OTHER_JSON_PRD = `# Feature — PRD
|
|
106
|
+
|
|
107
|
+
## Overview
|
|
108
|
+
Some feature with json-like content: \`{"key": "value"}\`
|
|
109
|
+
|
|
110
|
+
Another block:
|
|
111
|
+
\`\`\`json
|
|
112
|
+
{"not": "complexity"}
|
|
113
|
+
\`\`\`
|
|
114
|
+
|
|
115
|
+
## Complexity Assessment
|
|
116
|
+
|
|
117
|
+
\`\`\`json
|
|
118
|
+
{
|
|
119
|
+
"total_tasks": 6,
|
|
120
|
+
"total_phases": 3,
|
|
121
|
+
"domains": ["api", "frontend"],
|
|
122
|
+
"complexity": "medium",
|
|
123
|
+
"recommended_strategy": "single",
|
|
124
|
+
"convoy_groups": [
|
|
125
|
+
{
|
|
126
|
+
"name": "impl",
|
|
127
|
+
"description": "All phases",
|
|
128
|
+
"phases": [1, 2, 3],
|
|
129
|
+
"depends_on": []
|
|
130
|
+
}
|
|
131
|
+
]
|
|
132
|
+
}
|
|
133
|
+
\`\`\`
|
|
134
|
+
`
|
|
135
|
+
|
|
136
|
+
describe('parseComplexityAssessment', () => {
|
|
137
|
+
it('returns null when PRD has no Complexity Assessment section', () => {
|
|
138
|
+
expect(parseComplexityAssessment(NO_SECTION_PRD)).toBeNull()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('returns null when JSON is malformed', () => {
|
|
142
|
+
expect(parseComplexityAssessment(MALFORMED_JSON_PRD)).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('parses a valid single strategy assessment', () => {
|
|
146
|
+
const result = parseComplexityAssessment(SINGLE_PRD)
|
|
147
|
+
expect(result).not.toBeNull()
|
|
148
|
+
expect(result?.recommended_strategy).toBe('single')
|
|
149
|
+
expect(result?.complexity).toBe('medium')
|
|
150
|
+
expect(result?.total_tasks).toBe(4)
|
|
151
|
+
expect(result?.total_phases).toBe(2)
|
|
152
|
+
expect(result?.domains).toEqual(['api', 'frontend'])
|
|
153
|
+
expect(result?.convoy_groups).toHaveLength(1)
|
|
154
|
+
expect(result?.convoy_groups[0].name).toBe('full-implementation')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('parses a valid chain strategy assessment with multiple groups', () => {
|
|
158
|
+
const result = parseComplexityAssessment(CHAIN_PRD)
|
|
159
|
+
expect(result).not.toBeNull()
|
|
160
|
+
expect(result?.recommended_strategy).toBe('chain')
|
|
161
|
+
expect(result?.complexity).toBe('high')
|
|
162
|
+
expect(result?.convoy_groups).toHaveLength(3)
|
|
163
|
+
expect(result?.convoy_groups[0].name).toBe('database-setup')
|
|
164
|
+
expect(result?.convoy_groups[1].depends_on).toEqual(['database-setup'])
|
|
165
|
+
expect(result?.convoy_groups[2].phases).toEqual([3, 4])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('handles missing optional fields gracefully', () => {
|
|
169
|
+
const prd = `# Feature — PRD\n\n## Complexity Assessment\n\n\`\`\`json\n{\n "total_tasks": 3,\n "total_phases": 1,\n "domains": ["api"],\n "complexity": "low",\n "recommended_strategy": "single",\n "convoy_groups": [{"name": "all", "description": "All", "phases": [1], "depends_on": []}]\n}\n\`\`\`\n`
|
|
170
|
+
const result = parseComplexityAssessment(prd)
|
|
171
|
+
expect(result).not.toBeNull()
|
|
172
|
+
expect(result?.estimated_duration_minutes).toBeUndefined()
|
|
173
|
+
expect(result?.chain_rationale).toBeUndefined()
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('returns null when JSON block is not fenced properly', () => {
|
|
177
|
+
expect(parseComplexityAssessment(UNFENCED_JSON_PRD)).toBeNull()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('correctly extracts JSON even when PRD has other JSON-like content elsewhere', () => {
|
|
181
|
+
const result = parseComplexityAssessment(OTHER_JSON_PRD)
|
|
182
|
+
expect(result).not.toBeNull()
|
|
183
|
+
expect(result?.total_tasks).toBe(6)
|
|
184
|
+
expect(result?.recommended_strategy).toBe('single')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('returns null when required fields are missing from JSON', () => {
|
|
188
|
+
const prd = `# Feature — PRD\n\n## Complexity Assessment\n\n\`\`\`json\n{"total_tasks": 3}\n\`\`\`\n`
|
|
189
|
+
expect(parseComplexityAssessment(prd)).toBeNull()
|
|
190
|
+
})
|
|
191
|
+
})
|
package/src/cli/pipeline.ts
CHANGED
|
@@ -1,10 +1,56 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
2
|
import { existsSync } from 'node:fs'
|
|
3
|
-
import { resolve } from 'node:path'
|
|
3
|
+
import { resolve, dirname } from 'node:path'
|
|
4
|
+
import { stringify } from 'yaml'
|
|
4
5
|
import { c, confirm, closePrompts } from './prompt.js'
|
|
5
6
|
import { runPromptStep } from './plan.js'
|
|
6
7
|
import type { CliContext } from './types.js'
|
|
7
8
|
|
|
9
|
+
export interface ConvoyGroup {
|
|
10
|
+
name: string
|
|
11
|
+
description: string
|
|
12
|
+
phases: number[]
|
|
13
|
+
depends_on: string[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ComplexityAssessment {
|
|
17
|
+
total_tasks: number
|
|
18
|
+
total_phases: number
|
|
19
|
+
domains: string[]
|
|
20
|
+
estimated_duration_minutes?: number
|
|
21
|
+
complexity: 'low' | 'medium' | 'high'
|
|
22
|
+
recommended_strategy: 'single' | 'chain'
|
|
23
|
+
chain_rationale?: string
|
|
24
|
+
convoy_groups: ConvoyGroup[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseComplexityAssessment(prdContent: string): ComplexityAssessment | null {
|
|
28
|
+
const sectionMatch = prdContent.match(/## Complexity Assessment\s+([\s\S]*?)(?=\n## |\n# |$)/)
|
|
29
|
+
if (!sectionMatch) return null
|
|
30
|
+
|
|
31
|
+
const sectionContent = sectionMatch[1]
|
|
32
|
+
const jsonMatch = sectionContent.match(/```json\s*([\s\S]*?)```/)
|
|
33
|
+
if (!jsonMatch) return null
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(jsonMatch[1].trim()) as ComplexityAssessment
|
|
37
|
+
// Validate required fields
|
|
38
|
+
if (
|
|
39
|
+
typeof parsed.total_tasks !== 'number' ||
|
|
40
|
+
typeof parsed.total_phases !== 'number' ||
|
|
41
|
+
!Array.isArray(parsed.domains) ||
|
|
42
|
+
!parsed.complexity ||
|
|
43
|
+
!parsed.recommended_strategy ||
|
|
44
|
+
!Array.isArray(parsed.convoy_groups)
|
|
45
|
+
) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
return parsed
|
|
49
|
+
} catch {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
8
54
|
const HELP = `
|
|
9
55
|
opencastle pipeline [options]
|
|
10
56
|
|
|
@@ -12,9 +58,10 @@ const HELP = `
|
|
|
12
58
|
|
|
13
59
|
Step 1 — Generate PRD (generate-prd)
|
|
14
60
|
Step 2 — Validate PRD (validate-prd)
|
|
15
|
-
Step 3 —
|
|
16
|
-
Step 4 —
|
|
17
|
-
Step 5 —
|
|
61
|
+
Step 3 — Fix PRD (fix-prd, up to 2 retries if invalid)
|
|
62
|
+
Step 4 — Generate convoy spec (generate-convoy, using PRD as BDO)
|
|
63
|
+
Step 5 — Validate convoy spec (validate-convoy)
|
|
64
|
+
Step 6 — Fix convoy spec (fix-convoy, up to 2 retries if invalid)
|
|
18
65
|
|
|
19
66
|
Options:
|
|
20
67
|
--text, -t <text> Feature prompt text (required, unless --prd is set)
|
|
@@ -137,7 +184,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
|
|
|
137
184
|
}
|
|
138
185
|
}
|
|
139
186
|
|
|
140
|
-
const totalSteps = opts.skipValidation ? 3 :
|
|
187
|
+
const totalSteps = opts.skipValidation ? 3 : 6
|
|
141
188
|
const sharedOpts = {
|
|
142
189
|
adapterName: opts.adapter ?? undefined,
|
|
143
190
|
verbose: opts.verbose,
|
|
@@ -196,20 +243,254 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
|
|
|
196
243
|
}
|
|
197
244
|
|
|
198
245
|
if (!result.isValid) {
|
|
199
|
-
|
|
200
|
-
console.log(
|
|
246
|
+
let prdValidationErrors = result.errors ?? result.rawOutput
|
|
247
|
+
console.log(c.yellow(` ⚠ PRD has validation issues — attempting auto-fix…\n`))
|
|
248
|
+
console.log(c.dim(prdValidationErrors))
|
|
249
|
+
console.log()
|
|
250
|
+
|
|
251
|
+
// ── Step 3: Fix PRD (up to 2 retries) ──────────────────────────────────
|
|
252
|
+
const MAX_PRD_FIX_RETRIES = 2
|
|
253
|
+
let fixedPrdContent = prdContent
|
|
254
|
+
let prdFixed = false
|
|
255
|
+
|
|
256
|
+
for (let attempt = 1; attempt <= MAX_PRD_FIX_RETRIES; attempt++) {
|
|
257
|
+
const label = `Fix PRD attempt ${attempt}/${MAX_PRD_FIX_RETRIES}…`
|
|
258
|
+
console.log(stepLabel(3, totalSteps, label))
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
await runPromptStep({
|
|
262
|
+
...sharedOpts,
|
|
263
|
+
template: 'fix-prd',
|
|
264
|
+
goalText: fixedPrdContent,
|
|
265
|
+
contextText: prdValidationErrors,
|
|
266
|
+
outputPath: prdPath, // overwrite in place
|
|
267
|
+
})
|
|
268
|
+
} catch (err) {
|
|
269
|
+
console.error(`\n ✗ Step 3 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
270
|
+
process.exit(1)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.log(c.dim(` Re-validating after fix…`))
|
|
274
|
+
|
|
275
|
+
fixedPrdContent = await readFile(prdPath, 'utf8')
|
|
276
|
+
|
|
277
|
+
let revalidation
|
|
278
|
+
try {
|
|
279
|
+
revalidation = await runPromptStep({
|
|
280
|
+
...sharedOpts,
|
|
281
|
+
template: 'validate-prd',
|
|
282
|
+
goalText: fixedPrdContent,
|
|
283
|
+
})
|
|
284
|
+
} catch (err) {
|
|
285
|
+
console.error(`\n ✗ Re-validation failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
286
|
+
process.exit(1)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (revalidation.isValid) {
|
|
290
|
+
console.log(c.green(` ✓ PRD fixed and validated\n`))
|
|
291
|
+
prdFixed = true
|
|
292
|
+
break
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
prdValidationErrors = revalidation.errors ?? revalidation.rawOutput
|
|
296
|
+
|
|
297
|
+
if (attempt < MAX_PRD_FIX_RETRIES) {
|
|
298
|
+
console.log(c.yellow(` ⚠ Still has issues after fix attempt ${attempt} — retrying…\n`))
|
|
299
|
+
console.log(c.dim(prdValidationErrors))
|
|
300
|
+
console.log()
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!prdFixed) {
|
|
305
|
+
console.log(c.red(`\n ✗ Could not auto-fix the PRD after ${MAX_PRD_FIX_RETRIES} attempts.\n`))
|
|
306
|
+
console.log(` Remaining issues:\n`)
|
|
307
|
+
console.log(prdValidationErrors)
|
|
308
|
+
console.log(
|
|
309
|
+
c.dim(`\n The PRD has been saved to ${relPath(prdPath)} with the best available fixes.\n`) +
|
|
310
|
+
c.dim(` Review the remaining issues above and edit the file manually, then re-run with:\n`) +
|
|
311
|
+
` opencastle pipeline --prd ${relPath(prdPath)}${opts.adapter ? ` --adapter ${opts.adapter}` : ''}\n`
|
|
312
|
+
)
|
|
313
|
+
process.exit(1)
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
console.log(c.green(` ✓ PRD is valid\n`))
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── Complexity-aware strategy decision ────────────────────────────────────
|
|
321
|
+
const prdContentForComplexity = await readFile(prdPath, 'utf8')
|
|
322
|
+
const complexity = parseComplexityAssessment(prdContentForComplexity)
|
|
323
|
+
|
|
324
|
+
if (complexity) {
|
|
325
|
+
if (complexity.recommended_strategy === 'chain' && complexity.convoy_groups.length > 1) {
|
|
201
326
|
console.log(
|
|
202
|
-
c.
|
|
203
|
-
`
|
|
327
|
+
c.cyan(` ℹ`) +
|
|
328
|
+
` Complexity: ${complexity.complexity} | Strategy: chain | ${complexity.convoy_groups.length} convoy groups\n`
|
|
329
|
+
)
|
|
330
|
+
console.log(` Chain plan:`)
|
|
331
|
+
for (let i = 0; i < complexity.convoy_groups.length; i++) {
|
|
332
|
+
const g = complexity.convoy_groups[i]
|
|
333
|
+
const depStr =
|
|
334
|
+
g.depends_on.length > 0 ? ` → depends on: ${g.depends_on.join(', ')}` : ''
|
|
335
|
+
console.log(
|
|
336
|
+
` ${i + 1}. ${g.name.padEnd(20)} (phases: ${g.phases.join(', ')})${depStr}`
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
console.log()
|
|
340
|
+
|
|
341
|
+
const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
|
|
342
|
+
await mkdir(convoyDir, { recursive: true })
|
|
343
|
+
|
|
344
|
+
const genBaseStep = opts.skipValidation ? 2 : 4
|
|
345
|
+
const groupSpecPaths: string[] = []
|
|
346
|
+
const totalGroupSteps =
|
|
347
|
+
(opts.skipValidation ? 2 : 3) + complexity.convoy_groups.length * (opts.skipValidation ? 1 : 2)
|
|
348
|
+
|
|
349
|
+
for (let i = 0; i < complexity.convoy_groups.length; i++) {
|
|
350
|
+
const group = complexity.convoy_groups[i]
|
|
351
|
+
const groupStep = genBaseStep + i * (opts.skipValidation ? 1 : 2)
|
|
352
|
+
|
|
353
|
+
console.log(
|
|
354
|
+
stepLabel(
|
|
355
|
+
groupStep,
|
|
356
|
+
totalGroupSteps,
|
|
357
|
+
`Generating convoy spec for group: ${group.name}…`
|
|
358
|
+
)
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
const chainContext = JSON.stringify({
|
|
362
|
+
mode: 'chain_subset',
|
|
363
|
+
group_name: group.name,
|
|
364
|
+
group_description: group.description,
|
|
365
|
+
group_phases: group.phases,
|
|
366
|
+
depends_on_groups: group.depends_on,
|
|
367
|
+
total_groups: complexity.convoy_groups.length,
|
|
368
|
+
group_index: i + 1,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
const groupSpecPath = resolve(convoyDir, `${group.name}.convoy.yml`)
|
|
372
|
+
|
|
373
|
+
let groupResult
|
|
374
|
+
try {
|
|
375
|
+
groupResult = await runPromptStep({
|
|
376
|
+
...sharedOpts,
|
|
377
|
+
template: 'generate-convoy',
|
|
378
|
+
filePath: prdPath,
|
|
379
|
+
contextText: chainContext,
|
|
380
|
+
outputPath: groupSpecPath,
|
|
381
|
+
})
|
|
382
|
+
} catch (err) {
|
|
383
|
+
console.error(
|
|
384
|
+
`\n ✗ Step ${groupStep} failed: ${err instanceof Error ? err.message : String(err)}`
|
|
385
|
+
)
|
|
386
|
+
process.exit(1)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const resolvedGroupSpecPath = groupResult.outputPath ?? groupSpecPath
|
|
390
|
+
groupSpecPaths.push(resolvedGroupSpecPath)
|
|
391
|
+
|
|
392
|
+
console.log(c.green(` ✓ Group spec written to ${relPath(resolvedGroupSpecPath)}\n`))
|
|
393
|
+
|
|
394
|
+
if (!opts.skipValidation) {
|
|
395
|
+
const valStep = groupStep + 1
|
|
396
|
+
console.log(stepLabel(valStep, totalGroupSteps, `Validating spec: ${group.name}…`))
|
|
397
|
+
|
|
398
|
+
const groupSpecContent = await readFile(resolvedGroupSpecPath, 'utf8')
|
|
399
|
+
let groupValidation
|
|
400
|
+
try {
|
|
401
|
+
groupValidation = await runPromptStep({
|
|
402
|
+
...sharedOpts,
|
|
403
|
+
template: 'validate-convoy',
|
|
404
|
+
goalText: groupSpecContent,
|
|
405
|
+
})
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.error(
|
|
408
|
+
`\n ✗ Validation failed for group ${group.name}: ${err instanceof Error ? err.message : String(err)}`
|
|
409
|
+
)
|
|
410
|
+
process.exit(1)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (!groupValidation.isValid) {
|
|
414
|
+
console.log(c.yellow(` ⚠ Spec has issues — attempting one auto-fix…\n`))
|
|
415
|
+
console.log(c.dim(groupValidation.errors ?? groupValidation.rawOutput))
|
|
416
|
+
console.log()
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
await runPromptStep({
|
|
420
|
+
...sharedOpts,
|
|
421
|
+
template: 'fix-convoy',
|
|
422
|
+
goalText: groupSpecContent,
|
|
423
|
+
contextText: groupValidation.errors ?? groupValidation.rawOutput,
|
|
424
|
+
outputPath: resolvedGroupSpecPath,
|
|
425
|
+
})
|
|
426
|
+
} catch (err) {
|
|
427
|
+
console.error(
|
|
428
|
+
`\n ✗ Fix failed for group ${group.name}: ${err instanceof Error ? err.message : String(err)}`
|
|
429
|
+
)
|
|
430
|
+
process.exit(1)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
console.log(c.dim(` Applied fix for ${group.name}\n`))
|
|
434
|
+
} else {
|
|
435
|
+
console.log(c.green(` ✓ Spec valid\n`))
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Build master pipeline spec (version 2)
|
|
441
|
+
const featureNameMatch = prdContentForComplexity.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
|
|
442
|
+
const featureName = featureNameMatch
|
|
443
|
+
? featureNameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
|
|
444
|
+
: 'feature'
|
|
445
|
+
|
|
446
|
+
const branchMatch = prdContentForComplexity.match(/`feat\/([^`]+)`/)
|
|
447
|
+
const branch = branchMatch ? `feat/${branchMatch[1]}` : `feat/${featureName}`
|
|
448
|
+
|
|
449
|
+
const masterSpec = {
|
|
450
|
+
name: featureNameMatch ? featureNameMatch[1].trim() : 'Feature Pipeline',
|
|
451
|
+
version: 2,
|
|
452
|
+
branch,
|
|
453
|
+
on_failure: 'stop',
|
|
454
|
+
depends_on_convoy: groupSpecPaths.map(p => relPath(p)),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
const masterSpecPath = resolve(convoyDir, `${featureName}-pipeline.convoy.yml`)
|
|
458
|
+
await writeFile(masterSpecPath, stringify(masterSpec), 'utf8')
|
|
459
|
+
|
|
460
|
+
console.log(c.green(` ✓ Generated convoy chain:\n`))
|
|
461
|
+
for (const p of groupSpecPaths) {
|
|
462
|
+
console.log(` ${relPath(p)}`)
|
|
463
|
+
}
|
|
464
|
+
console.log(` ${relPath(masterSpecPath)} ${c.dim('(master)')}`)
|
|
465
|
+
console.log()
|
|
466
|
+
console.log(
|
|
467
|
+
` ${c.dim('Preview:')} npx opencastle run -f ${relPath(masterSpecPath)} --dry-run\n` +
|
|
468
|
+
` ${c.dim('Execute:')} npx opencastle run -f ${relPath(masterSpecPath)}\n`
|
|
204
469
|
)
|
|
205
|
-
process.exit(1)
|
|
206
|
-
}
|
|
207
470
|
|
|
208
|
-
|
|
471
|
+
try {
|
|
472
|
+
const shouldRun = await confirm('Run the convoy chain now?', true)
|
|
473
|
+
if (shouldRun) {
|
|
474
|
+
closePrompts()
|
|
475
|
+
const runModule = await import('./run.js')
|
|
476
|
+
const runArgs = ['-f', masterSpecPath]
|
|
477
|
+
if (opts.adapter) runArgs.push('-a', opts.adapter)
|
|
478
|
+
if (opts.verbose) runArgs.push('--verbose')
|
|
479
|
+
await runModule.default({ args: runArgs, pkgRoot })
|
|
480
|
+
}
|
|
481
|
+
} finally {
|
|
482
|
+
closePrompts()
|
|
483
|
+
}
|
|
484
|
+
return
|
|
485
|
+
} else {
|
|
486
|
+
console.log(
|
|
487
|
+
c.cyan(` ℹ`) + ` Complexity: ${complexity.complexity} | Strategy: single\n`
|
|
488
|
+
)
|
|
489
|
+
}
|
|
209
490
|
}
|
|
210
491
|
|
|
211
|
-
// ── Step
|
|
212
|
-
const genStep = opts.skipValidation ? 2 :
|
|
492
|
+
// ── Step 4: Generate convoy spec ──────────────────────────────────────────
|
|
493
|
+
const genStep = opts.skipValidation ? 2 : 4
|
|
213
494
|
console.log(stepLabel(genStep, totalSteps, 'Generating convoy spec…'))
|
|
214
495
|
|
|
215
496
|
let specPath: string
|
|
@@ -233,8 +514,8 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
|
|
|
233
514
|
return
|
|
234
515
|
}
|
|
235
516
|
|
|
236
|
-
// ── Step
|
|
237
|
-
console.log(stepLabel(
|
|
517
|
+
// ── Step 5: Validate convoy spec ──────────────────────────────────────────
|
|
518
|
+
console.log(stepLabel(5, totalSteps, 'Validating convoy spec…'))
|
|
238
519
|
|
|
239
520
|
const specContent = await readFile(specPath, 'utf8')
|
|
240
521
|
let validationErrors: string
|
|
@@ -248,7 +529,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
|
|
|
248
529
|
goalText: specContent,
|
|
249
530
|
})
|
|
250
531
|
} catch (err) {
|
|
251
|
-
console.error(`\n ✗ Step
|
|
532
|
+
console.error(`\n ✗ Step 5 failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
252
533
|
process.exit(1)
|
|
253
534
|
}
|
|
254
535
|
|
|
@@ -264,13 +545,13 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
|
|
|
264
545
|
console.log()
|
|
265
546
|
}
|
|
266
547
|
|
|
267
|
-
// ── Step
|
|
548
|
+
// ── Step 6: Fix convoy spec (up to 2 retries) ─────────────────────────────
|
|
268
549
|
const MAX_FIX_RETRIES = 2
|
|
269
550
|
let fixedSpecContent = specContent
|
|
270
551
|
|
|
271
552
|
for (let attempt = 1; attempt <= MAX_FIX_RETRIES; attempt++) {
|
|
272
553
|
const label = `Fix attempt ${attempt}/${MAX_FIX_RETRIES}…`
|
|
273
|
-
console.log(stepLabel(
|
|
554
|
+
console.log(stepLabel(6, totalSteps, label))
|
|
274
555
|
|
|
275
556
|
let fixResult
|
|
276
557
|
try {
|
|
@@ -282,7 +563,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
|
|
|
282
563
|
outputPath: specPath, // overwrite in place
|
|
283
564
|
})
|
|
284
565
|
} catch (err) {
|
|
285
|
-
console.error(`\n ✗ Step
|
|
566
|
+
console.error(`\n ✗ Step 6 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
286
567
|
process.exit(1)
|
|
287
568
|
}
|
|
288
569
|
|