create-claude-workspace 2.3.10 → 2.3.12

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.
@@ -55,13 +55,16 @@ export function listOrphanedWorktrees(projectDir, knownWorktrees) {
55
55
  return actual.filter(p => !knownSet.has(normalizePath(resolve(p))));
56
56
  }
57
57
  // ─── Commit operations ───
58
- export function commitInWorktree(worktreePath, message) {
58
+ export function commitInWorktree(worktreePath, message, skipHooks = false) {
59
59
  git(['add', '-A'], worktreePath);
60
60
  // Check if there's anything to commit
61
61
  const status = git(['status', '--porcelain'], worktreePath);
62
62
  if (!status)
63
63
  return '';
64
- git(['commit', '-m', message], worktreePath);
64
+ const args = ['commit', '-m', message];
65
+ if (skipHooks)
66
+ args.push('--no-verify');
67
+ git(args, worktreePath);
65
68
  return git(['rev-parse', 'HEAD'], worktreePath);
66
69
  }
67
70
  export function getChangedFiles(worktreePath) {
@@ -1,13 +1,14 @@
1
1
  // ─── Main orchestration loop: pipeline state machine ───
2
2
  // Picks tasks, assigns to workers, advances through plan→implement→test→review→commit→merge.
3
3
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
4
5
  import { resolve } from 'node:path';
5
6
  import { parseTodoMd, updateTaskCheckbox } from './tasks/parser.mjs';
6
7
  import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-source.mjs';
7
8
  import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
8
9
  import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
9
10
  import { recordSession, getSession, clearSession } from './state/session.mjs';
10
- import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, abortMerge, deleteBranchRemote, } from './git/manager.mjs';
11
+ import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, deleteBranchRemote, } from './git/manager.mjs';
11
12
  import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
12
13
  import { scanAgents } from './agents/health-checker.mjs';
13
14
  import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
