opencastle 0.31.0 → 0.31.2

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/README.md +3 -3
  2. package/bin/cli.mjs +4 -2
  3. package/dist/cli/convoy/spec-builder.d.ts +68 -0
  4. package/dist/cli/convoy/spec-builder.d.ts.map +1 -0
  5. package/dist/cli/convoy/spec-builder.js +179 -0
  6. package/dist/cli/convoy/spec-builder.js.map +1 -0
  7. package/dist/cli/convoy/spec-builder.test.d.ts +2 -0
  8. package/dist/cli/convoy/spec-builder.test.d.ts.map +1 -0
  9. package/dist/cli/convoy/spec-builder.test.js +453 -0
  10. package/dist/cli/convoy/spec-builder.test.js.map +1 -0
  11. package/dist/cli/pipeline.d.ts +1 -0
  12. package/dist/cli/pipeline.d.ts.map +1 -1
  13. package/dist/cli/pipeline.js +254 -185
  14. package/dist/cli/pipeline.js.map +1 -1
  15. package/dist/cli/pipeline.test.js +15 -1
  16. package/dist/cli/pipeline.test.js.map +1 -1
  17. package/dist/cli/plan.d.ts +1 -1
  18. package/dist/cli/plan.d.ts.map +1 -1
  19. package/dist/cli/plan.js +4 -4
  20. package/dist/cli/plan.js.map +1 -1
  21. package/dist/cli/prompt.js +2 -1
  22. package/dist/cli/prompt.js.map +1 -1
  23. package/dist/cli/run/adapters/claude.js +2 -2
  24. package/dist/cli/run/adapters/claude.js.map +1 -1
  25. package/dist/cli/run/adapters/copilot.js +2 -2
  26. package/dist/cli/run/adapters/copilot.js.map +1 -1
  27. package/dist/cli/run/adapters/cursor.js +1 -1
  28. package/dist/cli/run/adapters/cursor.js.map +1 -1
  29. package/dist/cli/run/adapters/opencode.js +1 -1
  30. package/dist/cli/run/adapters/opencode.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/cli/convoy/spec-builder.test.ts +523 -0
  33. package/src/cli/convoy/spec-builder.ts +221 -0
  34. package/src/cli/pipeline.test.ts +21 -1
  35. package/src/cli/pipeline.ts +274 -224
  36. package/src/cli/plan.ts +5 -4
  37. package/src/cli/prompt.ts +1 -1
  38. package/src/cli/run/adapters/claude.ts +2 -2
  39. package/src/cli/run/adapters/copilot.ts +2 -2
  40. package/src/cli/run/adapters/cursor.ts +1 -1
  41. package/src/cli/run/adapters/opencode.ts +1 -1
  42. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  43. package/src/orchestrator/prompts/fix-convoy.prompt.md +47 -56
  44. package/src/orchestrator/prompts/generate-convoy.prompt.md +85 -295
  45. package/src/orchestrator/prompts/validate-convoy.prompt.md +31 -42
@@ -4,8 +4,12 @@ import { resolve, dirname } from 'node:path'
4
4
  import { stringify } from 'yaml'
5
5
  import { c, confirm, closePrompts } from './prompt.js'
6
6
  import { runPromptStep, readProjectMcpServers } from './plan.js'
7
+ import type { PromptStepOptions } from './plan.js'
7
8
  import { cleanupAdapters } from './run/adapters/index.js'
8
9
  import type { CliContext } from './types.js'
10
+ import { parseYaml, validateSpec } from './run/schema.js'
11
+ import { buildConvoyYaml, parseTaskPlan, parsePatches, applyPatches, deriveSpecEnrichment } from './convoy/spec-builder.js'
12
+ import type { TaskPlan, SpecEnrichment } from './convoy/spec-builder.js'
9
13
 
10
14
  export interface ConvoyGroup {
11
15
  name: string
@@ -47,8 +51,15 @@ export function parseComplexityAssessment(jsonText: string): ComplexityAssessmen
47
51
  }
48
52
  }
49
53
 
54
+ export function deriveComplexityPath(prdPath: string): string {
55
+ if (prdPath.endsWith('.prd.md')) {
56
+ return prdPath.slice(0, -'.prd.md'.length) + '.complexity.json'
57
+ }
58
+ return prdPath + '.complexity.json'
59
+ }
60
+
50
61
  const HELP = `
51
- opencastle pipeline [options]
62
+ opencastle start [options]
52
63
 
53
64
  Run the full convoy generation pipeline from a feature prompt:
54
65
 
@@ -56,9 +67,9 @@ const HELP = `
56
67
  Step 2 — Validate PRD (validate-prd)
