opencastle 0.30.0 → 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 +123 -60
  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 +9 -1
  8. package/dist/cli/plan.d.ts.map +1 -1
  9. package/dist/cli/plan.js +120 -11
  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 +145 -62
  27. package/src/cli/plan.ts +130 -12
  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 +25 -36
  36. package/src/orchestrator/prompts/validate-prd.prompt.md +1 -1
package/src/cli/plan.ts CHANGED
@@ -2,10 +2,11 @@ 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'
9
+ import type { MCPServerConfig } from './convoy/types.js'
9
10
 
10
11
  const HELP = `
11
12
  opencastle plan [options]
@@ -18,11 +19,13 @@ const HELP = `
18
19
  --text, -t <text> Inline text to use as {{goal}} (alternative to --file)
19
20
  --template <name> Prompt template name (default: generate-convoy)
20
21
  Built-in templates:
21
- generate-prd — Write a PRD from a feature prompt
22
- validate-prd — Check a PRD for completeness
23
- generate-convoy Generate a convoy spec from a PRD (default)
24
- validate-convoyCheck a convoy spec for correctness
25
- 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
26
29
  --context <path> Optional path to an additional context file (fills {{context}})
27
30
  --context-text <text> Inline text to fill {{context}} (alternative to --context)
28
31
  --output, -o <path> Output path override (skipped for validation output)
@@ -66,6 +69,8 @@ export interface PromptStepOptions {
66
69
  dryRun?: boolean
67
70
  /** Absolute path to the opencastle package root (for locating prompt templates) */
68
71
  pkgRoot: string
72
+ /** MCP servers to make available to the AI adapter during execution. */
73
+ mcpServers?: MCPServerConfig[]
69
74
  }
70
75
 
