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.
- package/bin/cli.mjs +13 -5
- package/dist/cli/convoy/engine.js +2 -2
- package/dist/cli/convoy/engine.js.map +1 -1
- package/dist/cli/convoy/engine.test.js +1 -1
- package/dist/cli/convoy/engine.test.js.map +1 -1
- package/dist/cli/convoy/issues.js +3 -3
- package/dist/cli/convoy/issues.js.map +1 -1
- package/dist/cli/convoy/issues.test.js +4 -3
- package/dist/cli/convoy/issues.test.js.map +1 -1
- package/dist/cli/pipeline.d.ts +3 -0
- package/dist/cli/pipeline.d.ts.map +1 -0
- package/dist/cli/pipeline.js +305 -0
- package/dist/cli/pipeline.js.map +1 -0
- package/dist/cli/plan.d.ts +37 -0
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli/plan.js +321 -161
- package/dist/cli/plan.js.map +1 -1
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +37 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/validate.d.ts +3 -0
- package/dist/cli/validate.d.ts.map +1 -0
- package/dist/cli/validate.js +60 -0
- package/dist/cli/validate.js.map +1 -0
- package/package.json +5 -3
- package/src/cli/convoy/engine.test.ts +1 -1
- package/src/cli/convoy/engine.ts +2 -2
- package/src/cli/convoy/issues.test.ts +3 -2
- package/src/cli/convoy/issues.ts +3 -3
- package/src/cli/pipeline.ts +343 -0
- package/src/cli/plan.ts +357 -153
- package/src/cli/run.ts +37 -1
- package/src/cli/validate.ts +65 -0
- package/src/dashboard/dist/data/convoy-list.json +65 -1
- package/src/dashboard/dist/data/convoys/demo-api-v2.json +177 -0
- package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +239 -0
- package/src/dashboard/dist/data/convoys/demo-convoy-1.json +111 -0
- package/src/dashboard/dist/data/convoys/demo-convoy-2.json +72 -0
- package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +328 -0
- package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +187 -0
- package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +153 -0
- package/src/dashboard/dist/data/convoys/demo-docs-update.json +154 -0
- package/src/dashboard/dist/data/convoys/demo-perf-opt.json +227 -0
- package/src/dashboard/dist/data/events.ndjson +115 -0
- package/src/dashboard/dist/data/overall-stats.json +67 -12
- package/src/dashboard/dist/data/pipelines.ndjson +5285 -0
- package/src/dashboard/dist/index.html +39 -16
- package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
- package/src/dashboard/public/data/convoy-list.json +65 -1
- package/src/dashboard/public/data/convoys/demo-api-v2.json +177 -0
- package/src/dashboard/public/data/convoys/demo-auth-revamp.json +239 -0
- package/src/dashboard/public/data/convoys/demo-convoy-1.json +111 -0
- package/src/dashboard/public/data/convoys/demo-convoy-2.json +72 -0
- package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +328 -0
- package/src/dashboard/public/data/convoys/demo-data-pipeline.json +187 -0
- package/src/dashboard/public/data/convoys/demo-deploy-ci.json +153 -0
- package/src/dashboard/public/data/convoys/demo-docs-update.json +154 -0
- package/src/dashboard/public/data/convoys/demo-perf-opt.json +227 -0
- package/src/dashboard/public/data/events.ndjson +115 -0
- package/src/dashboard/public/data/overall-stats.json +67 -12
- package/src/dashboard/public/data/pipelines.ndjson +5285 -0
- package/src/dashboard/scripts/etl.ts +38 -4
- package/src/dashboard/scripts/generate-demo-db.test.ts +30 -0
- package/src/dashboard/scripts/generate-demo-db.ts +507 -0
- package/src/dashboard/scripts/verify-demo-data.sh +51 -0
- package/src/dashboard/src/pages/index.astro +46 -23
- package/src/orchestrator/prompts/fix-convoy.prompt.md +79 -0
- package/src/orchestrator/prompts/generate-convoy.prompt.md +53 -58
- package/src/orchestrator/prompts/generate-prd.prompt.md +120 -0
- package/src/orchestrator/prompts/validate-convoy.prompt.md +89 -0
- 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
|
|
14
|
-
|
|
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
|
|
18
|
-
--
|
|
19
|
-
--
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
151
|
-
* Checks for a
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
const
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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(
|
|
217
|
-
.replace(
|
|
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(
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
+
throw new Error(`Adapter "${adapterName}" failed to load`)
|
|
245
287
|
}
|
|
246
288
|
|
|
247
|
-
|
|
248
|
-
if (!available) {
|
|
289
|
+
if (!(await adapter.isAvailable())) {
|
|
249
290
|
printAdapterError(false, adapterName)
|
|
250
|
-
|
|
291
|
+
throw new Error(`Adapter "${adapterName}" is not available`)
|
|
251
292
|
}
|
|
252
293
|
|
|
253
|
-
|
|
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:
|
|
296
|
+
id: templateName,
|
|
259
297
|
prompt: assembledPrompt,
|
|
260
|
-
agent:
|
|
298
|
+
agent: agentField,
|
|
261
299
|
timeout: '10m',
|
|
262
300
|
depends_on: [],
|
|
263
301
|
files: [],
|
|
264
|
-
description: '
|
|
302
|
+
description: frontmatter['description'] ?? templateName,
|
|
265
303
|
max_retries: 1,
|
|
266
304
|
}
|
|
267
305
|
|
|
268
|
-
const
|
|
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
|
-
//
|
|
271
|
-
const yamlContent = extractYamlBlock(
|
|
327
|
+
// convoy-spec (default)
|
|
328
|
+
const yamlContent = extractYamlBlock(rawOutput)
|
|
272
329
|
if (!yamlContent) {
|
|
273
|
-
const preview =
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
280
|
-
let validationWarning = false
|
|
336
|
+
let schemaValid = true
|
|
281
337
|
try {
|
|
282
338
|
parseTaskSpecText(yamlContent)
|
|
283
339
|
} catch (err) {
|
|
284
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
446
|
+
if (opts.file && opts.text) {
|
|
447
|
+
console.error(` ✗ --file and --text are mutually exclusive.`)
|
|
448
|
+
process.exit(1)
|
|
311
449
|
}
|
|
312
|
-
|
|
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
|
}
|