create-claude-workspace 2.3.18 → 2.3.20

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.
package/README.md CHANGED
@@ -65,6 +65,15 @@ Write to `.claude/scheduler/inbox.json` while the scheduler is running:
65
65
 
66
66
  The scheduler reads the inbox every iteration and processes messages immediately.
67
67
 
68
+ #### Proactive problem resolution
69
+
70
+ Agents never dismiss problems as "pre-existing." The scheduler injects a `report_issue` MCP tool into every agent. When an agent discovers an out-of-scope problem:
71
+
72
+ - **Non-blocker**: calls `report_issue(severity: 'non-blocker')` and continues working
73
+ - **Blocker**: calls `report_issue(severity: 'blocker')`, wraps up, and returns
74
+
75
+ The scheduler records the issue locally (`discovered.ndjson`) and creates a platform issue (GitHub/GitLab) if available. Discovered issues enter the task queue on the next iteration.
76
+
68
77
  ### npx Options
69
78
 
70
79
  ```bash
@@ -42,6 +42,8 @@ export class WorkerPool {
42
42
  queryOptions.agent = opts.agent;
43
43
  if (opts.model)
44
44
  queryOptions.model = opts.model;
45
+ if (opts.mcpServers)
46
+ queryOptions.mcpServers = opts.mcpServers;
45
47
  if (opts.resume) {
46
48
  queryOptions.resume = opts.resume;
47
49
  }
@@ -0,0 +1,54 @@
1
+ // ─── Platform issue creation (GitHub/GitLab) ───
2
+ // Creates issues on the remote platform for discovered problems.
3
+ // Non-fatal — returns null on failure (local record always exists).
4
+ import { execFileSync } from 'node:child_process';
5
+ const CLI_TIMEOUT = 30_000;
6
+ function run(cmd, args, cwd) {
7
+ return execFileSync(cmd, args, { cwd, timeout: CLI_TIMEOUT, stdio: 'pipe', encoding: 'utf-8' }).trim();
8
+ }
9
+ /**
10
+ * Create an issue on the remote platform.
11
+ * Returns the created issue info, or null if creation failed (non-fatal).
12
+ */
13
+ export function createPlatformIssue(cwd, platform, opts) {
14
+ try {
15
+ if (platform === 'github')
16
+ return createGitHubIssue(cwd, opts);
17
+ return createGitLabIssue(cwd, opts);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ function createGitHubIssue(cwd, opts) {
24
+ const args = ['issue', 'create', '--title', opts.title, '--body', opts.body];
25
+ for (const label of opts.labels) {
26
+ args.push('--label', label);
27
+ }
28
+ if (opts.milestone)
29
+ args.push('--milestone', opts.milestone);
30
+ const output = run('gh', args, cwd);
31
+ // gh issue create prints the URL: https://github.com/owner/repo/issues/42
32
+ const urlMatch = output.match(/https:\/\/github\.com\/[^/]+\/[^/]+\/issues\/(\d+)/);
33
+ if (!urlMatch)
34
+ return null;
35
+ return { number: parseInt(urlMatch[1], 10), url: urlMatch[0] };
36
+ }
37
+ function createGitLabIssue(cwd, opts) {
38
+ const args = ['issue', 'create', '--title', opts.title, '--description', opts.body];
39
+ if (opts.labels.length > 0) {
40
+ args.push('--label', opts.labels.join(','));
41
+ }
42
+ if (opts.milestone)
43
+ args.push('--milestone', opts.milestone);
44
+ const output = run('glab', args, cwd);
45
+ // glab outputs: Creating issue... or the URL
46
+ const urlMatch = output.match(/https:\/\/gitlab\.[^/]+\/[^/]+\/[^/]+\/-\/issues\/(\d+)/);
47
+ if (urlMatch)
48
+ return { number: parseInt(urlMatch[1], 10), url: urlMatch[0] };
49
+ // Fallback: look for issue number in output like "#42" or "issue #42"
50
+ const numMatch = output.match(/#(\d+)/);
51
+ if (numMatch)
52
+ return { number: parseInt(numMatch[1], 10), url: output };
53
+ return null;
54
+ }
@@ -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 {
@@ -188,7 +188,6 @@ function getGitLabMRComments(cwd, mrIid) {
188
188
  return [];
189
189
  }
190
190
  }
191
- // ─── Merge PR ───
192
191
  export function mergePR(cwd, platform, prNumber, method = 'merge') {
193
192
  try {
194
193
  if (platform === 'github') {
@@ -201,10 +200,10 @@ export function mergePR(cwd, platform, prNumber, method = 'merge') {
201
200
  args.push('--squash');
202
201
  run('glab', args, cwd, WRITE_TIMEOUT);
203
202
  }
204
- return true;
203
+ return { success: true };
205
204
  }
206
- catch {
207
- return false;
205
+ catch (err) {
206
+ return { success: false, error: err.message?.split('\n')[0] };
208
207
  }
209
208
  }
210
209
  // ─── Issue label management ───
@@ -14,6 +14,7 @@ import { runIteration } from './loop.mjs';
14
14
  import { checkAuth } from './agents/health-checker.mjs';
15
15
  import { pollForNewWork } from './util/idle-poll.mjs';
16
16
  import { TUI } from './ui/tui.mjs';
17
+ import { DiscoveredIssueStore } from './tools/report-issue.mjs';
17
18
  // ─── Args ───
18
19
  export function parseSchedulerArgs(argv) {
19
20
  const opts = { ...SCHEDULER_DEFAULTS };
@@ -281,6 +282,8 @@ export async function runScheduler(opts) {
281
282
  const onMessage = (msg) => tui.handleMessage(msg);
282
283
  const onSpawnStart = (name) => tui.pushAgent(name);
283
284
  const onSpawnEnd = () => tui.popAgent();
285
+ // Discovered issue store (shared across all agent spawns)
286
+ const discoveredIssueStore = new DiscoveredIssueStore();
284
287
  // Orchestrator client
285
288
  const orchestrator = new OrchestratorClient({
286
289
  pool,
@@ -317,6 +320,7 @@ export async function runScheduler(opts) {
317
320
  state,
318
321
  opts,
319
322
  logger,
323
+ discoveredIssueStore,
320
324
  onMessage,
321
325
  onSpawnStart,
322
326
  onSpawnEnd,
@@ -8,13 +8,15 @@ 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';
16
16
  import { processInbox, addTaskMessageToTask } from './tasks/inbox.mjs';
17
17
  import { buildPlanPrompt, buildImplementPrompt, buildQAPrompt, buildReviewPrompt, buildReworkPrompt, buildCIFixPrompt, buildPRCommentPrompt, } from './agents/prompt-builder.mjs';
18
+ import { discoveredIssueToTask } from './tools/report-issue.mjs';
19
+ import { createSchedulerToolServer } from './tools/scheduler-tools.mjs';
18
20
  const MAX_REVIEW_CYCLES = 5;
19
21
  const MAX_BUILD_FIXES = 3;
20
22
  const MAX_CI_FIXES = 3;
@@ -28,15 +30,16 @@ export async function runIteration(deps) {
28
30
  // Rotate log if needed
29
31
  rotateLog(projectDir);
30
32
  // Process inbox — immediate, non-blocking
33
+ const inboxTasks = [];
31
34
  const inboxMessages = processInbox(projectDir);
32
35
  for (const msg of inboxMessages) {
33
36
  if (msg.type === 'add-task') {
34
37
  const addMsg = msg;
35
38
  const nextId = `inbox-${Date.now()}`;
36
39
  const newTask = addTaskMessageToTask(addMsg, state.currentPhase, nextId);
40
+ inboxTasks.push(newTask);
37
41
  logger.info(`[inbox] New task: ${newTask.title}`);
38
42
  appendEvent(projectDir, createEvent('task_started', { taskId: nextId, detail: `inbox: ${newTask.title}` }));
39
- // Task will be picked up when we load tasks below
40
43
  }
41
44
  else if (msg.type === 'message') {
42
45
  const freeMsg = msg;
@@ -129,6 +132,18 @@ export async function runIteration(deps) {
129
132
  }
130
133
  }
131
134
  }
135
+ // Merge inbox tasks into loaded tasks
136
+ if (inboxTasks.length > 0) {
137
+ tasks.push(...inboxTasks);
138
+ }
139
+ // Drain discovered issues from agent report_issue tool calls (previous iteration)
140
+ const discovered = deps.discoveredIssueStore.drain();
141
+ for (const issue of discovered) {
142
+ const task = discoveredIssueToTask(issue, state.currentPhase);
143
+ tasks.push(task);
144
+ logger.info(`[discovered] ${issue.severity}: ${issue.title} (reported by ${issue.reportedBy})`);
145
+ appendEvent(projectDir, createEvent('issue_discovered', { taskId: task.id, detail: `${issue.severity}: ${issue.title}` }));
146
+ }
132
147
  // Reconcile task status with scheduler state (handles restart without --resume)
133
148
  for (const task of tasks) {
134
149
  if (state.completedTasks.includes(task.id) && task.status !== 'done') {
@@ -200,13 +215,7 @@ export async function runIteration(deps) {
200
215
  const pipeline = state.pipelines[taskId];
201
216
  const result = await checkPRWatch(taskId, pipeline, projectDir, agents, deps);
202
217
  if (result === 'merged') {
203
- let branch;
204
- try {
205
- branch = getCurrentBranch(pipeline.worktreePath);
206
- }
207
- catch {
208
- branch = taskId;
209
- }
218
+ const branch = pipeline.branchSlug;
210
219
  logger.info(`[${taskId}] PR merged via platform`);
211
220
  appendEvent(projectDir, createEvent('pr_merged', { taskId }));
212
221
  syncMain(projectDir);
@@ -228,10 +237,11 @@ export async function runIteration(deps) {
228
237
  workDone = true;
229
238
  }
230
239
  else if (result === 'failed') {
231
- logger.error(`[${taskId}] PR failed — skipping task`);
240
+ logger.error(`[${taskId}] PR failed — all recovery strategies exhausted, skipping task`);
232
241
  pipeline.step = 'failed';
233
242
  state.skippedTasks.push(taskId);
234
243
  delete state.pipelines[taskId];
244
+ appendEvent(projectDir, createEvent('task_skipped', { taskId, detail: 'PR merge failed after all recovery attempts' }));
235
245
  workDone = true;
236
246
  }
237
247
  else if (result === 'rework') {
@@ -266,6 +276,7 @@ export async function runIteration(deps) {
266
276
  issueMarker: taskId,
267
277
  kitUpgrade: false,
268
278
  lineNumber: 0,
279
+ source: 'todo',
269
280
  status: 'in-progress',
270
281
  changelog: 'changed',
271
282
  };
@@ -364,12 +375,16 @@ export async function runIteration(deps) {
364
375
  async function runTaskPipeline(task, workerId, agents, deps) {
365
376
  const { pool, orchestrator, state, opts, logger, onMessage, onSpawnStart, onSpawnEnd } = deps;
366
377
  const projectDir = opts.projectDir;
378
+ // Create per-pipeline MCP server with scheduler tools (report_issue, etc.)
379
+ const ciPlatform = detectCIPlatform(projectDir);
380
+ const mcpServer = createSchedulerToolServer(deps.discoveredIssueStore, task.id, projectDir, ciPlatform === 'none' ? 'none' : ciPlatform);
381
+ const mcpServers = { 'scheduler-tools': mcpServer };
367
382
  // Check for existing pipeline (recovered from previous run)
368
383
  const existing = state.pipelines[task.id];
369
384
  const resumeStep = existing?.step;
370
385
  // 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));
386
+ const slug = existing?.branchSlug ?? taskToSlug(task);
387
+ const worktreePath = existing?.worktreePath ?? createWorktree(projectDir, slug);
373
388
  if (!existing) {
374
389
  logger.info(`[${task.id}] Worktree created: ${taskToSlug(task)}`);
375
390
  }
@@ -378,6 +393,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
378
393
  taskId: task.id,
379
394
  workerId,
380
395
  worktreePath,
396
+ branchSlug: slug,
381
397
  step: 'plan',
382
398
  architectPlan: null,
383
399
  apiContract: null,
@@ -388,6 +404,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
388
404
  buildFixes: 0,
389
405
  assignedAgent: null,
390
406
  prState: null,
407
+ approvalWaitingSince: null,
391
408
  };
392
409
  pipeline.workerId = workerId;
393
410
  state.pipelines[task.id] = pipeline;
@@ -417,7 +434,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
417
434
  cwd: worktreePath,
418
435
  prompt: buildPlanPrompt({ task, worktreePath, projectDir }),
419
436
  model: getAgentModel(pipeline.assignedAgent, agents, task),
420
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
437
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd, mcpServers);
421
438
  if (!planResult.success) {
422
439
  logger.error(`[${task.id}] Planning failed: ${planResult.error}`);
423
440
  return false;
@@ -445,7 +462,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
445
462
  apiContract: pipeline.apiContract ?? undefined,
446
463
  }),
447
464
  model: getAgentModel(implAgent, agents, task),
448
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
465
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd, mcpServers);
449
466
  if (!implResult.success) {
450
467
  logger.error(`[${task.id}] Implementation failed: ${implResult.error}`);
451
468
  return false;
@@ -464,7 +481,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
464
481
  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',
465
482
  }),
466
483
  model: getAgentModel(pipeline.assignedAgent, agents, task),
467
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
484
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd, mcpServers);
468
485
  }
469
486
  // STEP 3: QA (E2E tests, integration tests, acceptance criteria verification)
470
487
  // Only for tasks that need it — skip for pure refactoring, config changes, etc.
@@ -483,7 +500,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
483
500
  testingSection: pipeline.testingSection ?? undefined,
484
501
  }),
485
502
  model: getAgentModel(qaRouting.agent, agents, task),
486
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
503
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd, mcpServers);
487
504
  if (!qaResult.success) {
488
505
  pipeline.buildFixes++;
489
506
  if (pipeline.buildFixes >= MAX_BUILD_FIXES) {
@@ -516,7 +533,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
516
533
  testingSection: pipeline.testingSection ?? undefined,
517
534
  }),
518
535
  model: getAgentModel(reviewRouting.agent, agents, task),
519
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
536
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd, mcpServers);
520
537
  if (reviewResult.output.includes('**PASS**') || reviewResult.output.includes('PASS')) {
521
538
  reviewPassed = true;
522
539
  }
@@ -538,7 +555,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
538
555
  reviewFindings: pipeline.reviewFindings,
539
556
  }),
540
557
  model: getAgentModel(pipeline.assignedAgent, agents, task),
541
- }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd);
558
+ }, state, task.id, logger, onMessage, onSpawnStart, onSpawnEnd, mcpServers);
542
559
  pipeline.step = 're-review';
543
560
  }
544
561
  }
@@ -563,6 +580,17 @@ async function runTaskPipeline(task, workerId, agents, deps) {
563
580
  logger.warn(`[${task.id}] Nothing to commit`);
564
581
  }
565
582
  } // end if !shouldSkip('commit')
583
+ // If branch has no changes vs main, there's nothing to push/merge — skip to done
584
+ const changedFromMain = getChangedFiles(worktreePath);
585
+ if (changedFromMain.length === 0) {
586
+ logger.warn(`[${task.id}] Branch has no changes vs main — nothing to merge`);
587
+ cleanupWorktree(projectDir, worktreePath, slug);
588
+ deleteBranchRemote(projectDir, slug);
589
+ pipeline.step = 'done';
590
+ clearSession(state, task.id);
591
+ delete state.pipelines[task.id];
592
+ return true; // Not a failure, just nothing to do
593
+ }
566
594
  const commitMsg = `feat: ${task.title}${task.issueMarker ? ` (${task.issueMarker})` : ''}`;
567
595
  // STEP 6: Push + PR/MR flow
568
596
  const ciPlatform = detectCIPlatform(projectDir);
@@ -582,7 +610,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
582
610
  pipeline.step = 'pr-create';
583
611
  appendEvent(projectDir, createEvent('step_changed', { taskId: task.id, step: 'pr-create' }));
584
612
  const issueNum = extractIssueNumber(task.issueMarker);
585
- const mainBranch = getCurrentBranchFromProject(projectDir);
613
+ const mainBranch = getMainBranch(projectDir);
586
614
  try {
587
615
  const prInfo = createPR({
588
616
  cwd: projectDir,
@@ -668,7 +696,7 @@ async function runTaskPipeline(task, workerId, agents, deps) {
668
696
  }
669
697
  }
670
698
  // ─── Helpers ───
671
- async function spawnAgent(pool, slotId, opts, state, taskId, logger, onMessage, onSpawnStart, onSpawnEnd) {
699
+ async function spawnAgent(pool, slotId, opts, state, taskId, logger, onMessage, onSpawnStart, onSpawnEnd, mcpServers) {
672
700
  const agentName = opts.agent ?? 'claude';
673
701
  onSpawnStart?.(agentName);
674
702
  // Check for existing session (resume on crash)
@@ -677,6 +705,7 @@ async function spawnAgent(pool, slotId, opts, state, taskId, logger, onMessage,
677
705
  ...opts,
678
706
  resume: existingSession ?? undefined,
679
707
  onMessage,
708
+ mcpServers,
680
709
  });
681
710
  onSpawnEnd?.();
682
711
  // Record session for crash recovery
@@ -789,6 +818,7 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
789
818
  taskId,
790
819
  workerId: -1, // Will be assigned when picked up
791
820
  worktreePath,
821
+ branchSlug: branch,
792
822
  step,
793
823
  architectPlan: null,
794
824
  apiContract: null,
@@ -799,6 +829,7 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
799
829
  buildFixes: 0,
800
830
  assignedAgent: null,
801
831
  prState: null,
832
+ approvalWaitingSince: null,
802
833
  };
803
834
  }
804
835
  catch (err) {
@@ -809,6 +840,104 @@ export async function recoverOrphanedWorktrees(projectDir, state, logger, _deps)
809
840
  if (ciPlatform !== 'none') {
810
841
  recoverOpenMRs(projectDir, ciPlatform, state, logger);
811
842
  }
843
+ // Phase 3: Clean up merged branches (local + remote) and prune stale remote refs
844
+ pruneRemoteBranches(projectDir);
845
+ const cleaned = cleanMergedBranches(projectDir);
846
+ if (cleaned.length > 0) {
847
+ logger.info(`[recovery] Cleaned ${cleaned.length} merged branch(es): ${cleaned.join(', ')}`);
848
+ }
849
+ // Phase 4: Analyze stale unmerged branches (no worktree, no MR, not merged)
850
+ // These need AI to decide: re-inject, delete, or ignore
851
+ await analyzeStaleUnmergedBranches(projectDir, state, logger, _deps);
852
+ }
853
+ /**
854
+ * Phase 4: Find branches with unmerged commits that have no worktree and no open MR.
855
+ * Ask orchestrator AI to decide what to do with each one.
856
+ */
857
+ async function analyzeStaleUnmergedBranches(projectDir, state, logger, deps) {
858
+ const staleBranches = findStaleUnmergedBranches(projectDir);
859
+ if (staleBranches.length === 0)
860
+ return;
861
+ // Exclude branches already in pipelines
862
+ const pipelineBranches = new Set(Object.values(state.pipelines).map(p => p.branchSlug));
863
+ const unhandled = staleBranches.filter(b => !pipelineBranches.has(b.name));
864
+ if (unhandled.length === 0)
865
+ return;
866
+ logger.info(`[recovery] Found ${unhandled.length} stale unmerged branch(es) — consulting orchestrator`);
867
+ 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');
868
+ try {
869
+ const result = await deps.orchestrator.consult([
870
+ 'The following git branches have unmerged commits but no active worktree or open MR.',
871
+ 'For each branch, decide the action:',
872
+ '',
873
+ branchSummary,
874
+ '',
875
+ 'Respond with a JSON array of objects: { "branch": "name", "action": "reinject" | "delete" | "ignore", "reason": "..." }',
876
+ '- "reinject": branch has valuable work that should be completed — create worktree and resume pipeline',
877
+ '- "delete": branch is abandoned/superseded/duplicate — safe to remove',
878
+ '- "ignore": leave it alone for now (e.g. user may be working on it manually)',
879
+ ].join('\n'));
880
+ // Parse AI decision
881
+ const jsonMatch = result.output.match(/\[[\s\S]*\]/);
882
+ if (!jsonMatch) {
883
+ logger.warn('[recovery] Orchestrator did not return valid JSON for stale branches');
884
+ return;
885
+ }
886
+ const decisions = JSON.parse(jsonMatch[0]);
887
+ for (const d of decisions) {
888
+ const branchInfo = unhandled.find(b => b.name === d.branch);
889
+ if (!branchInfo)
890
+ continue;
891
+ if (d.action === 'delete') {
892
+ logger.info(`[recovery] Deleting stale branch "${d.branch}": ${d.reason}`);
893
+ try {
894
+ execFileSync('git', ['branch', '-D', d.branch], { cwd: projectDir, stdio: 'pipe', timeout: 10_000 });
895
+ }
896
+ catch { /* ignore */ }
897
+ deleteBranchRemote(projectDir, d.branch);
898
+ }
899
+ else if (d.action === 'reinject') {
900
+ logger.info(`[recovery] Re-injecting stale branch "${d.branch}": ${d.reason}`);
901
+ const issueNum = extractIssueFromBranch(d.branch);
902
+ const taskId = issueNum ? `#${issueNum}` : d.branch;
903
+ if (state.completedTasks.includes(taskId) || state.skippedTasks.includes(taskId)) {
904
+ logger.info(`[recovery] Task ${taskId} already completed/skipped — skipping re-injection`);
905
+ continue;
906
+ }
907
+ try {
908
+ const worktreePath = createWorktree(projectDir, d.branch);
909
+ const step = diagnoseStep(projectDir, d.branch, detectCIPlatform(projectDir));
910
+ state.pipelines[taskId] = {
911
+ taskId,
912
+ workerId: -1,
913
+ worktreePath,
914
+ branchSlug: d.branch,
915
+ step,
916
+ architectPlan: null,
917
+ apiContract: null,
918
+ reviewFindings: null,
919
+ testingSection: null,
920
+ reviewCycles: 0,
921
+ ciFixes: 0,
922
+ buildFixes: 0,
923
+ assignedAgent: null,
924
+ prState: null,
925
+ approvalWaitingSince: null,
926
+ };
927
+ logger.info(`[recovery] Branch "${d.branch}" re-injected at step "${step}"`);
928
+ }
929
+ catch (err) {
930
+ logger.warn(`[recovery] Failed to re-inject "${d.branch}": ${err.message?.split('\n')[0]}`);
931
+ }
932
+ }
933
+ else {
934
+ logger.info(`[recovery] Ignoring stale branch "${d.branch}": ${d.reason}`);
935
+ }
936
+ }
937
+ }
938
+ catch (err) {
939
+ logger.warn(`[recovery] Stale branch analysis failed: ${err.message?.split('\n')[0]}`);
940
+ }
812
941
  }
