opencastle 0.23.1 → 0.24.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 (51) hide show
  1. package/dist/cli/convoy/engine.d.ts +1 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +72 -22
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/engine.test.js +205 -0
  6. package/dist/cli/convoy/engine.test.js.map +1 -1
  7. package/dist/cli/dashboard.d.ts.map +1 -1
  8. package/dist/cli/dashboard.js +5 -4
  9. package/dist/cli/dashboard.js.map +1 -1
  10. package/dist/cli/run/adapters/claude.d.ts +6 -0
  11. package/dist/cli/run/adapters/claude.d.ts.map +1 -0
  12. package/dist/cli/run/adapters/claude.js +211 -0
  13. package/dist/cli/run/adapters/claude.js.map +1 -0
  14. package/dist/cli/run/adapters/copilot.d.ts +0 -18
  15. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  16. package/dist/cli/run/adapters/copilot.js +123 -38
  17. package/dist/cli/run/adapters/copilot.js.map +1 -1
  18. package/dist/cli/run/adapters/index.js +2 -2
  19. package/dist/cli/run/adapters/index.js.map +1 -1
  20. package/dist/cli/run/schema.d.ts.map +1 -1
  21. package/dist/cli/run/schema.js +8 -0
  22. package/dist/cli/run/schema.js.map +1 -1
  23. package/dist/cli/run/schema.test.js +41 -0
  24. package/dist/cli/run/schema.test.js.map +1 -1
  25. package/dist/cli/run.d.ts.map +1 -1
  26. package/dist/cli/run.js +21 -9
  27. package/dist/cli/run.js.map +1 -1
  28. package/dist/cli/types.d.ts +2 -0
  29. package/dist/cli/types.d.ts.map +1 -1
  30. package/package.json +9 -1
  31. package/src/cli/convoy/engine.test.ts +240 -0
  32. package/src/cli/convoy/engine.ts +80 -23
  33. package/src/cli/dashboard.ts +6 -5
  34. package/src/cli/run/adapters/claude.ts +238 -0
  35. package/src/cli/run/adapters/copilot.ts +125 -47
  36. package/src/cli/run/adapters/index.ts +2 -2
  37. package/src/cli/run/adapters/vendor.d.ts +2 -0
  38. package/src/cli/run/schema.test.ts +51 -0
  39. package/src/cli/run/schema.ts +10 -0
  40. package/src/cli/run.ts +23 -11
  41. package/src/cli/types.ts +2 -0
  42. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  43. package/src/orchestrator/agents/team-lead.agent.md +6 -6
  44. package/src/orchestrator/prompts/bug-fix.prompt.md +6 -2
  45. package/src/orchestrator/prompts/generate-convoy.prompt.md +3 -3
  46. package/src/orchestrator/prompts/implement-feature.prompt.md +8 -19
  47. package/dist/cli/run/adapters/claude-code.d.ts +0 -16
  48. package/dist/cli/run/adapters/claude-code.d.ts.map +0 -1
  49. package/dist/cli/run/adapters/claude-code.js +0 -95
  50. package/dist/cli/run/adapters/claude-code.js.map +0 -1
  51. package/src/cli/run/adapters/claude-code.ts +0 -107
@@ -1,29 +1,27 @@
1
+
1
2
  import { spawn } from 'node:child_process'
2
3
  import type { CopilotClient as CopilotClientType, CopilotSession, PermissionHandler } from '@github/copilot-sdk'
3
4
  import { parseTimeout } from '../schema.js'
4
- import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js'
5
+ import type { Task, ExecuteOptions, ExecuteResult, TokenUsage } from '../../types.js'
5
6
 
6
- /** Adapter name */
7
+ // Adapter name
7
8
  export const name = 'copilot'
8
9
 
9
- /**
10
- * Lazy-initialized shared client instance.
11
- * The client manages a single Copilot CLI server process; all task sessions
12
- * multiplex over it via JSON-RPC.
13
- */
14
- let clientPromise: Promise<CopilotClientType> | null = null
15
-
16
- /** Cached permission handler from the SDK module. */
17
- let cachedApproveAll: PermissionHandler | null = null
10
+ // --- Unified adapter: SDK first, fallback to CLI ---
11
+ let mode: 'sdk' | 'cli' | null = null
18
12
 
19
- /** Active sessions keyed by task id — used by `kill()` for timeout enforcement. */
20
- const activeSessions = new Map<string, CopilotSession>()
13
+ // SDK check
14
+ async function sdkAvailable(): Promise<boolean> {
15
+ try {
16
+ await import('@github/copilot-sdk')
17
+ return true
18
+ } catch {
19
+ return false
20
+ }
21
+ }
21
22
 
