@jojonax/codex-copilot 1.3.3 → 1.3.5

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/bin/cli.js CHANGED
@@ -20,6 +20,8 @@ import { init } from '../src/commands/init.js';
20
20
  import { run } from '../src/commands/run.js';
21
21
  import { status } from '../src/commands/status.js';
22
22
  import { reset } from '../src/commands/reset.js';
23
+ import { retry } from '../src/commands/retry.js';
24
+ import { skip } from '../src/commands/skip.js';
23
25
  import { log } from '../src/utils/logger.js';
24
26
  import { checkForUpdates } from '../src/utils/update-check.js';
25
27
 
@@ -61,12 +63,38 @@ async function main() {
61
63
  process.exit(1);
62
64
  }
63
65
  await status(projectDir);
66
+ process.exit(0);
64
67
  break;
65
68
 
66
69
  case 'reset':
67
70
  await reset(projectDir);
71
+ process.exit(0);
68
72
  break;
69
73
 
74
+ case 'retry':
75
+ if (!existsSync(resolve(projectDir, '.codex-copilot/tasks.json'))) {
76
+ log.error('Not initialized. Run: codex-copilot init');
77
+ process.exit(1);
78
+ }
79
+ await retry(projectDir);
80
+ process.exit(0);
81
+ break;
82
+
83
+ case 'skip': {
84
+ if (!existsSync(resolve(projectDir, '.codex-copilot/tasks.json'))) {
85
+ log.error('Not initialized. Run: codex-copilot init');
86
+ process.exit(1);
87
+ }
88
+ const skipTaskId = process.argv[3];
89
+ if (!skipTaskId) {
90
+ log.error('Usage: codex-copilot skip <task_id>');
91
+ process.exit(1);
92
+ }
93
+ await skip(projectDir, skipTaskId);
94
+ process.exit(0);
95
+ break;
96
+ }
97
+
70
98
  case 'update':
71
99
  log.info('Updating to latest version...');
