opencastle 0.31.3 → 0.31.4

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.
@@ -58,6 +58,125 @@ export function deriveComplexityPath(prdPath: string): string {
58
58
  return prdPath + '.complexity.json'
59
59
  }
60
60
 
61
+ export function validateComplexityGroups(assessment: ComplexityAssessment): { valid: boolean; reason: string } {
62
+ const groups = assessment.convoy_groups
63
+
64
+ // Each group must reference at least 1 phase
65
+ for (const group of groups) {
66
+ if (group.phases.length === 0) {
67
+ return { valid: false, reason: `Group "${group.name}" has an empty phases array` }
68
+ }
69
+ }
70
+
71
+ // Maximum group count: ≤3 for total_tasks ≤ 15, ≤4 for total_tasks > 15
72
+ const maxGroups = assessment.total_tasks > 15 ? 4 : 3
73
+ if (groups.length > maxGroups) {
74
+ return { valid: false, reason: `Too many groups: ${groups.length} exceeds maximum of ${maxGroups} for total_tasks=${assessment.total_tasks}` }
75
+ }
76
+
77
+ // No overlapping phases
78
+ const seenPhases = new Map<number, string>()
79
+ for (const group of groups) {
80
+ for (const phase of group.phases) {
81
+ if (seenPhases.has(phase)) {
82
+ return { valid: false, reason: `Phase ${phase} overlap: referenced by both "${seenPhases.get(phase)}" and "${group.name}"` }
83
+ }
84
+ seenPhases.set(phase, group.name)
85
+ }
86
+ }
87
+
88
+ // Valid depends_on references
89
+ const groupNames = new Set(groups.map(g => g.name))
90
+ for (const group of groups) {
91
+ for (const dep of group.depends_on) {
92
+ if (!groupNames.has(dep)) {
93
+ return { valid: false, reason: `Group "${group.name}" depends_on "${dep}" which does not exist` }
94
+ }
95
+ }
96
+ }
97
+
98
+ // No dependency cycles (Kahn's algorithm)
99
+ const inDegree = new Map<string, number>()
100
+ const adjList = new Map<string, string[]>()
101
+ for (const group of groups) {
102
+ inDegree.set(group.name, 0)
103
+ adjList.set(group.name, [])
104
+ }
105
+ for (const group of groups) {
106
+ for (const dep of group.depends_on) {
107
+ adjList.get(dep)!.push(group.name)
108
+ inDegree.set(group.name, (inDegree.get(group.name) ?? 0) + 1)
109
+ }
110
+ }
111
+ const queue: string[] = []
112
+ for (const [name, degree] of inDegree) {
113
+ if (degree === 0) queue.push(name)
114
+ }
115
+ let visited = 0
116
+ while (queue.length > 0) {
117
+ const node = queue.shift()!
118
+ visited++
119
+ for (const neighbor of adjList.get(node) ?? []) {
120
+ const newDegree = (inDegree.get(neighbor) ?? 0) - 1
121
+ inDegree.set(neighbor, newDegree)
122
+ if (newDegree === 0) queue.push(neighbor)
123
+ }
124
+ }
125
+ if (visited !== groups.length) {
126
+ return { valid: false, reason: 'Dependency cycle detected in convoy_groups' }
127
+ }
128
+
129
+ // Group names must be kebab-case safe
130
+ const kebabCaseRegex = /^[a-z0-9]+(-[a-z0-9]+)*$/
131
+ for (const group of groups) {
132
+ if (!kebabCaseRegex.test(group.name)) {
133
+ return { valid: false, reason: `Group name "${group.name}" is not valid kebab-case` }
134
+ }
135
+ }
136
+
137
+ return { valid: true, reason: '' }
138
+ }
139
+
140
+ export function topologicalSortGroups(groups: ConvoyGroup[]): ConvoyGroup[] {
141
+ const groupMap = new Map<string, ConvoyGroup>()
142
+ const inDegree = new Map<string, number>()
143
+ const adjList = new Map<string, string[]>()
144
+
145
+ for (const group of groups) {
146
+ groupMap.set(group.name, group)
147
+ inDegree.set(group.name, 0)
148
+ adjList.set(group.name, [])
149
+ }
150
+ for (const group of groups) {
151
+ for (const dep of group.depends_on) {
152
+ adjList.get(dep)!.push(group.name)
153
+ inDegree.set(group.name, (inDegree.get(group.name) ?? 0) + 1)
154
+ }
155
+ }
156
+
157
+ const queue: string[] = []
158
+ for (const [name, degree] of inDegree) {
159
+ if (degree === 0) queue.push(name)
160
+ }
161
+
162
+ const sorted: ConvoyGroup[] = []
163
+ while (queue.length > 0) {
164
+ const node = queue.shift()!
165
+ sorted.push(groupMap.get(node)!)
166
+ for (const neighbor of adjList.get(node) ?? []) {
167
+ const newDegree = (inDegree.get(neighbor) ?? 0) - 1
168
+ inDegree.set(neighbor, newDegree)
169
+ if (newDegree === 0) queue.push(neighbor)
170
+ }
171
+ }
172
+
173
+ if (sorted.length !== groups.length) {
174
+ throw new Error('Cycle detected in convoy_groups dependency graph')
175
+ }
176
+
177
+ return sorted
178
+ }
179
+
61
180
  const HELP = `
62
181
  opencastle start [options]
63
182
 
@@ -244,6 +363,12 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
244
363
  console.log(c.green(` ✓ PRD written to ${relPath(prdPath)}\n`))
245
364
  }
246
365
 
366
+ // Handle --dry-run when PRD was provided externally (not generated)
367
+ if (opts.dryRun && opts.prd) {
368
+ console.log(c.dim('\n [dry-run] Nothing to preview — PRD already provided via --prd. Remove --dry-run to continue.'))
369
+ return
370
+ }
371
+
247
372
  // ── Step 2: Validate PRD ──────────────────────────────────────────────────
248
373
  if (!opts.skipValidation) {
249
374
  console.log(stepLabel(2, totalSteps, 'Validating PRD…'))
@@ -400,105 +525,115 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
400
525
 
401
526
  if (complexity) {
402
527
  if (complexity.recommended_strategy === 'chain' && complexity.convoy_groups.length > 1) {
403
- console.log(
404
- c.cyan(` ℹ`) +
405
- ` Complexity: ${complexity.complexity} | Strategy: chain | ${complexity.convoy_groups.length} convoy groups\n`
406
- )
407
- console.log(` Chain plan:`)
408
- for (let i = 0; i < complexity.convoy_groups.length; i++) {
409
- const g = complexity.convoy_groups[i]
410
- const depStr =
411
- g.depends_on.length > 0 ? ` → depends on: ${g.depends_on.join(', ')}` : ''
528
+ // Validate complexity groups before chain generation
529
+ const groupValidation = validateComplexityGroups(complexity)
530
+ if (!groupValidation.valid) {
531
+ console.log(c.yellow(` ⚠ Complexity groups failed validation: ${groupValidation.reason}`))
532
+ console.log(c.yellow(` Falling back to single convoy strategy.\n`))
533
+ // Fall through to single-spec generation below
534
+ } else {
535
+ // Sort groups in dependency order
536
+ complexity.convoy_groups = topologicalSortGroups(complexity.convoy_groups)
412
537
  console.log(
413
- ` ${i + 1}. ${g.name.padEnd(20)} (phases: ${g.phases.join(', ')})${depStr}`
538
+ c.cyan(` ℹ`) +
539
+ ` Complexity: ${complexity.complexity} | Strategy: chain | ${complexity.convoy_groups.length} convoy groups\n`
414
540
  )
415
- }
416
- console.log()
541
+ console.log(` Chain plan:`)
542
+ for (let i = 0; i < complexity.convoy_groups.length; i++) {
543
+ const g = complexity.convoy_groups[i]
544
+ const depStr =
545
+ g.depends_on.length > 0 ? ` → depends on: ${g.depends_on.join(', ')}` : ''
546
+ console.log(
547
+ ` ${i + 1}. ${g.name.padEnd(20)} (phases: ${g.phases.join(', ')})${depStr}`
548
+ )
549
+ }
550
+ console.log()
417
551
 
418
- const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
419
- await mkdir(convoyDir, { recursive: true })
420
-
421
- const groupSpecPaths: string[] = []
422
-
423
- for (let i = 0; i < complexity.convoy_groups.length; i++) {
424
- const group = complexity.convoy_groups[i]
425
-
426
- const chainGoal = [
427
- complexity.original_prompt,
428
- '',
429
- '## Convoy Group Scope',
430
- '',
431
- `This is group **${i + 1} of ${complexity.convoy_groups.length}** in a convoy chain.`,
432
- `Generate a convoy spec covering ONLY the phases listed below.`,
433
- '',
434
- `- **Group name:** ${group.name}`,
435
- `- **Description:** ${group.description}`,
436
- `- **Phases to include:** ${group.phases.join(', ')}`,
437
- group.depends_on.length ? `- **Depends on groups:** ${group.depends_on.join(', ')}` : '',
438
- ].filter(Boolean).join('\n')
439
-
440
- const prdContent = await readFile(prdPath, 'utf8')
441
- const groupSpecPath = resolve(convoyDir, `${group.name}.convoy.yml`)
442
-
443
- const { specPath: resolvedGroupSpecPath } = await generateAndValidateSpec({
444
- sharedOpts,
445
- goalText: chainGoal,
446
- contextText: prdContent,
447
- specPath: groupSpecPath,
448
- skipValidation: opts.skipValidation,
449
- groupName: group.name,
450
- enrichment: complexity ? deriveSpecEnrichment(complexity) : undefined,
451
- })
452
- groupSpecPaths.push(resolvedGroupSpecPath)
453
- }
552
+ const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
553
+ await mkdir(convoyDir, { recursive: true })
554
+
555
+ const groupSpecPaths: string[] = []
556
+
557
+ for (let i = 0; i < complexity.convoy_groups.length; i++) {
558
+ const group = complexity.convoy_groups[i]
559
+
560
+ const chainGoal = [
561
+ complexity.original_prompt,
562
+ '',
563
+ '## Convoy Group Scope',
564
+ '',
565
+ `This is group **${i + 1} of ${complexity.convoy_groups.length}** in a convoy chain.`,
566
+ `Generate a convoy spec covering ONLY the phases listed below.`,
567
+ '',
568
+ `- **Group name:** ${group.name}`,
569
+ `- **Description:** ${group.description}`,
570
+ `- **Phases to include:** ${group.phases.join(', ')}`,
571
+ group.depends_on.length ? `- **Depends on groups:** ${group.depends_on.join(', ')}` : '',
572
+ ].filter(Boolean).join('\n')
573
+
574
+ const prdContent = await readFile(prdPath, 'utf8')
575
+ const groupSpecPath = resolve(convoyDir, `${group.name}.convoy.yml`)
576
+
577
+ const { specPath: resolvedGroupSpecPath } = await generateAndValidateSpec({
578
+ sharedOpts,
579
+ goalText: chainGoal,
580
+ contextText: prdContent,
581
+ specPath: groupSpecPath,
582
+ skipValidation: opts.skipValidation,
583
+ groupName: group.name,
584
+ enrichment: complexity ? deriveSpecEnrichment(complexity) : undefined,
585
+ })
586
+ groupSpecPaths.push(resolvedGroupSpecPath)
587
+ }
454
588
 
455
- // Build master pipeline spec (version 2)
456
- const chainPrdContent = await readFile(prdPath, 'utf8')
457
- const featureNameMatch = chainPrdContent.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
458
- const featureName = featureNameMatch
459
- ? featureNameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
460
- : 'feature'
461
-
462
- const branchMatch = chainPrdContent.match(/`feat\/([^`]+)`/)
463
- const branch = branchMatch ? `feat/${branchMatch[1]}` : `feat/${featureName}`
464
-
465
- const masterSpec = {
466
- name: featureNameMatch ? featureNameMatch[1].trim() : 'Feature Pipeline',
467
- version: 2,
468
- branch,
469
- on_failure: 'stop',
470
- depends_on_convoy: groupSpecPaths.map(p => relPath(p)),
471
- }
589
+ // Build master pipeline spec (version 2)
590
+ const chainPrdContent = await readFile(prdPath, 'utf8')
591
+ const featureNameMatch = chainPrdContent.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
592
+ const featureName = featureNameMatch
593
+ ? featureNameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
594
+ : 'feature'
595
+
596
+ const branchMatch = chainPrdContent.match(/`feat\/([^`]+)`/)
597
+ const branch = branchMatch ? `feat/${branchMatch[1]}` : `feat/${featureName}`
598
+
599
+ const masterSpec = {
600
+ name: featureNameMatch ? featureNameMatch[1].trim() : 'Feature Pipeline',
601
+ version: 2,
602
+ branch,
603
+ on_failure: 'stop',
604
+ depends_on_convoy: groupSpecPaths.map(p => relPath(p)),
605
+ }
472
606
 
473
- const masterSpecPath = resolve(convoyDir, `${featureName}-pipeline.convoy.yml`)
474
- await writeFile(masterSpecPath, stringify(masterSpec), 'utf8')
607
+ const masterSpecPath = resolve(convoyDir, `${featureName}-pipeline.convoy.yml`)
608
+ await writeFile(masterSpecPath, stringify(masterSpec), 'utf8')
475
609
 
476
- console.log(c.green(` ✓ Generated convoy chain:\n`))
477
- for (const p of groupSpecPaths) {
478
- console.log(` ${relPath(p)}`)
479
- }
480
- console.log(` ${relPath(masterSpecPath)} ${c.dim('(master)')}`)
481
- console.log()
482
- console.log(
483
- ` ${c.dim('Preview:')} npx opencastle run -f ${relPath(masterSpecPath)} --dry-run\n` +
484
- ` ${c.dim('Execute:')} npx opencastle run -f ${relPath(masterSpecPath)}\n`
485
- )
610
+ console.log(c.green(` ✓ Generated convoy chain:\n`))
611
+ for (const p of groupSpecPaths) {
612
+ console.log(` ${relPath(p)}`)
613
+ }
614
+ console.log(` ${relPath(masterSpecPath)} ${c.dim('(master)')}`)
615
+ console.log()
616
+ console.log(
617
+ ` ${c.dim('Preview:')} npx opencastle run -f ${relPath(masterSpecPath)} --dry-run\n` +
618
+ ` ${c.dim('Execute:')} npx opencastle run -f ${relPath(masterSpecPath)}\n`
619
+ )
486
620
 
487
- try {
488
- const shouldRun = await confirm('Run the convoy chain now?', true)
489
- if (shouldRun) {
621
+ try {
622
+ const shouldRun = await confirm('Run the convoy chain now?', true)
623
+ if (shouldRun) {
624
+ closePrompts()
625
+ const runModule = await import('./run.js')
626
+ const runArgs = ['-f', masterSpecPath]
627
+ if (opts.adapter) runArgs.push('-a', opts.adapter)
628
+ if (opts.verbose) runArgs.push('--verbose')
629
+ await runModule.default({ args: runArgs, pkgRoot })
630
+ }
631
+ } finally {
490
632
  closePrompts()
491
- const runModule = await import('./run.js')
492
- const runArgs = ['-f', masterSpecPath]
493
- if (opts.adapter) runArgs.push('-a', opts.adapter)
494
- if (opts.verbose) runArgs.push('--verbose')
495
- await runModule.default({ args: runArgs, pkgRoot })
633
+ await cleanupAdapters()
496
634
  }
497
- } finally {
498
- closePrompts()
499
- await cleanupAdapters()
635
+ return
500
636
  }
501
- return
502
637
  } else {
503
638
  console.log(
504
639
  c.cyan(` ℹ`) + ` Complexity: ${complexity.complexity} | Strategy: single\n`
package/src/cli/plan.ts CHANGED
@@ -225,10 +225,27 @@ function extractMarkdownBody(output: string): string {
225
225
 
226
226
  /**
227
227
  * Parse a validation AI response for VALID / INVALID verdict.
228
- * INVALID takes precedence because VALID is a substring of INVALID.
228
+ * Prefers structured JSON output; falls back to VALID/INVALID keyword matching.
229
229
  */
230
230
  function parseValidationResult(output: string): { isValid: boolean; errors: string } {
231
231
  const trimmed = output.trim()
232
+
233
+ // Try structured JSON first (preferred format)
234
+ const jsonFenceMatch = trimmed.match(/```json\s*\n([\s\S]*?)```/)
235
+ if (jsonFenceMatch) {
236
+ try {
237
+ const parsed = JSON.parse(jsonFenceMatch[1].trim()) as { valid?: boolean; issues?: string[] }
238
+ if (typeof parsed.valid === 'boolean') {
239
+ if (parsed.valid) return { isValid: true, errors: '' }
240
+ const errors = Array.isArray(parsed.issues) ? parsed.issues.join('\n') : ''
241
+ return { isValid: false, errors }
242
+ }
243
+ } catch {
244
+ // JSON parse failed — fall through to regex fallback
245
+ }
246
+ }
247
+
248
+ // Fallback: regex-based parsing for backward compatibility
232
249
  const hasInvalid = /\bINVALID\b/.test(trimmed)
233
250
  const hasValid = /\bVALID\b/.test(trimmed)
234
251
  if (hasValid && !hasInvalid) return { isValid: true, errors: '' }
@@ -364,9 +381,17 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
364
381
  }
365
382
 
366
383
  if (outputType === 'json') {
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()
384
+ // Extract JSON from a ```json fenced block — fail fast if missing
385
+ const jsonFenceMatch = rawOutput.match(/```json\s*\n([\s\S]*?)```/)
386
+ if (!jsonFenceMatch) {
387
+ const preview = rawOutput.slice(0, 300)
388
+ throw new Error(
389
+ `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.`
392
+ )
393
+ }
394
+ const jsonContent = jsonFenceMatch[1].trim()
370
395
 
