create-claude-workspace 2.3.11 → 2.3.13

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.
@@ -8,7 +8,7 @@ import { fetchOpenIssues, issueToTask, updateIssueStatus } from './tasks/issue-s
8
8
  import { buildGraph, getParallelBatches, isPhaseComplete, getNextPhase, isProjectComplete } from './tasks/queue.mjs';
9
9
  import { writeState, appendEvent, createEvent, rotateLog } from './state/state.mjs';
10
10
  import { recordSession, getSession, clearSession } from './state/session.mjs';
11
- import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, 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';
12
12
  import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
13
13
  import { scanAgents } from './agents/health-checker.mjs';
14
14
  import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
@@ -193,9 +193,72 @@ export async function runIteration(deps) {
193
193
  if (queued > 0) {
194
194
  logger.info(`Starting ${tasksToRun.length} tasks, ${queued} queued (waiting for workers)`);
195
195
  }
196
- // Process each task through the pipeline
196
+ // Process recovered pipelines first (from recovery phase)
197
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
198
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;
199
262
  const slot = pool.idleSlot();
200
263
  if (!slot)
201
264
  break;
@@ -246,12 +309,17 @@ export async function runIteration(deps) {
246
309
  async function runTaskPipeline(task, workerId, agents, deps) {
247
310
  const { pool, orchestrator, state, opts, logger, onMessage, onSpawnStart, onSpawnEnd } = deps;
248
311
  const projectDir = opts.projectDir;
249
- // Create worktree
250
- const slug = taskToSlug(task);
251
- const worktreePath = createWorktree(projectDir, slug);
252
- logger.info(`[${task.id}] Worktree created: ${slug}`);
253
- // Initialize pipeline state
254
- 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 ?? {
255
323
  taskId: task.id,
256
324
  workerId,
257
325
  worktreePath,
@@ -266,149 +334,181 @@ async function runTaskPipeline(task, workerId, agents, deps) {
266
334
  assignedAgent: null,
267
335
  prState: null,
268
336
  };
337
+ pipeline.workerId = workerId;
269
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;
270
344
  try {
271
345
  // Route task to agent
272
- const routing = await orchestrator.routeTask(task, 'plan', agents);
346
+ const routing = await orchestrator.routeTask(task, shouldSkip('plan') ? skipTo : 'plan', agents);
273
347
  pipeline.assignedAgent = routing.agent;
274
- if (routing.create) {
275
- // Auto-create new agent
348
+ if (routing.create && !shouldSkip('plan')) {
276
349
  await createAgentFile(projectDir, routing.create);
277
350
  pipeline.assignedAgent = routing.create.name;
278
351
  appendEvent(projectDir, createEvent('agent_created', { agentType: routing.create.name }));
279
352
  }
280
- // STEP 1: Plan
281
- pipeline.step = 'plan';
282
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'plan' }));
283
- const planResult = await spawnAgent(pool, workerId, {
284
- agent: pipeline.assignedAgent ?? undefined,
285
- cwd: worktreePath,
286
- prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
287
- model: getAgentModel(pipeline.assignedAgent, agents, task),
288
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
289
- if (!planResult.success) {
290
- logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
291
- return false;
353
+ if (shouldSkip('plan')) {
354
+ logger.info(`[${task.id}] Resuming from step "${skipTo}" with agent ${pipeline.assignedAgent ?? 'default'}`);
292
355
  }
293
- pipeline.architectPlan = planResult.output;
294
- pipeline.testingSection = extractSection(planResult.output, 'TESTING');
295
- // Check for split recommendation
296
- if (task.complexity === 'L' && planResult.output.includes('SPLIT RECOMMENDATION') && !planResult.output.includes('No split needed')) {
297
- logger.info(`[${task.id}] L-task split recommended deferring to decomposition`);
298
- // 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
+ }
299
375
  }
300
376
  // STEP 2: Implement (includes unit tests + build/lint)
301
- pipeline.step = 'implement';
302
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'implement' }));
303
- // Re-route to implementation agent (may differ from planning agent)
304
- // e.g., ui-engineer plans angular-engineer implements
305
- const implRouting = await orchestrator.routeTask(task, 'implement', agents);
306
- const implAgent = implRouting.agent ?? pipeline.assignedAgent;
307
- const implResult = await spawnAgent(pool, workerId, {
308
- agent: implAgent ?? undefined,
309
- cwd: worktreePath,
310
- prompt: buildImplementPrompt({
311
- task, worktreePath, projectDir,
312
- architectPlan: pipeline.architectPlan,
313
- apiContract: pipeline.apiContract ?? undefined,
314
- }),
315
- model: getAgentModel(implAgent, agents, task),
316
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
317
- if (!implResult.success) {
318
- logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
319
- return false;
320
- }
321
- // STEP 3: QA (E2E tests, integration tests, acceptance criteria verification)
322
- // Only for tasks that need it — skip for pure refactoring, config changes, etc.
323
- const needsQA = task.type !== 'fullstack' || !isRefactoringTask(task);
324
- if (needsQA) {
325
- pipeline.step = 'test';
326
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'test' }));
327
- const changedFiles = getChangedFiles(worktreePath);
328
- const qaRouting = await orchestrator.routeTask(task, 'test', agents);
329
- const qaResult = await spawnAgent(pool, workerId, {
330
- 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,
331
386
  cwd: worktreePath,
332
- prompt: buildQAPrompt({
333
- task, worktreePath, projectDir, changedFiles,
334
- testingSection: pipeline.testingSection ?? undefined,
387
+ prompt: buildImplementPrompt({
388
+ task, worktreePath, projectDir,
389
+ architectPlan: pipeline.architectPlan ?? undefined,
390
+ apiContract: pipeline.apiContract ?? undefined,
335
391
  }),
336
- model: getAgentModel(qaRouting.agent, agents, task),
392
+ model: getAgentModel(implAgent, agents, task),
337
393
  }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
338
- if (!qaResult.success) {
339
- pipeline.buildFixes++;
340
- if (pipeline.buildFixes >= MAX_BUILD_FIXES) {
341
- logger.error(`[${task.id}] QA failed ${MAX_BUILD_FIXES} times — skipping`);
342
- return false;
343
- }
344
- const decision = await orchestrator.handleFailure(task.title, 'test', qaResult.error ?? 'QA failed', pipeline.buildFixes);
345
- if (decision.action === 'skip')
346
- return false;
394
+ if (!implResult.success) {
395
+ logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
396
+ return false;
347
397
  }
348
- }
349
- else {
350
- logger.info(`[${task.id}] Skipping QA step (refactoring/config task)`);
351
- }
352
- // STEP 4: Review
353
- pipeline.step = 'review';
354
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'review' }));
355
- const reviewRouting = await orchestrator.routeTask(task, 'review', agents);
356
- let reviewPassed = false;
357
- while (pipeline.reviewCycles < MAX_REVIEW_CYCLES && !reviewPassed) {
358
- pipeline.reviewCycles++;
359
- const reviewResult = await spawnAgent(pool, workerId, {
360
- 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,
361
406
  cwd: worktreePath,
362
- prompt: buildReviewPrompt({
407
+ prompt: buildReworkPrompt({
363
408
  task, worktreePath, projectDir,
364
- changedFiles: getChangedFiles(worktreePath),
365
- 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',
366
410
  }),
367
- model: getAgentModel(reviewRouting.agent, agents, task),
411
+ model: getAgentModel(pipeline.assignedAgent, agents, task),
368
412
  }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
369
- if (reviewResult.output.includes('**PASS**') || reviewResult.output.includes('PASS')) {
370
- 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
+ }
371
442
  }
372
443
  else {
373
- pipeline.reviewFindings = reviewResult.output;
374
- if (pipeline.reviewCycles >= MAX_REVIEW_CYCLES) {
375
- 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')) {
376
466
  reviewPassed = true;
377
467
  }
378
468
  else {
379
- // Rework
380
- pipeline.step = 'rework';
381
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'rework' }));
382
- await spawnAgent(pool, workerId, {
383
- agent: pipeline.assignedAgent ?? undefined,
384
- cwd: worktreePath,
385
- prompt: buildReworkPrompt({
386
- task, worktreePath, projectDir,
387
- reviewFindings: pipeline.reviewFindings,
388
- }),
389
- model: getAgentModel(pipeline.assignedAgent, agents, task),
390
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
391
- 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
+ }
392
489
  }
393
490
  }
394
- }
491
+ } // end if !shouldSkip('review')
395
492
  // STEP 5: Commit
396
- pipeline.step = 'commit';
397
- appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'commit' }));
398
- // In local mode: update TODO.md checkbox inside worktree before commit
399
- if (state.taskMode === 'local') {
400
- const worktreeTodoPath = resolve(worktreePath, 'TODO.md');
401
- if (task.lineNumber > 0 && existsSync(worktreeTodoPath)) {
402
- const todoContent = readFileSync(worktreeTodoPath, 'utf-8');
403
- const updated = updateTaskCheckbox(todoContent, task.lineNumber, 'done');
404
- 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
+ }
405
504
  }
