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.
@@ -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
+ })
@@ -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 — Generate convoy spec (generate-convoy, using PRD as BDO)
16
- Step 4 — Validate convoy spec (validate-convoy)
17
- Step 5 — Fix convoy spec (fix-convoy, up to 2 retries if invalid)
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 : 5
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
- console.log(c.red(` ✗ PRD validation failed.\n`))
200
- console.log(result.errors ?? result.rawOutput)
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.dim(`\n Fix the PRD at ${relPath(prdPath)} and re-run with:\n`) +
203
- ` opencastle pipeline --prd ${relPath(prdPath)}${opts.adapter ? ` --adapter ${opts.adapter}` : ''}\n`
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
- console.log(c.green(` ✓ PRD is valid\n`))
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 3: Generate convoy spec ──────────────────────────────────────────
212
- const genStep = opts.skipValidation ? 2 : 3
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 4: Validate convoy spec ──────────────────────────────────────────
237
- console.log(stepLabel(4, totalSteps, 'Validating convoy spec…'))
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 4 failed: ${err instanceof Error ? err.message : String(err)}`)
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 5: Fix convoy spec (up to 2 retries) ─────────────────────────────
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(5, totalSteps, label))
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 5 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
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