opencastle 0.32.8 → 0.32.10

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 (33) hide show
  1. package/dist/cli/convoy/spec-builder.d.ts +16 -0
  2. package/dist/cli/convoy/spec-builder.d.ts.map +1 -1
  3. package/dist/cli/convoy/spec-builder.js +115 -62
  4. package/dist/cli/convoy/spec-builder.js.map +1 -1
  5. package/dist/cli/pipeline.d.ts.map +1 -1
  6. package/dist/cli/pipeline.js +279 -116
  7. package/dist/cli/pipeline.js.map +1 -1
  8. package/dist/cli/plan.d.ts.map +1 -1
  9. package/dist/cli/plan.js +19 -7
  10. package/dist/cli/plan.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/cli/convoy/spec-builder.ts +124 -58
  13. package/src/cli/pipeline.ts +324 -128
  14. package/src/cli/plan.ts +25 -7
  15. package/src/dashboard/dist/data/convoys/demo-api-v2.json +3 -3
  16. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +4 -4
  17. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +18 -18
  18. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +9 -9
  19. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +1 -1
  20. package/src/dashboard/dist/data/convoys/demo-docs-update.json +3 -3
  21. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +4 -4
  22. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  23. package/src/dashboard/public/data/convoys/demo-api-v2.json +3 -3
  24. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +4 -4
  25. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +18 -18
  26. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +9 -9
  27. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +1 -1
  28. package/src/dashboard/public/data/convoys/demo-docs-update.json +3 -3
  29. package/src/dashboard/public/data/convoys/demo-perf-opt.json +4 -4
  30. package/src/orchestrator/prompts/fix-prd.prompt.md +4 -9
  31. package/src/orchestrator/prompts/generate-convoy.prompt.md +1 -0
  32. package/src/orchestrator/prompts/generate-prd.prompt.md +29 -0
  33. package/src/orchestrator/prompts/validate-prd.prompt.md +14 -37
@@ -1,6 +1,6 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
1
+ import { readFile, writeFile, mkdir, unlink } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
- import { resolve, dirname } from 'node:path'
3
+ import { resolve } 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'
@@ -8,7 +8,7 @@ import type { PromptStepOptions } from './plan.js'
8
8
  import { cleanupAdapters } from './run/adapters/index.js'
9
9
  import type { CliContext } from './types.js'
10
10
  import { parseYaml, validateSpec } from './run/schema.js'
11
- import { buildConvoyYaml, parseTaskPlan, parsePatches, applyPatches, deriveSpecEnrichment } from './convoy/spec-builder.js'
11
+ import { buildConvoyYaml, parseTaskPlanWithReason, parsePatches, applyPatches, deriveSpecEnrichment, detectJsonTruncation } from './convoy/spec-builder.js'
12
12
  import type { TaskPlan, SpecEnrichment } from './convoy/spec-builder.js'
13
13
 
