opencastle 0.27.3 → 0.28.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 (61) hide show
  1. package/bin/cli.mjs +13 -5
  2. package/dist/cli/convoy/engine.js +2 -2
  3. package/dist/cli/convoy/engine.js.map +1 -1
  4. package/dist/cli/convoy/engine.test.js +1 -1
  5. package/dist/cli/convoy/engine.test.js.map +1 -1
  6. package/dist/cli/convoy/issues.js +3 -3
  7. package/dist/cli/convoy/issues.js.map +1 -1
  8. package/dist/cli/convoy/issues.test.js +4 -3
  9. package/dist/cli/convoy/issues.test.js.map +1 -1
  10. package/dist/cli/pipeline.d.ts +3 -0
  11. package/dist/cli/pipeline.d.ts.map +1 -0
  12. package/dist/cli/pipeline.js +305 -0
  13. package/dist/cli/pipeline.js.map +1 -0
  14. package/dist/cli/plan.d.ts +37 -0
  15. package/dist/cli/plan.d.ts.map +1 -1
  16. package/dist/cli/plan.js +321 -161
  17. package/dist/cli/plan.js.map +1 -1
  18. package/dist/cli/validate.d.ts +3 -0
  19. package/dist/cli/validate.d.ts.map +1 -0
  20. package/dist/cli/validate.js +60 -0
  21. package/dist/cli/validate.js.map +1 -0
  22. package/package.json +5 -4
  23. package/src/cli/convoy/engine.test.ts +1 -1
  24. package/src/cli/convoy/engine.ts +2 -2
  25. package/src/cli/convoy/issues.test.ts +3 -2
  26. package/src/cli/convoy/issues.ts +3 -3
  27. package/src/cli/pipeline.ts +343 -0
  28. package/src/cli/plan.ts +357 -153
  29. package/src/cli/validate.ts +65 -0
  30. package/src/dashboard/dist/data/convoy-list.json +54 -9
  31. package/src/dashboard/dist/data/convoys/demo-api-v2.json +177 -0
  32. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +239 -0
  33. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +328 -0
  34. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +187 -0
  35. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +153 -0
  36. package/src/dashboard/dist/data/convoys/demo-docs-update.json +154 -0
  37. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +227 -0
  38. package/src/dashboard/dist/data/events.ndjson +115 -0
  39. package/src/dashboard/dist/data/overall-stats.json +56 -13
  40. package/src/dashboard/dist/data/pipelines.ndjson +5285 -0
  41. package/src/dashboard/dist/index.html +39 -16
  42. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  43. package/src/dashboard/public/data/convoy-list.json +54 -9
  44. package/src/dashboard/public/data/convoys/demo-api-v2.json +177 -0
  45. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +239 -0
  46. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +328 -0
  47. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +187 -0
  48. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +153 -0
  49. package/src/dashboard/public/data/convoys/demo-docs-update.json +154 -0
  50. package/src/dashboard/public/data/convoys/demo-perf-opt.json +227 -0
  51. package/src/dashboard/public/data/events.ndjson +115 -0
  52. package/src/dashboard/public/data/overall-stats.json +56 -13
  53. package/src/dashboard/public/data/pipelines.ndjson +5285 -0
  54. package/src/dashboard/scripts/etl.ts +24 -3
  55. package/src/dashboard/scripts/generate-demo-db.ts +482 -115
  56. package/src/dashboard/src/pages/index.astro +46 -23
  57. package/src/orchestrator/prompts/fix-convoy.prompt.md +79 -0
  58. package/src/orchestrator/prompts/generate-convoy.prompt.md +53 -58
  59. package/src/orchestrator/prompts/generate-prd.prompt.md +120 -0
  60. package/src/orchestrator/prompts/validate-convoy.prompt.md +89 -0
  61. package/src/orchestrator/prompts/validate-prd.prompt.md +83 -0
