@jojonax/codex-copilot 1.0.3 → 1.2.0

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.
@@ -2,15 +2,17 @@
2
2
  * codex-copilot run - Main orchestration loop
3
3
  *
4
4
  * Executes tasks one by one: Develop → PR → Review → Fix → Merge → Next
5
+ * Features fine-grained checkpoint/resume at every sub-step.
5
6
  */
6
7
 
7
8
  import { readFileSync, writeFileSync, existsSync } from 'fs';
8
9
  import { resolve } from 'path';
9
- import { execSync } from 'child_process';
10
10
  import { log, progressBar } from '../utils/logger.js';
11
11
  import { git } from '../utils/git.js';
12
12
  import { github } from '../utils/github.js';
13
- import { ask, confirm, closePrompt } from '../utils/prompt.js';
13
+ import { ask, closePrompt } from '../utils/prompt.js';
14
+ import { createCheckpoint } from '../utils/checkpoint.js';
15
+ import { provider } from '../utils/provider.js';
14
16
 
15
17
  function readJSON(path) {
16
18
  return JSON.parse(readFileSync(path, 'utf-8'));
@@ -22,14 +24,14 @@ function writeJSON(path, data) {
22
24
 
23
25
  export async function run(projectDir) {
24
26
  const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
25
- const statePath = resolve(projectDir, '.codex-copilot/state.json');
26
27
  const configPath = resolve(projectDir, '.codex-copilot/config.json');
28
+ const checkpoint = createCheckpoint(projectDir);
27
29
 
28
30
  let tasks;
29
31
  let state;
30
32
  try {
31
33
  tasks = readJSON(tasksPath);
32
- state = readJSON(statePath);
34
+ state = checkpoint.load();
33
35
  } catch (err) {
34
36
  log.error(`Failed to read task/state files: ${err.message}`);
35
37
  log.warn('Files may be corrupted. Run: codex-copilot reset');
@@ -47,25 +49,63 @@ export async function run(projectDir) {
47
49
  }
48
50
 
49
51
  const baseBranch = config.base_branch || 'main';
52
+ const providerId = config.provider || 'codex-cli';
50
53
  const maxReviewRounds = config.max_review_rounds || 2;
51
54
  const pollInterval = config.review_poll_interval || 60;
52
55
  const waitTimeout = config.review_wait_timeout || 600;
53
56
 
57
+ const providerInfo = provider.getProvider(providerId);
58
+ log.info(`AI Provider: ${providerInfo ? providerInfo.name : providerId}`);
59
+
60
+ // ===== Graceful shutdown handler =====
61
+ let shuttingDown = false;
62
+ const gracefulShutdown = () => {
63
+ if (shuttingDown) {
64
+ log.warn('Force exit');
65
+ process.exit(1);
66
+ }
67
+ shuttingDown = true;
68
+ log.blank();
69
+ log.warn('Interrupt received — saving checkpoint...');
70
+ // State is already saved at each step, just need to save tasks.json
71
+ writeJSON(tasksPath, tasks);
72
+ log.info('✅ Checkpoint saved. Run `codex-copilot run` to resume.');
73
+ log.blank();
74
+ closePrompt();
75
+ process.exit(0);
76
+ };
77
+ process.on('SIGINT', gracefulShutdown);
78
+ process.on('SIGTERM', gracefulShutdown);
79
+
54
80
  log.title('🚀 Starting automated development loop');
55
81
  log.blank();
56
82
  log.info(`Project: ${tasks.project}`);
57
83
  log.info(`Total tasks: ${tasks.total}`);
58
- log.info(`Completed: ${state.current_task}`);
59
84
  log.info(`Base branch: ${baseBranch}`);
85
+
86
+ // ===== Pre-flight: ensure base branch is committed & pushed =====
87
+ await ensureBaseReady(projectDir, baseBranch);
88
+
89
+ // Show resume info if resuming mid-task
90
+ if (state.phase && state.current_task > 0) {
91
+ log.blank();
92
+ log.info(`⏩ Resuming task #${state.current_task} from: ${state.phase} → ${state.phase_step}`);
93
+ }
94
+
95
+ const completedCount = tasks.tasks.filter(t => t.status === 'completed').length;
60
96
  log.blank();
61
- progressBar(state.current_task, tasks.total, `${state.current_task}/${tasks.total} tasks done`);
97
+ progressBar(completedCount, tasks.total, `${completedCount}/${tasks.total} tasks done`);
62
98
  log.blank();
63
99
 
64
100
  // Execute tasks one by one
65
101
  for (const task of tasks.tasks) {
66
- if (task.id <= state.current_task) continue; // Skip completed
102
+ // Skip fully completed tasks
67
103
  if (task.status === 'completed' || task.status === 'skipped') continue;
68
104
 
105
+ // Skip tasks whose ID is below the completed threshold (and not the resuming task)
106
+ const isResumingTask = state.current_task === task.id && state.phase;
107
+ if (task.id < state.current_task && !isResumingTask) continue;
108
+
69
109
  log.blank();
70
110
  log.title(`━━━ Task ${task.id}/${tasks.total}: ${task.title} ━━━`);
71
111
  log.blank();
@@ -82,151 +122,183 @@ export async function run(projectDir) {
82
122
  }
83
123
  }
84
124
 
125
+ // Mark task as in_progress
126
+ task.status = 'in_progress';
127
+ writeJSON(tasksPath, tasks);
128
+
85
129
  // ===== Phase 1: Develop =====
86
- await developPhase(projectDir, task, baseBranch);
130
+ if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
131
+ await developPhase(projectDir, task, baseBranch, checkpoint, providerId);
132
+ } else {
133
+ log.dim('⏩ Skipping develop phase (already completed)');
134
+ }
87
135
 
88
136
  // ===== Phase 2: Create PR =====
89
- const prInfo = await prPhase(projectDir, task, baseBranch);
137
+ let prInfo;
138
+ if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
139
+ prInfo = await prPhase(projectDir, task, baseBranch, checkpoint);
140
+ } else {
141
+ // PR already created, load from state
142
+ state = checkpoint.load();
143
+ prInfo = { number: state.current_pr, url: '' };
144
+ log.dim(`⏩ Skipping PR phase (PR #${prInfo.number} already created)`);
145
+ }
90
146
 
91
147
  // ===== Phase 3: Review loop =====
92
- await reviewLoop(projectDir, task, prInfo, {
93
- maxRounds: maxReviewRounds,
94
- pollInterval,
95
- waitTimeout,
96
- });
148
+ if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
149
+ await reviewLoop(projectDir, task, prInfo, {
150
+ maxRounds: maxReviewRounds,
151
+ pollInterval,
152
+ waitTimeout,
153
+ }, checkpoint, providerId);
154
+ } else {
155
+ log.dim('⏩ Skipping review phase (already completed)');
156
+ }
97
157
 
98
158
  // ===== Phase 4: Merge =====
99
- await mergePhase(projectDir, task, prInfo, baseBranch);
159
+ if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
160
+ await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
161
+ } else {
162
+ log.dim('⏩ Skipping merge phase (already merged)');
163
+ }
100
164
 
