@jojonax/codex-copilot 1.5.5 → 1.6.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.
@@ -1,330 +1,330 @@
1
- /**
2
- * Self-Healing Module — Unified recovery and health check entry point
3
- *
4
- * Layer 3-4 of the self-healing architecture:
5
- * - preFlightCheck(): Full environment validation before run
6
- * - validatePhaseEntry(): Phase-specific guard before each phase starts
7
- *
8
- * Uses Layer 2 building blocks from git.js and github.js.
9
- */
10
-
11
- import { existsSync, writeFileSync, readFileSync, unlinkSync, accessSync, constants } from 'fs';
12
- import { resolve } from 'path';
13
- import { git } from './git.js';
14
- import { github } from './github.js';
15
- import { log } from './logger.js';
16
- import { readJSON, writeJSON } from './json.js';
17
-
18
- /**
19
- * Layer 4: Pre-flight health check — run before starting the automation loop.
20
- *
21
- * Performs comprehensive environment validation and auto-repairs:
22
- * 1. Concurrent execution lock (PID file)
23
- * 2. Git lock file cleanup
24
- * 3. Detached HEAD recovery
25
- * 4. Orphaned stash detection
26
- * 5. Git repository integrity
27
- * 6. GitHub auth verification
28
- * 7. Temp file cleanup
29
- * 8. Task/Checkpoint consistency validation
30
- *
31
- * @param {string} projectDir - Project root directory
32
- * @param {string} baseBranch - Base branch name (e.g., 'main')
33
- * @param {object} [options]
34
- * @param {object} [options.checkpoint] - Checkpoint manager instance
35
- * @param {object} [options.tasks] - Tasks data from tasks.json
36
- * @returns {{ ok: boolean, repairs: string[], warnings: string[], blockers: string[] }}
37
- */
38
- export function preFlightCheck(projectDir, baseBranch, options = {}) {
39
- const copilotDir = resolve(projectDir, '.codex-copilot');
40
- const repairs = [];
41
- const warnings = [];
42
- const blockers = [];
43
-
44
- log.dim('Running pre-flight health check...');
45
-
46
- // ── 1. Concurrent execution lock (D3) ──
47
- const pidPath = resolve(copilotDir, '.pid');
48
- if (existsSync(pidPath)) {
49
- try {
50
- const pidContent = readFileSync(pidPath, 'utf-8').trim();
51
- const [pid, startTime] = pidContent.split(':');
52
- const pidNum = parseInt(pid, 10);
53
-
54
- // Check if the process is still running
55
- let isRunning = false;
56
- try {
57
- process.kill(pidNum, 0); // Signal 0: check if process exists
58
- isRunning = true;
59
- } catch {
60
- isRunning = false; // Process doesn't exist
61
- }
62
-
63
- if (isRunning) {
64
- const age = startTime ? Math.round((Date.now() - parseInt(startTime, 10)) / 1000) : 0;
65
- blockers.push(`Another codex-copilot instance is running (PID: ${pidNum}, age: ${age}s). Kill it first or wait.`);
66
- } else {
67
- // Stale PID file — remove it
68
- unlinkSync(pidPath);
69
- repairs.push('Removed stale PID lock file from crashed process');
70
- }
71
- } catch {
72
- // Can't read PID file — remove it
73
- try { unlinkSync(pidPath); } catch {}
74
- repairs.push('Removed unreadable PID lock file');
75
- }
76
- }
77
-
78
- // Write our PID (even if there are other blockers, we need to own the lock)
79
- if (blockers.length === 0) {
80
- try {
81
- writeFileSync(pidPath, `${process.pid}:${Date.now()}`);
82
- } catch { /* non-critical */ }
83
- }
84
-
85
- // ── 2. Git lock file cleanup (A2) ──
86
- try {
87
- const removedLocks = git.clearStaleLocks(projectDir);
88
- if (removedLocks.length > 0) {
89
- repairs.push(`Cleared ${removedLocks.length} stale git lock file(s): ${removedLocks.join(', ')}`);
90
- }
91
- } catch (err) {
92
- warnings.push(`Lock file check failed: ${err.message}`);
93
- }
94
-
95
- // ── 3. Detached HEAD recovery (A3) ──
96
- try {
97
- const recovered = git.recoverDetachedHead(projectDir);
98
- if (recovered) {
99
- repairs.push(`Recovered from detached HEAD → ${recovered}`);
100
- }
101
- } catch (err) {
102
- warnings.push(`Detached HEAD check failed: ${err.message}`);
103
- }
104
-
105
- // ── 4. Orphaned stash detection (A4) ──
106
- try {
107
- const stashStatus = git.checkOrphanedStash(projectDir);
108
- if (stashStatus.found) {
109
- warnings.push(`${stashStatus.count} stash entry(s) found — run 'git stash list' to inspect`);
110
- }
111
- } catch (err) {
112
- // Non-critical
113
- }
114
-
115
- // ── 5. Git index health (A1) ──
116
- try {
117
- const gitDir = resolve(projectDir, '.git');
118
- const hasRebase = existsSync(resolve(gitDir, 'rebase-merge')) || existsSync(resolve(gitDir, 'rebase-apply'));
119
- const hasMerge = existsSync(resolve(gitDir, 'MERGE_HEAD'));
120
- const hasCherryPick = existsSync(resolve(gitDir, 'CHERRY_PICK_HEAD'));
121
-
122
- if (hasRebase || hasMerge || hasCherryPick) {
123
- git.resolveIndex(projectDir);
124
- const operations = [];
125
- if (hasRebase) operations.push('rebase');
126
- if (hasMerge) operations.push('merge');
127
- if (hasCherryPick) operations.push('cherry-pick');
128
- repairs.push(`Resolved stuck git operation(s): ${operations.join(', ')}`);
129
- }
130
- } catch (err) {
131
- warnings.push(`Index health check failed: ${err.message}`);
132
- }
133
-
134
- // ── 6. GitHub auth verification (C4) ──
135
- try {
136
- const authStatus = github.ensureAuth(projectDir);
137
- if (!authStatus.ok) {
138
- blockers.push(`GitHub auth: ${authStatus.error}`);
139
- }
140
- } catch (err) {
141
- warnings.push(`GitHub auth check failed: ${err.message}`);
142
- }
143
-
144
- // ── 7. Temp file cleanup (B3) ──
145
- try {
146
- const tempFiles = [
147
- '_current_prompt.md.tmp',
148
- 'state.json.tmp',
149
- 'tasks.json.tmp',
150
- ];
151
- for (const tmpName of tempFiles) {
152
- const tmpPath = resolve(copilotDir, tmpName);
153
- if (existsSync(tmpPath)) {
154
- unlinkSync(tmpPath);
155
- repairs.push(`Cleaned up temp file: ${tmpName}`);
156
- }
157
- }
158
- } catch { /* non-critical */ }
159
-
160
- // ── 8. Disk space check (B4) ──
161
- try {
162
- const probePath = resolve(copilotDir, '.disk_probe');
163
- writeFileSync(probePath, 'probe');
164
- unlinkSync(probePath);
165
- } catch (err) {
166
- if (err.code === 'ENOSPC' || err.code === 'EDQUOT') {
167
- blockers.push('Disk full — cannot write to project directory. Free up space before running.');
168
- } else if (err.code === 'EACCES' || err.code === 'EPERM') {
169
- blockers.push(`Permission denied writing to ${copilotDir}. Check directory permissions.`);
170
- }
171
- // Other errors are non-critical (e.g., copilotDir doesn't exist yet)
172
- }
173
-
174
- // ── 9. File permissions check (B5) ──
175
- try {
176
- const criticalFiles = ['tasks.json', 'state.json', 'config.json'];
177
- for (const fileName of criticalFiles) {
178
- const filePath = resolve(copilotDir, fileName);
179
- if (existsSync(filePath)) {
180
- try {
181
- // Verify write access
182
- accessSync(filePath, constants.R_OK | constants.W_OK);
183
- } catch (permErr) {
184
- if (permErr.code === 'EACCES' || permErr.code === 'EPERM') {
185
- blockers.push(`No read/write permission on ${fileName}. Fix with: chmod 644 ${filePath}`);
186
- }
187
- }
188
- }
189
- }
190
- } catch { /* non-critical */ }
191
-
192
- // ── 10. Task/Checkpoint consistency (E1, E4) ──
193
- if (options.checkpoint && options.tasks) {
194
- try {
195
- const consistency = options.checkpoint.validateConsistency(options.tasks);
196
- if (!consistency.ok) {
197
- repairs.push(...consistency.repairs);
198
- // Write back repaired tasks
199
- const tasksPath = resolve(copilotDir, 'tasks.json');
200
- writeJSON(tasksPath, options.tasks);
201
- }
202
- } catch (err) {
203
- warnings.push(`Consistency check failed: ${err.message}`);
204
- }
205
- }
206
-
207
- // ── Report ──
208
- if (repairs.length > 0) {
209
- log.info(`🔧 Pre-flight: ${repairs.length} issue(s) auto-repaired`);
210
- for (const r of repairs) {
211
- log.dim(` ✅ ${r}`);
212
- }
213
- }
214
-
215
- if (warnings.length > 0) {
216
- for (const w of warnings) {
217
- log.warn(` ⚠ ${w}`);
218
- }
219
- }
220
-
221
- if (blockers.length > 0) {
222
- for (const b of blockers) {
223
- log.error(` 🛑 ${b}`);
224
- }
225
- }
226
-
227
- if (repairs.length === 0 && warnings.length === 0 && blockers.length === 0) {
228
- log.dim('Pre-flight: all checks passed ✓');
229
- }
230
-
231
- return { ok: blockers.length === 0, repairs, warnings, blockers };
232
- }
233
-
234
- /**
235
- * Layer 3: Phase entry validation — called before each automation phase.
236
- *
237
- * Ensures the environment is in the correct state for the target phase:
238
- * - develop: correct branch, clean-enough index
239
- * - pr: has diff to commit, network is reachable
240
- * - review: PR exists and is in 'open' state
241
- * - merge: PR is approved or at least not rejected
242
- *
243
- * @param {string} projectDir - Project root
244
- * @param {object} task - Current task object
245
- * @param {object} checkpoint - Checkpoint manager
246
- * @param {string} targetPhase - 'develop' | 'pr' | 'review' | 'merge'
247
- * @param {object} [extra] - Extra context (e.g., prInfo for review/merge)
248
- * @returns {{ ok: boolean, error: string|null }}
249
- */
250
- export function validatePhaseEntry(projectDir, task, checkpoint, targetPhase, extra = {}) {
251
- try {
252
- switch (targetPhase) {
253
- case 'develop': {
254
- // Verify we can identify current branch (not detached)
255
- const current = git.currentBranch(projectDir);
256
- if (!current) {
257
- git.recoverDetachedHead(projectDir);
258
- const recovered = git.currentBranch(projectDir);
259
- if (!recovered) {
260
- return { ok: false, error: 'Cannot determine current branch (detached HEAD, recovery failed)' };
261
- }
262
- }
263
- return { ok: true, error: null };
264
- }
265
-
266
- case 'pr': {
267
- // Verify we're on the task's feature branch
268
- const current = git.currentBranch(projectDir);
269
- if (current !== task.branch) {
270
- return { ok: false, error: `Expected branch '${task.branch}' but on '${current}'` };
271
- }
272
- return { ok: true, error: null };
273
- }
274
-
275
- case 'review': {
276
- // Verify PR exists and is open
277
- if (!extra.prNumber) {
278
- return { ok: false, error: 'No PR number available for review phase' };
279
- }
280
- const prState = github.getPRState(projectDir, extra.prNumber);
281
- if (prState === 'merged') {
282
- return { ok: false, error: `PR #${extra.prNumber} is already merged` };
283
- }
284
- if (prState === 'closed') {
285
- return { ok: false, error: `PR #${extra.prNumber} was closed — needs re-creation` };
286
- }
287
- return { ok: true, error: null };
288
- }
289
-
290
- case 'merge': {
291
- // Verify PR is in a mergeable state
292
- if (!extra.prNumber) {
293
- return { ok: false, error: 'No PR number available for merge phase' };
294
- }
295
- const prState = github.getPRState(projectDir, extra.prNumber);
296
- if (prState === 'merged') {
297
- // Already merged — this is fine, skip merge
298
- return { ok: true, error: null };
299
- }
300
- if (prState === 'closed') {
301
- return { ok: false, error: `PR #${extra.prNumber} is closed — cannot merge` };
302
- }
303
- return { ok: true, error: null };
304
- }
305
-
306
- default:
307
- return { ok: true, error: null };
308
- }
309
- } catch (err) {
310
- return { ok: false, error: `Phase validation error: ${err.message}` };
311
- }
312
- }
313
-
314
- /**
315
- * Remove the PID lock file (called on clean exit).
316
- */
317
- export function releaseLock(projectDir) {
318
- const pidPath = resolve(projectDir, '.codex-copilot/.pid');
319
- try {
320
- if (existsSync(pidPath)) {
321
- unlinkSync(pidPath);
322
- }
323
- } catch { /* best effort */ }
324
- }
325
-
326
- export const selfHeal = {
327
- preFlightCheck,
328
- validatePhaseEntry,
329
- releaseLock,
330
- };
1
+ /**
2
+ * Self-Healing Module — Unified recovery and health check entry point
3
+ *
4
+ * Layer 3-4 of the self-healing architecture:
5
+ * - preFlightCheck(): Full environment validation before run
6
+ * - validatePhaseEntry(): Phase-specific guard before each phase starts
7
+ *
8
+ * Uses Layer 2 building blocks from git.js and github.js.
9
+ */
10
+
11
+ import { existsSync, writeFileSync, readFileSync, unlinkSync, accessSync, constants } from 'fs';
12
+ import { resolve } from 'path';
13
+ import { git } from './git.js';
14
+ import { github } from './github.js';
15
+ import { log } from './logger.js';
16
+ import { readJSON, writeJSON } from './json.js';
17
+
18
+ /**
19
+ * Layer 4: Pre-flight health check — run before starting the automation loop.
20
+ *
21
+ * Performs comprehensive environment validation and auto-repairs:
22
+ * 1. Concurrent execution lock (PID file)
23
+ * 2. Git lock file cleanup
24
+ * 3. Detached HEAD recovery
25
+ * 4. Orphaned stash detection
26
+ * 5. Git repository integrity
27
+ * 6. GitHub auth verification
28
+ * 7. Temp file cleanup
29
+ * 8. Task/Checkpoint consistency validation
30
+ *
31
+ * @param {string} projectDir - Project root directory
32
+ * @param {string} baseBranch - Base branch name (e.g., 'main')
33
+ * @param {object} [options]
34
+ * @param {object} [options.checkpoint] - Checkpoint manager instance
35
+ * @param {object} [options.tasks] - Tasks data from tasks.json
36
+ * @returns {{ ok: boolean, repairs: string[], warnings: string[], blockers: string[] }}
37
+ */
38
+ export function preFlightCheck(projectDir, baseBranch, options = {}) {
39
+ const copilotDir = resolve(projectDir, '.codex-copilot');
40
+ const repairs = [];
41
+ const warnings = [];
42
+ const blockers = [];
43
+
44
+ log.dim('Running pre-flight health check...');
45
+
46
+ // ── 1. Concurrent execution lock (D3) ──
47
+ const pidPath = resolve(copilotDir, '.pid');
48
+ if (existsSync(pidPath)) {
49
+ try {
50
+ const pidContent = readFileSync(pidPath, 'utf-8').trim();
51
+ const [pid, startTime] = pidContent.split(':');
52
+ const pidNum = parseInt(pid, 10);
53
+
54
+ // Check if the process is still running
55
+ let isRunning = false;
56
+ try {
57
+ process.kill(pidNum, 0); // Signal 0: check if process exists
58
+ isRunning = true;
59
+ } catch {
60
+ isRunning = false; // Process doesn't exist
61
+ }
62
+
63
+ if (isRunning) {
64
+ const age = startTime ? Math.round((Date.now() - parseInt(startTime, 10)) / 1000) : 0;
65
+ blockers.push(`Another codex-copilot instance is running (PID: ${pidNum}, age: ${age}s). Kill it first or wait.`);
66
+ } else {
67
+ // Stale PID file — remove it
68
+ unlinkSync(pidPath);
69
+ repairs.push('Removed stale PID lock file from crashed process');
70
+ }
71
+ } catch {
72
+ // Can't read PID file — remove it
73
+ try { unlinkSync(pidPath); } catch {}
74
+ repairs.push('Removed unreadable PID lock file');
75
+ }
76
+ }
77
+
78
+ // Write our PID (even if there are other blockers, we need to own the lock)
79
+ if (blockers.length === 0) {
80
+ try {
81
+ writeFileSync(pidPath, `${process.pid}:${Date.now()}`);
82
+ } catch { /* non-critical */ }
83
+ }
84
+
85
+ // ── 2. Git lock file cleanup (A2) ──
86
+ try {
87
+ const removedLocks = git.clearStaleLocks(projectDir);
88
+ if (removedLocks.length > 0) {
89
+ repairs.push(`Cleared ${removedLocks.length} stale git lock file(s): ${removedLocks.join(', ')}`);
90
+ }
91
+ } catch (err) {
92
+ warnings.push(`Lock file check failed: ${err.message}`);
93
+ }
94
+
95
+ // ── 3. Detached HEAD recovery (A3) ──
96
+ try {
97
+ const recovered = git.recoverDetachedHead(projectDir);
98
+ if (recovered) {
99
+ repairs.push(`Recovered from detached HEAD → ${recovered}`);
100
+ }
101
+ } catch (err) {
102
+ warnings.push(`Detached HEAD check failed: ${err.message}`);
103
+ }
104
+
105
+ // ── 4. Orphaned stash detection (A4) ──
106
+ try {
107
+ const stashStatus = git.checkOrphanedStash(projectDir);
108
+ if (stashStatus.found) {
109
+ warnings.push(`${stashStatus.count} stash entry(s) found — run 'git stash list' to inspect`);
110
+ }
111
+ } catch (err) {
112
+ // Non-critical
113
+ }
114
+
115
+ // ── 5. Git index health (A1) ──
116
+ try {
117
+ const gitDir = resolve(projectDir, '.git');
118
+ const hasRebase = existsSync(resolve(gitDir, 'rebase-merge')) || existsSync(resolve(gitDir, 'rebase-apply'));
119
+ const hasMerge = existsSync(resolve(gitDir, 'MERGE_HEAD'));
120
+ const hasCherryPick = existsSync(resolve(gitDir, 'CHERRY_PICK_HEAD'));
121
+
122
+ if (hasRebase || hasMerge || hasCherryPick) {
123
+ git.resolveIndex(projectDir);
124
+ const operations = [];
125
+ if (hasRebase) operations.push('rebase');
126
+ if (hasMerge) operations.push('merge');
127
+ if (hasCherryPick) operations.push('cherry-pick');
128
+ repairs.push(`Resolved stuck git operation(s): ${operations.join(', ')}`);
129
+ }
130
+ } catch (err) {
131
+ warnings.push(`Index health check failed: ${err.message}`);
132
+ }
133
+
134
+ // ── 6. GitHub auth verification (C4) ──
135
+ try {
136
+ const authStatus = github.ensureAuth(projectDir);
137
+ if (!authStatus.ok) {
138
+ blockers.push(`GitHub auth: ${authStatus.error}`);
139
+ }
140
+ } catch (err) {
141
+ warnings.push(`GitHub auth check failed: ${err.message}`);
142
+ }
143
+
144
+ // ── 7. Temp file cleanup (B3) ──
145
+ try {
146
+ const tempFiles = [
147
+ '_current_prompt.md.tmp',
148
+ 'state.json.tmp',
149
+ 'tasks.json.tmp',
150
+ ];
151
+ for (const tmpName of tempFiles) {
152
+ const tmpPath = resolve(copilotDir, tmpName);
153
+ if (existsSync(tmpPath)) {
154
+ unlinkSync(tmpPath);
155
+ repairs.push(`Cleaned up temp file: ${tmpName}`);
156
+ }
157
+ }
158
+ } catch { /* non-critical */ }
159
+
160
+ // ── 8. Disk space check (B4) ──
161
+ try {
162
+ const probePath = resolve(copilotDir, '.disk_probe');
163
+ writeFileSync(probePath, 'probe');
164
+ unlinkSync(probePath);
165
+ } catch (err) {
166
+ if (err.code === 'ENOSPC' || err.code === 'EDQUOT') {
167
+ blockers.push('Disk full — cannot write to project directory. Free up space before running.');
168
+ } else if (err.code === 'EACCES' || err.code === 'EPERM') {
169
+ blockers.push(`Permission denied writing to ${copilotDir}. Check directory permissions.`);
170
+ }
171
+ // Other errors are non-critical (e.g., copilotDir doesn't exist yet)
172
+ }
173
+
174
+ // ── 9. File permissions check (B5) ──
175
+ try {
176
+ const criticalFiles = ['tasks.json', 'state.json', 'config.json'];
177
+ for (const fileName of criticalFiles) {
178
+ const filePath = resolve(copilotDir, fileName);
179
+ if (existsSync(filePath)) {
180
+ try {
181
+ // Verify write access
182
+ accessSync(filePath, constants.R_OK | constants.W_OK);
183
+ } catch (permErr) {
184
+ if (permErr.code === 'EACCES' || permErr.code === 'EPERM') {
185
+ blockers.push(`No read/write permission on ${fileName}. Fix with: chmod 644 ${filePath}`);
186
+ }
187
+ }
188
+ }
189
+ }
190
+ } catch { /* non-critical */ }
191
+
192
+ // ── 10. Task/Checkpoint consistency (E1, E4) ──
193
+ if (options.checkpoint && options.tasks) {
194
+ try {
195
+ const consistency = options.checkpoint.validateConsistency(options.tasks);
196
+ if (!consistency.ok) {
197
+ repairs.push(...consistency.repairs);
198
+ // Write back repaired tasks
199
+ const tasksPath = resolve(copilotDir, 'tasks.json');
200
+ writeJSON(tasksPath, options.tasks);
201
+ }
202
+ } catch (err) {
203
+ warnings.push(`Consistency check failed: ${err.message}`);
204
+ }
205
+ }
206
+
207
+ // ── Report ──
208
+ if (repairs.length > 0) {
209
+ log.info(`🔧 Pre-flight: ${repairs.length} issue(s) auto-repaired`);
210
+ for (const r of repairs) {
211
+ log.dim(` ✅ ${r}`);
212
+ }
213
+ }
214
+
215
+ if (warnings.length > 0) {
216
+ for (const w of warnings) {
217
+ log.warn(` ⚠ ${w}`);
218
+ }
219
+ }
220
+
221
+ if (blockers.length > 0) {
222
+ for (const b of blockers) {
223
+ log.error(` 🛑 ${b}`);
224
+ }
225
+ }
226
+
227
+ if (repairs.length === 0 && warnings.length === 0 && blockers.length === 0) {
228
+ log.dim('Pre-flight: all checks passed ✓');
229
+ }
230
+
231
+ return { ok: blockers.length === 0, repairs, warnings, blockers };
232
+ }
233
+
234
+ /**
235
+ * Layer 3: Phase entry validation — called before each automation phase.
236
+ *
237
+ * Ensures the environment is in the correct state for the target phase:
238
+ * - develop: correct branch, clean-enough index
239
+ * - pr: has diff to commit, network is reachable
240
+ * - review: PR exists and is in 'open' state
241
+ * - merge: PR is approved or at least not rejected
242
+ *
243
+ * @param {string} projectDir - Project root
244
+ * @param {object} task - Current task object
245
+ * @param {object} checkpoint - Checkpoint manager
246
+ * @param {string} targetPhase - 'develop' | 'pr' | 'review' | 'merge'
247
+ * @param {object} [extra] - Extra context (e.g., prInfo for review/merge)
248
+ * @returns {{ ok: boolean, error: string|null }}
249
+ */
250
+ export function validatePhaseEntry(projectDir, task, checkpoint, targetPhase, extra = {}) {
251
+ try {
252
+ switch (targetPhase) {
253
+ case 'develop': {
254
+ // Verify we can identify current branch (not detached)
255
+ const current = git.currentBranch(projectDir);
256
+ if (!current) {
257
+ git.recoverDetachedHead(projectDir);
258
+ const recovered = git.currentBranch(projectDir);
259
+ if (!recovered) {
260
+ return { ok: false, error: 'Cannot determine current branch (detached HEAD, recovery failed)' };
261
+ }
262
+ }
263
+ return { ok: true, error: null };
264
+ }
265
+
266
+ case 'pr': {
267
+ // Verify we're on the task's feature branch
268
+ const current = git.currentBranch(projectDir);
269
+ if (current !== task.branch) {
270
+ return { ok: false, error: `Expected branch '${task.branch}' but on '${current}'` };
271
+ }
272
+ return { ok: true, error: null };
273
+ }
274
+
275
+ case 'review': {
276
+ // Verify PR exists and is open
277
+ if (!extra.prNumber) {
278
+ return { ok: false, error: 'No PR number available for review phase' };
279
+ }
280
+ const prState = github.getPRState(projectDir, extra.prNumber);
281
+ if (prState === 'merged') {
282
+ return { ok: false, error: `PR #${extra.prNumber} is already merged` };
283
+ }
284
+ if (prState === 'closed') {
285
+ return { ok: false, error: `PR #${extra.prNumber} was closed — needs re-creation` };
286
+ }
287
+ return { ok: true, error: null };
288
+ }
289
+
290
+ case 'merge': {
291
+ // Verify PR is in a mergeable state
292
+ if (!extra.prNumber) {
293
+ return { ok: false, error: 'No PR number available for merge phase' };
294
+ }
295
+ const prState = github.getPRState(projectDir, extra.prNumber);
296
+ if (prState === 'merged') {
297
+ // Already merged — this is fine, skip merge
298
+ return { ok: true, error: null };
299
+ }
300
+ if (prState === 'closed') {
301
+ return { ok: false, error: `PR #${extra.prNumber} is closed — cannot merge` };
302
+ }
303
+ return { ok: true, error: null };
304
+ }
305
+
306
+ default:
307
+ return { ok: true, error: null };
308
+ }
309
+ } catch (err) {
310
+ return { ok: false, error: `Phase validation error: ${err.message}` };
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Remove the PID lock file (called on clean exit).
316
+ */
317
+ export function releaseLock(projectDir) {
318
+ const pidPath = resolve(projectDir, '.codex-copilot/.pid');
319
+ try {
320
+ if (existsSync(pidPath)) {
321
+ unlinkSync(pidPath);
322
+ }
323
+ } catch { /* best effort */ }
324
+ }
325
+
326
+ export const selfHeal = {
327
+ preFlightCheck,
328
+ validatePhaseEntry,
329
+ releaseLock,
330
+ };