371
396
  const outputPath = opts.outputPath ?? null
372
397
  if (outputPath) {
package/src/cli/run.ts CHANGED
@@ -645,12 +645,14 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
645
645
  template = parseFormula(formulaPath)
646
646
  } catch (err: unknown) {
647
647
  console.error(` ✗ ${(err as Error).message}`)
648
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run --formula ${opts.formula}`)
648
649
  process.exit(1)
649
650
  }
650
651
 
651
652
  const validation = validateTemplate(template)
652
653
  if (!validation.valid) {
653
654
  console.error(` ✗ Invalid formula template:\n • ${validation.errors.join('\n • ')}`)
655
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run --formula ${opts.formula}`)
654
656
  process.exit(1)
655
657
  }
656
658
 
@@ -672,6 +674,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
672
674
  spec = substituteVariables(template, opts.setVars)
673
675
  } catch (err: unknown) {
674
676
  console.error(` ✗ ${(err as Error).message}`)
677
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run --formula ${opts.formula}`)
675
678
  process.exit(1)
676
679
  }
677
680
  specText = yamlStringify(spec)
@@ -687,6 +690,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
687
690
  } else {
688
691
  console.error(` ✗ Cannot read task spec file: ${e.message}`)
689
692
  }
693
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
690
694
  process.exit(1)
691
695
  }
692
696
 
@@ -694,6 +698,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
694
698
  spec = parseTaskSpecText(specText)
695
699
  } catch (err: unknown) {
696
700
  console.error(` ✗ ${(err as Error).message}`)
701
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
697
702
  process.exit(1)
698
703
  }
699
704
  }
@@ -745,6 +750,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
745
750
  const available = await adapter.isAvailable()
746
751
  if (!available) {
747
752
  printAdapterError(detectionFailed, spec.adapter)
753
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
748
754
  process.exit(1)
749
755
  }
750
756
 
@@ -762,22 +768,45 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
762
768
  const { execFile: execFileCb } = await import('node:child_process')
763
769
  const { promisify } = await import('node:util')
764
770
  const execFile = promisify(execFileCb)
765
- const { stdout: statusOut } = await execFile('git', ['status', '--porcelain'], {
766
- cwd: process.cwd(),
767
- })
768
- if (statusOut.trim()) {
771
+ let statusOut: string
772
+ try {
773
+ const result = await execFile('git', ['status', '--porcelain'], {
774
+ cwd: process.cwd(),
775
+ })
776
+ statusOut = result.stdout
777
+ } catch {
778
+ console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
779
+ console.error(` Run \`git init\` to initialize a repository.`)
780
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
781
+ process.exit(1)
782
+ }
783
+ // Untracked files (??) don't block branch checkout — ignore them
784
+ const trackedChanges = statusOut
785
+ .split('\n')
786
+ .filter(line => line.trim() && !line.startsWith('??'))
787
+ .join('\n')
788
+ if (trackedChanges) {
769
789
  console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
770
790
  const shouldStash = await confirm('Stash changes and continue?', true)
771
791
  if (!shouldStash) {
772
792
  console.log(' Aborted. Commit or stash your changes manually, then retry.')
793
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
794
+ closePrompts()
795
+ process.exit(1)
796
+ }
797
+ try {
798
+ await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before pipeline'], {
799
+ cwd: process.cwd(),
800
+ })
801
+ pipelineDidStash = true
802
+ console.log(` ${c.green('✓')} Changes stashed.`)
803
+ } catch {
804
+ console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
805
+ console.log(` Commit or stash your changes manually, then resume:\n`)
806
+ console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
773
807
  closePrompts()
774
808
  process.exit(1)
775
809
  }
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
810
  }