22
- /**
23
- * Check if the `copilot` CLI is available on the system PATH.
24
- * The SDK communicates with the CLI in server mode, so it must be installed.
25
- */
26
- export async function isAvailable(): Promise<boolean> {
23
+ // CLI check
24
+ async function cliAvailable(): Promise<boolean> {
27
25
  return new Promise((resolve) => {
28
26
  const proc = spawn('which', ['copilot'], { stdio: 'pipe' })
29
27
  proc.on('close', (code) => resolve(code === 0))
@@ -31,10 +29,23 @@ export async function isAvailable(): Promise<boolean> {
31
29
  })
32
30
  }
33
31
 
34
- /**
35
- * Get or create the shared CopilotClient.
36
- * The client is started once and reused across all task executions.
37
- */
32
+ export async function isAvailable(): Promise<boolean> {
33
+ if (await sdkAvailable()) {
34
+ mode = 'sdk'
35
+ return true
36
+ }
37
+ if (await cliAvailable()) {
38
+ mode = 'cli'
39
+ return true
40
+ }
41
+ return false
42
+ }
43
+
44
+ // --- SDK implementation (existing logic) ---
45
+ let clientPromise: Promise<CopilotClientType> | null = null
46
+ let cachedApproveAll: PermissionHandler | null = null
47
+ const activeSessions = new Map<string, CopilotSession>()
48
+
38
49
  async function getClient(): Promise<CopilotClientType> {
39
50
  if (!clientPromise) {
40
51
  clientPromise = (async () => {
@@ -51,29 +62,16 @@ async function getClient(): Promise<CopilotClientType> {
51
62
  return clientPromise
52
63
  }
53
64
 
54
- /**
55
- * Execute a task using the Copilot SDK.
56
- *
57
- * Each task gets its own session with:
58
- * - All tool permissions auto-approved (equivalent to `--allow-all-tools`)
59
- * - No `ask_user` tool (autonomous — equivalent to `--no-ask-user`)
60
- * - System message injected with the agent role
61
- * - Streaming enabled in verbose mode for live output
62
- */
63
- export async function execute(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
65
+ async function executeViaSdk(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
64
66
  // NOTE: The Copilot SDK CopilotClient is a shared singleton. Per-task cwd
65
67
  // isolation requires SDK support for per-session workingDirectory, which is
66
68
  // not yet available. When running in convoy mode with worktrees, prefer
67
- // subprocess-based adapters (claude-code, cursor) that support options.cwd
68
- // natively. Copilot SDK per-session cwd support is tracked for Phase 3.
69
+ // subprocess-based adapters (cli mode) that support options.cwd natively.
69
70
  let prompt = `You are a ${task.agent}. ${task.prompt}`
70
-
71
71
  if (task.files && task.files.length > 0) {
72
72
  prompt += `\n\nOnly modify files under: ${task.files.join(', ')}`
73
73
  }
74
-
75
74
  const client = await getClient()
76
-
77
75
  const session = await client.createSession({
78
76
  onPermissionRequest: cachedApproveAll!,
79
77
  systemMessage: {
@@ -86,16 +84,12 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
86
84
  infiniteSessions: { enabled: false },
87
85
  ...(options.verbose ? { streaming: true } : {}),
88
86
  })
89
-
90
87
  activeSessions.set(task.id, session)
91
-
92
- // Stream deltas to stdout in verbose mode
93
88
  if (options.verbose) {
94
89
  session.on('assistant.message_delta', (event: { data: { deltaContent: string } }) => {
95
90
  process.stdout.write(event.data.deltaContent)
96
91
  })
97
92
  }
98
-
99
93
  try {
100
94
  const timeoutMs = parseTimeout(task.timeout)
101
95
  const response = await session.sendAndWait({ prompt }, timeoutMs)
@@ -107,7 +101,6 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
107
101
  completion_tokens: u.completion_tokens ?? u.completionTokens,
108
102
  total_tokens: u.total_tokens ?? u.totalTokens,
109
103
  } : undefined
110
-
111
104
  return {
112
105
  success: true,
113
106
  output: output.slice(0, 10_000),
@@ -126,11 +119,7 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
126
119
  }
127
120
  }
128
121
 
129
- /**
130
- * Abort and destroy the session associated with a task.
131
- * Called by the executor when a task exceeds its timeout.
132
- */
133
- export function kill(task: Task): void {
122
+ function killSdk(task: Task): void {
134
123
  const session = activeSessions.get(task.id)
135
124
  if (session) {
136
125
  session.abort().catch(() => {})
@@ -138,3 +127,92 @@ export function kill(task: Task): void {
138
127
  activeSessions.delete(task.id)
139
128
  }
140
129
  }
130
+
131
+ // --- CLI implementation ---
132
+ async function executeViaCli(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
133
+ // CLI supports --output-format json, --max-turns, and respects cwd
134
+ let prompt = `You are a ${task.agent}. ${task.prompt}`
135
+ if (task.files && task.files.length > 0) {
136
+ prompt += `\n\nOnly modify files under: ${task.files.join(', ')}`
137
+ }
138
+ const args = [
139
+ '-p',
140
+ prompt,
141
+ '--output-format',
142
+ 'json',
143
+ '--max-turns',
144
+ '50',
145
+ ]
146
+ return new Promise((resolve) => {
147
+ const proc = spawn('copilot', args, {
148
+ stdio: ['ignore', 'pipe', 'pipe'],
149
+ env: { ...process.env },
150
+ cwd: options?.cwd ?? process.cwd(),
151
+ })
152
+ let stdout = ''
153
+ let stderr = ''
154
+ proc.stdout.on('data', (chunk: Buffer) => {
155
+ stdout += chunk.toString()
156
+ if (options.verbose) {
157
+ process.stdout.write(chunk)
158
+ }
159
+ })
160
+ proc.stderr.on('data', (chunk: Buffer) => {
161
+ stderr += chunk.toString()
162
+ if (options.verbose) {
163
+ process.stderr.write(chunk)
164
+ }
165
+ })
166
+ proc.on('close', (code) => {
167
+ const output = [stdout, stderr].filter(Boolean).join('\n')
168
+ let usage: TokenUsage | undefined
169
+ try {
170
+ const parsedJson = JSON.parse(stdout) as Record<string, unknown>
171
+ const u = parsedJson?.usage as Record<string, number> | undefined
172
+ if (u) {
173
+ const promptTokens = (u.input_tokens ?? u.prompt_tokens) as number | undefined
174
+ const completionTokens = (u.output_tokens ?? u.completion_tokens) as number | undefined
175
+ const total = ((promptTokens ?? 0) + (completionTokens ?? 0)) || undefined
176
+ usage = { prompt_tokens: promptTokens, completion_tokens: completionTokens, total_tokens: total }
177
+ }
178
+ } catch { /* not JSON or no usage — graceful degradation */ }
179
+ resolve({
180
+ success: code === 0,
181
+ output: output.slice(0, 10000),
182
+ exitCode: code ?? -1,
183
+ usage,
184
+ })
185
+ })
186
+ proc.on('error', (err) => {
187
+ resolve({
188
+ success: false,
189
+ output: `Failed to spawn copilot: ${err.message}`,
190
+ exitCode: -1,
191
+ })
192
+ })
193
+ task._process = proc
194
+ })
195
+ }
196
+
197
+ function killCli(task: Task): void {
198
+ if (task._process && !task._process.killed) {
199
+ task._process.kill('SIGTERM')
200
+ setTimeout(() => {
201
+ if (task._process && !task._process.killed) {
202
+ task._process.kill('SIGKILL')
203
+ }
204
+ }, 5000)
205
+ }
206
+ }
207
+
208
+ // --- Unified interface ---
209
+ export async function execute(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
210
+ if (!mode) await isAvailable()
211
+ if (mode === 'sdk') return executeViaSdk(task, options)
212
+ return executeViaCli(task, options)
213
+ }
214
+
215
+ export function kill(task: Task): void {
216
+ if (mode === 'sdk') killSdk(task)
217
+ else killCli(task)
218
+ }
@@ -4,7 +4,7 @@ import type { AgentAdapter } from '../../types.js'
4
4
  * Adapter registry for agent runtimes.
5
5
  */
6
6
  const ADAPTERS: Record<string, () => Promise<AgentAdapter>> = {
7
- 'claude-code': () => import('./claude-code.js') as Promise<AgentAdapter>,
7
+ claude: () => import('./claude.js') as Promise<AgentAdapter>,
8
8
  copilot: () => import('./copilot.js') as Promise<AgentAdapter>,
9
9
  cursor: () => import('./cursor.js') as Promise<AgentAdapter>,
10
10
  opencode: () => import('./opencode.js') as Promise<AgentAdapter>,
@@ -29,7 +29,7 @@ export async function getAdapter(name: string): Promise<AgentAdapter> {
29
29
  * Detection priority order — checked first-to-last.
30
30
  * The first available adapter wins.
31
31
  */
32
- const DETECTION_ORDER = ['copilot', 'claude-code', 'cursor', 'opencode'] as const
32
+ const DETECTION_ORDER = ['copilot', 'claude', 'cursor', 'opencode'] as const
33
33
 
34
34
  /**
35
35
  * Auto-detect which adapter CLI is available on the system.
@@ -0,0 +1,2 @@
1
+ /** Ambient declaration for optional peer dependency — resolved at runtime when installed. */
2
+ declare module '@anthropic-ai/agent-sdk'
@@ -1160,3 +1160,54 @@ describe('applyDefaults — pipeline spec (version:2, no tasks)', () => {
1160
1160
  expect(spec.depends_on_convoy).toEqual(['phase-1', 'phase-2'])
1161
1161
  })
1162
1162
  })
1163
+
1164
+ // ── validateSpec — gate_retries field ─────────────────────────
1165
+
1166
+ describe('validateSpec — gate_retries field', () => {
1167
+ const validSpec = {
1168
+ name: 'test',
1169
+ tasks: [{ id: 'a', prompt: 'do something' }],
1170
+ }
1171
+
1172
+ it('accepts gate_retries as 0', () => {
1173
+ const result = validateSpec({ ...validSpec, gate_retries: 0 })
1174
+ expect(result.valid).toBe(true)
1175
+ })
1176
+
1177
+ it('accepts gate_retries as a positive integer', () => {
1178
+ const result = validateSpec({ ...validSpec, gate_retries: 3 })
1179
+ expect(result.valid).toBe(true)
1180
+ })
1181
+
1182
+ it('rejects gate_retries as negative', () => {
1183
+ const result = validateSpec({ ...validSpec, gate_retries: -1 })
1184
+ expect(result.valid).toBe(false)
1185
+ expect(result.errors).toContainEqual(expect.stringContaining('gate_retries'))
1186
+ })
1187
+
1188
+ it('rejects gate_retries as a float', () => {
1189
+ const result = validateSpec({ ...validSpec, gate_retries: 1.5 })
1190
+ expect(result.valid).toBe(false)
1191
+ expect(result.errors).toContainEqual(expect.stringContaining('gate_retries'))
1192
+ })
1193
+
1194
+ it('rejects gate_retries as a string', () => {
1195
+ const result = validateSpec({ ...validSpec, gate_retries: 'two' })
1196
+ expect(result.valid).toBe(false)
1197
+ expect(result.errors).toContainEqual(expect.stringContaining('gate_retries'))
1198
+ })
1199
+ })
1200
+
1201
+ // ── applyDefaults — gate_retries default ───────────────────────
1202
+
1203
+ describe('applyDefaults — gate_retries default', () => {
1204
+ it('defaults gate_retries to 0', () => {
1205
+ const spec = applyDefaults({ name: 'test', tasks: [{ id: 'a', prompt: 'p' }] })
1206
+ expect(spec.gate_retries).toBe(0)
1207
+ })
1208
+
1209
+ it('preserves explicit gate_retries value', () => {
1210
+ const spec = applyDefaults({ name: 'test', tasks: [{ id: 'a', prompt: 'p' }], gate_retries: 2 })
1211
+ expect(spec.gate_retries).toBe(2)
1212
+ })
1213
+ })
@@ -42,6 +42,7 @@ interface RawSpec {
42
42
  version?: unknown
43
43
  defaults?: unknown
44
44
  gates?: unknown
45
+ gate_retries?: unknown
45
46
  branch?: unknown
46
47
  depends_on_convoy?: unknown
47
48
  }
@@ -154,6 +155,14 @@ export function validateSpec(spec: unknown): ValidationResult {
154
155
  }
155
156
  }
156
157
 
158
+ // gate_retries
159
+ if (s.gate_retries !== undefined) {
160
+ const gr = Number(s.gate_retries)
161
+ if (!Number.isInteger(gr) || gr < 0) {
162
+ errors.push('`gate_retries` must be a non-negative integer')
163
+ }
164
+ }
165
+
157
166
  // branch
158
167
  if (s.branch !== undefined && typeof s.branch !== 'string') {
159
168
  errors.push('`branch` must be a string')
@@ -319,6 +328,7 @@ export function applyDefaults(spec: Record<string, unknown>): TaskSpec {
319
328
  s.on_failure = (s.on_failure as string) || 'continue'
320
329
  // Leave adapter empty so run.ts can auto-detect the best available CLI
321
330
  s.adapter = (s.adapter as string) || ''
331
+ s.gate_retries = s.gate_retries !== undefined ? Number(s.gate_retries) : 0
322
332
 
323
333
  const tasks = (s.tasks as Array<Record<string, unknown>> | undefined) ?? []
324
334
  const d =
package/src/cli/run.ts CHANGED
@@ -5,6 +5,7 @@ import { parseTaskSpecText, isConvoySpec, isPipelineSpec } from './run/schema.js
5
5
  import { createExecutor, buildPhases } from './run/executor.js'
6
6
  import { getAdapter, detectAdapter } from './run/adapters/index.js'
7
7
  import { createReporter, printExecutionPlan } from './run/reporter.js'
8
+ import { c } from './prompt.js'
8
9
  import type { CliContext, RunOptions } from './types.js'
9
10
  import type { ConvoyResult } from './convoy/engine.js'
10
11
  import type { PipelineResult } from './convoy/pipeline.js'
@@ -22,7 +23,7 @@ const HELP = `
22
23
  Version 1 specs use the Convoy Engine; legacy specs use the standard executor.
23
24
 
24
25
  Options:
25
- --file, -f <path> Task spec file (default: opencastle.tasks.yml)
26
+ --file, -f <path> Task spec file
26
27
  --dry-run Show execution plan without running
27
28
  --concurrency, -c <n> Override max parallel tasks
28
29
  --adapter, -a <name> Override agent runtime adapter
@@ -38,7 +39,7 @@ const HELP = `
38
39
  */
39
40
  function parseArgs(args: string[]): RunOptions {
40
41
  const opts: RunOptions = {
41
- file: 'opencastle.tasks.yml',
42
+ file: 'convoy.yml',
42
43
  dryRun: false,
43
44
  concurrency: null,
44
45
  adapter: null,
@@ -121,7 +122,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
121
122
  )
122
123
  } else {
123
124
  const hints: Record<string, string> = {
124
- 'claude-code':
125
+ 'claude':
125
126
  ' Install: npm install -g @anthropic-ai/claude-code\n' +
126
127
  ' Docs: https://docs.anthropic.com/en/docs/claude-code',
127
128
  copilot:
@@ -136,7 +137,7 @@ function printAdapterError(detectionFailed: boolean, adapterName: string): void
136
137
  ' Install OpenCode from https://opencode.ai\n' +
137
138
  ' Ensure the "opencode" command is on your PATH.',
138
139
  }
139
- const cliName = adapterName === 'claude-code' ? 'claude' : adapterName === 'cursor' ? 'agent' : adapterName
140
+ const cliName = adapterName === 'cursor' ? 'agent' : adapterName
140
141
  const hint = hints[adapterName] ?? ''
141
142
  console.error(
142
143
  ` ✗ Adapter "${adapterName}" is not available.\n` +
@@ -153,10 +154,15 @@ function printConvoyResult(result: ConvoyResult): void {
153
154
  console.log(`\n ──────────────────────────────────────`)
154
155
  console.log(` Convoy ${result.status}: ${result.duration}`)
155
156
  console.log(
156
- ` Done: ${result.summary.done} | Failed: ${result.summary.failed} | Skipped: ${result.summary.skipped} | Timed out: ${result.summary.timedOut}`
157
+ ` Tasks: ${result.summary.done}/${result.summary.total} done` +
158
+ (result.summary.failed > 0 ? ` | ${result.summary.failed} failed` : '') +
159
+ (result.summary.skipped > 0 ? ` | ${result.summary.skipped} skipped` : '') +
160
+ (result.summary.timedOut > 0 ? ` | ${result.summary.timedOut} timed out` : '')
157
161
  )
158
162
  if (result.gateResults) {
159
- console.log(` Gates:`)
163
+ const gatesPassed = result.gateResults.filter(g => g.passed).length
164
+ const gatesFailed = result.gateResults.filter(g => !g.passed).length
165
+ console.log(` Gates: ${gatesPassed}/${result.gateResults.length} passed${gatesFailed > 0 ? ` | ${gatesFailed} failed` : ''}`)
160
166
  for (const g of result.gateResults) {
161
167
  console.log(` ${g.passed ? '✓' : '✗'} ${g.command}`)
162
168
  }
@@ -307,7 +313,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
307
313
  console.log(` ℹ Auto-detected adapter: ${detected}`)
308
314
  } else {
309
315
  resumePipelineDetectionFailed = true
310
- resumePipelineSpec.adapter = 'claude-code'
316
+ resumePipelineSpec.adapter = 'claude'
311
317
  }
312
318
  }
313
319
 
@@ -359,7 +365,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
359
365
  console.log(` ℹ Auto-detected adapter: ${detected}`)
360
366
  } else {
361
367
  resumeDetectionFailed = true
362
- resumeSpec.adapter = 'claude-code'
368
+ resumeSpec.adapter = 'claude'
363
369
  }
364
370
  }
365
371
 
@@ -421,7 +427,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
421
427
  console.log(` ℹ Auto-detected adapter: ${detected}`)
422
428
  } else {
423
429
  detectionFailed = true
424
- spec.adapter = 'claude-code' // fallback for availability check below
430
+ spec.adapter = 'claude' // fallback for availability check below
425
431
  }
426
432
  }
427
433
 
@@ -466,7 +472,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
466
472
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
467
473
 
468
474
  const { startDashboardServer } = await import('./dashboard.js')
469
- let pipelineDashboardResult: { server: import('node:http').Server } | null = null
475
+ let pipelineDashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
470
476
  try {
471
477
  pipelineDashboardResult = await startDashboardServer({
472
478
  pkgRoot,
@@ -476,6 +482,9 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
476
482
  } catch {
477
483
  // Dashboard failure must not block pipeline
478
484
  }
485
+ if (pipelineDashboardResult) {
486
+ console.log(` ${c.dim('Dashboard:')} ${pipelineDashboardResult.url}`)
487
+ }
479
488
 
480
489
  const pipelineOrchestrator = createPipelineOrchestrator({
481
490
  spec,
@@ -503,7 +512,7 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
503
512
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
504
513
 
505
514
  const { startDashboardServer } = await import('./dashboard.js')
506
- let dashboardResult: { server: import('node:http').Server } | null = null
515
+ let dashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
507
516
  try {
508
517
  dashboardResult = await startDashboardServer({
509
518
  pkgRoot,
@@ -513,6 +522,9 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
513
522
  } catch {
514
523
  // Dashboard failure must not block convoy
515
524
  }
525
+ if (dashboardResult) {
526
+ console.log(` ${c.dim('Dashboard:')} ${dashboardResult.url}`)
527
+ }
516
528
 
517
529
  const engine = createConvoyEngine({
518
530
  spec,
package/src/cli/types.ts CHANGED
@@ -164,6 +164,8 @@ export interface TaskSpec {
164
164
  defaults?: TaskDefaults;
165
165
  /** Shell commands run after all tasks complete; each must exit 0. */
166
166
  gates?: string[];
167
+ /** How many times to retry failing gates with an auto-fix task (default: 0). */
168
+ gate_retries?: number;
167
169
  /** Git feature branch name. */
168
170
  branch?: string;
169
171
  /** Other convoy spec names to run before this one (version: 2 pipeline specs). */
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "47102a21",
2
+ "hash": "8d888497",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "ecc512ab",
5
- "browserHash": "a22d8cb1",
4
+ "lockfileHash": "433479a7",
5
+ "browserHash": "261fa44b",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "11a66757",
10
+ "fileHash": "de4544e1",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "468db3c9",
16
+ "fileHash": "e15a50a2",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "8c573df7",
22
+ "fileHash": "e06f0936",
23
23
  "needsInterop": true
24
24
  }
25
25
  },
@@ -145,14 +145,14 @@ Before EVERY delegation verify: (1) Tracker issue exists, (2) File partition is
145
145
 
146
146
  ## Convoy Integration
147
147
 
148
- The convoy engine is the preferred execution mechanism for multi-task work. Use it when a request decomposes into 3 or more subtasks.
148
+ The convoy engine is the **mandatory** execution mechanism for all project-related work features, bug fixes, and refactors. This ensures consistent observability, crash recovery, and progress visibility.
149
149
 
150
150
  ### When to use convoy vs. direct delegation
151
151
 
152
- | Task count | Approach |
153
- |------------|----------|
154
- | 1–2 subtasks | **Direct delegation** — sub-agents inline, standard workflow |
155
- | 3+ subtasks | **Convoy execution** — generate spec, hand to user to run |
152
+ | Work type | Approach |
153
+ |-----------|----------|
154
+ | Features, bug fixes, refactors (any subtask count) | **Convoy execution** — always generate a `.convoy.yml` spec, even for 1-task fixes |
155
+ | Utility prompts (`bootstrap-customizations`, `create-skill`, `generate-convoy`, `brainstorm`, `quick-refinement`) | **Direct** — these are meta/tooling operations, not project code changes |
156
156
 
157
157
  ### How to generate a convoy spec
158
158
 
@@ -164,7 +164,7 @@ The convoy engine is the preferred execution mechanism for multi-task work. Use
164
164
 
165
165
  Tell the user to run:
166
166
  ```
167
- npx opencastle run -f <name>.convoy.yml
167
+ npx opencastle run -f .opencastle/convoys/<name>.convoy.yml
168
168
  ```
169
169
  This gives the user control over when execution starts (preferred — supports overnight/unattended runs and manual review of the spec before execution).
170
170
 
@@ -83,9 +83,13 @@ Find WHY the bug happens, not just WHERE:
83
83
 
84
84
  ### 4. Implement the Fix
85
85
 
86
- Delegate to the appropriate specialist agent via **sub-agent** (inline). For bugs that are clearly isolated and well-understood, a single delegation is usually sufficient.
86
+ All bug fixes are executed via the convoy engine even single-task fixes to ensure observability and crash recovery.
87
87
 
88
- #### Delegation Prompt Must Include
88
+ 1. **Generate a convoy spec** — use the `generate-convoy` prompt with the root cause analysis, fix approach, and file paths as context.
89
+ 2. **Hand the spec to the user** — tell them to run: `npx opencastle run -f .opencastle/convoys/<name>.convoy.yml`
90
+ 3. **After convoy completes** — proceed to Step 5 (validation).
91
+
92
+ #### Convoy Task Prompt Must Include
89
93
 
90
94
  - **Tracker issue ID and title** — e.g., `TAS-XX — [Bug] Description`
91
95
  - **Root cause** — What's wrong and why
@@ -7,7 +7,7 @@ agent: 'Team Lead (OpenCastle)'
7
7
 
8
8
  # Generate Convoy Spec
9
9
 
10
- You are the Team Lead. The user wants to run `opencastle run` to execute a batch of tasks autonomously via the convoy engine. Your job is to produce a valid `.convoy.yml` file they can feed to the CLI. Derive a short, descriptive, kebab-case filename from the user's goal (2–4 words max) and use it as the filename — for example `auth-refactor.convoy.yml` or `add-search.convoy.yml`. Always use the `.convoy.yml` extension.
10
+ You are the Team Lead. The user wants to run `opencastle run` to execute a batch of tasks autonomously via the convoy engine. Your job is to produce a valid `.convoy.yml` file they can feed to the CLI. Derive a short, descriptive, kebab-case filename from the user's goal (2–4 words max) and use it as the filename — for example `auth-refactor.convoy.yml` or `add-search.convoy.yml`. Always use the `.convoy.yml` extension. Store all generated convoy specs in the `.opencastle/convoys/` directory (create it if it doesn't exist).
11
11
 
12
12
  ## User Goal
13
13
 
@@ -137,7 +137,7 @@ Before presenting the YAML, mentally verify:
137
137
  Return the final YAML inside a fenced code block with a filename annotation:
138
138
 
139
139
  ````yaml
140
- # <feature-name>.convoy.yml
140
+ # .opencastle/convoys/<feature-name>.convoy.yml
141
141
  name: <run name>
142
142
  version: 1
143
143
  concurrency: <n>
@@ -172,6 +172,6 @@ gates:
172
172
  Also provide:
173
173
  1. A **DAG summary** showing the phase structure so the user can verify execution order.
174
174
  2. An **estimated total duration** (sum of timeouts on the critical path).
175
- 3. A `--dry-run` command they can use to validate: `npx opencastle run --file <feature-name>.convoy.yml --dry-run`
175
+ 3. A `--dry-run` command they can use to validate: `npx opencastle run --file .opencastle/convoys/<feature-name>.convoy.yml --dry-run`
176
176
 
177
177
 
@@ -47,31 +47,20 @@ Every subtask must be tracked. **No issue = no implementation.** This step produ
47
47
  5. **Link to roadmap** — Reference the roadmap section in the issue description so context is never lost
48
48
  6. **Verify issues exist** — List all created issue IDs. If count is 0, do NOT proceed to Step 2.5
49
49
 
50
- ### 2.5 Choose Execution Path (BLOCKING — decides how Step 3 proceeds)
50
+ ### 2.5 Generate Convoy Spec (BLOCKING — decides how Step 3 proceeds)
51
51
 
52
- With the full task list in hand, decide the execution mechanism:
52
+ All project-related work is executed via the convoy engine regardless of subtask count. This ensures consistent observability, crash recovery, and live progress.
53
53
 
54
- | Condition | Execution path |
55
- |-----------|----------------|
56
- | 1–2 subtasks | **Direct delegation** delegate to sub-agents as today (proceed to Step 3 as-is) |
57
- | 3+ subtasks | **Convoy execution** — generate a `.convoy.yml` spec using the `generate-convoy` prompt, then hand it to the user |
58
-
59
- #### Direct delegation (1–2 subtasks)
60
-
61
- Proceed with the normal Step 3 delegation workflow. Sub-agents handle each task inline.
62
-
63
- #### Convoy execution (3+ subtasks)
64
-
65
- 1. **Generate the spec** — use the `generate-convoy` prompt with the decomposed task list as context. The spec IS the implementation plan — no manual per-task delegation is needed.
66
- 2. **Hand the spec to the user** — tell them to run: `npx opencastle run -f <name>.convoy.yml`
67
- 3. **The convoy engine handles** isolated git worktrees, parallel execution, merge queue ordering, and crash recovery automatically.
54
+ 1. **Generate the spec** use the `generate-convoy` prompt with the decomposed task list as context. The spec IS the implementation plan — no manual per-task delegation is needed. Even single-task fixes go through convoy for observability.
55
+ 2. **Hand the spec to the user** — tell them to run: `npx opencastle run -f .opencastle/convoys/<name>.convoy.yml`
56
+ 3. **The convoy engine handles** isolated git worktrees, parallel execution, merge queue ordering, crash recovery, and structured logging automatically.
68
57
  4. **After convoy completes** — proceed to Step 4 (validation) and Step 5 (delivery/PR). The convoy engine will have created its own commits on the configured branch.
69
58
 
70
- > **Why convoy for 3+ tasks?** Parallel worktree isolation prevents file conflicts. The merge queue ensures safe ordering. Crash recovery means a failing task doesn't block others. Manual delegation of 3+ parallel tasks risks conflicts and is harder to monitor.
59
+ > **Why always convoy?** Convoy execution is the only path that guarantees observability logging, crash recovery, gate validation, and live progress. Direct sub-agent delegation produces no structured logs and cannot be resumed if interrupted.
71
60
 
72
61
  ### 3. Implementation Rules
73
62
 
74
- > **For convoy execution (3+ subtasks):** The convoy spec file IS the implementation plan — skip the manual delegation workflow below and jump to Step 4 after the user runs the convoy. The convoy engine delegates tasks internally using the agents and prompts defined in the spec.
63
+ > **Convoy execution:** The convoy spec file IS the implementation plan — skip the manual delegation workflow below and jump to Step 4 after the user runs the convoy. The convoy engine delegates tasks internally using the agents and prompts defined in the spec.
75
64
 
76
65
  #### Issue Traceability
77
66
 
@@ -117,7 +106,7 @@ Every subtask must pass ALL gates before being marked Done:
117
106
 
118
107
  Follow the **Delivery Outcome** defined in the **git-workflow** skill — commit, push, open PR (not merged), and link to the tracker.
119
108
 
120
- > **For convoy execution:** The convoy engine creates commits on the configured `branch` directly. After validation passes, open the PR from that branch. No additional commits from the Team Lead are needed unless gates failed and required manual fixes.
109
+ > The convoy engine creates commits on the configured `branch` directly. After validation passes, open the PR from that branch. No additional commits from the Team Lead are needed unless gates failed and required manual fixes.
121
110
 
122
111
  ### 6. Documentation & Traceability
123
112
 
@@ -1,16 +0,0 @@
1
- import type { Task, ExecuteOptions, ExecuteResult } from '../../types.js';
2
- /** Adapter name */
3
- export declare const name = "claude-code";
4
- /**
5
- * Check if the `claude` CLI is available on the system PATH.
6
- */
7
- export declare function isAvailable(): Promise<boolean>;
8
- /**
9
- * Execute a task by invoking the Claude Code CLI in print mode.
10
- */
11
- export declare function execute(task: Task, options?: ExecuteOptions): Promise<ExecuteResult>;
12
- /**
13
- * Kill the process associated with a task (used by timeout enforcement).
14
- */
15
- export declare function kill(task: Task): void;
16
- //# sourceMappingURL=claude-code.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"claude-code.d.ts","sourceRoot":"","sources":["../../../../src/cli/run/adapters/claude-code.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,IAAI,EAAE,cAAc,EAAE,aAAa,EAAc,MAAM,gBAAgB,CAAA;AAErF,mBAAmB;AACnB,eAAO,MAAM,IAAI,gBAAgB,CAAA;AAEjC;;GAEG;AACH,wBAAsB,WAAW,IAAI,OAAO,CAAC,OAAO,CAAC,CAMpD;AAED;;GAEG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,OAAO,GAAE,cAAmB,GAAG,OAAO,CAAC,aAAa,CAAC,CAwE9F;AAED;;GAEG;AACH,wBAAgB,IAAI,CAAC,IAAI,EAAE,IAAI,GAAG,IAAI,CASrC"}