@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.
- package/bin/cli.js +5 -0
- package/package.json +1 -1
- package/src/commands/init.js +44 -36
- package/src/commands/reset.js +16 -8
- package/src/commands/run.js +289 -161
- package/src/commands/status.js +24 -7
- package/src/utils/automator.js +279 -0
- package/src/utils/checkpoint.js +129 -0
- package/src/utils/git.js +6 -2
- package/src/utils/github.js +145 -8
- package/src/utils/provider.js +415 -0
package/src/commands/status.js
CHANGED
|
@@ -2,18 +2,19 @@
|
|
|
2
2
|
* codex-copilot status - Show current progress
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { readFileSync } from 'fs';
|
|
5
|
+
import { readFileSync, existsSync } from 'fs';
|
|
6
6
|
import { resolve } from 'path';
|
|
7
7
|
import { log, progressBar } from '../utils/logger.js';
|
|
8
|
+
import { createCheckpoint } from '../utils/checkpoint.js';
|
|
8
9
|
|
|
9
10
|
export async function status(projectDir) {
|
|
10
11
|
const tasksPath = resolve(projectDir, '.codex-copilot/tasks.json');
|
|
11
|
-
const
|
|
12
|
+
const checkpoint = createCheckpoint(projectDir);
|
|
12
13
|
|
|
13
14
|
let tasks, state;
|
|
14
15
|
try {
|
|
15
16
|
tasks = JSON.parse(readFileSync(tasksPath, 'utf-8'));
|
|
16
|
-
state =
|
|
17
|
+
state = checkpoint.load();
|
|
17
18
|
} catch (err) {
|
|
18
19
|
log.error(`Failed to read files: ${err.message}`);
|
|
19
20
|
log.warn('Files may be corrupted. Run: codex-copilot reset');
|
|
@@ -39,9 +40,22 @@ export async function status(projectDir) {
|
|
|
39
40
|
if (skipped > 0) log.warn(`⏭ Skipped: ${skipped}`);
|
|
40
41
|
log.blank();
|
|
41
42
|
|
|
42
|
-
// Current state
|
|
43
|
-
if (state.
|
|
44
|
-
|
|
43
|
+
// Current checkpoint state
|
|
44
|
+
if (state.phase) {
|
|
45
|
+
const phaseLabels = {
|
|
46
|
+
develop: '🔨 Develop',
|
|
47
|
+
pr: '📦 PR',
|
|
48
|
+
review: '👀 Review',
|
|
49
|
+
merge: '🔀 Merge',
|
|
50
|
+
};
|
|
51
|
+
const phaseLabel = phaseLabels[state.phase] || state.phase;
|
|
52
|
+
log.info(`📍 Checkpoint: Task #${state.current_task} — ${phaseLabel} → ${state.phase_step}`);
|
|
53
|
+
if (state.branch) log.dim(` Branch: ${state.branch}`);
|
|
54
|
+
if (state.current_pr) log.dim(` PR: #${state.current_pr}`);
|
|
55
|
+
if (state.review_round > 0) log.dim(` Review round: ${state.review_round}`);
|
|
56
|
+
if (state.last_updated) log.dim(` Last saved: ${state.last_updated}`);
|
|
57
|
+
} else if (state.current_task > 0) {
|
|
58
|
+
log.info(`Last completed task: #${state.current_task}`);
|
|
45
59
|
}
|
|
46
60
|
log.blank();
|
|
47
61
|
|
|
@@ -49,11 +63,14 @@ export async function status(projectDir) {
|
|
|
49
63
|
log.title('Task list:');
|
|
50
64
|
log.blank();
|
|
51
65
|
for (const task of tasks.tasks) {
|
|
66
|
+
const isCurrentTask = state.current_task === task.id && state.phase;
|
|
52
67
|
const icon = task.status === 'completed' ? '✅' :
|
|
68
|
+
isCurrentTask ? '🔄' :
|
|
53
69
|
task.status === 'in_progress' ? '🔄' :
|
|
54
70
|
task.status === 'developed' ? '📦' :
|
|
55
71
|
task.status === 'skipped' ? '⏭ ' : '⬜';
|
|
56
|
-
|
|
72
|
+
const suffix = isCurrentTask ? ` ← ${state.phase}:${state.phase_step}` : '';
|
|
73
|
+
console.log(` ${icon} #${task.id} ${task.title} [${task.branch}]${suffix}`);
|
|
57
74
|
}
|
|
58
75
|
log.blank();
|
|
59
76
|
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Desktop IDE Automator — platform-aware process detection + auto-paste
|
|
3
|
+
*
|
|
4
|
+
* Supports macOS (AppleScript/osascript) and Windows (PowerShell/SendKeys).
|
|
5
|
+
* Each IDE has its own keystroke recipe.
|
|
6
|
+
* Falls back gracefully if automation fails.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { execSync } from 'child_process';
|
|
10
|
+
import { log } from './logger.js';
|
|
11
|
+
|
|
12
|
+
const IS_MAC = process.platform === 'darwin';
|
|
13
|
+
const IS_WIN = process.platform === 'win32';
|
|
14
|
+
|
|
15
|
+
// ──────────────────────────────────────────────
|
|
16
|
+
// Per-IDE Automation Recipes
|
|
17
|
+
// ──────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Each recipe defines:
|
|
21
|
+
* processName: { mac, win } — binary/process name to detect
|
|
22
|
+
* appName: { mac, win } — display name for activation
|
|
23
|
+
* preKeys: keystroke sequence BEFORE paste (e.g., open Composer in Cursor)
|
|
24
|
+
* preDelay: ms to wait after preKeys
|
|
25
|
+
* postKeys: keystroke sequence AFTER paste (usually Enter to send)
|
|
26
|
+
* pasteDelay: ms to wait between paste and postKeys
|
|
27
|
+
*/
|
|
28
|
+
const IDE_RECIPES = {
|
|
29
|
+
'codex-desktop': {
|
|
30
|
+
processName: { mac: 'Codex', win: 'Codex' },
|
|
31
|
+
appName: { mac: 'Codex', win: 'Codex' },
|
|
32
|
+
// Codex Desktop: input is focused on activate → paste → Enter
|
|
33
|
+
preKeys: null,
|
|
34
|
+
preDelay: 0,
|
|
35
|
+
postKeys: 'return',
|
|
36
|
+
pasteDelay: 300,
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
'cursor': {
|
|
40
|
+
processName: { mac: 'Cursor', win: 'Cursor' },
|
|
41
|
+
appName: { mac: 'Cursor', win: 'Cursor' },
|
|
42
|
+
// Cursor: Cmd/Ctrl+I opens Composer, then paste, then Enter
|
|
43
|
+
preKeys: IS_MAC
|
|
44
|
+
? 'keystroke "i" using {command down}'
|
|
45
|
+
: '^i',
|
|
46
|
+
preDelay: 800,
|
|
47
|
+
postKeys: 'return',
|
|
48
|
+
pasteDelay: 300,
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
'antigravity': {
|
|
52
|
+
processName: { mac: 'Antigravity', win: 'Antigravity' },
|
|
53
|
+
appName: { mac: 'Antigravity', win: 'Antigravity' },
|
|
54
|
+
// Antigravity: chat input auto-focused → paste → Enter
|
|
55
|
+
preKeys: null,
|
|
56
|
+
preDelay: 0,
|
|
57
|
+
postKeys: 'return',
|
|
58
|
+
pasteDelay: 300,
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// ──────────────────────────────────────────────
|
|
63
|
+
// Process Detection
|
|
64
|
+
// ──────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Check if a process is running
|
|
68
|
+
* @param {string} processName - Name of the process
|
|
69
|
+
* @returns {boolean}
|
|
70
|
+
*/
|
|
71
|
+
export function isProcessRunning(processName) {
|
|
72
|
+
try {
|
|
73
|
+
if (IS_MAC) {
|
|
74
|
+
const result = execSync(
|
|
75
|
+
`pgrep -x "${processName}" || pgrep -fi "${processName}"`,
|
|
76
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
77
|
+
).trim();
|
|
78
|
+
return result.length > 0;
|
|
79
|
+
} else if (IS_WIN) {
|
|
80
|
+
const result = execSync(
|
|
81
|
+
`tasklist /FI "IMAGENAME eq ${processName}.exe" /NH`,
|
|
82
|
+
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
83
|
+
).trim();
|
|
84
|
+
return !result.includes('No tasks');
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ──────────────────────────────────────────────
|
|
93
|
+
// macOS Automation (AppleScript)
|
|
94
|
+
// ──────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
function buildAppleScript(recipe) {
|
|
97
|
+
const appName = recipe.appName.mac;
|
|
98
|
+
const lines = [];
|
|
99
|
+
|
|
100
|
+
// Activate the application
|
|
101
|
+
lines.push(`tell application "${appName}" to activate`);
|
|
102
|
+
lines.push('delay 0.5');
|
|
103
|
+
|
|
104
|
+
// System Events block for keystrokes
|
|
105
|
+
lines.push('tell application "System Events"');
|
|
106
|
+
|
|
107
|
+
// Pre-keys (e.g., Cmd+I for Cursor Composer)
|
|
108
|
+
if (recipe.preKeys) {
|
|
109
|
+
lines.push(` ${recipe.preKeys}`);
|
|
110
|
+
lines.push(` delay ${recipe.preDelay / 1000}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Paste: Cmd+V
|
|
114
|
+
lines.push(' keystroke "v" using {command down}');
|
|
115
|
+
lines.push(` delay ${recipe.pasteDelay / 1000}`);
|
|
116
|
+
|
|
117
|
+
// Post-keys: usually Enter to send
|
|
118
|
+
if (recipe.postKeys === 'return') {
|
|
119
|
+
lines.push(' keystroke return');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
lines.push('end tell');
|
|
123
|
+
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function runAppleScript(script) {
|
|
128
|
+
try {
|
|
129
|
+
execSync(`osascript -e '${script.replace(/'/g, "'\"'\"'")}'`, {
|
|
130
|
+
encoding: 'utf-8',
|
|
131
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
132
|
+
timeout: 10000,
|
|
133
|
+
});
|
|
134
|
+
return true;
|
|
135
|
+
} catch (err) {
|
|
136
|
+
log.warn(`AppleScript failed: ${err.message}`);
|
|
137
|
+
if (err.message.includes('not allowed assistive access')) {
|
|
138
|
+
log.error('⚠ Accessibility permission required:');
|
|
139
|
+
log.error(' System Settings → Privacy & Security → Accessibility');
|
|
140
|
+
log.error(' Add your terminal app (Terminal / iTerm2 / Warp)');
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ──────────────────────────────────────────────
|
|
147
|
+
// Windows Automation (PowerShell)
|
|
148
|
+
// ──────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
function buildPowerShell(recipe) {
|
|
151
|
+
const appName = recipe.appName.win;
|
|
152
|
+
const lines = [];
|
|
153
|
+
|
|
154
|
+
lines.push('Add-Type -AssemblyName System.Windows.Forms');
|
|
155
|
+
lines.push('Add-Type -AssemblyName Microsoft.VisualBasic');
|
|
156
|
+
|
|
157
|
+
// Activate window
|
|
158
|
+
lines.push(`[Microsoft.VisualBasic.Interaction]::AppActivate("${appName}")`);
|
|
159
|
+
lines.push('Start-Sleep -Milliseconds 500');
|
|
160
|
+
|
|
161
|
+
// Pre-keys
|
|
162
|
+
if (recipe.preKeys) {
|
|
163
|
+
lines.push(`[System.Windows.Forms.SendKeys]::SendWait("${recipe.preKeys}")`);
|
|
164
|
+
lines.push(`Start-Sleep -Milliseconds ${recipe.preDelay}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Paste: Ctrl+V
|
|
168
|
+
lines.push('[System.Windows.Forms.SendKeys]::SendWait("^v")');
|
|
169
|
+
lines.push(`Start-Sleep -Milliseconds ${recipe.pasteDelay}`);
|
|
170
|
+
|
|
171
|
+
// Post-keys: Enter
|
|
172
|
+
if (recipe.postKeys === 'return') {
|
|
173
|
+
lines.push('[System.Windows.Forms.SendKeys]::SendWait("{ENTER}")');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return lines.join('; ');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function runPowerShell(script) {
|
|
180
|
+
try {
|
|
181
|
+
execSync(`powershell -NoProfile -Command "${script.replace(/"/g, '\\"')}"`, {
|
|
182
|
+
encoding: 'utf-8',
|
|
183
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
184
|
+
timeout: 10000,
|
|
185
|
+
});
|
|
186
|
+
return true;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
log.warn(`PowerShell automation failed: ${err.message}`);
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ──────────────────────────────────────────────
|
|
194
|
+
// Public API
|
|
195
|
+
// ──────────────────────────────────────────────
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Check if a specific IDE has a known automation recipe
|
|
199
|
+
*/
|
|
200
|
+
export function hasRecipe(providerId) {
|
|
201
|
+
return providerId in IDE_RECIPES;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Check if the IDE is currently running
|
|
206
|
+
*/
|
|
207
|
+
export function isIDERunning(providerId) {
|
|
208
|
+
const recipe = IDE_RECIPES[providerId];
|
|
209
|
+
if (!recipe) return false;
|
|
210
|
+
|
|
211
|
+
const processName = IS_MAC ? recipe.processName.mac : recipe.processName.win;
|
|
212
|
+
return isProcessRunning(processName);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Attempt to auto-paste prompt into the IDE and trigger send
|
|
217
|
+
*
|
|
218
|
+
* Flow: copy to clipboard → activate window → pre-keys → paste → post-keys
|
|
219
|
+
*
|
|
220
|
+
* @param {string} providerId - IDE provider ID
|
|
221
|
+
* @param {string} text - Prompt text to paste
|
|
222
|
+
* @returns {boolean} true if automation succeeded
|
|
223
|
+
*/
|
|
224
|
+
export function activateAndPaste(providerId, text) {
|
|
225
|
+
const recipe = IDE_RECIPES[providerId];
|
|
226
|
+
if (!recipe) {
|
|
227
|
+
log.warn(`No automation recipe for '${providerId}'`);
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Step 1: Check process is running
|
|
232
|
+
const processName = IS_MAC ? recipe.processName.mac : recipe.processName.win;
|
|
233
|
+
if (!isProcessRunning(processName)) {
|
|
234
|
+
log.warn(`${recipe.appName[IS_MAC ? 'mac' : 'win']} is not running`);
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Step 2: Copy to clipboard (handled by caller, but ensure it's done)
|
|
239
|
+
copyToSystemClipboard(text);
|
|
240
|
+
|
|
241
|
+
// Step 3: Run platform-specific automation
|
|
242
|
+
log.info(`Auto-pasting into ${recipe.appName[IS_MAC ? 'mac' : 'win']}...`);
|
|
243
|
+
|
|
244
|
+
if (IS_MAC) {
|
|
245
|
+
const script = buildAppleScript(recipe);
|
|
246
|
+
return runAppleScript(script);
|
|
247
|
+
} else if (IS_WIN) {
|
|
248
|
+
const script = buildPowerShell(recipe);
|
|
249
|
+
return runPowerShell(script);
|
|
250
|
+
} else {
|
|
251
|
+
log.warn('Desktop automation not supported on this platform');
|
|
252
|
+
return false;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Copy text to system clipboard (cross-platform)
|
|
258
|
+
*/
|
|
259
|
+
function copyToSystemClipboard(text) {
|
|
260
|
+
try {
|
|
261
|
+
if (IS_MAC) {
|
|
262
|
+
execSync('pbcopy', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
263
|
+
} else if (IS_WIN) {
|
|
264
|
+
execSync('clip', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
265
|
+
} else {
|
|
266
|
+
try {
|
|
267
|
+
execSync('xclip -selection clipboard', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
268
|
+
} catch {
|
|
269
|
+
execSync('xsel --clipboard --input', { input: text, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
} catch {
|
|
273
|
+
// Clipboard failure is non-fatal — user can copy file manually
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const automator = {
|
|
278
|
+
isProcessRunning, hasRecipe, isIDERunning, activateAndPaste,
|
|
279
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint state manager - Fine-grained state persistence
|
|
3
|
+
*
|
|
4
|
+
* Provides atomic save/load for checkpoint state, enabling
|
|
5
|
+
* resume from any sub-step within a task after interruption.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, renameSync, existsSync } from 'fs';
|
|
9
|
+
import { resolve } from 'path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a checkpoint manager for a project
|
|
13
|
+
* @param {string} projectDir - Project root directory
|
|
14
|
+
*/
|
|
15
|
+
export function createCheckpoint(projectDir) {
|
|
16
|
+
const statePath = resolve(projectDir, '.codex-copilot/state.json');
|
|
17
|
+
const tempPath = resolve(projectDir, '.codex-copilot/state.json.tmp');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Load current state from disk
|
|
21
|
+
*/
|
|
22
|
+
function load() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(statePath, 'utf-8'));
|
|
25
|
+
} catch {
|
|
26
|
+
return getDefaultState();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Save state atomically (write to temp file, then rename)
|
|
32
|
+
* @param {object} update - Partial state to merge
|
|
33
|
+
*/
|
|
34
|
+
function save(update) {
|
|
35
|
+
const current = load();
|
|
36
|
+
const merged = {
|
|
37
|
+
...current,
|
|
38
|
+
...update,
|
|
39
|
+
last_updated: new Date().toISOString(),
|
|
40
|
+
};
|
|
41
|
+
// Atomic write: temp file + rename prevents corruption on crash
|
|
42
|
+
writeFileSync(tempPath, JSON.stringify(merged, null, 2));
|
|
43
|
+
renameSync(tempPath, statePath);
|
|
44
|
+
return merged;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mark a specific phase & step as reached
|
|
49
|
+
*/
|
|
50
|
+
function saveStep(taskId, phase, phaseStep, extra = {}) {
|
|
51
|
+
return save({
|
|
52
|
+
current_task: taskId,
|
|
53
|
+
phase,
|
|
54
|
+
phase_step: phaseStep,
|
|
55
|
+
...extra,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Mark current task as fully completed, clear phase info
|
|
61
|
+
*/
|
|
62
|
+
function completeTask(taskId) {
|
|
63
|
+
return save({
|
|
64
|
+
current_task: taskId,
|
|
65
|
+
phase: null,
|
|
66
|
+
phase_step: null,
|
|
67
|
+
current_pr: null,
|
|
68
|
+
review_round: 0,
|
|
69
|
+
branch: null,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if a specific phase+step has been reached for a task
|
|
75
|
+
* Returns true if the saved state is at or past the given step
|
|
76
|
+
*/
|
|
77
|
+
function isStepDone(taskId, phase, phaseStep) {
|
|
78
|
+
const state = load();
|
|
79
|
+
if (state.current_task !== taskId) return false;
|
|
80
|
+
if (!state.phase) return false;
|
|
81
|
+
|
|
82
|
+
const phaseOrder = ['develop', 'pr', 'review', 'merge'];
|
|
83
|
+
const savedPhaseIdx = phaseOrder.indexOf(state.phase);
|
|
84
|
+
const targetPhaseIdx = phaseOrder.indexOf(phase);
|
|
85
|
+
|
|
86
|
+
// If saved phase is ahead, this step is done
|
|
87
|
+
if (savedPhaseIdx > targetPhaseIdx) return true;
|
|
88
|
+
// If saved phase is behind, this step is not done
|
|
89
|
+
if (savedPhaseIdx < targetPhaseIdx) return false;
|
|
90
|
+
|
|
91
|
+
// Same phase: compare steps within phase
|
|
92
|
+
const stepOrders = {
|
|
93
|
+
develop: ['branch_created', 'prompt_ready', 'codex_complete'],
|
|
94
|
+
pr: ['committed', 'pushed', 'pr_created'],
|
|
95
|
+
review: ['waiting_review', 'feedback_received', 'fix_applied'],
|
|
96
|
+
merge: ['merged'],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const steps = stepOrders[phase] || [];
|
|
100
|
+
const savedStepIdx = steps.indexOf(state.phase_step);
|
|
101
|
+
const targetStepIdx = steps.indexOf(phaseStep);
|
|
102
|
+
|
|
103
|
+
return savedStepIdx >= targetStepIdx;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Full reset to initial state
|
|
108
|
+
*/
|
|
109
|
+
function reset() {
|
|
110
|
+
const state = getDefaultState();
|
|
111
|
+
writeFileSync(statePath, JSON.stringify(state, null, 2));
|
|
112
|
+
return state;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { load, save, saveStep, completeTask, isStepDone, reset };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getDefaultState() {
|
|
119
|
+
return {
|
|
120
|
+
current_task: 0,
|
|
121
|
+
phase: null,
|
|
122
|
+
phase_step: null,
|
|
123
|
+
current_pr: null,
|
|
124
|
+
review_round: 0,
|
|
125
|
+
branch: null,
|
|
126
|
+
status: 'initialized',
|
|
127
|
+
last_updated: new Date().toISOString(),
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/utils/git.js
CHANGED
|
@@ -91,11 +91,15 @@ function shellEscape(str) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
/**
|
|
94
|
-
* Push branch to remote
|
|
94
|
+
* Push branch to remote (handles new branches without upstream)
|
|
95
95
|
*/
|
|
96
96
|
export function pushBranch(cwd, branch) {
|
|
97
97
|
validateBranch(branch);
|
|
98
|
-
|
|
98
|
+
const result = execSafe(`git push origin ${branch} --force-with-lease`, cwd);
|
|
99
|
+
if (!result.ok) {
|
|
100
|
+
// Force-with-lease fails on new branches — fall back to regular push with upstream
|
|
101
|
+
exec(`git push -u origin ${branch}`, cwd);
|
|
102
|
+
}
|
|
99
103
|
}
|
|
100
104
|
|
|
101
105
|
/**
|
package/src/utils/github.js
CHANGED
|
@@ -9,6 +9,14 @@ function gh(cmd, cwd) {
|
|
|
9
9
|
return execSync(`gh ${cmd}`, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
function ghSafe(cmd, cwd) {
|
|
13
|
+
try {
|
|
14
|
+
return { ok: true, output: gh(cmd, cwd) };
|
|
15
|
+
} catch (err) {
|
|
16
|
+
return { ok: false, output: err.stderr || err.message };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
function ghJSON(cmd, cwd) {
|
|
13
21
|
const output = gh(cmd, cwd);
|
|
14
22
|
if (!output) return null;
|
|
@@ -19,6 +27,14 @@ function ghJSON(cmd, cwd) {
|
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
function ghJSONSafe(cmd, cwd) {
|
|
31
|
+
try {
|
|
32
|
+
return ghJSON(cmd, cwd);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
function shellEscape(str) {
|
|
23
39
|
return `'${str.replace(/'/g, "'\\''")}'`
|
|
24
40
|
}
|
|
@@ -36,9 +52,74 @@ export function checkGhAuth() {
|
|
|
36
52
|
}
|
|
37
53
|
|
|
38
54
|
/**
|
|
39
|
-
*
|
|
55
|
+
* Ensure the base branch exists on remote.
|
|
56
|
+
* If not, push it first.
|
|
57
|
+
*/
|
|
58
|
+
export function ensureRemoteBranch(cwd, branch) {
|
|
59
|
+
try {
|
|
60
|
+
const result = execSync(
|
|
61
|
+
`git ls-remote --heads origin ${branch}`,
|
|
62
|
+
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
63
|
+
).trim();
|
|
64
|
+
if (!result) {
|
|
65
|
+
// Branch doesn't exist on remote, push it
|
|
66
|
+
log.info(`Base branch '${branch}' not found on remote, pushing...`);
|
|
67
|
+
execSync(`git push origin ${branch}`, {
|
|
68
|
+
cwd,
|
|
69
|
+
encoding: 'utf-8',
|
|
70
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
71
|
+
});
|
|
72
|
+
log.info(`Base branch '${branch}' pushed to remote`);
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
log.warn(`Failed to verify remote branch '${branch}': ${err.message}`);
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check if there are commits between base and head branches
|
|
83
|
+
*/
|
|
84
|
+
export function hasCommitsBetween(cwd, base, head) {
|
|
85
|
+
try {
|
|
86
|
+
const result = execSync(
|
|
87
|
+
`git log ${base}..${head} --oneline`,
|
|
88
|
+
{ cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
89
|
+
).trim();
|
|
90
|
+
return result.length > 0;
|
|
91
|
+
} catch {
|
|
92
|
+
// If comparison fails (e.g., branches diverged), assume there are commits
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Find an existing PR for a given head branch
|
|
99
|
+
* @returns {{ number: number, url: string } | null}
|
|
100
|
+
*/
|
|
101
|
+
export function findExistingPR(cwd, head) {
|
|
102
|
+
try {
|
|
103
|
+
const prs = ghJSON(
|
|
104
|
+
`pr list --head ${shellEscape(head)} --json number,url --limit 1`,
|
|
105
|
+
cwd
|
|
106
|
+
);
|
|
107
|
+
if (prs && prs.length > 0) {
|
|
108
|
+
return { number: prs[0].number, url: prs[0].url || '' };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Create a pull request with pre-checks and auto-recovery
|
|
40
118
|
*/
|
|
41
119
|
export function createPR(cwd, { title, body, base = 'main', head }) {
|
|
120
|
+
// Pre-check: ensure base branch exists on remote
|
|
121
|
+
ensureRemoteBranch(cwd, base);
|
|
122
|
+
|
|
42
123
|
const output = gh(
|
|
43
124
|
`pr create --title ${shellEscape(title)} --body ${shellEscape(body)} --base ${shellEscape(base)} --head ${shellEscape(head)}`,
|
|
44
125
|
cwd
|
|
@@ -56,6 +137,59 @@ export function createPR(cwd, { title, body, base = 'main', head }) {
|
|
|
56
137
|
throw new Error(`Failed to create PR: ${output}`);
|
|
57
138
|
}
|
|
58
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Create PR with full auto-recovery: pre-checks → create → fallback to find existing
|
|
142
|
+
* @returns {{ number: number, url: string }}
|
|
143
|
+
*/
|
|
144
|
+
export function createPRWithRecovery(cwd, { title, body, base = 'main', head }) {
|
|
145
|
+
// Step 1: Try to create the PR
|
|
146
|
+
try {
|
|
147
|
+
return createPR(cwd, { title, body, base, head });
|
|
148
|
+
} catch (err) {
|
|
149
|
+
log.warn(`PR creation failed: ${err.message}`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Step 2: Auto-find existing PR for this branch
|
|
153
|
+
log.info('Searching for existing PR...');
|
|
154
|
+
const existing = findExistingPR(cwd, head);
|
|
155
|
+
if (existing) {
|
|
156
|
+
log.info(`Found existing PR #${existing.number}`);
|
|
157
|
+
return existing;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Step 3: Check if there are actually commits to create a PR for
|
|
161
|
+
if (!hasCommitsBetween(cwd, base, head)) {
|
|
162
|
+
log.warn('No commits between base and head branch');
|
|
163
|
+
log.info('This may be a new repo or empty branch. Attempting to push base branch and retry...');
|
|
164
|
+
|
|
165
|
+
// Ensure both branches are pushed
|
|
166
|
+
ensureRemoteBranch(cwd, base);
|
|
167
|
+
ensureRemoteBranch(cwd, head);
|
|
168
|
+
|
|
169
|
+
// Retry PR creation
|
|
170
|
+
try {
|
|
171
|
+
return createPR(cwd, { title, body, base, head });
|
|
172
|
+
} catch (retryErr) {
|
|
173
|
+
log.warn(`PR retry also failed: ${retryErr.message}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Last resort: try to find PR again
|
|
177
|
+
const retryExisting = findExistingPR(cwd, head);
|
|
178
|
+
if (retryExisting) {
|
|
179
|
+
log.info(`Found existing PR #${retryExisting.number}`);
|
|
180
|
+
return retryExisting;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Step 4: If all else fails, throw with actionable message
|
|
185
|
+
throw new Error(
|
|
186
|
+
`Cannot create or find PR for branch '${head}'. ` +
|
|
187
|
+
`Possible causes: base branch '${base}' may not exist on remote, ` +
|
|
188
|
+
`or there are no commits between '${base}' and '${head}'. ` +
|
|
189
|
+
`Try: git push origin ${base} && git push origin ${head}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
59
193
|
/**
|
|
60
194
|
* Get PR review list
|
|
61
195
|
*/
|
|
@@ -126,7 +260,8 @@ function validatePRNumber(prNumber) {
|
|
|
126
260
|
}
|
|
127
261
|
|
|
128
262
|
/**
|
|
129
|
-
* Collect all review feedback as structured text
|
|
263
|
+
* Collect all review feedback as structured text.
|
|
264
|
+
* Returns raw feedback — classification is done by AI provider.
|
|
130
265
|
*/
|
|
131
266
|
export function collectReviewFeedback(cwd, prNumber) {
|
|
132
267
|
const reviews = getReviews(cwd, prNumber);
|
|
@@ -135,19 +270,19 @@ export function collectReviewFeedback(cwd, prNumber) {
|
|
|
135
270
|
|
|
136
271
|
let feedback = '';
|
|
137
272
|
|
|
138
|
-
// Review
|
|
273
|
+
// Review body text (skip APPROVED — already handled by state check)
|
|
139
274
|
for (const r of reviews) {
|
|
140
|
-
if (r.body && r.body.trim()) {
|
|
275
|
+
if (r.body && r.body.trim() && r.state !== 'APPROVED') {
|
|
141
276
|
feedback += `### Review (${r.state})\n${r.body}\n\n`;
|
|
142
277
|
}
|
|
143
278
|
}
|
|
144
279
|
|
|
145
|
-
// Inline comments
|
|
280
|
+
// Inline code comments (comments on specific diff lines)
|
|
146
281
|
for (const c of comments) {
|
|
147
282
|
feedback += `### ${c.path}:L${c.line || c.original_line}\n${c.body}\n\n`;
|
|
148
283
|
}
|
|
149
284
|
|
|
150
|
-
// Bot comments
|
|
285
|
+
// Bot comments
|
|
151
286
|
for (const c of issueComments) {
|
|
152
287
|
if (c.user?.type === 'Bot' || c.user?.login?.includes('bot')) {
|
|
153
288
|
feedback += `### Bot Review (${c.user.login})\n${c.body}\n\n`;
|
|
@@ -158,6 +293,8 @@ export function collectReviewFeedback(cwd, prNumber) {
|
|
|
158
293
|
}
|
|
159
294
|
|
|
160
295
|
export const github = {
|
|
161
|
-
checkGhAuth, createPR,
|
|
162
|
-
|
|
296
|
+
checkGhAuth, createPR, createPRWithRecovery, findExistingPR,
|
|
297
|
+
ensureRemoteBranch, hasCommitsBetween,
|
|
298
|
+
getReviews, getReviewComments, getIssueComments,
|
|
299
|
+
getLatestReviewState, mergePR, collectReviewFeedback,
|
|
163
300
|
};
|