101
- // Update task state
165
+ // Mark task complete
102
166
  task.status = 'completed';
103
- state.current_task = task.id;
104
- state.current_pr = null;
105
- state.review_round = 0;
106
167
  writeJSON(tasksPath, tasks);
107
- writeJSON(statePath, state);
168
+ checkpoint.completeTask(task.id);
108
169
 
109
170
  log.blank();
110
- progressBar(task.id, tasks.total, `${task.id}/${tasks.total} tasks done`);
171
+ const done = tasks.tasks.filter(t => t.status === 'completed').length;
172
+ progressBar(done, tasks.total, `${done}/${tasks.total} tasks done`);
111
173
  log.info(`✅ Task #${task.id} complete!`);
112
174
  }
113
175
 
114
176
  log.blank();
115
177
  log.title('🎉 All tasks complete!');
116
178
  log.blank();
179
+ process.removeListener('SIGINT', gracefulShutdown);
180
+ process.removeListener('SIGTERM', gracefulShutdown);
117
181
  closePrompt();
118
182
  }
119
183
 
120
184
  // ──────────────────────────────────────────────
121
185
  // Phase 1: Develop
122
186
  // ──────────────────────────────────────────────
123
- async function developPhase(projectDir, task, baseBranch) {
187
+ async function developPhase(projectDir, task, baseBranch, checkpoint, providerId) {
124
188
  log.step('Phase 1/4: Develop');
125
189
 
126
- // Switch to feature branch
127
- git.checkoutBranch(projectDir, task.branch, baseBranch);
128
- log.info(`Switched to branch: ${task.branch}`);
129
-
130
- // Build development prompt
131
- const devPrompt = buildDevPrompt(task);
132
-
133
- // Check if CodeX CLI is available
134
- const codexAvailable = isCodexAvailable();
135
-
136
- if (codexAvailable) {
137
- log.info('CodeX CLI detected, ready for auto-execution...');
138
- const autoRun = await confirm('Auto-invoke CodeX for development?');
139
- if (autoRun) {
140
- try {
141
- const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
142
- writeFileSync(promptPath, devPrompt);
143
- execSync(`cat .codex-copilot/_current_prompt.md | codex exec --full-auto -`, {
144
- cwd: projectDir,
145
- stdio: 'inherit',
146
- });
147
- log.info('CodeX development complete');
148
- return;
149
- } catch (err) {
150
- log.warn(`CodeX CLI invocation failed: ${err.message}`);
151
- log.warn('Falling back to manual mode');
152
- }
190
+ // Step 1: Switch to feature branch
191
+ if (!checkpoint.isStepDone(task.id, 'develop', 'branch_created')) {
192
+ git.checkoutBranch(projectDir, task.branch, baseBranch);
193
+ log.info(`Switched to branch: ${task.branch}`);
194
+ checkpoint.saveStep(task.id, 'develop', 'branch_created', { branch: task.branch });
195
+ } else {
196
+ // Ensure we're on the right branch
197
+ const current = git.currentBranch(projectDir);
198
+ if (current !== task.branch) {
199
+ git.checkoutBranch(projectDir, task.branch, baseBranch);
153
200
  }
201
+ log.dim('⏩ Branch already created');
154
202
  }
155
203
 
156
- // Manual mode: display prompt, wait for user confirmation
157
- log.blank();
158
- console.log(' ┌─── Paste the following into CodeX Desktop to execute ───┐');
159
- log.blank();
160
- console.log(devPrompt.split('\n').map(l => ` │ ${l}`).join('\n'));
161
- log.blank();
162
- console.log(' └─────────────────────────────────────────────────────────┘');
163
- log.blank();
164
-
165
- // Also save prompt to file for easy copying
166
- const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
167
- writeFileSync(promptPath, devPrompt);
168
- log.dim('Prompt saved to .codex-copilot/_current_prompt.md (you can copy the file directly)');
169
- log.blank();
170
-
171
- // Try to copy to clipboard (via stdin to avoid shell injection)
172
- copyToClipboard(devPrompt);
204
+ // Step 2: Build development prompt
205
+ if (!checkpoint.isStepDone(task.id, 'develop', 'prompt_ready')) {
206
+ const devPrompt = buildDevPrompt(task);
207
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
208
+ writeFileSync(promptPath, devPrompt);
209
+ checkpoint.saveStep(task.id, 'develop', 'prompt_ready', { branch: task.branch });
210
+ log.info('Development prompt generated');
211
+ } else {
212
+ log.dim('⏩ Prompt already generated');
213
+ }
173
214
 
174
- await ask('Press Enter after CodeX development is complete...');
215
+ // Step 3: Execute via AI Provider
216
+ if (!checkpoint.isStepDone(task.id, 'develop', 'codex_complete')) {
217
+ const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
218
+ const ok = await provider.executePrompt(providerId, promptPath, projectDir);
219
+ if (ok) {
220
+ log.info('Development complete');
221
+ checkpoint.saveStep(task.id, 'develop', 'codex_complete', { branch: task.branch });
222
+ }
223
+ }
175
224
  }
176
225
 
177
226
  // ──────────────────────────────────────────────
178
227
  // Phase 2: Create PR
179
228
  // ──────────────────────────────────────────────
180
- async function prPhase(projectDir, task, baseBranch) {
229
+ async function prPhase(projectDir, task, baseBranch, checkpoint) {
181
230
  log.step('Phase 2/4: Submit PR');
182
231
 
183
- // Ensure changes are committed
184
- if (!git.isClean(projectDir)) {
185
- log.info('Uncommitted changes detected, auto-committing...');
186
- git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}`);
232
+ // Step 1: Commit changes
233
+ if (!checkpoint.isStepDone(task.id, 'pr', 'committed')) {
234
+ if (!git.isClean(projectDir)) {
235
+ log.info('Uncommitted changes detected, auto-committing...');
236
+ git.commitAll(projectDir, `feat(task-${task.id}): ${task.title}`);
237
+ }
238
+ checkpoint.saveStep(task.id, 'pr', 'committed', { branch: task.branch });
239
+ log.info('Changes committed');
240
+ } else {
241
+ log.dim('⏩ Changes already committed');
187
242
  }
188
243
 
189
- // Push
190
- git.pushBranch(projectDir, task.branch);
191
- log.info('Code pushed');
244
+ // Step 2: Push
245
+ if (!checkpoint.isStepDone(task.id, 'pr', 'pushed')) {
246
+ git.pushBranch(projectDir, task.branch);
247
+ checkpoint.saveStep(task.id, 'pr', 'pushed', { branch: task.branch });
248
+ log.info('Code pushed');
249
+ } else {
250
+ log.dim('⏩ Code already pushed');
251
+ }
192
252
 
193
- // Create PR
194
- const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
195
- ? task.acceptance.map(a => `- [ ] ${a}`).join('\n')
196
- : '- [ ] Feature works correctly';
197
- 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*`;
253
+ // Step 3: Create PR
254
+ if (!checkpoint.isStepDone(task.id, 'pr', 'pr_created')) {
255
+ const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
256
+ ? task.acceptance.map(a => `- [ ] ${a}`).join('\n')
257
+ : '- [ ] Feature works correctly';
258
+ 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*`;
198
259
 
199
- try {
200
- const prInfo = github.createPR(projectDir, {
260
+ // Use auto-recovery: create → find existing → fix remote → retry
261
+ const prInfo = github.createPRWithRecovery(projectDir, {
201
262
  title: `feat(task-${task.id}): ${task.title}`,
202
263
  body: prBody,
203
264
  base: baseBranch,
204
265
  head: task.branch,
205
266
  });
206
- log.info(`PR created: ${prInfo.url}`);
267
+ log.info(`PR ready: #${prInfo.number} ${prInfo.url}`);
268
+
269
+ checkpoint.saveStep(task.id, 'pr', 'pr_created', {
270
+ branch: task.branch,
271
+ current_pr: prInfo.number,
272
+ });
207
273
  return prInfo;
208
- } catch (err) {
209
- // PR may already exist
210
- log.warn(`PR creation error: ${err.message}`);
211
- const prNumber = await ask('Enter existing PR number:');
212
- const parsed = parseInt(prNumber, 10);
213
- if (isNaN(parsed) || parsed <= 0) {
214
- log.error('Invalid PR number');
215
- throw new Error('Invalid PR number');
216
- }
217
- return { number: parsed, url: '' };
218
274
  }
275
+
276
+ // If we get here, PR was already created — load from state
277
+ const state = checkpoint.load();
278
+ return { number: state.current_pr, url: '' };
219
279
  }
220
280
 
221
281
  // ──────────────────────────────────────────────
222
282
  // Phase 3: Review loop
223
283
  // ──────────────────────────────────────────────
224
- async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }) {
284
+ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }, checkpoint, providerId) {
225
285
  let maxRounds = _maxRounds;
226
286
  log.step('Phase 3/4: Waiting for review');
227
287
 
228
- for (let round = 1; round <= maxRounds; round++) {
229
- // Wait for review
288
+ // Resume from saved review round
289
+ const state = checkpoint.load();
290
+ const startRound = (state.current_task === task.id && state.review_round > 0)
291
+ ? state.review_round
292
+ : 1;
293
+
294
+ for (let round = startRound; round <= maxRounds; round++) {
295
+ // Step: Waiting for review
296
+ checkpoint.saveStep(task.id, 'review', 'waiting_review', {
297
+ branch: task.branch,
298
+ current_pr: prInfo.number,
299
+ review_round: round,
300
+ });
301
+
230
302
  log.info(`Waiting for review feedback... (timeout: ${waitTimeout}s)`);
231
303
  const gotReview = await waitForReview(projectDir, prInfo.number, pollInterval, waitTimeout);
232
304
 
@@ -240,22 +312,63 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
240
312
  break;
241
313
  }
242
314
 
315
+ // Step: Feedback received
316
+ checkpoint.saveStep(task.id, 'review', 'feedback_received', {
317
+ branch: task.branch,
318
+ current_pr: prInfo.number,
319
+ review_round: round,
320
+ });
321
+
243
322
  // Check review status
244
- const state = github.getLatestReviewState(projectDir, prInfo.number);
245
- log.info(`Review status: ${state}`);
323
+ const reviewState = github.getLatestReviewState(projectDir, prInfo.number);
324
+ log.info(`Review status: ${reviewState}`);
246
325
 
247
- if (state === 'APPROVED') {
326
+ if (reviewState === 'APPROVED') {
248
327
  log.info('✅ Review approved!');
249
328
  return;
250
329
  }
251
330
 
252
- // Collect review feedback
331
+ // Collect review feedback (raw — classification done by AI)
253
332
  const feedback = github.collectReviewFeedback(projectDir, prInfo.number);
254
333
  if (!feedback) {
255
- log.info('No specific change requests found, proceeding');
334
+ log.info('No review feedback found proceeding');
335
+ return;
336
+ }
337
+
338
+ // Use AI to classify the review feedback
339
+ log.info('Classifying review feedback via AI...');
340
+ const classification = await provider.classifyReview(providerId, feedback, projectDir);
341
+
342
+ if (classification === 'pass') {
343
+ log.info('AI determined no actionable issues — proceeding ✅');
256
344
  return;
257
345
  }
258
346
 
347
+ if (classification === null) {
348
+ // AI classification failed — fall back to structural GitHub API signals
349
+ log.dim('AI classification unavailable, using structural fallback');
350
+ const inlineComments = github.getReviewComments(projectDir, prInfo.number);
351
+ const hasInlineComments = inlineComments && inlineComments.length > 0;
352
+
353
+ if (reviewState === 'CHANGES_REQUESTED') {
354
+ // Explicit change request — always treat as needing fixes
355
+ log.info('Review state: CHANGES_REQUESTED — entering fix phase');
356
+ } else if (reviewState === 'COMMENTED' && !hasInlineComments) {
357
+ // General comment with no code-level feedback — treat as passing
358
+ log.info('COMMENTED with no inline code comments — treating as passed ✅');
359
+ return;
360
+ } else if (!hasInlineComments) {
361
+ // Any other state (PENDING, null, etc.) with no inline comments — treat as passing
362
+ log.info('No inline code comments found — treating as passed ✅');
363
+ return;
364
+ } else {
365
+ // Has inline comments — treat as needing fixes
366
+ log.info(`Found ${inlineComments.length} inline code comment(s) — entering fix phase`);
367
+ }
368
+ }
369
+
370
+ // AI says FIX, or structural fallback indicates issues
371
+
259
372
  log.blank();
260
373
  log.warn(`Received review feedback (round ${round}/${maxRounds})`);
261
374
 
@@ -271,18 +384,28 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
271
384
  }
272
385
  }
273
386
 
274
- // Let CodeX fix
275
- await fixPhase(projectDir, task, feedback, round);
387
+ // Let AI fix
388
+ await fixPhase(projectDir, task, feedback, round, providerId);
276
389
 
277
- // Push fix
390
+ // Step: Fix applied
391
+ checkpoint.saveStep(task.id, 'review', 'fix_applied', {
392
+ branch: task.branch,
393
+ current_pr: prInfo.number,
394
+ review_round: round,
395
+ });
396
+
397
+ // Push fix — only if there are actual changes
278
398
  if (!git.isClean(projectDir)) {
279
399
  git.commitAll(projectDir, `fix(task-${task.id}): address review comments (round ${round})`);
400
+ git.pushBranch(projectDir, task.branch);
401
+ log.info('Fix pushed, waiting for next review round...');
402
+ // Brief wait for review bot to react
403
+ await sleep(10000);
404
+ } else {
405
+ // No actual changes were made by the fix — the AI determined nothing needed fixing
406
+ log.info('No changes needed after review — proceeding to merge ✅');
407
+ return;
280
408
  }
281
- git.pushBranch(projectDir, task.branch);
282
- log.info('Fix pushed, waiting for next review round...');
283
-
284
- // Brief wait for review bot to react
285
- await sleep(10000);
286
409
  }
287
410
  }
