opencastle 0.29.0 → 0.30.1

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.
@@ -1,10 +1,56 @@
1
- import { readFile } from 'node:fs/promises'
1
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
2
  import { existsSync } from 'node:fs'
3
- import { resolve } from 'node:path'
3
+ import { resolve, dirname } from 'node:path'
4
+ import { stringify } from 'yaml'
4
5
  import { c, confirm, closePrompts } from './prompt.js'
5
- import { runPromptStep } from './plan.js'
6
+ import { runPromptStep, readProjectMcpServers } from './plan.js'
6
7
  import type { CliContext } from './types.js'
7
8
 
9
+ export interface ConvoyGroup {
10
+ name: string
11
+ description: string
12
+ phases: number[]
13
+ depends_on: string[]
14
+ }
15
+
16
+ export interface ComplexityAssessment {
17
+ total_tasks: number
18
+ total_phases: number
19
+ domains: string[]
20
+ estimated_duration_minutes?: number
21
+ complexity: 'low' | 'medium' | 'high'
22
+ recommended_strategy: 'single' | 'chain'
23
+ chain_rationale?: string
24
+ convoy_groups: ConvoyGroup[]
25
+ }
26
+
27
+ export function parseComplexityAssessment(prdContent: string): ComplexityAssessment | null {
28
+ const sectionMatch = prdContent.match(/## Complexity Assessment\s+([\s\S]*?)(?=\n## |\n# |$)/)
29
+ if (!sectionMatch) return null
30
+
31
+ const sectionContent = sectionMatch[1]
32
+ const jsonMatch = sectionContent.match(/```json\s*([\s\S]*?)```/)
33
+ if (!jsonMatch) return null
34
+
35
+ try {
36
+ const parsed = JSON.parse(jsonMatch[1].trim()) as ComplexityAssessment
37
+ // Validate required fields
38
+ if (
39
+ typeof parsed.total_tasks !== 'number' ||
40
+ typeof parsed.total_phases !== 'number' ||
41
+ !Array.isArray(parsed.domains) ||
42
+ !parsed.complexity ||
43
+ !parsed.recommended_strategy ||
44
+ !Array.isArray(parsed.convoy_groups)
45
+ ) {
46
+ return null
47
+ }
48
+ return parsed
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
8
54
  const HELP = `
9
55
  opencastle pipeline [options]
10
56
 
@@ -12,9 +58,10 @@ const HELP = `
12
58
 
13
59
  Step 1 — Generate PRD (generate-prd)
14
60
  Step 2 — Validate PRD (validate-prd)
15
- Step 3 — Generate convoy spec (generate-convoy, using PRD as BDO)
16
- Step 4 — Validate convoy spec (validate-convoy)
17
- Step 5 — Fix convoy spec (fix-convoy, up to 2 retries if invalid)
61
+ Step 3 — Fix PRD (fix-prd, up to 2 retries if invalid)
62
+ Step 4 — Generate convoy spec (generate-convoy, using PRD as BDO)
63
+ Step 5 — Validate convoy spec (validate-convoy)
64
+ Step 6 — Fix convoy spec (fix-convoy, up to 2 retries if invalid)
18
65
 
19
66
  Options:
20
67
  --text, -t <text> Feature prompt text (required, unless --prd is set)
@@ -137,11 +184,13 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
137
184
  }
138
185
  }
139
186
 
140
- const totalSteps = opts.skipValidation ? 3 : 5
187
+ const totalSteps = opts.skipValidation ? 3 : 6
188
+ const mcpServers = await readProjectMcpServers(process.cwd())
141
189
  const sharedOpts = {
142
190
  adapterName: opts.adapter ?? undefined,
143
191
  verbose: opts.verbose,
144
192
  pkgRoot,
193
+ ...(mcpServers.length ? { mcpServers } : {}),
145
194
  }
146
195
 
147
196
  console.log(c.bold('\n opencastle pipeline\n'))
@@ -196,20 +245,254 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
196
245
  }
197
246
 