71
76
  export interface PromptStepResult {
@@ -74,7 +79,7 @@ export interface PromptStepResult {
74
79
  /** Raw text returned by the AI adapter (or assembled prompt on dry-run) */
75
80
  rawOutput: string
76
81
  /** How the output was interpreted */
77
- outputType: 'convoy-spec' | 'prd' | 'validation'
82
+ outputType: 'convoy-spec' | 'prd' | 'validation' | 'json'
78
83
  /** Set when outputType === 'validation' */
79
84
  isValid?: boolean
80
85
  /** Set when outputType === 'validation' and isValid === false */
@@ -147,9 +152,19 @@ function parseFrontmatter(text: string): Record<string, string> {
147
152
 
148
153
  /** Extract YAML content from a fenced ```yaml ... ``` block. */
149
154
  function extractYamlBlock(text: string): string | null {
150
- const match = text.match(/```ya?ml\s*\n([\s\S]*?)```/)
151
- if (!match) return null
152
- 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
153
168
  }
154
169
 
155
170
  /**
@@ -221,6 +236,34 @@ function parseValidationResult(output: string): { isValid: boolean; errors: stri
221
236
  return { isValid: false, errors: errorsMatch ? errorsMatch[1].trim() : trimmed }
222
237
  }
223
238
 
239
+ const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
240
+
241
+ const TEMPLATE_MESSAGES: Record<string, string> = {
242
+ 'generate-prd': 'Generating PRD…',
243
+ 'generate-convoy': 'Generating convoy spec…',
244
+ 'validate-prd': 'Validating PRD…',
245
+ 'validate-convoy': 'Validating convoy spec…',
246
+ 'fix-prd': 'Fixing PRD…',
247
+ 'fix-convoy': 'Fixing convoy spec…',
248
+ }
249
+
250
+ /** Show an in-place spinner with elapsed time during a long-running adapter call. Returns a stop function. */
251
+ function startProgress(templateName: string): () => void {
252
+ const message = TEMPLATE_MESSAGES[templateName] ?? `Running ${templateName}…`
253
+ const startTime = Date.now()
254
+ let frame = 0
255
+ const interval = setInterval(() => {
256
+ const elapsed = Math.floor((Date.now() - startTime) / 1000)
257
+ const spinner = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]!
258
+ process.stdout.write(c.dim(`\r ${spinner} ${message} (${elapsed}s)`))
259
+ frame++
260
+ }, 250)
261
+ return () => {
262
+ clearInterval(interval)
263
+ process.stdout.write('\r' + ' '.repeat(60) + '\r')
264
+ }
265
+ }
266
+
224
267
  // ── Exported programmatic API ───────────────────────────────────────────────
225
268
 
226
269
  /**
@@ -243,7 +286,7 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
243
286
 
244
287
  const rawTemplate = await readFile(templatePath, 'utf8')
245
288
  const frontmatter = parseFrontmatter(rawTemplate)
246
- const outputType = (frontmatter['output'] ?? 'convoy-spec') as 'convoy-spec' | 'prd' | 'validation'
289
+ const outputType = (frontmatter['output'] ?? 'convoy-spec') as 'convoy-spec' | 'prd' | 'validation' | 'json'
247
290
  const template = stripFrontmatter(rawTemplate)
248
291
 
249
292
  let goalContent = opts.goalText ?? ''
@@ -303,7 +346,16 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
303
346
  max_retries: 1,
304
347
  }
305
348
 
306
- const execResult = await adapter.execute(task, { verbose: opts.verbose ?? false })
349
+ const stop = opts.verbose ? null : startProgress(templateName)
350
+ let execResult
351
+ try {
352
+ execResult = await adapter.execute(task, {
353
+ verbose: opts.verbose ?? false,
354
+ ...(opts.mcpServers?.length ? { mcpServers: opts.mcpServers } : {}),
355
+ })
356
+ } finally {
357
+ stop?.()
358
+ }
307
359
  const rawOutput = execResult.output
308
360
 
309
361
  if (outputType === 'validation') {
@@ -311,6 +363,18 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
311
363
  return { outputPath: null, rawOutput, outputType, isValid, errors }
312
364
  }
313
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
+
314
378
  if (outputType === 'prd') {
315
379
  const content = extractMarkdownBody(rawOutput)
316
380
  let outputPath = opts.outputPath ?? null
@@ -355,6 +419,48 @@ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStep
355
419
  return { outputPath, rawOutput, outputType, isValid: schemaValid }
356
420
  }
357
421
 
422
+ /**
423
+ * Read MCP server configurations from the project's MCP config file.
424
+ * Checks in priority order: .vscode/mcp.json, .cursor/mcp.json, .claude/mcp.json, mcp.json
425
+ */
426
+ export async function readProjectMcpServers(projectRoot: string): Promise<MCPServerConfig[]> {
427
+ const candidates = [
428
+ join(projectRoot, '.vscode', 'mcp.json'),
429
+ join(projectRoot, '.cursor', 'mcp.json'),
430
+ join(projectRoot, '.claude', 'mcp.json'),
431
+ join(projectRoot, 'mcp.json'),
432
+ ]
433
+
434
+ for (const filePath of candidates) {
435
+ if (!existsSync(filePath)) continue
436
+ try {
437
+ const raw = await readFile(filePath, 'utf8')
438
+ const parsed = JSON.parse(raw) as Record<string, unknown>
439
+
440
+ // VS Code format: { servers: { name: { type, command, args } } }
441
+ // Cursor/Claude format: { mcpServers: { name: { command, args } } }
442
+ const serversMap =
443
+ (parsed['servers'] as Record<string, unknown> | undefined) ??
444
+ (parsed['mcpServers'] as Record<string, unknown> | undefined)
445
+
446
+ if (!serversMap || typeof serversMap !== 'object') continue
447
+
448
+ return Object.entries(serversMap).map(([name, cfg]) => {
449
+ const c = cfg as Record<string, unknown>
450
+ const server: MCPServerConfig = { name, type: (c['type'] as string) ?? 'stdio' }
451
+ if (typeof c['command'] === 'string') server.command = c['command']
452
+ if (Array.isArray(c['args'])) server.args = c['args'] as string[]
453
+ if (typeof c['url'] === 'string') server.url = c['url']
454
+ return server
455
+ })
456
+ } catch {
457
+ return []
458
+ }
459
+ }
460
+
461
+ return []
462
+ }
463
+
358
464
  // ── CLI argument parsing ────────────────────────────────────────────────────
359
465
 
360
466
  function parseArgs(args: string[]): PlanOptions {
@@ -503,6 +609,16 @@ export default async function plan({ args, pkgRoot }: CliContext): Promise<void>
503
609
  console.log(`\n ${c.dim('Next step:')} opencastle plan --file ${relPath} --template validate-prd`)
504
610
  break
505
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
+ }
506
622
  default: {
507
623
  const relPath = result.outputPath!.startsWith(process.cwd())
508
624
  ? result.outputPath!.slice(process.cwd().length + 1)
@@ -517,4 +633,6 @@ export default async function plan({ args, pkgRoot }: CliContext): Promise<void>
517
633
  `)
518
634
  }
519
635
  }
636
+
637
+ await cleanupAdapters()
520
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": "572e36d2",
2
+ "hash": "92391349",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "197646b6",
5
- "browserHash": "744352ae",
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": "15f42bbc",
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": "0bfdb9ce",
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": "97401cc3",
22
+ "fileHash": "bd7268ed",
23
23
  "needsInterop": true
24
24
  }
25
25
  },
@@ -0,0 +1,67 @@
1
+ ---
2
+ description: 'Assess PRD complexity and recommend convoy strategy (single vs chain). Returns structured JSON consumed by the pipeline.'
3
+ agent: 'Reviewer'
4
+ output: json
5
+ ---
6
+
7
+ <!-- ⚠️ This file is managed by OpenCastle. Edits will be overwritten on update. Customize in the .opencastle/ directory instead. -->
8
+
9
+ # Assess PRD Complexity
10
+
11
+ Analyze the PRD below and produce a complexity assessment as a **single JSON object**. This JSON is consumed programmatically by the pipeline to decide whether to generate one convoy spec or a chain of convoy specs.
12
+
13
+ ## PRD to Analyze
14
+
15
+ {{goal}}
16
+
17
+ ## Original User Prompt
18
+
19
+ {{context}}
20
+
21
+ ---
22
+
23
+ ## Output Rules
24
+
25
+ **CRITICAL:** Return ONLY a single fenced JSON block — no prose, no explanation, no markdown headings. Start your response with the opening fence and end with the closing fence.
26
+
27
+ ## Required JSON Schema
28
+
29
+ ```json
30
+ {
31
+ "original_prompt": "<string>",
32
+ "total_tasks": <number>,
33
+ "total_phases": <number>,
34
+ "domains": ["<string>", ...],
35
+ "estimated_duration_minutes": <number>,
36
+ "complexity": "low" | "medium" | "high",
37
+ "recommended_strategy": "single" | "chain",
38
+ "chain_rationale": "<string — empty when strategy is single>",
39
+ "convoy_groups": [
40
+ {
41
+ "name": "<kebab-case-name>",
42
+ "description": "<one sentence>",
43
+ "phases": [<phase numbers>],
44
+ "depends_on": ["<group name>", ...]
45
+ }
46
+ ]
47
+ }
48
+ ```
49
+
50
+ ## Field Rules
51
+
52
+ - `original_prompt`: Copy the user's original feature request verbatim from the "Original User Prompt" section above. If that section is empty, extract a one-sentence summary from the PRD's Overview section.
53
+ - `total_tasks`: Count of individual workstreams in the Task Breakdown.
54
+ - `total_phases`: Count of phases in the Task Breakdown.
55
+ - `domains`: List of technical domains involved (e.g., "frontend", "api", "database", "testing", "config").
56
+ - `estimated_duration_minutes`: Rough estimate assuming AI agent execution (not human).
57
+ - `complexity`: `"low"` (1–4 tasks), `"medium"` (5–8 tasks), `"high"` (9+ tasks).
58
+ - `recommended_strategy`:
59
+ - `"single"` when: total tasks ≤ 8, OR total phases ≤ 3, OR all tasks are tightly coupled with heavy cross-phase file sharing.
60
+ - `"chain"` when: total tasks > 8 AND total phases > 3 AND domains have natural boundaries — AND splitting improves failure isolation, observability, or retry granularity.
61
+ - `chain_rationale`: Only filled when `recommended_strategy` is `"chain"` — explain WHY splitting benefits this specific feature.
62
+ - `convoy_groups`:
63
+ - When `"single"`: exactly one group covering all phases.
64
+ - When `"chain"`: 2–4 groups with explicit `depends_on` order. Each group covers a coherent domain boundary.
65
+ - **Minimum 3 tasks per group.** Never create a group that would produce a convoy with only 1–2 tasks — merge small groups with adjacent ones. A convoy with a single task is pointless overhead.
66
+ - **Do NOT map phases 1:1 to groups.** Groups should bundle multiple related phases when tasks are tightly coupled (e.g., config + data in one group, components + pages in another). Only split at genuine domain boundaries where failure isolation matters.
67
+ - Maximum 3 groups for projects with ≤ 15 tasks. Maximum 4 groups for 16+ tasks.
@@ -9,11 +9,13 @@ agent: 'Team Lead (OpenCastle)'
9
9
 
10
10
  You are the Team Lead. The user wants to run `opencastle run` to execute a batch of tasks autonomously via the convoy engine. Your job is to produce a valid `.convoy.yml` file they can feed to the CLI. Derive a short, descriptive, kebab-case filename from the user's goal (2–4 words max) and use it as the filename — for example `auth-refactor.convoy.yml` or `add-search.convoy.yml`. Always use the `.convoy.yml` extension. Store all generated convoy specs in the `.opencastle/convoys/` directory (create it if it doesn't exist).
11
11
 
12
+ > **⚠️ OUTPUT FORMAT: Your entire response must be a single ` ```yaml ` fenced code block containing the convoy spec. Do NOT output any text, explanations, summaries, or DAG diagrams before or after the YAML block. The parser only reads the ` ```yaml ` fence — everything else causes a failure.**
13
+
12
14
  ## User Goal
13
15
 
14
16
  {{goal}}
15
17
 
16
- ## Additional Context
18
+ ## PRD Reference
17
19
 
18
20
  {{context}}
19
21
 
@@ -311,24 +313,17 @@ For complex tasks, consider using `steps` to break the prompt into sequential su
311
313
 
312
314
  ### Chain Mode (Subset Generation)
313
315
 
314
- When the `{{context}}` field contains a JSON object with `"mode": "chain_subset"`, you are generating ONE convoy spec that is part of a larger convoy chain. The context will look like:
315
-
316
- ```json
317
- {
318
- "mode": "chain_subset",
319
- "group_name": "database-setup",
320
- "group_description": "Schema changes and migrations",
321
- "group_phases": [1],
322
- "depends_on_groups": [],
323
- "total_groups": 3,
324
- "group_index": 1
325
- }
326
- ```
327
-
328
- When this context is present:
329
- - **Only** generate tasks for the phases listed in `group_phases`. Do not include tasks from other phases.
316
+ When the `{{goal}}` section contains a "Convoy Group Scope" heading, you are generating ONE convoy spec that is part of a larger convoy chain. The goal will contain:
317
+
318
+ - The original user prompt
319
+ - The group name, description, phases to cover, and dependency info
320
+
321
+ The full PRD is available in the `{{context}}` section as reference.
322
+
323
+ When chain mode is detected:
324
+ - **Only** generate tasks for the phases listed in the group scope. Do not include tasks from other phases.
330
325
  - Use `version: 1` — this spec is a single convoy, not a pipeline.
331
- - Derive the convoy `name` from `group_name` (e.g., "Database Setup").
326
+ - Derive the convoy `name` from the group name (e.g., "Database Setup").
332
327
  - Derive the `branch` from the PRD's feature name, but it will be overridden by the pipeline anyway.
333
328
  - Keep all other conventions (prompts, files, gates, etc.) the same as for single-spec generation.
334
329
 
@@ -346,7 +341,7 @@ Before presenting the YAML, mentally verify:
346
341
 
347
342
  ### 7. Output
348
343
 
349
- Return the final YAML inside a fenced code block with a filename annotation:
344
+ Your response must contain **ONLY** a single ` ```yaml ` fenced code block no text before it, no text after it, no explanations, no summaries, no DAG diagrams. The pipeline parser will only extract content from the ` ```yaml ` fence. Any other text in your response is discarded and may cause parsing failures.
350
345
 
351
346
  ````yaml
352
347
  # .opencastle/convoys/<feature-name>.convoy.yml
@@ -393,9 +388,4 @@ gates:
393
388
  gate_retries: 1
394
389
  ````
395
390
 
396
- Also provide:
397
- 1. A **DAG summary** showing the phase structure so the user can verify execution order.
398
- 2. An **estimated total duration** (sum of timeouts on the critical path).
399
- 3. A `--dry-run` command they can use to validate: `npx opencastle run -f .opencastle/convoys/<feature-name>.convoy.yml --dry-run`
400
-
401
391
 
@@ -22,13 +22,20 @@ You are the Team Lead. Convert the feature request below into a structured Produ
22
22
 
23
23
  ## Research Before Writing
24
24
 
25
- If the feature request involves a specific person, place, organization, topic, or any real-world subject you are not confident you have accurate knowledge about — **you MUST search the internet first** using any available web search or fetch tools (e.g. `fetch_webpage`, web search MCP, or similar). Use the search results to gather accurate facts, names, dates, descriptions, and other details.
25
+ If the feature request involves a specific person, place, organization, topic, or any real-world subject:
26
26
 
27
- **Never fabricate or hallucinate content** about real-world subjects. If you cannot verify a claim through web search, state what is unknown rather than inventing plausible-sounding text. This applies to all content: bios, descriptions, histories, statistics, quotes, and any factual claims.
27
+ 1. **Search the internet first** if web search or fetch tools are available (e.g. `fetch_webpage`, web search MCP, or similar). Use the search results to gather accurate facts, names, dates, descriptions, and other details.
28
+ 2. **If web search tools are unavailable or return no useful results**, you may use your training knowledge — but clearly mark any such content with:
29
+ > ℹ️ Content based on training data — verify before launch.
30
+ 3. **Never fabricate or hallucinate content.** If you genuinely have no knowledge about a real-world subject and cannot search, state what is unknown and use placeholder text. This applies to all content: bios, descriptions, histories, statistics, quotes, and any factual claims.
31
+
32
+ ## Output Rules
33
+
34
+ **CRITICAL:** Return the PRD as your text response. Do NOT create any files. Do NOT use file-writing tools. Simply output the full PRD document as text. Do not wrap it in a code fence — start directly with the `#` heading. Do not summarize — output the complete document.
28
35
 
29
36
  ## Required PRD Structure
30
37
 
31
- Produce the PRD in Markdown using **exactly** the sections below. Do not skip or merge sections. Do not wrap the output in a code fence — output raw Markdown starting directly with the `#` heading.
38
+ Produce the PRD in Markdown using **exactly** the sections below. Do not skip or merge sections.
32
39
 
33
40
  ---
34
41
 
@@ -53,6 +60,12 @@ Explicit exclusions — what this work does **not** cover. If nothing is exclude
53
60
 
54
61
  For each primary scenario, write a user story + binary acceptance criteria. Criteria must be testable (pass/fail — no subjective language).
55
62
 
63
+ **Quality rules for acceptance criteria (the validator WILL reject violations):**
64
+ - Every criterion must be evaluable as deterministic pass/fail — no subjective language ("looks good", "feels responsive", "is clean", "visually distinct")
65
+ - Do NOT use modal verbs that imply optionality: "should", "might", "could", "may"
66
+ - Do NOT use vague qualifiers: "or equivalent", "or similar", "as needed"
67
+ - State exact expected values (e.g., exact heading text, exact attribute names)
68
+
56
69
  **US-1: [Short title]**
57
70
  As a [user type], I want [action] so that [benefit].
58
71
 
@@ -73,7 +86,7 @@ Specific technical constraints the implementation must respect:
73
86
 
74
87
  ## Implementation Scope
75
88
 
76
- List **every file and directory** that will be created, modified, or deleted. Use specific paths — not broad paths like `src/`. Group by concern.
89
+ List **every file and directory** that will be created, modified, or deleted. Use specific paths — not broad paths like `src/`. Group by concern. Use compact file lists — group related files with commas instead of separate rows when they share a concern. Do NOT use glob patterns (`*`, `**`). Every concern must list at least one specific file.
77
90
 
78
91
  | Concern | Files / Directories |
79
92
  |---------|---------------------|
@@ -92,6 +105,14 @@ List **every file and directory** that will be created, modified, or deleted. Us
92
105
 
93
106
  Decompose into the minimum number of phases. Tasks in the same phase run in parallel and **must not share any files**.
94
107
 
108
+ Keep task descriptions **brief** — 1 sentence each. List only file paths, not explanations. Prefer compact formatting.
109
+
110
+ **Quality rules (the validator WILL reject violations):**
111
+ - Each workstream must list exact files it will modify
112
+ - No two parallel workstreams (same phase) may claim the same file
113
+ - Phases must have explicit dependency declarations (`depends on: Phase N`)
114
+ - No circular dependencies
115
+
95
116
  ```
96
117
  Phase 1 — Foundation (parallel, no dependencies):
97
118
  - [Workstream A title]: [2-sentence description]
@@ -124,35 +145,3 @@ Measurable, binary checks that confirm the feature is shippable:
124
145
  - **[Open question]**: [What needs to be decided before implementation can start]
125
146
 
126
147
  If there are no risks or open questions, write "None identified."
127
-
128
- ## Complexity Assessment
129
-
130
- Produce a fenced JSON block with the following fields. This is consumed programmatically by the pipeline to decide whether to generate a single convoy spec or a convoy chain.
131
-
132
- ```json
133
- {
134
- "total_tasks": 12,
135
- "total_phases": 4,
136
- "domains": ["database", "api", "frontend", "testing"],
137
- "estimated_duration_minutes": 120,
138
- "complexity": "low",
139
- "recommended_strategy": "single",
140
- "chain_rationale": "",
141
- "convoy_groups": [
142
- {
143
- "name": "full-implementation",
144
- "description": "All phases in a single convoy",
145
- "phases": [1, 2, 3, 4],
146
- "depends_on": []
147
- }
148
- ]
149
- }
150
- ```
151
-
152
- **Strategy decision rules:**
153
- - Use `"single"` when: total tasks ≤ 8, or total phases ≤ 3, or all tasks are tightly coupled with heavy cross-phase file sharing.
154
- - Use `"chain"` when: total tasks > 8 AND total phases > 3 AND domains have natural boundaries (e.g., database changes are independent from frontend components from test suites) — AND splitting would improve failure isolation, observability, or retry granularity.
155
- - When `"single"`: provide exactly one convoy group covering all phases.
156
- - When `"chain"`: provide 2–4 convoy groups with explicit `depends_on` order. Each group should cover a coherent domain boundary.
157
- - `complexity` values: `"low"` (1–4 tasks), `"medium"` (5–8 tasks), `"high"` (9+ tasks).
158
- - `chain_rationale` is only filled when `recommended_strategy` is `"chain"` — explain WHY splitting benefits this specific feature.
@@ -56,7 +56,7 @@ Evaluate **every item** below. If ALL items pass, respond `VALID`. If ANY item f
56
56
 
57
57
  ### Language Quality
58
58
 
59
- - [ ] No undefined acronyms or jargon used without explanation
59
+ - [ ] No **domain-specific** acronyms or jargon used without explanation (standard software acronyms like API, CSS, HTML, CI/CD, CMS, SDK, CLI, URL, JSON, REST, SQL, SSR, SSG, CDN, DNS, TLS, JWT, OAuth, CRUD, DOM, UI, UX, HTTP, HTTPS, LTS, WCAG, RTL, MCP, PRD, E2E are considered universally understood and do not need expansion)
60
60
  - [ ] No conflicting requirements (e.g., "must be fast AND run full suite on every change")
61
61
  - [ ] Section content is not placeholder/template text (e.g., "2–3 sentences about…", "Description here")
62
62