288
411
 
@@ -317,12 +440,12 @@ async function waitForReview(projectDir, prNumber, pollInterval, timeout) {
317
440
  // ──────────────────────────────────────────────
318
441
  // Fix phase
319
442
  // ──────────────────────────────────────────────
320
- async function fixPhase(projectDir, task, feedback, round) {
443
+ async function fixPhase(projectDir, task, feedback, round, providerId) {
321
444
  log.step(`Fixing review comments (round ${round})`);
322
445
 
323
- const fixPrompt = `The following are PR review comments. Please fix each one:
446
+ const fixPrompt = `Please fix the following review comments on Task #${task.id}: ${task.title}
324
447
 
325
- ## Review Comments
448
+ ## Review Feedback
326
449
  ${feedback}
327
450
 
328
451
  ## Requirements
@@ -332,50 +455,21 @@ ${feedback}
332
455
  4. When done, run: git add -A && git commit -m "fix(task-${task.id}): address review round ${round}"
333
456
  `;
334
457
 
335
- // Save to file and prompt user
458
+ // Save to file and execute via provider
336
459
  const promptPath = resolve(projectDir, '.codex-copilot/_current_prompt.md');
337
460
  writeFileSync(promptPath, fixPrompt);
338
461
 
339
- // Try CodeX CLI
340
- const codexAvailable = isCodexAvailable();
341
-
342
- if (codexAvailable) {
343
- const autoFix = await confirm('Auto-invoke CodeX to fix?');
344
- if (autoFix) {
345
- try {
346
- execSync(`cat .codex-copilot/_current_prompt.md | codex exec --full-auto -`, {
347
- cwd: projectDir,
348
- stdio: 'inherit',
349
- });
350
- log.info('CodeX fix complete');
351
- return;
352
- } catch {
353
- log.warn('CodeX CLI invocation failed, falling back to manual mode');
354
- }
355
- }
356
- }
357
-
358
- // Manual mode
359
- log.blank();
360
- log.dim('Review fix prompt saved to .codex-copilot/_current_prompt.md');
361
- log.dim('Paste the file content into CodeX to execute');
362
-
363
- copyToClipboard(fixPrompt);
364
-
365
- await ask('Press Enter after CodeX fix is complete...');
462
+ await provider.executePrompt(providerId, promptPath, projectDir);
463
+ log.info('Fix complete');
366
464
  }
367
465
 
368
466
  // ──────────────────────────────────────────────
369
467
  // Phase 4: Merge
370
468
  // ──────────────────────────────────────────────
371
- async function mergePhase(projectDir, task, prInfo, baseBranch) {
469
+ async function mergePhase(projectDir, task, prInfo, baseBranch, checkpoint) {
372
470
  log.step('Phase 4/4: Merge PR');
373
471
 
374
- const doMerge = await confirm(`Merge PR #${prInfo.number}?`);
375
- if (!doMerge) {
376
- log.warn('User skipped merge');
377
- return;
378
- }
472
+ log.info(`Auto-merging PR #${prInfo.number}...`);
379
473
 
380
474
  try {
381
475
  github.mergePR(projectDir, prInfo.number);
@@ -386,10 +480,58 @@ async function mergePhase(projectDir, task, prInfo, baseBranch) {
386
480
  await ask('Continue...');
387
481
  }
388
482
 
483
+ checkpoint.saveStep(task.id, 'merge', 'merged', {
484
+ branch: task.branch,
485
+ current_pr: prInfo.number,
486
+ });
487
+
389
488
  // Switch back to main branch
390
489
  git.checkoutMain(projectDir, baseBranch);
391
490
  }
392
491
 
492
+ // ──────────────────────────────────────────────
493
+ // Pre-flight: ensure base branch has commits & is pushed
494
+ // ──────────────────────────────────────────────
495
+ async function ensureBaseReady(projectDir, baseBranch) {
496
+ // Check if the repo has any commits at all
497
+ const hasCommits = git.execSafe('git rev-parse HEAD', projectDir);
498
+ if (!hasCommits.ok) {
499
+ // No commits yet — brand new repo
500
+ log.warn('No commits found in repository');
501
+ if (!git.isClean(projectDir)) {
502
+ log.info('Creating initial commit from existing code...');
503
+ git.commitAll(projectDir, 'chore: initial commit');
504
+ log.info('✅ Initial commit created');
505
+ } else {
506
+ log.warn('Repository is empty — no files to commit');
507
+ return;
508
+ }
509
+ } else {
510
+ // Has commits, but base branch might have uncommitted changes
511
+ const currentBranch = git.currentBranch(projectDir);
512
+ if (currentBranch === baseBranch && !git.isClean(projectDir)) {
513
+ log.info('Uncommitted changes on base branch, committing first...');
514
+ git.commitAll(projectDir, 'chore: save current progress before automation');
515
+ log.info('✅ Base branch changes committed');
516
+ }
517
+ }
518
+
519
+ // Ensure we're on the base branch
520
+ const currentBranch = git.currentBranch(projectDir);
521
+ if (currentBranch !== baseBranch) {
522
+ // If base branch doesn't exist locally, create it from current
523
+ const branchExists = git.execSafe(`git rev-parse --verify ${baseBranch}`, projectDir);
524
+ if (!branchExists.ok) {
525
+ log.info(`Creating base branch '${baseBranch}' from current branch...`);
526
+ git.execSafe(`git branch ${baseBranch}`, projectDir);
527
+ }
528
+ }
529
+
530
+ // Ensure base branch is pushed to remote
531
+ github.ensureRemoteBranch(projectDir, baseBranch);
532
+ log.info(`Base branch '${baseBranch}' ready ✓`);
533
+ }
534
+
393
535
  function buildDevPrompt(task) {
394
536
  const acceptanceList = Array.isArray(task.acceptance) && task.acceptance.length > 0
395
537
  ? task.acceptance.map(a => `- ${a}`).join('\n')
@@ -416,19 +558,5 @@ function sleep(ms) {
416
558
  return new Promise(resolve => setTimeout(resolve, ms));
417
559
  }
418
560
 
419
- function isCodexAvailable() {
420
- try {
421
- const cmd = process.platform === 'win32' ? 'where codex' : 'which codex';
422
- execSync(cmd, { stdio: 'pipe' });
423
- return true;
424
- } catch {
425
- return false;
426
- }
427
- }
428
561
 
429
- function copyToClipboard(text) {
430
- try {
431
- execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
432
- log.info('📋 Copied to clipboard');
433
- } catch {}
434
- }
562
+