@@ -63,7 +64,7 @@ export async function runIteration(deps) {
63
64
  if (hasUncommittedChanges(projectDir)) {
64
65
  logger.warn('Uncommitted changes on main — auto-committing before starting work');
65
66
  try {
66
- commitInWorktree(projectDir, 'chore: auto-commit uncommitted changes before scheduler start');
67
+ commitInWorktree(projectDir, 'chore: auto-commit uncommitted changes before scheduler start', true);
67
68
  logger.info('Uncommitted changes committed on main');
68
69
  }
69
70
  catch (err) {
@@ -192,9 +193,72 @@ export async function runIteration(deps) {
192
193
  if (queued > 0) {
193
194
  logger.info(`Starting ${tasksToRun.length} tasks, ${queued} queued (waiting for workers)`);
194
195
  }
195
- // Process each task through the pipeline
196
+ // Process recovered pipelines first (from recovery phase)
196
197
  let workDone = false;
198
+ const recoveredIds = Object.keys(state.pipelines).filter(id => {
199
+ const p = state.pipelines[id];
200
+ return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
201
+ });
202
+ for (const taskId of recoveredIds) {
203
+ const slot = pool.idleSlot();
204
+ if (!slot)
205
+ break;
206
+ const pipeline = state.pipelines[taskId];
207
+ pipeline.workerId = slot.id;
208
+ // Find matching task from loaded tasks, or create a minimal one
209
+ let task = tasks.find(t => t.id === taskId);
210
+ if (!task) {
211
+ const issueNum = extractIssueNumber(taskId);
212
+ task = {
213
+ id: taskId,
214
+ title: `Recovered: ${taskId}`,
215
+ phase: state.currentPhase,
216
+ type: 'fullstack',
217
+ complexity: 'M',
218
+ dependsOn: [],
219
+ issueMarker: taskId,
220
+ kitUpgrade: false,
221
+ lineNumber: 0,
222
+ status: 'in-progress',
223
+ changelog: 'changed',
224
+ };
225
+ }
226
+ logger.info(`[${taskId}] Resuming recovered pipeline at step "${pipeline.step}"`);
227
+ try {
228
+ const success = await runTaskPipeline(task, slot.id, agents, deps);
229
+ workDone = true;
230
+ if (success) {
231
+ task.status = 'done';
232
+ state.completedTasks.push(task.id);
233
+ if (state.taskMode === 'platform') {
234
+ const platform = detectCIPlatform(projectDir);
235
+ if (platform !== 'none') {
236
+ const issueNum = extractIssueNumber(task.issueMarker);
237
+ if (issueNum)
238
+ updateIssueStatus(projectDir, platform, issueNum, 'done');
239
+ }
240
+ }
241
+ appendEvent(projectDir, createEvent('task_completed', { taskId: task.id }));
242
+ }
243
+ else {
244
+ task.status = 'skipped';
245
+ state.skippedTasks.push(task.id);
246
+ appendEvent(projectDir, createEvent('task_skipped', { taskId: task.id }));
247
+ }
248
+ }
249
+ catch (err) {
250
+ logger.error(`Recovered task ${taskId} failed: ${err.message}`);
251
+ state.skippedTasks.push(taskId);
252
+ }
253
+ writeState(projectDir, state);
254
+ }
255
+ // Process new tasks through the pipeline
197
256
  for (const task of tasksToRun) {
257
+ // Skip if already handled as recovered pipeline
258
+ if (state.pipelines[task.id])
259
+ continue;
260
+ if (state.completedTasks.includes(task.id) || state.skippedTasks.includes(task.id))
261
+ continue;
198
262
  const slot = pool.idleSlot();
199
263
  if (!slot)
200
264
  break;
@@ -245,12 +309,17 @@ export async function runIteration(deps) {
245
309
  async function runTaskPipeline(task, workerId, agents, deps) {
246
310
  const { pool, orchestrator, state, opts, logger, onMessage, onSpawnStart, onSpawnEnd } = deps;
247
311
  const projectDir = opts.projectDir;
248
- // Create worktree
249
- const slug = taskToSlug(task);
250
- const worktreePath = createWorktree(projectDir, slug);
251
- logger.info(`[${task.id}] Worktree created: ${slug}`);
252
- // Initialize pipeline state
253
- const pipeline = {
312
+ // Check for existing pipeline (recovered from previous run)
313
+ const existing = state.pipelines[task.id];
314
+ const resumeStep = existing?.step;
315
+ // Create worktree (returns existing path if already exists)
316
+ const slug = existing ? getCurrentBranch(existing.worktreePath) : taskToSlug(task);
317
+ const worktreePath = existing?.worktreePath ?? createWorktree(projectDir, taskToSlug(task));
318
+ if (!existing) {
319
+ logger.info(`[${task.id}] Worktree created: ${taskToSlug(task)}`);
320
+ }
321
+ // Initialize or reuse pipeline state
322
+ const pipeline = existing ?? {
254
323
  taskId: task.id,
255
324
  workerId,
256
325
  worktreePath,
@@ -265,149 +334,181 @@ async function runTaskPipeline(task, workerId, agents, deps) {
265
334
  assignedAgent: null,
266
335
  prState: null,
267
336
  };
337
+ pipeline.workerId = workerId;
268
338
  state.pipelines[task.id] = pipeline;
339
+ // Determine which steps to skip (for resumed pipelines)
340
+ const skipTo = resumeStep ?? 'plan';
341
+ const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
342
+ const skipToIndex = stepOrder.indexOf(skipTo);
343
+ const shouldSkip = (step) => stepOrder.indexOf(step) < skipToIndex;
269
344
  try {
270
345
  // Route task to agent
271
- const routing = await orchestrator.routeTask(task, 'plan', agents);
346
+ const routing = await orchestrator.routeTask(task, shouldSkip('plan') ? skipTo : 'plan', agents);
272
347
  pipeline.assignedAgent = routing.agent;
273
- if (routing.create) {
274
- // Auto-create new agent
348
+ if (routing.create && !shouldSkip('plan')) {
275
349
  await createAgentFile(projectDir, routing.create);
276
350
  pipeline.assignedAgent = routing.create.name;
277
351
  appendEvent(projectDir, createEvent('agent_created', { agentType: routing.create.name }));
278
352
  }
279
- // STEP 1: Plan
280
- pipeline.step = 'plan';
281
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'plan' }));
282
- const planResult = await spawnAgent(pool, workerId, {
283
- agent: pipeline.assignedAgent ?? undefined,
284
- cwd: worktreePath,
285
- prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
286
- model: getAgentModel(pipeline.assignedAgent, agents, task),
287
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
288
- if (!planResult.success) {
289
- logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
290
- return false;
353
+ if (shouldSkip('plan')) {
354
+ logger.info(`[${task.id}] Resuming from step "${skipTo}" with agent ${pipeline.assignedAgent ?? 'default'}`);
291
355
  }
292
- pipeline.architectPlan = planResult.output;
293
- pipeline.testingSection = extractSection(planResult.output, 'TESTING');
294
- // Check for split recommendation
295
- if (task.complexity === 'L' && planResult.output.includes('SPLIT RECOMMENDATION') && !planResult.output.includes('No split needed')) {
296
- logger.info(`[${task.id}] L-task split recommended deferring to decomposition`);
297
- // TODO: implement decomposition flow
356
+ // STEP 1: Plan
357
+ if (!shouldSkip('plan')) {
358
+ pipeline.step = 'plan';
359
+ appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'plan' }));
360
+ const planResult = await spawnAgent(pool, workerId, {
361
+ agent: pipeline.assignedAgent ?? undefined,
362
+ cwd: worktreePath,
363
+ prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
364
+ model: getAgentModel(pipeline.assignedAgent, agents, task),
365
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
366
+ if (!planResult.success) {
367
+ logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
368
+ return false;
369
+ }
370
+ pipeline.architectPlan = planResult.output;
371
+ pipeline.testingSection = extractSection(planResult.output, 'TESTING');
372
+ if (task.complexity === 'L' && planResult.output.includes('SPLIT RECOMMENDATION') && !planResult.output.includes('No split needed')) {
373
+ logger.info(`[${task.id}] L-task split recommended — deferring to decomposition`);
374
+ }
298
375
  }
299
376
  // STEP 2: Implement (includes unit tests + build/lint)
300
- pipeline.step = 'implement';
301
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'implement' }));
302
- // Re-route to implementation agent (may differ from planning agent)
303
- // e.g., ui-engineer plans angular-engineer implements
304
- const implRouting = await orchestrator.routeTask(task, 'implement', agents);
305
- const implAgent = implRouting.agent ?? pipeline.assignedAgent;
306
- const implResult = await spawnAgent(pool, workerId, {
307
- agent: implAgent ?? undefined,
308
- cwd: worktreePath,
309
- prompt: buildImplementPrompt({
310
- task, worktreePath, projectDir,
311
- architectPlan: pipeline.architectPlan,
312
- apiContract: pipeline.apiContract ?? undefined,
313
- }),
314
- model: getAgentModel(implAgent, agents, task),
315
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
316
- if (!implResult.success) {
317
- logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
318
- return false;
319
- }
320
- // STEP 3: QA (E2E tests, integration tests, acceptance criteria verification)
321
- // Only for tasks that need it — skip for pure refactoring, config changes, etc.
322
- const needsQA = task.type !== 'fullstack' || !isRefactoringTask(task);
323
- if (needsQA) {
324
- pipeline.step = 'test';
325
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'test' }));
326
- const changedFiles = getChangedFiles(worktreePath);
327
- const qaRouting = await orchestrator.routeTask(task, 'test', agents);
328
- const qaResult = await spawnAgent(pool, workerId, {
329
- agent: qaRouting.agent ?? undefined,
377
+ if (!shouldSkip('implement')) {
378
+ pipeline.step = 'implement';
379
+ appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'implement' }));
380
+ // Re-route to implementation agent (may differ from planning agent)
381
+ // e.g., ui-engineer plans angular-engineer implements
382
+ const implRouting = await orchestrator.routeTask(task, 'implement', agents);
383
+ const implAgent = implRouting.agent ?? pipeline.assignedAgent;
384
+ const implResult = await spawnAgent(pool, workerId, {
385
+ agent: implAgent ?? undefined,
330
386
  cwd: worktreePath,
331
- prompt: buildQAPrompt({
332
- task, worktreePath, projectDir, changedFiles,
333
- testingSection: pipeline.testingSection ?? undefined,
387
+ prompt: buildImplementPrompt({
388
+ task, worktreePath, projectDir,
389
+ architectPlan: pipeline.architectPlan ?? undefined,
390
+ apiContract: pipeline.apiContract ?? undefined,
334
391
  }),
335
- model: getAgentModel(qaRouting.agent, agents, task),
392
+ model: getAgentModel(implAgent, agents, task),
336
393
  }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
337
- if (!qaResult.success) {
338
- pipeline.buildFixes++;
339
- if (pipeline.buildFixes >= MAX_BUILD_FIXES) {
340
- logger.error(`[${task.id}] QA failed ${MAX_BUILD_FIXES} times — skipping`);
341
- return false;
342
- }
343
- const decision = await orchestrator.handleFailure(task.title, 'test', qaResult.error ?? 'QA failed', pipeline.buildFixes);
344
- if (decision.action === 'skip')
345
- return false;
394
+ if (!implResult.success) {
395
+ logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
396
+ return false;
346
397
  }
347
- }
348
- else {
349
- logger.info(`[${task.id}] Skipping QA step (refactoring/config task)`);
350
- }
351
- // STEP 4: Review
352
- pipeline.step = 'review';
353
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'review' }));
354
- const reviewRouting = await orchestrator.routeTask(task, 'review', agents);
355
- let reviewPassed = false;
356
- while (pipeline.reviewCycles < MAX_REVIEW_CYCLES && !reviewPassed) {
357
- pipeline.reviewCycles++;
358
- const reviewResult = await spawnAgent(pool, workerId, {
359
- agent: reviewRouting.agent ?? undefined,
398
+ } // end if !shouldSkip('implement')
399
+ // For recovered pipelines at 'rework' step: run rework with context about what failed
400
+ if (resumeStep === 'rework') {
401
+ pipeline.step = 'rework';
402
+ appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'rework' }));
403
+ logger.info(`[${task.id}] Running rework step (recovered pipeline)`);
404
+ await spawnAgent(pool, workerId, {
405
+ agent: pipeline.assignedAgent ?? undefined,
360
406
  cwd: worktreePath,
361
- prompt: buildReviewPrompt({
407
+ prompt: buildReworkPrompt({
362
408
  task, worktreePath, projectDir,
363
- changedFiles: getChangedFiles(worktreePath),
364
- testingSection: pipeline.testingSection ?? undefined,
409
+ reviewFindings: 'This task was interrupted. The branch has existing work but may have build/lint failures, merge conflicts, or failing CI. Please:\n1. Run git fetch origin main && git rebase origin/main (resolve any conflicts)\n2. Run the build and lint scripts from package.json\n3. Fix any errors found\n4. Ensure all tests pass',
365
410
  }),
366
- model: getAgentModel(reviewRouting.agent, agents, task),
411
+ model: getAgentModel(pipeline.assignedAgent, agents, task),
367
412
  }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
368
- if (reviewResult.output.includes('**PASS**') || reviewResult.output.includes('PASS')) {
369
- reviewPassed = true;
413
+ }
414
+ // STEP 3: QA (E2E tests, integration tests, acceptance criteria verification)
415
+ // Only for tasks that need it — skip for pure refactoring, config changes, etc.
416
+ if (!shouldSkip('test')) {
417
+ const needsQA = task.type !== 'fullstack' || !isRefactoringTask(task);
418
+ if (needsQA) {
419
+ pipeline.step = 'test';
420
+ appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'test' }));
421
+ const changedFiles = getChangedFiles(worktreePath);
422
+ const qaRouting = await orchestrator.routeTask(task, 'test', agents);
423
+ const qaResult = await spawnAgent(pool, workerId, {
424
+ agent: qaRouting.agent ?? undefined,
425
+ cwd: worktreePath,
426
+ prompt: buildQAPrompt({
427
+ task, worktreePath, projectDir, changedFiles,
428
+ testingSection: pipeline.testingSection ?? undefined,
429
+ }),
430
+ model: getAgentModel(qaRouting.agent, agents, task),
431
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
432
+ if (!qaResult.success) {
433
+ pipeline.buildFixes++;
434
+ if (pipeline.buildFixes >= MAX_BUILD_FIXES) {
435
+ logger.error(`[${task.id}] QA failed ${MAX_BUILD_FIXES} times — skipping`);
436
+ return false;
437
+ }
438
+ const decision = await orchestrator.handleFailure(task.title, 'test', qaResult.error ?? 'QA failed', pipeline.buildFixes);
439
+ if (decision.action === 'skip')
440
+ return false;
441
+ }
370
442
  }
371
443
  else {
372
- pipeline.reviewFindings = reviewResult.output;
373
- if (pipeline.reviewCycles >= MAX_REVIEW_CYCLES) {
374
- logger.warn(`[${task.id}] Review limit (${MAX_REVIEW_CYCLES}) reached — proceeding anyway`);
444
+ logger.info(`[${task.id}] Skipping QA step (refactoring/config task)`);
445
+ }
446
+ } // end if !shouldSkip('test')
447
+ // STEP 4: Review
448
+ if (!shouldSkip('review')) {
449
+ pipeline.step = 'review';
450
+ appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'review' }));
451
+ const reviewRouting = await orchestrator.routeTask(task, 'review', agents);
452
+ let reviewPassed = false;
453
+ while (pipeline.reviewCycles < MAX_REVIEW_CYCLES && !reviewPassed) {
454
+ pipeline.reviewCycles++;
455
+ const reviewResult = await spawnAgent(pool, workerId, {
456
+ agent: reviewRouting.agent ?? undefined,
457
+ cwd: worktreePath,
458
+ prompt: buildReviewPrompt({
459
+ task, worktreePath, projectDir,
460
+ changedFiles: getChangedFiles(worktreePath),
461
+ testingSection: pipeline.testingSection ?? undefined,
462
+ }),
463
+ model: getAgentModel(reviewRouting.agent, agents, task),
464
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
465
+ if (reviewResult.output.includes('**PASS**') || reviewResult.output.includes('PASS')) {
375
466
  reviewPassed = true;
376
467
  }
377
468
  else {
378
- // Rework
379
- pipeline.step = 'rework';
380
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'rework' }));
381
- await spawnAgent(pool, workerId, {
382
- agent: pipeline.assignedAgent ?? undefined,
383
- cwd: worktreePath,
384
- prompt: buildReworkPrompt({
385
- task, worktreePath, projectDir,
386
- reviewFindings: pipeline.reviewFindings,
387
- }),
388
- model: getAgentModel(pipeline.assignedAgent, agents, task),
389
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
390
- pipeline.step = 're-review';
469
+ pipeline.reviewFindings = reviewResult.output;
470
+ if (pipeline.reviewCycles >= MAX_REVIEW_CYCLES) {
471
+ logger.warn(`[${task.id}] Review limit (${MAX_REVIEW_CYCLES}) reached — proceeding anyway`);
472
+ reviewPassed = true;
473
+ }
474
+ else {
475
+ // Rework
476
+ pipeline.step = 'rework';
477
+ appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'rework' }));
478
+ await spawnAgent(pool, workerId, {
479
+ agent: pipeline.assignedAgent ?? undefined,
480
+ cwd: worktreePath,
481
+ prompt: buildReworkPrompt({
482
+ task, worktreePath, projectDir,
483
+ reviewFindings: pipeline.reviewFindings,
484
+ }),
485
+ model: getAgentModel(pipeline.assignedAgent, agents, task),
486
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
487
+ pipeline.step = 're-review';
488
+ }
391
489
  }
392
490
  }
393
- }
491
+ } // end if !shouldSkip('review')
394
492
  // STEP 5: Commit
395
- pipeline.step = 'commit';
396
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'commit' }));
397
- // In local mode: update TODO.md checkbox inside worktree before commit
398
- if (state.taskMode === 'local') {
399
- const worktreeTodoPath = resolve(worktreePath, 'TODO.md');
400
- if (task.lineNumber > 0 && existsSync(worktreeTodoPath)) {
401
- const todoContent = readFileSync(worktreeTodoPath, 'utf-8');
402
- const updated = updateTaskCheckbox(todoContent, task.lineNumber, 'done');
403
- writeFileSync(worktreeTodoPath, updated, 'utf-8');
493
+ if (!shouldSkip('commit')) {
494
+ pipeline.step = 'commit';
495
+ appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'commit' }));
496
+ // In local mode: update TODO.md checkbox inside worktree before commit
497
+ if (state.taskMode === 'local') {
498
+ const worktreeTodoPath = resolve(worktreePath, 'TODO.md');
499
+ if (task.lineNumber > 0 && existsSync(worktreeTodoPath)) {
500
+ const todoContent = readFileSync(worktreeTodoPath, 'utf-8');
501
+ const updated = updateTaskCheckbox(todoContent, task.lineNumber, 'done');
502
+ writeFileSync(worktreeTodoPath, updated, 'utf-8');
503
+ }
404
504
  }
405
- }
505
+ const msg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
506
+ const sha = commitInWorktree(worktreePath, msg);
507
+ if (!sha) {
508
+ logger.warn(`[${task.id}] Nothing to commit`);
509
+ }
510
+ } // end if !shouldSkip('commit')
406
511
  const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
407
- const sha = commitInWorktree(worktreePath, commitMsg);
408
- if (!sha) {
409
- logger.warn(`[${task.id}] Nothing to commit`);
410
- }
411
512
  // STEP 6: Push + PR/MR flow
412
513
  const ciPlatform = detectCIPlatform(projectDir);
413
514
  if (ciPlatform !== 'none') {
@@ -590,176 +691,195 @@ function closeRecoveredIssue(projectDir, branch, logger) {
590
691
  logger.warn(`[recovery] Failed to close issue #${issueNum}: ${err.message}`);
591
692
  }
592
693
  }
593
- async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
694
+ /**
695
+ * Recovery: diagnose orphaned worktrees + open MRs, then re-inject them
696
+ * into the normal pipeline so standard agents handle them via contracts.
697
+ *
698
+ * This does NOT spawn custom recovery agents — it just figures out the
699
+ * right pipeline step and lets runTaskPipeline do the real work.
700
+ */
701
+ async function recoverOrphanedWorktrees(projectDir, state, logger, _deps) {
594
702
  const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
595
703
  const orphans = listOrphanedWorktrees(projectDir, knownPaths);
596
- if (orphans.length === 0)
704
+ const ciPlatform = detectCIPlatform(projectDir);
705
+ if (orphans.length === 0 && ciPlatform === 'none') {
706
+ logger.info('[recovery] No orphaned worktrees or open MRs');
597
707
  return;
598
- logger.info(`Found ${orphans.length} orphaned worktree(s) — attempting recovery`);
708
+ }
709
+ if (orphans.length > 0) {
710
+ logger.info(`[recovery] Found ${orphans.length} orphaned worktree(s)`);
711
+ }
712
+ // Phase 1: Orphaned worktrees → re-inject into pipelines
599
713
  for (const worktreePath of orphans) {
600
714
  try {
601
715
  const branch = getCurrentBranch(worktreePath);
602
716
  const alreadyMerged = isBranchMerged(projectDir, branch);
603
717
  if (alreadyMerged) {
604
- // Already merged — just clean up
605
- logger.info(`[recovery] ${branch} already merged — cleaning up worktree`);
718
+ logger.info(`[recovery] ${branch} already merged — cleaning up`);
606
719
  cleanupWorktree(projectDir, worktreePath, branch);
607
720
  deleteBranchRemote(projectDir, branch);
608
721
  closeRecoveredIssue(projectDir, branch, logger);
609
722
  continue;
610
723
  }
611
- // Check if worktree has commits ahead of main
612
724
  const hasChanges = hasUncommittedChanges(worktreePath);
613
725
  const changedFromMain = getChangedFiles(worktreePath);
614
726
  const hasCommits = changedFromMain.length > 0;
615
727
  if (!hasCommits && !hasChanges) {
616
- // No work done — clean up empty worktree
617
728
  logger.info(`[recovery] ${branch} has no changes — cleaning up`);
618
729
  cleanupWorktree(projectDir, worktreePath, branch);
730
+ deleteBranchRemote(projectDir, branch);
619
731
  continue;
620
732
  }
733
+ // Auto-commit any uncommitted changes so the pipeline can work with them
621
734
  if (hasChanges) {
622
- // Uncommitted changes — commit them first
623
735
  logger.info(`[recovery] ${branch} has uncommitted changes — committing`);
624
- commitInWorktree(worktreePath, `wip: auto-commit from recovery`);
625
- }
626
- // Try to merge
627
- logger.info(`[recovery] ${branch} has unmerged commits — attempting merge`);
628
- // Clean dirty main before merge
629
- // Clean main: abort any in-progress merge/rebase, stash uncommitted changes
630
- const stashed = cleanMainForMerge(projectDir);
631
- syncMain(projectDir);
632
- // Try PR flow first if remote exists
633
- const ciPlatform = detectCIPlatform(projectDir);
634
- if (ciPlatform !== 'none') {
635
- const pushed = pushWorktree(worktreePath);
636
- if (pushed) {
637
- try {
638
- const mainBranch = getMainBranch(projectDir);
639
- const prInfo = createPR({
640
- cwd: projectDir,
641
- platform: ciPlatform,
642
- branch,
643
- baseBranch: mainBranch,
644
- title: `feat: ${branch} (recovered)`,
645
- body: `Auto-recovered from orphaned worktree.\n\nThis PR was created by the scheduler to complete a previously interrupted task.`,
646
- });
647
- logger.info(`[recovery] PR created for ${branch}: ${prInfo.url}`);
648
- // Try to merge immediately if possible
649
- const status = getPRStatus(projectDir, ciPlatform, branch);
650
- if (status.mergeable) {
651
- mergePR(projectDir, ciPlatform, status.number);
652
- logger.info(`[recovery] ${branch} merged via platform`);
653
- syncMain(projectDir);
654
- cleanupWorktree(projectDir, worktreePath, branch);
655
- deleteBranchRemote(projectDir, branch);
656
- closeRecoveredIssue(projectDir, branch, logger);
657
- if (stashed)
658
- popStash(projectDir);
659
- continue;
660
- }
661
- // PR created but not yet mergeable — leave it for next iteration
662
- logger.info(`[recovery] PR for ${branch} not yet mergeable — will retry`);
663
- if (stashed)
664
- popStash(projectDir);
665
- continue;
666
- }
667
- catch (err) {
668
- logger.warn(`[recovery] PR creation failed for ${branch}: ${err.message} — trying local merge`);
669
- }
670
- }
736
+ commitInWorktree(worktreePath, 'wip: auto-commit from recovery', true);
671
737
  }
672
- // Local merge fallback
673
- const mergeResult = mergeToMain(projectDir, branch);
674
- if (mergeResult.success) {
675
- logger.info(`[recovery] ${branch} merged locally (${mergeResult.sha?.slice(0, 7)})`);
676
- appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery: ${branch}` }));
677
- closeRecoveredIssue(projectDir, branch, logger);
738
+ // Determine the right pipeline step to resume from
739
+ const issueNum = extractIssueFromBranch(branch);
740
+ const taskId = issueNum ? `#${issueNum}` : branch;
741
+ const step = diagnoseStep(projectDir, branch, ciPlatform);
742
+ logger.info(`[recovery] ${branch} re-injecting into pipeline at step "${step}"`);
743
+ // Register in pipelines so the normal task loop picks it up
744
+ state.pipelines[taskId] = {
745
+ taskId,
746
+ workerId: -1, // Will be assigned when picked up
747
+ worktreePath,
748
+ step,
749
+ architectPlan: null,
750
+ apiContract: null,
751
+ reviewFindings: null,
752
+ testingSection: null,
753
+ reviewCycles: 0,
754
+ ciFixes: 0,
755
+ buildFixes: 0,
756
+ assignedAgent: null,
757
+ prState: null,
758
+ };
759
+ }
760
+ catch (err) {
761
+ logger.error(`[recovery] Failed to process ${worktreePath}: ${err.message}`);
762
+ }
763
+ }
764
+ // Phase 2: Open MRs/PRs without local worktrees
765
+ if (ciPlatform !== 'none') {
766
+ recoverOpenMRs(projectDir, ciPlatform, state, logger);
767
+ }
768
+ }
769
+ /** Diagnose which pipeline step a recovered worktree should resume from */
770
+ function diagnoseStep(projectDir, branch, ciPlatform) {
771
+ // Check if there's an open MR/PR for this branch
772
+ if (ciPlatform !== 'none') {
773
+ try {
774
+ const prStatus = getPRStatus(projectDir, ciPlatform, branch);
775
+ if (prStatus.status === 'open') {
776
+ if (prStatus.ciStatus === 'failed')
777
+ return 'rework'; // CI failed → rework
778
+ if (prStatus.mergeable)
779
+ return 'merge'; // Ready to merge
780
+ if (prStatus.ciStatus === 'pending')
781
+ return 'pr-watch'; // CI still running
782
+ return 'rework'; // Not mergeable for other reasons
678
783
  }
679
- else if (mergeResult.conflict) {
680
- // mergeToMain already aborted the merge internally
681
- // Delegate conflict resolution to orchestrator AI
682
- logger.info(`[recovery] ${branch} has merge conflicts delegating to AI`);
683
- let resolved = false;
684
- try {
685
- const conflictDecision = await deps.orchestrator.handleMergeConflict(branch, mergeResult.conflictFiles ?? []);
686
- if (conflictDecision.action === 'resolve') {
687
- // Spawn agent to resolve conflicts in the worktree via rebase
688
- logger.info(`[recovery] Spawning agent to resolve conflicts in ${branch}`);
689
- const conflictPrompt = [
690
- `You are in a worktree for branch "${branch}".`,
691
- `Rebase this branch onto main and resolve all merge conflicts.`,
692
- ``,
693
- `Steps:`,
694
- `1. Run: git rebase main`,
695
- `2. For each conflict, read the conflicting files and resolve them sensibly`,
696
- `3. After resolving each file: git add <file>`,
697
- `4. Continue rebase: git rebase --continue`,
698
- `5. Repeat until rebase is complete`,
699
- ``,
700
- `Conflicting files: ${(mergeResult.conflictFiles ?? []).join(', ') || 'unknown'}`,
701
- ``,
702
- `Important: preserve the intent of both sides. When in doubt, prefer the feature branch changes.`,
703
- ].join('\n');
704
- const slot = deps.pool.idleSlot();
705
- if (slot) {
706
- await deps.pool.spawn(slot.id, {
707
- cwd: worktreePath,
708
- prompt: conflictPrompt,
709
- model: 'claude-sonnet-4-6',
710
- onMessage: deps.onMessage,
711
- });
712
- // Retry merge after agent resolved conflicts
713
- const retryResult = mergeToMain(projectDir, branch);
714
- if (retryResult.success) {
715
- logger.info(`[recovery] ${branch} merged after AI conflict resolution`);
716
- appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery-ai-resolve: ${branch}` }));
717
- resolved = true;
718
- }
719
- }
720
- }
721
- else if (conflictDecision.action === 'rebase') {
722
- const rebaseResult = rebaseOnMain(worktreePath, projectDir);
723
- if (rebaseResult.success) {
724
- const retryResult = mergeToMain(projectDir, branch);
725
- if (retryResult.success) {
726
- logger.info(`[recovery] ${branch} merged after rebase`);
727
- resolved = true;
728
- }
729
- }
730
- }
731
- // action === 'skip' → resolved stays false
732
- }
733
- catch (err) {
734
- logger.warn(`[recovery] AI conflict resolution failed for ${branch}: ${err.message}`);
735
- abortMerge(projectDir);
736
- }
737
- if (!resolved) {
738
- logger.error(`[recovery] ${branch} has unresolvable conflicts — skipping`);
739
- abortMerge(projectDir);
740
- if (stashed)
741
- popStash(projectDir);
742
- continue;
743
- }
784
+ if (prStatus.status === 'merged')
785
+ return 'done';
786
+ }
787
+ catch { /* no PR exists */ }
788
+ }
789
+ // No PR — branch has commits but hasn't been reviewed/pushed yet
790
+ // Start from review step (implementation is done since there are commits)
791
+ return 'review';
792
+ }
793
+ /** Re-inject open MRs that have no local worktree into the pipeline */
794
+ function recoverOpenMRs(projectDir, platform, state, logger) {
795
+ try {
796
+ const openMRs = getOpenMRs(projectDir, platform);
797
+ if (openMRs.length === 0)
798
+ return;
799
+ // Collect branches that already have worktrees or pipelines
800
+ const handledBranches = new Set();
801
+ for (const wt of listWorktrees(projectDir)) {
802
+ try {
803
+ handledBranches.add(getCurrentBranch(wt));
744
804
  }
745
- else {
746
- logger.error(`[recovery] ${branch} merge failed: ${mergeResult.error} — skipping`);
747
- abortMerge(projectDir);
748
- if (stashed)
749
- popStash(projectDir);
805
+ catch { /* skip */ }
806
+ }
807
+ for (const p of Object.values(state.pipelines)) {
808
+ try {
809
+ const branch = getCurrentBranch(p.worktreePath);
810
+ handledBranches.add(branch);
811
+ }
812
+ catch { /* skip */ }
813
+ }
814
+ for (const mr of openMRs) {
815
+ if (handledBranches.has(mr.branch))
816
+ continue;
817
+ const prStatus = getPRStatus(projectDir, platform, mr.branch);
818
+ if (prStatus.status === 'merged') {
819
+ logger.info(`[recovery] MR !${mr.number} already merged — closing issue`);
820
+ closeRecoveredIssue(projectDir, mr.branch, logger);
750
821
  continue;
751
822
  }
752
- // Post-merge cleanup: worktree + local branch + remote branch + close issue
753
- cleanupWorktree(projectDir, worktreePath, branch);
754
- deleteBranchRemote(projectDir, branch);
755
- closeRecoveredIssue(projectDir, branch, logger);
756
- if (stashed)
757
- popStash(projectDir);
823
+ // Create a worktree for the MR branch so the pipeline can work on it
824
+ const issueNum = extractIssueFromBranch(mr.branch);
825
+ const taskId = issueNum ? `#${issueNum}` : mr.branch;
826
+ // Skip if already completed/skipped
827
+ if (state.completedTasks.includes(taskId) || state.skippedTasks.includes(taskId))
828
+ continue;
829
+ let step;
830
+ if (prStatus.ciStatus === 'failed')
831
+ step = 'rework';
832
+ else if (prStatus.mergeable)
833
+ step = 'merge';
834
+ else if (prStatus.ciStatus === 'pending')
835
+ step = 'pr-watch';
836
+ else
837
+ step = 'rework';
838
+ logger.info(`[recovery] MR !${mr.number} (${mr.branch}) → creating worktree, pipeline at "${step}"`);
839
+ try {
840
+ const slug = mr.branch.replace(/^feat\//, '');
841
+ const worktreePath = createWorktree(projectDir, mr.branch);
842
+ state.pipelines[taskId] = {
843
+ taskId,
844
+ workerId: -1,
845
+ worktreePath,
846
+ step,
847
+ architectPlan: null,
848
+ apiContract: null,
849
+ reviewFindings: null,
850
+ testingSection: null,
851
+ reviewCycles: 0,
852
+ ciFixes: 0,
853
+ buildFixes: 0,
854
+ assignedAgent: null,
855
+ prState: { prNumber: mr.number, url: '', issueNumber: issueNum },
856
+ };
857
+ }
858
+ catch (err) {
859
+ logger.warn(`[recovery] Failed to create worktree for MR !${mr.number}: ${err.message?.split('\n')[0]}`);
860
+ }
758
861
  }
759
- catch (err) {
760
- logger.error(`[recovery] Failed to recover ${worktreePath}: ${err.message}`);
862
+ }
863
+ catch (err) {
864
+ logger.warn(`[recovery] MR scan failed: ${err.message?.split('\n')[0]}`);
865
+ }
866
+ }
867
+ function getOpenMRs(projectDir, platform) {
868
+ try {
869
+ if (platform === 'github') {
870
+ const output = execFileSync('gh', ['pr', 'list', '--json', 'number,headRefName', '--state', 'open'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
871
+ const prs = JSON.parse(output);
872
+ return prs.map(p => ({ number: p.number, branch: p.headRefName }));
873
+ }
874
+ else {
875
+ const output = execFileSync('glab', ['mr', 'list', '--output', 'json'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
876
+ const mrs = JSON.parse(output);
877
+ return mrs.map(m => ({ number: m.iid, branch: m.source_branch }));
761
878
  }
762
879
  }
880
+ catch {
881
+ return [];
882
+ }
763
883
  }
764
884
  function loadTasksJson(path) {
765
885
  const content = readFileSync(path, 'utf-8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "2.3.10",
3
+ "version": "2.3.12",
4
4
  "author": "",
5
5
  "repository": {
6
6
  "type": "git",