14
14
  export interface ConvoyGroup {
@@ -29,6 +29,104 @@ function appendTaskComplexity(base: string, taskComplexity: ComplexityAssessment
29
29
  return result
30
30
  }
31
31
 
32
+ /**
33
+ * For chain mode, extract only the PRD sections relevant to the given phases.
34
+ * Keeps Overview, Technical Requirements, and the matching phase sections from
35
+ * Task Breakdown, while trimming the full User Stories, Implementation Scope,
36
+ * and non-matching phases to reduce context size and avoid output truncation.
37
+ */
38
+ function extractRelevantPrdSections(prdContent: string, phases: number[]): string {
39
+ const phaseSet = new Set(phases)
40
+ const lines = prdContent.split('\n')
41
+ const result: string[] = []
42
+
43
+ // Sections to always include (key context)
44
+ const alwaysInclude = ['overview', 'goals', 'non-goals', 'technical requirements']
45
+ // Sections to include condensed
46
+ const condenseSection = ['user stories & acceptance criteria', 'implementation scope', 'success criteria', 'risks & open questions']
47
+
48
+ let currentSection = ''
49
+ let inTaskBreakdown = false
50
+ let inRelevantPhase = false
51
+ let skipSection = false
52
+
53
+ for (const line of lines) {
54
+ // Detect heading (## level)
55
+ const h2Match = line.match(/^## (.+)/)
56
+ if (h2Match) {
57
+ const heading = h2Match[1].trim().toLowerCase()
58
+ currentSection = heading
59
+ inTaskBreakdown = heading === 'task breakdown'
60
+ inRelevantPhase = false
61
+ skipSection = false
62
+
63
+ if (alwaysInclude.some(s => heading.startsWith(s))) {
64
+ result.push(line)
65
+ continue
66
+ }
67
+
68
+ if (inTaskBreakdown) {
69
+ result.push(line)
70
+ result.push('')
71
+ result.push(`*(Only phases ${phases.join(', ')} shown — other phases omitted for brevity)*`)
72
+ result.push('')
73
+ continue
74
+ }
75
+
76
+ if (condenseSection.some(s => heading.startsWith(s))) {
77
+ // Include the heading but mark as condensed
78
+ result.push(line)
79
+ result.push('')
80
+ result.push('*(Condensed — see full PRD for details)*')
81
+ result.push('')
82
+ skipSection = true
83
+ continue
84
+ }
85
+
86
+ // # title heading — always include
87
+ result.push(line)
88
+ continue
89
+ }
90
+
91
+ // H1 heading — always include
92
+ if (line.match(/^# /)) {
93
+ result.push(line)
94
+ continue
95
+ }
96
+
97
+ if (skipSection) continue
98
+
99
+ if (inTaskBreakdown) {
100
+ // Detect phase headers like "Phase 1 —" or "Phase 2 —"
101
+ const phaseMatch = line.match(/Phase\s+(\d+)/i)
102
+ if (phaseMatch) {
103
+ const phaseNum = parseInt(phaseMatch[1], 10)
104
+ inRelevantPhase = phaseSet.has(phaseNum)
105
+ }
106
+ if (inRelevantPhase) {
107
+ result.push(line)
108
+ }
109
+ continue
110
+ }
111
+
112
+ result.push(line)
113
+ }
114
+
115
+ return result.join('\n')
116
+ }
117
+
118
+ /**
119
+ * Filter task complexity entries to only those matching the given phases.
120
+ */
121
+ function filterTaskComplexityByPhases(
122
+ taskComplexity: ComplexityAssessment['task_complexity'],
123
+ phases: number[],
124
+ ): ComplexityAssessment['task_complexity'] {
125
+ if (!taskComplexity?.length) return taskComplexity
126
+ const phaseSet = new Set(phases)
127
+ return taskComplexity.filter(tc => phaseSet.has(tc.phase))
128
+ }
129
+
32
130
  export interface ComplexityAssessment {
33
131
  original_prompt: string
34
132
  total_tasks: number
@@ -310,6 +408,79 @@ function stepLabel(n: number, total: number, name: string): string {
310
408
  return c.bold(c.cyan(` [${n}/${total}] ${name}`))
311
409
  }
312
410
 
411
+ async function fixPrd(
412
+ sharedOpts: Omit<PromptStepOptions, 'template' | 'goalText'>,
413
+ prdPath: string,
414
+ prdContent: string,
415
+ initialErrors: string,
416
+ totalSteps: number,
417
+ adapterFlag: string | null,
418
+ ): Promise<void> {
419
+ const MAX_PRD_FIX_RETRIES = 2
420
+ let fixedPrdContent = prdContent
421
+ let prdValidationErrors = initialErrors
422
+ let prdFixed = false
423
+
424
+ for (let attempt = 1; attempt <= MAX_PRD_FIX_RETRIES; attempt++) {
425
+ const label = `Fix PRD attempt ${attempt}/${MAX_PRD_FIX_RETRIES}…`
426
+ console.log(stepLabel(3, totalSteps, label))
427
+
428
+ try {
429
+ await runPromptStep({
430
+ ...sharedOpts,
431
+ template: 'fix-prd',
432
+ goalText: fixedPrdContent,
433
+ contextText: prdValidationErrors,
434
+ outputPath: prdPath,
435
+ })
436
+ } catch (err) {
437
+ console.error(`\n ✗ Step 3 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
438
+ process.exit(1)
439
+ }
440
+
441
+ console.log(c.dim(` Re-validating after fix…`))
442
+
443
+ fixedPrdContent = await readFile(prdPath, 'utf8')
444
+
445
+ let revalidation
446
+ try {
447
+ revalidation = await runPromptStep({
448
+ ...sharedOpts,
449
+ template: 'validate-prd',
450
+ goalText: `<!-- validation-pass: ${attempt + 1} -->\n${fixedPrdContent}`,
451
+ })
452
+ } catch (err) {
453
+ console.error(`\n ✗ Re-validation failed: ${err instanceof Error ? err.message : String(err)}`)
454
+ process.exit(1)
455
+ }
456
+
457
+ if (revalidation.isValid) {
458
+ console.log(c.green(` ✓ PRD fixed and validated\n`))
459
+ prdFixed = true
460
+ break
461
+ }
462
+
463
+ prdValidationErrors = revalidation.errors ?? revalidation.rawOutput
464
+
465
+ if (attempt < MAX_PRD_FIX_RETRIES) {
466
+ console.log(c.yellow(` ⚠ Still has issues after fix attempt ${attempt} — retrying…\n`))
467
+ console.log(c.dim(prdValidationErrors))
468
+ console.log()
469
+ }
470
+ }
471
+
472
+ if (!prdFixed) {
473
+ console.log(c.yellow(`\n ⚠ Could not fully auto-fix the PRD after ${MAX_PRD_FIX_RETRIES} attempts — continuing with best-effort PRD.\n`))
474
+ console.log(c.dim(` Remaining issues:\n`))
475
+ console.log(c.dim(prdValidationErrors))
476
+ console.log(
477
+ c.dim(`\n PRD saved to ${relPath(prdPath)} with best available fixes.`) +
478
+ c.dim(`\n You can re-validate later with:\n`) +
479
+ ` opencastle start --prd ${relPath(prdPath)}${adapterFlag ? ` --adapter ${adapterFlag}` : ''}\n`
480
+ )
481
+ }
482
+ }
483
+
313
484
  export default async function pipeline({ args, pkgRoot }: CliContext): Promise<void> {
314
485
  const opts = parseArgs(args)
315
486
 
@@ -386,98 +557,11 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
386
557
  return
387
558
  }
388
559
 
389
- // ── Step 2: Validate PRD ──────────────────────────────────────────────────
390
- if (!opts.skipValidation) {
391
- console.log(stepLabel(2, totalSteps, 'Validating PRD…'))
392
-
393
- const prdContent = await readFile(prdPath, 'utf8')
394
- let result
395
- try {
396
- result = await runPromptStep({
397
- ...sharedOpts,
398
- template: 'validate-prd',
399
- goalText: `<!-- validation-pass: 1 -->\n${prdContent}`,
400
- })
401
- } catch (err) {
402
- console.error(`\n ✗ Step 2 failed: ${err instanceof Error ? err.message : String(err)}`)
403
- process.exit(1)
404
- }
405
-
406
- if (!result.isValid) {
407
- let prdValidationErrors = result.errors ?? result.rawOutput
408
- console.log(c.yellow(` ⚠ PRD has validation issues — attempting auto-fix…\n`))
409
- console.log(c.dim(prdValidationErrors))
410
- console.log()
411
-
412
- // ── Step 3: Fix PRD (up to 2 retries) ──────────────────────────────────
413
- const MAX_PRD_FIX_RETRIES = 2
414
- let fixedPrdContent = prdContent
415
- let prdFixed = false
416
-
417
- for (let attempt = 1; attempt <= MAX_PRD_FIX_RETRIES; attempt++) {
418
- const label = `Fix PRD attempt ${attempt}/${MAX_PRD_FIX_RETRIES}…`
419
- console.log(stepLabel(3, totalSteps, label))
420
-
421
- try {
422
- await runPromptStep({
423
- ...sharedOpts,
424
- template: 'fix-prd',
425
- goalText: fixedPrdContent,
426
- contextText: prdValidationErrors,
427
- outputPath: prdPath, // overwrite in place
428
- })
429
- } catch (err) {
430
- console.error(`\n ✗ Step 3 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
431
- process.exit(1)
432
- }
433
-
434
- console.log(c.dim(` Re-validating after fix…`))
435
-
436
- fixedPrdContent = await readFile(prdPath, 'utf8')
437
-
438
- let revalidation
439
- try {
440
- revalidation = await runPromptStep({
441
- ...sharedOpts,
442
- template: 'validate-prd',
443
- goalText: `<!-- validation-pass: ${attempt + 1} -->\n${fixedPrdContent}`,
444
- })
445
- } catch (err) {
446
- console.error(`\n ✗ Re-validation failed: ${err instanceof Error ? err.message : String(err)}`)
447
- process.exit(1)
448
- }
449
-
450
- if (revalidation.isValid) {
451
- console.log(c.green(` ✓ PRD fixed and validated\n`))
452
- prdFixed = true
453
- break
454
- }
455
-
456
- prdValidationErrors = revalidation.errors ?? revalidation.rawOutput
457
-
458
- if (attempt < MAX_PRD_FIX_RETRIES) {
459
- console.log(c.yellow(` ⚠ Still has issues after fix attempt ${attempt} — retrying…\n`))
460
- console.log(c.dim(prdValidationErrors))
461
- console.log()
462
- }
463
- }
464
-
465
- if (!prdFixed) {
466
- console.log(c.yellow(`\n ⚠ Could not fully auto-fix the PRD after ${MAX_PRD_FIX_RETRIES} attempts — continuing with best-effort PRD.\n`))
467
- console.log(c.dim(` Remaining issues:\n`))
468
- console.log(c.dim(prdValidationErrors))
469
- console.log(
470
- c.dim(`\n PRD saved to ${relPath(prdPath)} with best available fixes.`) +
471
- c.dim(`\n You can re-validate later with:\n`) +
472
- ` opencastle start --prd ${relPath(prdPath)}${opts.adapter ? ` --adapter ${opts.adapter}` : ''}\n`
473
- )
474
- }
475
- } else {
476
- console.log(c.green(` ✓ PRD is valid\n`))
477
- }
478
- }
560
+ // ── Steps 2 + 4: Validate PRD & Assess complexity (in parallel) ────────
561
+ // Both only read the PRD — run them concurrently to save one LLM round-trip.
562
+ // If validation fails we still use the complexity result (fix-prd patches
563
+ // issues without changing the overall structure/phases).
479
564
 
480
- // ── Complexity-aware strategy decision ────────────────────────────────────
481
565
  const complexityStep = opts.skipValidation ? 2 : 4
482
566
 
483
567
  let complexity: ComplexityAssessment | null = null
@@ -485,6 +569,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
485
569
  ? resolve(process.cwd(), opts.complexity)
486
570
  : deriveComplexityPath(prdPath)
487
571
 
572
+ // Check for cached / provided complexity before launching LLM calls
488
573
  if (opts.complexity) {
489
574
  if (!existsSync(complexityFilePath)) {
490
575
  console.error(` ✗ Complexity file not found: ${opts.complexity}`)
@@ -516,23 +601,107 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
516
601
  }
517
602
  }
518
603
 
519
- if (!complexity) {
520
- console.log(stepLabel(complexityStep, totalSteps, 'Assessing complexity…'))
521
- try {
522
- const complexityResult = await runPromptStep({
523
- ...sharedOpts,
524
- template: 'assess-complexity',
525
- filePath: prdPath,
526
- contextText: opts.text ?? undefined,
527
- })
528
- complexity = parseComplexityAssessment(complexityResult.rawOutput)
529
- if (complexity) {
530
- await writeFile(complexityFilePath, JSON.stringify(complexity, null, 2), 'utf8')
531
- console.log(c.green(` ✓ Complexity assessment saved to ${relPath(complexityFilePath)}\n`))
604
+ const needsValidation = !opts.skipValidation
605
+ const needsComplexity = !complexity
606
+
607
+ if (needsValidation || needsComplexity) {
608
+ const prdContent = await readFile(prdPath, 'utf8')
609
+
610
+ // Launch validation and complexity in parallel when both are needed
611
+ if (needsValidation && needsComplexity) {
612
+ console.log(stepLabel(2, totalSteps, 'Validating PRD…'))
613
+ console.log(stepLabel(complexityStep, totalSteps, 'Assessing complexity…'))
614
+ console.log(c.dim(` (running in parallel)\n`))
615
+
616
+ const [validationResult, complexityResult] = await Promise.all([
617
+ runPromptStep({
618
+ ...sharedOpts,
619
+ template: 'validate-prd',
620
+ goalText: `<!-- validation-pass: 1 -->\n${prdContent}`,
621
+ }).catch((err) => {
622
+ console.error(`\n ✗ Validation failed: ${err instanceof Error ? err.message : String(err)}`)
623
+ return null
624
+ }),
625
+ runPromptStep({
626
+ ...sharedOpts,
627
+ template: 'assess-complexity',
628
+ filePath: prdPath,
629
+ contextText: opts.text ?? undefined,
630
+ }).catch((err) => {
631
+ console.warn(c.yellow(` ⚠ Complexity assessment failed: ${err instanceof Error ? err.message : String(err)}`))
632
+ return null
633
+ }),
634
+ ])
635
+
636
+ // Process complexity result
637
+ if (complexityResult) {
638
+ complexity = parseComplexityAssessment(complexityResult.rawOutput)
639
+ if (complexity) {
640
+ await writeFile(complexityFilePath, JSON.stringify(complexity, null, 2), 'utf8')
641
+ console.log(c.green(` ✓ Complexity assessment saved to ${relPath(complexityFilePath)}`))
642
+ }
643
+ }
644
+
645
+ // Process validation result
646
+ if (!validationResult) {
647
+ process.exit(1)
648
+ }
649
+
650
+ if (!validationResult.isValid) {
651
+ let prdValidationErrors = validationResult.errors ?? validationResult.rawOutput
652
+ console.log(c.yellow(` ⚠ PRD has validation issues — attempting auto-fix…\n`))
653
+ console.log(c.dim(prdValidationErrors))
654
+ console.log()
655
+
656
+ await fixPrd(sharedOpts, prdPath, prdContent, prdValidationErrors, totalSteps, opts.adapter)
657
+ } else {
658
+ console.log(c.green(` ✓ PRD is valid\n`))
659
+ }
660
+ } else if (needsValidation) {
661
+ // Only validation needed (complexity was cached)
662
+ console.log(stepLabel(2, totalSteps, 'Validating PRD…'))
663
+
664
+ let result
665
+ try {
666
+ result = await runPromptStep({
667
+ ...sharedOpts,
668
+ template: 'validate-prd',
669
+ goalText: `<!-- validation-pass: 1 -->\n${prdContent}`,
670
+ })
671
+ } catch (err) {
672
+ console.error(`\n ✗ Step 2 failed: ${err instanceof Error ? err.message : String(err)}`)
673
+ process.exit(1)
674
+ }
675
+
676
+ if (!result.isValid) {
677
+ let prdValidationErrors = result.errors ?? result.rawOutput
678
+ console.log(c.yellow(` ⚠ PRD has validation issues — attempting auto-fix…\n`))
679
+ console.log(c.dim(prdValidationErrors))
680
+ console.log()
681
+
682
+ await fixPrd(sharedOpts, prdPath, prdContent, prdValidationErrors, totalSteps, opts.adapter)
683
+ } else {
684
+ console.log(c.green(` ✓ PRD is valid\n`))
685
+ }
686
+ } else {
687
+ // Only complexity needed (validation skipped)
688
+ console.log(stepLabel(complexityStep, totalSteps, 'Assessing complexity…'))
689
+ try {
690
+ const complexityResult = await runPromptStep({
691
+ ...sharedOpts,
692
+ template: 'assess-complexity',
693
+ filePath: prdPath,
694
+ contextText: opts.text ?? undefined,
695
+ })
696
+ complexity = parseComplexityAssessment(complexityResult.rawOutput)
697
+ if (complexity) {
698
+ await writeFile(complexityFilePath, JSON.stringify(complexity, null, 2), 'utf8')
699
+ console.log(c.green(` ✓ Complexity assessment saved to ${relPath(complexityFilePath)}\n`))
700
+ }
701
+ } catch (err) {
702
+ console.warn(c.yellow(` ⚠ Complexity assessment failed: ${err instanceof Error ? err.message : String(err)}`))
703
+ console.warn(c.dim(` Falling back to single convoy strategy.\n`))
532
704
  }
533
- } catch (err) {
534
- console.warn(c.yellow(` ⚠ Complexity assessment failed: ${err instanceof Error ? err.message : String(err)}`))
535
- console.warn(c.dim(` Falling back to single convoy strategy.\n`))
536
705
  }
537
706
  }
538
707
 
@@ -569,10 +738,18 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
569
738
  const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
570
739
  await mkdir(convoyDir, { recursive: true })
571
740
 
741
+ // Extract feature name early for convoy naming
742
+ const chainPrdContent = await readFile(prdPath, 'utf8')
743
+ const featureNameMatch = chainPrdContent.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
744
+ const featureName = featureNameMatch
745
+ ? featureNameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
746
+ : 'feature'
747
+
572
748
  const groupSpecPaths: string[] = []
573
749
 
574
750
  for (let i = 0; i < complexity.convoy_groups.length; i++) {
575
751
  const group = complexity.convoy_groups[i]
752
+ console.log(c.cyan(` [${i + 1}/${complexity.convoy_groups.length}] Generating convoy: ${group.name}`) + c.dim(` (phases: ${group.phases.join(', ')})`))
576
753
 
577
754
  const chainGoal = [
578
755
  complexity.original_prompt,
@@ -589,8 +766,10 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
589
766
  ].filter(Boolean).join('\n')
590
767
 
591
768
  const prdContent = await readFile(prdPath, 'utf8')
592
- const contextForSpec = appendTaskComplexity(prdContent, complexity?.task_complexity)
593
- const groupSpecPath = resolve(convoyDir, `${group.name}.convoy.yml`)
769
+ const relevantPrd = extractRelevantPrdSections(prdContent, group.phases)
770
+ const relevantComplexity = filterTaskComplexityByPhases(complexity?.task_complexity, group.phases)
771
+ const contextForSpec = appendTaskComplexity(relevantPrd, relevantComplexity)
772
+ const groupSpecPath = resolve(convoyDir, `${featureName}-${group.name}.convoy.yml`)
594
773
 
595
774
  const { specPath: resolvedGroupSpecPath } = await generateAndValidateSpec({
596
775
  sharedOpts,
@@ -599,18 +778,13 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
599
778
  specPath: groupSpecPath,
600
779
  skipValidation: opts.skipValidation,
601
780
  groupName: group.name,
781
+ featureName,
602
782
  enrichment: complexity ? deriveSpecEnrichment(complexity) : undefined,
603
783
  })
604
784
  groupSpecPaths.push(resolvedGroupSpecPath)
605
785
  }
606
786
 
607
787
  // Build master pipeline spec (version 2)
608
- const chainPrdContent = await readFile(prdPath, 'utf8')
609
- const featureNameMatch = chainPrdContent.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
610
- const featureName = featureNameMatch
611
- ? featureNameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
612
- : 'feature'
613
-
614
788
  const branchMatch = chainPrdContent.match(/`feat\/([^`]+)`/)
615
789
  const branch = branchMatch ? `feat/${branchMatch[1]}` : `feat/${featureName}`
616
790
 
@@ -777,6 +951,7 @@ async function generateAndValidateSpec(params: {
777
951
  specPath?: string
778
952
  skipValidation: boolean
779
953
  groupName?: string
954
+ featureName?: string
780
955
  enrichment?: SpecEnrichment
781
956
  }): Promise<{ specPath: string; taskPlan: TaskPlan }> {
782
957
  const label = params.groupName
@@ -784,6 +959,14 @@ async function generateAndValidateSpec(params: {
784
959
  : 'Generating task plan…'
785
960
  console.log(c.cyan(` ${label}`))
786
961
 
962
+ // Temp file for debugging — kept on failure, deleted on success
963
+ const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
964
+ await mkdir(convoyDir, { recursive: true })
965
+ const tempJsonName = params.groupName
966
+ ? `${params.featureName ? `${params.featureName}-` : ''}${params.groupName}.task-plan.json`
967
+ : `${params.featureName ?? 'task-plan'}.task-plan.json`
968
+ const tempJsonPath = resolve(convoyDir, tempJsonName)
969
+
787
970
  let taskPlanResult
788
971
  try {
789
972
  taskPlanResult = await runPromptStep({
@@ -797,12 +980,15 @@ async function generateAndValidateSpec(params: {
797
980
  process.exit(1)
798
981
  }
799
982
 
800
- let taskPlan = parseTaskPlan(taskPlanResult.rawOutput)
801
- if (!taskPlan) {
802
- console.log(c.yellow(` ⚠ Failed to parse task plan JSON — retrying generation…\n`))
803
- if (params.sharedOpts.verbose) {
804
- console.log(c.dim(taskPlanResult.rawOutput.slice(0, 500)))
805
- }
983
+ // Write raw JSON to temp file for debugging
984
+ await writeFile(tempJsonPath, taskPlanResult.rawOutput + '\n', 'utf8')
985
+
986
+ let result = parseTaskPlanWithReason(taskPlanResult.rawOutput)
987
+ if (!result.plan) {
988
+ console.log(c.yellow(` ⚠ Failed to parse task plan JSON: ${result.reason}`))
989
+ console.log(c.dim(` Output length: ${taskPlanResult.rawOutput.length} chars`))
990
+ console.log(c.dim(` Temp file: ${relPath(tempJsonPath)}`))
991
+ console.log(c.yellow(` Retrying generation…\n`))
806
992
 
807
993
  let retryResult
808
994
  try {
@@ -817,14 +1003,24 @@ async function generateAndValidateSpec(params: {
817
1003
  process.exit(1)
818
1004
  }
819
1005
 
820
- taskPlan = parseTaskPlan(retryResult.rawOutput)
821
- if (!taskPlan) {
822
- console.error(' ✗ Failed to parse task plan JSON after retry')
823
- console.error(c.dim(retryResult.rawOutput.slice(0, 500)))
1006
+ // Overwrite temp file with retry output
1007
+ await writeFile(tempJsonPath, retryResult.rawOutput + '\n', 'utf8')
1008
+
1009
+ result = parseTaskPlanWithReason(retryResult.rawOutput)
1010
+ if (!result.plan) {
1011
+ console.error(` ✗ Failed to parse task plan JSON after retry: ${result.reason}`)
1012
+ console.error(c.dim(` Output length: ${retryResult.rawOutput.length} chars`))
1013
+ console.error(c.dim(` Raw JSON saved to ${relPath(tempJsonPath)} for inspection.`))
1014
+ console.error(c.dim(`\n${retryResult.rawOutput}`))
824
1015
  process.exit(1)
825
1016
  }
826
1017
  }
827
1018
 
1019
+ let taskPlan = result.plan
1020
+
1021
+ // Success — clean up temp file
1022
+ await unlink(tempJsonPath).catch(() => {})
1023
+
828
1024
  console.log(c.green(` ✓ Task plan generated (${taskPlan.tasks.length} tasks)`))
829
1025
 
830
1026
  // Derive spec path from plan name if not provided
package/src/cli/plan.ts CHANGED
@@ -152,8 +152,8 @@ function parseFrontmatter(text: string): Record<string, string> {
152
152
 
153
153
  /** Extract YAML content from a fenced ```yaml ... ``` block. */
154
154
  function extractYamlBlock(text: string): string | null {
155
- // 1. Prefer explicit yaml/yml fence
156
- const yamlFence = text.match(/```ya?ml\s*\n([\s\S]*?)```/)
155
+ // 1. Prefer explicit yaml/yml fence — greedy to skip backticks inside content
156
+ const yamlFence = text.match(/```ya?ml\s*\n([\s\S]*)```/)
157
157
  if (yamlFence) return yamlFence[1].trim()
158
158
 
159
159
  // 2. Fallback: any code fence whose content looks like a convoy spec
@@ -363,6 +363,10 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
363
363
  max_retries: 1,
364
364
  }
365
365
 
366
+ if (opts.verbose) {
367
+ console.log(c.dim(` Adapter: ${adapterName} | Template: ${templateName} | Agent: ${agentField}`))
368
+ }
369
+
366
370
  const stop = opts.verbose ? null : startProgress(templateName)
367
371
  let execResult
368
372
  try {
@@ -373,8 +377,20 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
373
377
  } finally {
374
378
  stop?.()
375
379
  }
380
+
381
+ if (!execResult.success) {
382
+ throw new Error(
383
+ `Adapter "${adapterName}" returned failure (exit code ${execResult.exitCode}) for template "${templateName}".\n` +
384
+ `Output (${execResult.output.length} chars):\n${execResult.output.slice(0, 2000)}`
385
+ )
386
+ }
387
+
376
388
  const rawOutput = execResult.output
377
389
 
390
+ if (opts.verbose) {
391
+ console.log(c.dim(` Output: ${rawOutput.length} chars | Exit code: ${execResult.exitCode}`))
392
+ }
393
+
378
394
  if (outputType === 'validation') {
379
395
  const { isValid, errors } = parseValidationResult(rawOutput)
380
396
  return { outputPath: null, rawOutput, outputType, isValid, errors }
@@ -382,13 +398,15 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
382
398
 
383
399
  if (outputType === 'json') {
384
400
  // Extract JSON from a ```json fenced block — fail fast if missing
385
- const jsonFenceMatch = rawOutput.match(/```json\s*\n([\s\S]*?)```/)
401
+ // Use greedy match (.*) to capture up to the LAST closing fence,
402
+ // since task prompts may contain triple backticks in code examples
403
+ const jsonFenceMatch = rawOutput.match(/```json\s*\n([\s\S]*)```/)
386
404
  if (!jsonFenceMatch) {
387
- const preview = rawOutput.slice(0, 300)
388
405
  throw new Error(
389
406
  `Expected a fenced \`\`\`json block in the AI response but found none.\n\n` +
390
- `Raw output (truncated):\n${preview}\n\n` +
391
- `Tip: re-run with --verbose to see the full output.`
407
+ `Adapter: ${adapterName} | Output length: ${rawOutput.length} chars\n\n` +
408
+ `Raw output:\n${rawOutput}\n\n` +
409
+ `Tip: re-run with --verbose to see the full adapter output.`
392
410
  )
393
411
  }
394
412
  const jsonContent = jsonFenceMatch[1].trim()
@@ -417,7 +435,7 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
417
435
  // convoy-spec (default)
418
436
  const yamlContent = extractYamlBlock(rawOutput)
419
437
  if (!yamlContent) {
420
- const preview = rawOutput.slice(0, 500)
438
+ const preview = rawOutput.slice(0, 10_000)
421
439
  throw new Error(
422
440
  `No YAML code block found in the agent response.\n\nRaw output (truncated):\n${preview}`
423
441
  )
@@ -51,21 +51,21 @@
51
51
  "name": "docs/api-v2-contract.json",
52
52
  "type": "json",
53
53
  "task_id": "api-t1",
54
- "created_at": "2026-03-18T01:38:47.256Z"
54
+ "created_at": "2026-03-18T16:27:21.682Z"
55
55
  },
56
56
  {
57
57
  "id": "artifact-demo-api-v2-reports-security-gate-failure-md",
58
58
  "name": "reports/security-gate-failure.md",
59
59
  "type": "summary",
60
60
  "task_id": "api-t3",
61
- "created_at": "2026-03-18T01:38:47.256Z"
61
+ "created_at": "2026-03-18T16:27:21.682Z"
62
62
  },
63
63
  {
64
64
  "id": "artifact-demo-api-v2-src-api-rate-limiter-ts",
65
65
  "name": "src/api/rate-limiter.ts",
66
66
  "type": "file",
67
67
  "task_id": "api-t2",
68
- "created_at": "2026-03-18T01:38:47.256Z"
68
+ "created_at": "2026-03-18T16:27:21.682Z"
69
69
  }
70
70
  ],
71
71
  "has_more_events": false,