72
100
  try {
@@ -75,8 +103,18 @@ async function main() {
75
103
  } catch {
76
104
  log.error('Update failed. Try manually: npm install -g @jojonax/codex-copilot@latest --force');
77
105
  }
106
+ process.exit(0);
78
107
  break;
79
108
 
109
+ case '--version':
110
+ case '-v':
111
+ console.log(`v${version}`);
112
+ process.exit(0);
113
+ break;
114
+
115
+ case 'help':
116
+ case '--help':
117
+ case '-h':
80
118
  default:
81
119
  console.log(' Usage: codex-copilot <command>');
82
120
  console.log('');
@@ -85,6 +123,8 @@ async function main() {
85
123
  console.log(' run Start automated development loop');
86
124
  console.log(' status View current task progress');
87
125
  console.log(' reset Reset state and start over');
126
+ console.log(' retry Retry blocked tasks with enhanced prompts');
127
+ console.log(' skip <id> Force-skip a task to unblock dependents');
88
128
  console.log(' update Update to latest version');
89
129
  console.log('');
90
130
  console.log(' Workflow:');
@@ -92,12 +132,9 @@ async function main() {
92
132
  console.log(' 2. codex-copilot init (auto-detect PRD and decompose tasks)');
93
133
  console.log(' 3. codex-copilot run (start automated dev loop)');
94
134
  console.log('');
95
- console.log(' Update:');
96
- console.log(' npm install -g @jojonax/codex-copilot@latest');
97
- console.log('');
98
135
  console.log(' \x1b[36mⓘ This is a help page. Exit and run the commands above directly in your terminal.\x1b[0m');
99
136
  console.log('');
100
- break;
137
+ process.exit(0);
101
138
  }
102
139
  } catch (err) {
103
140
  log.error(`Execution failed: ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jojonax/codex-copilot",
3
- "version": "1.3.3",
3
+ "version": "1.3.5",
4
4
  "description": "PRD-driven automated development orchestrator for CodeX / Cursor",
5
5
  "bin": {
6
6
  "codex-copilot": "./bin/cli.js"
@@ -0,0 +1,158 @@
1
+ /**
2
+ * codex-copilot retry - Reset blocked tasks for re-execution
3
+ *
4
+ * Recovery strategy per block scenario:
5
+ * Dev-blocked: Save partial context, reset to pending
6
+ * Review-blocked: Close PR, delete branch, save review feedback, reset to pending
7
+ * Merge-blocked: Reset only the merge step
8
+ */
9
+
10
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
11
+ import { resolve } from 'path';
12
+ import { log, progressBar } from '../utils/logger.js';
13
+ import { git } from '../utils/git.js';
14
+ import { github } from '../utils/github.js';
15
+ import { createCheckpoint } from '../utils/checkpoint.js';
16
+
17
+ function readJSON(path) {
18
+ return JSON.parse(readFileSync(path, 'utf-8'));
19
+ }
20
+
21
+ function writeJSON(path, data) {
22
+ writeFileSync(path, JSON.stringify(data, null, 2));
23
+ }
24
+
25
+ export async function retry(projectDir) {
26
+ const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
27
+ const checkpoint = createCheckpoint(projectDir);
28
+ const retryDir = resolve(projectDir, '.codex-copilot/retry_context');
29
+
30
+ let tasks;
31
+ try {
32
+ tasks = readJSON(tasksPath);
33
+ } catch (err) {
34
+ log.error(`Failed to read tasks: ${err.message}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ const blocked = tasks.tasks.filter(t => t.status === 'blocked');
39
+
40
+ if (blocked.length === 0) {
41
+ log.info('No blocked tasks found \u2014 nothing to retry.');
42
+ return;
43
+ }
44
+
45
+ log.title('\ud83d\udd04 Blocked Task Recovery');
46
+ log.blank();
47
+ log.info(`Found ${blocked.length} blocked task(s):`);
48
+ log.blank();
49
+
50
+ // Ensure retry context directory exists
51
+ mkdirSync(retryDir, { recursive: true });
52
+
53
+ for (const task of blocked) {
54
+ const reason = task.block_reason || 'unknown';
55
+ log.info(` #${task.id}: ${task.title}`);
56
+ log.dim(` Reason: ${reason}`);
57
+
58
+ if (reason === 'review_failed') {
59
+ await recoverReviewBlocked(projectDir, task, checkpoint, retryDir);
60
+ } else if (reason === 'merge_failed') {
61
+ recoverMergeBlocked(task, checkpoint);
62
+ } else {
63
+ // dev_failed or unknown
64
+ recoverDevBlocked(projectDir, task, checkpoint, retryDir);
65
+ }
66
+
67
+ task.status = 'pending';
68
+ task.retry_count = (task.retry_count || 0) + 1;
69
+ log.info(` \u2705 Task #${task.id} reset to pending (retry #${task.retry_count})`);
70
+ log.blank();
71
+ }
72
+
73
+ writeJSON(tasksPath, tasks);
74
+
75
+ log.blank();
76
+ const done = tasks.tasks.filter(t => t.status === 'completed').length;
77
+ const pending = tasks.tasks.filter(t => t.status === 'pending').length;
78
+ progressBar(done, tasks.total, `${done}/${tasks.total} done, ${pending} pending`);
79
+ log.blank();
80
+ log.info('Run `codex-copilot run` to resume with enhanced prompts.');
81
+ }
82
+
83
+ /**
84
+ * Dev-blocked: save any partial work context, clear checkpoints
85
+ */
86
+ function recoverDevBlocked(projectDir, task, checkpoint, retryDir) {
87
+ // Try to capture any partial diff from the branch
88
+ const diffResult = git.execSafe(`git diff HEAD`, projectDir);
89
+ if (diffResult.ok && diffResult.output.trim()) {
90
+ const contextPath = resolve(retryDir, `${task.id}.md`);
91
+ writeFileSync(contextPath, [
92
+ `# Retry Context for Task #${task.id}`,
93
+ '',
94
+ '## Previous Attempt',
95
+ 'The AI attempted this task but failed to produce working code.',
96
+ 'Try a simpler, more incremental approach.',
97
+ '',
98
+ '## Partial Changes (from failed attempt)',
99
+ '```diff',
100
+ diffResult.output.substring(0, 5000), // Cap at 5KB
101
+ '```',
102
+ ].join('\n'));
103
+ log.dim(' Saved partial diff as retry context');
104
+ }
105
+
106
+ // Clear all checkpoints for this task
107
+ checkpoint.clearTask(task.id);
108
+ }
109
+
110
+ /**
111
+ * Review-blocked: close PR, delete branch, save review feedback
112
+ */
113
+ async function recoverReviewBlocked(projectDir, task, checkpoint, retryDir) {
114
+ const state = checkpoint.load();
115
+ const prNumber = state.current_pr;
116
+
117
+ // Save review feedback as retry context
118
+ if (prNumber) {
119
+ const feedback = github.collectReviewFeedback(projectDir, prNumber);
120
+ if (feedback) {
121
+ const contextPath = resolve(retryDir, `${task.id}.md`);
122
+ writeFileSync(contextPath, [
123
+ `# Retry Context for Task #${task.id}`,
124
+ '',
125
+ '## Known Issues from Previous Review',
126
+ 'The previous implementation was rejected. Avoid making the same mistakes.',
127
+ '',
128
+ feedback,
129
+ ].join('\n'));
130
+ log.dim(' Saved review feedback as retry context');
131
+ }
132
+
133
+ // Close the stale PR
134
+ if (github.closePR(projectDir, prNumber)) {
135
+ log.dim(` Closed stale PR #${prNumber}`);
136
+ }
137
+ }
138
+
139
+ // Delete the remote feature branch
140
+ if (task.branch) {
141
+ if (github.deleteBranch(projectDir, task.branch)) {
142
+ log.dim(` Deleted remote branch ${task.branch}`);
143
+ }
144
+ // Delete local branch too
145
+ git.execSafe(`git branch -D ${task.branch}`, projectDir);
146
+ }
147
+
148
+ // Clear all checkpoints for this task
149
+ checkpoint.clearTask(task.id);
150
+ }
151
+
152
+ /**
153
+ * Merge-blocked: only reset the merge step
154
+ */
155
+ function recoverMergeBlocked(task, checkpoint) {
156
+ checkpoint.clearStep(task.id, 'merge', 'merged');
157
+ log.dim(' Reset merge step \u2014 will retry merge on next run');
158
+ }
@@ -53,6 +53,7 @@ export async function run(projectDir) {
53
53
  const maxReviewRounds = config.max_review_rounds || 2;
54
54
  const pollInterval = config.review_poll_interval || 60;
55
55
  const waitTimeout = config.review_wait_timeout || 600;
56
+ const isPrivate = github.isPrivateRepo(projectDir); // Cache once
56
57
 
57
58
  const providerInfo = provider.getProvider(providerId);
58
59
  log.info(`AI Provider: ${providerInfo ? providerInfo.name : providerId}`);
@@ -110,16 +111,27 @@ export async function run(projectDir) {
110
111
  log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
111
112
  log.blank();
112
113
 
113
- // Check dependencies
114
+ // Check dependencies — completed, skipped, and blocked all satisfy dependencies.
115
+ // Blocked tasks are treated as "done for now" to prevent cascade-skipping;
116
+ // the user can retry them later with `codex-copilot retry`.
114
117
  if (task.depends_on && task.depends_on.length > 0) {
118
+ const DONE_STATUSES = ['completed', 'skipped', 'blocked'];
115
119
  const unfinished = task.depends_on.filter(dep => {
116
120
  const depTask = tasks.tasks.find(t => t.id === dep);
117
- return depTask && depTask.status !== 'completed';
121
+ return depTask && !DONE_STATUSES.includes(depTask.status);
118
122
  });
119
123
  if (unfinished.length > 0) {
120
124
  log.warn(`Unfinished dependencies: ${unfinished.join(', ')} — skipping`);
121
125
  continue;
122
126
  }
127
+ // Warn about blocked deps but still proceed
128
+ const blockedDeps = task.depends_on.filter(dep => {
129
+ const depTask = tasks.tasks.find(t => t.id === dep);
130
+ return depTask && depTask.status === 'blocked';
131
+ });
132
+ if (blockedDeps.length > 0) {
133
+ log.warn(`⚠ Dependencies ${blockedDeps.join(', ')} are blocked — proceeding anyway`);
134
+ }
123
135
  }
124
136
 
125
137
  // Mark task as in_progress
@@ -129,6 +141,13 @@ export async function run(projectDir) {
129
141
  // ===== Phase 1: Develop =====
130
142
  if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
131
143
  await developPhase(projectDir, task, baseBranch, checkpoint, providerId);
144
+ // If dev phase failed, mark blocked and skip to next task
145
+ if (task.status === 'blocked') {
146
+ writeJSON(tasksPath, tasks);
147
+ log.blank();
148
+ log.warn(`⚠ Task #${task.id} is blocked — AI development failed`);
149
+ continue;
150
+ }
132
151
  } else {
133
152
  log.dim('⏩ Skipping develop phase (already completed)');
134
153
  }
@@ -136,7 +155,7 @@ export async function run(projectDir) {
136
155
  // ===== Phase 2: Create PR =====
137
156
  let prInfo;
138
157
  if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
139
- prInfo = await prPhase(projectDir, task, baseBranch, checkpoint);
158
+ prInfo = await prPhase(projectDir, task, baseBranch, checkpoint, isPrivate);
140
159
  } else {
141
160
  // PR already created, load from state
142
161
  state = checkpoint.load();
@@ -150,7 +169,7 @@ export async function run(projectDir) {
150
169
  maxRounds: maxReviewRounds,
151
170
  pollInterval,
152
171
  waitTimeout,
153
- }, checkpoint, providerId);
172
+ }, checkpoint, providerId, isPrivate);
154
173
  } else {
155
174
  log.dim('⏩ Skipping review phase (already completed)');
156
175
  }
@@ -176,6 +195,8 @@ export async function run(projectDir) {
176
195
 
177
196
  // Mark task complete
178
197
  task.status = 'completed';
198
+ delete task.block_reason;
199
+ delete task.retry_count;
179
200
  writeJSON(tasksPath, tasks);
180
201
  checkpoint.completeTask(task.id);
181
202
 
@@ -186,7 +207,13 @@ export async function run(projectDir) {
186
207
  }
187
208
 
188
209
  log.blank();
189
- log.title('🎉 All tasks complete!');
210
+ const finalDone = tasks.tasks.filter(t => t.status === 'completed').length;
211
+ const finalBlocked = tasks.tasks.filter(t => t.status === 'blocked').length;
212
+ if (finalBlocked > 0) {
213
+ log.title(`✅ Finished — ${finalDone}/${tasks.total} done, ${finalBlocked} blocked`);
214
+ } else {
215
+ log.title('🎉 All tasks complete!');
216
+ }
190
217
  log.blank();
191
218
  process.removeListener('SIGINT', gracefulShutdown);
192
219
  process.removeListener('SIGTERM', gracefulShutdown);
@@ -215,7 +242,7 @@ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId
215
242
 
216
243
  // Step 2: Build development prompt
217
244
  if (!checkpoint.isStepDone(task.id, 'develop', 'prompt_ready')) {
218
- const devPrompt = buildDevPrompt(task);
245
+ const devPrompt = buildDevPrompt(task, projectDir);
219
246
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
220
247
  writeFileSync(promptPath, devPrompt);
221
248
  checkpoint.saveStep(task.id, 'develop', 'prompt_ready', { branch: task.branch });
@@ -231,6 +258,11 @@ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId
231
258
  if (ok) {
232
259
  log.info('Development complete');
233
260
  checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
261
+ } else {
262
+ log.error('AI development failed — marking task as blocked');
263
+ task.status = 'blocked';
264
+ task.block_reason = 'dev_failed';
265
+ return;
234
266
  }
235
267
  }
236
268
  }
@@ -238,14 +270,15 @@ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId
238
270
  // ──────────────────────────────────────────────
239
271
  // Phase 2: Create PR
240
272
  // ──────────────────────────────────────────────
241
- async function prPhase(projectDir, task, baseBranch, checkpoint) {
273
+ async function prPhase(projectDir, task, baseBranch, checkpoint, isPrivate) {
242
274
  log.step('Phase 2/4: Submit PR');
243
275
 
244
276
  // Step 1: Commit changes
245
277
  if (!checkpoint.isStepDone(task.id, 'pr', 'committed')) {
246
278
  if (!git.isClean(projectDir)) {
247
279
  log.info('Uncommitted changes detected, auto-committing...');
248
- git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}`);
280
+ const skipCI = isPrivate ? ' [skip ci]' : '';
281
+ git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}${skipCI}`);
249
282
  }
250
283
  checkpoint.saveStep(task.id, 'pr', 'committed', { branch: task.branch });
251
284
  log.info('Changes committed');
@@ -293,7 +326,7 @@ async function prPhase(projectDir, task, baseBranch, checkpoint) {
293
326
  // ──────────────────────────────────────────────
294
327
  // Phase 3: Review loop
295
328
  // ──────────────────────────────────────────────
296
- async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }, checkpoint, providerId) {
329
+ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }, checkpoint, providerId, isPrivate) {
297
330
  const HARD_MAX_ROUNDS = 5;
298
331
  const MAX_POLL_RETRIES = 3;
299
332
  let maxRounds = Math.min(_maxRounds, HARD_MAX_ROUNDS);
@@ -319,26 +352,41 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
319
352
 
320
353
  let gotReview = false;
321
354
 
322
- // Round 1 (or resume): proactively check for existing reviews.
323
- // After a fix push (round > 1): ONLY wait for NEW reviews
324
- // to avoid re-processing the same stale feedback in a loop.
325
- if (round <= 1 || round === startRound) {
326
- const existingReviews = github.getReviews(projectDir, prInfo.number);
327
- const existingComments = github.getIssueComments(projectDir, prInfo.number);
328
- const hasReview = existingReviews.some(r => r.state !== 'PENDING');
329
- const hasBotComment = existingComments.some(c =>
330
- c.user?.type === 'Bot' || c.user?.login?.includes('bot')
331
- );
332
-
333
- if (hasReview || hasBotComment) {
334
- log.info('Review found — processing immediately');
335
- gotReview = true;
336
- }
355
+ // Always proactively check for existing reviews first.
356
+ // This catches: already-posted bot reviews, stale reviews found on resume,
357
+ // and fast bot responses after fix pushes.
358
+ const existingReviews = github.getReviews(projectDir, prInfo.number);
359
+ const existingComments = github.getIssueComments(projectDir, prInfo.number);
360
+ const hasReview = existingReviews.some(r => r.state !== 'PENDING');
361
+ const hasBotComment = existingComments.some(c =>
362
+ c.user?.type === 'Bot' || c.user?.login?.includes('bot')
363
+ );
364
+
365
+ if (hasReview || hasBotComment) {
366
+ log.info('Review found — processing immediately');
367
+ gotReview = true;
337
368
  }
338
369
 
339
370
  if (!gotReview) {
340
- log.info(`Waiting for ${round > 1 ? 'new ' : ''}review... (timeout: ${waitTimeout}s)`);
341
- gotReview = await waitForReview(projectDir, prInfo.number, pollInterval, waitTimeout);
371
+ // After fix pushes (round > 1), bot should respond quickly if configured.
372
+ // Use shorter timeout to avoid wasting 10+ minutes waiting for nothing.
373
+ const effectiveTimeout = round > 1 ? Math.min(waitTimeout, 120) : waitTimeout;
374
+ log.info(`Waiting for ${round > 1 ? 'new ' : ''}review... (timeout: ${effectiveTimeout}s)`);
375
+ gotReview = await waitForReview(projectDir, prInfo.number, pollInterval, effectiveTimeout);
376
+ }
377
+
378
+ if (!gotReview) {
379
+ // Before giving up, do one last proactive check
380
+ const lastChance = github.getLatestReviewState(projectDir, prInfo.number);
381
+ if (lastChance === 'APPROVED') {
382
+ log.info('✅ Review approved (found on final check)!');
383
+ return;
384
+ }
385
+ const lastFeedback = github.collectReviewFeedback(projectDir, prInfo.number);
386
+ if (lastFeedback) {
387
+ log.info('Found existing feedback — processing');
388
+ gotReview = true;
389
+ }
342
390
  }
343
391
 
344
392
  if (!gotReview) {
@@ -346,6 +394,7 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
346
394
  if (pollRetries >= MAX_POLL_RETRIES) {
347
395
  log.error(`Review polling timed out ${MAX_POLL_RETRIES} times — marking task as blocked`);
348
396
  task.status = 'blocked';
397
+ task.block_reason = 'review_timeout';
349
398
  return;
350
399
  }
351
400
  log.warn(`Review wait timed out — auto-retrying (${pollRetries}/${MAX_POLL_RETRIES})...`);
@@ -435,7 +484,7 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
435
484
 
436
485
  // Push fix — only if there are actual changes
437
486
  if (!git.isClean(projectDir)) {
438
- const skipCI = github.isPrivateRepo(projectDir) ? ' [skip ci]' : '';
487
+ const skipCI = isPrivate ? ' [skip ci]' : '';
439
488
  git.commitAll(projectDir, `fix(task-${task.id}): address review comments (round ${round})${skipCI}`);
440
489
  git.pushBranch(projectDir, task.branch);
441
490
  log.info('Fix pushed');
@@ -451,6 +500,7 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
451
500
  log.error('AI fix produced no changes — marking task as blocked');
452
501
  log.error('This task needs manual code changes to resolve review issues');
453
502
  task.status = 'blocked';
503
+ task.block_reason = 'review_failed';
454
504
  return;
455
505
  }
456
506
  }
@@ -459,6 +509,17 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
459
509
  async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
460
510
  let elapsed = 0;
461
511
  const startReviewCount = github.getReviews(projectDir, prNumber).length;
512
+ const startCommentCount = github.getIssueComments(projectDir, prNumber).length;
513
+
514
+ // Spinner for visual feedback during polling
515
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
516
+ let spinIdx = 0;
517
+ const spinTimer = setInterval(() => {
518
+ const frame = SPINNER[spinIdx % SPINNER.length];
519
+ const remaining = Math.max(0, timeout - elapsed);
520
+ process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m Waiting for review... (${remaining}s remaining)`);
521
+ spinIdx++;
522
+ }, 80);
462
523
 
463
524
  while (elapsed < timeout) {
464
525
  await sleep(pollInterval * 1000);
@@ -467,20 +528,18 @@ async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
467
528
  const currentReviews = github.getReviews(projectDir, prNumber);
468
529
  const currentComments = github.getIssueComments(projectDir, prNumber);
469
530
 
470
- // Check for new reviews or bot comments
471
531
  const hasNewReview = currentReviews.length > startReviewCount;
472
- const hasBotComment = currentComments.some(c =>
473
- (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) &&
474
- new Date(c.created_at).getTime() > (Date.now() - elapsed * 1000)
475
- );
532
+ const hasNewBotComment = currentComments.length > startCommentCount &&
533
+ currentComments.some(c => c.user?.type === 'Bot' || c.user?.login?.includes('bot'));
476
534
 
477
- if (hasNewReview || hasBotComment) {
535
+ if (hasNewReview || hasNewBotComment) {
536
+ clearInterval(spinTimer);
537
+ process.stdout.write('\r\x1b[K');
478
538
  return true;
479
539
  }
480
-
481
- process.stdout.write('.');
482
540
  }
483
- console.log('');
541
+ clearInterval(spinTimer);
542
+ process.stdout.write('\r\x1b[K');
484
543
  return false;
485
544
  }
486
545
 
@@ -499,7 +558,7 @@ ${feedback}
499
558
  1. Fix each issue listed above
500
559
  2. Suggestions (non-blocking) can be skipped — explain why in the commit message
501
560
  3. Ensure fixes don't introduce new issues
502
- 4. When done, run: git add -A && git commit -m "fix(task-${task.id}): address review round ${round}"
561
+ 4. Do NOT run git add or git commit the automation handles committing
503
562
  `;
504
563
 
505
564
  // Save to file and execute via provider
@@ -543,6 +602,7 @@ async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
543
602
  if (!merged) {
544
603
  log.error('Merge failed after 3 attempts — marking task as blocked');
545
604
  task.status = 'blocked';
605
+ task.block_reason = 'merge_failed';
546
606
  return;
547
607
  }
548
608
 
@@ -598,25 +658,33 @@ async function ensureBaseReady(projectDir, baseBranch) {
598
658
  log.info(`Base branch '${baseBranch}' ready ✓`);
599
659
  }
600
660
 
601
- function buildDevPrompt(task) {
661
+ function buildDevPrompt(task, projectDir) {
602
662
  const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
603
663
  ? task.acceptance.map(a => `- ${a}`).join('\n')
604
664
  : '- Feature works correctly';
665
+
666
+ let retrySection = '';
667
+ if (projectDir) {
668
+ const retryContextPath = resolve(projectDir, `.codex-copilot/retry_context/${task.id}.md`);
669
+ if (existsSync(retryContextPath)) {
670
+ const retryContext = readFileSync(retryContextPath, 'utf-8');
671
+ retrySection = `\n## ⚠️ Retry Context (from previous failed attempt)\n${retryContext}\n`;
672
+ }
673
+ }
674
+
605
675
  return `Please complete the following development task:
606
676
 
607
677
  ## Task #${task.id}: ${task.title}
608
678
 
609
679
  ${task.description}
610
-
680
+ ${retrySection}
611
681
  ## Acceptance Criteria
612
682
  ${acceptanceList}
613
683
 
614
684
  ## Requirements
615
685
  1. Strictly follow the project's existing code style and tech stack
616
686
  2. Ensure the code compiles/runs correctly when done
617
- 3. When done, run:
618
- git add -A
619
- git commit -m "feat(task-${task.id}): ${task.title}"
687
+ 3. Do NOT run git add or git commit — the automation handles committing
620
688
  `;
621
689
  }
622
690
 
@@ -0,0 +1,68 @@
1
+ /**
2
+ * codex-copilot skip <task_id> - Force-skip a blocked task
3
+ *
4
+ * Marks a blocked/pending task as 'skipped' so that tasks
5
+ * depending on it can proceed. Use when a task is permanently
6
+ * stuck and downstream work should continue regardless.
7
+ */
8
+
9
+ import { readFileSync, writeFileSync } from 'fs';
10
+ import { resolve } from 'path';
11
+ import { log } from '../utils/logger.js';
12
+
13
+ function readJSON(path) {
14
+ return JSON.parse(readFileSync(path, 'utf-8'));
15
+ }
16
+
17
+ function writeJSON(path, data) {
18
+ writeFileSync(path, JSON.stringify(data, null, 2));
19
+ }
20
+
21
+ export async function skip(projectDir, taskId) {
22
+ const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
23
+
24
+ let tasks;
25
+ try {
26
+ tasks = readJSON(tasksPath);
27
+ } catch (err) {
28
+ log.error(`Failed to read tasks: ${err.message}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const id = parseInt(taskId, 10);
33
+ if (isNaN(id)) {
34
+ log.error('Invalid task ID. Usage: codex-copilot skip <task_id>');
35
+ process.exit(1);
36
+ }
37
+
38
+ const task = tasks.tasks.find(t => t.id === id);
39
+ if (!task) {
40
+ log.error(`Task #${id} not found`);
41
+ process.exit(1);
42
+ }
43
+
44
+ if (task.status === 'completed') {
45
+ log.info(`Task #${id} is already completed — no action needed`);
46
+ return;
47
+ }
48
+
49
+ if (task.status === 'skipped') {
50
+ log.info(`Task #${id} is already skipped`);
51
+ return;
52
+ }
53
+
54
+ // Find downstream tasks that depend on this one
55
+ const downstream = tasks.tasks.filter(t =>
56
+ t.depends_on && t.depends_on.includes(id)
57
+ );
58
+
59
+ task.status = 'skipped';
60
+ writeJSON(tasksPath, tasks);
61
+
62
+ log.info(`\u2705 Task #${id} marked as skipped: ${task.title}`);
63
+ if (downstream.length > 0) {
64
+ log.info(` ${downstream.length} downstream task(s) unblocked: ${downstream.map(t => `#${t.id}`).join(', ')}`);
65
+ }
66
+ log.blank();
67
+ log.info('Run `codex-copilot run` to continue.');
68
+ }
@@ -112,7 +112,38 @@ export function createCheckpoint(projectDir) {
112
112
  return state;
113
113
  }
114
114
 
115
- return { load, save, saveStep, completeTask, isStepDone, reset };
115
+ /**
116
+ * Clear all checkpoint data for a task (for retry)
117
+ */
118
+ function clearTask(taskId) {
119
+ const state = load();
120
+ if (state.current_task === taskId) {
121
+ return save({
122
+ phase: null,
123
+ phase_step: null,
124
+ current_pr: null,
125
+ review_round: 0,
126
+ branch: null,
127
+ });
128
+ }
129
+ return state;
130
+ }
131
+
132
+ /**
133
+ * Clear a specific phase step (for partial retry, e.g. merge-only)
134
+ */
135
+ function clearStep(taskId, phase, phaseStep) {
136
+ const state = load();
137
+ if (state.current_task === taskId && state.phase === phase && state.phase_step === phaseStep) {
138
+ return save({
139
+ phase: null,
140
+ phase_step: null,
141
+ });
142
+ }
143
+ return state;
144
+ }
145
+
146
+ return { load, save, saveStep, completeTask, isStepDone, reset, clearTask, clearStep };
116
147
  }
117
148
 
118
149
  function getDefaultState() {
@@ -311,7 +311,8 @@ export function isPrivateRepo(cwd) {
311
311
  */
312
312
  export function requestReReview(cwd, prNumber) {
313
313
  try {
314
- gh(`pr comment ${prNumber} --body "/review"`, cwd);
314
+ const num = validatePRNumber(prNumber);
315
+ gh(`pr comment ${num} --body "/review"`, cwd);
315
316
  return true;
316
317
  } catch {
317
318
  return false;
@@ -323,5 +324,32 @@ export const github = {
323
324
  ensureRemoteBranch, hasCommitsBetween,
324
325
  getReviews, getReviewComments, getIssueComments,
325
326
  getLatestReviewState, mergePR, collectReviewFeedback,
326
- isPrivateRepo, requestReReview,
327
+ isPrivateRepo, requestReReview, closePR, deleteBranch,
327
328
  };
329
+
330
+ /**
331
+ * Close a pull request without merging
332
+ */
333
+ export function closePR(cwd, prNumber) {
334
+ try {
335
+ const num = validatePRNumber(prNumber);
336
+ gh(`pr close ${num}`, cwd);
337
+ return true;
338
+ } catch {
339
+ return false;
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Delete a remote branch
345
+ */
346
+ export function deleteBranch(cwd, branch) {
347
+ try {
348
+ execSync(`git push origin --delete ${branch}`, {
349
+ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'],
350
+ });
351
+ return true;
352
+ } catch {
353
+ return false;
354
+ }
355
+ }
@@ -9,7 +9,7 @@
9
9
  * - Codex Desktop / Cursor IDE / Antigravity IDE: clipboard + manual
10
10
  */
11
11
 
12
- import { execSync } from 'child_process';
12
+ import { execSync, spawn } from 'child_process';
13
13
  import { readFileSync, writeFileSync } from 'fs';
14
14
  import { resolve } from 'path';
15
15
  import { log } from './logger.js';
@@ -196,7 +196,9 @@ export async function executePrompt(providerId, promptPath, cwd) {
196
196
  }
197
197
 
198
198
  /**
199
- * Execute via CLI provider — each tool has its own command pattern
199
+ * Execute via CLI provider — each tool has its own command pattern.
200
+ * Output is captured and filtered to show only file-level progress,
201
+ * keeping the terminal clean (like Claude Code's compact display).
200
202
  */
201
203
  async function executeCLI(prov, providerId, promptPath, cwd) {
202
204
  // Verify the CLI is still available
@@ -214,24 +216,71 @@ async function executeCLI(prov, providerId, promptPath, cwd) {
214
216
 
215
217
  const command = prov.buildCommand(promptPath, cwd);
216
218
  log.info(`Executing via ${prov.name}...`);
217
- log.dim(` ${command.substring(0, 80)}${command.length > 80 ? '...' : ''}`);
219
+ log.dim(` \u2192 ${command.substring(0, 80)}${command.length > 80 ? '...' : ''}`);
218
220
 
219
- try {
220
- execSync(command, { cwd, stdio: 'inherit', timeout: 0 });
221
- log.info(`${prov.name} execution complete`);
222
- return true;
223
- } catch (err) {
224
- log.warn(`${prov.name} execution failed: ${err.message}`);
221
+ return new Promise((resolvePromise) => {
222
+ const child = spawn('sh', ['-c', command], {
223
+ cwd,
224
+ stdio: ['pipe', 'pipe', 'pipe'],
225
+ });
225
226
 
226
- // For quota exhaustion / auth errors, give a clear message
227
- if (err.message.includes('rate_limit') || err.message.includes('quota') ||
228
- err.message.includes('429') || err.message.includes('insufficient')) {
229
- log.error('⚠ Possible quota exhaustion — checkpoint saved, you can resume later');
227
+ let lastFile = '';
228
+ let statusText = 'Working...';
229
+ let lineBuffer = '';
230
+ const FILE_EXT = /(?:^|\s|['"|(/])([a-zA-Z0-9_.\/-]+\.(?:rs|ts|js|jsx|tsx|py|go|toml|yaml|yml|json|md|css|html|sh|sql|prisma|vue|svelte))\b/;
231
+
232
+ // Spinner animation — gives a dynamic, alive feel
233
+ const SPINNER = ['\u280B', '\u2819', '\u2839', '\u2838', '\u283C', '\u2834', '\u2826', '\u2827', '\u2807', '\u280F'];
234
+ let spinIdx = 0;
235
+ const spinTimer = setInterval(() => {
236
+ const frame = SPINNER[spinIdx % SPINNER.length];
237
+ process.stdout.write(`\r\x1b[K \x1b[36m${frame}\x1b[0m ${statusText}`);
238
+ spinIdx++;
239
+ }, 80);
240
+
241
+ function processLine(line) {
242
+ const fileMatch = line.match(FILE_EXT);
243
+ if (fileMatch && fileMatch[1] !== lastFile) {
244
+ lastFile = fileMatch[1];
245
+ statusText = lastFile;
246
+ }
230
247
  }
231
248
 
232
- log.warn('Falling back to clipboard mode');
233
- return await clipboardFallback(promptPath);
234
- }
249
+ child.stdout.on('data', (data) => {
250
+ lineBuffer += data.toString();
251
+ const lines = lineBuffer.split('\n');
252
+ lineBuffer = lines.pop();
253
+ for (const line of lines) processLine(line);
254
+ });
255
+
256
+ child.stderr.on('data', (data) => {
257
+ const text = data.toString().trim();
258
+ if (text && !text.includes('\u2588') && !text.includes('progress')) {
259
+ for (const line of text.split('\n').slice(0, 3)) {
260
+ if (line.trim()) log.dim(` ${line.substring(0, 120)}`);
261
+ }
262
+ }
263
+ });
264
+
265
+ child.on('close', (code) => {
266
+ clearInterval(spinTimer);
267
+ process.stdout.write('\r\x1b[K');
268
+ if (code === 0) {
269
+ log.info(`${prov.name} execution complete`);
270
+ resolvePromise(true);
271
+ } else {
272
+ log.warn(`${prov.name} exited with code ${code}`);
273
+ resolvePromise(false);
274
+ }
275
+ });
276
+
277
+ child.on('error', (err) => {
278
+ clearInterval(spinTimer);
279
+ process.stdout.write('\r\x1b[K');
280
+ log.warn(`${prov.name} execution failed: ${err.message}`);
281
+ resolvePromise(false);
282
+ });
283
+ });
235
284
  }
236
285
 
237
286
  /**