opencastle 0.27.3 → 0.29.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 (130) hide show
  1. package/README.md +12 -3
  2. package/bin/cli.mjs +13 -5
  3. package/dist/cli/convoy/engine.d.ts.map +1 -1
  4. package/dist/cli/convoy/engine.js +2 -11
  5. package/dist/cli/convoy/engine.js.map +1 -1
  6. package/dist/cli/convoy/engine.test.js +2 -1
  7. package/dist/cli/convoy/engine.test.js.map +1 -1
  8. package/dist/cli/convoy/export.d.ts +1 -3
  9. package/dist/cli/convoy/export.d.ts.map +1 -1
  10. package/dist/cli/convoy/export.js +9 -88
  11. package/dist/cli/convoy/export.js.map +1 -1
  12. package/dist/cli/convoy/export.test.js +7 -186
  13. package/dist/cli/convoy/export.test.js.map +1 -1
  14. package/dist/cli/convoy/issues.js +3 -3
  15. package/dist/cli/convoy/issues.js.map +1 -1
  16. package/dist/cli/convoy/issues.test.js +4 -3
  17. package/dist/cli/convoy/issues.test.js.map +1 -1
  18. package/dist/cli/convoy/pipeline.d.ts.map +1 -1
  19. package/dist/cli/convoy/pipeline.js +0 -21
  20. package/dist/cli/convoy/pipeline.js.map +1 -1
  21. package/dist/cli/convoy/pipeline.test.js +0 -21
  22. package/dist/cli/convoy/pipeline.test.js.map +1 -1
  23. package/dist/cli/dashboard.d.ts.map +1 -1
  24. package/dist/cli/dashboard.js +32 -8
  25. package/dist/cli/dashboard.js.map +1 -1
  26. package/dist/cli/destroy.d.ts.map +1 -1
  27. package/dist/cli/destroy.js +13 -0
  28. package/dist/cli/destroy.js.map +1 -1
  29. package/dist/cli/dispute.d.ts +3 -0
  30. package/dist/cli/dispute.d.ts.map +1 -0
  31. package/dist/cli/dispute.js +25 -0
  32. package/dist/cli/dispute.js.map +1 -0
  33. package/dist/cli/doctor.d.ts +1 -1
  34. package/dist/cli/doctor.d.ts.map +1 -1
  35. package/dist/cli/doctor.js +14 -1
  36. package/dist/cli/doctor.js.map +1 -1
  37. package/dist/cli/eject.d.ts.map +1 -1
  38. package/dist/cli/eject.js +14 -0
  39. package/dist/cli/eject.js.map +1 -1
  40. package/dist/cli/init.d.ts.map +1 -1
  41. package/dist/cli/init.js +14 -0
  42. package/dist/cli/init.js.map +1 -1
  43. package/dist/cli/log.d.ts +0 -11
  44. package/dist/cli/log.d.ts.map +1 -1
  45. package/dist/cli/log.js +2 -114
  46. package/dist/cli/log.js.map +1 -1
  47. package/dist/cli/pipeline.d.ts +3 -0
  48. package/dist/cli/pipeline.d.ts.map +1 -0
  49. package/dist/cli/pipeline.js +321 -0
  50. package/dist/cli/pipeline.js.map +1 -0
  51. package/dist/cli/plan.d.ts +37 -0
  52. package/dist/cli/plan.d.ts.map +1 -1
  53. package/dist/cli/plan.js +321 -161
  54. package/dist/cli/plan.js.map +1 -1
  55. package/dist/cli/run.js +2 -2
  56. package/dist/cli/run.js.map +1 -1
  57. package/dist/cli/update.d.ts.map +1 -1
  58. package/dist/cli/update.js +16 -0
  59. package/dist/cli/update.js.map +1 -1
  60. package/dist/cli/validate.d.ts +3 -0
  61. package/dist/cli/validate.d.ts.map +1 -0
  62. package/dist/cli/validate.js +60 -0
  63. package/dist/cli/validate.js.map +1 -0
  64. package/dist/cli/watch.d.ts.map +1 -1
  65. package/dist/cli/watch.js +1 -3
  66. package/dist/cli/watch.js.map +1 -1
  67. package/package.json +5 -4
  68. package/src/cli/convoy/engine.test.ts +2 -1
  69. package/src/cli/convoy/engine.ts +2 -5
  70. package/src/cli/convoy/export.test.ts +7 -224
  71. package/src/cli/convoy/export.ts +10 -106
  72. package/src/cli/convoy/issues.test.ts +3 -2
  73. package/src/cli/convoy/issues.ts +3 -3
  74. package/src/cli/convoy/pipeline.test.ts +0 -25
  75. package/src/cli/convoy/pipeline.ts +0 -19
  76. package/src/cli/dashboard.ts +33 -8
  77. package/src/cli/destroy.ts +15 -0
  78. package/src/cli/dispute.ts +28 -0
  79. package/src/cli/doctor.ts +16 -1
  80. package/src/cli/eject.ts +16 -0
  81. package/src/cli/init.ts +16 -0
  82. package/src/cli/log.ts +2 -120
  83. package/src/cli/pipeline.ts +362 -0
  84. package/src/cli/plan.ts +357 -153
  85. package/src/cli/run.ts +2 -2
  86. package/src/cli/update.ts +18 -0
  87. package/src/cli/validate.ts +65 -0
  88. package/src/cli/watch.ts +1 -3
  89. package/src/dashboard/dist/_astro/index.Je1YjU_y.css +1 -0
  90. package/src/dashboard/dist/data/convoy-list.json +54 -9
  91. package/src/dashboard/dist/data/convoys/demo-api-v2.json +177 -0
  92. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +239 -0
  93. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +328 -0
  94. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +187 -0
  95. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +153 -0
  96. package/src/dashboard/dist/data/convoys/demo-docs-update.json +154 -0
  97. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +227 -0
  98. package/src/dashboard/dist/data/events.ndjson +115 -0
  99. package/src/dashboard/dist/data/overall-stats.json +56 -13
  100. package/src/dashboard/dist/data/pipelines.ndjson +5285 -0
  101. package/src/dashboard/dist/index.html +165 -1392
  102. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  103. package/src/dashboard/public/data/convoy-list.json +54 -9
  104. package/src/dashboard/public/data/convoys/demo-api-v2.json +177 -0
  105. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +239 -0
  106. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +328 -0
  107. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +187 -0
  108. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +153 -0
  109. package/src/dashboard/public/data/convoys/demo-docs-update.json +154 -0
  110. package/src/dashboard/public/data/convoys/demo-perf-opt.json +227 -0
  111. package/src/dashboard/public/data/events.ndjson +115 -0
  112. package/src/dashboard/public/data/overall-stats.json +56 -13
  113. package/src/dashboard/public/data/pipelines.ndjson +5285 -0
  114. package/src/dashboard/scripts/etl.test.ts +4 -62
  115. package/src/dashboard/scripts/etl.ts +11 -10
  116. package/src/dashboard/scripts/generate-demo-db.ts +482 -115
  117. package/src/dashboard/src/pages/index.astro +235 -1638
  118. package/src/dashboard/src/styles/dashboard.css +473 -7
  119. package/src/orchestrator/prompts/brainstorm.prompt.md +1 -0
  120. package/src/orchestrator/prompts/fix-convoy.prompt.md +79 -0
  121. package/src/orchestrator/prompts/generate-convoy.prompt.md +60 -58
  122. package/src/orchestrator/prompts/generate-prd.prompt.md +126 -0
  123. package/src/orchestrator/prompts/validate-convoy.prompt.md +89 -0
  124. package/src/orchestrator/prompts/validate-prd.prompt.md +83 -0
  125. package/dist/cli/convoy/log-merge.test.d.ts +0 -2
  126. package/dist/cli/convoy/log-merge.test.d.ts.map +0 -1
  127. package/dist/cli/convoy/log-merge.test.js +0 -147
  128. package/dist/cli/convoy/log-merge.test.js.map +0 -1
  129. package/src/cli/convoy/log-merge.test.ts +0 -179
  130. package/src/dashboard/dist/_astro/index.6L3_HsPT.css +0 -1