57
68
  Step 3 — Fix PRD (fix-prd, up to 2 retries if invalid)
58
69
  Step 4 — Assess complexity (assess-complexity, determines single vs chain)
59
- Step 5 — Generate convoy spec (generate-convoy, using PRD as input)
60
- Step 6 — Validate convoy spec (validate-convoy)
61
- Step 7 — Fix convoy spec (fix-convoy, up to 2 retries if invalid)
70
+ Step 5 — Generate task plan (generate-convoy outputs JSON, code builds YAML)
71
+ Step 6 — Validate convoy spec (programmatic + semantic validation)
72
+ Step 7 — Fix convoy spec (patch-based fixing, up to 2 retries)
62
73
 
63
74
  Options:
64
75
  --text, -t <text> Feature prompt text (required, unless --prd is set)
@@ -68,6 +79,7 @@ const HELP = `
68
79
  --adapter, -a <name> Override agent runtime adapter
69
80
  --verbose Show full agent output for each step
70
81
  --dry-run Generate and print the PRD prompt only, then stop
82
+ --complexity <path> Skip complexity assessment — use an existing complexity file
71
83
  --skip-validation Skip PRD and convoy validation (steps 2, 3, 6, 7)
72
84
  --help, -h Show this help
73
85
  `
@@ -75,6 +87,7 @@ const HELP = `
75
87
  interface PipelineOptions {
76
88
  text: string | null
77
89
  prd: string | null
90
+ complexity: string | null
78
91
  outputPrd: string | null
79
92
  outputSpec: string | null
80
93
  adapter: string | null
@@ -88,6 +101,7 @@ function parseArgs(args: string[]): PipelineOptions {
88
101
  const opts: PipelineOptions = {
89
102
  text: null,
90
103
  prd: null,
104
+ complexity: null,
91
105
  outputPrd: null,
92
106
  outputSpec: null,
93
107
  adapter: null,
@@ -113,6 +127,10 @@ function parseArgs(args: string[]): PipelineOptions {
113
127
  if (i + 1 >= args.length) { console.error(' ✗ --prd requires a path'); process.exit(1) }
114
128
  opts.prd = args[++i]
115
129
  break
130
+ case '--complexity':
131
+ if (i + 1 >= args.length) { console.error(' ✗ --complexity requires a path'); process.exit(1) }
132
+ opts.complexity = args[++i]
133
+ break
116
134
  case '--output-prd':
117
135
  if (i + 1 >= args.length) { console.error(' ✗ --output-prd requires a path'); process.exit(1) }
118
136
  opts.outputPrd = args[++i]
@@ -192,7 +210,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
192
210
  ...(mcpServers.length ? { mcpServers } : {}),
193
211
  }
194
212
 
195
- console.log(c.bold('\n opencastle pipeline\n'))
213
+ console.log(c.bold('\n opencastle start\n'))
196
214
 
197
215
  // ── Step 1: Generate PRD ──────────────────────────────────────────────────
198
216
  let prdPath: string
@@ -309,7 +327,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
309
327
  console.log(
310
328
  c.dim(`\n The PRD has been saved to ${relPath(prdPath)} with the best available fixes.\n`) +
311
329
  c.dim(` Review the remaining issues above and edit the file manually, then re-run with:\n`) +
312
- ` opencastle pipeline --prd ${relPath(prdPath)}${opts.adapter ? ` --adapter ${opts.adapter}` : ''}\n`
330
+ ` opencastle start --prd ${relPath(prdPath)}${opts.adapter ? ` --adapter ${opts.adapter}` : ''}\n`
313
331
  )
314
332
  process.exit(1)
315
333
  }
@@ -320,20 +338,61 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
320
338
 
321
339
  // ── Complexity-aware strategy decision ────────────────────────────────────
322
340
  const complexityStep = opts.skipValidation ? 2 : 4
323
- console.log(stepLabel(complexityStep, totalSteps, 'Assessing complexity…'))
324
341
 
325
342
  let complexity: ComplexityAssessment | null = null
326
- try {
327
- const complexityResult = await runPromptStep({
328
- ...sharedOpts,
329
- template: 'assess-complexity',
330
- filePath: prdPath,
331
- contextText: opts.text ?? undefined,
332
- })
333
- complexity = parseComplexityAssessment(complexityResult.rawOutput)
334
- } catch (err) {
335
- console.warn(c.yellow(` ⚠ Complexity assessment failed: ${err instanceof Error ? err.message : String(err)}`))
336
- console.warn(c.dim(` Falling back to single convoy strategy.\n`))
343
+ const complexityFilePath = opts.complexity
344
+ ? resolve(process.cwd(), opts.complexity)
345
+ : deriveComplexityPath(prdPath)
346
+
347
+ if (opts.complexity) {
348
+ if (!existsSync(complexityFilePath)) {
349
+ console.error(` ✗ Complexity file not found: ${opts.complexity}`)
350
+ process.exit(1)
351
+ }
352
+ try {
353
+ const raw = await readFile(complexityFilePath, 'utf8')
354
+ complexity = parseComplexityAssessment(raw)
355
+ if (complexity) {
356
+ console.log(c.dim(` [−] Using existing complexity assessment: ${relPath(complexityFilePath)}`))
357
+ } else {
358
+ console.error(` ✗ Invalid complexity file: ${opts.complexity}`)
359
+ process.exit(1)
360
+ }
361
+ } catch (err) {
362
+ console.error(` ✗ Failed to read complexity file: ${err instanceof Error ? err.message : String(err)}`)
363
+ process.exit(1)
364
+ }
365
+ } else if (existsSync(complexityFilePath)) {
366
+ try {
367
+ const raw = await readFile(complexityFilePath, 'utf8')
368
+ const cached = parseComplexityAssessment(raw)
369
+ if (cached) {
370
+ complexity = cached
371
+ console.log(c.dim(` [−] Using existing complexity assessment: ${relPath(complexityFilePath)}`))
372
+ }
373
+ } catch {
374
+ // ignore — fall through to LLM assessment
375
+ }
376
+ }
377
+
378
+ if (!complexity) {
379
+ console.log(stepLabel(complexityStep, totalSteps, 'Assessing complexity…'))
380
+ try {
381
+ const complexityResult = await runPromptStep({
382
+ ...sharedOpts,
383
+ template: 'assess-complexity',
384
+ filePath: prdPath,
385
+ contextText: opts.text ?? undefined,
386
+ })
387
+ complexity = parseComplexityAssessment(complexityResult.rawOutput)
388
+ if (complexity) {
389
+ await writeFile(complexityFilePath, JSON.stringify(complexity, null, 2), 'utf8')
390
+ console.log(c.green(` ✓ Complexity assessment saved to ${relPath(complexityFilePath)}\n`))
391
+ }
392
+ } catch (err) {
393
+ console.warn(c.yellow(` ⚠ Complexity assessment failed: ${err instanceof Error ? err.message : String(err)}`))
394
+ console.warn(c.dim(` Falling back to single convoy strategy.\n`))
395
+ }
337
396
  }
338
397
 
339
398
  if (!complexity) {
@@ -360,22 +419,10 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
360
419
  const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
361
420
  await mkdir(convoyDir, { recursive: true })
362
421
 
363
- const genBaseStep = opts.skipValidation ? 2 : 4
364
422
  const groupSpecPaths: string[] = []
365
- const totalGroupSteps =
366
- (opts.skipValidation ? 2 : 3) + complexity.convoy_groups.length * (opts.skipValidation ? 1 : 2)
367
423
 
368
424
  for (let i = 0; i < complexity.convoy_groups.length; i++) {
369
425
  const group = complexity.convoy_groups[i]
370
- const groupStep = genBaseStep + i * (opts.skipValidation ? 1 : 2)
371
-
372
- console.log(
373
- stepLabel(
374
- groupStep,
375
- totalGroupSteps,
376
- `Generating convoy spec for group: ${group.name}…`
377
- )
378
- )
379
426
 
380
427
  const chainGoal = [
381
428
  complexity.original_prompt,
@@ -394,122 +441,16 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
394
441
  const prdContent = await readFile(prdPath, 'utf8')
395
442
  const groupSpecPath = resolve(convoyDir, `${group.name}.convoy.yml`)
396
443
 
397
- let groupResult
398
- try {
399
- groupResult = await runPromptStep({
400
- ...sharedOpts,
401
- template: 'generate-convoy',
402
- goalText: chainGoal,
403
- contextText: prdContent,
404
- outputPath: groupSpecPath,
405
- })
406
- } catch (err) {
407
- console.error(
408
- `\n ✗ Step ${groupStep} failed: ${err instanceof Error ? err.message : String(err)}`
409
- )
410
- process.exit(1)
411
- }
412
-
413
- const resolvedGroupSpecPath = groupResult.outputPath ?? groupSpecPath
444
+ const { specPath: resolvedGroupSpecPath } = await generateAndValidateSpec({
445
+ sharedOpts,
446
+ goalText: chainGoal,
447
+ contextText: prdContent,
448
+ specPath: groupSpecPath,
449
+ skipValidation: opts.skipValidation,
450
+ groupName: group.name,
451
+ enrichment: complexity ? deriveSpecEnrichment(complexity) : undefined,
452
+ })
414
453
  groupSpecPaths.push(resolvedGroupSpecPath)
415
-
416
- console.log(c.green(` ✓ Group spec written to ${relPath(resolvedGroupSpecPath)}\n`))
417
-
418
- if (!opts.skipValidation) {
419
- const valStep = groupStep + 1
420
- console.log(stepLabel(valStep, totalGroupSteps, `Validating spec: ${group.name}…`))
421
-
422
- let currentSpecContent = await readFile(resolvedGroupSpecPath, 'utf8')
423
- let groupValidation
424
- try {
425
- groupValidation = await runPromptStep({
426
- ...sharedOpts,
427
- template: 'validate-convoy',
428
- goalText: currentSpecContent,
429
- })
430
- } catch (err) {
431
- console.error(
432
- `\n ✗ Validation failed for group ${group.name}: ${err instanceof Error ? err.message : String(err)}`
433
- )
434
- process.exit(1)
435
- }
436
-
437
- if (groupValidation.isValid) {
438
- console.log(c.green(` ✓ Spec valid\n`))
439
- } else {
440
- let groupErrors = groupValidation.errors ?? groupValidation.rawOutput
441
- let groupFixed = false
442
-
443
- for (let attempt = 1; attempt <= MAX_FIX_RETRIES; attempt++) {
444
- console.log(
445
- c.yellow(` ⚠ Spec has issues — fix attempt ${attempt}/${MAX_FIX_RETRIES} for ${group.name}…\n`)
446
- )
447
- console.log(c.dim(groupErrors))
448
- console.log()
449
-
450
- try {
451
- await runPromptStep({
452
- ...sharedOpts,
453
- template: 'fix-convoy',
454
- goalText: currentSpecContent,
455
- contextText: groupErrors,
456
- outputPath: resolvedGroupSpecPath,
457
- })
458
- } catch (err) {
459
- console.error(
460
- `\n ✗ Fix failed for group ${group.name} (attempt ${attempt}): ${err instanceof Error ? err.message : String(err)}`
461
- )
462
- process.exit(1)
463
- }
464
-
465
- console.log(c.dim(` Re-validating ${group.name} after fix…`))
466
-
467
- currentSpecContent = await readFile(resolvedGroupSpecPath, 'utf8')
468
-
469
- let revalidation
470
- try {
471
- revalidation = await runPromptStep({
472
- ...sharedOpts,
473
- template: 'validate-convoy',
474
- goalText: currentSpecContent,
475
- })
476
- } catch (err) {
477
- console.error(
478
- `\n ✗ Re-validation failed for group ${group.name}: ${err instanceof Error ? err.message : String(err)}`
479
- )
480
- process.exit(1)
481
- }
482
-
483
- if (revalidation.isValid) {
484
- console.log(c.green(` ✓ ${group.name} fixed and validated\n`))
485
- groupFixed = true
486
- break
487
- }
488
-
489
- groupErrors = revalidation.errors ?? revalidation.rawOutput
490
-
491
- if (attempt < MAX_FIX_RETRIES) {
492
- console.log(
493
- c.yellow(` ⚠ Still has issues after fix attempt ${attempt} — retrying…\n`)
494
- )
495
- }
496
- }
497
-
498
- if (!groupFixed) {
499
- console.log(
500
- c.red(`\n ✗ Could not auto-fix convoy spec for group ${group.name} after ${MAX_FIX_RETRIES} attempts.\n`)
501
- )
502
- console.log(` Remaining issues:\n`)
503
- console.log(groupErrors)
504
- console.log(
505
- c.dim(`\n The spec has been saved to ${relPath(resolvedGroupSpecPath)} with best available fixes.\n`) +
506
- c.dim(` Review the issues above and edit manually, then re-validate with:\n`) +
507
- ` opencastle plan --file ${relPath(resolvedGroupSpecPath)} --template validate-convoy\n`
508
- )
509
- process.exit(1)
510
- }
511
- }
512
- }
513
454
  }
514
455
 
515
456
  // Build master pipeline spec (version 2)
@@ -567,99 +508,84 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
567
508
  }
568
509
 
569
510
  // ── Generate convoy spec ──────────────────────────────────────────────────
570
- const genStep = opts.skipValidation ? 3 : 5
571
- console.log(stepLabel(genStep, totalSteps, 'Generating convoy spec…'))
572
-
573
511
  const singlePrdContent = await readFile(prdPath, 'utf8')
574
512
  const singleGoal = complexity?.original_prompt ?? opts.text ?? ''
575
513
 
576
- let specPath: string
577
- try {
578
- const result = await runPromptStep({
579
- ...sharedOpts,
580
- template: 'generate-convoy',
581
- goalText: singleGoal,
582
- contextText: singlePrdContent,
583
- outputPath: opts.outputSpec ? resolve(process.cwd(), opts.outputSpec) : undefined,
584
- })
585
- specPath = result.outputPath!
586
- } catch (err) {
587
- console.error(`\n ✗ Step ${genStep} failed: ${err instanceof Error ? err.message : String(err)}`)
588
- process.exit(1)
589
- }
590
-
591
- console.log(c.green(` ✓ Convoy spec written to ${relPath(specPath)}\n`))
514
+ const specResult = await generateAndValidateSpec({
515
+ sharedOpts,
516
+ goalText: singleGoal,
517
+ contextText: singlePrdContent,
518
+ specPath: opts.outputSpec ? resolve(process.cwd(), opts.outputSpec) : undefined,
519
+ skipValidation: opts.skipValidation,
520
+ enrichment: complexity ? deriveSpecEnrichment(complexity) : undefined,
521
+ })
592
522
 
593
- if (opts.skipValidation) {
594
- await printFinalSummary(prdPath, specPath, opts, pkgRoot)
595
- return
596
- }
523
+ await printFinalSummary(prdPath, specResult.specPath, opts, pkgRoot)
524
+ }
597
525
 
598
- // ── Validate convoy spec ──────────────────────────────────────────────
599
- const valStep = opts.skipValidation ? 4 : 6
600
- console.log(stepLabel(valStep, totalSteps, 'Validating convoy spec…'))
526
+ async function fixViaPatch(
527
+ taskPlan: TaskPlan,
528
+ errors: string,
529
+ sharedOpts: Omit<PromptStepOptions, 'template' | 'goalText' | 'contextText'>,
530
+ specPath: string,
531
+ enrichment?: SpecEnrichment,
532
+ ): Promise<TaskPlan> {
533
+ let currentPlan = taskPlan
534
+ let currentErrors = errors
601
535
 
602
- const specContent = await readFile(specPath, 'utf8')
603
- let validationErrors: string
536
+ for (let attempt = 1; attempt <= MAX_FIX_RETRIES; attempt++) {
537
+ console.log(c.dim(` Fix attempt ${attempt}/${MAX_FIX_RETRIES}…`))
604
538
 
605
- {
606
- let result
539
+ let fixResult
607
540
  try {
608
- result = await runPromptStep({
541
+ fixResult = await runPromptStep({
609
542
  ...sharedOpts,
610
- template: 'validate-convoy',
611
- goalText: specContent,
543
+ template: 'fix-convoy',
544
+ goalText: JSON.stringify(currentPlan, null, 2),
545
+ contextText: currentErrors,
612
546
  })
613
547
  } catch (err) {
614
- console.error(`\n ✗ Step ${valStep} failed: ${err instanceof Error ? err.message : String(err)}`)
548
+ console.error(`\n ✗ Fix attempt ${attempt} failed: ${err instanceof Error ? err.message : String(err)}`)
615
549
  process.exit(1)
616
550
  }
617
551
 
618
- if (result.isValid) {
619
- console.log(c.green(` ✓ Convoy spec is valid\n`))
620
- await printFinalSummary(prdPath, specPath, opts, pkgRoot)
621
- return
552
+ const patches = parsePatches(fixResult.rawOutput)
553
+ if (!patches || patches.length === 0) {
554
+ console.warn(c.yellow(` ⚠ No valid patches returned`))
555
+ if (attempt >= MAX_FIX_RETRIES) break
556
+ continue
622
557
  }
623
558
 
624
- validationErrors = result.errors ?? result.rawOutput
625
- console.log(c.yellow(` ⚠ Spec has validation issues — attempting auto-fix…\n`))
626
- console.log(c.dim(validationErrors))
627
- console.log()
628
- }
629
-
630
- // ── Fix convoy spec (up to 2 retries) ─────────────────────────────────
631
- const fixStep = opts.skipValidation ? 4 : 7
632
- let fixedSpecContent = specContent
633
-
634
- for (let attempt = 1; attempt <= MAX_FIX_RETRIES; attempt++) {
635
- const label = `Fix attempt ${attempt}/${MAX_FIX_RETRIES}…`
636
- console.log(stepLabel(fixStep, totalSteps, label))
559
+ console.log(c.dim(` Applied ${patches.length} patches`))
560
+ currentPlan = applyPatches(currentPlan, patches)
637
561
 
638
- let fixResult
562
+ // Rebuild YAML and re-validate
563
+ const yaml = buildConvoyYaml(currentPlan, enrichment)
639
564
  try {
640
- fixResult = await runPromptStep({
641
- ...sharedOpts,
642
- template: 'fix-convoy',
643
- goalText: fixedSpecContent,
644
- contextText: validationErrors,
645
- outputPath: specPath, // overwrite in place
646
- })
565
+ const parsed = parseYaml(yaml)
566
+ const { valid, errors: schemaErrors } = validateSpec(parsed)
567
+ if (!valid) {
568
+ currentErrors = schemaErrors.map(e => `- Schema: ${e}`).join('\n')
569
+ if (attempt < MAX_FIX_RETRIES) {
570
+ console.log(c.yellow(` ⚠ Still has schema issues — retrying…\n`))
571
+ console.log(c.dim(currentErrors))
572
+ }
573
+ continue
574
+ }
647
575
  } catch (err) {
648
- console.error(`\n ✗ Step 6 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
649
- process.exit(1)
576
+ currentErrors = `YAML error: ${err instanceof Error ? err.message : String(err)}`
577
+ continue
650
578
  }
