opencastle 0.27.2 → 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 (71) 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/run.d.ts.map +1 -1
  19. package/dist/cli/run.js +37 -1
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/cli/validate.d.ts +3 -0
  22. package/dist/cli/validate.d.ts.map +1 -0
  23. package/dist/cli/validate.js +60 -0
  24. package/dist/cli/validate.js.map +1 -0
  25. package/package.json +5 -3
  26. package/src/cli/convoy/engine.test.ts +1 -1
  27. package/src/cli/convoy/engine.ts +2 -2
  28. package/src/cli/convoy/issues.test.ts +3 -2
  29. package/src/cli/convoy/issues.ts +3 -3
  30. package/src/cli/pipeline.ts +343 -0
  31. package/src/cli/plan.ts +357 -153
  32. package/src/cli/run.ts +37 -1
  33. package/src/cli/validate.ts +65 -0
  34. package/src/dashboard/dist/data/convoy-list.json +65 -1
  35. package/src/dashboard/dist/data/convoys/demo-api-v2.json +177 -0
  36. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +239 -0
  37. package/src/dashboard/dist/data/convoys/demo-convoy-1.json +111 -0
  38. package/src/dashboard/dist/data/convoys/demo-convoy-2.json +72 -0
  39. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +328 -0
  40. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +187 -0
  41. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +153 -0
  42. package/src/dashboard/dist/data/convoys/demo-docs-update.json +154 -0
  43. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +227 -0
  44. package/src/dashboard/dist/data/events.ndjson +115 -0
  45. package/src/dashboard/dist/data/overall-stats.json +67 -12
  46. package/src/dashboard/dist/data/pipelines.ndjson +5285 -0
  47. package/src/dashboard/dist/index.html +39 -16
  48. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  49. package/src/dashboard/public/data/convoy-list.json +65 -1
  50. package/src/dashboard/public/data/convoys/demo-api-v2.json +177 -0
  51. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +239 -0
  52. package/src/dashboard/public/data/convoys/demo-convoy-1.json +111 -0
  53. package/src/dashboard/public/data/convoys/demo-convoy-2.json +72 -0
  54. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +328 -0
  55. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +187 -0
  56. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +153 -0
  57. package/src/dashboard/public/data/convoys/demo-docs-update.json +154 -0
  58. package/src/dashboard/public/data/convoys/demo-perf-opt.json +227 -0
  59. package/src/dashboard/public/data/events.ndjson +115 -0
  60. package/src/dashboard/public/data/overall-stats.json +67 -12
  61. package/src/dashboard/public/data/pipelines.ndjson +5285 -0
  62. package/src/dashboard/scripts/etl.ts +38 -4
  63. package/src/dashboard/scripts/generate-demo-db.test.ts +30 -0
  64. package/src/dashboard/scripts/generate-demo-db.ts +507 -0
  65. package/src/dashboard/scripts/verify-demo-data.sh +51 -0
  66. package/src/dashboard/src/pages/index.astro +46 -23
  67. package/src/orchestrator/prompts/fix-convoy.prompt.md +79 -0
  68. package/src/orchestrator/prompts/generate-convoy.prompt.md +53 -58
  69. package/src/orchestrator/prompts/generate-prd.prompt.md +120 -0
  70. package/src/orchestrator/prompts/validate-convoy.prompt.md +89 -0
  71. package/src/orchestrator/prompts/validate-prd.prompt.md +83 -0
