opencastle 0.30.1 → 0.31.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.
Files changed (36) hide show
  1. package/dist/cli/pipeline.d.ts +2 -1
  2. package/dist/cli/pipeline.d.ts.map +1 -1
  3. package/dist/cli/pipeline.js +120 -59
  4. package/dist/cli/pipeline.js.map +1 -1
  5. package/dist/cli/pipeline.test.js +82 -143
  6. package/dist/cli/pipeline.test.js.map +1 -1
  7. package/dist/cli/plan.d.ts +1 -1
  8. package/dist/cli/plan.d.ts.map +1 -1
  9. package/dist/cli/plan.js +43 -10
  10. package/dist/cli/plan.js.map +1 -1
  11. package/dist/cli/run/adapters/copilot.d.ts +1 -0
  12. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  13. package/dist/cli/run/adapters/copilot.js +11 -1
  14. package/dist/cli/run/adapters/copilot.js.map +1 -1
  15. package/dist/cli/run/adapters/index.d.ts +5 -0
  16. package/dist/cli/run/adapters/index.d.ts.map +1 -1
  17. package/dist/cli/run/adapters/index.js +13 -0
  18. package/dist/cli/run/adapters/index.js.map +1 -1
  19. package/dist/cli/run.d.ts.map +1 -1
  20. package/dist/cli/run.js +62 -9
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/cli/types.d.ts +2 -0
  23. package/dist/cli/types.d.ts.map +1 -1
  24. package/package.json +1 -1
  25. package/src/cli/pipeline.test.ts +82 -140
  26. package/src/cli/pipeline.ts +142 -61
  27. package/src/cli/plan.ts +47 -11
  28. package/src/cli/run/adapters/copilot.ts +11 -1
  29. package/src/cli/run/adapters/index.ts +13 -0
  30. package/src/cli/run.ts +60 -9
  31. package/src/cli/types.ts +2 -0
  32. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  33. package/src/orchestrator/prompts/assess-complexity.prompt.md +67 -0
  34. package/src/orchestrator/prompts/generate-convoy.prompt.md +14 -24
  35. package/src/orchestrator/prompts/generate-prd.prompt.md +20 -34
  36. package/src/orchestrator/prompts/validate-prd.prompt.md +1 -1
@@ -4,6 +4,7 @@ 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 { cleanupAdapters } from './run/adapters/index.js'
7
8
  import type { CliContext } from './types.js'
8
9
 
