@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 +41 -4
- package/package.json +1 -1
- package/src/commands/retry.js +158 -0
- package/src/commands/run.js +110 -42
- package/src/commands/skip.js +68 -0
- package/src/utils/checkpoint.js +32 -1
- package/src/utils/github.js +30 -2
- package/src/utils/provider.js +65 -16
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
|
-
|
|
137
|
+
process.exit(0);
|
|
101
138
|
}
|
|
102
139
|
} catch (err) {
|
|
103
140
|
log.error(`Execution failed: ${err.message}`);
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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 =
|
|
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
|
|
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 ||
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/src/utils/checkpoint.js
CHANGED
|
@@ -112,7 +112,38 @@ export function createCheckpoint(projectDir) {
|
|
|
112
112
|
return state;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
-
|
|
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() {
|
package/src/utils/github.js
CHANGED
|
@@ -311,7 +311,8 @@ export function isPrivateRepo(cwd) {
|
|
|
311
311
|
*/
|
|
312
312
|
export function requestReReview(cwd, prNumber) {
|
|
313
313
|
try {
|
|
314
|
-
|
|
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
|
+
}
|
package/src/utils/provider.js
CHANGED
|
@@ -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(`
|
|
219
|
+
log.dim(` \u2192 ${command.substring(0, 80)}${command.length > 80 ? '...' : ''}`);
|
|
218
220
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
233
|
-
|
|
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
|
/**
|