651
579
 
580
+ await writeFile(specPath, yaml, 'utf8')
652
581
  console.log(c.dim(` Re-validating after fix…`))
653
582
 
654
- // Read the newly written spec
655
- fixedSpecContent = await readFile(specPath, 'utf8')
656
-
657
583
  let revalidation
658
584
  try {
659
585
  revalidation = await runPromptStep({
660
586
  ...sharedOpts,
661
587
  template: 'validate-convoy',
662
- goalText: fixedSpecContent,
588
+ goalText: yaml,
663
589
  })
664
590
  } catch (err) {
665
591
  console.error(`\n ✗ Re-validation failed: ${err instanceof Error ? err.message : String(err)}`)
@@ -667,32 +593,156 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
667
593
  }
668
594
 
669
595
  if (revalidation.isValid) {
670
- console.log(c.green(` ✓ Spec fixed and validated\n`))
671
- await printFinalSummary(prdPath, specPath, opts, pkgRoot)
672
- return
596
+ console.log(c.green(` ✓ Fixed and validated\n`))
597
+ return currentPlan
673
598
  }
674
599
 
675
- validationErrors = revalidation.errors ?? revalidation.rawOutput
676
-
600
+ currentErrors = revalidation.errors ?? revalidation.rawOutput
677
601
  if (attempt < MAX_FIX_RETRIES) {
678
- console.log(c.yellow(` ⚠ Still has issues after fix attempt ${attempt} — retrying…\n`))
679
- console.log(c.dim(validationErrors))
680
- console.log()
602
+ console.log(c.yellow(` ⚠ Still has issues — retrying…\n`))
603
+ console.log(c.dim(currentErrors))
681
604
  }