782
811
  }
783
812
 
@@ -809,6 +838,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
809
838
  } catch (err) {
810
839
  if (err instanceof EngineAlreadyRunningError) {
811
840
  console.error(` ✗ ${err.message}`)
841
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file} --resume`)
812
842
  process.exit(1)
813
843
  }
814
844
  throw err
@@ -829,6 +859,9 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
829
859
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
830
860
  console.log(` ${c.dim('Dashboard:')} ${pipelineDashboardResult.url}`)
831
861
  console.log(`\n Press Ctrl+C to stop`)
862
+ if (pipelineResult.status !== 'done') {
863
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file} --resume`)
864
+ }
832
865
  const exitCode = pipelineResult.status !== 'done' ? 1 : 0
833
866
  process.on('SIGINT', () => {
834
867
  console.log('\n Dashboard stopped.\n')
@@ -836,6 +869,9 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
836
869
  process.exit(exitCode)
837
870
  })
838
871
  } else {
872
+ if (pipelineResult.status !== 'done') {
873
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file} --resume`)
874
+ }
839
875
  process.exit(pipelineResult.status !== 'done' ? 1 : 0)
840
876
  }
841
877
  return
@@ -857,22 +893,45 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
857
893
  const { execFile: execFileCb } = await import('node:child_process')
858
894
  const { promisify } = await import('node:util')
859
895
  const execFile = promisify(execFileCb)
860
- const { stdout: statusOut } = await execFile('git', ['status', '--porcelain'], {
861
- cwd: process.cwd(),
862
- })
863
- if (statusOut.trim()) {
896
+ let statusOut: string
897
+ try {
898
+ const result = await execFile('git', ['status', '--porcelain'], {
899
+ cwd: process.cwd(),
900
+ })
901
+ statusOut = result.stdout
902
+ } catch {
903
+ console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
904
+ console.error(` Run \`git init\` to initialize a repository.`)
905
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
906
+ process.exit(1)
907
+ }
908
+ // Untracked files (??) don't block branch checkout — ignore them
909
+ const trackedChanges = statusOut
910
+ .split('\n')
911
+ .filter(line => line.trim() && !line.startsWith('??'))
912
+ .join('\n')
913
+ if (trackedChanges) {
864
914
  console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
865
915
  const shouldStash = await confirm('Stash changes and continue?', true)
866
916
  if (!shouldStash) {
867
917
  console.log(' Aborted. Commit or stash your changes manually, then retry.')
918
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
919
+ closePrompts()
920
+ process.exit(1)
921
+ }
922
+ try {
923
+ await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before convoy'], {
924
+ cwd: process.cwd(),
925
+ })
926
+ didStash = true
927
+ console.log(` ${c.green('✓')} Changes stashed.`)
928
+ } catch {
929
+ console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
930
+ console.log(` Commit or stash your changes manually, then resume:\n`)
931
+ console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
868
932
  closePrompts()
869
933
  process.exit(1)
870
934
  }