198
247
  if (!result.isValid) {
199
- console.log(c.red(` ✗ PRD validation failed.\n`))
200
- console.log(result.errors ?? result.rawOutput)
248
+ let prdValidationErrors = result.errors ?? result.rawOutput
249
+ console.log(c.yellow(` ⚠ PRD has validation issues — attempting auto-fix…\n`))
250
+ console.log(c.dim(prdValidationErrors))
251
+ console.log()
252
+
253
+ // ── Step 3: Fix PRD (up to 2 retries) ──────────────────────────────────
254
+ const MAX_PRD_FIX_RETRIES = 2
255
+ let fixedPrdContent = prdContent
256
+ let prdFixed = false
257
+
258
+ for (let attempt = 1; attempt <= MAX_PRD_FIX_RETRIES; attempt++) {
259
+ const label = `Fix PRD attempt ${attempt}/${MAX_PRD_FIX_RETRIES}…`
260
+ console.log(stepLabel(3, totalSteps, label))
261
+
262
+ try {
263
+ await runPromptStep({
264
+ ...sharedOpts,
265
+ template: 'fix-prd',
266
+ goalText: fixedPrdContent,
267
+ contextText: prdValidationErrors,
268
+ outputPath: prdPath, // overwrite in place
269
+ })
270
+ } catch (err) {
271
+ console.error(`\n ✗ Step 3 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
272
+ process.exit(1)
273
+ }
274
+
275
+ console.log(c.dim(` Re-validating after fix…`))
276
+
277
+ fixedPrdContent = await readFile(prdPath, 'utf8')
278
+
279
+ let revalidation
280
+ try {
281
+ revalidation = await runPromptStep({
282
+ ...sharedOpts,
283
+ template: 'validate-prd',
284
+ goalText: fixedPrdContent,
285
+ })
286
+ } catch (err) {
287
+ console.error(`\n ✗ Re-validation failed: ${err instanceof Error ? err.message : String(err)}`)
288
+ process.exit(1)
289
+ }
290
+
291
+ if (revalidation.isValid) {
292
+ console.log(c.green(` ✓ PRD fixed and validated\n`))
293
+ prdFixed = true
294
+ break
295
+ }
296
+
297
+ prdValidationErrors = revalidation.errors ?? revalidation.rawOutput
298
+
299
+ if (attempt < MAX_PRD_FIX_RETRIES) {
300
+ console.log(c.yellow(` ⚠ Still has issues after fix attempt ${attempt} — retrying…\n`))
301
+ console.log(c.dim(prdValidationErrors))
302
+ console.log()
303
+ }
304
+ }
305
+
306
+ if (!prdFixed) {
307
+ console.log(c.red(`\n ✗ Could not auto-fix the PRD after ${MAX_PRD_FIX_RETRIES} attempts.\n`))
308
+ console.log(` Remaining issues:\n`)
309
+ console.log(prdValidationErrors)
310
+ console.log(
311
+ c.dim(`\n The PRD has been saved to ${relPath(prdPath)} with the best available fixes.\n`) +
312
+ c.dim(` Review the remaining issues above and edit the file manually, then re-run with:\n`) +
313
+ ` opencastle pipeline --prd ${relPath(prdPath)}${opts.adapter ? ` --adapter ${opts.adapter}` : ''}\n`
314
+ )
315
+ process.exit(1)
316
+ }
317
+ } else {
318
+ console.log(c.green(` ✓ PRD is valid\n`))
319
+ }
320
+ }
321
+
322
+ // ── Complexity-aware strategy decision ────────────────────────────────────
323
+ const prdContentForComplexity = await readFile(prdPath, 'utf8')
324
+ const complexity = parseComplexityAssessment(prdContentForComplexity)
325
+
326
+ if (complexity) {
327
+ if (complexity.recommended_strategy === 'chain' && complexity.convoy_groups.length > 1) {
201
328
  console.log(
202
- c.dim(`\n Fix the PRD at ${relPath(prdPath)} and re-run with:\n`) +
203
- ` opencastle pipeline --prd ${relPath(prdPath)}${opts.adapter ? ` --adapter ${opts.adapter}` : ''}\n`
329
+ c.cyan(` ℹ`) +
330
+ ` Complexity: ${complexity.complexity} | Strategy: chain | ${complexity.convoy_groups.length} convoy groups\n`
331
+ )
332
+ console.log(` Chain plan:`)
333
+ for (let i = 0; i < complexity.convoy_groups.length; i++) {
334
+ const g = complexity.convoy_groups[i]
335
+ const depStr =
336
+ g.depends_on.length > 0 ? ` → depends on: ${g.depends_on.join(', ')}` : ''
337
+ console.log(
338
+ ` ${i + 1}. ${g.name.padEnd(20)} (phases: ${g.phases.join(', ')})${depStr}`
339
+ )
340
+ }
341
+ console.log()
342
+
343
+ const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
344
+ await mkdir(convoyDir, { recursive: true })
345
+
346
+ const genBaseStep = opts.skipValidation ? 2 : 4
347
+ const groupSpecPaths: string[] = []
348
+ const totalGroupSteps =
349
+ (opts.skipValidation ? 2 : 3) + complexity.convoy_groups.length * (opts.skipValidation ? 1 : 2)
350
+
351
+ for (let i = 0; i < complexity.convoy_groups.length; i++) {
352
+ const group = complexity.convoy_groups[i]
353
+ const groupStep = genBaseStep + i * (opts.skipValidation ? 1 : 2)
354
+
355
+ console.log(
356
+ stepLabel(
357
+ groupStep,
358
+ totalGroupSteps,
359
+ `Generating convoy spec for group: ${group.name}…`
360
+ )
361
+ )
362
+
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
+
373
+ const groupSpecPath = resolve(convoyDir, `${group.name}.convoy.yml`)
374
+
375
+ let groupResult
376
+ try {
377
+ groupResult = await runPromptStep({
378
+ ...sharedOpts,
379
+ template: 'generate-convoy',
380
+ filePath: prdPath,
381
+ contextText: chainContext,
382
+ outputPath: groupSpecPath,
383
+ })
384
+ } catch (err) {
385
+ console.error(
386
+ `\n ✗ Step ${groupStep} failed: ${err instanceof Error ? err.message : String(err)}`
387
+ )
388
+ process.exit(1)
389
+ }
390
+
391
+ const resolvedGroupSpecPath = groupResult.outputPath ?? groupSpecPath
392
+ groupSpecPaths.push(resolvedGroupSpecPath)
393
+
394
+ console.log(c.green(` ✓ Group spec written to ${relPath(resolvedGroupSpecPath)}\n`))
395
+
396
+ if (!opts.skipValidation) {
397
+ const valStep = groupStep + 1
398
+ console.log(stepLabel(valStep, totalGroupSteps, `Validating spec: ${group.name}…`))
399
+
400
+ const groupSpecContent = await readFile(resolvedGroupSpecPath, 'utf8')
401
+ let groupValidation
402
+ try {
403
+ groupValidation = await runPromptStep({
404
+ ...sharedOpts,
405
+ template: 'validate-convoy',
406
+ goalText: groupSpecContent,
407
+ })
408
+ } catch (err) {
409
+ console.error(
410
+ `\n ✗ Validation failed for group ${group.name}: ${err instanceof Error ? err.message : String(err)}`
411
+ )
412
+ process.exit(1)
413
+ }
414
+
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)}`
431
+ )
432
+ process.exit(1)
433
+ }
434
+
435
+ console.log(c.dim(` Applied fix for ${group.name}\n`))
436
+ } else {
437
+ console.log(c.green(` ✓ Spec valid\n`))
438
+ }
439
+ }
440
+ }
441
+
442
+ // Build master pipeline spec (version 2)
443
+ const featureNameMatch = prdContentForComplexity.match(/^# (.+?)\s*(?:—|-)?\s*PRD/m)
444
+ const featureName = featureNameMatch
445
+ ? featureNameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-')
446
+ : 'feature'
447
+
448
+ const branchMatch = prdContentForComplexity.match(/`feat\/([^`]+)`/)
449
+ const branch = branchMatch ? `feat/${branchMatch[1]}` : `feat/${featureName}`
450
+
451
+ const masterSpec = {
452
+ name: featureNameMatch ? featureNameMatch[1].trim() : 'Feature Pipeline',
453
+ version: 2,
454
+ branch,
455
+ on_failure: 'stop',
456
+ depends_on_convoy: groupSpecPaths.map(p => relPath(p)),
457
+ }
458
+
459
+ const masterSpecPath = resolve(convoyDir, `${featureName}-pipeline.convoy.yml`)
460
+ await writeFile(masterSpecPath, stringify(masterSpec), 'utf8')
461
+
462
+ console.log(c.green(` ✓ Generated convoy chain:\n`))
463
+ for (const p of groupSpecPaths) {
464
+ console.log(` ${relPath(p)}`)
465
+ }
466
+ console.log(` ${relPath(masterSpecPath)} ${c.dim('(master)')}`)
467
+ console.log()
468
+ console.log(
469
+ ` ${c.dim('Preview:')} npx opencastle run -f ${relPath(masterSpecPath)} --dry-run\n` +
470
+ ` ${c.dim('Execute:')} npx opencastle run -f ${relPath(masterSpecPath)}\n`
204
471
  )
205
- process.exit(1)
206
- }
207
472
 
208
- console.log(c.green(` ✓ PRD is valid\n`))
473
+ try {
474
+ const shouldRun = await confirm('Run the convoy chain now?', true)
475
+ if (shouldRun) {
476
+ closePrompts()
477
+ const runModule = await import('./run.js')
478
+ const runArgs = ['-f', masterSpecPath]
479
+ if (opts.adapter) runArgs.push('-a', opts.adapter)
480
+ if (opts.verbose) runArgs.push('--verbose')
481
+ await runModule.default({ args: runArgs, pkgRoot })
482
+ }
483
+ } finally {
484
+ closePrompts()
485
+ }
486
+ return
487
+ } else {
488
+ console.log(
489
+ c.cyan(` ℹ`) + ` Complexity: ${complexity.complexity} | Strategy: single\n`
490
+ )
491
+ }
209
492
  }
210
493
 
211
- // ── Step 3: Generate convoy spec ──────────────────────────────────────────
212
- const genStep = opts.skipValidation ? 2 : 3
494
+ // ── Step 4: Generate convoy spec ──────────────────────────────────────────
495
+ const genStep = opts.skipValidation ? 2 : 4
213
496
  console.log(stepLabel(genStep, totalSteps, 'Generating convoy spec…'))
214
497
 
215
498
  let specPath: string
@@ -233,8 +516,8 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
233
516
  return
234
517
  }
235
518
 
236
- // ── Step 4: Validate convoy spec ──────────────────────────────────────────
237
- console.log(stepLabel(4, totalSteps, 'Validating convoy spec…'))
519
+ // ── Step 5: Validate convoy spec ──────────────────────────────────────────
520
+ console.log(stepLabel(5, totalSteps, 'Validating convoy spec…'))
238
521
 
239
522
  const specContent = await readFile(specPath, 'utf8')
240
523
  let validationErrors: string
@@ -248,7 +531,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
248
531
  goalText: specContent,
249
532
  })
250
533
  } catch (err) {
251
- console.error(`\n ✗ Step 4 failed: ${err instanceof Error ? err.message : String(err)}`)
534
+ console.error(`\n ✗ Step 5 failed: ${err instanceof Error ? err.message : String(err)}`)
252
535
  process.exit(1)
253
536
  }
254
537
 
@@ -264,13 +547,13 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
264
547
  console.log()
265
548
  }
266
549
 
267
- // ── Step 5: Fix convoy spec (up to 2 retries) ─────────────────────────────
550
+ // ── Step 6: Fix convoy spec (up to 2 retries) ─────────────────────────────
268
551
  const MAX_FIX_RETRIES = 2
269
552
  let fixedSpecContent = specContent
270
553
 
271
554
  for (let attempt = 1; attempt <= MAX_FIX_RETRIES; attempt++) {
272
555
  const label = `Fix attempt ${attempt}/${MAX_FIX_RETRIES}…`
273
- console.log(stepLabel(5, totalSteps, label))
556
+ console.log(stepLabel(6, totalSteps, label))
274
557
 
275
558
  let fixResult
276
559
  try {
@@ -282,7 +565,7 @@ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<v
282
565
  outputPath: specPath, // overwrite in place
283
566
  })
284
567
  } catch (err) {
285
- console.error(`\n ✗ Step 5 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
568
+ console.error(`\n ✗ Step 6 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
286
569
  process.exit(1)
287
570
  }
288
571
 
package/src/cli/plan.ts CHANGED
@@ -6,6 +6,7 @@ import { getAdapter, detectAdapter } 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'
9
+ import type { MCPServerConfig } from './convoy/types.js'
9
10
 
10
11
  const HELP = `
11
12
  opencastle plan [options]
@@ -66,6 +67,8 @@ export interface PromptStepOptions {
66
67
  dryRun?: boolean
67
68
  /** Absolute path to the opencastle package root (for locating prompt templates) */
68
69
  pkgRoot: string
70
+ /** MCP servers to make available to the AI adapter during execution. */
71
+ mcpServers?: MCPServerConfig[]
69
72
  }
70
73
 
71
74
  export interface PromptStepResult {
@@ -221,6 +224,34 @@ function parseValidationResult(output: string): { isValid: boolean; errors: stri
221
224
  return { isValid: false, errors: errorsMatch ? errorsMatch[1].trim() : trimmed }
222
225
  }
223
226
 
227
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
228
+
229
+ const TEMPLATE_MESSAGES: Record<string, string> = {
230
+ 'generate-prd': 'Generating PRD…',
231
+ 'generate-convoy': 'Generating convoy spec…',
232
+ 'validate-prd': 'Validating PRD…',
233
+ 'validate-convoy': 'Validating convoy spec…',
234
+ 'fix-prd': 'Fixing PRD…',
235
+ 'fix-convoy': 'Fixing convoy spec…',
236
+ }
237
+
238
+ /** Show an in-place spinner with elapsed time during a long-running adapter call. Returns a stop function. */
239
+ function startProgress(templateName: string): () => void {
240
+ const message = TEMPLATE_MESSAGES[templateName] ?? `Running ${templateName}…`
241
+ const startTime = Date.now()
242
+ let frame = 0
243
+ const interval = setInterval(() => {
244
+ const elapsed = Math.floor((Date.now() - startTime) / 1000)
245
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!
246
+ process.stdout.write(c.dim(`\r ${spinner} ${message} (${elapsed}s)`))
247
+ frame++
248
+ }, 250)
249
+ return () => {
250
+ clearInterval(interval)
251
+ process.stdout.write('\r' + ' '.repeat(60) + '\r')
252
+ }
253
+ }
254
+
224
255
  // ── Exported programmatic API ───────────────────────────────────────────────
225
256
 
226
257
  /**
@@ -303,7 +334,16 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
303
334
  max_retries: 1,
304
335
  }
305
336
 
306
- const execResult = await adapter.execute(task, { verbose: opts.verbose ?? false })
337
+ const stop = opts.verbose ? null : startProgress(templateName)
338
+ let execResult
339
+ try {
340
+ execResult = await adapter.execute(task, {
341
+ verbose: opts.verbose ?? false,
342
+ ...(opts.mcpServers?.length ? { mcpServers: opts.mcpServers } : {}),
343
+ })
344
+ } finally {
345
+ stop?.()
346
+ }
307
347
  const rawOutput = execResult.output
308
348
 
309
349
  if (outputType === 'validation') {
@@ -355,6 +395,48 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
355
395
  return { outputPath, rawOutput, outputType, isValid: schemaValid }
356
396
  }
357
397
 
398
+ /**
399
+ * Read MCP server configurations from the project's MCP config file.
400
+ * Checks in priority order: .vscode/mcp.json, .cursor/mcp.json, .claude/mcp.json, mcp.json
401
+ */
402
+ export async function readProjectMcpServers(projectRoot: string): Promise<MCPServerConfig[]> {
403
+ const candidates = [
404
+ join(projectRoot, '.vscode', 'mcp.json'),
405
+ join(projectRoot, '.cursor', 'mcp.json'),
406
+ join(projectRoot, '.claude', 'mcp.json'),
407
+ join(projectRoot, 'mcp.json'),
408
+ ]
409
+
410
+ for (const filePath of candidates) {
411
+ if (!existsSync(filePath)) continue
412
+ try {
413
+ const raw = await readFile(filePath, 'utf8')
414
+ const parsed = JSON.parse(raw) as Record<string, unknown>
415
+
416
+ // VS Code format: { servers: { name: { type, command, args } } }
417
+ // Cursor/Claude format: { mcpServers: { name: { command, args } } }
418
+ const serversMap =
419
+ (parsed['servers'] as Record<string, unknown> | undefined) ??
420
+ (parsed['mcpServers'] as Record<string, unknown> | undefined)
421
+
422
+ if (!serversMap || typeof serversMap !== 'object') continue
423
+
424
+ return Object.entries(serversMap).map(([name, cfg]) => {
425
+ const c = cfg as Record<string, unknown>
426
+ const server: MCPServerConfig = { name, type: (c['type'] as string) ?? 'stdio' }
427
+ if (typeof c['command'] === 'string') server.command = c['command']
428
+ if (Array.isArray(c['args'])) server.args = c['args'] as string[]
429
+ if (typeof c['url'] === 'string') server.url = c['url']
430
+ return server
431
+ })
432
+ } catch {
433
+ return []
434
+ }
435
+ }
436
+
437
+ return []
438
+ }
439
+
358
440
  // ── CLI argument parsing ────────────────────────────────────────────────────
359
441
 
360
442
  function parseArgs(args: string[]): PlanOptions {