9
10
  export interface ConvoyGroup {
@@ -14,6 +15,7 @@ export interface ConvoyGroup {
14
15
  }
15
16
 
16
17
  export interface ComplexityAssessment {
18
+ original_prompt: string
17
19
  total_tasks: number
18
20
  total_phases: number
19
21
  domains: string[]
@@ -24,18 +26,12 @@ export interface ComplexityAssessment {
24
26
  convoy_groups: ConvoyGroup[]
25
27
  }
26
28
 
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
-
29
+ export function parseComplexityAssessment(jsonText: string): ComplexityAssessment | null {
35
30
  try {
36
- const parsed = JSON.parse(jsonMatch[1].trim()) as ComplexityAssessment
31
+ const parsed = JSON.parse(jsonText.trim()) as ComplexityAssessment
37
32
  // Validate required fields
38
33
  if (
34
+ typeof parsed.original_prompt !== 'string' ||
39
35
  typeof parsed.total_tasks !== 'number' ||
40
36
  typeof parsed.total_phases !== 'number' ||
41
37
  !Array.isArray(parsed.domains) ||
@@ -59,9 +55,10 @@ const HELP = `
59
55
  Step 1 — Generate PRD (generate-prd)
60
56
  Step 2 — Validate PRD (validate-prd)
61
57
  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)
58
+ 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)
65
62
 
66
63
  Options:
67
64
  --text, -t <text> Feature prompt text (required, unless --prd is set)
@@ -71,7 +68,7 @@ const HELP = `
71
68
  --adapter, -a <name> Override agent runtime adapter
72
69
  --verbose Show full agent output for each step
73
70
  --dry-run Generate and print the PRD prompt only, then stop
74
- --skip-validation Skip steps 2 and 4 (PRD and convoy validation)
71
+ --skip-validation Skip PRD and convoy validation (steps 2, 3, 6, 7)
75
72
  --help, -h Show this help
76
73
  `
77
74
 
@@ -149,6 +146,8 @@ function parseArgs(args: string[]): PipelineOptions {
149
146
  return opts
150
147
  }
151
148
 
149
+ const MAX_FIX_RETRIES = 2
150
+
152
151
  function relPath(abs: string): string {
153
152
  return abs.startsWith(process.cwd()) ? abs.slice(process.cwd().length + 1) : abs
154
153
  }
@@ -184,7 +183,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
184
183
  }
185
184
  }
186
185
 
187
- const totalSteps = opts.skipValidation ? 3 : 6
186
+ const totalSteps = opts.skipValidation ? 4 : 7
188
187
  const mcpServers = await readProjectMcpServers(process.cwd())
189
188
  const sharedOpts = {
190
189
  adapterName: opts.adapter ?? undefined,
@@ -320,8 +319,26 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
320
319
  }
321
320
 
322
321
  // ── Complexity-aware strategy decision ────────────────────────────────────
323
- const prdContentForComplexity = await readFile(prdPath, 'utf8')
324
- const complexity = parseComplexityAssessment(prdContentForComplexity)
322
+ const complexityStep = opts.skipValidation ? 2 : 4
323
+ console.log(stepLabel(complexityStep, totalSteps, 'Assessing complexity…'))
324
+
325
+ 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`))
337
+ }
338
+
339
+ if (!complexity) {
340
+ console.log(c.dim(` Could not determine complexity — using single convoy strategy.\n`))
341
+ }
325
342
 
326
343
  if (complexity) {
327
344
  if (complexity.recommended_strategy === 'chain' && complexity.convoy_groups.length > 1) {
@@ -360,16 +377,21 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
360
377
  )
361
378
  )
362
379
 
363
- const chainContext = JSON.stringify({
364
- mode: 'chain_subset',
365
- group_name: group.name,
366
- group_description: group.description,
367
- group_phases: group.phases,
368
- depends_on_groups: group.depends_on,
369
- total_groups: complexity.convoy_groups.length,
370
- group_index: i + 1,
371
- })
372
-
380
+ const chainGoal = [
381
+ complexity.original_prompt,
382
+ '',
383
+ '## Convoy Group Scope',
384
+ '',
385
+ `This is group **${i + 1} of ${complexity.convoy_groups.length}** in a convoy chain.`,
386
+ `Generate a convoy spec covering ONLY the phases listed below.`,
387
+ '',
388
+ `- **Group name:** ${group.name}`,
389
+ `- **Description:** ${group.description}`,
390
+ `- **Phases to include:** ${group.phases.join(', ')}`,
391
+ group.depends_on.length ? `- **Depends on groups:** ${group.depends_on.join(', ')}` : '',
392
+ ].filter(Boolean).join('\n')
393
+
394
+ const prdContent = await readFile(prdPath, 'utf8')
373
395
  const groupSpecPath = resolve(convoyDir, `${group.name}.convoy.yml`)
374
396
 
375
397
  let groupResult
@@ -377,8 +399,8 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
377
399
  groupResult = await runPromptStep({
378
400
  ...sharedOpts,
379
401
  template: 'generate-convoy',
380
- filePath: prdPath,
381
- contextText: chainContext,
402
+ goalText: chainGoal,
403
+ contextText: prdContent,
382
404
  outputPath: groupSpecPath,
383
405
  })
384
406
  } catch (err) {
@@ -397,13 +419,13 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
397
419
  const valStep = groupStep + 1
398
420
  console.log(stepLabel(valStep, totalGroupSteps, `Validating spec: ${group.name}…`))
399
421
 
400
- const groupSpecContent = await readFile(resolvedGroupSpecPath, 'utf8')
422
+ let currentSpecContent = await readFile(resolvedGroupSpecPath, 'utf8')
401
423
  let groupValidation
402
424
  try {
403
425
  groupValidation = await runPromptStep({
404
426
  ...sharedOpts,
405
427
  template: 'validate-convoy',
406
- goalText: groupSpecContent,
428
+ goalText: currentSpecContent,
407
429
  })
408
430
  } catch (err) {
409
431
  console.error(
@@ -412,40 +434,92 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
412
434
  process.exit(1)
413
435
  }
414
436
 
415
- if (!groupValidation.isValid) {
416
- console.log(c.yellow(` Spec has issues — attempting one auto-fix…\n`))
417
- console.log(c.dim(groupValidation.errors ?? groupValidation.rawOutput))
418
- console.log()
419
-
420
- try {
421
- await runPromptStep({
422
- ...sharedOpts,
423
- template: 'fix-convoy',
424
- goalText: groupSpecContent,
425
- contextText: groupValidation.errors ?? groupValidation.rawOutput,
426
- outputPath: resolvedGroupSpecPath,
427
- })
428
- } catch (err) {
429
- console.error(
430
- `\n ✗ Fix failed for group ${group.name}: ${err instanceof Error ? err.message : String(err)}`
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`)
431
446
  )
432
- process.exit(1)
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
+ }
433
496
  }
434
497
 
435
- console.log(c.dim(` Applied fix for ${group.name}\n`))
436
- } else {
437
- console.log(c.green(` Spec valid\n`))
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
+ }
438
511
  }
439
512
  }
440
513
  }
441
514
 
442
515
  // Build master pipeline spec (version 2)
443
- const featureNameMatch = prdContentForComplexity.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
516
+ const chainPrdContent = await readFile(prdPath, 'utf8')
517
+ const featureNameMatch = chainPrdContent.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
444
518
  const featureName = featureNameMatch
445
519
  ? featureNameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
446
520
  : 'feature'
447
521
 
448
- const branchMatch = prdContentForComplexity.match(/`feat\/([^`]+)`/)
522
+ const branchMatch = chainPrdContent.match(/`feat\/([^`]+)`/)
449
523
  const branch = branchMatch ? `feat/${branchMatch[1]}` : `feat/${featureName}`
450
524
 
451
525
  const masterSpec = {
@@ -482,6 +556,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
482
556
  }
483
557
  } finally {
484
558
  closePrompts()
559
+ await cleanupAdapters()
485
560
  }
486
561
  return
487
562
  } else {
@@ -491,16 +566,20 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
491
566
  }
492
567
  }
493
568
 
494
- // ── Step 4: Generate convoy spec ──────────────────────────────────────────
495
- const genStep = opts.skipValidation ? 2 : 4
569
+ // ── Generate convoy spec ──────────────────────────────────────────────────
570
+ const genStep = opts.skipValidation ? 3 : 5
496
571
  console.log(stepLabel(genStep, totalSteps, 'Generating convoy spec…'))
497
572
 
573
+ const singlePrdContent = await readFile(prdPath, 'utf8')
574
+ const singleGoal = complexity?.original_prompt ?? opts.text ?? ''
575
+
498
576
  let specPath: string
499
577
  try {
500
578
  const result = await runPromptStep({
501
579
  ...sharedOpts,
502
580
  template: 'generate-convoy',
503
- filePath: prdPath,
581
+ goalText: singleGoal,
582
+ contextText: singlePrdContent,
504
583
  outputPath: opts.outputSpec ? resolve(process.cwd(), opts.outputSpec) : undefined,
505
584
  })
506
585
  specPath = result.outputPath!
@@ -516,8 +595,9 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
516
595
  return
517
596
  }
518
597
 
519
- // ── Step 5: Validate convoy spec ──────────────────────────────────────────
520
- console.log(stepLabel(5, totalSteps, 'Validating convoy spec…'))
598
+ // ── Validate convoy spec ──────────────────────────────────────────────
599
+ const valStep = opts.skipValidation ? 4 : 6
600
+ console.log(stepLabel(valStep, totalSteps, 'Validating convoy spec…'))
521
601
 
522
602
  const specContent = await readFile(specPath, 'utf8')
523
603
  let validationErrors: string
@@ -531,7 +611,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
531
611
  goalText: specContent,
532
612
  })
533
613
  } catch (err) {
534
- console.error(`\n ✗ Step 5 failed: ${err instanceof Error ? err.message : String(err)}`)
614
+ console.error(`\n ✗ Step ${valStep} failed: ${err instanceof Error ? err.message : String(err)}`)
535
615
  process.exit(1)
536
616
  }
537
617
 
@@ -547,13 +627,13 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
547
627
  console.log()
548
628
  }
549
629
 
550
- // ── Step 6: Fix convoy spec (up to 2 retries) ─────────────────────────────
551
- const MAX_FIX_RETRIES = 2
630
+ // ── Fix convoy spec (up to 2 retries) ─────────────────────────────────
631
+ const fixStep = opts.skipValidation ? 4 : 7
552
632
  let fixedSpecContent = specContent
553
633
 
554
634
  for (let attempt = 1; attempt <= MAX_FIX_RETRIES; attempt++) {
555
635
  const label = `Fix attempt ${attempt}/${MAX_FIX_RETRIES}…`
556
- console.log(stepLabel(6, totalSteps, label))
636
+ console.log(stepLabel(fixStep, totalSteps, label))
557
637
 
558
638
  let fixResult
559
639
  try {
@@ -641,5 +721,6 @@ async function printFinalSummary(
641
721
  }
642
722
  } finally {
643
723
  closePrompts()
724
+ await cleanupAdapters()
644
725
  }
645
726
  }
package/src/cli/plan.ts CHANGED
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
3
  import { resolve, join, basename } from 'node:path'
4
4
  import { mkdir, writeFile } from 'node:fs/promises'
5
- import { getAdapter, detectAdapter } from './run/adapters/index.js'
5
+ import { getAdapter, detectAdapter, cleanupAdapters } from './run/adapters/index.js'
6
6
  import { parseTaskSpecText } from './run/schema.js'
7
7
  import { c } from './prompt.js'
8
8
  import type { CliContext, Task } from './types.js'
@@ -19,11 +19,13 @@ const HELP = `
19
19
  --text, -t <text> Inline text to use as {{goal}} (alternative to --file)
20
20
  --template <name> Prompt template name (default: generate-convoy)
21
21
  Built-in templates:
22
- generate-prd — Write a PRD from a feature prompt
23
- validate-prd — Check a PRD for completeness
24
- generate-convoy Generate a convoy spec from a PRD (default)
25
- validate-convoyCheck a convoy spec for correctness
26
- fix-convoy Fix validation errors in a convoy spec
22
+ generate-prd — Write a PRD from a feature prompt
23
+ validate-prd — Check a PRD for completeness
24
+ fix-prd Fix validation errors in a PRD
25
+ assess-complexityAssess PRD complexity (returns JSON)
26
+ generate-convoy Generate a convoy spec from a PRD (default)
27
+ validate-convoy — Check a convoy spec for correctness
28
+ fix-convoy — Fix validation errors in a convoy spec
27
29
  --context <path> Optional path to an additional context file (fills {{context}})
28
30
  --context-text <text> Inline text to fill {{context}} (alternative to --context)
29
31
  --output, -o <path> Output path override (skipped for validation output)
@@ -77,7 +79,7 @@ export interface PromptStepResult {
77
79
  /** Raw text returned by the AI adapter (or assembled prompt on dry-run) */
78
80
  rawOutput: string
79
81
  /** How the output was interpreted */
80
- outputType: 'convoy-spec' | 'prd' | 'validation'
82
+ outputType: 'convoy-spec' | 'prd' | 'validation' | 'json'
81
83
  /** Set when outputType === 'validation' */
82
84
  isValid?: boolean
83
85
  /** Set when outputType === 'validation' and isValid === false */
@@ -150,9 +152,19 @@ function parseFrontmatter(text: string): Record<string, string> {
150
152
 
151
153
  /** Extract YAML content from a fenced ```yaml ... ``` block. */
152
154
  function extractYamlBlock(text: string): string | null {
153
- const match = text.match(/```ya?ml\s*\n([\s\S]*?)```/)
154
- if (!match) return null
155
- return match[1].trim()
155
+ // 1. Prefer explicit yaml/yml fence
156
+ const yamlFence = text.match(/```ya?ml\s*\n([\s\S]*?)```/)
157
+ if (yamlFence) return yamlFence[1].trim()
158
+
159
+ // 2. Fallback: any code fence whose content looks like a convoy spec
160
+ // Must contain at least `name:` AND `tasks:` to avoid false positives
161
+ const genericFences = [...text.matchAll(/```\s*\n([\s\S]*?)```/g)]
162
+ for (const m of genericFences) {
163
+ const content = m[1].trim()
164
+ if (/^name:/m.test(content) && /^tasks:/m.test(content)) return content
165
+ }
166
+
167
+ return null
156
168
  }
157
169
 
158
170
  /**
@@ -274,7 +286,7 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
274
286
 
275
287
  const rawTemplate = await readFile(templatePath, 'utf8')
276
288
  const frontmatter = parseFrontmatter(rawTemplate)
277
- const outputType = (frontmatter['output'] ?? 'convoy-spec') as 'convoy-spec' | 'prd' | 'validation'
289
+ const outputType = (frontmatter['output'] ?? 'convoy-spec') as 'convoy-spec' | 'prd' | 'validation' | 'json'
278
290
  const template = stripFrontmatter(rawTemplate)
279
291
 
280
292
  let goalContent = opts.goalText ?? ''
@@ -351,6 +363,18 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
351
363
  return { outputPath: null, rawOutput, outputType, isValid, errors }
352
364
  }
353
365
 
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()
370
+ const outputPath = opts.outputPath ?? null
371
+ if (outputPath) {
372
+ await mkdir(resolve(outputPath, '..'), { recursive: true })
373
+ await writeFile(outputPath, jsonContent + '\n', 'utf8')
374
+ }
375
+ return { outputPath, rawOutput: jsonContent, outputType }
376
+ }
377
+
354
378
  if (outputType === 'prd') {
355
379
  const content = extractMarkdownBody(rawOutput)
356
380
  let outputPath = opts.outputPath ?? null
@@ -585,6 +609,16 @@ export default async function plan({ args, pkgRoot }: CliContext): Promise<void>
585
609
  console.log(`\n ${c.dim('Next step:')} opencastle plan --file ${relPath} --template validate-prd`)
586
610
  break
587
611
  }
612
+ case 'json': {
613
+ if (result.outputPath) {
614
+ const relP = result.outputPath.startsWith(process.cwd())
615
+ ? result.outputPath.slice(process.cwd().length + 1)
616
+ : result.outputPath
617
+ console.log(c.green(` ✓ JSON written to ${relP}`))
618
+ }
619
+ console.log(result.rawOutput)
620
+ break
621
+ }
588
622
  default: {
589
623
  const relPath = result.outputPath!.startsWith(process.cwd())
590
624
  ? result.outputPath!.slice(process.cwd().length + 1)
@@ -599,4 +633,6 @@ export default async function plan({ args, pkgRoot }: CliContext): Promise<void>
599
633
  `)
600
634
  }
601
635
  }
636
+
637
+ await cleanupAdapters()
602
638
  }
@@ -108,7 +108,7 @@ async function executeViaSdk(task: Task, options: ExecuteOptions = {}): Promise<
108
108
  } : undefined
109
109
  return {
110
110
  success: true,
111
- output: output.slice(0, 10_000),
111
+ output: output.slice(0, 100_000),
112
112
  exitCode: 0,
113
113
  usage: usageResult,
114
114
  }
@@ -246,3 +246,13 @@ export function kill(task: Task): void {
246
246
  if (mode === 'sdk') killSdk(task)
247
247
  else killCli(task)
248
248
  }
249
+
250
+ export async function cleanup(): Promise<void> {
251
+ if (clientPromise) {
252
+ try {
253
+ const client = await clientPromise
254
+ await client.stop()
255
+ } catch { /* ignore */ }
256
+ clientPromise = null
257
+ }
258
+ }
@@ -45,6 +45,19 @@ export async function detectAdapter(): Promise<string | null> {
45
45
  return null
46
46
  }
47
47
 
48
+ /**
49
+ * Clean up all loaded adapters (stop SDK clients, close connections).
50
+ * Call this before process exit to avoid hanging.
51
+ */
52
+ export async function cleanupAdapters(): Promise<void> {
53
+ for (const loader of Object.values(ADAPTERS)) {
54
+ try {
55
+ const mod = await loader()
56
+ await mod.cleanup?.()
57
+ } catch { /* ignore */ }
58
+ }
59
+ }
60
+
48
61
  /**
49
62
  * List all registered adapters with their availability status.
50
63
  */
package/src/cli/run.ts CHANGED
@@ -756,6 +756,31 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
756
756
  if (spec.branch) console.log(` Branch: ${spec.branch}`)
757
757
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
758
758
 
759
+ // ── Pre-flight: handle uncommitted changes before branch switch ──
760
+ let pipelineDidStash = false
761
+ if (spec.branch) {
762
+ const { execFile: execFileCb } = await import('node:child_process')
763
+ const { promisify } = await import('node:util')
764
+ const execFile = promisify(execFileCb)
765
+ const { stdout: statusOut } = await execFile('git', ['status', '--porcelain'], {
766
+ cwd: process.cwd(),
767
+ })
768
+ if (statusOut.trim()) {
769
+ console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
770
+ const shouldStash = await confirm('Stash changes and continue?', true)
771
+ if (!shouldStash) {
772
+ console.log(' Aborted. Commit or stash your changes manually, then retry.')
773
+ closePrompts()
774
+ process.exit(1)
775
+ }
776
+ await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before pipeline'], {
777
+ cwd: process.cwd(),
778
+ })
779
+ pipelineDidStash = true
780
+ console.log(` ${c.green('✓')} Changes stashed.`)
781
+ }
782
+ }
783
+
759
784
  const { startDashboardServer } = await import('./dashboard.js')
760
785
  let pipelineDashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
761
786
  try {
@@ -789,12 +814,31 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
789
814
  throw err
790
815
  }
791
816
  printPipelineResult(pipelineResult)
817
+ if (pipelineDidStash) {
818
+ const { execFile: execFileCb } = await import('node:child_process')
819
+ const { promisify } = await import('node:util')
820
+ const execFile = promisify(execFileCb)
821
+ try {
822
+ await execFile('git', ['stash', 'pop'], { cwd: process.cwd() })
823
+ console.log(` ${c.green('✓')} Stashed changes restored.`)
824
+ } catch {
825
+ console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
826
+ }
827
+ }
792
828
  if (pipelineDashboardResult) {
793
829
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
794
- console.log(` ${c.dim('View again:')} opencastle dashboard`)
795
- pipelineDashboardResult.server.close()
830
+ console.log(` ${c.dim('Dashboard:')} ${pipelineDashboardResult.url}`)
831
+ console.log(`\n Press Ctrl+C to stop`)
832
+ const exitCode = pipelineResult.status !== 'done' ? 1 : 0
833
+ process.on('SIGINT', () => {
834
+ console.log('\n Dashboard stopped.\n')
835
+ pipelineDashboardResult!.server.close()
836
+ process.exit(exitCode)
837
+ })
838
+ } else {
839
+ process.exit(pipelineResult.status !== 'done' ? 1 : 0)
796
840
  }
797
- process.exit(pipelineResult.status !== 'done' ? 1 : 0)
841
+ return
798
842
  }
799
843
 
800
844
  // ── Convoy engine path (version: 1 specs) ────────────────────
@@ -882,11 +926,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
882
926
  throw err
883
927
  }
884
928
  printConvoyResult(result)
885
- if (dashboardResult) {
886
- console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
887
- console.log(` ${c.dim('View again:')} opencastle dashboard`)
888
- dashboardResult.server.close()
889
- }
890
929
  if (didStash) {
891
930
  const { execFile: execFileCb } = await import('node:child_process')
892
931
  const { promisify } = await import('node:util')
@@ -898,7 +937,19 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
898
937
  console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
899
938
  }
900
939
  }