682
605
  }
683
606
 
684
- // All retries exhausted
685
- console.log(c.red(`\n ✗ Could not auto-fix the convoy spec after ${MAX_FIX_RETRIES} attempts.\n`))
607
+ // Exhausted write best effort and exit
608
+ await writeFile(specPath, buildConvoyYaml(currentPlan, enrichment), 'utf8')
609
+ console.log(c.red(`\n ✗ Could not auto-fix after ${MAX_FIX_RETRIES} attempts.\n`))
686
610
  console.log(` Remaining issues:\n`)
687
- console.log(validationErrors)
611
+ console.log(currentErrors)
688
612
  console.log(
689
- c.dim(`\n The spec has been saved to ${relPath(specPath)} with the best available fixes.\n`) +
690
- c.dim(` Review the remaining issues above and edit the file manually, then validate with:\n`) +
691
- ` opencastle plan --file ${relPath(specPath)} --template validate-convoy\n`
613
+ c.dim(`\n Spec saved to ${relPath(specPath)} with best available fixes.\n`) +
614
+ c.dim(` Edit manually, then re-validate with:\n`) +
615
+ ` opencastle plan --file ${relPath(specPath)} --template validate-convoy\n`
692
616
  )
693
617
  process.exit(1)
694
618
  }
695
619
 