871
- await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before convoy'], {
872
- cwd: process.cwd(),
873
- })
874
- didStash = true
875
- console.log(` ${c.green('✓')} Changes stashed.`)
876
935
  }
877
936
  }
878
937
 
@@ -921,6 +980,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
921
980
  } catch (err) {
922
981
  if (err instanceof EngineAlreadyRunningError) {
923
982
  console.error(` ✗ ${err.message}`)
983
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file} --resume`)
924
984
  process.exit(1)
925
985
  }
926
986
  throw err
@@ -941,6 +1001,9 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
941
1001
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
942
1002
  console.log(` ${c.dim('Dashboard:')} ${dashboardResult.url}`)
943
1003
  console.log(`\n Press Ctrl+C to stop`)
1004
+ if (result.status !== 'done') {
1005
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file} --resume`)
1006
+ }
944
1007
  const exitCode = result.status !== 'done' ? 1 : 0
945
1008
  process.on('SIGINT', () => {
946
1009
  console.log('\n Dashboard stopped.\n')
@@ -948,6 +1011,9 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
948
1011
  process.exit(exitCode)
949
1012
  })
950
1013
  } else {
1014
+ if (result.status !== 'done') {
1015
+ console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file} --resume`)
1016
+ }
951
1017
  process.exit(result.status !== 'done' ? 1 : 0)
952
1018
  }
953
1019
  }
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "cbfbf8fa",
2
+ "hash": "dcf92252",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "4d248ba7",
5
- "browserHash": "45b21a68",
4
+ "lockfileHash": "afb56fd1",
5
+ "browserHash": "6d49cf21",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "2a8eea79",
10
+ "fileHash": "53e7f26d",
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": "16cb4c63",
16
+ "fileHash": "4d0f70f0",
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": "95262852",
22
+ "fileHash": "26bf71c8",
23
23
  "needsInterop": true
24
24
  }
25
25
  },
@@ -65,20 +65,23 @@ The overall plan must make engineering sense.
65
65
 
66
66
  ## Output Format
67
67
 
68
- If all checks pass:
68
+ Your entire response must be a single fenced JSON block — no text before or after:
69
69
 
70
+ ```json
71
+ {
72
+ "valid": true
73
+ }
70
74
  ```
71
- VALID
72
- ```
73
-
74
- If any check fails:
75
75
 
76
- ```
77
- INVALID
76
+ Or if any check fails:
78
77
 
79
- Errors:
80
- - [Check category] / [task id if applicable]: [Specific problem] — Fix: [How to correct it]
81
- - [Check category] / [task id if applicable]: [Another problem] — Fix: [How to correct it]
78
+ ```json
79
+ {
80
+ "valid": false,
81
+ "issues": [
82
+ "[Section name]: [Specific problem] — Fix: [What to change]"
83
+ ]
84
+ }
82
85
  ```
83
86
 
84
- List only real failures. Do not list passing checks. Be specific — name the task id, the field, and the exact value that violates the rule.
87
+ List only real failures in `issues`. Do not list items that passed.