813
942
  /** Diagnose which pipeline step a recovered worktree should resume from */
814
943
  export function diagnoseStep(projectDir, branch, ciPlatform) {
@@ -860,7 +989,13 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
860
989
  continue;
861
990
  const prStatus = getPRStatus(projectDir, platform, mr.branch);
862
991
  if (prStatus.status === 'merged') {
863
- logger.info(`[recovery] MR !${mr.number} already merged — closing issue`);
992
+ logger.info(`[recovery] MR !${mr.number} already merged — cleaning up branch + closing issue`);
993
+ // Clean up local branch if it exists (no worktree, but branch may linger)
994
+ try {
995
+ execFileSync('git', ['branch', '-d', mr.branch], { cwd: projectDir, stdio: 'pipe', timeout: 10_000 });
996
+ }
997
+ catch { /* may not exist */ }
998
+ deleteBranchRemote(projectDir, mr.branch);
864
999
  closeRecoveredIssue(projectDir, mr.branch, logger);
865
1000
  continue;
866
1001
  }
@@ -881,12 +1016,12 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
881
1016
  step = 'rework';
882
1017
  logger.info(`[recovery] MR !${mr.number} (${mr.branch}) → creating worktree, pipeline at "${step}"`);
883
1018
  try {
884
- const slug = mr.branch.replace(/^feat\//, '');
885
1019
  const worktreePath = createWorktree(projectDir, mr.branch);
886
1020
  state.pipelines[taskId] = {
887
1021
  taskId,
888
1022
  workerId: -1,
889
1023
  worktreePath,
1024
+ branchSlug: mr.branch,
890
1025
  step,
891
1026
  architectPlan: null,
892
1027
  apiContract: null,
@@ -897,6 +1032,7 @@ export function recoverOpenMRs(projectDir, platform, state, logger) {
897
1032
  buildFixes: 0,
898
1033
  assignedAgent: null,
899
1034
  prState: { prNumber: mr.number, url: '', issueNumber: issueNum },
1035
+ approvalWaitingSince: null,
900
1036
  };
901
1037
  }
902
1038
  catch (err) {
@@ -937,6 +1073,7 @@ function loadTasksJson(path) {
937
1073
  issueMarker: t.issueMarker ?? null,
938
1074
  kitUpgrade: t.kitUpgrade ?? false,
939
1075
  lineNumber: t.lineNumber ?? 0,
1076
+ source: t.source ?? 'todo',
940
1077
  changelog: t.changelog ?? 'added',
941
1078
  })));
942
1079
  }