620
+ async function generateAndValidateSpec(params: {
621
+ sharedOpts: Omit<PromptStepOptions, 'template' | 'goalText' | 'contextText'>
622
+ goalText: string
623
+ contextText: string
624
+ specPath?: string
625
+ skipValidation: boolean
626
+ groupName?: string
627
+ enrichment?: SpecEnrichment
628
+ }): Promise<{ specPath: string; taskPlan: TaskPlan }> {
629
+ const label = params.groupName
630
+ ? `Generating task plan: ${params.groupName}…`
631
+ : 'Generating task plan…'
632
+ console.log(c.cyan(` ${label}`))
633
+
634
+ let taskPlanResult
635
+ try {
636
+ taskPlanResult = await runPromptStep({
637
+ ...params.sharedOpts,
638
+ template: 'generate-convoy',
639
+ goalText: params.goalText,
640
+ contextText: params.contextText,
641
+ })
642
+ } catch (err) {
643
+ console.error(`\n ✗ Task plan generation failed: ${err instanceof Error ? err.message : String(err)}`)
644
+ process.exit(1)
645
+ }
646
+
647
+ let taskPlan = parseTaskPlan(taskPlanResult.rawOutput)
648
+ if (!taskPlan) {
649
+ console.log(c.yellow(` ⚠ Failed to parse task plan JSON — retrying generation…\n`))
650
+ if (params.sharedOpts.verbose) {
651
+ console.log(c.dim(taskPlanResult.rawOutput.slice(0, 500)))
652
+ }
653
+
654
+ let retryResult
655
+ try {
656
+ retryResult = await runPromptStep({
657
+ ...params.sharedOpts,
658
+ template: 'generate-convoy',
659
+ goalText: params.goalText,
660
+ contextText: params.contextText,
661
+ })
662
+ } catch (err) {
663
+ console.error(`\n ✗ Retry failed: ${err instanceof Error ? err.message : String(err)}`)
664
+ process.exit(1)
665
+ }
666
+
667
+ taskPlan = parseTaskPlan(retryResult.rawOutput)
668
+ if (!taskPlan) {
669
+ console.error(' ✗ Failed to parse task plan JSON after retry')
670
+ console.error(c.dim(retryResult.rawOutput.slice(0, 500)))
671
+ process.exit(1)
672
+ }
673
+ }
674
+
675
+ console.log(c.green(` ✓ Task plan generated (${taskPlan.tasks.length} tasks)`))
676
+
677
+ // Derive spec path from plan name if not provided
678
+ let resolvedSpecPath = params.specPath
679
+ if (!resolvedSpecPath) {
680
+ const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
681
+ await mkdir(convoyDir, { recursive: true })
682
+ const kebab = taskPlan.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
683
+ resolvedSpecPath = resolve(convoyDir, `${kebab}.convoy.yml`)
684
+ }
685
+
686
+ // Build YAML from JSON task plan
687
+ let yamlContent = buildConvoyYaml(taskPlan, params.enrichment)
688
+ await mkdir(resolve(resolvedSpecPath, '..'), { recursive: true })
689
+ await writeFile(resolvedSpecPath, yamlContent, 'utf8')
690
+ console.log(c.green(` ✓ Convoy spec written to ${relPath(resolvedSpecPath)}\n`))
691
+
692
+ if (!params.skipValidation) {
693
+ // Programmatic validation first
694
+ try {
695
+ const parsed = parseYaml(yamlContent)
696
+ const { valid, errors: schemaErrors } = validateSpec(parsed)
697
+ if (!valid) {
698
+ console.log(c.yellow(` ⚠ Schema validation issues — auto-fixing…\n`))
699
+ const errorText = schemaErrors.map(e => `- Schema: ${e}`).join('\n')
700
+ console.log(c.dim(errorText))
701
+ console.log()
702
+ taskPlan = await fixViaPatch(taskPlan, errorText, params.sharedOpts, resolvedSpecPath, params.enrichment)
703
+ yamlContent = buildConvoyYaml(taskPlan, params.enrichment)
704
+ await writeFile(resolvedSpecPath, yamlContent, 'utf8')
705
+ } else {
706
+ console.log(c.dim(` ✓ Schema validation passed`))
707
+ }
708
+ } catch (err) {
709
+ console.warn(c.yellow(` ⚠ YAML warning: ${err instanceof Error ? err.message : String(err)}`))
710
+ }
711
+
712
+ // Semantic validation (LLM)
713
+ const valLabel = params.groupName
714
+ ? `Validating spec: ${params.groupName}…`
715
+ : 'Validating convoy spec…'
716
+ console.log(c.cyan(` ${valLabel}`))
717
+
718
+ let semanticResult
719
+ try {
720
+ semanticResult = await runPromptStep({
721
+ ...params.sharedOpts,
722
+ template: 'validate-convoy',
723
+ goalText: await readFile(resolvedSpecPath, 'utf8'),
724
+ })
725
+ } catch (err) {
726
+ console.error(`\n ✗ Semantic validation failed: ${err instanceof Error ? err.message : String(err)}`)
727
+ process.exit(1)
728
+ }
729
+
730
+ if (semanticResult.isValid) {
731
+ console.log(c.green(` ✓ Spec is valid\n`))
732
+ } else {
733
+ const semanticErrors = semanticResult.errors ?? semanticResult.rawOutput
734
+ console.log(c.yellow(` ⚠ Semantic issues — auto-fixing…\n`))
735
+ console.log(c.dim(semanticErrors))
736
+ console.log()
737
+ taskPlan = await fixViaPatch(taskPlan, semanticErrors, params.sharedOpts, resolvedSpecPath, params.enrichment)
738
+ yamlContent = buildConvoyYaml(taskPlan, params.enrichment)
739
+ await writeFile(resolvedSpecPath, yamlContent, 'utf8')
740
+ }
741
+ }
742
+
743
+ return { specPath: resolvedSpecPath, taskPlan }
744
+ }
745
+
696
746
  async function printFinalSummary(
697
747
  prdPath: string,
698
748
  specPath: string,
package/src/cli/plan.ts CHANGED
@@ -268,7 +268,7 @@ function startProgress(templateName: string): () => void {
268
268
 
269
269
  /**
270
270
  * Execute a single prompt template step via an AI adapter.
271
- * Used by the pipeline command to chain steps programmatically.
271
+ * Used by the start command to chain steps programmatically.
272
272
  */
273
273
  export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStepResult> {
274
274
  const templateName = opts.template ?? 'generate-convoy'
@@ -364,9 +364,10 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
364
364
  }
365
365
 
366
366
  if (outputType === 'json') {
367
- // Extract JSON from fenced block or raw output
368
- const jsonMatch = rawOutput.match(/```(?:json)?\s*\n([\s\S]*?)```/)
369
- const jsonContent = jsonMatch ? jsonMatch[1].trim() : rawOutput.trim()
367
+ // Extract JSON from a ```json fenced block only
368
+ const jsonFenceMatch = rawOutput.match(/```json\s*\n([\s\S]*)```/)
369
+ const jsonContent = jsonFenceMatch ? jsonFenceMatch[1].trim() : rawOutput.trim()
370
+
370
371
  const outputPath = opts.outputPath ?? null
371
372
  if (outputPath) {
372
373
  await mkdir(resolve(outputPath, '..'), { recursive: true })
package/src/cli/prompt.ts CHANGED
@@ -120,7 +120,7 @@ export function closePrompts(): void {
120
120
  // Node.js TTY stdin can remain active even after readline.close() — unref it
121
121
  // so the process exits cleanly.
122
122
  stdin.pause();
123
- stdin.unref();
123
+ if (typeof stdin.unref === 'function') stdin.unref();
124
124
  }
125
125
 
126
126
  /**