406
- }
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')
407
511
  const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
408
- const sha = commitInWorktree(worktreePath, commitMsg);
409
- if (!sha) {
410
- logger.warn(`[${task.id}] Nothing to commit`);
411
- }
412
512
  // STEP 6: Push + PR/MR flow
413
513
  const ciPlatform = detectCIPlatform(projectDir);
414
514
  if (ciPlatform !== 'none') {
@@ -576,7 +676,7 @@ function isRefactoringTask(task) {
576
676
  }
577
677
  // ─── Orphaned worktree recovery ───
578
678
  /** Mark issue as done on the platform after a successful recovery merge */
579
- function closeRecoveredIssue(projectDir, branch, logger) {
679
+ export function closeRecoveredIssue(projectDir, branch, logger) {
580
680
  const platform = detectCIPlatform(projectDir);
581
681
  if (platform === 'none')
582
682
  return;
@@ -591,21 +691,28 @@ function closeRecoveredIssue(projectDir, branch, logger) {
591
691
  logger.warn(`[recovery] Failed to close issue #${issueNum}: ${err.message}`);
592
692
  }
593
693
  }
594
- 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
+ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps) {
595
702
  const knownPaths = Object.values(state.pipelines).map(p => p.worktreePath);
596
703
  const orphans = listOrphanedWorktrees(projectDir, knownPaths);
597
704
  const ciPlatform = detectCIPlatform(projectDir);
598
- if (orphans.length === 0) {
599
- logger.info('[recovery] No orphaned worktrees found');
705
+ if (orphans.length === 0 && ciPlatform === 'none') {
706
+ logger.info('[recovery] No orphaned worktrees or open MRs');
707
+ return;
600
708
  }
601
- else {
709
+ if (orphans.length > 0) {
602
710
  logger.info(`[recovery] Found ${orphans.length} orphaned worktree(s)`);
603
711
  }
604
- // Phase 1: Handle orphaned worktrees
712
+ // Phase 1: Orphaned worktrees → re-inject into pipelines
605
713
  for (const worktreePath of orphans) {
606
714
  try {
607
715
  const branch = getCurrentBranch(worktreePath);
608
- logger.info(`[recovery] Processing worktree: ${branch}`);
609
716
  const alreadyMerged = isBranchMerged(projectDir, branch);
610
717
  if (alreadyMerged) {
611
718
  logger.info(`[recovery] ${branch} already merged — cleaning up`);
@@ -617,159 +724,139 @@ async function recoverOrphanedWorktrees(projectDir, state, logger, deps) {
617
724
  const hasChanges = hasUncommittedChanges(worktreePath);
618
725
  const changedFromMain = getChangedFiles(worktreePath);
619
726
  const hasCommits = changedFromMain.length > 0;
620
- logger.info(`[recovery] ${branch}: uncommitted=${hasChanges}, commits ahead=${hasCommits} (${changedFromMain.length} files)`);
621
727
  if (!hasCommits && !hasChanges) {
622
728
  logger.info(`[recovery] ${branch} has no changes — cleaning up`);
623
729
  cleanupWorktree(projectDir, worktreePath, branch);
624
730
  deleteBranchRemote(projectDir, branch);
625
731
  continue;
626
732
  }
733
+ // Auto-commit any uncommitted changes so the pipeline can work with them
627
734
  if (hasChanges) {
628
735
  logger.info(`[recovery] ${branch} has uncommitted changes — committing`);
629
736
  commitInWorktree(worktreePath, 'wip: auto-commit from recovery', true);
630
737
  }
631
- // Delegate to AI agent: rebase onto main, fix any issues, then commit
632
- logger.info(`[recovery] Spawning AI agent to finalize ${branch}`);
633
- const slot = deps.pool.idleSlot();
634
- if (!slot) {
635
- logger.warn(`[recovery] No idle worker skipping ${branch} for now`);
636
- continue;
637
- }
638
- deps.onSpawnStart?.(`recovery-${branch}`);
639
- try {
640
- await deps.pool.spawn(slot.id, {
641
- cwd: worktreePath,
642
- prompt: [
643
- `You are in a git worktree for branch "${branch}".`,
644
- `This branch was interrupted and needs to be finalized for merge into main.`,
645
- ``,
646
- `Do the following:`,
647
- `1. Run: git fetch origin main && git rebase origin/main`,
648
- `2. If rebase has conflicts, resolve them sensibly (prefer feature branch intent)`,
649
- `3. After rebase, run the project's build/lint to check for errors:`,
650
- ` - Look at package.json for available scripts (build, lint, test)`,
651
- ` - Fix any build or lint errors in the code`,
652
- `4. Commit fixes: git add -A && git commit --no-verify -m "fix: resolve issues for merge"`,
653
- `5. Do NOT merge into main — just make the branch clean and ready`,
654
- ``,
655
- `Changed files in this branch: ${changedFromMain.join(', ')}`,
656
- ].join('\n'),
657
- model: 'claude-sonnet-4-6',
658
- onMessage: deps.onMessage,
659
- });
660
- }
661
- finally {
662
- deps.onSpawnEnd?.();
663
- }
664
- // Now try to merge
665
- logger.info(`[recovery] Agent done — attempting merge of ${branch}`);
666
- const stashed = cleanMainForMerge(projectDir);
667
- syncMain(projectDir);
668
- // Try platform merge (PR/MR) first
669
- let merged = false;
670
- if (ciPlatform !== 'none') {
671
- const pushed = pushWorktree(worktreePath) || forcePushWorktree(worktreePath);
672
- if (pushed) {
673
- try {
674
- const mainBranch = getMainBranch(projectDir);
675
- const issueNum = extractIssueFromBranch(branch);
676
- const prInfo = createPR({
677
- cwd: projectDir,
678
- platform: ciPlatform,
679
- branch,
680
- baseBranch: mainBranch,
681
- title: `feat: ${branch}`,
682
- body: 'Recovered and finalized by scheduler.',
683
- issueNumber: issueNum ?? undefined,
684
- });
685
- logger.info(`[recovery] PR: ${prInfo.url}`);
686
- if (prInfo.status === 'merged') {
687
- logger.info(`[recovery] ${branch} already merged via platform`);
688
- merged = true;
689
- }
690
- else {
691
- const prStatus = getPRStatus(projectDir, ciPlatform, branch);
692
- if (prStatus.mergeable) {
693
- merged = mergePR(projectDir, ciPlatform, prStatus.number);
694
- if (merged)
695
- logger.info(`[recovery] ${branch} merged via platform`);
696
- }
697
- else {
698
- logger.info(`[recovery] PR for ${branch} not yet mergeable (CI: ${prStatus.ciStatus}) — leaving open`);
699
- }
700
- }
701
- }
702
- catch (err) {
703
- logger.warn(`[recovery] PR flow failed for ${branch}: ${err.message?.split('\n')[0]}`);
704
- }
705
- }
706
- }
707
- // Local merge fallback
708
- if (!merged) {
709
- const mergeResult = mergeToMain(projectDir, branch);
710
- if (mergeResult.success) {
711
- logger.info(`[recovery] ${branch} merged locally (${mergeResult.sha?.slice(0, 7)})`);
712
- merged = true;
713
- }
714
- else {
715
- logger.error(`[recovery] ${branch} merge failed: ${mergeResult.error ?? 'unknown'}`);
716
- abortMerge(projectDir);
717
- }
718
- }
719
- if (merged) {
720
- syncMain(projectDir);
721
- cleanupWorktree(projectDir, worktreePath, branch);
722
- deleteBranchRemote(projectDir, branch);
723
- closeRecoveredIssue(projectDir, branch, logger);
724
- appendEvent(projectDir, createEvent('merge_completed', { detail: `recovery: ${branch}` }));
725
- }
726
- if (stashed)
727
- popStash(projectDir);
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
+ };
728
759
  }
729
760
  catch (err) {
730
- logger.error(`[recovery] Failed to recover ${worktreePath}: ${err.message}`);
761
+ logger.error(`[recovery] Failed to process ${worktreePath}: ${err.message}`);
731
762
  }
732
763
  }
733
- // Phase 2: Check for open MRs/PRs without local worktrees
764
+ // Phase 2: Open MRs/PRs without local worktrees
734
765
  if (ciPlatform !== 'none') {
735
- await recoverOpenMRs(projectDir, ciPlatform, state, logger);
766
+ recoverOpenMRs(projectDir, ciPlatform, state, logger);
736
767
  }
737
768
  }
738
- /** Find open MRs/PRs that don't have local worktrees and try to merge them */
739
- async function recoverOpenMRs(projectDir, platform, state, logger) {
769
+ /** Diagnose which pipeline step a recovered worktree should resume from */
770
+ export 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
783
+ }
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
+ export function recoverOpenMRs(projectDir, platform, state, logger) {
740
795
  try {
741
- // Get all open MRs
742
796
  const openMRs = getOpenMRs(projectDir, platform);
743
797
  if (openMRs.length === 0)
744
798
  return;
745
- const existingBranches = new Set(listWorktrees(projectDir).map(wt => {
799
+ // Collect branches that already have worktrees or pipelines
800
+ const handledBranches = new Set();
801
+ for (const wt of listWorktrees(projectDir)) {
746
802
  try {
747
- return getCurrentBranch(wt);
803
+ handledBranches.add(getCurrentBranch(wt));
748
804
  }
749
- catch {
750
- return '';
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);
751
811
  }
752
- }));
812
+ catch { /* skip */ }
813
+ }
753
814
  for (const mr of openMRs) {
754
- if (existingBranches.has(mr.branch))
755
- continue; // Handled in phase 1
756
- logger.info(`[recovery] Found open MR !${mr.number} for ${mr.branch} (no local worktree)`);
815
+ if (handledBranches.has(mr.branch))
816
+ continue;
757
817
  const prStatus = getPRStatus(projectDir, platform, mr.branch);
758
818
  if (prStatus.status === 'merged') {
759
- logger.info(`[recovery] MR !${mr.number} already merged`);
819
+ logger.info(`[recovery] MR !${mr.number} already merged — closing issue`);
760
820
  closeRecoveredIssue(projectDir, mr.branch, logger);
761
821
  continue;
762
822
  }
763
- if (prStatus.mergeable) {
764
- const merged = mergePR(projectDir, platform, prStatus.number);
765
- if (merged) {
766
- logger.info(`[recovery] MR !${mr.number} merged via platform`);
767
- syncMain(projectDir);
768
- closeRecoveredIssue(projectDir, mr.branch, logger);
769
- }
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
+ };
770
857
  }
771
- else {
772
- logger.info(`[recovery] MR !${mr.number} not mergeable (CI: ${prStatus.ciStatus}) — leaving open`);
858
+ catch (err) {
859
+ logger.warn(`[recovery] Failed to create worktree for MR !${mr.number}: ${err.message?.split('\n')[0]}`);
773
860
  }
774
861
  }
775
862
  }
@@ -777,7 +864,7 @@ async function recoverOpenMRs(projectDir, platform, state, logger) {
777
864
  logger.warn(`[recovery] MR scan failed: ${err.message?.split('\n')[0]}`);
778
865
  }
779
866
  }
780
- function getOpenMRs(projectDir, platform) {
867
+ export function getOpenMRs(projectDir, platform) {
781
868
  try {
782
869
  if (platform === 'github') {
783
870
  const output = execFileSync('gh', ['pr', 'list', '--json', 'number,headRefName', '--state', 'open'], { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000 }).trim();
@@ -910,7 +997,7 @@ function extractIssueNumber(marker) {
910
997
  return match ? parseInt(match[1], 10) : null;
911
998
  }
912
999
  /** Extract issue number from branch name like "feat/#94-description" or "feat/94-description" */
913
- function extractIssueFromBranch(branch) {
1000
+ export function extractIssueFromBranch(branch) {
914
1001
  const match = branch.match(/#?(\d+)/);
915
1002
  return match ? parseInt(match[1], 10) : null;
916
1003
  }
@@ -139,3 +139,156 @@ describe('pipeline state tracking', () => {
139
139
  expect(pipeline.reviewCycles < 5).toBe(true);
140
140
  });
141
141
  });
142
+ // ─── Test exports of recovery functions ───
143
+ describe('recovery function exports', () => {
144
+ it('exports diagnoseStep', async () => {
145
+ const mod = await import('./loop.mjs');
146
+ expect(typeof mod.diagnoseStep).toBe('function');
147
+ });
148
+ it('exports recoverOrphanedWorktrees', async () => {
149
+ const mod = await import('./loop.mjs');
150
+ expect(typeof mod.recoverOrphanedWorktrees).toBe('function');
151
+ });
152
+ it('exports recoverOpenMRs', async () => {
153
+ const mod = await import('./loop.mjs');
154
+ expect(typeof mod.recoverOpenMRs).toBe('function');
155
+ });
156
+ it('exports getOpenMRs', async () => {
157
+ const mod = await import('./loop.mjs');
158
+ expect(typeof mod.getOpenMRs).toBe('function');
159
+ });
160
+ it('exports extractIssueFromBranch', async () => {
161
+ const mod = await import('./loop.mjs');
162
+ expect(typeof mod.extractIssueFromBranch).toBe('function');
163
+ });
164
+ it('exports closeRecoveredIssue', async () => {
165
+ const mod = await import('./loop.mjs');
166
+ expect(typeof mod.closeRecoveredIssue).toBe('function');
167
+ });
168
+ });
169
+ // ─── Test shouldSkip pattern used in runTaskPipeline ───
170
+ describe('shouldSkip pattern (pipeline resume logic)', () => {
171
+ const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
172
+ function shouldSkip(step, resumeFrom) {
173
+ const skipToIndex = stepOrder.indexOf(resumeFrom);
174
+ return stepOrder.indexOf(step) < skipToIndex;
175
+ }
176
+ it('fresh pipeline (plan) skips nothing', () => {
177
+ for (const step of stepOrder) {
178
+ expect(shouldSkip(step, 'plan')).toBe(false);
179
+ }
180
+ });
181
+ it('resuming at "implement" skips only plan', () => {
182
+ expect(shouldSkip('plan', 'implement')).toBe(true);
183
+ expect(shouldSkip('implement', 'implement')).toBe(false);
184
+ expect(shouldSkip('test', 'implement')).toBe(false);
185
+ });
186
+ it('resuming at "review" skips plan, implement, test', () => {
187
+ expect(shouldSkip('plan', 'review')).toBe(true);
188
+ expect(shouldSkip('implement', 'review')).toBe(true);
189
+ expect(shouldSkip('test', 'review')).toBe(true);
190
+ expect(shouldSkip('review', 'review')).toBe(false);
191
+ expect(shouldSkip('commit', 'review')).toBe(false);
192
+ expect(shouldSkip('merge', 'review')).toBe(false);
193
+ });
194
+ it('resuming at "merge" skips everything before merge', () => {
195
+ for (const step of stepOrder.slice(0, -1)) {
196
+ expect(shouldSkip(step, 'merge')).toBe(true);
197
+ }
198
+ expect(shouldSkip('merge', 'merge')).toBe(false);
199
+ });
200
+ it('steps not in stepOrder return -1 index (treated as "skip nothing")', () => {
201
+ // 'done' and 'failed' are not in stepOrder, so indexOf returns -1
202
+ // This means skipToIndex = -1, and all steps have index >= 0, so nothing is skipped
203
+ // unless the step itself is also not in the list
204
+ const skipToIndex = stepOrder.indexOf('done');
205
+ expect(skipToIndex).toBe(-1);
206
+ });
207
+ });
208
+ // ─── Test extractIssueFromBranch pattern ───
209
+ describe('extractIssueFromBranch pattern', () => {
210
+ // Mirrors the actual implementation: branch.match(/#?(\d+)/)
211
+ function extractIssueFromBranch(branch) {
212
+ const match = branch.match(/#?(\d+)/);
213
+ return match ? parseInt(match[1], 10) : null;
214
+ }
215
+ it('extracts from "feat/#42-auth"', () => {
216
+ expect(extractIssueFromBranch('feat/#42-auth')).toBe(42);
217
+ });
218
+ it('extracts from "feat/42-auth"', () => {
219
+ expect(extractIssueFromBranch('feat/42-auth')).toBe(42);
220
+ });
221
+ it('returns null for "feat/add-login"', () => {
222
+ expect(extractIssueFromBranch('feat/add-login')).toBeNull();
223
+ });
224
+ it('extracts first number from "feat/#42-fix-issue-99"', () => {
225
+ expect(extractIssueFromBranch('feat/#42-fix-issue-99')).toBe(42);
226
+ });
227
+ it('handles plain number branches like "42-auth"', () => {
228
+ expect(extractIssueFromBranch('42-auth')).toBe(42);
229
+ });
230
+ });
231
+ // ─── Test recovered pipeline identification pattern ───
232
+ describe('recovered pipeline identification', () => {
233
+ it('identifies recovered pipelines by workerId === -1', () => {
234
+ const state = emptyState(1);
235
+ state.pipelines['#42'] = {
236
+ taskId: '#42',
237
+ workerId: -1,
238
+ worktreePath: '/wt/feat-42',
239
+ step: 'review',
240
+ architectPlan: null,
241
+ apiContract: null,
242
+ reviewFindings: null,
243
+ testingSection: null,
244
+ reviewCycles: 0,
245
+ ciFixes: 0,
246
+ buildFixes: 0,
247
+ assignedAgent: null,
248
+ prState: null,
249
+ };
250
+ state.pipelines['p1-1'] = {
251
+ taskId: 'p1-1',
252
+ workerId: 0,
253
+ worktreePath: '/wt/feat-p1-1',
254
+ step: 'implement',
255
+ architectPlan: null,
256
+ apiContract: null,
257
+ reviewFindings: null,
258
+ testingSection: null,
259
+ reviewCycles: 0,
260
+ ciFixes: 0,
261
+ buildFixes: 0,
262
+ assignedAgent: null,
263
+ prState: null,
264
+ };
265
+ const recoveredIds = Object.keys(state.pipelines).filter(id => {
266
+ const p = state.pipelines[id];
267
+ return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
268
+ });
269
+ expect(recoveredIds).toEqual(['#42']);
270
+ });
271
+ it('excludes done and failed pipelines from recovery', () => {
272
+ const state = emptyState(1);
273
+ state.pipelines['#50'] = {
274
+ taskId: '#50',
275
+ workerId: -1,
276
+ worktreePath: '/wt/feat-50',
277
+ step: 'done',
278
+ architectPlan: null,
279
+ apiContract: null,
280
+ reviewFindings: null,
281
+ testingSection: null,
282
+ reviewCycles: 0,
283
+ ciFixes: 0,
284
+ buildFixes: 0,
285
+ assignedAgent: null,
286
+ prState: null,
287
+ };
288
+ const recoveredIds = Object.keys(state.pipelines).filter(id => {
289
+ const p = state.pipelines[id];
290
+ return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
291
+ });
292
+ expect(recoveredIds).toHaveLength(0);
293
+ });
294
+ });
@@ -0,0 +1,121 @@
1
+ // ─── Recovery & git workflow tests ───
2
+ // Tests for diagnoseStep, extractIssueFromBranch, getOpenMRs,
3
+ // closeRecoveredIssue, and shouldSkip pipeline resume logic.
4
+ //
5
+ // Pure function tests — no mocks needed.
6
+ // @ts-ignore bun:test
7
+ import { describe, it, expect } from 'bun:test';
8
+ import { diagnoseStep, extractIssueFromBranch, getOpenMRs, } from './loop.mjs';
9
+ // ─── diagnoseStep ───
10
+ // Note: diagnoseStep calls getPRStatus which calls execFileSync.
11
+ // For unit tests we test the logic indirectly — the function falls back
12
+ // to 'review' when getPRStatus throws (which happens when not in a real repo).
13
+ describe('diagnoseStep', () => {
14
+ it('returns "review" when CI platform is "none"', () => {
15
+ expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'none')).toBe('review');
16
+ });
17
+ it('returns "review" when getPRStatus throws (no PR/no repo)', () => {
18
+ // /nonexistent is not a repo, so getPRStatus will throw
19
+ expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'github')).toBe('review');
20
+ });
21
+ it('returns "review" for gitlab when no PR exists', () => {
22
+ expect(diagnoseStep('/nonexistent', 'feat/42-auth', 'gitlab')).toBe('review');
23
+ });
24
+ });
25
+ // ─── extractIssueFromBranch ───
26
+ describe('extractIssueFromBranch', () => {
27
+ it('extracts number from "feat/#94-description"', () => {
28
+ expect(extractIssueFromBranch('feat/#94-user-auth')).toBe(94);
29
+ });
30
+ it('extracts number from "feat/94-description"', () => {
31
+ expect(extractIssueFromBranch('feat/94-add-login')).toBe(94);
32
+ });
33
+ it('returns null for branches without numbers', () => {
34
+ expect(extractIssueFromBranch('feat/add-login-page')).toBeNull();
35
+ });
36
+ it('extracts first number from branch with multiple numbers', () => {
37
+ expect(extractIssueFromBranch('feat/#42-fix-issue-99')).toBe(42);
38
+ });
39
+ it('extracts number from simple format', () => {
40
+ expect(extractIssueFromBranch('#7')).toBe(7);
41
+ });
42
+ it('returns null for empty string', () => {
43
+ expect(extractIssueFromBranch('')).toBeNull();
44
+ });
45
+ });
46
+ // ─── getOpenMRs ───
47
+ describe('getOpenMRs', () => {
48
+ it('returns empty array when CLI not available', () => {
49
+ // /nonexistent is not a repo, so CLI will fail
50
+ expect(getOpenMRs('/nonexistent', 'github')).toEqual([]);
51
+ });
52
+ it('returns empty array for gitlab when CLI not available', () => {
53
+ expect(getOpenMRs('/nonexistent', 'gitlab')).toEqual([]);
54
+ });
55
+ });
56
+ // ─── shouldSkip pipeline resume (unit test of the pattern) ───
57
+ describe('shouldSkip pipeline resume', () => {
58
+ // Replicate the exact shouldSkip logic from runTaskPipeline
59
+ const stepOrder = ['plan', 'implement', 'test', 'review', 'rework', 'commit', 'pr-create', 'pr-watch', 'merge'];
60
+ function shouldSkip(step, resumeStep) {
61
+ const skipToIndex = stepOrder.indexOf(resumeStep);
62
+ return stepOrder.indexOf(step) < skipToIndex;
63
+ }
64
+ it('pipeline at "review" skips plan, implement, test', () => {
65
+ expect(shouldSkip('plan', 'review')).toBe(true);
66
+ expect(shouldSkip('implement', 'review')).toBe(true);
67
+ expect(shouldSkip('test', 'review')).toBe(true);
68
+ expect(shouldSkip('review', 'review')).toBe(false);
69
+ expect(shouldSkip('commit', 'review')).toBe(false);
70
+ });
71
+ it('pipeline at "rework" skips plan through review', () => {
72
+ expect(shouldSkip('plan', 'rework')).toBe(true);
73
+ expect(shouldSkip('implement', 'rework')).toBe(true);
74
+ expect(shouldSkip('test', 'rework')).toBe(true);
75
+ expect(shouldSkip('review', 'rework')).toBe(true);
76
+ expect(shouldSkip('rework', 'rework')).toBe(false);
77
+ });
78
+ it('pipeline at "merge" skips everything before merge', () => {
79
+ expect(shouldSkip('plan', 'merge')).toBe(true);
80
+ expect(shouldSkip('commit', 'merge')).toBe(true);
81
+ expect(shouldSkip('pr-watch', 'merge')).toBe(true);
82
+ expect(shouldSkip('merge', 'merge')).toBe(false);
83
+ });
84
+ it('pipeline at "plan" skips nothing (fresh)', () => {
85
+ expect(shouldSkip('plan', 'plan')).toBe(false);
86
+ expect(shouldSkip('implement', 'plan')).toBe(false);
87
+ expect(shouldSkip('review', 'plan')).toBe(false);
88
+ });
89
+ it('pipeline at "pr-watch" skips through pr-create', () => {
90
+ expect(shouldSkip('pr-create', 'pr-watch')).toBe(true);
91
+ expect(shouldSkip('pr-watch', 'pr-watch')).toBe(false);
92
+ expect(shouldSkip('merge', 'pr-watch')).toBe(false);
93
+ });
94
+ it('pipeline at "commit" skips through rework', () => {
95
+ expect(shouldSkip('rework', 'commit')).toBe(true);
96
+ expect(shouldSkip('commit', 'commit')).toBe(false);
97
+ });
98
+ it('step not in stepOrder is treated as before all steps (indexOf=-1)', () => {
99
+ // 'done' is not in stepOrder, indexOf returns -1, which is < any skipToIndex
100
+ expect(shouldSkip('done', 'review')).toBe(true);
101
+ // But if resumeStep is 'plan' (index 0), -1 < 0 is true
102
+ expect(shouldSkip('done', 'plan')).toBe(true);
103
+ });
104
+ });
105
+ // ─── Recovered pipeline identification ───
106
+ describe('recovered pipeline identification', () => {
107
+ it('workerId=-1 identifies recovered pipelines', () => {
108
+ const pipelines = {
109
+ '#1': { workerId: -1, step: 'review' },
110
+ '#2': { workerId: 0, step: 'implement' },
111
+ '#3': { workerId: -1, step: 'done' },
112
+ '#4': { workerId: -1, step: 'failed' },
113
+ '#5': { workerId: -1, step: 'rework' },
114
+ };
115
+ const recoveredIds = Object.keys(pipelines).filter(id => {
116
+ const p = pipelines[id];
117
+ return p.workerId === -1 && p.step !== 'done' && p.step !== 'failed';
118
+ });
119
+ expect(recoveredIds).toEqual(['#1', '#5']);
120
+ });
121
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "2.3.11",
3
+ "version": "2.3.13",
4
4
  "author": "",
5
5
  "repository": {
6
6
  "type": "git",