package/src/cli/plan.ts CHANGED
@@ -10,22 +10,34 @@ import type { CliContext, Task } from './types.js'
10
10
  const HELP = `
11
11
  opencastle plan [options]
12
12
 
13
- Generate a convoy spec from a task description file by running it through the
14
- generate-convoy prompt via an AI adapter.
13
+ Generate a convoy spec (or other AI output) from a task description by running
14
+ it through a prompt template via an AI adapter.
15
15
 
16
16
  Options:
17
- --file, -f <path> Path to a text file with the task description (required)
18
- --context <path> Optional path to an additional context file
19
- --output, -o <path> Output path for the generated convoy spec
17
+ --file, -f <path> Path to a text file (fills {{goal}} in the template)
18
+ --text, -t <text> Inline text to use as {{goal}} (alternative to --file)
19
+ --template <name> Prompt template name (default: generate-convoy)
20
+ 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-convoy — Check a convoy spec for correctness
25
+ fix-convoy — Fix validation errors in a convoy spec
26
+ --context <path> Optional path to an additional context file (fills {{context}})
27
+ --context-text <text> Inline text to fill {{context}} (alternative to --context)
28
+ --output, -o <path> Output path override (skipped for validation output)
20
29
  --adapter, -a <name> Override agent runtime adapter
21
30
  --verbose Show full agent output
22
- --dry-run Print the prompt that would be sent without executing
31
+ --dry-run Print the assembled prompt without executing
23
32
  --help, -h Show this help
24
33
  `
25
34
 
