opencastle 0.33.6 → 0.33.7

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 (33) hide show
  1. package/dist/cli/convoy/engine.d.ts +5 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -1
  3. package/dist/cli/convoy/engine.js +85 -16
  4. package/dist/cli/convoy/engine.js.map +1 -1
  5. package/dist/cli/convoy/engine.test.js +10 -12
  6. package/dist/cli/convoy/engine.test.js.map +1 -1
  7. package/dist/cli/convoy/pipeline.d.ts +3 -0
  8. package/dist/cli/convoy/pipeline.d.ts.map +1 -1
  9. package/dist/cli/convoy/pipeline.js +88 -18
  10. package/dist/cli/convoy/pipeline.js.map +1 -1
  11. package/dist/cli/run.d.ts.map +1 -1
  12. package/dist/cli/run.js +1 -123
  13. package/dist/cli/run.js.map +1 -1
  14. package/package.json +1 -1
  15. package/src/cli/convoy/engine.test.ts +10 -12
  16. package/src/cli/convoy/engine.ts +84 -16
  17. package/src/cli/convoy/pipeline.ts +81 -19
  18. package/src/cli/run.ts +0 -118
  19. package/src/dashboard/dist/data/convoys/demo-api-v2.json +3 -3
  20. package/src/dashboard/dist/data/convoys/demo-auth-revamp.json +4 -4
  21. package/src/dashboard/dist/data/convoys/demo-dashboard-ui.json +6 -6
  22. package/src/dashboard/dist/data/convoys/demo-data-pipeline.json +3 -3
  23. package/src/dashboard/dist/data/convoys/demo-deploy-ci.json +1 -1
  24. package/src/dashboard/dist/data/convoys/demo-docs-update.json +3 -3
  25. package/src/dashboard/dist/data/convoys/demo-perf-opt.json +4 -4
  26. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
  27. package/src/dashboard/public/data/convoys/demo-api-v2.json +3 -3
  28. package/src/dashboard/public/data/convoys/demo-auth-revamp.json +4 -4
  29. package/src/dashboard/public/data/convoys/demo-dashboard-ui.json +6 -6
  30. package/src/dashboard/public/data/convoys/demo-data-pipeline.json +3 -3
  31. package/src/dashboard/public/data/convoys/demo-deploy-ci.json +1 -1
  32. package/src/dashboard/public/data/convoys/demo-docs-update.json +3 -3
  33. package/src/dashboard/public/data/convoys/demo-perf-opt.json +4 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencastle",
3
- "version": "0.33.6",
3
+ "version": "0.33.7",
4
4
  "type": "module",
5
5
  "description": "Multi-agent orchestration framework for AI coding assistants",
6
6
  "bin": {
@@ -103,6 +103,7 @@ function makeEngine(opts: ConvoyEngineOptions): ReturnType<typeof createConvoyEn
103
103
  return createConvoyEngine({
104
104
  logsDir: join(tmpDir, 'logs'), // prevents test data in production logs
105
105
  _ensureBranch: vi.fn().mockResolvedValue(undefined),
106
+ _convoyWorktreeDir: null,
106
107
  ...opts,
107
108
  })
108
109
  }
@@ -3283,11 +3284,10 @@ describe('symlink security scan', () => {
3283
3284
  })
3284
3285
  })
3285
3286
 
3286
- // ── Security: ensureBranch fallback (issue #3) ────────────────────────────────
3287
+ // ── Security: convoy-level worktree when branch is set ───────────────────────
3287
3288
 