package/src/cli/log.ts CHANGED
@@ -1,29 +1,18 @@
1
1
  import { mkdir, appendFile, stat } from 'node:fs/promises'
2
- import { readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
3
2
  import { join, dirname } from 'node:path'
4
3
  import type { CliContext } from './types.js'
5
4
 
6
5
  const HELP = `
7
6
  opencastle log [options]
8
- opencastle log merge [--since <ISO-date>] [--until <ISO-date>] [--output <path>]
9
7
 
10
- Append a structured event to the observability log (events.ndjson),
11
- or merge per-convoy NDJSON files into a single file.
8
+ Append a structured event to the observability log (events.ndjson).
12
9
 
13
- Subcommands:
14
- merge Merge all .opencastle/logs/convoys/*.ndjson into convoy-events.ndjson
15
-
16
- Options (log append):
10
+ Options:
17
11
  --type <type> Event type (required): session|delegation|review|panel|dispute
18
12
  --<field> <value> Any field from the event schema (see documentation)
19
13
  --logs-dir <path> Override the logs directory path
20
14
  --help, -h Show this help
21
15
 
22
- Options (merge):
23
- --since <ISO-date> Only include records at or after this date
24
- --until <ISO-date> Only include records at or before this date
25
- --output <path> Output path (default: .opencastle/logs/convoy-events.ndjson)
26
-
27
16
  Array fields (comma-separated): file_partition, lessons_added, discoveries, reviewing_agents
28
17
  Boolean fields: escalated, weighted
29
18
  Numeric fields: auto-detected from value
@@ -32,8 +21,6 @@ const HELP = `
32
21
  opencastle log --type session --agent Developer --model claude-sonnet-4-6 --task "Fix bug" --outcome success
33
22
  opencastle log --type delegation --session_id feat/prj-1 --agent Developer --tier fast --mechanism sub-agent --outcome success
34
23
  opencastle log --type panel --panel_key auth-review --verdict pass --pass_count 3 --block_count 0
35
- opencastle log merge --since 2026-01-01 --output /tmp/merged.ndjson
36
- opencastle log merge
37
24
  `
38
25
 
39
26
  const VALID_TYPES = ['session', 'delegation', 'review', 'panel', 'dispute']
@@ -72,94 +59,6 @@ export async function resolveLogsDir(override?: string | null): Promise<string>
72
59
  return join(process.cwd(), '.opencastle', 'logs')
73
60
  }
74
61
 
75
- /** Merge per-convoy NDJSON files into a single deduplicated, sorted file. */
76
- export async function mergeConvoyLogs(options: {
77
- since?: string
78
- until?: string
79
- output?: string
80
- basePath?: string
81
- }): Promise<{ merged: number; deduplicated: number; written: number }> {
82
- const base = options.basePath ?? process.cwd()
83
- const convoysDir = join(base, '.opencastle', 'logs', 'convoys')
84
-
85
- let files: string[] = []
86
- try {
87
- files = readdirSync(convoysDir)
88
- .filter(f => f.endsWith('.ndjson'))
89
- .map(f => join(convoysDir, f))
90
- } catch {
91
- return { merged: 0, deduplicated: 0, written: 0 }
92
- }
93
-
94
- if (files.length === 0) {
95
- return { merged: 0, deduplicated: 0, written: 0 }
96
- }
97
-
98
- const allRecords: Array<Record<string, unknown>> = []
99
- let totalRead = 0
100
-
101
- for (const file of files) {
102
- const content = readFileSync(file, 'utf8')
103
- const lines = content.split('\n').filter(l => l.trim())
104
- for (const line of lines) {
105
- try {
106
- allRecords.push(JSON.parse(line) as Record<string, unknown>)
107
- totalRead++
108
- } catch {
109
- // skip malformed lines
110
- }
111
- }
112
- }
113
-
114
- // Deduplicate by _event_id — keep first occurrence
115
- const seen = new Set<unknown>()
116
- const unique: Array<Record<string, unknown>> = []
117
- for (const record of allRecords) {
118
- const id = record['_event_id']
119
- if (id !== undefined) {
120
- if (seen.has(id)) continue
121
- seen.add(id)
122
- }
123
- unique.push(record)
124
- }
125
-
126
- const deduplicatedCount = totalRead - unique.length
127
-
128
- // Filter by since/until
129
- let filtered = unique
130
- if (options.since) {
131
- const since = options.since
132
- filtered = filtered.filter(r => {
133
- const ts = r['timestamp'] as string | undefined
134
- return ts !== undefined && ts >= since
135
- })
136
- }
137
- if (options.until) {
138
- const until = options.until
139
- filtered = filtered.filter(r => {
140
- const ts = r['timestamp'] as string | undefined
141
- return ts !== undefined && ts <= until
142
- })
143
- }
144
-
145
- // Sort by timestamp ascending
146
- filtered.sort((a, b) => {
147
- const ta = (a['timestamp'] as string) ?? ''
148
- const tb = (b['timestamp'] as string) ?? ''
149
- return ta < tb ? -1 : ta > tb ? 1 : 0
150
- })
151
-
152
- if (filtered.length === 0) {
153
- return { merged: totalRead, deduplicated: deduplicatedCount, written: 0 }
154
- }
155
-
156
- const outputPath = options.output ?? join(base, '.opencastle', 'logs', 'convoy-events.ndjson')
157
- mkdirSync(dirname(outputPath), { recursive: true })
158
- writeFileSync(outputPath, filtered.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8')
159
-
160
- return { merged: totalRead, deduplicated: deduplicatedCount, written: filtered.length }
161
- }
162
-
163
62
  /** Append a structured event record to events.ndjson. */
164
63
  export async function appendEvent(
165
64
  record: Record<string, unknown>,
@@ -178,23 +77,6 @@ export default async function log({ args }: CliContext): Promise<void> {
178
77
  return
179
78
  }
180
79
 
181
- // merge subcommand
182
- if (args[0] === 'merge') {
183
- const mergeArgs = args.slice(1)
184
- let since: string | undefined
185
- let until: string | undefined
186
- let output: string | undefined
187
- for (let i = 0; i < mergeArgs.length; i++) {
188
- const a = mergeArgs[i]
189
- if (a === '--since' && i + 1 < mergeArgs.length) { since = mergeArgs[++i]; continue }
190
- if (a === '--until' && i + 1 < mergeArgs.length) { until = mergeArgs[++i]; continue }
191
- if (a === '--output' && i + 1 < mergeArgs.length) { output = mergeArgs[++i]; continue }
192
- }
193
- const result = await mergeConvoyLogs({ since, until, output })
194
- console.log(` Merged: ${result.merged} records, Deduplicated: ${result.deduplicated}, Written: ${result.written}`)
195
- return
196
- }
197
-
198
80
  let type: string | null = null
199
81
  let logsDir: string | null = null
200
82
  const fields: Record<string, unknown> = {}
@@ -0,0 +1,362 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { existsSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { c, confirm, closePrompts } 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
+ await printFinalSummary(prdPath, specPath, opts, pkgRoot)
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
+ await printFinalSummary(prdPath, specPath, opts, pkgRoot)
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
+ await printFinalSummary(prdPath, specPath, opts, pkgRoot)
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
+ async function printFinalSummary(
334
+ prdPath: string,
335
+ specPath: string,
336
+ opts: PipelineOptions,
337
+ pkgRoot: string,
338
+ ): Promise<void> {
339
+ const prd = relPath(prdPath)
340
+ const spec = relPath(specPath)
341
+ console.log(c.bold(c.green(' Pipeline complete!\n')))
342
+ console.log(` PRD: ${prd}`)
343
+ console.log(` Convoy spec: ${spec}\n`)
344
+ console.log(
345
+ ` ${c.dim('Preview:')} npx opencastle run -f ${spec} --dry-run\n` +
346
+ ` ${c.dim('Execute:')} npx opencastle run -f ${spec}\n`
347
+ )
348
+
349
+ try {
350
+ const shouldRun = await confirm('Run the convoy now?', true)
351
+ if (shouldRun) {
352
+ closePrompts()
353
+ const runModule = await import('./run.js')
354
+ const runArgs = ['-f', specPath]
355
+ if (opts.adapter) runArgs.push('-a', opts.adapter)
356
+ if (opts.verbose) runArgs.push('--verbose')
357
+ await runModule.default({ args: runArgs, pkgRoot })
358
+ }
359
+ } finally {
360
+ closePrompts()
361
+ }
362
+ }