26
35
  interface PlanOptions {
27
36
  file: string | null
37
+ text: string | null
38
+ template: string
28
39
  context: string | null
40
+ contextText: string | null
29
41
  output: string | null
30
42
  adapter: string | null
31
43
  verbose: boolean
@@ -33,60 +45,44 @@ interface PlanOptions {
33
45
  help: boolean
34
46
  }
35
47
 
36
- function parseArgs(args: string[]): PlanOptions {
37
- const opts: PlanOptions = {
38
- file: null,
39
- context: null,
40
- output: null,
41
- adapter: null,
42
- verbose: false,
43
- dryRun: false,
44
- help: false,
45
- }
46
-
47
- for (let i = 0; i < args.length; i++) {
48
- const arg = args[i]
49
- switch (arg) {
50
- case '--help':
51
- case '-h':
52
- opts.help = true
53
- break
54
- case '--file':
55
- case '-f':
56
- if (i + 1 >= args.length) { console.error(' --file requires a path'); process.exit(1) }
57
- opts.file = args[++i]
58
- break
59
- case '--context':
60
- if (i + 1 >= args.length) { console.error(' ✗ --context requires a path'); process.exit(1) }
61
- opts.context = args[++i]
62
- break
63
- case '--output':
64
- case '-o':
65
- if (i + 1 >= args.length) { console.error(' ✗ --output requires a path'); process.exit(1) }
66
- opts.output = args[++i]
67
- break
68
- case '--adapter':
69
- case '-a':
70
- if (i + 1 >= args.length) { console.error(' ✗ --adapter requires a name'); process.exit(1) }
71
- opts.adapter = args[++i]
72
- break
73
- case '--verbose':
74
- opts.verbose = true
75
- break
76
- case '--dry-run':
77
- case '--dryRun':
78
- opts.dryRun = true
79
- break
80
- default:
81
- console.error(` ✗ Unknown option: ${arg}`)
82
- console.log(HELP)
83
- process.exit(1)
84
- }
85
- }
48
+ // ── Exported types ──────────────────────────────────────────────────────────
49
+
50
+ export interface PromptStepOptions {
51
+ /** Template name without extension. Default: 'generate-convoy' */
52
+ template?: string
53
+ /** Absolute file path whose content fills {{goal}} */
54
+ filePath?: string
55
+ /** Inline text that fills {{goal}} — alternative to filePath */
56
+ goalText?: string
57
+ /** File path whose content fills {{context}} */
58
+ contextPath?: string
59
+ /** Inline text that fills {{context}} alternative to contextPath */
60
+ contextText?: string
61
+ /** Explicit output path override */
62
+ outputPath?: string
63
+ /** Adapter name override */
64
+ adapterName?: string
65
+ verbose?: boolean
66
+ dryRun?: boolean
67
+ /** Absolute path to the opencastle package root (for locating prompt templates) */
68
+ pkgRoot: string
69
+ }
86
70
 
87
- return opts
71
+ export interface PromptStepResult {
72
+ /** Absolute path the output was written to. null for validation output or dry-run */
73
+ outputPath: string | null
74
+ /** Raw text returned by the AI adapter (or assembled prompt on dry-run) */
75
+ rawOutput: string
76
+ /** How the output was interpreted */
77
+ outputType: 'convoy-spec' | 'prd' | 'validation'
78
+ /** Set when outputType === 'validation' */
79
+ isValid?: boolean
80
+ /** Set when outputType === 'validation' and isValid === false */
81
+ errors?: string
88
82
  }
89
83
 
84
+ // ── Private helpers ─────────────────────────────────────────────────────────
85
+
90
86
  function printAdapterError(detectionFailed: boolean, adapterName: string): void {
91
87
  if (detectionFailed) {
92
88
  console.error(
@@ -101,7 +97,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
101
97
  )
102
98
  } else {
103
99
  const hints: Record<string, string> = {
104
- 'claude':
100
+ claude:
105
101
  ' Install: npm install -g @anthropic-ai/claude-code\n' +
106
102
  ' Docs: https://docs.anthropic.com/en/docs/claude-code',
107
103
  copilot:
@@ -126,9 +122,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
126
122
  }
127
123
  }
128
124
 
129
- /**
130
- * Strip YAML frontmatter (everything between first and second --- lines).
131
- */
125
+ /** Strip YAML frontmatter (everything between first and second --- delimiters). */
132
126
  function stripFrontmatter(text: string): string {
133
127
  const lines = text.split('\n')
134
128
  if (lines[0]?.trim() !== '---') return text
@@ -137,9 +131,21 @@ function stripFrontmatter(text: string): string {
137
131
  return lines.slice(closingIdx + 1).join('\n').trimStart()
138
132
  }
139
133
 
140
- /**
141
- * Extract YAML content from a fenced code block (```yaml or ```yml).
142
- */
134
+ /** Extract key: value pairs from YAML frontmatter (top-level scalar values only). */
135
+ function parseFrontmatter(text: string): Record<string, string> {
136
+ const result: Record<string, string> = {}
137
+ const lines = text.split('\n')
138
+ if (lines[0]?.trim() !== '---') return result
139
+ const closingIdx = lines.findIndex((line, i) => i > 0 && line.trim() === '---')
140
+ if (closingIdx === -1) return result
141
+ for (let i = 1; i < closingIdx; i++) {
142
+ const match = lines[i].match(/^(\w[\w-]*):\s*['"]?([^'"]+?)['"]?\s*$/)
143
+ if (match) result[match[1]] = match[2].trim()
144
+ }
145
+ return result
146
+ }
147
+
148
+ /** Extract YAML content from a fenced ```yaml ... ``` block. */
143
149
  function extractYamlBlock(text: string): string | null {
144
150
  const match = text.match(/```ya?ml\s*\n([\s\S]*?)```/)
145
151
  if (!match) return null
@@ -147,91 +153,127 @@ function extractYamlBlock(text: string): string | null {
147
153
  }
148
154
 
149
155
  /**
150
- * Derive an output filename from YAML content.
151
- * Checks for a comment on the first line, then falls back to the `name` field.
156
+ * Derive a .convoy.yml filename from YAML content.
157
+ * Checks for a first-line comment, then falls back to the `name:` field.
152
158
  */
153
159
  function deriveOutputFilename(yaml: string): string {
154
- // First line comment: # .opencastle/convoys/some-name.convoy.yml
155
160
  const firstLine = yaml.split('\n')[0] ?? ''
156
161
  const commentMatch = firstLine.match(/^#\s*(.+\.convoy\.ya?ml)\s*$/)
157
- if (commentMatch) {
158
- return basename(commentMatch[1])
159
- }
160
-
161
- // Fall back to `name:` field
162
+ if (commentMatch) return basename(commentMatch[1])
162
163
  const nameMatch = yaml.match(/^name:\s*['"]?([^'"\n]+)['"]?\s*$/m)
163
164
  if (nameMatch) {
164
165
  const kebab = nameMatch[1].trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
165
166
  if (kebab) return `${kebab}.convoy.yml`
166
167
  }
167
-
168
168
  return 'convoy-plan.convoy.yml'
169
169
  }
170
170
 
171
- export default async function plan({ args, pkgRoot }: CliContext): Promise<void> {
172
- const opts = parseArgs(args)
173
-
174
- if (opts.help) {
175
- console.log(HELP)
176
- return
171
+ /**
172
+ * Derive a .prd.md filename from PRD Markdown content.
173
+ * Looks for a # Heading to extract a kebab-case name.
174
+ */
175
+ function derivePrdFilename(content: string): string {
176
+ const headingMatch = content.match(/^#\s+(.+?)(?:\s*[-—–]+\s*PRD)?\s*$/m)
177
+ if (headingMatch) {
178
+ const kebab = headingMatch[1]
179
+ .trim()
180
+ .toLowerCase()
181
+ .replace(/[^a-z0-9]+/g, '-')
182
+ .replace(/^-|-$/g, '')
183
+ if (kebab) return `${kebab}.prd.md`
177
184
  }
185
+ return `prd-${Date.now()}.prd.md`
186
+ }
178
187
 
179
- // ── Validate required --file arg ──────────────────────────────
180
- if (!opts.file) {
181
- console.error(` ✗ --file is required. Specify a text file with the task description.`)
182
- console.log(HELP)
183
- process.exit(1)
188
+ /**
189
+ * Extract Markdown body from AI output.
190
+ * Strips wrapping ```markdown / ```md fences if present.
191
+ * If a heading is present but prefixed with preamble, trims to the first heading.
192
+ */
193
+ function extractMarkdownBody(output: string): string {
194
+ // Strip explicit markdown fence
195
+ const mdFenceMatch = output.match(/^```(?:markdown|md)\s*\n([\s\S]*?)```\s*$/m)
196
+ if (mdFenceMatch) return mdFenceMatch[1].trim()
197
+
198
+ const lines = output.trim().split('\n')
199
+ // Strip plain ``` wrapping (first and last line are fences)
200
+ if (lines[0]?.startsWith('```') && lines[lines.length - 1]?.trim() === '```') {
201
+ return lines.slice(1, -1).join('\n').trim()
184
202
  }
185
203
 
186
- const filePath = resolve(process.cwd(), opts.file)
187
- if (!existsSync(filePath)) {
188
- console.error(` File not found: ${opts.file}`)
189
- process.exit(1)
204
+ // If there's preamble before the first heading, strip it
205
+ const headingIdx = lines.findIndex((l) => /^#{1,3}\s/.test(l))
206
+ if (headingIdx > 0) return lines.slice(headingIdx).join('\n').trim()
207
+
208
+ return output.trim()
209
+ }
210
+
211
+ /**
212
+ * Parse a validation AI response for VALID / INVALID verdict.
213
+ * INVALID takes precedence because VALID is a substring of INVALID.
214
+ */
215
+ function parseValidationResult(output: string): { isValid: boolean; errors: string } {
216
+ const trimmed = output.trim()
217
+ const hasInvalid = /\bINVALID\b/.test(trimmed)
218
+ const hasValid = /\bVALID\b/.test(trimmed)
219
+ if (hasValid && !hasInvalid) return { isValid: true, errors: '' }
220
+ const errorsMatch = trimmed.match(/(?:Issues|Errors):\s*\n([\s\S]+)/i)
221
+ return { isValid: false, errors: errorsMatch ? errorsMatch[1].trim() : trimmed }
222
+ }
223
+
224
+ // ── Exported programmatic API ───────────────────────────────────────────────
225
+
226
+ /**
227
+ * Execute a single prompt template step via an AI adapter.
228
+ * Used by the pipeline command to chain steps programmatically.
229
+ */
230
+ export async function runPromptStep(opts: PromptStepOptions): Promise<PromptStepResult> {
231
+ const templateName = opts.template ?? 'generate-convoy'
232
+
233
+ const templatePath = join(
234
+ opts.pkgRoot,
235
+ 'src',
236
+ 'orchestrator',
237
+ 'prompts',
238
+ `${templateName}.prompt.md`
239
+ )
240
+ if (!existsSync(templatePath)) {
241
+ throw new Error(`Prompt template not found: ${templatePath}`)
190
242
  }
191
243
 
192
- // ── Read task description ──────────────────────────────────────
193
- const taskDescription = await readFile(filePath, 'utf8')
244
+ const rawTemplate = await readFile(templatePath, 'utf8')
245
+ const frontmatter = parseFrontmatter(rawTemplate)
246
+ const outputType = (frontmatter['output'] ?? 'convoy-spec') as 'convoy-spec' | 'prd' | 'validation'
247
+ const template = stripFrontmatter(rawTemplate)
194
248
 
195
- // ── Read optional context file ─────────────────────────────────
196
- let contextContent = ''
197
- if (opts.context) {
198
- const contextPath = resolve(process.cwd(), opts.context)
199
- if (!existsSync(contextPath)) {
200
- console.error(` ✗ Context file not found: ${opts.context}`)
201
- process.exit(1)
202
- }
203
- contextContent = await readFile(contextPath, 'utf8')
249
+ let goalContent = opts.goalText ?? ''
250
+ if (!goalContent && opts.filePath) {
251
+ if (!existsSync(opts.filePath)) throw new Error(`File not found: ${opts.filePath}`)
252
+ goalContent = await readFile(opts.filePath, 'utf8')
204
253
  }
205
254
 
206
- // ── Load and assemble the prompt template ─────────────────────
207
- const promptTemplatePath = join(pkgRoot, 'src', 'orchestrator', 'prompts', 'generate-convoy.prompt.md')
208
- if (!existsSync(promptTemplatePath)) {
209
- console.error(` ✗ Prompt template not found: ${promptTemplatePath}`)
210
- process.exit(1)
255
+ let contextContent = opts.contextText ?? ''
256
+ if (!contextContent && opts.contextPath) {
257
+ if (!existsSync(opts.contextPath)) throw new Error(`Context file not found: ${opts.contextPath}`)
258
+ contextContent = await readFile(opts.contextPath, 'utf8')
211
259
  }
212
260
 
213
- const rawTemplate = await readFile(promptTemplatePath, 'utf8')
214
- const template = stripFrontmatter(rawTemplate)
215
261
  const assembledPrompt = template
216
- .replace('{{goal}}', taskDescription.trim())
217
- .replace('{{context}}', contextContent.trim())
262
+ .replace(/\{\{goal\}\}/g, goalContent.trim())
263
+ .replace(/\{\{context\}\}/g, contextContent.trim())
218
264
 
219
- // ── Dry-run: print prompt and exit ────────────────────────────
220
265
  if (opts.dryRun) {
221
- console.log(c.bold(c.cyan(' Assembled prompt (dry-run):\n')))
266
+ console.log(c.bold(c.cyan(` [${templateName}] Assembled prompt (dry-run):\n`)))
222
267
  console.log(assembledPrompt)
223
- return
268
+ return { outputPath: null, rawOutput: assembledPrompt, outputType }
224
269
  }
225
270
 
226
- // ── Resolve adapter ───────────────────────────────────────────
227
- let adapterName: string
228
- if (opts.adapter) {
229
- adapterName = opts.adapter
230
- } else {
271
+ let adapterName = opts.adapterName ?? ''
272
+ if (!adapterName) {
231
273
  const detected = await detectAdapter()
232
274
  if (!detected) {
233
275
  printAdapterError(true, '')
234
- process.exit(1)
276
+ throw new Error('No adapter available')
235
277
  }
236
278
  adapterName = detected
237
279
  }
@@ -241,76 +283,238 @@ export default async function plan({ args, pkgRoot }: CliContext): Promise<void>
241
283
  adapter = await getAdapter(adapterName)
242
284
  } catch {
243
285
  printAdapterError(false, adapterName)
244
- process.exit(1)
286
+ throw new Error(`Adapter "${adapterName}" failed to load`)
245
287
  }
246
288
 
247
- const available = await adapter.isAvailable()
248
- if (!available) {
289
+ if (!(await adapter.isAvailable())) {
249
290
  printAdapterError(false, adapterName)
250
- process.exit(1)
291
+ throw new Error(`Adapter "${adapterName}" is not available`)
251
292
  }
252
293
 
253
- console.log(c.dim(` Using adapter: ${adapterName}`))
254
- console.log(c.dim(` Generating convoy spec from: ${opts.file}\n`))
255
-
256
- // ── Execute the prompt through the adapter ────────────────────
294
+ const agentField = (frontmatter['agent'] ?? 'team-lead').toLowerCase().replace(/\s+/g, '-')
257
295
  const task: Task = {
258
- id: 'generate-convoy',
296
+ id: templateName,
259
297
  prompt: assembledPrompt,
260
- agent: 'team-lead',
298
+ agent: agentField,
261
299
  timeout: '10m',
262
300
  depends_on: [],
263
301
  files: [],
264
- description: 'Generate convoy spec from task description',
302
+ description: frontmatter['description'] ?? templateName,
265
303
  max_retries: 1,
266
304
  }
267
305
 
268
- const result = await adapter.execute(task, { verbose: opts.verbose })
306
+ const execResult = await adapter.execute(task, { verbose: opts.verbose ?? false })
307
+ const rawOutput = execResult.output
308
+
309
+ if (outputType === 'validation') {
310
+ const { isValid, errors } = parseValidationResult(rawOutput)
311
+ return { outputPath: null, rawOutput, outputType, isValid, errors }
312
+ }
313
+
314
+ if (outputType === 'prd') {
315
+ const content = extractMarkdownBody(rawOutput)
316
+ let outputPath = opts.outputPath ?? null
317
+ if (!outputPath) {
318
+ const prdDir = resolve(process.cwd(), '.opencastle', 'prds')
319
+ await mkdir(prdDir, { recursive: true })
320
+ outputPath = join(prdDir, derivePrdFilename(content))
321
+ }
322
+ await mkdir(resolve(outputPath, '..'), { recursive: true })
323
+ await writeFile(outputPath, content + '\n', 'utf8')
324
+ return { outputPath, rawOutput, outputType }
325
+ }
269
326
 
270
- // ── Extract YAML from the response ────────────────────────────
271
- const yamlContent = extractYamlBlock(result.output)
327
+ // convoy-spec (default)
328
+ const yamlContent = extractYamlBlock(rawOutput)
272
329
  if (!yamlContent) {
273
- const preview = result.output.slice(0, 500)
274
- console.error(` ✗ No YAML code block found in the agent response.\n`)
275
- console.error(c.dim(` Raw output (truncated):\n${preview}`))
276
- process.exit(1)
330
+ const preview = rawOutput.slice(0, 500)
331
+ throw new Error(
332
+ `No YAML code block found in the agent response.\n\nRaw output (truncated):\n${preview}`
333
+ )
277
334
  }
278
335
 
279
- // ── Validate YAML ─────────────────────────────────────────────
280
- let validationWarning = false
336
+ let schemaValid = true
281
337
  try {
282
338
  parseTaskSpecText(yamlContent)
283
339
  } catch (err) {
284
- validationWarning = true
340
+ schemaValid = false
285
341
  const msg = err instanceof Error ? err.message : String(err)
286
342
  console.warn(c.yellow(` ⚠ YAML validation warning: ${msg}`))
287
343
  console.warn(c.dim(` The file will still be written — you may need to edit it before running.\n`))
288
344
  }
289
345
 
290
- // ── Determine output path ─────────────────────────────────────
291
- let outputPath: string
292
- if (opts.output) {
293
- outputPath = resolve(process.cwd(), opts.output)
294
- } else {
346
+ let outputPath = opts.outputPath ?? null
347
+ if (!outputPath) {
295
348
  const convoyDir = resolve(process.cwd(), '.opencastle', 'convoys')
296
349
  await mkdir(convoyDir, { recursive: true })
297
- const filename = deriveOutputFilename(yamlContent)
298
- outputPath = join(convoyDir, filename)
350
+ outputPath = join(convoyDir, deriveOutputFilename(yamlContent))
299
351
  }
300
-
301
352
  await mkdir(resolve(outputPath, '..'), { recursive: true })
302
353
  await writeFile(outputPath, yamlContent + '\n', 'utf8')
303
354
 
304
- const relPath = outputPath.startsWith(process.cwd())
305
- ? outputPath.slice(process.cwd().length + 1)
306
- : outputPath
355
+ return { outputPath, rawOutput, outputType, isValid: schemaValid }
356
+ }
357
+
358
+ // ── CLI argument parsing ────────────────────────────────────────────────────
359
+
360
+ function parseArgs(args: string[]): PlanOptions {
361
+ const opts: PlanOptions = {
362
+ file: null,
363
+ text: null,
364
+ template: 'generate-convoy',
365
+ context: null,
366
+ contextText: null,
367
+ output: null,
368
+ adapter: null,
369
+ verbose: false,
370
+ dryRun: false,
371
+ help: false,
372
+ }
373
+
374
+ for (let i = 0; i < args.length; i++) {
375
+ const arg = args[i]
376
+ switch (arg) {
377
+ case '--help':
378
+ case '-h':
379
+ opts.help = true
380
+ break
381
+ case '--file':
382
+ case '-f':
383
+ if (i + 1 >= args.length) { console.error(' ✗ --file requires a path'); process.exit(1) }
384
+ opts.file = args[++i]
385
+ break
386
+ case '--text':
387
+ case '-t':
388
+ if (i + 1 >= args.length) { console.error(' ✗ --text requires a value'); process.exit(1) }
389
+ opts.text = args[++i]
390
+ break
391
+ case '--template':
392
+ if (i + 1 >= args.length) { console.error(' ✗ --template requires a name'); process.exit(1) }
393
+ opts.template = args[++i]
394
+ break
395
+ case '--context':
396
+ if (i + 1 >= args.length) { console.error(' ✗ --context requires a path'); process.exit(1) }
397
+ opts.context = args[++i]
398
+ break
399
+ case '--context-text':
400
+ if (i + 1 >= args.length) { console.error(' ✗ --context-text requires a value'); process.exit(1) }
401
+ opts.contextText = args[++i]
402
+ break
403
+ case '--output':
404
+ case '-o':
405
+ if (i + 1 >= args.length) { console.error(' ✗ --output requires a path'); process.exit(1) }
406
+ opts.output = args[++i]
407
+ break
408
+ case '--adapter':
409
+ case '-a':
410
+ if (i + 1 >= args.length) { console.error(' ✗ --adapter requires a name'); process.exit(1) }
411
+ opts.adapter = args[++i]
412
+ break
413
+ case '--verbose':
414
+ opts.verbose = true
415
+ break
416
+ case '--dry-run':
417
+ case '--dryRun':
418
+ opts.dryRun = true
419
+ break
420
+ default:
421
+ console.error(` ✗ Unknown option: ${arg}`)
422
+ console.log(HELP)
423
+ process.exit(1)
424
+ }
425
+ }
426
+
427
+ return opts
428
+ }
429
+
430
+ // ── CLI entrypoint ──────────────────────────────────────────────────────────
431
+
432
+ export default async function plan({ args, pkgRoot }: CliContext): Promise<void> {
433
+ const opts = parseArgs(args)
434
+
435
+ if (opts.help) {
436
+ console.log(HELP)
437
+ return
438
+ }
439
+
440
+ if (!opts.file && !opts.text) {
441
+ console.error(` ✗ Either --file or --text is required.`)
442
+ console.log(HELP)
443
+ process.exit(1)
444
+ }
307
445
 
308
- console.log(c.green(` ✓ Convoy spec written to ${relPath}`))
309
- if (validationWarning) {
310
- console.log(c.yellow(` (contains validation warnings — review before running)`))
446
+ if (opts.file && opts.text) {
447
+ console.error(` --file and --text are mutually exclusive.`)
448
+ process.exit(1)
311
449
  }
312
- console.log(`
450
+
451
+ const filePath = opts.file ? resolve(process.cwd(), opts.file) : undefined
452
+
453
+ if (filePath && !existsSync(filePath)) {
454
+ console.error(` ✗ File not found: ${opts.file}`)
455
+ process.exit(1)
456
+ }
457
+
458
+ const outputPath = opts.output ? resolve(process.cwd(), opts.output) : undefined
459
+ const source = opts.file
460
+ ? opts.file
461
+ : `"${(opts.text ?? '').slice(0, 60)}${(opts.text ?? '').length > 60 ? '…' : ''}"`
462
+
463
+ console.log(c.dim(` Template: ${opts.template}`))
464
+ console.log(c.dim(` Input: ${source}\n`))
465
+
466
+ let result: PromptStepResult
467
+ try {
468
+ result = await runPromptStep({
469
+ template: opts.template,
470
+ filePath,
471
+ goalText: opts.text ?? undefined,
472
+ contextPath: opts.context ?? undefined,
473
+ contextText: opts.contextText ?? undefined,
474
+ outputPath,
475
+ adapterName: opts.adapter ?? undefined,
476
+ verbose: opts.verbose,
477
+ dryRun: opts.dryRun,
478
+ pkgRoot,
479
+ })
480
+ } catch (err) {
481
+ console.error(` ✗ ${err instanceof Error ? err.message : String(err)}`)
482
+ process.exit(1)
483
+ }
484
+
485
+ if (opts.dryRun) return
486
+
487
+ switch (result.outputType) {
488
+ case 'validation': {
489
+ if (result.isValid) {
490
+ console.log(c.green(` ✓ VALID`))
491
+ } else {
492
+ console.log(c.red(` ✗ INVALID\n`))
493
+ console.log(result.errors ?? result.rawOutput)
494
+ process.exit(1)
495
+ }
496
+ break
497
+ }
498
+ case 'prd': {
499
+ const relPath = result.outputPath!.startsWith(process.cwd())
500
+ ? result.outputPath!.slice(process.cwd().length + 1)
501
+ : result.outputPath!
502
+ console.log(c.green(` ✓ PRD written to ${relPath}`))
503
+ console.log(`\n ${c.dim('Next step:')} opencastle plan --file ${relPath} --template validate-prd`)
504
+ break
505
+ }
506
+ default: {
507
+ const relPath = result.outputPath!.startsWith(process.cwd())
508
+ ? result.outputPath!.slice(process.cwd().length + 1)
509
+ : result.outputPath!
510
+ console.log(c.green(` ✓ Convoy spec written to ${relPath}`))
511
+ if (result.isValid === false) {
512
+ console.log(c.yellow(` (contains validation warnings — review before running)`))
513
+ }
514
+ console.log(`
313
515
  ${c.dim('Preview:')} npx opencastle run -f ${relPath} --dry-run
314
516
  ${c.dim('Execute:')} npx opencastle run -f ${relPath}
315
517
  `)
518
+ }
519
+ }
316
520
  }