@@ -0,0 +1,60 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import { parseYaml, validateSpec } from './run/schema.js';
4
+ import { c } from './prompt.js';
5
+ const HELP = `
6
+ opencastle validate <file> [options]
7
+
8
+ Validate a convoy YAML spec file without executing it.
9
+
10
+ Arguments:
11
+ <file> Path to the convoy YAML spec file
12
+
13
+ Options:
14
+ --help, -h Show this help
15
+ `;
16
+ export default async function validate({ args }) {
17
+ if (args.includes('--help') || args.includes('-h')) {
18
+ console.log(HELP);
19
+ return;
20
+ }
21
+ const filePath = args.find(a => !a.startsWith('--'));
22
+ if (!filePath) {
23
+ console.error(' ✗ A file path is required\n Usage: opencastle validate <file>');
24
+ process.exit(1);
25
+ }
26
+ const absPath = resolve(process.cwd(), filePath);
27
+ if (!existsSync(absPath)) {
28
+ console.error(` ✗ File not found: ${absPath}`);
29
+ process.exit(1);
30
+ }
31
+ let text;
32
+ try {
33
+ text = readFileSync(absPath, 'utf8');
34
+ }
35
+ catch (err) {
36
+ console.error(` ✗ Could not read file: ${err.message}`);
37
+ process.exit(1);
38
+ }
39
+ let parsed;
40
+ try {
41
+ parsed = parseYaml(text);
42
+ }
43
+ catch (err) {
44
+ console.error(` ✗ YAML parse error: ${err.message}`);
45
+ process.exit(1);
46
+ }
47
+ const result = validateSpec(parsed);
48
+ if (result.valid) {
49
+ console.log(` ${c.green('✓')} ${filePath} is valid`);
50
+ }
51
+ else {
52
+ console.error(` ${c.red('✗')} ${filePath} has ${result.errors.length} error${result.errors.length === 1 ? '' : 's'}:\n`);
53
+ for (const err of result.errors) {
54
+ console.error(` • ${err}`);
55
+ }
56
+ console.error();
57
+ process.exit(1);
58
+ }
59
+ }
60
+ //# sourceMappingURL=validate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.js","sourceRoot":"","sources":["../../src/cli/validate.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,SAAS,CAAA;AAClD,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AACnC,OAAO,EAAE,SAAS,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AACzD,OAAO,EAAE,CAAC,EAAE,MAAM,aAAa,CAAA;AAG/B,MAAM,IAAI,GAAG;;;;;;;;;;CAUZ,CAAA;AAED,MAAM,CAAC,OAAO,CAAC,KAAK,UAAU,QAAQ,CAAC,EAAE,IAAI,EAAc;IACzD,IAAI,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACnD,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QACjB,OAAM;IACR,CAAC;IAED,MAAM,QAAQ,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAA;IACpD,IAAI,CAAC,QAAQ,EAAE,CAAC;QACd,OAAO,CAAC,KAAK,CAAC,kEAAkE,CAAC,CAAA;QACjF,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IAED,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,QAAQ,CAAC,CAAA;IAChD,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;QACzB,OAAO,CAAC,KAAK,CAAC,uBAAuB,OAAO,EAAE,CAAC,CAAA;QAC/C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IAED,IAAI,IAAY,CAAA;IAChB,IAAI,CAAC;QACH,IAAI,GAAG,YAAY,CAAC,OAAO,EAAE,MAAM,CAAC,CAAA;IACtC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,4BAA6B,GAAa,CAAC,OAAO,EAAE,CAAC,CAAA;QACnE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IAED,IAAI,MAAe,CAAA;IACnB,IAAI,CAAC;QACH,MAAM,GAAG,SAAS,CAAC,IAAI,CAAC,CAAA;IAC1B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,CAAC,KAAK,CAAC,yBAA0B,GAAa,CAAC,OAAO,EAAE,CAAC,CAAA;QAChE,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;IAED,MAAM,MAAM,GAAG,YAAY,CAAC,MAAM,CAAC,CAAA;IAEnC,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,QAAQ,WAAW,CAAC,CAAA;IACvD,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,QAAQ,QAAQ,MAAM,CAAC,MAAM,CAAC,MAAM,SAAS,MAAM,CAAC,MAAM,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAA;QACzH,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,MAAM,EAAE,CAAC;YAChC,OAAO,CAAC,KAAK,CAAC,SAAS,GAAG,EAAE,CAAC,CAAA;QAC/B,CAAC;QACD,OAAO,CAAC,KAAK,EAAE,CAAA;QACf,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;IACjB,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencastle",
3
- "version": "0.27.3",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "description": "Multi-agent orchestration framework for AI coding assistants",
6
6
  "bin": {
@@ -34,10 +34,11 @@
34
34
  "license": "MIT",
35
35
  "author": "Filip Mares",
36
36
  "scripts": {
37
+ "build": "npm run cli:build && npm run dashboard:build",
37
38
  "cli:build": "tsc",
38
39
  "test": "vitest run",
39
- "dashboard:generate-demo-db": "tsx src/dashboard/scripts/generate-demo-db.ts --out .opencastle/convoy-demo.db",
40
- "dashboard:etl": "tsx src/dashboard/scripts/etl.ts --db .opencastle/convoy-demo.db --out src/dashboard/public/data",
40
+ "dashboard:generate-demo-db": "tsx src/dashboard/scripts/generate-demo-db.ts --out .opencastle/convoy-demo.db --events-out .opencastle/convoy-demo.events.ndjson",
41
+ "dashboard:etl": "tsx src/dashboard/scripts/etl.ts --db .opencastle/convoy-demo.db --out src/dashboard/public/data --events .opencastle/convoy-demo.events.ndjson",
41
42
  "dashboard:test": "tsx src/dashboard/scripts/integration-test.ts",
42
43
  "dashboard:dev": "astro dev --root src/dashboard --port 4300",
43
44
  "dashboard:build": "astro build --root src/dashboard",
@@ -45,7 +46,7 @@
45
46
  "website:dev": "astro dev --root website",
46
47
  "website:build": "astro build --root website",
47
48
  "website:preview": "astro preview --root website",
48
- "prepublishOnly": "npm run cli:build && npm run dashboard:build"
49
+ "prepublishOnly": "npm run build"
49
50
  },
50
51
  "engines": {
51
52
  "node": ">=22.5.0"
@@ -3407,7 +3407,7 @@ describe('secret scan in DLQ/dispute markdown write', () => {
3407
3407
 
3408
3408
  it('emits secret_leak_prevented when dispute markdown write detects secrets', async () => {
3409
3409
  const scanSpy = vi.spyOn(gates, 'scanForSecrets').mockImplementation((content: string, filePath = '') => {
3410
- if (filePath === 'DISPUTES.md') {
3410
+ if (filePath === '.opencastle/DISPUTES.md') {
3411
3411
  return {
3412
3412
  clean: false,
3413
3413
  findings: [{ pattern: 'Mock Secret', file: filePath, line: 1, snippet: content.slice(0, 20) }],
@@ -426,7 +426,7 @@ function writeDisputeToMarkdown(
426
426
  panelResults: ReviewResult[],
427
427
  events?: ConvoyEventEmitter | null,
428
428
  ): void {
429
- const mdPath = join(resolve(process.cwd()), 'DISPUTES.md')
429
+ const mdPath = join(resolve(process.cwd()), '.opencastle', 'DISPUTES.md')
430
430
  const marker = `<!-- dispute:${disputeId} -->`
431
431
 
432
432
  try {
@@ -443,7 +443,7 @@ function writeDisputeToMarkdown(
443
443
 
444
444
  const entry = `\n${marker}\n## Dispute: ${task.id}\n\n| Field | Value |\n|-------|-------|\n| Convoy | ${convoyId} |\n| Task | ${task.id} |\n| Date | ${new Date().toISOString()} |\n| Panel attempts | ${task.panel_attempts + 1} |\n| Agent | ${task.agent} |\n| Status | Open |\n\n**Blocking reasons:**\n\n${blockingReasons}\n`
445
445
 
446
- const scanResult = scanForSecrets(entry, 'DISPUTES.md')
446
+ const scanResult = scanForSecrets(entry, '.opencastle/DISPUTES.md')
447
447
  if (!scanResult.clean) {
448
448
  if (events) {
449
449
  events.emit(
@@ -8,14 +8,15 @@ vi.mock('./gates.js', () => ({
8
8
  scanForSecrets: vi.fn(() => ({ clean: true, findings: [] })),
9
9
  }))
10
10
 
11
- const DISCOVERED_REL = 'DISCOVERED-ISSUES.md'
12
- const KNOWN_REL = 'KNOWN-ISSUES.md'
11
+ const DISCOVERED_REL = '.opencastle/DISCOVERED-ISSUES.md'
12
+ const KNOWN_REL = '.opencastle/KNOWN-ISSUES.md'
13
13
 
14
14
  const DISCOVERED_HEADER = '# Discovered Issues\n\n'
15
15
  const KNOWN_HEADER = '# Known Issues\n\n'
16
16
 
17
17
  function makeBase(): string {
18
18
  const dir = realpathSync(mkdtempSync(join(tmpdir(), 'issues-test-')))
19
+ mkdirSync(join(dir, '.opencastle'), { recursive: true })
19
20
  return dir
20
21
  }
21
22
 
@@ -3,12 +3,12 @@ import { join } from 'node:path'
3
3
  import { scanForSecrets } from './gates.js'
4
4
  import type { ConvoyEventEmitter } from './events.js'
5
5
 
6
- const DISCOVERED_PATH = 'DISCOVERED-ISSUES.md'
7
- const KNOWN_PATH = 'KNOWN-ISSUES.md'
6
+ const DISCOVERED_PATH = '.opencastle/DISCOVERED-ISSUES.md'
7
+ const KNOWN_PATH = '.opencastle/KNOWN-ISSUES.md'
8
8
 
9
9
  const INJECT_INSTRUCTION =
10
10
  'IMPORTANT: After completing your task, if you notice any pre-existing bugs or issues ' +
11
- 'unrelated to your task, append them to DISCOVERED-ISSUES.md in the format:\n\n' +
11
+ 'unrelated to your task, append them to .opencastle/DISCOVERED-ISSUES.md in the format:\n\n' +
12
12
  '### ISSUE: [title]\n' +
13
13
  '- **File:** [filepath]\n' +
14
14
  '- **Description:** [description]\n' +
@@ -0,0 +1,343 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { c } from './prompt.js'
5
+ import { runPromptStep } from './plan.js'
6
+ import type { CliContext } from './types.js'
7
+
8
+ const HELP = `
9
+ opencastle pipeline [options]
10
+
11
+ Run the full convoy generation pipeline from a feature prompt:
12
+
13
+ Step 1 — Generate PRD (generate-prd)
14
+ 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)
18
+
19
+ Options:
20
+ --text, -t <text> Feature prompt text (required, unless --prd is set)
21
+ --prd <path> Skip step 1 — use an existing PRD file
22
+ --output-prd <path> Override path for the generated PRD
23
+ --output-spec <path> Override path for the generated convoy spec
24
+ --adapter, -a <name> Override agent runtime adapter
25
+ --verbose Show full agent output for each step
26
+ --dry-run Generate and print the PRD prompt only, then stop
27
+ --skip-validation Skip steps 2 and 4 (PRD and convoy validation)
28
+ --help, -h Show this help
29
+ `
30
+
31
+ interface PipelineOptions {
32
+ text: string | null
33
+ prd: string | null
34
+ outputPrd: string | null
35
+ outputSpec: string | null
36
+ adapter: string | null
37
+ verbose: boolean
38
+ dryRun: boolean
39
+ skipValidation: boolean
40
+ help: boolean
41
+ }
42
+
43
+ function parseArgs(args: string[]): PipelineOptions {
44
+ const opts: PipelineOptions = {
45
+ text: null,
46
+ prd: null,
47
+ outputPrd: null,
48
+ outputSpec: null,
49
+ adapter: null,
50
+ verbose: false,
51
+ dryRun: false,
52
+ skipValidation: false,
53
+ help: false,
54
+ }
55
+
56
+ for (let i = 0; i < args.length; i++) {
57
+ const arg = args[i]
58
+ switch (arg) {
59
+ case '--help':
60
+ case '-h':
61
+ opts.help = true
62
+ break
63
+ case '--text':
64
+ case '-t':
65
+ if (i + 1 >= args.length) { console.error(' ✗ --text requires a value'); process.exit(1) }
66
+ opts.text = args[++i]
67
+ break
68
+ case '--prd':
69
+ if (i + 1 >= args.length) { console.error(' ✗ --prd requires a path'); process.exit(1) }
70
+ opts.prd = args[++i]
71
+ break
72
+ case '--output-prd':
73
+ if (i + 1 >= args.length) { console.error(' ✗ --output-prd requires a path'); process.exit(1) }
74
+ opts.outputPrd = args[++i]
75
+ break
76
+ case '--output-spec':
77
+ if (i + 1 >= args.length) { console.error(' ✗ --output-spec requires a path'); process.exit(1) }
78
+ opts.outputSpec = args[++i]
79
+ break
80
+ case '--adapter':
81
+ case '-a':
82
+ if (i + 1 >= args.length) { console.error(' ✗ --adapter requires a name'); process.exit(1) }
83
+ opts.adapter = args[++i]
84
+ break
85
+ case '--verbose':
86
+ opts.verbose = true
87
+ break
88
+ case '--dry-run':
89
+ case '--dryRun':
90
+ opts.dryRun = true
91
+ break
92
+ case '--skip-validation':
93
+ opts.skipValidation = true
94
+ break
95
+ default:
96
+ console.error(` ✗ Unknown option: ${arg}`)
97
+ console.log(HELP)
98
+ process.exit(1)
99
+ }
100
+ }
101
+
102
+ return opts
103
+ }
104
+
105
+ function relPath(abs: string): string {
106
+ return abs.startsWith(process.cwd()) ? abs.slice(process.cwd().length + 1) : abs
107
+ }
108
+
109
+ function stepLabel(n: number, total: number, name: string): string {
110
+ return c.bold(c.cyan(` [${n}/${total}] ${name}`))
111
+ }
112
+
113
+ export default async function pipeline({ args, pkgRoot }: CliContext): Promise<void> {
114
+ const opts = parseArgs(args)
115
+
116
+ if (opts.help) {
117
+ console.log(HELP)
118
+ return
119
+ }
120
+
121
+ if (!opts.text && !opts.prd) {
122
+ console.error(` ✗ Either --text or --prd is required.`)
123
+ console.log(HELP)
124
+ process.exit(1)
125
+ }
126
+
127
+ if (opts.text && opts.prd) {
128
+ console.error(` ✗ --text and --prd are mutually exclusive.`)
129
+ process.exit(1)
130
+ }
131
+
132
+ if (opts.prd) {
133
+ const resolvedPrd = resolve(process.cwd(), opts.prd)
134
+ if (!existsSync(resolvedPrd)) {
135
+ console.error(` ✗ PRD file not found: ${opts.prd}`)
136
+ process.exit(1)
137
+ }
138
+ }
139
+
140
+ const totalSteps = opts.skipValidation ? 3 : 5
141
+ const sharedOpts = {
142
+ adapterName: opts.adapter ?? undefined,
143
+ verbose: opts.verbose,
144
+ pkgRoot,
145
+ }
146
+
147
+ console.log(c.bold('\n opencastle pipeline\n'))
148
+
149
+ // ── Step 1: Generate PRD ──────────────────────────────────────────────────
150
+ let prdPath: string
151
+
152
+ if (opts.prd) {
153
+ prdPath = resolve(process.cwd(), opts.prd)
154
+ console.log(c.dim(` [−] Skipping PRD generation — using: ${relPath(prdPath)}`))
155
+ } else {
156
+ console.log(stepLabel(1, totalSteps, 'Generating PRD…'))
157
+
158
+ let result
159
+ try {
160
+ result = await runPromptStep({
161
+ ...sharedOpts,
162
+ template: 'generate-prd',
163
+ goalText: opts.text!,
164
+ outputPath: opts.outputPrd ? resolve(process.cwd(), opts.outputPrd) : undefined,
165
+ dryRun: opts.dryRun,
166
+ })
167
+ } catch (err) {
168
+ console.error(`\n ✗ Step 1 failed: ${err instanceof Error ? err.message : String(err)}`)
169
+ process.exit(1)
170
+ }
171
+
172
+ if (opts.dryRun) {
173
+ console.log(c.dim('\n [dry-run] Stopping after step 1. Remove --dry-run to run the full pipeline.'))
174
+ return
175
+ }
176
+
177
+ prdPath = result.outputPath!
178
+ console.log(c.green(` ✓ PRD written to ${relPath(prdPath)}\n`))
179
+ }
180
+
181
+ // ── Step 2: Validate PRD ──────────────────────────────────────────────────
182
+ if (!opts.skipValidation) {
183
+ console.log(stepLabel(2, totalSteps, 'Validating PRD…'))
184
+
185
+ const prdContent = await readFile(prdPath, 'utf8')
186
+ let result
187
+ try {
188
+ result = await runPromptStep({
189
+ ...sharedOpts,
190
+ template: 'validate-prd',
191
+ goalText: prdContent,
192
+ })
193
+ } catch (err) {
194
+ console.error(`\n ✗ Step 2 failed: ${err instanceof Error ? err.message : String(err)}`)
195
+ process.exit(1)
196
+ }
197
+
198
+ if (!result.isValid) {
199
+ console.log(c.red(` ✗ PRD validation failed.\n`))
200
+ console.log(result.errors ?? result.rawOutput)
201
+ 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`
204
+ )
205
+ process.exit(1)
206
+ }
207
+
208
+ console.log(c.green(` ✓ PRD is valid\n`))
209
+ }
210
+
211
+ // ── Step 3: Generate convoy spec ──────────────────────────────────────────
212
+ const genStep = opts.skipValidation ? 2 : 3
213
+ console.log(stepLabel(genStep, totalSteps, 'Generating convoy spec…'))
214
+
215
+ let specPath: string
216
+ try {
217
+ const result = await runPromptStep({
218
+ ...sharedOpts,
219
+ template: 'generate-convoy',
220
+ filePath: prdPath,
221
+ outputPath: opts.outputSpec ? resolve(process.cwd(), opts.outputSpec) : undefined,
222
+ })
223
+ specPath = result.outputPath!
224
+ } catch (err) {
225
+ console.error(`\n ✗ Step ${genStep} failed: ${err instanceof Error ? err.message : String(err)}`)
226
+ process.exit(1)
227
+ }
228
+
229
+ console.log(c.green(` ✓ Convoy spec written to ${relPath(specPath)}\n`))
230
+
231
+ if (opts.skipValidation) {
232
+ printFinalSummary(prdPath, specPath)
233
+ return
234
+ }
235
+
236
+ // ── Step 4: Validate convoy spec ──────────────────────────────────────────
237
+ console.log(stepLabel(4, totalSteps, 'Validating convoy spec…'))
238
+
239
+ const specContent = await readFile(specPath, 'utf8')
240
+ let validationErrors: string
241
+
242
+ {
243
+ let result
244
+ try {
245
+ result = await runPromptStep({
246
+ ...sharedOpts,
247
+ template: 'validate-convoy',
248
+ goalText: specContent,
249
+ })
250
+ } catch (err) {
251
+ console.error(`\n ✗ Step 4 failed: ${err instanceof Error ? err.message : String(err)}`)
252
+ process.exit(1)
253
+ }
254
+
255
+ if (result.isValid) {
256
+ console.log(c.green(` ✓ Convoy spec is valid\n`))
257
+ printFinalSummary(prdPath, specPath)
258
+ return
259
+ }
260
+
261
+ validationErrors = result.errors ?? result.rawOutput
262
+ console.log(c.yellow(` ⚠ Spec has validation issues — attempting auto-fix…\n`))
263
+ console.log(c.dim(validationErrors))
264
+ console.log()
265
+ }
266
+
267
+ // ── Step 5: Fix convoy spec (up to 2 retries) ─────────────────────────────
268
+ const MAX_FIX_RETRIES = 2
269
+ let fixedSpecContent = specContent
270
+
271
+ for (let attempt = 1; attempt <= MAX_FIX_RETRIES; attempt++) {
272
+ const label = `Fix attempt ${attempt}/${MAX_FIX_RETRIES}…`
273
+ console.log(stepLabel(5, totalSteps, label))
274
+
275
+ let fixResult
276
+ try {
277
+ fixResult = await runPromptStep({
278
+ ...sharedOpts,
279
+ template: 'fix-convoy',
280
+ goalText: fixedSpecContent,
281
+ contextText: validationErrors,
282
+ outputPath: specPath, // overwrite in place
283
+ })
284
+ } catch (err) {
285
+ console.error(`\n ✗ Step 5 (attempt ${attempt}) failed: ${err instanceof Error ? err.message : String(err)}`)
286
+ process.exit(1)
287
+ }
288
+
289
+ console.log(c.dim(` Re-validating after fix…`))
290
+
291
+ // Read the newly written spec
292
+ fixedSpecContent = await readFile(specPath, 'utf8')
293
+
294
+ let revalidation
295
+ try {
296
+ revalidation = await runPromptStep({
297
+ ...sharedOpts,
298
+ template: 'validate-convoy',
299
+ goalText: fixedSpecContent,
300
+ })
301
+ } catch (err) {
302
+ console.error(`\n ✗ Re-validation failed: ${err instanceof Error ? err.message : String(err)}`)
303
+ process.exit(1)
304
+ }
305
+
306
+ if (revalidation.isValid) {
307
+ console.log(c.green(` ✓ Spec fixed and validated\n`))
308
+ printFinalSummary(prdPath, specPath)
309
+ return
310
+ }
311
+
312
+ validationErrors = revalidation.errors ?? revalidation.rawOutput
313
+
314
+ if (attempt < MAX_FIX_RETRIES) {
315
+ console.log(c.yellow(` ⚠ Still has issues after fix attempt ${attempt} — retrying…\n`))
316
+ console.log(c.dim(validationErrors))
317
+ console.log()
318
+ }
319
+ }
320
+
321
+ // All retries exhausted
322
+ console.log(c.red(`\n ✗ Could not auto-fix the convoy spec after ${MAX_FIX_RETRIES} attempts.\n`))
323
+ console.log(` Remaining issues:\n`)
324
+ console.log(validationErrors)
325
+ console.log(
326
+ c.dim(`\n The spec has been saved to ${relPath(specPath)} with the best available fixes.\n`) +
327
+ c.dim(` Review the remaining issues above and edit the file manually, then validate with:\n`) +
328
+ ` opencastle plan --file ${relPath(specPath)} --template validate-convoy\n`
329
+ )
330
+ process.exit(1)
331
+ }
332
+
333
+ function printFinalSummary(prdPath: string, specPath: string): void {
334
+ const prd = relPath(prdPath)
335
+ const spec = relPath(specPath)
336
+ console.log(c.bold(c.green(' Pipeline complete!\n')))
337
+ console.log(` PRD: ${prd}`)
338
+ console.log(` Convoy spec: ${spec}\n`)
339
+ console.log(
340
+ ` ${c.dim('Preview:')} npx opencastle run -f ${spec} --dry-run\n` +
341
+ ` ${c.dim('Execute:')} npx opencastle run -f ${spec}\n`
342
+ )
343
+ }