@@ -951,33 +1088,165 @@ async function checkPRWatch(taskId, pipeline, projectDir, agents, deps) {
951
1088
  const ciPlatform = detectCIPlatform(projectDir);
952
1089
  if (ciPlatform === 'none' || !pipeline.prState)
953
1090
  return 'failed';
954
- const branch = getCurrentBranch(pipeline.worktreePath);
1091
+ const branch = pipeline.branchSlug;
955
1092
  try {
956
1093
  const prStatus = getPRStatus(projectDir, ciPlatform, branch);
957
1094
  if (prStatus.status === 'merged')
958
1095
  return 'merged';
959
- if (prStatus.status === 'closed')
1096
+ // PR was closed (not merged) — try local merge as fallback before giving up
1097
+ if (prStatus.status === 'closed') {
1098
+ logger.warn(`[${taskId}] PR was closed without merging — attempting local merge fallback`);
1099
+ syncMain(projectDir);
1100
+ const stashed = cleanMainForMerge(projectDir);
1101
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1102
+ if (localMerge.success) {
1103
+ logger.info(`[${taskId}] Local merge succeeded after closed PR (${localMerge.sha?.slice(0, 7)})`);
1104
+ if (stashed)
1105
+ popStash(projectDir);
1106
+ return 'merged';
1107
+ }
1108
+ if (stashed)
1109
+ popStash(projectDir);
1110
+ logger.error(`[${taskId}] Local merge also failed after closed PR: ${localMerge.error}`);
960
1111
  return 'failed';
1112
+ }
961
1113
  // Ready to merge
962
1114
  if (prStatus.mergeable) {
963
1115
  logger.info(`[${taskId}] PR mergeable — merging`);
964
- const merged = mergePR(projectDir, ciPlatform, prStatus.number);
965
- return merged ? 'merged' : 'failed';
1116
+ const mergeResult = mergePR(projectDir, ciPlatform, prStatus.number);
1117
+ if (mergeResult.success)
1118
+ return 'merged';
1119
+ // Platform merge failed — diagnose and try to recover
1120
+ logger.warn(`[${taskId}] Platform merge failed: ${mergeResult.error} — attempting recovery`);
1121
+ // Step 1: Try rebase onto main and force-push
1122
+ const rebaseResult = rebaseOnMain(pipeline.worktreePath, projectDir);
1123
+ if (rebaseResult.success) {
1124
+ logger.info(`[${taskId}] Rebased successfully — force-pushing`);
1125
+ forcePushWorktree(pipeline.worktreePath);
1126
+ return 'rework'; // Will re-check PR status next iteration
1127
+ }
1128
+ // Step 2: Rebase failed — delegate conflict resolution to agent
1129
+ logger.warn(`[${taskId}] Rebase conflict — delegating to agent for resolution`);
1130
+ const slot = pool.idleSlot();
1131
+ if (slot) {
1132
+ // Sync main and get conflict info
1133
+ syncMain(projectDir);
1134
+ const conflictFiles = rebaseResult.error ?? 'unknown conflict';
1135
+ const task = {
1136
+ id: taskId, title: `Resolve merge conflict for ${taskId}`, phase: state.currentPhase,
1137
+ type: 'fullstack', complexity: 'S', dependsOn: [], issueMarker: taskId,
1138
+ kitUpgrade: false, lineNumber: 0, source: 'todo', status: 'in-progress', changelog: 'fixed',
1139
+ };
1140
+ // Fetch the main branch into the worktree so agent can see what changed
1141
+ try {
1142
+ execFileSync('git', ['fetch', 'origin', getMainBranch(projectDir)], { cwd: pipeline.worktreePath, timeout: 60_000, stdio: 'pipe' });
1143
+ }
1144
+ catch { /* best-effort */ }
1145
+ const fixResult = await spawnAgent(pool, slot.id, {
1146
+ agent: pipeline.assignedAgent ?? undefined,
1147
+ cwd: pipeline.worktreePath,
1148
+ prompt: [
1149
+ `The branch "${pipeline.branchSlug}" has merge conflicts with main that must be resolved.`,
1150
+ `Conflict info: ${conflictFiles}`,
1151
+ '',
1152
+ 'Steps:',
1153
+ `1. Run: git rebase origin/${getMainBranch(projectDir)}`,
1154
+ '2. Resolve ALL conflicts in the reported files',
1155
+ '3. Stage resolved files with: git add <file>',
1156
+ '4. Continue rebase with: git rebase --continue',
1157
+ '5. If rebase is too complex, abort with: git rebase --abort, then merge main into the branch instead',
1158
+ '',
1159
+ 'Do NOT commit separately — the rebase/merge handles commits.',
1160
+ ].join('\n'),
1161
+ model: getAgentModel(pipeline.assignedAgent, agents, task),
1162
+ }, state, taskId, logger, onMessage, onSpawnStart, onSpawnEnd);
1163
+ if (fixResult.success) {
1164
+ forcePushWorktree(pipeline.worktreePath);
1165
+ logger.info(`[${taskId}] Agent resolved conflicts — force-pushed, will re-check next iteration`);
1166
+ return 'rework';
1167
+ }
1168
+ logger.warn(`[${taskId}] Agent failed to resolve conflicts`);
1169
+ }
1170
+ else {
1171
+ logger.info(`[${taskId}] No idle worker for conflict resolution — will retry next iteration`);
1172
+ return 'waiting';
1173
+ }
1174
+ // Step 3: Agent failed — fall back to local merge
1175
+ logger.warn(`[${taskId}] Falling back to local merge`);
1176
+ syncMain(projectDir);
1177
+ const stashed = cleanMainForMerge(projectDir);
1178
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1179
+ if (localMerge.success) {
1180
+ logger.info(`[${taskId}] Local merge succeeded (${localMerge.sha?.slice(0, 7)}) — closing PR`);
1181
+ if (stashed)
1182
+ popStash(projectDir);
1183
+ closePR(projectDir, ciPlatform, prStatus.number);
1184
+ return 'merged';
1185
+ }
1186
+ if (stashed)
1187
+ popStash(projectDir);
1188
+ logger.error(`[${taskId}] All merge strategies exhausted: ${localMerge.error}`);
1189
+ return 'failed';
966
1190
  }
967
1191
  // CI pending — keep waiting
968
1192
  if (prStatus.ciStatus === 'pending' || prStatus.ciStatus === 'not-found') {
969
1193
  logger.info(`[${taskId}] CI: ${prStatus.ciStatus} — waiting`);
970
1194
  return 'waiting';
971
1195
  }
972
- // CI passed but not mergeable waiting for approval
1196
+ // CI canceled force-push to re-trigger pipeline
1197
+ if (prStatus.ciStatus === 'canceled') {
1198
+ logger.warn(`[${taskId}] CI was canceled — force-pushing to re-trigger pipeline`);
1199
+ forcePushWorktree(pipeline.worktreePath);
1200
+ return 'rework';
1201
+ }
1202
+ // CI passed but not mergeable — waiting for approval (with timeout)
973
1203
  if (prStatus.ciStatus === 'passed' && !prStatus.mergeable) {
974
- logger.info(`[${taskId}] CI passed, not mergeable — waiting`);
975
- return 'waiting';
1204
+ if (!pipeline.approvalWaitingSince) {
1205
+ pipeline.approvalWaitingSince = Date.now();
1206
+ }
1207
+ const waitingMs = Date.now() - pipeline.approvalWaitingSince;
1208
+ const APPROVAL_TIMEOUT = 30 * 60_000; // 30 minutes
1209
+ if (waitingMs < APPROVAL_TIMEOUT) {
1210
+ const waitMin = Math.round(waitingMs / 60_000);
1211
+ logger.info(`[${taskId}] CI passed, waiting for approval (${waitMin}min/${APPROVAL_TIMEOUT / 60_000}min)`);
1212
+ return 'waiting';
1213
+ }
1214
+ // Timeout reached — fall back to local merge (solo mode behavior)
1215
+ logger.warn(`[${taskId}] Approval timeout (${APPROVAL_TIMEOUT / 60_000}min) — falling back to local merge`);
1216
+ syncMain(projectDir);
1217
+ const stashed = cleanMainForMerge(projectDir);
1218
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1219
+ if (localMerge.success) {
1220
+ logger.info(`[${taskId}] Local merge succeeded (${localMerge.sha?.slice(0, 7)}) — closing PR`);
1221
+ if (stashed)
1222
+ popStash(projectDir);
1223
+ closePR(projectDir, ciPlatform, prStatus.number);
1224
+ return 'merged';
1225
+ }
1226
+ if (stashed)
1227
+ popStash(projectDir);
1228
+ logger.error(`[${taskId}] Local merge failed after approval timeout: ${localMerge.error}`);
1229
+ return 'failed';
976
1230
  }
1231
+ // Reset approval timer if CI status changed from passed
1232
+ pipeline.approvalWaitingSince = null;
977
1233
  // CI failed — delegate fix to implementing agent
978
1234
  if (prStatus.ciStatus === 'failed') {
979
1235
  if (pipeline.ciFixes >= MAX_CI_FIXES) {
980
- logger.error(`[${taskId}] CI fix limit (${MAX_CI_FIXES}) reached`);
1236
+ logger.warn(`[${taskId}] CI fix limit (${MAX_CI_FIXES}) reached — falling back to local merge`);
1237
+ syncMain(projectDir);
1238
+ const stashed = cleanMainForMerge(projectDir);
1239
+ const localMerge = mergeToMain(projectDir, pipeline.branchSlug);
1240
+ if (localMerge.success) {
1241
+ logger.info(`[${taskId}] Local merge succeeded after CI fix limit (${localMerge.sha?.slice(0, 7)}) — closing PR`);
1242
+ if (stashed)
1243
+ popStash(projectDir);
1244
+ closePR(projectDir, ciPlatform, prStatus.number);
1245
+ return 'merged';
1246
+ }
1247
+ if (stashed)
1248
+ popStash(projectDir);
1249
+ logger.error(`[${taskId}] Local merge also failed: ${localMerge.error}`);
981
1250
  return 'failed';
982
1251
  }
983
1252
  pipeline.ciFixes++;
@@ -993,7 +1262,7 @@ async function checkPRWatch(taskId, pipeline, projectDir, agents, deps) {
993
1262
  const task = {
994
1263
  id: taskId, title: `Fix CI for ${taskId}`, phase: state.currentPhase,
995
1264
  type: 'fullstack', complexity: 'S', dependsOn: [], issueMarker: taskId,
996
- kitUpgrade: false, lineNumber: 0, status: 'in-progress', changelog: 'fixed',
1265
+ kitUpgrade: false, lineNumber: 0, source: 'todo', status: 'in-progress', changelog: 'fixed',
997
1266
  };
998
1267
  const fixResult = await spawnAgent(pool, slot.id, {
999
1268
  agent: pipeline.assignedAgent ?? undefined,
@@ -1025,7 +1294,7 @@ async function checkPRWatch(taskId, pipeline, projectDir, agents, deps) {
1025
1294
  const task = {
1026
1295
  id: taskId, title: `Address PR comments for ${taskId}`, phase: state.currentPhase,
1027
1296
  type: 'fullstack', complexity: 'S', dependsOn: [], issueMarker: taskId,
1028
- kitUpgrade: false, lineNumber: 0, status: 'in-progress', changelog: 'fixed',
1297
+ kitUpgrade: false, lineNumber: 0, source: 'todo', status: 'in-progress', changelog: 'fixed',
1029
1298
  };
1030
1299
  const result = await spawnAgent(pool, slot.id, {
1031
1300
  agent: pipeline.assignedAgent ?? undefined,
@@ -1063,7 +1332,7 @@ async function pollAndMergePR(task, pipeline, branch, platform, projectDir, work
1063
1332
  // Ready to merge — CI passed and mergeable
1064
1333
  if (prStatus.mergeable) {
1065
1334
  logger.info(`[${task.id}] PR mergeable — merging`);
1066
- return mergePR(projectDir, platform, prStatus.number);
1335
+ return mergePR(projectDir, platform, prStatus.number).success;
1067
1336
  }
1068
1337
  // CI still running or not started — poll
1069
1338
  if (prStatus.ciStatus === 'pending' || prStatus.ciStatus === 'not-found') {
@@ -1180,9 +1449,6 @@ export function extractIssueFromBranch(branch) {
1180
1449
  const match = branch.match(/#?(\d+)/);
1181
1450
  return match ? parseInt(match[1], 10) : null;
1182
1451
  }
1183
- function getCurrentBranchFromProject(projectDir) {
1184
- return getMainBranch(projectDir);
1185
- }
1186
1452
  function sleep(ms) {
1187
1453
  return new Promise(resolve => setTimeout(resolve, ms));
1188
1454
  }
@@ -66,6 +66,7 @@ export function addTaskMessageToTask(msg, phase, nextId) {
66
66
  issueMarker: null,
67
67
  kitUpgrade: false,
68
68
  lineNumber: 0,
69
+ source: 'inbox',
69
70
  changelog: 'added',
70
71
  };
71
72
  }
@@ -56,6 +56,7 @@ export function issueToTask(issue, fallbackPhase) {
56
56
  issueMarker: `#${issue.issueNumber}`,
57
57
  kitUpgrade: issue.labels.some(l => l.toLowerCase().includes('kit-upgrade')),
58
58
  lineNumber: 0, // not applicable for platform issues
59
+ source: 'platform',
59
60
  status,
60
61
  changelog: inferChangelog(issue.title, issue.labels),
61
62
  };
@@ -111,6 +111,7 @@ export function parseTodoMd(content) {
111
111
  issueMarker: pendingTask.issueMarker,
112
112
  kitUpgrade: meta.kitUpgrade,
113
113
  lineNumber: pendingTask.lineNumber,
114
+ source: 'todo',
114
115
  changelog: inferChangelogCategory(title, pendingTask.status),
115
116
  });
116
117
  pendingTask = null;
@@ -0,0 +1,94 @@
1
+ // ─── report_issue MCP tool for agents ───
2
+ // Allows agents to report discovered issues outside their current task scope.
3
+ // Handler runs in-process in the scheduler — records locally + creates platform issues.
4
+ import { appendFileSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+ import { tool } from '@anthropic-ai/claude-agent-sdk';
7
+ import { z } from 'zod/v4';
8
+ import { createPlatformIssue } from '../git/issue-creator.mjs';
9
+ // ─── In-memory store ───
10
+ export class DiscoveredIssueStore {
11
+ issues = [];
12
+ add(issue) {
13
+ this.issues.push(issue);
14
+ }
15
+ /** Returns all issues and clears the store. */
16
+ drain() {
17
+ return this.issues.splice(0);
18
+ }
19
+ /** Read without clearing. */
20
+ peek() {
21
+ return this.issues;
22
+ }
23
+ }
24
+ // ─── Tool definition factory ───
25
+ const DISCOVERED_NDJSON = '.claude/scheduler/discovered.ndjson';
26
+ export function buildReportIssueTool(store, currentTaskId, projectDir, platform) {
27
+ return tool('report_issue', 'Report a discovered issue outside your current task scope. Use severity "non-blocker" for issues that don\'t prevent your work (then continue normally), or "blocker" for issues that prevent you from completing your task (then wrap up what you can and return).', {
28
+ title: z.string().describe('Concise issue title'),
29
+ type: z.enum(['frontend', 'backend', 'fullstack']).describe('Affected layer'),
30
+ complexity: z.enum(['S', 'M', 'L']).default('M').describe('Estimated complexity'),
31
+ severity: z.enum(['blocker', 'non-blocker']).describe('Whether this blocks your current task'),
32
+ description: z.string().describe('What is broken, where, and what the fix should look like'),
33
+ evidence: z.string().default('').describe('Error messages, file paths, stack traces'),
34
+ }, async (args) => {
35
+ const id = `discovered-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
36
+ const issue = {
37
+ id,
38
+ title: args.title,
39
+ type: args.type,
40
+ complexity: args.complexity,
41
+ severity: args.severity,
42
+ description: args.description,
43
+ evidence: args.evidence,
44
+ reportedBy: currentTaskId,
45
+ timestamp: Date.now(),
46
+ };
47
+ // Record in memory (scheduler drains on next iteration)
48
+ store.add(issue);
49
+ // Persist for crash recovery
50
+ try {
51
+ const ndjsonPath = resolve(projectDir, DISCOVERED_NDJSON);
52
+ appendFileSync(ndjsonPath, JSON.stringify(issue) + '\n', 'utf-8');
53
+ }
54
+ catch { /* best-effort — in-memory store is primary */ }
55
+ // Create platform issue if available
56
+ if (platform !== 'none') {
57
+ try {
58
+ const labels = [`discovered`, `type::${args.type}`, `complexity::${args.complexity}`, 'status::todo'];
59
+ const created = createPlatformIssue(projectDir, platform, {
60
+ title: args.title,
61
+ body: `**Discovered during:** ${currentTaskId}\n**Severity:** ${args.severity}\n\n${args.description}\n\n**Evidence:**\n\`\`\`\n${args.evidence}\n\`\`\``,
62
+ labels,
63
+ });
64
+ if (created) {
65
+ issue.platformIssue = created;
66
+ }
67
+ }
68
+ catch { /* non-fatal */ }
69
+ }
70
+ const platformNote = issue.platformIssue
71
+ ? ` Platform issue: ${issue.platformIssue.url}`
72
+ : '';
73
+ return {
74
+ content: [{ type: 'text', text: `Issue recorded: ${args.title} (${id}).${platformNote}` }],
75
+ };
76
+ });
77
+ }
78
+ // ─── Convert discovered issue to Task ───
79
+ export function discoveredIssueToTask(issue, currentPhase) {
80
+ return {
81
+ id: issue.id,
82
+ title: issue.title,
83
+ phase: currentPhase,
84
+ type: issue.type,
85
+ complexity: issue.complexity,
86
+ dependsOn: [],
87
+ issueMarker: issue.platformIssue ? `#${issue.platformIssue.number}` : null,
88
+ kitUpgrade: false,
89
+ lineNumber: 0,
90
+ source: 'discovered',
91
+ status: 'todo',
92
+ changelog: 'added',
93
+ };
94
+ }
@@ -0,0 +1,17 @@
1
+ // ─── In-process MCP server with scheduler-provided tools ───
2
+ // Creates a per-agent MCP server instance injected into query() options.
3
+ // Each agent gets its own server (currentTaskId differs per spawn).
4
+ import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk';
5
+ import { buildReportIssueTool } from './report-issue.mjs';
6
+ /**
7
+ * Create an in-process MCP server with scheduler tools for a specific agent.
8
+ * Returns a config that can be passed to query() via options.mcpServers.
9
+ */
10
+ export function createSchedulerToolServer(store, currentTaskId, projectDir, platform) {
11
+ const reportIssueTool = buildReportIssueTool(store, currentTaskId, projectDir, platform);
12
+ return createSdkMcpServer({
13
+ name: 'scheduler-tools',
14
+ version: '1.0.0',
15
+ tools: [reportIssueTool],
16
+ });
17
+ }
@@ -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') {
@@ -561,4 +561,8 @@ When reviewing Angular code, check:
561
561
  - @defer for heavy below-fold components
562
562
  - Naming: PascalCase components, lib- selectors, kebab-case files
563
563
  - i18n: ALL user-facing text has `i18n` attribute (templates) or `$localize` (TS) — no bare text
564
- - VRT: new pages/routes have corresponding `*.vrt.spec.ts` with 3-viewport coverage (375px, 768px, 1280px), baseline snapshots committed
564
+ - VRT: new pages/routes have corresponding `*.vrt.spec.ts` with 3-viewport coverage (375px, 768px, 1280px), baseline snapshots committed
565
+
566
+ ## Out-of-Scope Issues
567
+
568
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
@@ -136,3 +136,7 @@ When called for **direct implementation**, provide complete production-ready cod
136
136
  - Explain architectural decisions and trade-offs
137
137
  - Proactively identify potential issues
138
138
  - No fluff — every word serves a purpose
139
+
140
+ ## Out-of-Scope Issues
141
+
142
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
@@ -870,6 +870,19 @@ 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
+ ## Proactive Problem Resolution
874
+
875
+ All agents have access to a `report_issue` tool provided by the scheduler. When an agent encounters a problem outside its current task scope, it calls this tool to create a tracked issue. The scheduler picks it up on the next iteration and routes it through the normal pipeline.
876
+
877
+ **Agent behavior:**
878
+ - **In-scope problem** → fix it directly (it's the agent's job)
879
+ - **Out-of-scope, non-blocking** → call `report_issue` with `severity: 'non-blocker'`, then continue working
880
+ - **Out-of-scope, blocking** → call `report_issue` with `severity: 'blocker'`, finish what's possible, then return
881
+ - **Warnings are actionable** — unless they originate from inside a third-party package's own source code
882
+ - **Never dismiss** — "pre-existing" and "not in scope" are not valid reasons to ignore a problem
883
+
884
+ **Orchestrator does NOT process discovered issues.** The scheduler handles everything — creating tasks from `report_issue` calls, creating platform issues, and routing them to the right agent via the normal task queue. The orchestrator just picks tasks and delegates as usual.
885
+
873
886
  ## When Stuck
874
887
 
875
888
  NEVER stay stuck. Escalation order:
@@ -393,3 +393,7 @@ When reviewing React code, check:
393
393
  - No unstable nested component definitions (components defined inside render)
394
394
  - `React.memo` only on components with measured re-render cost — not as a default
395
395
  - VRT: new pages/routes have corresponding `*.vrt.spec.ts` with 3-viewport coverage (375px, 768px, 1280px), baseline snapshots committed
396
+
397
+ ## Out-of-Scope Issues
398
+
399
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
@@ -195,3 +195,7 @@ What is done well. Correct patterns, clean code, good decisions. Be specific.
195
195
  4. **Prioritize impact** — security > correctness > performance > style
196
196
  5. **Respect project standards** — align with CLAUDE.md conventions
197
197
  6. **Don't offer to fix** — this is not interactive, the user cannot respond
198
+
199
+ ## Out-of-Scope Issues
200
+
201
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
@@ -394,3 +394,7 @@ When reviewing Svelte code, check:
394
394
  - Callback props for outputs (not `createEventDispatcher` — that's Svelte 4)
395
395
  - Resource cleanup in `onDestroy` or `$effect` return for manual subscriptions
396
396
  - VRT: new pages/routes have corresponding `*.vrt.spec.ts` with 3-viewport coverage (375px, 768px, 1280px), baseline snapshots committed
397
+
398
+ ## Out-of-Scope Issues
399
+
400
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
@@ -538,3 +538,7 @@ Tests should be easy to write. If they are not, the production code needs refact
538
538
  - **No flaky tests** — if a test fails intermittently, fix it or delete it
539
539
  - **Test public API** — don't test private methods directly
540
540
  - **Pure functions are easy to test** — prefer pure transformations over stateful operations. If a function is hard to test, it likely has too many responsibilities or side effects.
541
+
542
+ ## Out-of-Scope Issues
543
+
544
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
@@ -144,4 +144,8 @@ When called for **direct implementation**, provide:
144
144
  - TypeScript interfaces and types
145
145
  - Follow project conventions from CLAUDE.md (App Separation Principle, Onion Architecture layers)
146
146
  - Monorepo-compatible solutions for shared functionality
147
- - When uncertain about approach, ask for clarification before proceeding
147
+ - When uncertain about approach, ask for clarification before proceeding
148
+
149
+ ## Out-of-Scope Issues
150
+
151
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
@@ -423,3 +423,7 @@ When reviewing Vue code, check:
423
423
  - Naming: PascalCase components, `Base` prefix for atoms, `use` prefix for composables
424
424
  - i18n: ALL user-facing text goes through `$t()` / `t()` — no bare strings in templates or script
425
425
  - VRT: new pages/routes have corresponding `*.vrt.spec.ts` with 3-viewport coverage (375px, 768px, 1280px), baseline snapshots committed
426
+
427
+ ## Out-of-Scope Issues
428
+
429
+ If you discover problems outside your current task (broken infrastructure, misconfigurations, deprecated APIs in other code), use the `report_issue` tool to create a tracked issue. Set `severity: 'non-blocker'` and continue your work, or `severity: 'blocker'` if the problem prevents you from completing your task — in that case, finish what you can and return. Never dismiss problems as "pre-existing."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "2.3.18",
3
+ "version": "2.3.20",
4
4
  "author": "",
5
5
  "repository": {
6
6
  "type": "git",