create-claude-workspace 2.3.17 → 2.3.19

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.
@@ -15,7 +15,21 @@ export function createWorktree(projectDir, branchSlug, baseBranch) {
15
15
  if (existsSync(worktreePath)) {
16
16
  return worktreePath;
17
17
  }
18
- git(['worktree', 'add', worktreePath, '-b', branchSlug, base], projectDir);
18
+ // If the branch already exists (locally or from remote), check it out instead of creating
19
+ if (branchExists(projectDir, branchSlug)) {
20
+ git(['worktree', 'add', worktreePath, branchSlug], projectDir);
21
+ }
22
+ else {
23
+ // Try fetching from remote in case it exists only on origin
24
+ try {
25
+ git(['fetch', 'origin', branchSlug], projectDir, PUSH_TIMEOUT);
26
+ git(['worktree', 'add', worktreePath, '-b', branchSlug, `origin/${branchSlug}`], projectDir);
27
+ }
28
+ catch {
29
+ // Branch doesn't exist anywhere — create new from base
30
+ git(['worktree', 'add', worktreePath, '-b', branchSlug, base], projectDir);
31
+ }
32
+ }
19
33
  return worktreePath;
20
34
  }
21
35
  export function cleanupWorktree(projectDir, worktreePath, branch) {
@@ -216,6 +230,120 @@ export function isBranchMerged(projectDir, branch) {
216
230
  return false;
217
231
  }
218
232
  }
233
+ // ─── Branch cleanup ───
234
+ /**
235
+ * Find and clean up local branches that are fully merged into main
236
+ * and have no associated worktree. Also removes their remote counterparts.
237
+ * Returns the list of cleaned branch names.
238
+ */
239
+ export function cleanMergedBranches(projectDir) {
240
+ const main = getMainBranch(projectDir);
241
+ const cleaned = [];
242
+ // Get all local branches except main
243
+ let branches;
244
+ try {
245
+ const output = git(['branch', '--format', '%(refname:short)'], projectDir);
246
+ branches = output.split('\n').filter(b => b && b !== main);
247
+ }
248
+ catch {
249
+ return [];
250
+ }
251
+ // Get worktree branches to avoid cleaning active worktrees
252
+ const worktreeBranches = new Set();
253
+ try {
254
+ const wtOutput = git(['worktree', 'list', '--porcelain'], projectDir);
255
+ for (const line of wtOutput.split('\n')) {
256
+ if (line.startsWith('branch refs/heads/')) {
257
+ worktreeBranches.add(line.replace('branch refs/heads/', '').trim());
258
+ }
259
+ }
260
+ }
261
+ catch { /* ignore */ }
262
+ for (const branch of branches) {
263
+ if (worktreeBranches.has(branch))
264
+ continue;
265
+ if (isBranchMerged(projectDir, branch)) {
266
+ // Delete local branch
267
+ try {
268
+ git(['branch', '-d', branch], projectDir);
269
+ }
270
+ catch {
271
+ try {
272
+ git(['branch', '-D', branch], projectDir);
273
+ }
274
+ catch {
275
+ continue;
276
+ }
277
+ }
278
+ // Delete remote branch (best-effort)
279
+ deleteBranchRemote(projectDir, branch);
280
+ cleaned.push(branch);
281
+ }
282
+ }
283
+ return cleaned;
284
+ }
285
+ /**
286
+ * Clean up remote-tracking branches whose upstream has been deleted.
287
+ * Equivalent to `git fetch --prune`.
288
+ */
289
+ export function pruneRemoteBranches(projectDir) {
290
+ try {
291
+ git(['fetch', '--prune'], projectDir, PUSH_TIMEOUT);
292
+ }
293
+ catch { /* best-effort */ }
294
+ }
295
+ /**
296
+ * Find local branches that are NOT merged into main, NOT in any worktree,
297
+ * and NOT the main branch itself. These are potentially abandoned/orphaned
298
+ * work that needs AI analysis to decide what to do with them.
299
+ */
300
+ export function findStaleUnmergedBranches(projectDir) {
301
+ const main = getMainBranch(projectDir);
302
+ const stale = [];
303
+ let branches;
304
+ try {
305
+ const output = git(['branch', '--format', '%(refname:short)'], projectDir);
306
+ branches = output.split('\n').filter(b => b && b !== main);
307
+ }
308
+ catch {
309
+ return [];
310
+ }
311
+ // Get worktree branches to exclude
312
+ const worktreeBranches = new Set();
313
+ try {
314
+ const wtOutput = git(['worktree', 'list', '--porcelain'], projectDir);
315
+ for (const line of wtOutput.split('\n')) {
316
+ if (line.startsWith('branch refs/heads/')) {
317
+ worktreeBranches.add(line.replace('branch refs/heads/', '').trim());
318
+ }
319
+ }
320
+ }
321
+ catch { /* ignore */ }
322
+ for (const branch of branches) {
323
+ if (worktreeBranches.has(branch))
324
+ continue;
325
+ if (isBranchMerged(projectDir, branch))
326
+ continue;
327
+ // Get commit info
328
+ try {
329
+ const aheadOutput = git(['rev-list', '--count', `${main}..${branch}`], projectDir);
330
+ const commitsAhead = parseInt(aheadOutput, 10) || 0;
331
+ if (commitsAhead === 0)
332
+ continue; // No actual changes
333
+ const logOutput = git(['log', '-1', '--format=%s|||%aI', branch], projectDir);
334
+ const [lastCommitMsg, lastCommitDate] = logOutput.split('|||');
335
+ let hasRemote = false;
336
+ try {
337
+ git(['rev-parse', '--verify', `origin/${branch}`], projectDir);
338
+ hasRemote = true;
339
+ }
340
+ catch { /* no remote */ }
341
+ stale.push({ name: branch, commitsAhead, lastCommitMsg: lastCommitMsg ?? '', lastCommitDate: lastCommitDate ?? '', hasRemote });
342
+ }
343
+ catch { /* skip branches that can't be inspected */ }
344
+ }
345
+ return stale;
346
+ }
219
347
  // ─── Git identity ───
