@jojonax/codex-copilot 1.4.1 → 1.4.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jojonax/codex-copilot",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "PRD-driven automated development orchestrator for CodeX / Cursor",
5
5
  "bin": {
6
6
  "codex-copilot": "./bin/cli.js"
@@ -95,8 +95,8 @@ export async function run(projectDir) {
95
95
 
96
96
  // ===== Auto-retry blocked tasks =====
97
97
  // On each run, reset blocked tasks to pending so they're retried in order.
98
- // Per-task checkpoint steps are cleared so the task starts fresh.
99
- // The main checkpoint position (current_task) is preserved.
98
+ // Branches and checkpoint steps are PRESERVED the task resumes from where
99
+ // it left off, keeping all previously developed code.
100
100
  const blockedTasks = tasks.tasks.filter(t => t.status === 'blocked');
101
101
  if (blockedTasks.length > 0) {
102
102
  log.blank();
@@ -105,30 +105,8 @@ export async function run(projectDir) {
105
105
  bt.status = 'pending';
106
106
  bt.retry_count = (bt.retry_count || 0) + 1;
107
107
  bt._retrying = true; // Flag: don't skip even if below checkpoint
108
-
109
- // Clear per-task checkpoint steps — the task needs a fresh start
110
- // (The main checkpoint.current_task is NOT affected)
111
- checkpoint.clearTask(bt.id);
112
-
113
- // Clean up old branch to avoid "branch already exists" errors
114
- try {
115
- git.resetBranch(projectDir, bt.branch, baseBranch);
116
- log.dim(` ↳ Task #${bt.id}: ${bt.title.substring(0, 50)} (attempt ${bt.retry_count + 1}, branch reset)`);
117
- } catch {
118
- // If branch cleanup fails, still let the task try
119
- log.dim(` ↳ Task #${bt.id}: ${bt.title.substring(0, 50)} (attempt ${bt.retry_count + 1})`);
120
- }
121
-
122
- // Close any existing PR for this task (stale from previous attempt)
123
- if (bt.pr_number) {
124
- try {
125
- github.closePR(projectDir, bt.pr_number);
126
- delete bt.pr_number;
127
- } catch { /* ignore */ }
128
- }
108
+ log.dim(` ↳ Task #${bt.id}: ${bt.title.substring(0, 50)} (attempt ${bt.retry_count + 1})`);
129
109
  }
130
- // Switch back to base branch after all resets
131
- git.checkoutMain(projectDir, baseBranch);
132
110
  writeJSON(tasksPath, tasks);
133
111
  }
134
112
 
@@ -178,6 +156,8 @@ export async function run(projectDir) {
178
156
 
179
157
  // Mark task as in_progress
180
158
  task.status = 'in_progress';
159
+ const isRetry = (task.retry_count || 0) > 0;
160
+ const retryStartedAt = isRetry ? new Date().toISOString() : null;
181
161
  writeJSON(tasksPath, tasks);
182
162
 
183
163
  // ===== Phase 1: Develop =====
@@ -207,10 +187,19 @@ export async function run(projectDir) {
207
187
 
208
188
  // ===== Phase 3: Review loop =====
209
189
  if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
190
+ // On retry: request fresh re-review since old reviews may be stale
191
+ if (isRetry && prInfo?.number) {
192
+ log.info('🔄 Retry: requesting fresh re-review on existing PR...');
193
+ try {
194
+ github.requestReReview(projectDir, prInfo.number);
195
+ } catch { /* ignore — review bots may not be configured */ }
196
+ }
197
+
210
198
  await reviewLoop(projectDir, task, prInfo, {
211
199
  maxRounds: maxReviewRounds,
212
200
  pollInterval,
213
201
  waitTimeout,
202
+ retryStartedAt, // Only count reviews after this timestamp
214
203
  }, checkpoint, providerId, isPrivate);
215
204
  } else {
216
205
  log.dim('⏩ Skipping review phase (already completed)');
@@ -220,7 +209,18 @@ export async function run(projectDir) {
220
209
  if (!checkpoint.isStepDone(task.id, 'merge', 'merged')) {
221
210
  await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
222
211
  } else {
223
- log.dim('⏩ Skipping merge phase (already merged)');
212
+ // Checkpoint says merged verify against GitHub
213
+ if (prInfo?.number) {
214
+ const prState = github.getPRState(projectDir, prInfo.number);
215
+ if (prState !== 'merged') {
216
+ log.warn(`⚠ Checkpoint says merged but PR #${prInfo.number} is ${prState} — re-entering merge`);
217
+ await mergePhase(projectDir, task, prInfo, baseBranch, checkpoint);
218
+ } else {
219
+ log.dim('⏩ Skipping merge phase (PR confirmed merged on GitHub)');
220
+ }
221
+ } else {
222
+ log.dim('⏩ Skipping merge phase (already merged)');
223
+ }
224
224
  }
225
225
 
226
226
  // Check if task was blocked during review/merge
@@ -379,7 +379,7 @@ async function prPhase(projectDir, task, baseBranch, checkpoint, isPrivate) {
379
379
  // ──────────────────────────────────────────────
380
380
  // Phase 3: Review loop
381
381
  // ──────────────────────────────────────────────
382
- async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout }, checkpoint, providerId, isPrivate) {
382
+ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pollInterval, waitTimeout, retryStartedAt }, checkpoint, providerId, isPrivate) {
383
383
  const HARD_MAX_ROUNDS = 5;
384
384
  const MAX_POLL_RETRIES = 3;
385
385
  let maxRounds = Math.min(_maxRounds, HARD_MAX_ROUNDS);
@@ -410,8 +410,19 @@ async function reviewLoop(projectDir, task, prInfo, { maxRounds: _maxRounds, pol
410
410
  // and fast bot responses after fix pushes.
411
411
  const existingReviews = github.getReviews(projectDir, prInfo.number);
412
412
  const existingComments = github.getIssueComments(projectDir, prInfo.number);
413
- const hasReview = existingReviews.some(r => r.state !== 'PENDING');
414
- const hasBotComment = existingComments.some(c =>
413
+
414
+ // On retry: only count reviews posted AFTER the retry started
415
+ const isReviewFresh = (item) => {
416
+ if (!retryStartedAt) return true; // Not a retry — all reviews are valid
417
+ const itemDate = item.submitted_at || item.created_at || item.updated_at;
418
+ return itemDate && new Date(itemDate) > new Date(retryStartedAt);
419
+ };
420
+
421
+ const freshReviews = existingReviews.filter(isReviewFresh);
422
+ const freshComments = existingComments.filter(isReviewFresh);
423
+
424
+ const hasReview = freshReviews.some(r => r.state !== 'PENDING');
425
+ const hasBotComment = freshComments.some(c =>
415
426
  c.user?.type === 'Bot' || c.user?.login?.includes('bot')
416
427
  );
417
428
 
package/src/utils/git.js CHANGED
@@ -52,48 +52,41 @@ export function getRepoInfo(cwd) {
52
52
  return { owner: match[1], repo: match[2] };
53
53
  }
54
54
 
55
- /**
56
- * Switch to target branch (create if not exists)
57
- */
58
55
  export function checkoutBranch(cwd, branch, baseBranch = 'main') {
59
56
  validateBranch(branch);
60
57
  validateBranch(baseBranch);
61
58
  const current = currentBranch(cwd);
62
59
  if (current === branch) return;
63
60
 
64
- // Switch to base and pull latest
65
- execSafe(`git checkout ${baseBranch}`, cwd);
66
- execSafe(`git pull origin ${baseBranch}`, cwd);
61
+ // Stash any uncommitted changes to allow safe branch switching
62
+ const hasChanges = !isClean(cwd);
63
+ if (hasChanges) {
64
+ execSafe('git stash --include-untracked', cwd);
65
+ }
67
66
 
68
- // Try to switch to existing branch
69
- const result = execSafe(`git checkout ${branch}`, cwd);
70
- if (!result.ok) {
71
- // Branch doesn't exist or checkout failed — clean up and create fresh
72
- execSafe(`git branch -D ${branch}`, cwd); // Delete if exists but broken
67
+ // Check if the branch already exists locally
68
+ const branchExists = execSafe(`git rev-parse --verify ${branch}`, cwd).ok;
69
+
70
+ if (branchExists) {
71
+ // Branch exists just switch to it (preserving all previous work)
72
+ exec(`git checkout ${branch}`, cwd);
73
+
74
+ // Try to rebase onto latest base to pick up any new changes
75
+ execSafe(`git fetch origin ${baseBranch}`, cwd);
76
+ execSafe(`git rebase origin/${baseBranch}`, cwd);
77
+ // If rebase fails (conflicts), abort and continue with existing state
78
+ execSafe('git rebase --abort', cwd);
79
+ } else {
80
+ // Branch doesn't exist — create from latest base
81
+ execSafe(`git checkout ${baseBranch}`, cwd);
82
+ execSafe(`git pull origin ${baseBranch}`, cwd);
73
83
  exec(`git checkout -b ${branch}`, cwd);
74
84
  }
75
- }
76
-
77
- /**
78
- * Delete a branch (local + remote) and recreate fresh from base.
79
- * Used when retrying blocked tasks to ensure a clean slate.
80
- */
81
- export function resetBranch(cwd, branch, baseBranch = 'main') {
82
- validateBranch(branch);
83
- validateBranch(baseBranch);
84
-
85
- // Switch to base first (can't delete current branch)
86
- execSafe(`git checkout ${baseBranch}`, cwd);
87
- execSafe(`git pull origin ${baseBranch}`, cwd);
88
-
89
- // Delete local branch (force, ignore errors if doesn't exist)
90
- execSafe(`git branch -D ${branch}`, cwd);
91
85
 
92
- // Delete remote branch (ignore errors if doesn't exist)
93
- execSafe(`git push origin --delete ${branch}`, cwd);
94
-
95
- // Create fresh branch from base
96
- exec(`git checkout -b ${branch}`, cwd);
86
+ // Restore stashed changes if we stashed earlier
87
+ if (hasChanges) {
88
+ execSafe('git stash pop', cwd);
89
+ }
97
90
  }
98
91
 
99
92
  /**
@@ -135,6 +128,6 @@ export function checkoutMain(cwd, baseBranch = 'main') {
135
128
  }
136
129
 
137
130
  export const git = {
138
- isClean, currentBranch, getRepoInfo, checkoutBranch, resetBranch,
131
+ isClean, currentBranch, getRepoInfo, checkoutBranch,
139
132
  commitAll, pushBranch, checkoutMain, exec, execSafe,
140
133
  };
@@ -383,7 +383,7 @@ export const github = {
383
383
  ensureRemoteBranch, hasCommitsBetween,
384
384
  getReviews, getReviewComments, getIssueComments,
385
385
  getLatestReviewState, mergePR, collectReviewFeedback,
386
- isPrivateRepo, requestReReview, closePR, deleteBranch,
386
+ isPrivateRepo, requestReReview, closePR, deleteBranch, getPRState,
387
387
  };
388
388
 
389
389
  /**
@@ -412,3 +412,18 @@ export function deleteBranch(cwd, branch) {
412
412
  return false;
413
413
  }
414
414
  }
415
+
416
+ /**
417
+ * Get the state of a PR: 'open', 'closed', or 'merged'
418
+ */
419
+ export function getPRState(cwd, prNumber) {
420
+ try {
421
+ const num = validatePRNumber(prNumber);
422
+ const output = gh(`pr view ${num} --json state,mergedAt`, cwd);
423
+ const data = JSON.parse(output);
424
+ if (data.mergedAt) return 'merged';
425
+ return (data.state || 'OPEN').toLowerCase();
426
+ } catch {
427
+ return 'unknown';
428
+ }
429
+ }