901
- process.exit(result.status !== 'done' ? 1 : 0)
940
+ if (dashboardResult) {
941
+ console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
942
+ console.log(` ${c.dim('Dashboard:')} ${dashboardResult.url}`)
943
+ console.log(`\n Press Ctrl+C to stop`)
944
+ const exitCode = result.status !== 'done' ? 1 : 0
945
+ process.on('SIGINT', () => {
946
+ console.log('\n Dashboard stopped.\n')
947
+ dashboardResult!.server.close()
948
+ process.exit(exitCode)
949
+ })
950
+ } else {
951
+ process.exit(result.status !== 'done' ? 1 : 0)
952
+ }
902
953
  }
903
954
 
904
955
  // ── Legacy executor path ──────────────────────────────────────
package/src/cli/types.ts CHANGED
@@ -300,6 +300,8 @@ export interface AgentAdapter {
300
300
  kill?(_task: Task): void;
301
301
  /** Whether the adapter supports reusing sessions across multi-step task steps. Defaults to false. */
302
302
  supportsSessionContinuity?(): boolean;
303
+ /** Clean up any long-lived resources (SDK clients, open connections) so the process can exit. */
304
+ cleanup?(): Promise<void>;
303
305
  }
304
306
 
305
307
  /** Options for agent execution. */
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "019f3d08",
2
+ "hash": "92391349",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "d116e77a",
5
- "browserHash": "68231191",
4
+ "lockfileHash": "f32f6327",
5
+ "browserHash": "a3143d98",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "f4a93516",
10
+ "fileHash": "4da4d2a1",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "3b18a33f",
16
+ "fileHash": "65e6b113",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "dc8d5b49",
22
+ "fileHash": "bd7268ed",
23
23
  "needsInterop": true
24
24
  }
25
25
  },