220
348
  export function hasGitIdentity(projectDir) {
221
349
  try {
@@ -23,18 +23,20 @@ export function createPR(opts) {
23
23
  ? `${opts.body}\n\nCloses #${opts.issueNumber}`
24
24
  : opts.body;
25
25
  if (opts.platform === 'github') {
26
- const output = run('gh', [
26
+ // gh pr create outputs the PR URL on success
27
+ const url = run('gh', [
27
28
  'pr', 'create',
28
29
  '--title', opts.title,
29
30
  '--body', body,
30
31
  '--base', opts.baseBranch,
31
32
  '--head', opts.branch,
32
- '--json', 'number,url',
33
33
  ], opts.cwd, WRITE_TIMEOUT);
34
- const pr = JSON.parse(output);
34
+ // Extract PR number from URL: https://github.com/owner/repo/pull/123
35
+ const prNumMatch = url.match(/\/pull\/(\d+)/);
36
+ const prNumber = prNumMatch ? parseInt(prNumMatch[1], 10) : 0;
35
37
  return {
36
- number: pr.number,
37
- url: pr.url,
38
+ number: prNumber,
39
+ url: url.trim(),
38
40
  status: 'open',
39
41
  ciStatus: 'pending',
40
42
  approvals: 0,
@@ -186,7 +188,6 @@ function getGitLabMRComments(cwd, mrIid) {
186
188
  return [];
187
189
  }
188
190
  }
189
- // ─── Merge PR ───
190
191
  export function mergePR(cwd, platform, prNumber, method = 'merge') {
191
192
  try {
192
193
  if (platform === 'github') {
@@ -199,10 +200,10 @@ export function mergePR(cwd, platform, prNumber, method = 'merge') {
199
200
  args.push('--squash');
200
201
  run('glab', args, cwd, WRITE_TIMEOUT);
201
202
  }
202
- return true;
203
+ return { success: true };
203
204
  }
204
- catch {
205
- return false;
205
+ catch (err) {
206
+ return { success: false, error: err.message?.split('\n')[0] };
206
207
  }
207
208
  }
208
209
  // ─── Issue label management ───
@@ -8,8 +8,8 @@ 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, deleteBranchRemote, } from './git/manager.mjs';
12
- import { createPR, getPRStatus, getPRComments, mergePR } from './git/pr-manager.mjs';
11
+ import { createWorktree, commitInWorktree, getChangedFiles, cleanupWorktree, mergeToMain, syncMain, pushWorktree, forcePushWorktree, rebaseOnMain, cleanMainForMerge, popStash, getMainBranch, listWorktrees, listOrphanedWorktrees, isBranchMerged, getCurrentBranch, hasUncommittedChanges, deleteBranchRemote, cleanMergedBranches, pruneRemoteBranches, findStaleUnmergedBranches, } from './git/manager.mjs';
12
+ import { createPR, getPRStatus, getPRComments, mergePR, closePR } from './git/pr-manager.mjs';
13
13
  import { scanAgents } from './agents/health-checker.mjs';
14
14
  import { detectCIPlatform, fetchFailureLogs } from './git/ci-watcher.mjs';
15
15
  import { createRelease } from './git/release.mjs';
@@ -200,13 +200,7 @@ export async function runIteration(deps) {
200
200
  const pipeline = state.pipelines[taskId];
201
201
  const result = await checkPRWatch(taskId, pipeline, projectDir, agents, deps);
202
202
  if (result === 'merged') {
203
- let branch;
204
- try {
205
- branch = getCurrentBranch(pipeline.worktreePath);
206
- }
207
- catch {
208
- branch = taskId;
209
- }
203
+ const branch = pipeline.branchSlug;
210
204
  logger.info(`[${taskId}] PR merged via platform`);
211
205
  appendEvent(projectDir, createEvent('pr_merged', { taskId }));
212
206
  syncMain(projectDir);
@@ -228,10 +222,11 @@ export async function runIteration(deps) {
228
222
  workDone = true;
229
223
  }
230
224
  else if (result === 'failed') {
231
- logger.error(`[${taskId}] PR failed — skipping task`);
225
+ logger.error(`[${taskId}] PR failed — all recovery strategies exhausted, skipping task`);
232
226
  pipeline.step = 'failed';
233
227
  state.skippedTasks.push(taskId);
234
228
  delete state.pipelines[taskId];
229
+ appendEvent(projectDir, createEvent('task_skipped', { taskId, detail: 'PR merge failed after all recovery attempts' }));
235
230
  workDone = true;
236
231
  }
237
232
  else if (result === 'rework') {
@@ -368,8 +363,8 @@ async function runTaskPipeline(task, workerId, agents, deps) {
368
363
  const existing = state.pipelines[task.id];
369
364
  const resumeStep = existing?.step;
370
365
  // Create worktree (returns existing path if already exists)
371
- const slug = existing ? getCurrentBranch(existing.worktreePath) : taskToSlug(task);
372
- const worktreePath = existing?.worktreePath ?? createWorktree(projectDir, taskToSlug(task));
366
+ const slug = existing?.branchSlug ?? taskToSlug(task);
367
+ const worktreePath = existing?.worktreePath ?? createWorktree(projectDir, slug);
373
368
  if (!existing) {
374
369
  logger.info(`[${task.id}] Worktree created: ${taskToSlug(task)}`);
375
370
  }
@@ -378,6 +373,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
378
373
  taskId: task.id,
379
374
  workerId,
380
375
  worktreePath,
376
+ branchSlug: slug,
381
377
  step: 'plan',
382
378
  architectPlan: null,
383
379
  apiContract: null,
@@ -388,6 +384,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
388
384
  buildFixes: 0,
389
385
  assignedAgent: null,
390
386
  prState: null,
387
+ approvalWaitingSince: null,
391
388
  };
392
389
  pipeline.workerId = workerId;
393
390
  state.pipelines[task.id] = pipeline;
@@ -563,6 +560,17 @@ async function runTaskPipeline(task, workerId, agents, deps) {
563
560
  logger.warn(`[${task.id}] Nothing to commit`);
564
561
  }
565
562
  } // end if !shouldSkip('commit')
563
+ // If branch has no changes vs main, there's nothing to push/merge — skip to done
564
+ const changedFromMain = getChangedFiles(worktreePath);
565
+ if (changedFromMain.length === 0) {
566
+ logger.warn(`[${task.id}] Branch has no changes vs main — nothing to merge`);
567
+ cleanupWorktree(projectDir, worktreePath, slug);
568
+ deleteBranchRemote(projectDir, slug);
569
+ pipeline.step = 'done';
570
+ clearSession(state, task.id);
571
+ delete state.pipelines[task.id];
572
+ return true; // Not a failure, just nothing to do
573
+ }
566
574
  const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
567
575
  // STEP 6: Push + PR/MR flow
568
576
  const ciPlatform = detectCIPlatform(projectDir);
@@ -582,7 +590,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
582
590
  pipeline.step = 'pr-create';
583
591
  appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'pr-create' }));
584
592
  const issueNum = extractIssueNumber(task.issueMarker);
585
- const mainBranch = getCurrentBranchFromProject(projectDir);
593
+ const mainBranch = getMainBranch(projectDir);
586
594
  try {
587
595
  const prInfo = createPR({
588
596
  cwd: projectDir,
@@ -789,6 +797,7 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
789
797
  taskId,
790
798
  workerId: -1, // Will be assigned when picked up
791
799
  worktreePath,
800
+ branchSlug: branch,
792
801
  step,
793
802
  architectPlan: null,
794
803
  apiContract: null,
@@ -799,6 +808,7 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
799
808
  buildFixes: 0,
800
809
  assignedAgent: null,
801
810
  prState: null,
811
+ approvalWaitingSince: null,
802
812
  };
803
813
  }
804
814
  catch (err) {
@@ -809,6 +819,104 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
809
819
  if (ciPlatform !== 'none') {
810
820
  recoverOpenMRs(projectDir, ciPlatform, state, logger);
811
821
  }
822
+ // Phase 3: Clean up merged branches (local + remote) and prune stale remote refs
823
+ pruneRemoteBranches(projectDir);
824
+ const cleaned = cleanMergedBranches(projectDir);
825
+ if (cleaned.length > 0) {
826
+ logger.info(`[recovery] Cleaned ${cleaned.length} merged branch(es): ${cleaned.join(', ')}`);
827
+ }
828
+ // Phase 4: Analyze stale unmerged branches (no worktree, no MR, not merged)
829
+ // These need AI to decide: re-inject, delete, or ignore
830
+ await analyzeStaleUnmergedBranches(projectDir, state, logger, _deps);
831
+ }
832
+ /**
833
+ * Phase 4: Find branches with unmerged commits that have no worktree and no open MR.
834
+ * Ask orchestrator AI to decide what to do with each one.
835
+ */
836
+ async function analyzeStaleUnmergedBranches(projectDir, state, logger, deps) {
837
+ const staleBranches = findStaleUnmergedBranches(projectDir);
838
+ if (staleBranches.length === 0)
839
+ return;
840
+ // Exclude branches already in pipelines
841
+ const pipelineBranches = new Set(Object.values(state.pipelines).map(p => p.branchSlug));
842
+ const unhandled = staleBranches.filter(b => !pipelineBranches.has(b.name));
843
+ if (unhandled.length === 0)
844
+ return;
845
+ logger.info(`[recovery] Found ${unhandled.length} stale unmerged branch(es) — consulting orchestrator`);
846
+ const branchSummary = unhandled.map(b => `- ${b.name}: ${b.commitsAhead} commit(s) ahead, last: "${b.lastCommitMsg}" (${b.lastCommitDate})${b.hasRemote ? ' [has remote]' : ' [local only]'}`).join('\n');
847
+ try {
848
+ const result = await deps.orchestrator.consult([
849
+ 'The following git branches have unmerged commits but no active worktree or open MR.',
850
+ 'For each branch, decide the action:',
851
+ '',
852
+ branchSummary,
853
+ '',
854
+ 'Respond with a JSON array of objects: { "branch": "name", "action": "reinject" | "delete" | "ignore", "reason": "..." }',
855
+ '- "reinject": branch has valuable work that should be completed — create worktree and resume pipeline',
856
+ '- "delete": branch is abandoned/superseded/duplicate — safe to remove',
857
+ '- "ignore": leave it alone for now (e.g. user may be working on it manually)',
858
+ ].join('\n'));
859
+ // Parse AI decision
860
+ const jsonMatch = result.output.match(/\[[\s\S]*\]/);
861
+ if (!jsonMatch) {
862
+ logger.warn('[recovery] Orchestrator did not return valid JSON for stale branches');
863
+ return;
864
+ }
865
+ const decisions = JSON.parse(jsonMatch[0]);
866
+ for (const d of decisions) {
867
+ const branchInfo = unhandled.find(b => b.name === d.branch);
868
+ if (!branchInfo)
869
+ continue;
870
+ if (d.action === 'delete') {
871
+ logger.info(`[recovery] Deleting stale branch "${d.branch}": ${d.reason}`);
872
+ try {
873
+ execFileSync('git', ['branch', '-D', d.branch], { cwd: projectDir, stdio: 'pipe', timeout: 10_000 });
874
+ }
875
+ catch { /* ignore */ }
876
+ deleteBranchRemote(projectDir, d.branch);
877
+ }
878
+ else if (d.action === 'reinject') {
879
+ logger.info(`[recovery] Re-injecting stale branch "${d.branch}": ${d.reason}`);
880
+ const issueNum = extractIssueFromBranch(d.branch);
881
+ const taskId = issueNum ? `#${issueNum}` : d.branch;
882
+ if (state.completedTasks.includes(taskId) || state.skippedTasks.includes(taskId)) {
883
+ logger.info(`[recovery] Task ${taskId} already completed/skipped — skipping re-injection`);
884
+ continue;
885
+ }
886
+ try {
887
+ const worktreePath = createWorktree(projectDir, d.branch);
888
+ const step = diagnoseStep(projectDir, d.branch, detectCIPlatform(projectDir));
889
+ state.pipelines[taskId] = {
890
+ taskId,
891
+ workerId: -1,
892
+ worktreePath,
893
+ branchSlug: d.branch,
894
+ step,
895
+ architectPlan: null,
896
+ apiContract: null,
897
+ reviewFindings: null,
898
+ testingSection: null,
899
+ reviewCycles: 0,
900
+ ciFixes: 0,
901
+ buildFixes: 0,
902
+ assignedAgent: null,
903
+ prState: null,
904
+ approvalWaitingSince: null,
905
+ };
906
+ logger.info(`[recovery] Branch "${d.branch}" re-injected at step "${step}"`);
907
+ }
908
+ catch (err) {
909
+ logger.warn(`[recovery] Failed to re-inject "${d.branch}": ${err.message?.split('\n')[0]}`);
910
+ }
911
+ }
912
+ else {
913
+ logger.info(`[recovery] Ignoring stale branch "${d.branch}": ${d.reason}`);
914
+ }
915
+ }
916
+ }
917
+ catch (err) {
918
+ logger.warn(`[recovery] Stale branch analysis failed: ${err.message?.split('\n')[0]}`);
919
+ }
812
920
  }
813
921
  /** Diagnose which pipeline step a recovered worktree should resume from */
814
922
  export function diagnoseStep(projectDir, branch, ciPlatform) {
@@ -860,7 +968,13 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
860
968
  continue;
861
969
  const prStatus = getPRStatus(projectDir, platform, mr.branch);
862
970
  if (prStatus.status === 'merged') {
863
- logger.info(`[recovery] MR !${mr.number} already merged — closing issue`);
971
+ logger.info(`[recovery] MR !${mr.number} already merged — cleaning up branch + closing issue`);
972
+ // Clean up local branch if it exists (no worktree, but branch may linger)
973
+ try {
974
+ execFileSync('git', ['branch', '-d', mr.branch], { cwd: projectDir, stdio: 'pipe', timeout: 10_000 });
975
+ }
976
+ catch { /* may not exist */ }
977
+ deleteBranchRemote(projectDir, mr.branch);
864
978
  closeRecoveredIssue(projectDir, mr.branch, logger);
865
979
  continue;
866
980
  }
@@ -881,12 +995,12 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
881
995
  step = 'rework';
882
996
  logger.info(`[recovery] MR !${mr.number} (${mr.branch}) → creating worktree, pipeline at "${step}"`);
883
997
  try {
884
- const slug = mr.branch.replace(/^feat\//, '');
885
998
  const worktreePath = createWorktree(projectDir, mr.branch);
886
999
  state.pipelines[taskId] = {
887
1000
  taskId,
888
1001
  workerId: -1,
889
1002
  worktreePath,
1003
+ branchSlug: mr.branch,
890
1004
  step,
891
1005
  architectPlan: null,
892
1006
  apiContract: null,
@@ -897,6 +1011,7 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
897
1011
  buildFixes: 0,
898
1012
  assignedAgent: null,
899
1013
  prState: { prNumber: mr.number, url: '', issueNumber: issueNum },
1014
+ approvalWaitingSince: null,
900
1015
  };
901
1016
  }
902
1017
  catch (err) {
@@ -951,33 +1066,165 @@ async function checkPRWatch(taskId, pipeline, projectDir, agents, deps) {
951
1066
  const ciPlatform = detectCIPlatform(projectDir);
952
1067
  if (ciPlatform === 'none' || !pipeline.prState)
953
1068
  return 'failed';
954
- const branch = getCurrentBranch(pipeline.worktreePath);
1069
+ const branch = pipeline.branchSlug;
955
1070
  try {
956
1071
  const prStatus = getPRStatus(projectDir, ciPlatform, branch);
957
1072
  if (prStatus.status === 'merged')
958
1073
  return 'merged';
959
- if (prStatus.status === 'closed')
1074
+ // PR was closed (not merged) — try local merge as fallback before giving up
1075
+ if (prStatus.status === 'closed') {
1076
+ logger.warn(`[${taskId}] PR was closed without merging — attempting local merge fallback`);
1077
+ syncMain(projectDir);
1078
+ const stashed = cleanMainForMerge(projectDir);
1079
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1080
+ if (localMerge.success) {
1081
+ logger.info(`[${taskId}] Local merge succeeded after closed PR (${localMerge.sha?.slice(0, 7)})`);
1082
+ if (stashed)
1083
+ popStash(projectDir);
1084
+ return 'merged';
1085
+ }
1086
+ if (stashed)
1087
+ popStash(projectDir);
1088
+ logger.error(`[${taskId}] Local merge also failed after closed PR: ${localMerge.error}`);
960
1089
  return 'failed';
1090
+ }
961
1091
  // Ready to merge
962
1092
  if (prStatus.mergeable) {
963
1093
  logger.info(`[${taskId}] PR mergeable — merging`);
964
- const merged = mergePR(projectDir, ciPlatform, prStatus.number);
965
- return merged ? 'merged' : 'failed';
1094
+ const mergeResult = mergePR(projectDir, ciPlatform, prStatus.number);
1095
+ if (mergeResult.success)
1096
+ return 'merged';
1097
+ // Platform merge failed — diagnose and try to recover
1098
+ logger.warn(`[${taskId}] Platform merge failed: ${mergeResult.error} — attempting recovery`);
1099
+ // Step 1: Try rebase onto main and force-push
1100
+ const rebaseResult = rebaseOnMain(pipeline.worktreePath, projectDir);
1101
+ if (rebaseResult.success) {
1102
+ logger.info(`[${taskId}] Rebased successfully — force-pushing`);
1103
+ forcePushWorktree(pipeline.worktreePath);
1104
+ return 'rework'; // Will re-check PR status next iteration
1105
+ }
1106
+ // Step 2: Rebase failed — delegate conflict resolution to agent
1107
+ logger.warn(`[${taskId}] Rebase conflict — delegating to agent for resolution`);
1108
+ const slot = pool.idleSlot();
1109
+ if (slot) {
1110
+ // Sync main and get conflict info
1111
+ syncMain(projectDir);
1112
+ const conflictFiles = rebaseResult.error ?? 'unknown conflict';
1113
+ const task = {
1114
+ id: taskId, title: `Resolve merge conflict for ${taskId}`, phase: state.currentPhase,
1115
+ type: 'fullstack', complexity: 'S', dependsOn: [], issueMarker: taskId,
1116
+ kitUpgrade: false, lineNumber: 0, status: 'in-progress', changelog: 'fixed',
1117
+ };
1118
+ // Fetch the main branch into the worktree so agent can see what changed
1119
+ try {
1120
+ execFileSync('git', ['fetch', 'origin', getMainBranch(projectDir)], { cwd: pipeline.worktreePath, timeout: 60_000, stdio: 'pipe' });
1121
+ }
1122
+ catch { /* best-effort */ }
1123
+ const fixResult = await spawnAgent(pool, slot.id, {
1124
+ agent: pipeline.assignedAgent ?? undefined,
1125
+ cwd: pipeline.worktreePath,
1126
+ prompt: [
1127
+ `The branch "${pipeline.branchSlug}" has merge conflicts with main that must be resolved.`,
1128
+ `Conflict info: ${conflictFiles}`,
1129
+ '',
1130
+ 'Steps:',
1131
+ `1. Run: git rebase origin/${getMainBranch(projectDir)}`,
1132
+ '2. Resolve ALL conflicts in the reported files',
1133
+ '3. Stage resolved files with: git add <file>',
1134
+ '4. Continue rebase with: git rebase --continue',
1135
+ '5. If rebase is too complex, abort with: git rebase --abort, then merge main into the branch instead',
1136
+ '',
1137
+ 'Do NOT commit separately — the rebase/merge handles commits.',
1138
+ ].join('\n'),
1139
+ model: getAgentModel(pipeline.assignedAgent, agents, task),
1140
+ }, state, taskId, logger, onMessage, onSpawnStart, onSpawnEnd);
1141
+ if (fixResult.success) {
1142
+ forcePushWorktree(pipeline.worktreePath);
1143
+ logger.info(`[${taskId}] Agent resolved conflicts — force-pushed, will re-check next iteration`);
1144
+ return 'rework';
1145
+ }
1146
+ logger.warn(`[${taskId}] Agent failed to resolve conflicts`);
1147
+ }
1148
+ else {
1149
+ logger.info(`[${taskId}] No idle worker for conflict resolution — will retry next iteration`);
1150
+ return 'waiting';
1151
+ }
1152
+ // Step 3: Agent failed — fall back to local merge
1153
+ logger.warn(`[${taskId}] Falling back to local merge`);
1154
+ syncMain(projectDir);
1155
+ const stashed = cleanMainForMerge(projectDir);
1156
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1157
+ if (localMerge.success) {
1158
+ logger.info(`[${taskId}] Local merge succeeded (${localMerge.sha?.slice(0, 7)}) — closing PR`);
1159
+ if (stashed)
1160
+ popStash(projectDir);
1161
+ closePR(projectDir, ciPlatform, prStatus.number);
1162
+ return 'merged';
1163
+ }
1164
+ if (stashed)
1165
+ popStash(projectDir);
1166
+ logger.error(`[${taskId}] All merge strategies exhausted: ${localMerge.error}`);
1167
+ return 'failed';
966
1168
  }
967
1169
  // CI pending — keep waiting
968
1170
  if (prStatus.ciStatus === 'pending' || prStatus.ciStatus === 'not-found') {
969
1171
  logger.info(`[${taskId}] CI: ${prStatus.ciStatus} — waiting`);
970
1172
  return 'waiting';
971
1173
  }
972
- // CI passed but not mergeable waiting for approval
1174
+ // CI canceled force-push to re-trigger pipeline
1175
+ if (prStatus.ciStatus === 'canceled') {
1176
+ logger.warn(`[${taskId}] CI was canceled — force-pushing to re-trigger pipeline`);
1177
+ forcePushWorktree(pipeline.worktreePath);
1178
+ return 'rework';
1179
+ }
1180
+ // CI passed but not mergeable — waiting for approval (with timeout)
973
1181
  if (prStatus.ciStatus === 'passed' && !prStatus.mergeable) {
974
- logger.info(`[${taskId}] CI passed, not mergeable — waiting`);
975
- return 'waiting';
1182
+ if (!pipeline.approvalWaitingSince) {
1183
+ pipeline.approvalWaitingSince = Date.now();
1184
+ }
1185
+ const waitingMs = Date.now() - pipeline.approvalWaitingSince;
1186
+ const APPROVAL_TIMEOUT = 30 * 60_000; // 30 minutes
1187
+ if (waitingMs < APPROVAL_TIMEOUT) {
1188
+ const waitMin = Math.round(waitingMs / 60_000);
1189
+ logger.info(`[${taskId}] CI passed, waiting for approval (${waitMin}min/${APPROVAL_TIMEOUT / 60_000}min)`);
1190
+ return 'waiting';
1191
+ }
1192
+ // Timeout reached — fall back to local merge (solo mode behavior)
1193
+ logger.warn(`[${taskId}] Approval timeout (${APPROVAL_TIMEOUT / 60_000}min) — falling back to local merge`);
1194
+ syncMain(projectDir);
1195
+ const stashed = cleanMainForMerge(projectDir);
1196
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1197
+ if (localMerge.success) {
1198
+ logger.info(`[${taskId}] Local merge succeeded (${localMerge.sha?.slice(0, 7)}) — closing PR`);
1199
+ if (stashed)
1200
+ popStash(projectDir);
1201
+ closePR(projectDir, ciPlatform, prStatus.number);
1202
+ return 'merged';
1203
+ }
1204
+ if (stashed)
1205
+ popStash(projectDir);
1206
+ logger.error(`[${taskId}] Local merge failed after approval timeout: ${localMerge.error}`);
1207
+ return 'failed';
976
1208
  }
1209
+ // Reset approval timer if CI status changed from passed
1210
+ pipeline.approvalWaitingSince = null;
977
1211
  // CI failed — delegate fix to implementing agent
978
1212
  if (prStatus.ciStatus === 'failed') {
979
1213
  if (pipeline.ciFixes >= MAX_CI_FIXES) {
980
- logger.error(`[${taskId}] CI fix limit (${MAX_CI_FIXES}) reached`);
1214
+ logger.warn(`[${taskId}] CI fix limit (${MAX_CI_FIXES}) reached — falling back to local merge`);
1215
+ syncMain(projectDir);
1216
+ const stashed = cleanMainForMerge(projectDir);
1217
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1218
+ if (localMerge.success) {
1219
+ logger.info(`[${taskId}] Local merge succeeded after CI fix limit (${localMerge.sha?.slice(0, 7)}) — closing PR`);
1220
+ if (stashed)
1221
+ popStash(projectDir);
1222
+ closePR(projectDir, ciPlatform, prStatus.number);
1223
+ return 'merged';
1224
+ }
1225
+ if (stashed)
1226
+ popStash(projectDir);
1227
+ logger.error(`[${taskId}] Local merge also failed: ${localMerge.error}`);
981
1228
  return 'failed';
982
1229
  }
983
1230
  pipeline.ciFixes++;
@@ -1063,7 +1310,7 @@ async function pollAndMergePR(task, pipeline, branch, platform, projectDir, work
1063
1310
  // Ready to merge — CI passed and mergeable
1064
1311
  if (prStatus.mergeable) {
1065
1312
  logger.info(`[${task.id}] PR mergeable — merging`);
1066
- return mergePR(projectDir, platform, prStatus.number);
1313
+ return mergePR(projectDir, platform, prStatus.number).success;
1067
1314
  }
1068
1315
  // CI still running or not started — poll
1069
1316
  if (prStatus.ciStatus === 'pending' || prStatus.ciStatus === 'not-found') {
@@ -1180,9 +1427,6 @@ export function extractIssueFromBranch(branch) {
1180
1427
  const match = branch.match(/#?(\d+)/);
1181
1428
  return match ? parseInt(match[1], 10) : null;
1182
1429
  }
1183
- function getCurrentBranchFromProject(projectDir) {
1184
- return getMainBranch(projectDir);
1185
- }
1186
1430
  function sleep(ms) {
1187
1431
  return new Promise(resolve => setTimeout(resolve, ms));
1188
1432
  }
@@ -290,8 +290,7 @@ export class TUI {
290
290
  return '';
291
291
  const cur = this.state.agents[this.state.agents.length - 1];
292
292
  const col = ANSI_COLORS[agentColor(cur)] || '';
293
- const name = cur.length > 24 ? cur.slice(0, 24) : cur;
294
- return `${col}${name.padEnd(25)}${RESET}`;
293
+ return `${col}${cur}${RESET} `;
295
294
  }
296
295
  // ─── Public API ───
297
296
  banner() {
@@ -304,10 +303,10 @@ export class TUI {
304
303
  }
305
304
  this.log('');
306
305
  }
307
- info(msg) { this.log(` ${this.prefix()}${ANSI_COLORS.gray}${ts()}${RESET} ${ANSI_COLORS.blue}ℹ${RESET} ${msg}`); }
308
- warn(msg) { this.log(` ${this.prefix()}${ANSI_COLORS.gray}${ts()}${RESET} ${ANSI_COLORS.yellow}⚠ ${msg}${RESET}`); }
309
- error(msg) { this.log(` ${this.prefix()}${ANSI_COLORS.gray}${ts()}${RESET} ${ANSI_COLORS.red}✗ ${msg}${RESET}`); }
310
- success(msg) { this.log(` ${this.prefix()}${ANSI_COLORS.gray}${ts()}${RESET} ${ANSI_COLORS.green}✓ ${msg}${RESET}`); }
306
+ info(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.blue}ℹ${RESET} ${msg}`); }
307
+ warn(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.yellow}⚠ ${msg}${RESET}`); }
308
+ error(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.red}✗ ${msg}${RESET}`); }
309
+ success(msg) { this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${ANSI_COLORS.green}✓ ${msg}${RESET}`); }
311
310
  setIteration(i, max) {
312
311
  this.state.iteration = i;
313
312
  this.state.maxIter = max;
@@ -363,7 +362,7 @@ export class TUI {
363
362
  }
364
363
  for (const block of content) {
365
364
  if (block.type === 'text' && block.text?.trim()) {
366
- this.log(` ${this.prefix()}${block.text}`, `TEXT: ${block.text}`);
365
+ this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${block.text}`, `TEXT: ${block.text}`);
367
366
  }
368
367
  if (block.type === 'tool_use')
369
368
  this.onToolUse(block);
@@ -383,7 +382,7 @@ export class TUI {
383
382
  const col = ANSI_COLORS[agentColor(type)] || '';
384
383
  // Don't push to agent stack — this is a sub-agent inside the SDK session.
385
384
  // Our external spawn lifecycle is managed by onSpawnStart/onSpawnEnd callbacks.
386
- this.log(` ${pre}${time} ${icon} ${col}${BOLD}${type}${RESET}${model ? ` ${ANSI_COLORS.gray}(${model})${RESET}` : ''} ${ANSI_COLORS.gray}${desc}${RESET}`, `AGENT: ${type} ${model} — ${desc}`);
385
+ this.log(` ${time} ${pre}${icon} ${col}${BOLD}${type}${RESET}${model ? ` ${ANSI_COLORS.gray}(${model})${RESET}` : ''} ${ANSI_COLORS.gray}${desc}${RESET}`, `AGENT: ${type} ${model} — ${desc}`);
387
386
  return;
388
387
  }
389
388
  let detail;
@@ -406,7 +405,7 @@ export class TUI {
406
405
  break;
407
406
  default: detail = `${ANSI_COLORS.gray}${JSON.stringify(input)}${RESET}`;
408
407
  }
409
- this.log(` ${pre}${time} ${icon} ${BOLD}${name}${RESET} ${detail}`, `TOOL: ${name} ${JSON.stringify(input)}`);
408
+ this.log(` ${time} ${pre}${icon} ${BOLD}${name}${RESET} ${detail}`, `TOOL: ${name} ${JSON.stringify(input)}`);
410
409
  }
411
410
  onToolResult(msg) {
412
411
  const content = msg.message?.content;
@@ -419,15 +418,16 @@ export class TUI {
419
418
  if (!output)
420
419
  continue;
421
420
  const pre = this.prefix();
421
+ const time = `${ANSI_COLORS.gray}${ts()}${RESET}`;
422
422
  if (block.is_error) {
423
- this.log(` ${pre} ${ANSI_COLORS.red}✗ ${output}${RESET}`, `ERROR: ${output}`);
423
+ this.log(` ${time} ${pre}${ANSI_COLORS.red}✗ ${output}${RESET}`, `ERROR: ${output}`);
424
424
  }
425
425
  else {
426
426
  const lines = output.split('\n');
427
427
  const summary = lines.length > 3
428
428
  ? `${lines.length} lines`
429
429
  : output.replace(/\n/g, ' ').trim();
430
- this.log(` ${pre} ${ANSI_COLORS.green}✓${RESET} ${DIM}${summary}${RESET}`, `OK: ${summary}`);
430
+ this.log(` ${time} ${pre}${ANSI_COLORS.green}✓${RESET} ${DIM}${summary}${RESET}`, `OK: ${summary}`);
431
431
  }
432
432
  }
433
433
  }
@@ -456,7 +456,7 @@ export class TUI {
456
456
  return;
457
457
  }
458
458
  if (msg.subtype === 'task_progress' && msg.description) {
459
- this.log(` ${this.prefix()} ${DIM}${msg.description}${RESET}`, `PROGRESS: ${msg.description}`);
459
+ this.log(` ${ANSI_COLORS.gray}${ts()}${RESET} ${this.prefix()}${DIM}${msg.description}${RESET}`, `PROGRESS: ${msg.description}`);
460
460
  return;
461
461
  }
462
462
  if (msg.subtype === 'api_retry') {
@@ -870,6 +870,71 @@ If a merge conflict occurs:
870
870
  4. Push: `git push origin HEAD`
871
871
  5. Then clean up: `git worktree remove .worktrees/feat/{slug} && git branch -d feat/{slug}`
872
872
 
873
+ ## DISCOVERED ISSUES Protocol
874
+
875
+ Agents encounter problems outside their current scope during work. This protocol gives them a structured way to report issues without breaking their flow, and ensures the orchestrator processes every report.
876
+
877
+ ### Agent-side: How agents report issues
878
+
879
+ Every agent (architect, implementer, reviewer, test-engineer) MAY include a `## DISCOVERED ISSUES` section at the END of their response. Format:
880
+
881
+ ```
882
+ ## DISCOVERED ISSUES
883
+
884
+ ### [NON-BLOCKER] Title describing the problem
885
+ - **Scope**: which agent should handle this (e.g., `deployment-engineer`, `backend-ts-architect`)
886
+ - **Type**: frontend | backend | fullstack | devops
887
+ - **Complexity**: S | M
888
+ - **Description**: What is broken, where, and what the fix should look like
889
+ - **Evidence**: error message, file:line, warning output
890
+
891
+ ### [BLOCKER] Title describing the blocking problem
892
+ - **Scope**: which agent should handle this
893
+ - **Type**: frontend | backend | fullstack | devops
894
+ - **Complexity**: S | M
895
+ - **Description**: What is broken and why it blocks the current task
896
+ - **Evidence**: error message, file:line, warning output
897
+ - **Impact**: What cannot proceed until this is resolved
898
+ ```
899
+
900
+ **Agent rules:**
901
+ - **In-scope problem** → fix it directly, do NOT add to DISCOVERED ISSUES (it's your job)
902
+ - **Out-of-scope, non-blocking** → add as `[NON-BLOCKER]`, then **continue and deliver your task**
903
+ - **Out-of-scope, blocking** → add as `[BLOCKER]`, finish what you can, then return. The orchestrator will handle delegation
904
+ - **Warnings count** — treat project warnings as issues unless they originate from inside a third-party package's own source code. Misconfiguration or misuse of a third-party tool is a project problem.
905
+ - **"Third-party issue" is narrow** — only valid when the bug is in the third-party package's source code itself
906
+ - **Never dismiss** — if you see a problem, either fix it or report it. "Pre-existing" and "not in scope" are not valid reasons to ignore a problem.
907
+
908
+ ### Orchestrator-side: Processing DISCOVERED ISSUES
909
+
910
+ **After EVERY agent delegation** (STEP 2, 3, 4, 5, 6, 7, 8), scan the agent's response for a `## DISCOVERED ISSUES` section. If present:
911
+
912
+ #### NON-BLOCKER items → create tasks, continue current workflow
913
+ 1. **Local mode**: Add each item to TODO.md in the CURRENT phase (after the current task). Format:
914
+ ```
915
+ - [ ] **[Title from DISCOVERED ISSUES]** — [Description]. Discovered during #[current-task-id] (Complexity: [S|M], Type: [type])
916
+ ```
917
+ 2. **Platform mode**: Delegate to `devops-integrator`: "Create issues for these discovered problems: [list items]. Label: `status::todo`, `discovered`. Milestone: current phase. Do NOT perform git operations."
918
+ 3. **Continue with the current task** — non-blockers do NOT interrupt the workflow.
919
+
920
+ #### BLOCKER items → resolve before continuing
921
+ 1. **Assess if truly blocking**: Can the current task still be completed without resolving this? If the agent completed their deliverable despite reporting `[BLOCKER]`, reclassify as `[NON-BLOCKER]` and process accordingly.
922
+ 2. **If blocking and in another agent's scope**: Delegate to the appropriate agent IMMEDIATELY (before continuing the current step). Include the evidence and description from the DISCOVERED ISSUES entry. This is an interruption to the current workflow — resume the current step after the blocker is resolved.
923
+ 3. **If blocking and unfixable now** (requires human action, external dependency, missing credentials): Mark the current task as `[~]` SKIPPED with reason referencing the blocker. Create the task for the blocker issue. Move to next task.
924
+ 4. **Max 1 blocker delegation per step** — if resolving a blocker reveals another blocker, skip the current task and create tasks for both blockers.
925
+
926
+ #### Deduplication
927
+ Before creating a task from a discovered issue:
928
+ - Check TODO.md / platform issues for existing tasks with similar title or description
929
+ - Check if the issue was already reported in a previous iteration (grep TODO.md for keywords)
930
+ - Skip duplicates — do NOT create redundant tasks
931
+
932
+ ### Including the protocol in delegation prompts
933
+
934
+ **Add this paragraph to ALL agent delegation prompts** (STEP 2, 3, 4, 6 — all Agent tool calls to specialist agents):
935
+
936
+ > "If you encounter any problems, warnings, or broken infrastructure outside the scope of this task, include a `## DISCOVERED ISSUES` section at the end of your response. Use `[NON-BLOCKER]` for issues that don't prevent you from completing this task, `[BLOCKER]` for issues that do. Format: title, scope (which agent), type (frontend/backend/fullstack/devops), complexity (S/M), description, evidence. Do not dismiss issues as pre-existing — report everything you find."
937
+
873
938
  ## When Stuck
874
939
 
875
940
  NEVER stay stuck. Escalation order:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "2.3.17",
3
+ "version": "2.3.19",
4
4
  "author": "",
5
5
  "repository": {
6
6
  "type": "git",