@jojonax/codex-copilot 1.5.5 → 1.6.1

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.
@@ -1,958 +1,958 @@
1
- /**
2
- * codex-copilot run - Main orchestration loop
3
- *
4
- * Executes tasks one by one: Develop → PR → Review → Fix → Merge → Next
5
- * Features fine-grained checkpoint/resume at every sub-step.
6
- */
7
-
8
- import { readFileSync, writeFileSync, existsSync } from 'fs';
9
- import { resolve } from 'path';
10
- import { log, progressBar } from '../utils/logger.js';
11
- import { git } from '../utils/git.js';
12
- import { github } from '../utils/github.js';
13
- import { closePrompt } from '../utils/prompt.js';
14
- import { createCheckpoint } from '../utils/checkpoint.js';
15
- import { provider } from '../utils/provider.js';
16
- import { readJSON, writeJSON } from '../utils/json.js';
17
- import { preFlightCheck, releaseLock } from '../utils/self-heal.js';
18
-
19
- const maxRateLimitRetries = 3;
20
-
21
- export async function run(projectDir) {
22
- const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
23
- const configPath = resolve(projectDir, '.codex-copilot/config.json');
24
- const checkpoint = createCheckpoint(projectDir);
25
-
26
- let tasks;
27
- let state;
28
-
29
- // Load tasks.json (required)
30
- try {
31
- tasks = readJSON(tasksPath);
32
- } catch (err) {
33
- log.warn(`tasks.json read issue: ${err.message}`);
34
- log.info('Attempting auto-repair...');
35
-
36
- // Try auto-fix (deep repair + schema validation)
37
- const { autoFix } = await import('./fix.js');
38
- const fixResult = autoFix(projectDir, { files: ['tasks.json'] });
39
-
40
- if (fixResult.ok && fixResult.repaired.includes('tasks.json')) {
41
- try {
42
- tasks = readJSON(tasksPath);
43
- log.info('✅ tasks.json auto-repaired successfully');
44
- } catch {
45
- // Auto-fix wrote the file but it's still not readable (shouldn't happen)
46
- log.error('Auto-repair wrote file but it remains unreadable');
47
- }
48
- }
49
-
50
- // If still no tasks, try backup
51
- if (!tasks && existsSync(tasksPath + '.bak')) {
52
- log.info('Trying backup file...');
53
- try {
54
- tasks = readJSON(tasksPath + '.bak');
55
- writeJSON(tasksPath, tasks);
56
- log.info('✅ Restored tasks.json from backup');
57
- } catch {
58
- log.error('Backup is also corrupted');
59
- }
60
- }
61
-
62
- if (!tasks) {
63
- log.error('Cannot recover tasks.json. Run: codex-copilot fix');
64
- closePrompt();
65
- process.exit(1);
66
- }
67
- }
68
-
69
- // Load state.json (optional — falls back to default)
70
- try {
71
- state = checkpoint.load();
72
- } catch {
73
- log.warn('State file corrupted — resetting to initial state');
74
- state = checkpoint.reset();
75
- }
76
- const config = existsSync(configPath) ? readJSON(configPath) : {};
77
-
78
- // Validate tasks.json structure
79
- if (!tasks.tasks || !Array.isArray(tasks.tasks) || !tasks.total) {
80
- log.error('Invalid tasks.json format: missing tasks array or total field');
81
- log.warn('Please re-run codex-copilot init and let CodeX regenerate tasks.json');
82
- closePrompt();
83
- process.exit(1);
84
- }
85
-
86
- const baseBranch = config.base_branch || 'main';
87
- const providerId = config.provider || 'codex-cli';
88
- const maxReviewRounds = config.max_review_rounds || 2;
89
- const pollInterval = config.review_poll_interval || 60;
90
- const waitTimeout = config.review_wait_timeout || 600;
91
- const isPrivate = github.isPrivateRepo(projectDir); // Cache once
92
- const weeklyQuotaThreshold = config.weekly_quota_threshold || 97;
93
-
94
- const providerInfo = provider.getProvider(providerId);
95
- log.info(`AI Provider: ${providerInfo ? providerInfo.name : providerId}`);
96
-
97
- // ===== Graceful shutdown handler =====
98
- let shuttingDown = false;
99
- const gracefulShutdown = () => {
100
- if (shuttingDown) {
101
- log.warn('Force exit');
102
- process.exit(1);
103
- }
104
- shuttingDown = true;
105
- log.blank();
106
- log.warn('Interrupt received — saving checkpoint...');
107
- // State is already saved at each step, just need to save tasks.json
108
- writeJSON(tasksPath, tasks);
109
- releaseLock(projectDir);
110
- log.info('✅ Checkpoint saved. Run `codex-copilot run` to resume.');
111
- log.blank();
112
- closePrompt();
113
- process.exit(0);
114
- };
115
- process.on('SIGINT', gracefulShutdown);
116
- process.on('SIGTERM', gracefulShutdown);
117
-
118
- log.title('🚀 Starting automated development loop');
119
- log.blank();
120
- log.info(`Project: ${tasks.project}`);
121
- log.info(`Total tasks: ${tasks.total}`);
122
- log.info(`Base branch: ${baseBranch}`);
123
-
124
- // ===== Pre-flight self-healing =====
125
- const health = preFlightCheck(projectDir, baseBranch, { checkpoint, tasks });
126
- if (!health.ok) {
127
- log.blank();
128
- log.error('Pre-flight check failed — cannot proceed:');
129
- for (const b of health.blockers) {
130
- log.error(` 🛑 ${b}`);
131
- }
132
- log.blank();
133
- releaseLock(projectDir);
134
- closePrompt();
135
- process.exit(1);
136
- }
137
- log.blank();
138
-
139
- // ===== Pre-flight: ensure base branch is committed & pushed =====
140
- await ensureBaseReady(projectDir, baseBranch, isPrivate);
141
-
142
- // Show resume info if resuming mid-task
143
- if (state.phase && state.current_task > 0) {
144
- log.blank();
145
- log.info(`⏩ Resuming task #${state.current_task} from: ${state.phase} → ${state.phase_step}`);
146
- }
147
-
148
- // ===== Auto-retry blocked tasks =====
149
- // On each run, reset blocked tasks to pending so they're retried in order.
150
- // Branches and checkpoint steps are PRESERVED — the task resumes from where
151
- // it left off, keeping all previously developed code.
152
- const blockedTasks = tasks.tasks.filter(t => t.status === 'blocked');
153
- if (blockedTasks.length > 0) {
154
- log.blank();
155
- log.info(`🔄 Auto-retrying ${blockedTasks.length} blocked task(s)...`);
156
- for (const bt of blockedTasks) {
157
- bt.status = 'pending';
158
- bt.retry_count = (bt.retry_count || 0) + 1;
159
- bt._retrying = true; // Flag: don't skip even if below checkpoint
160
- log.dim(` ↳ Task #${bt.id}: ${bt.title.substring(0, 50)} (attempt ${bt.retry_count + 1})`);
161
- }
162
- writeJSON(tasksPath, tasks);
163
- }
164
-
165
- const completedCount = tasks.tasks.filter(t => t.status === 'completed').length;
166
- log.blank();
167
- progressBar(completedCount, tasks.total, `${completedCount}/${tasks.total} tasks done`);
168
- log.blank();
169
-
170
- // Execute tasks one by one
171
- for (const task of tasks.tasks) {
172
- // Skip fully completed tasks
173
- if (task.status === 'completed' || task.status === 'skipped') continue;
174
-
175
- // Skip tasks whose ID is below the checkpoint (unless retrying a blocked task)
176
- const isResumingTask = state.current_task === task.id && state.phase;
177
- if (task.id < state.current_task && !isResumingTask && !task._retrying) continue;
178
-
179
- // Clean up retry flag (used only to bypass checkpoint skip)
180
- delete task._retrying;
181
-
182
- log.blank();
183
- log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
184
- log.blank();
185
-
186
- // ===== Quota pre-check (Codex CLI only) =====
187
- if (providerId === 'codex-cli' || providerId === 'codex-desktop') {
188
- const quota = provider.checkQuotaBeforeExecution(weeklyQuotaThreshold);
189
- if (!quota.ok) {
190
- log.blank();
191
- log.error(`⚠ Weekly quota at ${quota.quota7d}% (threshold: ${weeklyQuotaThreshold}%) — stopping to preserve remaining quota`);
192
- log.info('Run `codex-copilot usage` to check quota details');
193
- log.info('Run `codex-copilot run` again when quota resets');
194
- writeJSON(tasksPath, tasks);
195
- closePrompt();
196
- process.exit(0);
197
- }
198
- if (quota.warning) {
199
- log.warn(`⚠ Weekly quota at ${quota.quota7d}% — approaching limit`);
200
- }
201
- }
202
-
203
- // Check dependencies — completed, skipped, and blocked all satisfy dependencies.
204
- // Blocked tasks are treated as "done for now" to prevent cascade-skipping;
205
- // the user can retry them later with `codex-copilot retry`.
206
- if (task.depends_on && task.depends_on.length > 0) {
207
- const DONE_STATUSES = ['completed', 'skipped', 'blocked'];
208
- const unfinished = task.depends_on.filter(dep => {
209
- const depTask = tasks.tasks.find(t => t.id === dep);
210
- return depTask && !DONE_STATUSES.includes(depTask.status);
211
- });
212
- if (unfinished.length > 0) {
213
- log.warn(`Unfinished dependencies: ${unfinished.join(', ')} — skipping`);
214
- continue;
215
- }
216
- // Warn about blocked deps but still proceed
217
- const blockedDeps = task.depends_on.filter(dep => {
218
- const depTask = tasks.tasks.find(t => t.id === dep);
219
- return depTask && depTask.status === 'blocked';
220
- });
221
- if (blockedDeps.length > 0) {
222
- log.warn(`⚠ Dependencies ${blockedDeps.join(', ')} are blocked — proceeding anyway`);
223
- }
224
- }
225
-
226
- // ===== Ensure task has all required fields =====
227
- // Auto-generate branch name if missing (common after corruption recovery)
228
- if (!task.branch) {
229
- // Try to recover from checkpoint state
230
- if (state.current_task === task.id && state.branch) {
231
- task.branch = state.branch;
232
- log.dim(` ↳ Recovered branch name from checkpoint: ${task.branch}`);
233
- } else {
234
- const slug = (task.title || `task-${task.id}`)
235
- .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 40);
236
- task.branch = `feature/${String(task.id).padStart(3, '0')}-${slug}`;
237
- log.dim(` ↳ Generated branch name: ${task.branch}`);
238
- }
239
- }
240
- if (!task.depends_on) task.depends_on = [];
241
- if (!task.acceptance) task.acceptance = [];
242
- if (!task.description) task.description = task.title;
243
-
244
- // Mark task as in_progress
245
- task.status = 'in_progress';
246
- const isRetry = (task.retry_count || 0) > 0;
247
- const retryStartedAt = isRetry ? new Date().toISOString() : null;
248
- writeJSON(tasksPath, tasks);
249
-
250
- // ===== Phase 1: Develop =====
251
- if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
252
- await developPhase(projectDir, task, baseBranch, checkpoint, providerId);
253
- // If dev phase failed, mark blocked and skip to next task
254
- if (task.status === 'blocked') {
255
- writeJSON(tasksPath, tasks);
256
- log.blank();
257
- log.warn(`⚠ Task #${task.id} is blocked — AI development failed`);
258
- continue;
259
- }
260
- } else {
261
- log.dim('⏩ Skipping develop phase (already completed)');
262
- }
263
-
264
- // ===== Phase 2: Create PR =====
265
- let prInfo;
266
- if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
267
- prInfo = await prPhase(projectDir, task, baseBranch, checkpoint, isPrivate);
268
- } else {
269
- // PR already created, load from state
270
- state = checkpoint.load();
271
- prInfo = { number: state.current_pr, url: '' };
272
- log.dim(`⏩ Skipping PR phase (PR #${prInfo.number} already created)`);
273
- }
274
-
275
- // ===== Phase 3: Review loop =====
276
- if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
277
- // On retry: request fresh re-review since old reviews may be stale
278
- if (isRetry && prInfo?.number) {
279
- log.info('🔄 Retry: requesting fresh re-review on existing PR...');
280
- try {
281
- github.requestReReview(projectDir, prInfo.number);
282
- } catch { /* ignore — review bots may not be configured */ }
283
- }
284
-
285
- await reviewLoop(projectDir, task, prInfo, {
286
- maxRounds: maxReviewRounds,
287
- pollInterval,
288
- waitTimeout,
289
- retryStartedAt, // Only count reviews after this timestamp
290
- }, checkpoint, providerId, isPrivate);
291
- } else {
292
- log.dim('⏩ Skipping review phase (already completed)');
293
- }
294
-
295
- // ===== Phase 4: Merge =====
296
- if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
297
- await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
298
- } else {
299
- // Checkpoint says merged — verify against GitHub
300
- if (prInfo?.number) {
301
- const prState = github.getPRState(projectDir, prInfo.number);
302
- if (prState !== 'merged') {
303
- log.warn(`⚠ Checkpoint says merged but PR #${prInfo.number} is ${prState} — re-entering merge`);
304
- await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
305
- } else {
306
- log.dim('⏩ Skipping merge phase (PR confirmed merged on GitHub)');
307
- }
308
- } else {
309
- log.dim('⏩ Skipping merge phase (already merged)');
310
- }
311
- }
312
-
313
- // Check if task was blocked during review/merge
314
- if (task.status === 'blocked') {
315
- writeJSON(tasksPath, tasks);
316
- log.blank();
317
- log.warn(`⚠ Task #${task.id} is blocked — needs manual intervention`);
318
- log.blank();
319
- const done = tasks.tasks.filter(t => t.status === 'completed').length;
320
- const blocked = tasks.tasks.filter(t => t.status === 'blocked').length;
321
- progressBar(done, tasks.total, `${done}/${tasks.total} done, ${blocked} blocked`);
322
- continue;
323
- }
324
-
325
- // Mark task complete
326
- task.status = 'completed';
327
- delete task.block_reason;
328
- delete task.retry_count;
329
- writeJSON(tasksPath, tasks);
330
- checkpoint.completeTask(task.id);
331
-
332
- log.blank();
333
- const done = tasks.tasks.filter(t => t.status === 'completed').length;
334
- progressBar(done, tasks.total, `${done}/${tasks.total} tasks done`);
335
- log.info(`✅ Task #${task.id} complete!`);
336
- }
337
-
338
- log.blank();
339
- const finalDone = tasks.tasks.filter(t => t.status === 'completed').length;
340
- const finalBlocked = tasks.tasks.filter(t => t.status === 'blocked').length;
341
- if (finalBlocked > 0) {
342
- log.title(`✅ Finished — ${finalDone}/${tasks.total} done, ${finalBlocked} blocked`);
343
- } else {
344
- log.title('🎉 All tasks complete!');
345
- }
346
- log.blank();
347
- process.removeListener('SIGINT', gracefulShutdown);
348
- process.removeListener('SIGTERM', gracefulShutdown);
349
- releaseLock(projectDir);
350
-
351
- // ===== Auto-evolve: trigger next round if enabled =====
352
- if (config.auto_evolve !== false) {
353
- log.blank();
354
- log.info('🔄 Auto-evolving to next round...');
355
- log.blank();
356
- const { evolve } = await import('./evolve.js');
357
- await evolve(projectDir);
358
- return; // evolve() calls run() internally
359
- }
360
-
361
- closePrompt();
362
- }
363
-
364
- // ──────────────────────────────────────────────
365
- // Phase 1: Develop
366
- // ──────────────────────────────────────────────
367
- async function developPhase(projectDir, task, baseBranch, checkpoint, providerId) {
368
- log.step('Phase 1/4: Develop');
369
-
370
- // Step 1: Switch to feature branch
371
- if (!checkpoint.isStepDone(task.id, 'develop', 'branch_created')) {
372
- try {
373
- git.checkoutBranch(projectDir, task.branch, baseBranch);
374
- } catch (err) {
375
- log.error(`Failed to switch to branch ${task.branch}: ${err.message}`);
376
- task.status = 'blocked';
377
- task.block_reason = 'git_checkout_failed';
378
- return;
379
- }
380
- log.info(`Switched to branch: ${task.branch}`);
381
- checkpoint.saveStep(task.id, 'develop', 'branch_created', { branch: task.branch });
382
- } else {
383
- // Ensure we're on the right branch
384
- const current = git.currentBranch(projectDir);
385
- if (current !== task.branch) {
386
- try {
387
- git.checkoutBranch(projectDir, task.branch, baseBranch);
388
- } catch (err) {
389
- log.error(`Failed to switch to branch ${task.branch}: ${err.message}`);
390
- task.status = 'blocked';
391
- task.block_reason = 'git_checkout_failed';
392
- return;
393
- }
394
- }
395
- log.dim('⏩ Branch already created');
396
- }
397
-
398
- // Step 2: Build development prompt
399
- if (!checkpoint.isStepDone(task.id, 'develop', 'prompt_ready')) {
400
- const devPrompt = buildDevPrompt(task, projectDir);
401
- const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
402
- writeFileSync(promptPath, devPrompt);
403
- checkpoint.saveStep(task.id, 'develop', 'prompt_ready', { branch: task.branch });
404
- log.info('Development prompt generated');
405
- } else {
406
- log.dim('⏩ Prompt already generated');
407
- }
408
-
409
- // Step 3: Execute via AI Provider (with rate limit auto-retry + timeout protection)
410
- if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
411
- const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
412
- let rateLimitRetries = 0;
413
- const AI_TIMEOUT_MS = 30 * 60 * 1000; // D4: 30 minute timeout for AI execution
414
-
415
- while (rateLimitRetries < maxRateLimitRetries) {
416
- // D4: Race the AI execution against a timeout to prevent infinite hangs
417
- const timeoutPromise = new Promise((_, reject) =>
418
- setTimeout(() => reject(new Error('AI_TIMEOUT')), AI_TIMEOUT_MS)
419
- );
420
-
421
- let result;
422
- try {
423
- result = await Promise.race([
424
- provider.executePrompt(providerId, promptPath, projectDir),
425
- timeoutPromise,
426
- ]);
427
- } catch (timeoutErr) {
428
- if (timeoutErr.message === 'AI_TIMEOUT') {
429
- log.error(`AI provider timed out after ${AI_TIMEOUT_MS / 60000} minutes — marking task as blocked`);
430
- task.status = 'blocked';
431
- task.block_reason = 'ai_timeout';
432
- return;
433
- }
434
- throw timeoutErr;
435
- }
436
-
437
- if (result.ok) {
438
- log.info('Development complete');
439
- checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
440
- break;
441
- }
442
-
443
- if (result.rateLimited && result.retryAt) {
444
- rateLimitRetries++;
445
- log.warn(`Rate limit hit (attempt ${rateLimitRetries}/${maxRateLimitRetries})`);
446
- if (rateLimitRetries >= maxRateLimitRetries) {
447
- log.error('Max rate limit retries reached — marking task as blocked');
448
- task.status = 'blocked';
449
- task.block_reason = 'rate_limited';
450
- return;
451
- }
452
- await waitForRateLimitReset(result.retryAt, result.retryAtStr);
453
- log.info('Rate limit reset — resuming development...');
454
- continue;
455
- }
456
-
457
- // Non-rate-limit failure
458
- log.error('AI development failed — marking task as blocked');
459
- task.status = 'blocked';
460
- task.block_reason = 'dev_failed';
461
- return;
462
- }
463
- }
464
- }
465
-
466
- // ──────────────────────────────────────────────
467
- // Phase 2: Create PR
468
- // ──────────────────────────────────────────────
469
- async function prPhase(projectDir, task, baseBranch, checkpoint, isPrivate) {
470
- log.step('Phase 2/4: Submit PR');
471
-
472
- // Step 1: Commit changes
473
- if (!checkpoint.isStepDone(task.id, 'pr', 'committed')) {
474
- if (!git.isClean(projectDir)) {
475
- log.info('Uncommitted changes detected, auto-committing...');
476
- const skipCI = isPrivate ? ' [skip ci]' : '';
477
- git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}${skipCI}`);
478
- }
479
- checkpoint.saveStep(task.id, 'pr', 'committed', { branch: task.branch });
480
- log.info('Changes committed');
481
- } else {
482
- log.dim('⏩ Changes already committed');
483
- }
484
-
485
- // Step 2: Push
486
- if (!checkpoint.isStepDone(task.id, 'pr', 'pushed')) {
487
- git.pushBranch(projectDir, task.branch);
488
- checkpoint.saveStep(task.id, 'pr', 'pushed', { branch: task.branch });
489
- log.info('Code pushed');
490
- } else {
491
- log.dim('⏩ Code already pushed');
492
- }
493
-
494
- // Step 3: Create PR
495
- if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
496
- const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
497
- ? task.acceptance.map(a => `- [ ] ${a}`).join('\n')
498
- : '- [ ] Feature works correctly';
499
- const prBody = `## Task #${task.id}: ${task.title}\n\n${task.description}\n\n### Acceptance Criteria\n${acceptanceList}\n\n---\n*Auto-created by Codex-Copilot*`;
500
-
501
- // Use auto-recovery: create → find existing → fix remote → retry
502
- const prInfo = github.createPRWithRecovery(projectDir, {
503
- title: `feat(task-${task.id}): ${task.title}`,
504
- body: prBody,
505
- base: baseBranch,
506
- head: task.branch,
507
- });
508
- log.info(`PR ready: #${prInfo.number} ${prInfo.url}`);
509
-
510
- checkpoint.saveStep(task.id, 'pr', 'pr_created', {
511
- branch: task.branch,
512
- current_pr: prInfo.number,
513
- });
514
- return prInfo;
515
- }
516
-
517
- // If we get here, PR was already created — load from state
518
- const state = checkpoint.load();
519
- return { number: state.current_pr, url: '' };
520
- }
521
-
522
- // ──────────────────────────────────────────────
523
- // Phase 3: Review loop
524
- // ──────────────────────────────────────────────
525
- async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout, retryStartedAt }, checkpoint, providerId, isPrivate) {
526
- const HARD_MAX_ROUNDS = 5;
527
- const MAX_POLL_RETRIES = 3;
528
- let maxRounds = Math.min(_maxRounds, HARD_MAX_ROUNDS);
529
- log.step('Phase 3/4: Waiting for review');
530
-
531
- // Resume from saved review round
532
- const state = checkpoint.load();
533
- const startRound = (state.current_task === task.id && state.review_round > 0)
534
- ? state.review_round
535
- : 1;
536
-
537
- let pollRetries = 0;
538
-
539
- for (let round = startRound; round <= maxRounds; round++) {
540
- // Step: Waiting for review
541
- checkpoint.saveStep(task.id, 'review', 'waiting_review', {
542
- branch: task.branch,
543
- current_pr: prInfo.number,
544
- review_round: round,
545
- });
546
-
547
- log.info('Checking for review feedback...');
548
-
549
- let gotReview = false;
550
-
551
- // Always proactively check for existing reviews first.
552
- // This catches: already-posted bot reviews, stale reviews found on resume,
553
- // and fast bot responses after fix pushes.
554
- const existingReviews = github.getReviews(projectDir, prInfo.number);
555
- const existingComments = github.getIssueComments(projectDir, prInfo.number);
556
-
557
- // On retry: only count reviews posted AFTER the retry started
558
- const isReviewFresh = (item) => {
559
- if (!retryStartedAt) return true; // Not a retry — all reviews are valid
560
- const itemDate = item.submitted_at || item.created_at || item.updated_at;
561
- return itemDate && new Date(itemDate) > new Date(retryStartedAt);
562
- };
563
-
564
- const freshReviews = existingReviews.filter(isReviewFresh);
565
- const freshComments = existingComments.filter(isReviewFresh);
566
-
567
- const hasReview = freshReviews.some(r => r.state !== 'PENDING');
568
- const hasBotComment = freshComments.some(c =>
569
- c.user?.type === 'Bot' || c.user?.login?.includes('bot')
570
- );
571
-
572
- if (hasReview || hasBotComment) {
573
- log.info('Review found — processing immediately');
574
- gotReview = true;
575
- }
576
-
577
- if (!gotReview) {
578
- // After fix pushes (round > 1), bot should respond quickly if configured.
579
- // Use shorter timeout to avoid wasting 10+ minutes waiting for nothing.
580
- const effectiveTimeout = round > 1 ? Math.min(waitTimeout, 120) : waitTimeout;
581
- log.info(`Waiting for ${round > 1 ? 'new ' : ''}review... (timeout: ${effectiveTimeout}s)`);
582
- gotReview = await waitForReview(projectDir, prInfo.number, pollInterval, effectiveTimeout);
583
- }
584
-
585
- if (!gotReview) {
586
- // Before giving up, do one last proactive check
587
- const lastChance = github.getLatestReviewState(projectDir, prInfo.number);
588
- if (lastChance === 'APPROVED') {
589
- log.info('✅ Review approved (found on final check)!');
590
- return;
591
- }
592
- const lastFeedback = github.collectReviewFeedback(projectDir, prInfo.number);
593
- if (lastFeedback) {
594
- log.info('Found existing feedback — processing');
595
- gotReview = true;
596
- }
597
- }
598
-
599
- if (!gotReview) {
600
- pollRetries++;
601
- if (pollRetries >= MAX_POLL_RETRIES) {
602
- log.error(`Review polling timed out ${MAX_POLL_RETRIES} times — marking task as blocked`);
603
- task.status = 'blocked';
604
- task.block_reason = 'review_timeout';
605
- return;
606
- }
607
- log.warn(`Review wait timed out — auto-retrying (${pollRetries}/${MAX_POLL_RETRIES})...`);
608
- round--; // retry same round
609
- continue;
610
- }
611
- pollRetries = 0; // reset on success
612
-
613
- // Step: Feedback received
614
- checkpoint.saveStep(task.id, 'review', 'feedback_received', {
615
- branch: task.branch,
616
- current_pr: prInfo.number,
617
- review_round: round,
618
- });
619
-
620
- // Check review status
621
- const reviewState = github.getLatestReviewState(projectDir, prInfo.number);
622
- log.info(`Review status: ${reviewState}`);
623
-
624
- if (reviewState === 'APPROVED') {
625
- log.info('✅ Review approved!');
626
- return;
627
- }
628
-
629
- // Collect review feedback (raw — classification done by AI)
630
- const feedback = github.collectReviewFeedback(projectDir, prInfo.number);
631
- if (!feedback) {
632
- log.info('No review feedback found — proceeding ✅');
633
- return;
634
- }
635
-
636
- // Use AI to classify the review feedback
637
- log.info('Classifying review feedback via AI...');
638
- const classification = await provider.classifyReview(providerId, feedback, projectDir);
639
-
640
- if (classification === 'pass') {
641
- log.info('AI determined no actionable issues — proceeding ✅');
642
- return;
643
- }
644
-
645
- if (classification === null) {
646
- // AI classification failed — fall back to structural GitHub API signals
647
- log.dim('AI classification unavailable, using structural fallback');
648
- const inlineComments = github.getReviewComments(projectDir, prInfo.number);
649
- const hasInlineComments = inlineComments && inlineComments.length > 0;
650
-
651
- if (reviewState === 'CHANGES_REQUESTED') {
652
- log.info('Review state: CHANGES_REQUESTED — entering fix phase');
653
- } else if (reviewState === 'COMMENTED' && !hasInlineComments) {
654
- log.info('COMMENTED with no inline code comments — treating as passed ✅');
655
- return;
656
- } else if (!hasInlineComments) {
657
- log.info('No inline code comments found — treating as passed ✅');
658
- return;
659
- } else {
660
- log.info(`Found ${inlineComments.length} inline code comment(s) — entering fix phase`);
661
- }
662
- }
663
-
664
- // AI says FIX, or structural fallback indicates issues
665
- log.blank();
666
- log.warn(`Received review feedback (round ${round}/${maxRounds})`);
667
-
668
- if (round >= maxRounds) {
669
- if (maxRounds < HARD_MAX_ROUNDS) {
670
- // Auto-extend: give it one more round
671
- maxRounds++;
672
- log.warn(`Auto-extending fix rounds to ${maxRounds}`);
673
- } else {
674
- // Hard limit reached — cannot keep fixing forever
675
- log.error(`Hard limit of ${HARD_MAX_ROUNDS} fix rounds reached — marking task as blocked`);
676
- log.error('This task needs manual intervention to resolve review issues');
677
- task.status = 'blocked';
678
- return;
679
- }
680
- }
681
-
682
- // Let AI fix based on the specific review feedback
683
- await fixPhase(projectDir, task, feedback, round, providerId);
684
-
685
- // Step: Fix applied
686
- checkpoint.saveStep(task.id, 'review', 'fix_applied', {
687
- branch: task.branch,
688
- current_pr: prInfo.number,
689
- review_round: round,
690
- });
691
-
692
- // Push fix — only if there are actual changes
693
- if (!git.isClean(projectDir)) {
694
- const skipCI = isPrivate ? ' [skip ci]' : '';
695
- git.commitAll(projectDir, `fix(task-${task.id}): address review comments (round ${round})${skipCI}`);
696
- git.pushBranch(projectDir, task.branch);
697
- log.info('Fix pushed');
698
-
699
- // Request bot to re-review the updated code
700
- log.info('Requesting re-review...');
701
- github.requestReReview(projectDir, prInfo.number);
702
-
703
- // Wait for review bot to react
704
- await sleep(15000);
705
- } else {
706
- // AI fix produced no code changes — it cannot resolve this issue
707
- log.error('AI fix produced no changes — marking task as blocked');
708
- log.error('This task needs manual code changes to resolve review issues');
709
- task.status = 'blocked';
710
- task.block_reason = 'review_failed';
711
- return;
712
- }
713
- }
714
- }
715
-
716
- async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
717
- let elapsed = 0;
718
- const startReviewCount = github.getReviews(projectDir, prNumber).length;
719
- const startCommentCount = github.getIssueComments(projectDir, prNumber).length;
720
-
721
- // Spinner for visual feedback during polling
722
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
723
- let spinIdx = 0;
724
- const spinTimer = setInterval(() => {
725
- const frame = SPINNER[spinIdx % SPINNER.length];
726
- const remaining = Math.max(0, timeout - elapsed);
727
- process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m Waiting for review... (${remaining}s remaining)`);
728
- spinIdx++;
729
- }, 80);
730
-
731
- while (elapsed < timeout) {
732
- await sleep(pollInterval * 1000);
733
- elapsed += pollInterval;
734
-
735
- const currentReviews = github.getReviews(projectDir, prNumber);
736
- const currentComments = github.getIssueComments(projectDir, prNumber);
737
-
738
- const hasNewReview = currentReviews.length > startReviewCount;
739
- const hasNewBotComment = currentComments.length > startCommentCount &&
740
- currentComments.some(c => c.user?.type === 'Bot' || c.user?.login?.includes('bot'));
741
-
742
- if (hasNewReview || hasNewBotComment) {
743
- clearInterval(spinTimer);
744
- process.stdout.write('\r\x1b[K');
745
- return true;
746
- }
747
- }
748
- clearInterval(spinTimer);
749
- process.stdout.write('\r\x1b[K');
750
- return false;
751
- }
752
-
753
- // ──────────────────────────────────────────────
754
- // Fix phase
755
- // ──────────────────────────────────────────────
756
- async function fixPhase(projectDir, task, feedback, round, providerId) {
757
- log.step(`Fixing review comments (round ${round})`);
758
-
759
- const fixPrompt = `Please fix the following review comments on Task #${task.id}: ${task.title}
760
-
761
- ## Review Feedback
762
- ${feedback}
763
-
764
- ## Requirements
765
- 1. Fix each issue listed above
766
- 2. Suggestions (non-blocking) can be skipped — explain why in the commit message
767
- 3. Ensure fixes don't introduce new issues
768
- 4. Do NOT run git add or git commit — the automation handles committing
769
- `;
770
-
771
- // Save to file and execute via provider (with rate limit auto-retry)
772
- const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
773
- writeFileSync(promptPath, fixPrompt);
774
-
775
- let rateLimitRetries = 0;
776
- while (rateLimitRetries < maxRateLimitRetries) {
777
- const result = await provider.executePrompt(providerId, promptPath, projectDir);
778
- if (result.ok) {
779
- break;
780
- }
781
- if (result.rateLimited && result.retryAt) {
782
- rateLimitRetries++;
783
- if (rateLimitRetries >= maxRateLimitRetries) {
784
- log.warn('Max rate limit retries reached during fix phase');
785
- break;
786
- }
787
- log.warn(`Rate limit hit during fix — waiting for reset...`);
788
- await waitForRateLimitReset(result.retryAt, result.retryAtStr);
789
- log.info('Rate limit reset — retrying fix...');
790
- continue;
791
- }
792
- break; // Non-rate-limit failure
793
- }
794
- log.info('Fix complete');
795
- }
796
-
797
- // ──────────────────────────────────────────────
798
- // Phase 4: Merge
799
- // ──────────────────────────────────────────────
800
- async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
801
- log.step('Phase 4/4: Merge PR');
802
-
803
- // Skip merge if task was blocked during review
804
- if (task.status === 'blocked') {
805
- log.warn(`Task #${task.id} is blocked — skipping merge`);
806
- return;
807
- }
808
-
809
- log.info(`Auto-merging PR #${prInfo.number}...`);
810
-
811
- let merged = false;
812
- for (let attempt = 1; attempt <= 3; attempt++) {
813
- try {
814
- github.mergePR(projectDir, prInfo.number);
815
- log.info(`PR #${prInfo.number} merged ✅`);
816
- merged = true;
817
- break;
818
- } catch (err) {
819
- log.warn(`Merge attempt ${attempt}/3 failed: ${err.message}`);
820
- if (attempt < 3) {
821
- log.info('Retrying in 10s...');
822
- await sleep(10000);
823
- }
824
- }
825
- }
826
-
827
- if (!merged) {
828
- log.error('Merge failed after 3 attempts — marking task as blocked');
829
- task.status = 'blocked';
830
- task.block_reason = 'merge_failed';
831
- return;
832
- }
833
-
834
- checkpoint.saveStep(task.id, 'merge', 'merged', {
835
- branch: task.branch,
836
- current_pr: prInfo.number,
837
- });
838
-
839
- // Switch back to main branch
840
- git.checkoutMain(projectDir, baseBranch);
841
- }
842
-
843
- // ──────────────────────────────────────────────
844
- // Pre-flight: ensure base branch has commits & is pushed
845
- // ──────────────────────────────────────────────
846
- async function ensureBaseReady(projectDir, baseBranch, isPrivate = false) {
847
- const skipCI = isPrivate ? ' [skip ci]' : '';
848
- // Check if the repo has any commits at all
849
- const hasCommits = git.execSafe('git rev-parse HEAD', projectDir);
850
- if (!hasCommits.ok) {
851
- // No commits yet — brand new repo
852
- log.warn('No commits found in repository');
853
- if (!git.isClean(projectDir)) {
854
- log.info('Creating initial commit from existing code...');
855
- git.commitAll(projectDir, `chore: initial commit${skipCI}`);
856
- log.info('✅ Initial commit created');
857
- } else {
858
- log.warn('Repository is empty — no files to commit');
859
- return;
860
- }
861
- } else {
862
- // Has commits, but base branch might have uncommitted changes
863
- const currentBranch = git.currentBranch(projectDir);
864
- if (currentBranch === baseBranch && !git.isClean(projectDir)) {
865
- log.info('Uncommitted changes on base branch, committing first...');
866
- git.commitAll(projectDir, `chore: save current progress before automation${skipCI}`);
867
- log.info('✅ Base branch changes committed');
868
- }
869
- }
870
-
871
- // Ensure we're on the base branch
872
- const currentBranch = git.currentBranch(projectDir);
873
- if (currentBranch !== baseBranch) {
874
- // If base branch doesn't exist locally, create it from current
875
- const branchExists = git.execSafe(`git rev-parse --verify ${baseBranch}`, projectDir);
876
- if (!branchExists.ok) {
877
- log.info(`Creating base branch '${baseBranch}' from current branch...`);
878
- git.execSafe(`git branch ${baseBranch}`, projectDir);
879
- }
880
- }
881
-
882
- // Ensure base branch is pushed to remote
883
- github.ensureRemoteBranch(projectDir, baseBranch);
884
- log.info(`Base branch '${baseBranch}' ready ✓`);
885
- }
886
-
887
- function buildDevPrompt(task, projectDir) {
888
- const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
889
- ? task.acceptance.map(a => `- ${a}`).join('\n')
890
- : '- Feature works correctly';
891
-
892
- let retrySection = '';
893
- if (projectDir) {
894
- const retryContextPath = resolve(projectDir, `.codex-copilot/retry_context/${task.id}.md`);
895
- if (existsSync(retryContextPath)) {
896
- const retryContext = readFileSync(retryContextPath, 'utf-8');
897
- retrySection = `\n## ⚠️ Retry Context (from previous failed attempt)\n${retryContext}\n`;
898
- }
899
- }
900
-
901
- return `Please complete the following development task:
902
-
903
- ## Task #${task.id}: ${task.title}
904
-
905
- ${task.description}
906
- ${retrySection}
907
- ## Acceptance Criteria
908
- ${acceptanceList}
909
-
910
- ## Requirements
911
- 1. Strictly follow the project's existing code style and tech stack
912
- 2. Ensure the code compiles/runs correctly when done
913
- 3. Do NOT run git add or git commit — the automation handles committing
914
- `;
915
- }
916
-
917
- function sleep(ms) {
918
- return new Promise(resolve => setTimeout(resolve, ms));
919
- }
920
-
921
- /**
922
- * Wait for rate limit reset with countdown display.
923
- * @param {Date} retryAt - When to resume
924
- * @param {string} retryAtStr - Human-readable time string
925
- */
926
- async function waitForRateLimitReset(retryAt, retryAtStr) {
927
- const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
928
- let spinIdx = 0;
929
-
930
- log.blank();
931
- log.info(`⏳ Rate limited — auto-resuming at ${retryAtStr}`);
932
- log.blank();
933
-
934
- while (true) {
935
- const now = Date.now();
936
- const remaining = retryAt.getTime() - now;
937
- if (remaining <= 0) break;
938
-
939
- const mins = Math.floor(remaining / 60000);
940
- const secs = Math.floor((remaining % 60000) / 1000);
941
- const frame = SPINNER[spinIdx % SPINNER.length];
942
- spinIdx++;
943
-
944
- process.stdout.write(
945
- `\r\x1b[K \x1b[33m${frame}\x1b[0m Waiting for rate limit reset... \x1b[1m${mins}m ${secs}s\x1b[0m remaining`
946
- );
947
-
948
- await sleep(1000);
949
- }
950
-
951
- process.stdout.write('\r\x1b[K');
952
- log.info('✅ Rate limit reset — waiting 3min buffer before resuming...');
953
- // Add 3-minute buffer to ensure the limit is actually lifted
954
- await sleep(180_000);
955
- }
956
-
957
-
958
-
1
+ /**
2
+ * codex-copilot run - Main orchestration loop
3
+ *
4
+ * Executes tasks one by one: Develop → PR → Review → Fix → Merge → Next
5
+ * Features fine-grained checkpoint/resume at every sub-step.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
9
+ import { resolve } from 'path';
10
+ import { log, progressBar } from '../utils/logger.js';
11
+ import { git } from '../utils/git.js';
12
+ import { github } from '../utils/github.js';
13
+ import { closePrompt } from '../utils/prompt.js';
14
+ import { createCheckpoint } from '../utils/checkpoint.js';
15
+ import { provider } from '../utils/provider.js';
16
+ import { readJSON, writeJSON } from '../utils/json.js';
17
+ import { preFlightCheck, releaseLock } from '../utils/self-heal.js';
18
+
19
+ const maxRateLimitRetries = 3;
20
+
21
+ export async function run(projectDir) {
22
+ const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
23
+ const configPath = resolve(projectDir, '.codex-copilot/config.json');
24
+ const checkpoint = createCheckpoint(projectDir);
25
+
26
+ let tasks;
27
+ let state;
28
+
29
+ // Load tasks.json (required)
30
+ try {
31
+ tasks = readJSON(tasksPath);
32
+ } catch (err) {
33
+ log.warn(`tasks.json read issue: ${err.message}`);
34
+ log.info('Attempting auto-repair...');
35
+
36
+ // Try auto-fix (deep repair + schema validation)
37
+ const { autoFix } = await import('./fix.js');
38
+ const fixResult = autoFix(projectDir, { files: ['tasks.json'] });
39
+
40
+ if (fixResult.ok && fixResult.repaired.includes('tasks.json')) {
41
+ try {
42
+ tasks = readJSON(tasksPath);
43
+ log.info('✅ tasks.json auto-repaired successfully');
44
+ } catch {
45
+ // Auto-fix wrote the file but it's still not readable (shouldn't happen)
46
+ log.error('Auto-repair wrote file but it remains unreadable');
47
+ }
48
+ }
49
+
50
+ // If still no tasks, try backup
51
+ if (!tasks && existsSync(tasksPath + '.bak')) {
52
+ log.info('Trying backup file...');
53
+ try {
54
+ tasks = readJSON(tasksPath + '.bak');
55
+ writeJSON(tasksPath, tasks);
56
+ log.info('✅ Restored tasks.json from backup');
57
+ } catch {
58
+ log.error('Backup is also corrupted');
59
+ }
60
+ }
61
+
62
+ if (!tasks) {
63
+ log.error('Cannot recover tasks.json. Run: codex-copilot fix');
64
+ closePrompt();
65
+ process.exit(1);
66
+ }
67
+ }
68
+
69
+ // Load state.json (optional — falls back to default)
70
+ try {
71
+ state = checkpoint.load();
72
+ } catch {
73
+ log.warn('State file corrupted — resetting to initial state');
74
+ state = checkpoint.reset();
75
+ }
76
+ const config = existsSync(configPath) ? readJSON(configPath) : {};
77
+
78
+ // Validate tasks.json structure
79
+ if (!tasks.tasks || !Array.isArray(tasks.tasks) || !tasks.total) {
80
+ log.error('Invalid tasks.json format: missing tasks array or total field');
81
+ log.warn('Please re-run codex-copilot init and let CodeX regenerate tasks.json');
82
+ closePrompt();
83
+ process.exit(1);
84
+ }
85
+
86
+ const baseBranch = config.base_branch || 'main';
87
+ const providerId = config.provider || 'codex-cli';
88
+ const maxReviewRounds = config.max_review_rounds || 2;
89
+ const pollInterval = config.review_poll_interval || 60;
90
+ const waitTimeout = config.review_wait_timeout || 600;
91
+ const isPrivate = github.isPrivateRepo(projectDir); // Cache once
92
+ const weeklyQuotaThreshold = config.weekly_quota_threshold || 97;
93
+
94
+ const providerInfo = provider.getProvider(providerId);
95
+ log.info(`AI Provider: ${providerInfo ? providerInfo.name : providerId}`);
96
+
97
+ // ===== Graceful shutdown handler =====
98
+ let shuttingDown = false;
99
+ const gracefulShutdown = () => {
100
+ if (shuttingDown) {
101
+ log.warn('Force exit');
102
+ process.exit(1);
103
+ }
104
+ shuttingDown = true;
105
+ log.blank();
106
+ log.warn('Interrupt received — saving checkpoint...');
107
+ // State is already saved at each step, just need to save tasks.json
108
+ writeJSON(tasksPath, tasks);
109
+ releaseLock(projectDir);
110
+ log.info('✅ Checkpoint saved. Run `codex-copilot run` to resume.');
111
+ log.blank();
112
+ closePrompt();
113
+ process.exit(0);
114
+ };
115
+ process.on('SIGINT', gracefulShutdown);
116
+ process.on('SIGTERM', gracefulShutdown);
117
+
118
+ log.title('🚀 Starting automated development loop');
119
+ log.blank();
120
+ log.info(`Project: ${tasks.project}`);
121
+ log.info(`Total tasks: ${tasks.total}`);
122
+ log.info(`Base branch: ${baseBranch}`);
123
+
124
+ // ===== Pre-flight self-healing =====
125
+ const health = preFlightCheck(projectDir, baseBranch, { checkpoint, tasks });
126
+ if (!health.ok) {
127
+ log.blank();
128
+ log.error('Pre-flight check failed — cannot proceed:');
129
+ for (const b of health.blockers) {
130
+ log.error(` 🛑 ${b}`);
131
+ }
132
+ log.blank();
133
+ releaseLock(projectDir);
134
+ closePrompt();
135
+ process.exit(1);
136
+ }
137
+ log.blank();
138
+
139
+ // ===== Pre-flight: ensure base branch is committed & pushed =====
140
+ await ensureBaseReady(projectDir, baseBranch, isPrivate);
141
+
142
+ // Show resume info if resuming mid-task
143
+ if (state.phase && state.current_task > 0) {
144
+ log.blank();
145
+ log.info(`⏩ Resuming task #${state.current_task} from: ${state.phase} → ${state.phase_step}`);
146
+ }
147
+
148
+ // ===== Auto-retry blocked tasks =====
149
+ // On each run, reset blocked tasks to pending so they're retried in order.
150
+ // Branches and checkpoint steps are PRESERVED — the task resumes from where
151
+ // it left off, keeping all previously developed code.
152
+ const blockedTasks = tasks.tasks.filter(t => t.status === 'blocked');
153
+ if (blockedTasks.length > 0) {
154
+ log.blank();
155
+ log.info(`🔄 Auto-retrying ${blockedTasks.length} blocked task(s)...`);
156
+ for (const bt of blockedTasks) {
157
+ bt.status = 'pending';
158
+ bt.retry_count = (bt.retry_count || 0) + 1;
159
+ bt._retrying = true; // Flag: don't skip even if below checkpoint
160
+ log.dim(` ↳ Task #${bt.id}: ${bt.title.substring(0, 50)} (attempt ${bt.retry_count + 1})`);
161
+ }
162
+ writeJSON(tasksPath, tasks);
163
+ }
164
+
165
+ const completedCount = tasks.tasks.filter(t => t.status === 'completed').length;
166
+ log.blank();
167
+ progressBar(completedCount, tasks.total, `${completedCount}/${tasks.total} tasks done`);
168
+ log.blank();
169
+
170
+ // Execute tasks one by one
171
+ for (const task of tasks.tasks) {
172
+ // Skip fully completed tasks
173
+ if (task.status === 'completed' || task.status === 'skipped') continue;
174
+
175
+ // Skip tasks whose ID is below the checkpoint (unless retrying a blocked task)
176
+ const isResumingTask = state.current_task === task.id && state.phase;
177
+ if (task.id < state.current_task && !isResumingTask && !task._retrying) continue;
178
+
179
+ // Clean up retry flag (used only to bypass checkpoint skip)
180
+ delete task._retrying;
181
+
182
+ log.blank();
183
+ log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
184
+ log.blank();
185
+
186
+ // ===== Quota pre-check (Codex CLI only) =====
187
+ if (providerId === 'codex-cli' || providerId === 'codex-desktop') {
188
+ const quota = provider.checkQuotaBeforeExecution(weeklyQuotaThreshold);
189
+ if (!quota.ok) {
190
+ log.blank();
191
+ log.error(`⚠ Weekly quota at ${quota.quota7d}% (threshold: ${weeklyQuotaThreshold}%) — stopping to preserve remaining quota`);
192
+ log.info('Run `codex-copilot usage` to check quota details');
193
+ log.info('Run `codex-copilot run` again when quota resets');
194
+ writeJSON(tasksPath, tasks);
195
+ closePrompt();
196
+ process.exit(0);
197
+ }
198
+ if (quota.warning) {
199
+ log.warn(`⚠ Weekly quota at ${quota.quota7d}% — approaching limit`);
200
+ }
201
+ }
202
+
203
+ // Check dependencies — completed, skipped, and blocked all satisfy dependencies.
204
+ // Blocked tasks are treated as "done for now" to prevent cascade-skipping;
205
+ // the user can retry them later with `codex-copilot retry`.
206
+ if (task.depends_on && task.depends_on.length > 0) {
207
+ const DONE_STATUSES = ['completed', 'skipped', 'blocked'];
208
+ const unfinished = task.depends_on.filter(dep => {
209
+ const depTask = tasks.tasks.find(t => t.id === dep);
210
+ return depTask && !DONE_STATUSES.includes(depTask.status);
211
+ });
212
+ if (unfinished.length > 0) {
213
+ log.warn(`Unfinished dependencies: ${unfinished.join(', ')} — skipping`);
214
+ continue;
215
+ }
216
+ // Warn about blocked deps but still proceed
217
+ const blockedDeps = task.depends_on.filter(dep => {
218
+ const depTask = tasks.tasks.find(t => t.id === dep);
219
+ return depTask && depTask.status === 'blocked';
220
+ });
221
+ if (blockedDeps.length > 0) {
222
+ log.warn(`⚠ Dependencies ${blockedDeps.join(', ')} are blocked — proceeding anyway`);
223
+ }
224
+ }
225
+
226
+ // ===== Ensure task has all required fields =====
227
+ // Auto-generate branch name if missing (common after corruption recovery)
228
+ if (!task.branch) {
229
+ // Try to recover from checkpoint state
230
+ if (state.current_task === task.id && state.branch) {
231
+ task.branch = state.branch;
232
+ log.dim(` ↳ Recovered branch name from checkpoint: ${task.branch}`);
233
+ } else {
234
+ const slug = (task.title || `task-${task.id}`)
235
+ .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 40);
236
+ task.branch = `feature/${String(task.id).padStart(3, '0')}-${slug}`;
237
+ log.dim(` ↳ Generated branch name: ${task.branch}`);
238
+ }
239
+ }
240
+ if (!task.depends_on) task.depends_on = [];
241
+ if (!task.acceptance) task.acceptance = [];
242
+ if (!task.description) task.description = task.title;
243
+
244
+ // Mark task as in_progress
245
+ task.status = 'in_progress';
246
+ const isRetry = (task.retry_count || 0) > 0;
247
+ const retryStartedAt = isRetry ? new Date().toISOString() : null;
248
+ writeJSON(tasksPath, tasks);
249
+
250
+ // ===== Phase 1: Develop =====
251
+ if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
252
+ await developPhase(projectDir, task, baseBranch, checkpoint, providerId);
253
+ // If dev phase failed, mark blocked and skip to next task
254
+ if (task.status === 'blocked') {
255
+ writeJSON(tasksPath, tasks);
256
+ log.blank();
257
+ log.warn(`⚠ Task #${task.id} is blocked — AI development failed`);
258
+ continue;
259
+ }
260
+ } else {
261
+ log.dim('⏩ Skipping develop phase (already completed)');
262
+ }
263
+
264
+ // ===== Phase 2: Create PR =====
265
+ let prInfo;
266
+ if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
267
+ prInfo = await prPhase(projectDir, task, baseBranch, checkpoint, isPrivate);
268
+ } else {
269
+ // PR already created, load from state
270
+ state = checkpoint.load();
271
+ prInfo = { number: state.current_pr, url: '' };
272
+ log.dim(`⏩ Skipping PR phase (PR #${prInfo.number} already created)`);
273
+ }
274
+
275
+ // ===== Phase 3: Review loop =====
276
+ if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
277
+ // On retry: request fresh re-review since old reviews may be stale
278
+ if (isRetry && prInfo?.number) {
279
+ log.info('🔄 Retry: requesting fresh re-review on existing PR...');
280
+ try {
281
+ github.requestReReview(projectDir, prInfo.number);
282
+ } catch { /* ignore — review bots may not be configured */ }
283
+ }
284
+
285
+ await reviewLoop(projectDir, task, prInfo, {
286
+ maxRounds: maxReviewRounds,
287
+ pollInterval,
288
+ waitTimeout,
289
+ retryStartedAt, // Only count reviews after this timestamp
290
+ }, checkpoint, providerId, isPrivate);
291
+ } else {
292
+ log.dim('⏩ Skipping review phase (already completed)');
293
+ }
294
+
295
+ // ===== Phase 4: Merge =====
296
+ if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
297
+ await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
298
+ } else {
299
+ // Checkpoint says merged — verify against GitHub
300
+ if (prInfo?.number) {
301
+ const prState = github.getPRState(projectDir, prInfo.number);
302
+ if (prState !== 'merged') {
303
+ log.warn(`⚠ Checkpoint says merged but PR #${prInfo.number} is ${prState} — re-entering merge`);
304
+ await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
305
+ } else {
306
+ log.dim('⏩ Skipping merge phase (PR confirmed merged on GitHub)');
307
+ }
308
+ } else {
309
+ log.dim('⏩ Skipping merge phase (already merged)');
310
+ }
311
+ }
312
+
313
+ // Check if task was blocked during review/merge
314
+ if (task.status === 'blocked') {
315
+ writeJSON(tasksPath, tasks);
316
+ log.blank();
317
+ log.warn(`⚠ Task #${task.id} is blocked — needs manual intervention`);
318
+ log.blank();
319
+ const done = tasks.tasks.filter(t => t.status === 'completed').length;
320
+ const blocked = tasks.tasks.filter(t => t.status === 'blocked').length;
321
+ progressBar(done, tasks.total, `${done}/${tasks.total} done, ${blocked} blocked`);
322
+ continue;
323
+ }
324
+
325
+ // Mark task complete
326
+ task.status = 'completed';
327
+ delete task.block_reason;
328
+ delete task.retry_count;
329
+ writeJSON(tasksPath, tasks);
330
+ checkpoint.completeTask(task.id);
331
+
332
+ log.blank();
333
+ const done = tasks.tasks.filter(t => t.status === 'completed').length;
334
+ progressBar(done, tasks.total, `${done}/${tasks.total} tasks done`);
335
+ log.info(`✅ Task #${task.id} complete!`);
336
+ }
337
+
338
+ log.blank();
339
+ const finalDone = tasks.tasks.filter(t => t.status === 'completed').length;
340
+ const finalBlocked = tasks.tasks.filter(t => t.status === 'blocked').length;
341
+ if (finalBlocked > 0) {
342
+ log.title(`✅ Finished — ${finalDone}/${tasks.total} done, ${finalBlocked} blocked`);
343
+ } else {
344
+ log.title('🎉 All tasks complete!');
345
+ }
346
+ log.blank();
347
+ process.removeListener('SIGINT', gracefulShutdown);
348
+ process.removeListener('SIGTERM', gracefulShutdown);
349
+ releaseLock(projectDir);
350
+
351
+ // ===== Auto-evolve: trigger next round if enabled =====
352
+ if (config.auto_evolve !== false) {
353
+ log.blank();
354
+ log.info('🔄 Auto-evolving to next round...');
355
+ log.blank();
356
+ const { evolve } = await import('./evolve.js');
357
+ await evolve(projectDir);
358
+ return; // evolve() calls run() internally
359
+ }
360
+
361
+ closePrompt();
362
+ }
363
+
364
+ // ──────────────────────────────────────────────
365
+ // Phase 1: Develop
366
+ // ──────────────────────────────────────────────
367
+ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId) {
368
+ log.step('Phase 1/4: Develop');
369
+
370
+ // Step 1: Switch to feature branch
371
+ if (!checkpoint.isStepDone(task.id, 'develop', 'branch_created')) {
372
+ try {
373
+ git.checkoutBranch(projectDir, task.branch, baseBranch);
374
+ } catch (err) {
375
+ log.error(`Failed to switch to branch ${task.branch}: ${err.message}`);
376
+ task.status = 'blocked';
377
+ task.block_reason = 'git_checkout_failed';
378
+ return;
379
+ }
380
+ log.info(`Switched to branch: ${task.branch}`);
381
+ checkpoint.saveStep(task.id, 'develop', 'branch_created', { branch: task.branch });
382
+ } else {
383
+ // Ensure we're on the right branch
384
+ const current = git.currentBranch(projectDir);
385
+ if (current !== task.branch) {
386
+ try {
387
+ git.checkoutBranch(projectDir, task.branch, baseBranch);
388
+ } catch (err) {
389
+ log.error(`Failed to switch to branch ${task.branch}: ${err.message}`);
390
+ task.status = 'blocked';
391
+ task.block_reason = 'git_checkout_failed';
392
+ return;
393
+ }
394
+ }
395
+ log.dim('⏩ Branch already created');
396
+ }
397
+
398
+ // Step 2: Build development prompt
399
+ if (!checkpoint.isStepDone(task.id, 'develop', 'prompt_ready')) {
400
+ const devPrompt = buildDevPrompt(task, projectDir);
401
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
402
+ writeFileSync(promptPath, devPrompt);
403
+ checkpoint.saveStep(task.id, 'develop', 'prompt_ready', { branch: task.branch });
404
+ log.info('Development prompt generated');
405
+ } else {
406
+ log.dim('⏩ Prompt already generated');
407
+ }
408
+
409
+ // Step 3: Execute via AI Provider (with rate limit auto-retry + timeout protection)
410
+ if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
411
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
412
+ let rateLimitRetries = 0;
413
+ const AI_TIMEOUT_MS = 30 * 60 * 1000; // D4: 30 minute timeout for AI execution
414
+
415
+ while (rateLimitRetries < maxRateLimitRetries) {
416
+ // D4: Race the AI execution against a timeout to prevent infinite hangs
417
+ const timeoutPromise = new Promise((_, reject) =>
418
+ setTimeout(() => reject(new Error('AI_TIMEOUT')), AI_TIMEOUT_MS)
419
+ );
420
+
421
+ let result;
422
+ try {
423
+ result = await Promise.race([
424
+ provider.executePrompt(providerId, promptPath, projectDir),
425
+ timeoutPromise,
426
+ ]);
427
+ } catch (timeoutErr) {
428
+ if (timeoutErr.message === 'AI_TIMEOUT') {
429
+ log.error(`AI provider timed out after ${AI_TIMEOUT_MS / 60000} minutes — marking task as blocked`);
430
+ task.status = 'blocked';
431
+ task.block_reason = 'ai_timeout';
432
+ return;
433
+ }
434
+ throw timeoutErr;
435
+ }
436
+
437
+ if (result.ok) {
438
+ log.info('Development complete');
439
+ checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
440
+ break;
441
+ }
442
+
443
+ if (result.rateLimited && result.retryAt) {
444
+ rateLimitRetries++;
445
+ log.warn(`Rate limit hit (attempt ${rateLimitRetries}/${maxRateLimitRetries})`);
446
+ if (rateLimitRetries >= maxRateLimitRetries) {
447
+ log.error('Max rate limit retries reached — marking task as blocked');
448
+ task.status = 'blocked';
449
+ task.block_reason = 'rate_limited';
450
+ return;
451
+ }
452
+ await waitForRateLimitReset(result.retryAt, result.retryAtStr);
453
+ log.info('Rate limit reset — resuming development...');
454
+ continue;
455
+ }
456
+
457
+ // Non-rate-limit failure
458
+ log.error('AI development failed — marking task as blocked');
459
+ task.status = 'blocked';
460
+ task.block_reason = 'dev_failed';
461
+ return;
462
+ }
463
+ }
464
+ }
465
+
466
+ // ──────────────────────────────────────────────
467
+ // Phase 2: Create PR
468
+ // ──────────────────────────────────────────────
469
+ async function prPhase(projectDir, task, baseBranch, checkpoint, isPrivate) {
470
+ log.step('Phase 2/4: Submit PR');
471
+
472
+ // Step 1: Commit changes
473
+ if (!checkpoint.isStepDone(task.id, 'pr', 'committed')) {
474
+ if (!git.isClean(projectDir)) {
475
+ log.info('Uncommitted changes detected, auto-committing...');
476
+ const skipCI = isPrivate ? ' [skip ci]' : '';
477
+ git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}${skipCI}`);
478
+ }
479
+ checkpoint.saveStep(task.id, 'pr', 'committed', { branch: task.branch });
480
+ log.info('Changes committed');
481
+ } else {
482
+ log.dim('⏩ Changes already committed');
483
+ }
484
+
485
+ // Step 2: Push
486
+ if (!checkpoint.isStepDone(task.id, 'pr', 'pushed')) {
487
+ git.pushBranch(projectDir, task.branch);
488
+ checkpoint.saveStep(task.id, 'pr', 'pushed', { branch: task.branch });
489
+ log.info('Code pushed');
490
+ } else {
491
+ log.dim('⏩ Code already pushed');
492
+ }
493
+
494
+ // Step 3: Create PR
495
+ if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
496
+ const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
497
+ ? task.acceptance.map(a => `- [ ] ${a}`).join('\n')
498
+ : '- [ ] Feature works correctly';
499
+ const prBody = `## Task #${task.id}: ${task.title}\n\n${task.description}\n\n### Acceptance Criteria\n${acceptanceList}\n\n---\n*Auto-created by Codex-Copilot*`;
500
+
501
+ // Use auto-recovery: create → find existing → fix remote → retry
502
+ const prInfo = github.createPRWithRecovery(projectDir, {
503
+ title: `feat(task-${task.id}): ${task.title}`,
504
+ body: prBody,
505
+ base: baseBranch,
506
+ head: task.branch,
507
+ });
508
+ log.info(`PR ready: #${prInfo.number} ${prInfo.url}`);
509
+
510
+ checkpoint.saveStep(task.id, 'pr', 'pr_created', {
511
+ branch: task.branch,
512
+ current_pr: prInfo.number,
513
+ });
514
+ return prInfo;
515
+ }
516
+
517
+ // If we get here, PR was already created — load from state
518
+ const state = checkpoint.load();
519
+ return { number: state.current_pr, url: '' };
520
+ }
521
+
522
+ // ──────────────────────────────────────────────
523
+ // Phase 3: Review loop
524
+ // ──────────────────────────────────────────────
525
+ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout, retryStartedAt }, checkpoint, providerId, isPrivate) {
526
+ const HARD_MAX_ROUNDS = 5;
527
+ const MAX_POLL_RETRIES = 3;
528
+ let maxRounds = Math.min(_maxRounds, HARD_MAX_ROUNDS);
529
+ log.step('Phase 3/4: Waiting for review');
530
+
531
+ // Resume from saved review round
532
+ const state = checkpoint.load();
533
+ const startRound = (state.current_task === task.id && state.review_round > 0)
534
+ ? state.review_round
535
+ : 1;
536
+
537
+ let pollRetries = 0;
538
+
539
+ for (let round = startRound; round <= maxRounds; round++) {
540
+ // Step: Waiting for review
541
+ checkpoint.saveStep(task.id, 'review', 'waiting_review', {
542
+ branch: task.branch,
543
+ current_pr: prInfo.number,
544
+ review_round: round,
545
+ });
546
+
547
+ log.info('Checking for review feedback...');
548
+
549
+ let gotReview = false;
550
+
551
+ // Always proactively check for existing reviews first.
552
+ // This catches: already-posted bot reviews, stale reviews found on resume,
553
+ // and fast bot responses after fix pushes.
554
+ const existingReviews = github.getReviews(projectDir, prInfo.number);
555
+ const existingComments = github.getIssueComments(projectDir, prInfo.number);
556
+
557
+ // On retry: only count reviews posted AFTER the retry started
558
+ const isReviewFresh = (item) => {
559
+ if (!retryStartedAt) return true; // Not a retry — all reviews are valid
560
+ const itemDate = item.submitted_at || item.created_at || item.updated_at;
561
+ return itemDate && new Date(itemDate) > new Date(retryStartedAt);
562
+ };
563
+
564
+ const freshReviews = existingReviews.filter(isReviewFresh);
565
+ const freshComments = existingComments.filter(isReviewFresh);
566
+
567
+ const hasReview = freshReviews.some(r => r.state !== 'PENDING');
568
+ const hasBotComment = freshComments.some(c =>
569
+ c.user?.type === 'Bot' || c.user?.login?.includes('bot')
570
+ );
571
+
572
+ if (hasReview || hasBotComment) {
573
+ log.info('Review found — processing immediately');
574
+ gotReview = true;
575
+ }
576
+
577
+ if (!gotReview) {
578
+ // After fix pushes (round > 1), bot should respond quickly if configured.
579
+ // Use shorter timeout to avoid wasting 10+ minutes waiting for nothing.
580
+ const effectiveTimeout = round > 1 ? Math.min(waitTimeout, 120) : waitTimeout;
581
+ log.info(`Waiting for ${round > 1 ? 'new ' : ''}review... (timeout: ${effectiveTimeout}s)`);
582
+ gotReview = await waitForReview(projectDir, prInfo.number, pollInterval, effectiveTimeout);
583
+ }
584
+
585
+ if (!gotReview) {
586
+ // Before giving up, do one last proactive check
587
+ const lastChance = github.getLatestReviewState(projectDir, prInfo.number);
588
+ if (lastChance === 'APPROVED') {
589
+ log.info('✅ Review approved (found on final check)!');
590
+ return;
591
+ }
592
+ const lastFeedback = github.collectReviewFeedback(projectDir, prInfo.number);
593
+ if (lastFeedback) {
594
+ log.info('Found existing feedback — processing');
595
+ gotReview = true;
596
+ }
597
+ }
598
+
599
+ if (!gotReview) {
600
+ pollRetries++;
601
+ if (pollRetries >= MAX_POLL_RETRIES) {
602
+ log.error(`Review polling timed out ${MAX_POLL_RETRIES} times — marking task as blocked`);
603
+ task.status = 'blocked';
604
+ task.block_reason = 'review_timeout';
605
+ return;
606
+ }
607
+ log.warn(`Review wait timed out — auto-retrying (${pollRetries}/${MAX_POLL_RETRIES})...`);
608
+ round--; // retry same round
609
+ continue;
610
+ }
611
+ pollRetries = 0; // reset on success
612
+
613
+ // Step: Feedback received
614
+ checkpoint.saveStep(task.id, 'review', 'feedback_received', {
615
+ branch: task.branch,
616
+ current_pr: prInfo.number,
617
+ review_round: round,
618
+ });
619
+
620
+ // Check review status
621
+ const reviewState = github.getLatestReviewState(projectDir, prInfo.number);
622
+ log.info(`Review status: ${reviewState}`);
623
+
624
+ if (reviewState === 'APPROVED') {
625
+ log.info('✅ Review approved!');
626
+ return;
627
+ }
628
+
629
+ // Collect review feedback (raw — classification done by AI)
630
+ const feedback = github.collectReviewFeedback(projectDir, prInfo.number);
631
+ if (!feedback) {
632
+ log.info('No review feedback found — proceeding ✅');
633
+ return;
634
+ }
635
+
636
+ // Use AI to classify the review feedback
637
+ log.info('Classifying review feedback via AI...');
638
+ const classification = await provider.classifyReview(providerId, feedback, projectDir);
639
+
640
+ if (classification === 'pass') {
641
+ log.info('AI determined no actionable issues — proceeding ✅');
642
+ return;
643
+ }
644
+
645
+ if (classification === null) {
646
+ // AI classification failed — fall back to structural GitHub API signals
647
+ log.dim('AI classification unavailable, using structural fallback');
648
+ const inlineComments = github.getReviewComments(projectDir, prInfo.number);
649
+ const hasInlineComments = inlineComments && inlineComments.length > 0;
650
+
651
+ if (reviewState === 'CHANGES_REQUESTED') {
652
+ log.info('Review state: CHANGES_REQUESTED — entering fix phase');
653
+ } else if (reviewState === 'COMMENTED' && !hasInlineComments) {
654
+ log.info('COMMENTED with no inline code comments — treating as passed ✅');
655
+ return;
656
+ } else if (!hasInlineComments) {
657
+ log.info('No inline code comments found — treating as passed ✅');
658
+ return;
659
+ } else {
660
+ log.info(`Found ${inlineComments.length} inline code comment(s) — entering fix phase`);
661
+ }
662
+ }
663
+
664
+ // AI says FIX, or structural fallback indicates issues
665
+ log.blank();
666
+ log.warn(`Received review feedback (round ${round}/${maxRounds})`);
667
+
668
+ if (round >= maxRounds) {
669
+ if (maxRounds < HARD_MAX_ROUNDS) {
670
+ // Auto-extend: give it one more round
671
+ maxRounds++;
672
+ log.warn(`Auto-extending fix rounds to ${maxRounds}`);
673
+ } else {
674
+ // Hard limit reached — cannot keep fixing forever
675
+ log.error(`Hard limit of ${HARD_MAX_ROUNDS} fix rounds reached — marking task as blocked`);
676
+ log.error('This task needs manual intervention to resolve review issues');
677
+ task.status = 'blocked';
678
+ return;
679
+ }
680
+ }
681
+
682
+ // Let AI fix based on the specific review feedback
683
+ await fixPhase(projectDir, task, feedback, round, providerId);
684
+
685
+ // Step: Fix applied
686
+ checkpoint.saveStep(task.id, 'review', 'fix_applied', {
687
+ branch: task.branch,
688
+ current_pr: prInfo.number,
689
+ review_round: round,
690
+ });
691
+
692
+ // Push fix — only if there are actual changes
693
+ if (!git.isClean(projectDir)) {
694
+ const skipCI = isPrivate ? ' [skip ci]' : '';
695
+ git.commitAll(projectDir, `fix(task-${task.id}): address review comments (round ${round})${skipCI}`);
696
+ git.pushBranch(projectDir, task.branch);
697
+ log.info('Fix pushed');
698
+
699
+ // Request bot to re-review the updated code
700
+ log.info('Requesting re-review...');
701
+ github.requestReReview(projectDir, prInfo.number);
702
+
703
+ // Wait for review bot to react
704
+ await sleep(15000);
705
+ } else {
706
+ // AI fix produced no code changes — it cannot resolve this issue
707
+ log.error('AI fix produced no changes — marking task as blocked');
708
+ log.error('This task needs manual code changes to resolve review issues');
709
+ task.status = 'blocked';
710
+ task.block_reason = 'review_failed';
711
+ return;
712
+ }
713
+ }
714
+ }
715
+
716
+ async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
717
+ let elapsed = 0;
718
+ const startReviewCount = github.getReviews(projectDir, prNumber).length;
719
+ const startCommentCount = github.getIssueComments(projectDir, prNumber).length;
720
+
721
+ // Spinner for visual feedback during polling
722
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
723
+ let spinIdx = 0;
724
+ const spinTimer = setInterval(() => {
725
+ const frame = SPINNER[spinIdx % SPINNER.length];
726
+ const remaining = Math.max(0, timeout - elapsed);
727
+ process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m Waiting for review... (${remaining}s remaining)`);
728
+ spinIdx++;
729
+ }, 80);
730
+
731
+ while (elapsed < timeout) {
732
+ await sleep(pollInterval * 1000);
733
+ elapsed += pollInterval;
734
+
735
+ const currentReviews = github.getReviews(projectDir, prNumber);
736
+ const currentComments = github.getIssueComments(projectDir, prNumber);
737
+
738
+ const hasNewReview = currentReviews.length > startReviewCount;
739
+ const hasNewBotComment = currentComments.length > startCommentCount &&
740
+ currentComments.some(c => c.user?.type === 'Bot' || c.user?.login?.includes('bot'));
741
+
742
+ if (hasNewReview || hasNewBotComment) {
743
+ clearInterval(spinTimer);
744
+ process.stdout.write('\r\x1b[K');
745
+ return true;
746
+ }
747
+ }
748
+ clearInterval(spinTimer);
749
+ process.stdout.write('\r\x1b[K');
750
+ return false;
751
+ }
752
+
753
+ // ──────────────────────────────────────────────
754
+ // Fix phase
755
+ // ──────────────────────────────────────────────
756
+ async function fixPhase(projectDir, task, feedback, round, providerId) {
757
+ log.step(`Fixing review comments (round ${round})`);
758
+
759
+ const fixPrompt = `Please fix the following review comments on Task #${task.id}: ${task.title}
760
+
761
+ ## Review Feedback
762
+ ${feedback}
763
+
764
+ ## Requirements
765
+ 1. Fix each issue listed above
766
+ 2. Suggestions (non-blocking) can be skipped — explain why in the commit message
767
+ 3. Ensure fixes don't introduce new issues
768
+ 4. Do NOT run git add or git commit — the automation handles committing
769
+ `;
770
+
771
+ // Save to file and execute via provider (with rate limit auto-retry)
772
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
773
+ writeFileSync(promptPath, fixPrompt);
774
+
775
+ let rateLimitRetries = 0;
776
+ while (rateLimitRetries < maxRateLimitRetries) {
777
+ const result = await provider.executePrompt(providerId, promptPath, projectDir);
778
+ if (result.ok) {
779
+ break;
780
+ }
781
+ if (result.rateLimited && result.retryAt) {
782
+ rateLimitRetries++;
783
+ if (rateLimitRetries >= maxRateLimitRetries) {
784
+ log.warn('Max rate limit retries reached during fix phase');
785
+ break;
786
+ }
787
+ log.warn(`Rate limit hit during fix — waiting for reset...`);
788
+ await waitForRateLimitReset(result.retryAt, result.retryAtStr);
789
+ log.info('Rate limit reset — retrying fix...');
790
+ continue;
791
+ }
792
+ break; // Non-rate-limit failure
793
+ }
794
+ log.info('Fix complete');
795
+ }
796
+
797
+ // ──────────────────────────────────────────────
798
+ // Phase 4: Merge
799
+ // ──────────────────────────────────────────────
800
+ async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
801
+ log.step('Phase 4/4: Merge PR');
802
+
803
+ // Skip merge if task was blocked during review
804
+ if (task.status === 'blocked') {
805
+ log.warn(`Task #${task.id} is blocked — skipping merge`);
806
+ return;
807
+ }
808
+
809
+ log.info(`Auto-merging PR #${prInfo.number}...`);
810
+
811
+ let merged = false;
812
+ for (let attempt = 1; attempt <= 3; attempt++) {
813
+ try {
814
+ github.mergePR(projectDir, prInfo.number);
815
+ log.info(`PR #${prInfo.number} merged ✅`);
816
+ merged = true;
817
+ break;
818
+ } catch (err) {
819
+ log.warn(`Merge attempt ${attempt}/3 failed: ${err.message}`);
820
+ if (attempt < 3) {
821
+ log.info('Retrying in 10s...');
822
+ await sleep(10000);
823
+ }
824
+ }
825
+ }
826
+
827
+ if (!merged) {
828
+ log.error('Merge failed after 3 attempts — marking task as blocked');
829
+ task.status = 'blocked';
830
+ task.block_reason = 'merge_failed';
831
+ return;
832
+ }
833
+
834
+ checkpoint.saveStep(task.id, 'merge', 'merged', {
835
+ branch: task.branch,
836
+ current_pr: prInfo.number,
837
+ });
838
+
839
+ // Switch back to main branch
840
+ git.checkoutMain(projectDir, baseBranch);
841
+ }
842
+
843
+ // ──────────────────────────────────────────────
844
+ // Pre-flight: ensure base branch has commits & is pushed
845
+ // ──────────────────────────────────────────────
846
+ async function ensureBaseReady(projectDir, baseBranch, isPrivate = false) {
847
+ const skipCI = isPrivate ? ' [skip ci]' : '';
848
+ // Check if the repo has any commits at all
849
+ const hasCommits = git.execSafe('git rev-parse HEAD', projectDir);
850
+ if (!hasCommits.ok) {
851
+ // No commits yet — brand new repo
852
+ log.warn('No commits found in repository');
853
+ if (!git.isClean(projectDir)) {
854
+ log.info('Creating initial commit from existing code...');
855
+ git.commitAll(projectDir, `chore: initial commit${skipCI}`);
856
+ log.info('✅ Initial commit created');
857
+ } else {
858
+ log.warn('Repository is empty — no files to commit');
859
+ return;
860
+ }
861
+ } else {
862
+ // Has commits, but base branch might have uncommitted changes
863
+ const currentBranch = git.currentBranch(projectDir);
864
+ if (currentBranch === baseBranch && !git.isClean(projectDir)) {
865
+ log.info('Uncommitted changes on base branch, committing first...');
866
+ git.commitAll(projectDir, `chore: save current progress before automation${skipCI}`);
867
+ log.info('✅ Base branch changes committed');
868
+ }
869
+ }
870
+
871
+ // Ensure we're on the base branch
872
+ const currentBranch = git.currentBranch(projectDir);
873
+ if (currentBranch !== baseBranch) {
874
+ // If base branch doesn't exist locally, create it from current
875
+ const branchExists = git.execSafe(`git rev-parse --verify ${baseBranch}`, projectDir);
876
+ if (!branchExists.ok) {
877
+ log.info(`Creating base branch '${baseBranch}' from current branch...`);
878
+ git.execSafe(`git branch ${baseBranch}`, projectDir);
879
+ }
880
+ }
881
+
882
+ // Ensure base branch is pushed to remote
883
+ github.ensureRemoteBranch(projectDir, baseBranch);
884
+ log.info(`Base branch '${baseBranch}' ready ✓`);
885
+ }
886
+
887
+ function buildDevPrompt(task, projectDir) {
888
+ const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
889
+ ? task.acceptance.map(a => `- ${a}`).join('\n')
890
+ : '- Feature works correctly';
891
+
892
+ let retrySection = '';
893
+ if (projectDir) {
894
+ const retryContextPath = resolve(projectDir, `.codex-copilot/retry_context/${task.id}.md`);
895
+ if (existsSync(retryContextPath)) {
896
+ const retryContext = readFileSync(retryContextPath, 'utf-8');
897
+ retrySection = `\n## ⚠️ Retry Context (from previous failed attempt)\n${retryContext}\n`;
898
+ }
899
+ }
900
+
901
+ return `Please complete the following development task:
902
+
903
+ ## Task #${task.id}: ${task.title}
904
+
905
+ ${task.description}
906
+ ${retrySection}
907
+ ## Acceptance Criteria
908
+ ${acceptanceList}
909
+
910
+ ## Requirements
911
+ 1. Strictly follow the project's existing code style and tech stack
912
+ 2. Ensure the code compiles/runs correctly when done
913
+ 3. Do NOT run git add or git commit — the automation handles committing
914
+ `;
915
+ }
916
+
917
+ function sleep(ms) {
918
+ return new Promise(resolve => setTimeout(resolve, ms));
919
+ }
920
+
921
+ /**
922
+ * Wait for rate limit reset with countdown display.
923
+ * @param {Date} retryAt - When to resume
924
+ * @param {string} retryAtStr - Human-readable time string
925
+ */
926
+ async function waitForRateLimitReset(retryAt, retryAtStr) {
927
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
928
+ let spinIdx = 0;
929
+
930
+ log.blank();
931
+ log.info(`⏳ Rate limited — auto-resuming at ${retryAtStr}`);
932
+ log.blank();
933
+
934
+ while (true) {
935
+ const now = Date.now();
936
+ const remaining = retryAt.getTime() - now;
937
+ if (remaining <= 0) break;
938
+
939
+ const mins = Math.floor(remaining / 60000);
940
+ const secs = Math.floor((remaining % 60000) / 1000);
941
+ const frame = SPINNER[spinIdx % SPINNER.length];
942
+ spinIdx++;
943
+
944
+ process.stdout.write(
945
+ `\r\x1b[K \x1b[33m${frame}\x1b[0m Waiting for rate limit reset... \x1b[1m${mins}m ${secs}s\x1b[0m remaining`
946
+ );
947
+
948
+ await sleep(1000);
949
+ }
950
+
951
+ process.stdout.write('\r\x1b[K');
952
+ log.info('✅ Rate limit reset — waiting 3min buffer before resuming...');
953
+ // Add 3-minute buffer to ensure the limit is actually lifted
954
+ await sleep(180_000);
955
+ }
956
+
957
+
958
+