3288
- describe('ensureBranch fallback when _ensureBranch not provided', () => {
3289
- it('calls the injected _ensureBranch when branch is set in spec', async () => {
3290
- const branchFn = vi.fn().mockResolvedValue(undefined)
3289
+ describe('convoy-level worktree when branch is set', () => {
3290
+ it('runs successfully when _convoyWorktreeDir is null and branch is set', async () => {
3291
3291
  const adapter = makeAdapter()
3292
3292
  const spec = makeSpec({ branch: 'feature-x' })
3293
3293
  const engine = createConvoyEngine({
@@ -3297,15 +3297,14 @@ describe('ensureBranch fallback when _ensureBranch not provided', () => {
3297
3297
  dbPath,
3298
3298
  _worktreeManager: makeWorktreeManager(),
3299
3299
  _mergeQueue: makeMergeQueue(),
3300
- _ensureBranch: branchFn,
3300
+ _convoyWorktreeDir: null,
3301
3301
  })
3302
3302
 
3303
- await engine.run()
3304
- expect(branchFn).toHaveBeenCalledWith('feature-x', expect.any(String))
3303
+ const result = await engine.run()
3304
+ expect(result.status).toBe('done')
3305
3305
  })
3306
3306
 
3307
- it('does not call ensureBranch when spec has no branch', async () => {
3308
- const branchFn = vi.fn().mockResolvedValue(undefined)
3307
+ it('does not attempt worktree creation when spec has no branch', async () => {
3309
3308
  const adapter = makeAdapter()
3310
3309
  const spec = makeSpec({ branch: undefined })
3311
3310
  const engine = makeEngine({
@@ -3315,11 +3314,10 @@ describe('ensureBranch fallback when _ensureBranch not provided', () => {
3315
3314
  dbPath,
3316
3315
  _worktreeManager: makeWorktreeManager(),
3317
3316
  _mergeQueue: makeMergeQueue(),
3318
- _ensureBranch: branchFn,
3319
3317
  })
3320
3318
 
3321
- await engine.run()
3322
- expect(branchFn).not.toHaveBeenCalled()
3319
+ const result = await engine.run()
3320
+ expect(result.status).toBe('done')
3323
3321
  })
3324
3322
  })
3325
3323
 
@@ -82,6 +82,11 @@ export interface ConvoyEngineOptions {
82
82
  _mergeQueue?: MergeQueue
83
83
  /** Override for test injection. Pass `ensureBranch` for real behavior, or a mock. */
84
84
  _ensureBranch?: (branchName: string, basePath: string) => Promise<void>
85
+ /** Pass `null` to skip convoy-level worktree creation (test mode).
86
+ * Pass a string path to inject a specific worktree directory.
87
+ * Omit (undefined) for real worktree creation.
88
+ * Also skipped when `_ensureBranch` is provided (backward-compat for tests). */
89
+ _convoyWorktreeDir?: string | null
85
90
  /** Injectable for test injection of the review pipeline. */
86
91
  _reviewRunner?: (task: TaskRecord, level: ReviewLevel, reviewerModel: string) => Promise<ReviewResult>
87
92
  }
@@ -2981,11 +2986,33 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
2981
2986
  const specHash = createHash('sha256').update(specYaml).digest('hex')
2982
2987
  const baseBranch = spec.branch ?? (await getCurrentBranch())
2983
2988
 
2984
- // Ensure target branch exists before acquiring any locks.
2985
- // Uses _ensureBranch injection so callers/tests can override.
2989
+ // Create convoy-level worktree for branch isolation.
2990
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
2991
+ let effectiveBasePath = basePath
2992
+ let convoyWorktreeDir: string | undefined
2986
2993
  if (spec.branch !== undefined) {
2987
- const branchFn = options._ensureBranch ?? ensureBranch
2988
- await branchFn(spec.branch, basePath)
2994
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
2995
+ if (!skipWorktree) {
2996
+ if (typeof options._convoyWorktreeDir === 'string') {
2997
+ effectiveBasePath = options._convoyWorktreeDir
2998
+ convoyWorktreeDir = options._convoyWorktreeDir
2999
+ } else {
3000
+ const worktreeId = `convoy-root-${Date.now()}`
3001
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
3002
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
3003
+ let branchExists = false
3004
+ try {
3005
+ await execFile('git', ['rev-parse', '--verify', spec.branch], { cwd: basePath })
3006
+ branchExists = true
3007
+ } catch { /* branch doesn't exist */ }
3008
+ if (branchExists) {
3009
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, spec.branch], { cwd: basePath })
3010
+ } else {
3011
+ await execFile('git', ['worktree', 'add', '-b', spec.branch, convoyWorktreeDir], { cwd: basePath })
3012
+ }
3013
+ effectiveBasePath = convoyWorktreeDir
3014
+ }
3015
+ }
2989
3016
  }
2990
3017
 
2991
3018
  mkdirSync(dirname(dbPath), { recursive: true })
@@ -3022,10 +3049,10 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3022
3049
  const store = createConvoyStore(dbPath)
3023
3050
  const ndjsonPath = options.logsDir
3024
3051
  ? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
3025
- : ndjsonPathForConvoy(convoyId, basePath)
3052
+ : ndjsonPathForConvoy(convoyId, effectiveBasePath)
3026
3053
  const events = createEventEmitter(store, { ndjsonPath })
3027
- const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
3028
- const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
3054
+ const wtManager = options._worktreeManager ?? createWorktreeManager(effectiveBasePath)
3055
+ const mergeQueue = options._mergeQueue ?? createMergeQueue(effectiveBasePath)
3029
3056
 
3030
3057
  let result: ConvoyResult
3031
3058
  try {
@@ -3088,7 +3115,7 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3088
3115
 
3089
3116
  result = await runConvoy(
3090
3117
  convoyId, spec, adapter, store, events,
3091
- wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath,
3118
+ wtManager, mergeQueue, effectiveBasePath, baseBranch, verbose, startTime, ndjsonPath,
3092
3119
  options._reviewRunner,
3093
3120
  )
3094
3121
  } finally {
@@ -3096,6 +3123,11 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3096
3123
  store.close()
3097
3124
  lock.release()
3098
3125
  lockDb.close()
3126
+ if (convoyWorktreeDir) {
3127
+ try {
3128
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
3129
+ } catch { /* ignore cleanup errors */ }
3130
+ }
3099
3131
  }
3100
3132
  return result
3101
3133
  }
@@ -3135,12 +3167,9 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3135
3167
  lock.startHeartbeat()
3136
3168
 
3137
3169
  const store = createConvoyStore(dbPath)
3138
- const ndjsonPath = options.logsDir
3139
- ? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
3140
- : ndjsonPathForConvoy(convoyId, basePath)
3141
- const events = createEventEmitter(store, { ndjsonPath })
3142
- const wtManager = options._worktreeManager ?? createWorktreeManager(basePath)
3143
- const mergeQueue = options._mergeQueue ?? createMergeQueue(basePath)
3170
+ let effectiveBasePath = basePath
3171
+ let convoyWorktreeDir: string | undefined
3172
+ let events: ConvoyEventEmitter | undefined
3144
3173
 
3145
3174
  let result: ConvoyResult
3146
3175
  try {
@@ -3150,6 +3179,40 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3150
3179
  }
3151
3180
 
3152
3181
  const baseBranch = convoy.branch ?? spec.branch ?? (await getCurrentBranch())
3182
+ const convoyBranch = convoy.branch ?? spec.branch
3183
+
3184
+ // Create convoy-level worktree for branch isolation.
3185
+ if (convoyBranch !== undefined) {
3186
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
3187
+ if (!skipWorktree) {
3188
+ if (typeof options._convoyWorktreeDir === 'string') {
3189
+ effectiveBasePath = options._convoyWorktreeDir
3190
+ convoyWorktreeDir = options._convoyWorktreeDir
3191
+ } else {
3192
+ const worktreeId = `convoy-root-${Date.now()}`
3193
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
3194
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
3195
+ let branchExists = false
3196
+ try {
3197
+ await execFile('git', ['rev-parse', '--verify', convoyBranch], { cwd: basePath })
3198
+ branchExists = true
3199
+ } catch { /* branch doesn't exist */ }
3200
+ if (branchExists) {
3201
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, convoyBranch], { cwd: basePath })
3202
+ } else {
3203
+ await execFile('git', ['worktree', 'add', '-b', convoyBranch, convoyWorktreeDir], { cwd: basePath })
3204
+ }
3205
+ effectiveBasePath = convoyWorktreeDir
3206
+ }
3207
+ }
3208
+ }
3209
+
3210
+ const ndjsonPath = options.logsDir
3211
+ ? join(options.logsDir, 'convoys', `${convoyId}.ndjson`)
3212
+ : ndjsonPathForConvoy(convoyId, effectiveBasePath)
3213
+ events = createEventEmitter(store, { ndjsonPath })
3214
+ const wtManager = options._worktreeManager ?? createWorktreeManager(effectiveBasePath)
3215
+ const mergeQueue = options._mergeQueue ?? createMergeQueue(effectiveBasePath)
3153
3216
 
3154
3217
  // Reset interrupted tasks and mark their workers as killed
3155
3218
  const allTasks = store.getTasksByConvoy(convoyId)
@@ -3187,14 +3250,19 @@ export function createConvoyEngine(options: ConvoyEngineOptions): ConvoyEngine {
3187
3250
 
3188
3251
  result = await runConvoy(
3189
3252
  convoyId, spec, adapter, store, events,
3190
- wtManager, mergeQueue, basePath, baseBranch, verbose, startTime, ndjsonPath,
3253
+ wtManager, mergeQueue, effectiveBasePath, baseBranch, verbose, startTime, ndjsonPath,
3191
3254
  options._reviewRunner,
3192
3255
  )
3193
3256
  } finally {
3194
- events.close()
3257
+ events?.close()
3195
3258
  store.close()
3196
3259
  lock.release()
3197
3260
  lockDb.close()
3261
+ if (convoyWorktreeDir) {
3262
+ try {
3263
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
3264
+ } catch { /* ignore cleanup errors */ }
3265
+ }
3198
3266
  }
3199
3267
  return result
3200
3268
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises'
2
2
  import { mkdirSync } from 'node:fs'
3
- import { resolve, dirname, relative, isAbsolute, sep } from 'node:path'
3
+ import { resolve, join, dirname, relative, isAbsolute, sep } from 'node:path'
4
4
  import { execFile as execFileCb } from 'node:child_process'
5
5
  import { promisify } from 'node:util'
6
6
  import type { TaskSpec, AgentAdapter } from '../types.js'
@@ -8,7 +8,6 @@ import { parseTaskSpecText } from '../run/schema.js'
8
8
  import { createConvoyStore } from './store.js'
9
9
  import {
10
10
  createConvoyEngine,
11
- ensureBranch,
12
11
  type ConvoyEngine,
13
12
  type ConvoyResult,
14
13
  type ConvoyEngineOptions,
@@ -51,6 +50,9 @@ export interface PipelineOrchestratorOptions {
51
50
  _createConvoyEngine?: (opts: ConvoyEngineOptions) => ConvoyEngine
52
51
  /** Injectable branch handler (used in tests). */
53
52
  _ensureBranch?: (branchName: string, basePath: string) => Promise<void>
53
+ /** Pass `null` to skip pipeline-level worktree creation (test mode).
54
+ * Also skipped when `_ensureBranch` is provided (backward-compat for tests). */
55
+ _convoyWorktreeDir?: string | null
54
56
  }
55
57
 
56
58
  // ── Internal helpers ──────────────────────────────────────────────────────────
@@ -87,7 +89,6 @@ export function createPipelineOrchestrator(
87
89
  const basePath = resolve(options.basePath ?? process.cwd())
88
90
  const dbPath = options.dbPath ?? resolve(basePath, '.opencastle', 'convoy.db')
89
91
  const engineFactory = options._createConvoyEngine ?? createConvoyEngine
90
- const branchFn = options._ensureBranch ?? ensureBranch
91
92
 
92
93
  async function getCurrentBranch(): Promise<string> {
93
94
  try {
@@ -118,7 +119,7 @@ export function createPipelineOrchestrator(
118
119
  specPath: string,
119
120
  pipelineId: string,
120
121
  branch: string,
121
- skipDirtyCheck = false,
122
+ effectiveBase: string = basePath,
122
123
  ): Promise<ConvoyResult> {
123
124
  const absPath = resolveSpecPath(specPath)
124
125
  const convoyYaml = await readFile(absPath, 'utf8')
@@ -129,16 +130,13 @@ export function createPipelineOrchestrator(
129
130
  spec: overriddenSpec,
130
131
  specYaml: convoyYaml,
131
132
  adapter,
132
- basePath,
133
+ basePath: effectiveBase,
133
134
  dbPath,
134
135
  logsDir: options.logsDir,
135
136
  verbose,
136
137
  pipelineId,
138
+ _convoyWorktreeDir: null,
137
139
  }
138
- if (skipDirtyCheck) {
139
- engineOpts._ensureBranch = (b, base) => ensureBranch(b, base, true)
140
- }
141
-
142
140
  const engine = engineFactory(engineOpts)
143
141
  return engine.run()
144
142
  }
@@ -149,10 +147,33 @@ export function createPipelineOrchestrator(
149
147
  const branch = spec.branch ?? (await getCurrentBranch())
150
148
  const convoySpecs = spec.depends_on_convoy ?? []
151
149
 
152
- // Switch branch BEFORE any DB writes — otherwise the convoy.db modification
153
- // from insertPipeline() causes ensureBranch's dirty check to fail.
150
+ // Create pipeline-level worktree for branch isolation.
151
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
152
+ let effectiveBasePath = basePath
153
+ let convoyWorktreeDir: string | undefined
154
154
  if (spec.branch !== undefined) {
155
- await branchFn(spec.branch, basePath)
155
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
156
+ if (!skipWorktree) {
157
+ if (typeof options._convoyWorktreeDir === 'string') {
158
+ effectiveBasePath = options._convoyWorktreeDir
159
+ convoyWorktreeDir = options._convoyWorktreeDir
160
+ } else {
161
+ const worktreeId = `convoy-root-${Date.now()}`
162
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
163
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
164
+ let branchExists = false
165
+ try {
166
+ await execFile('git', ['rev-parse', '--verify', spec.branch], { cwd: basePath })
167
+ branchExists = true
168
+ } catch { /* branch doesn't exist */ }
169
+ if (branchExists) {
170
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, spec.branch], { cwd: basePath })
171
+ } else {
172
+ await execFile('git', ['worktree', 'add', '-b', spec.branch, convoyWorktreeDir], { cwd: basePath })
173
+ }
174
+ effectiveBasePath = convoyWorktreeDir
175
+ }
176
+ }
156
177
  }
157
178
 
158
179
  mkdirSync(dirname(dbPath), { recursive: true })
@@ -187,10 +208,7 @@ export function createPipelineOrchestrator(
187
208
 
188
209
  let convoyResult: ConvoyResult
189
210
  try {
190
- // Always skip dirty check inside pipeline — run.ts pre-flight already
191
- // handled the stash prompt, and insertPipeline() writes to convoy.db
192
- // before the first convoy runs (which would cause a false dirty-check failure).
193
- convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, true)
211
+ convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, effectiveBasePath)
194
212
  } catch (err) {
195
213
  process.stderr.write(
196
214
  ` ✗ Convoy spec "${specPath}" failed to load: ${(err as Error).message}\n`,
@@ -218,11 +236,12 @@ export function createPipelineOrchestrator(
218
236
  spec: { ...spec, branch },
219
237
  specYaml,
220
238
  adapter,
221
- basePath,
239
+ basePath: effectiveBasePath,
222
240
  dbPath,
223
241
  logsDir: options.logsDir,
224
242
  verbose,
225
243
  pipelineId,
244
+ _convoyWorktreeDir: null,
226
245
  })
227
246
  const hybridResult = await hybridEngine.run()
228
247
  convoyResults.push(hybridResult)
@@ -238,6 +257,12 @@ export function createPipelineOrchestrator(
238
257
  failStore.close()
239
258
  }
240
259
  throw err
260
+ } finally {
261
+ if (convoyWorktreeDir) {
262
+ try {
263
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
264
+ } catch { /* ignore cleanup errors */ }
265
+ }
241
266
  }
242
267
 
243
268
  const totalTokens = aggregateTokens(convoyResults)
@@ -283,6 +308,36 @@ export function createPipelineOrchestrator(
283
308
  const convoySpecs: string[] = JSON.parse(pipeline.convoy_specs) as string[]
284
309
  const branch = pipeline.branch ?? spec.branch ?? (await getCurrentBranch())
285
310
 
311
+ // Create pipeline-level worktree for branch isolation.
312
+ // Skipped when _convoyWorktreeDir is null or _ensureBranch is injected (test mode).
313
+ let effectiveBasePath = basePath
314
+ let convoyWorktreeDir: string | undefined
315
+ const pipelineBranch = pipeline.branch ?? spec.branch
316
+ if (pipelineBranch !== undefined) {
317
+ const skipWorktree = options._convoyWorktreeDir === null || options._ensureBranch !== undefined
318
+ if (!skipWorktree) {
319
+ if (typeof options._convoyWorktreeDir === 'string') {
320
+ effectiveBasePath = options._convoyWorktreeDir
321
+ convoyWorktreeDir = options._convoyWorktreeDir
322
+ } else {
323
+ const worktreeId = `convoy-root-${Date.now()}`
324
+ convoyWorktreeDir = join(basePath, '.opencastle', 'worktrees', worktreeId)
325
+ mkdirSync(dirname(convoyWorktreeDir), { recursive: true })
326
+ let branchExists = false
327
+ try {
328
+ await execFile('git', ['rev-parse', '--verify', pipelineBranch], { cwd: basePath })
329
+ branchExists = true
330
+ } catch { /* branch doesn't exist */ }
331
+ if (branchExists) {
332
+ await execFile('git', ['worktree', 'add', convoyWorktreeDir, pipelineBranch], { cwd: basePath })
333
+ } else {
334
+ await execFile('git', ['worktree', 'add', '-b', pipelineBranch, convoyWorktreeDir], { cwd: basePath })
335
+ }
336
+ effectiveBasePath = convoyWorktreeDir
337
+ }
338
+ }
339
+ }
340
+
286
341
  // Load all convoys linked to this pipeline, sorted by creation time
287
342
  const convoyStore = createConvoyStore(dbPath)
288
343
  let existingConvoys
@@ -350,11 +405,12 @@ export function createPipelineOrchestrator(
350
405
  spec: overriddenSpec,
351
406
  specYaml: convoyYaml,
352
407
  adapter,
353
- basePath,
408
+ basePath: effectiveBasePath,
354
409
  dbPath,
355
410
  logsDir: options.logsDir,
356
411
  verbose,
357
412
  pipelineId,
413
+ _convoyWorktreeDir: null,
358
414
  })
359
415
 
360
416
  if (existing.status === 'failed') {
@@ -366,7 +422,7 @@ export function createPipelineOrchestrator(
366
422
  } else {
367
423
  // Run fresh
368
424
  try {
369
- convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, true)
425
+ convoyResult = await runConvoySpecFile(specPath, pipelineId, branch, effectiveBasePath)
370
426
  } catch (err) {
371
427
  process.stderr.write(
372
428
  ` ✗ Convoy spec "${specPath}" failed to load: ${(err as Error).message}\n`,
@@ -399,6 +455,12 @@ export function createPipelineOrchestrator(
399
455
  failStore.close()
400
456
  }
401
457
  throw err
458
+ } finally {
459
+ if (convoyWorktreeDir) {
460
+ try {
461
+ await execFile('git', ['worktree', 'remove', convoyWorktreeDir, '--force'], { cwd: basePath })
462
+ } catch { /* ignore cleanup errors */ }
463
+ }
402
464
  }
403
465
 
404
466
  const totalTokens = aggregateTokens(convoyResults)
package/src/cli/run.ts CHANGED
@@ -871,54 +871,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
871
871
  if (spec.branch) console.log(` Branch: ${spec.branch}`)
872
872
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
873
873
 
874
- // ── Pre-flight: handle uncommitted changes before branch switch ──
875
- let pipelineDidStash = false
876
- if (spec.branch) {
877
- const { execFile: execFileCb } = await import('node:child_process')
878
- const { promisify } = await import('node:util')
879
- const execFile = promisify(execFileCb)
880
- let statusOut: string
881
- try {
882
- const result = await execFile('git', ['status', '--porcelain'], {
883
- cwd: process.cwd(),
884
- })
885
- statusOut = result.stdout
886
- } catch {
887
- console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
888
- console.error(` Run \`git init\` to initialize a repository.`)
889
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
890
- process.exit(1)
891
- }
892
- // Untracked files (??) don't block branch checkout — ignore them
893
- const trackedChanges = statusOut
894
- .split('\n')
895
- .filter(line => line.trim() && !line.startsWith('??'))
896
- .join('\n')
897
- if (trackedChanges) {
898
- console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
899
- const shouldStash = await confirm('Stash changes and continue?', true)
900
- if (!shouldStash) {
901
- console.log(' Aborted. Commit or stash your changes manually, then retry.')
902
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
903
- closePrompts()
904
- process.exit(1)
905
- }
906
- try {
907
- await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before pipeline'], {
908
- cwd: process.cwd(),
909
- })
910
- pipelineDidStash = true
911
- console.log(` ${c.green('✓')} Changes stashed.`)
912
- } catch {
913
- console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
914
- console.log(` Commit or stash your changes manually, then resume:\n`)
915
- console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
916
- closePrompts()
917
- process.exit(1)
918
- }
919
- }
920
- }
921
-
922
874
  const { startDashboardServer } = await import('./dashboard.js')
923
875
  let pipelineDashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
924
876
  try {
@@ -953,17 +905,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
953
905
  throw err
954
906
  }
955
907
  printPipelineResult(pipelineResult)
956
- if (pipelineDidStash) {
957
- const { execFile: execFileCb } = await import('node:child_process')
958
- const { promisify } = await import('node:util')
959
- const execFile = promisify(execFileCb)
960
- try {
961
- await execFile('git', ['stash', 'pop'], { cwd: process.cwd() })
962
- console.log(` ${c.green('✓')} Stashed changes restored.`)
963
- } catch {
964
- console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
965
- }
966
- }
967
908
  if (pipelineDashboardResult) {
968
909
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
969
910
  console.log(` ${c.dim('Dashboard:')} ${pipelineDashboardResult.url}`)
@@ -996,54 +937,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
996
937
  if (spec.branch) console.log(` Branch: ${spec.branch}`)
997
938
  if (spec.gates?.length) console.log(` Gates: ${spec.gates.length} validation commands`)
998
939
 
999
- // ── Pre-flight: handle uncommitted changes before branch switch ──
1000
- let didStash = false
1001
- if (spec.branch) {
1002
- const { execFile: execFileCb } = await import('node:child_process')
1003
- const { promisify } = await import('node:util')
1004
- const execFile = promisify(execFileCb)
1005
- let statusOut: string
1006
- try {
1007
- const result = await execFile('git', ['status', '--porcelain'], {
1008
- cwd: process.cwd(),
1009
- })
1010
- statusOut = result.stdout
1011
- } catch {
1012
- console.error(` ✗ Git repository not found. A git repo is required when using the \`branch\` option.`)
1013
- console.error(` Run \`git init\` to initialize a repository.`)
1014
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1015
- process.exit(1)
1016
- }
1017
- // Untracked files (??) don't block branch checkout — ignore them
1018
- const trackedChanges = statusOut
1019
- .split('\n')
1020
- .filter(line => line.trim() && !line.startsWith('??'))
1021
- .join('\n')
1022
- if (trackedChanges) {
1023
- console.log(`\n ${c.yellow('⚠')} Uncommitted changes detected.`)
1024
- const shouldStash = await confirm('Stash changes and continue?', true)
1025
- if (!shouldStash) {
1026
- console.log(' Aborted. Commit or stash your changes manually, then retry.')
1027
- console.log(`\n ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1028
- closePrompts()
1029
- process.exit(1)
1030
- }
1031
- try {
1032
- await execFile('git', ['stash', 'push', '-m', 'opencastle: auto-stash before convoy'], {
1033
- cwd: process.cwd(),
1034
- })
1035
- didStash = true
1036
- console.log(` ${c.green('✓')} Changes stashed.`)
1037
- } catch {
1038
- console.log(` ${c.yellow('⚠')} Could not stash changes automatically.`)
1039
- console.log(` Commit or stash your changes manually, then resume:\n`)
1040
- console.log(` ${c.dim('Resume:')} npx opencastle run -f ${opts.file}`)
1041
- closePrompts()
1042
- process.exit(1)
1043
- }
1044
- }
1045
- }
1046
-
1047
940
  const { startDashboardServer } = await import('./dashboard.js')
1048
941
  let dashboardResult: { server: import('node:http').Server; port: number; url: string } | null = null
1049
942
  try {
@@ -1095,17 +988,6 @@ export default async function run({ args, pkgRoot }: CliContext): Promise<void>
1095
988
  throw err
1096
989
  }
1097
990
  printConvoyResult(result)
1098
- if (didStash) {
1099
- const { execFile: execFileCb } = await import('node:child_process')
1100
- const { promisify } = await import('node:util')
1101
- const execFile = promisify(execFileCb)
1102
- try {
1103
- await execFile('git', ['stash', 'pop'], { cwd: process.cwd() })
1104
- console.log(` ${c.green('✓')} Stashed changes restored.`)
1105
- } catch {
1106
- console.log(` ${c.yellow('⚠')} Could not restore stash automatically. Run \`git stash pop\` manually.`)
1107
- }
1108
- }
1109
991
  if (dashboardResult) {
1110
992
  console.log(`\n ${c.dim('Results saved to .opencastle/convoy.db')}`)
1111
993
  console.log(` ${c.dim('Dashboard:')} ${dashboardResult.url}`)
@@ -51,21 +51,21 @@
51
51
  "name": "docs/api-v2-contract.json",
52
52
  "type": "json",
53
53
  "task_id": "api-t1",
54
- "created_at": "2026-04-06T12:18:29.969Z"
54
+ "created_at": "2026-04-07T12:58:13.324Z"
55
55
  },
56
56
  {
57
57
  "id": "artifact-demo-api-v2-reports-security-gate-failure-md",
58
58
  "name": "reports/security-gate-failure.md",
59
59
  "type": "summary",
60
60
  "task_id": "api-t3",
61
- "created_at": "2026-04-06T12:18:29.969Z"
61
+ "created_at": "2026-04-07T12:58:13.324Z"
62
62
  },
63
63
  {
64
64
  "id": "artifact-demo-api-v2-src-api-rate-limiter-ts",
65
65
  "name": "src/api/rate-limiter.ts",
66
66
  "type": "file",
67
67
  "task_id": "api-t2",
68
- "created_at": "2026-04-06T12:18:29.969Z"
68
+ "created_at": "2026-04-07T12:58:13.324Z"
69
69
  }
70
70
  ],
71
71
  "has_more_events": false,
@@ -42,28 +42,28 @@
42
42
  "name": "libs/auth/src/jwt-middleware.ts",
43
43
  "type": "file",
44
44
  "task_id": "auth-t2",
45
- "created_at": "2026-04-06T12:18:29.968Z"
45
+ "created_at": "2026-04-07T12:58:13.323Z"
46
46
  },
47
47
  {
48
48
  "id": "artifact-demo-auth-revamp-libs-auth-src-rls-policies-sql",
49
49
  "name": "libs/auth/src/rls-policies.sql",
50
50
  "type": "file",
51
51
  "task_id": "auth-t3",
52
- "created_at": "2026-04-06T12:18:29.968Z"
52
+ "created_at": "2026-04-07T12:58:13.323Z"
53
53
  },
54
54
  {
55
55
  "id": "artifact-demo-auth-revamp-tests-auth-integration-test-ts",
56
56
  "name": "tests/auth/integration.test.ts",
57
57
  "type": "file",
58
58
  "task_id": "auth-t4",
59
- "created_at": "2026-04-06T12:18:29.968Z"
59
+ "created_at": "2026-04-07T12:58:13.323Z"
60
60
  },
61
61
  {
62
62
  "id": "artifact-demo-auth-revamp-reports-auth-review-summary-md",
63
63
  "name": "reports/auth-review-summary.md",
64
64
  "type": "summary",
65
65
  "task_id": "auth-t5",
66
- "created_at": "2026-04-06T12:18:29.969Z"
66
+ "created_at": "2026-04-07T12:58:13.324Z"
67
67
  }
68
68
  ],
69
69
  "has_more_events": false,