@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.
- package/LICENSE +21 -21
- package/README.md +144 -44
- package/bin/cli.js +189 -182
- package/package.json +39 -39
- package/src/commands/evolve.js +316 -316
- package/src/commands/fix.js +447 -447
- package/src/commands/init.js +298 -298
- package/src/commands/reset.js +61 -61
- package/src/commands/retry.js +190 -190
- package/src/commands/run.js +958 -958
- package/src/commands/skip.js +62 -62
- package/src/commands/status.js +95 -95
- package/src/commands/usage.js +361 -361
- package/src/utils/automator.js +279 -279
- package/src/utils/checkpoint.js +246 -246
- package/src/utils/detect-prd.js +137 -137
- package/src/utils/git.js +388 -388
- package/src/utils/github.js +486 -486
- package/src/utils/json.js +220 -220
- package/src/utils/logger.js +41 -41
- package/src/utils/prompt.js +49 -49
- package/src/utils/provider.js +770 -769
- package/src/utils/self-heal.js +330 -330
- package/src/utils/shell-bootstrap.js +404 -0
- package/src/utils/update-check.js +103 -103
package/src/commands/fix.js
CHANGED
|
@@ -1,447 +1,447 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* codex-copilot fix - Repair corrupted or malformed project files
|
|
3
|
-
*
|
|
4
|
-
* Scans and repairs all JSON files in .codex-copilot/:
|
|
5
|
-
* - tasks.json: validates structure, fixes missing fields, re-indexes
|
|
6
|
-
* - state.json: validates checkpoint schema, resets if unrecoverable
|
|
7
|
-
* - config.json: validates required fields
|
|
8
|
-
* - rounds.json: validates round history structure
|
|
9
|
-
*
|
|
10
|
-
* Can also be invoked programmatically via autoFix() for self-healing.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { existsSync, readdirSync } from 'fs';
|
|
14
|
-
import { resolve, basename } from 'path';
|
|
15
|
-
import { log } from '../utils/logger.js';
|
|
16
|
-
import { readJSON, writeJSON, repairRawJSON } from '../utils/json.js';
|
|
17
|
-
|
|
18
|
-
// ─── Schema Validators & Fixers ───────────────────────────
|
|
19
|
-
|
|
20
|
-
const TASK_REQUIRED_FIELDS = ['id', 'title', 'description', 'branch', 'status'];
|
|
21
|
-
const VALID_TASK_STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'skipped', 'developed'];
|
|
22
|
-
|
|
23
|
-
const DEFAULT_STATE = {
|
|
24
|
-
current_task: 0,
|
|
25
|
-
phase: null,
|
|
26
|
-
phase_step: null,
|
|
27
|
-
current_pr: null,
|
|
28
|
-
review_round: 0,
|
|
29
|
-
branch: null,
|
|
30
|
-
status: 'initialized',
|
|
31
|
-
last_updated: new Date().toISOString(),
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Fix a single JSON file: parse → validate → repair → write back.
|
|
36
|
-
* Returns { fixed, errors, warnings } report.
|
|
37
|
-
*/
|
|
38
|
-
function fixFile(filePath, validator) {
|
|
39
|
-
const report = { file: basename(filePath), fixed: false, repairs: [], errors: [] };
|
|
40
|
-
|
|
41
|
-
if (!existsSync(filePath)) {
|
|
42
|
-
report.errors.push('File does not exist');
|
|
43
|
-
return report;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// Step 1: Try to parse (with auto-repair in readJSON)
|
|
47
|
-
let data;
|
|
48
|
-
try {
|
|
49
|
-
data = readJSON(filePath);
|
|
50
|
-
// readJSON already does basic corruption repair (BOM, trailing commas, etc.)
|
|
51
|
-
} catch (parseErr) {
|
|
52
|
-
// readJSON's auto-repair failed — try our deeper repair
|
|
53
|
-
try {
|
|
54
|
-
data = repairRawJSON(filePath);
|
|
55
|
-
report.repairs.push('Recovered from deep corruption via raw repair');
|
|
56
|
-
} catch {
|
|
57
|
-
report.errors.push(`Unrecoverable parse error: ${parseErr.message}`);
|
|
58
|
-
return report;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Step 2: Run schema validator/fixer
|
|
63
|
-
if (validator) {
|
|
64
|
-
const { data: fixedData, repairs } = validator(data);
|
|
65
|
-
if (repairs.length > 0) {
|
|
66
|
-
data = fixedData;
|
|
67
|
-
report.repairs.push(...repairs);
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// Step 3: Write back if any repairs were made
|
|
72
|
-
if (report.repairs.length > 0) {
|
|
73
|
-
try {
|
|
74
|
-
writeJSON(filePath, data);
|
|
75
|
-
report.fixed = true;
|
|
76
|
-
} catch (writeErr) {
|
|
77
|
-
report.errors.push(`Failed to write repaired file: ${writeErr.message}`);
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
return report;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// ─── Validators ───────────────────────────────────────────
|
|
85
|
-
|
|
86
|
-
function validateTasks(data) {
|
|
87
|
-
const repairs = [];
|
|
88
|
-
|
|
89
|
-
// Ensure top-level structure
|
|
90
|
-
if (!data || typeof data !== 'object') {
|
|
91
|
-
return { data: { project: 'unknown', total: 0, tasks: [] }, repairs: ['Rebuilt empty tasks structure'] };
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
if (!Array.isArray(data.tasks)) {
|
|
95
|
-
if (data.tasks && typeof data.tasks === 'object') {
|
|
96
|
-
data.tasks = Object.values(data.tasks);
|
|
97
|
-
repairs.push('Converted tasks from object to array');
|
|
98
|
-
} else {
|
|
99
|
-
data.tasks = [];
|
|
100
|
-
repairs.push('Missing tasks array — initialized empty');
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Validate each task
|
|
105
|
-
for (let i = 0; i < data.tasks.length; i++) {
|
|
106
|
-
const task = data.tasks[i];
|
|
107
|
-
|
|
108
|
-
// Ensure task is an object
|
|
109
|
-
if (!task || typeof task !== 'object') {
|
|
110
|
-
data.tasks[i] = { id: i + 1, title: `Task ${i + 1}`, description: '', branch: `feature/${String(i + 1).padStart(3, '0')}-unknown`, status: 'pending' };
|
|
111
|
-
repairs.push(`Task at index ${i}: rebuilt from invalid value`);
|
|
112
|
-
continue;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// Fix missing ID
|
|
116
|
-
if (task.id === undefined || task.id === null) {
|
|
117
|
-
task.id = i + 1;
|
|
118
|
-
repairs.push(`Task at index ${i}: assigned missing id=${task.id}`);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Fix missing required fields
|
|
122
|
-
if (!task.title) { task.title = `Untitled Task ${task.id}`; repairs.push(`Task #${task.id}: added missing title`); }
|
|
123
|
-
if (!task.description) { task.description = task.title; repairs.push(`Task #${task.id}: added missing description`); }
|
|
124
|
-
if (!task.branch) {
|
|
125
|
-
const slug = task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30);
|
|
126
|
-
task.branch = `feature/${String(task.id).padStart(3, '0')}-${slug}`;
|
|
127
|
-
repairs.push(`Task #${task.id}: generated missing branch name`);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Fix invalid status
|
|
131
|
-
if (!task.status || !VALID_TASK_STATUSES.includes(task.status)) {
|
|
132
|
-
const oldStatus = task.status;
|
|
133
|
-
task.status = 'pending';
|
|
134
|
-
repairs.push(`Task #${task.id}: invalid status '${oldStatus}' → 'pending'`);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Ensure depends_on is an array
|
|
138
|
-
if (task.depends_on && !Array.isArray(task.depends_on)) {
|
|
139
|
-
task.depends_on = [];
|
|
140
|
-
repairs.push(`Task #${task.id}: fixed invalid depends_on`);
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Ensure acceptance is an array
|
|
144
|
-
if (task.acceptance && !Array.isArray(task.acceptance)) {
|
|
145
|
-
if (typeof task.acceptance === 'string') {
|
|
146
|
-
task.acceptance = [task.acceptance];
|
|
147
|
-
repairs.push(`Task #${task.id}: converted acceptance string to array`);
|
|
148
|
-
} else {
|
|
149
|
-
task.acceptance = [];
|
|
150
|
-
repairs.push(`Task #${task.id}: fixed invalid acceptance`);
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Check for duplicate IDs
|
|
156
|
-
const idMap = new Map();
|
|
157
|
-
for (const task of data.tasks) {
|
|
158
|
-
if (idMap.has(task.id)) {
|
|
159
|
-
// Reassign duplicate ID
|
|
160
|
-
const newId = Math.max(...data.tasks.map(t => t.id)) + 1;
|
|
161
|
-
repairs.push(`Task #${task.id}: duplicate ID → reassigned to #${newId}`);
|
|
162
|
-
task.id = newId;
|
|
163
|
-
}
|
|
164
|
-
idMap.set(task.id, true);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// Fix total count
|
|
168
|
-
if (data.total !== data.tasks.length) {
|
|
169
|
-
repairs.push(`total: ${data.total} → ${data.tasks.length}`);
|
|
170
|
-
data.total = data.tasks.length;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
// Ensure project name exists
|
|
174
|
-
if (!data.project) {
|
|
175
|
-
data.project = 'Unknown Project';
|
|
176
|
-
repairs.push('Added missing project name');
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return { data, repairs };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function validateState(data) {
|
|
183
|
-
const repairs = [];
|
|
184
|
-
|
|
185
|
-
if (!data || typeof data !== 'object') {
|
|
186
|
-
return { data: { ...DEFAULT_STATE }, repairs: ['Rebuilt from scratch — was not an object'] };
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Check and fix each expected field
|
|
190
|
-
if (typeof data.current_task !== 'number') {
|
|
191
|
-
data.current_task = parseInt(data.current_task, 10) || 0;
|
|
192
|
-
repairs.push(`current_task: fixed type → ${data.current_task}`);
|
|
193
|
-
}
|
|
194
|
-
if (typeof data.review_round !== 'number') {
|
|
195
|
-
data.review_round = parseInt(data.review_round, 10) || 0;
|
|
196
|
-
repairs.push(`review_round: fixed type → ${data.review_round}`);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// Validate phase
|
|
200
|
-
const validPhases = ['develop', 'pr', 'review', 'merge', null];
|
|
201
|
-
if (!validPhases.includes(data.phase)) {
|
|
202
|
-
repairs.push(`phase: invalid '${data.phase}' → null`);
|
|
203
|
-
data.phase = null;
|
|
204
|
-
data.phase_step = null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Ensure last_updated exists
|
|
208
|
-
if (!data.last_updated) {
|
|
209
|
-
data.last_updated = new Date().toISOString();
|
|
210
|
-
repairs.push('Added missing last_updated');
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return { data, repairs };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function validateConfig(data) {
|
|
217
|
-
const repairs = [];
|
|
218
|
-
|
|
219
|
-
if (!data || typeof data !== 'object') {
|
|
220
|
-
return { data: {}, repairs: ['Config was not an object — replaced with empty'] };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Validate provider
|
|
224
|
-
if (data.provider && typeof data.provider !== 'string') {
|
|
225
|
-
data.provider = 'codex-cli';
|
|
226
|
-
repairs.push('Invalid provider — reset to codex-cli');
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// Validate numeric fields
|
|
230
|
-
for (const field of ['max_review_rounds', 'review_poll_interval', 'review_wait_timeout']) {
|
|
231
|
-
if (data[field] !== undefined && typeof data[field] !== 'number') {
|
|
232
|
-
const parsed = parseInt(data[field], 10);
|
|
233
|
-
if (!isNaN(parsed)) {
|
|
234
|
-
data[field] = parsed;
|
|
235
|
-
repairs.push(`${field}: converted string to number`);
|
|
236
|
-
} else {
|
|
237
|
-
delete data[field];
|
|
238
|
-
repairs.push(`${field}: removed invalid value`);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
return { data, repairs };
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function validateRounds(data) {
|
|
247
|
-
const repairs = [];
|
|
248
|
-
|
|
249
|
-
if (!data || typeof data !== 'object') {
|
|
250
|
-
return { data: { current_round: 0, rounds: [] }, repairs: ['Rebuilt rounds structure'] };
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (typeof data.current_round !== 'number') {
|
|
254
|
-
data.current_round = parseInt(data.current_round, 10) || 0;
|
|
255
|
-
repairs.push(`current_round: fixed type → ${data.current_round}`);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
if (!Array.isArray(data.rounds)) {
|
|
259
|
-
data.rounds = [];
|
|
260
|
-
repairs.push('Missing rounds array — initialized empty');
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
return { data, repairs };
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ─── File → Validator mapping ─────────────────────────────
|
|
267
|
-
|
|
268
|
-
const FILE_VALIDATORS = {
|
|
269
|
-
'tasks.json': validateTasks,
|
|
270
|
-
'state.json': validateState,
|
|
271
|
-
'config.json': validateConfig,
|
|
272
|
-
'rounds.json': validateRounds,
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
// ─── Public API ───────────────────────────────────────────
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Interactive fix command — scans and repairs all project files.
|
|
279
|
-
* Called when user runs: codex-copilot fix
|
|
280
|
-
*/
|
|
281
|
-
export async function fix(projectDir) {
|
|
282
|
-
log.title('🔧 Codex-Copilot File Repair');
|
|
283
|
-
log.blank();
|
|
284
|
-
|
|
285
|
-
const copilotDir = resolve(projectDir, '.codex-copilot');
|
|
286
|
-
if (!existsSync(copilotDir)) {
|
|
287
|
-
log.error('Project not initialized. Run: codex-copilot init');
|
|
288
|
-
process.exit(1);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
// Phase 1: JSON file repair
|
|
292
|
-
log.info('Scanning .codex-copilot/ for JSON issues...');
|
|
293
|
-
log.blank();
|
|
294
|
-
|
|
295
|
-
const results = runRepairs(copilotDir);
|
|
296
|
-
printReport(results);
|
|
297
|
-
|
|
298
|
-
// Phase 2: Git state self-healing
|
|
299
|
-
log.info('Checking git state...');
|
|
300
|
-
log.blank();
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
const { git } = await import('../utils/git.js');
|
|
304
|
-
|
|
305
|
-
// Lock files
|
|
306
|
-
const locks = git.clearStaleLocks(projectDir);
|
|
307
|
-
if (locks.length > 0) {
|
|
308
|
-
log.info(`🔧 Removed ${locks.length} stale lock file(s)`);
|
|
309
|
-
} else {
|
|
310
|
-
log.info('✅ No stale lock files');
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Index state (merge/rebase/cherry-pick)
|
|
314
|
-
git.resolveIndex(projectDir);
|
|
315
|
-
|
|
316
|
-
// Detached HEAD
|
|
317
|
-
const recovered = git.recoverDetachedHead(projectDir);
|
|
318
|
-
if (recovered) {
|
|
319
|
-
log.info(`🔧 Recovered from detached HEAD → ${recovered}`);
|
|
320
|
-
} else {
|
|
321
|
-
log.info('✅ HEAD is attached to a branch');
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Stash check
|
|
325
|
-
const stash = git.checkOrphanedStash(projectDir);
|
|
326
|
-
if (stash.found) {
|
|
327
|
-
log.warn(`⚠ Found ${stash.count} stash entry(s) — run 'git stash list' to review`);
|
|
328
|
-
} else {
|
|
329
|
-
log.info('✅ No orphaned stashes');
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Repo integrity
|
|
333
|
-
const integrity = git.verifyRepo(projectDir);
|
|
334
|
-
if (integrity.ok) {
|
|
335
|
-
log.info('✅ Git repository integrity OK');
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
log.blank();
|
|
339
|
-
} catch (err) {
|
|
340
|
-
log.warn(`Git state check failed: ${err.message}`);
|
|
341
|
-
log.blank();
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* Auto-fix — called programmatically by other commands when errors are detected.
|
|
348
|
-
* Returns true if all files are healthy (either clean or successfully repaired).
|
|
349
|
-
* @param {string} projectDir - Project root directory
|
|
350
|
-
* @param {object} [options]
|
|
351
|
-
* @param {boolean} [options.silent] - Suppress non-error output
|
|
352
|
-
* @param {string[]} [options.files] - Only fix specific files (e.g. ['tasks.json'])
|
|
353
|
-
* @returns {{ ok: boolean, repaired: string[] }}
|
|
354
|
-
*/
|
|
355
|
-
export function autoFix(projectDir, options = {}) {
|
|
356
|
-
const copilotDir = resolve(projectDir, '.codex-copilot');
|
|
357
|
-
if (!existsSync(copilotDir)) {
|
|
358
|
-
return { ok: false, repaired: [] };
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (!options.silent) {
|
|
362
|
-
log.info('🔧 Auto-repairing project files...');
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
const results = runRepairs(copilotDir, options.files);
|
|
366
|
-
const repaired = results.filter(r => r.fixed).map(r => r.file);
|
|
367
|
-
const failed = results.filter(r => r.errors.length > 0).map(r => r.file);
|
|
368
|
-
|
|
369
|
-
if (!options.silent) {
|
|
370
|
-
for (const r of results) {
|
|
371
|
-
if (r.fixed) {
|
|
372
|
-
log.info(` ✅ ${r.file}: ${r.repairs.length} issue(s) repaired`);
|
|
373
|
-
}
|
|
374
|
-
for (const err of r.errors) {
|
|
375
|
-
log.error(` ❌ ${r.file}: ${err}`);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
return { ok: failed.length === 0, repaired };
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// ─── Internal helpers ─────────────────────────────────────
|
|
384
|
-
|
|
385
|
-
function runRepairs(copilotDir, filterFiles) {
|
|
386
|
-
const results = [];
|
|
387
|
-
|
|
388
|
-
for (const [fileName, validator] of Object.entries(FILE_VALIDATORS)) {
|
|
389
|
-
if (filterFiles && !filterFiles.includes(fileName)) continue;
|
|
390
|
-
const filePath = resolve(copilotDir, fileName);
|
|
391
|
-
if (!existsSync(filePath)) continue;
|
|
392
|
-
results.push(fixFile(filePath, validator));
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Also scan for any other .json files (e.g. tasks_round_N.json)
|
|
396
|
-
if (!filterFiles) {
|
|
397
|
-
try {
|
|
398
|
-
const files = readdirSync(copilotDir).filter(f =>
|
|
399
|
-
f.endsWith('.json') &&
|
|
400
|
-
!f.endsWith('.tmp') &&
|
|
401
|
-
!f.endsWith('.bak') &&
|
|
402
|
-
!Object.keys(FILE_VALIDATORS).includes(f)
|
|
403
|
-
);
|
|
404
|
-
for (const fileName of files) {
|
|
405
|
-
const filePath = resolve(copilotDir, fileName);
|
|
406
|
-
results.push(fixFile(filePath, null)); // Parse-only repair, no schema validation
|
|
407
|
-
}
|
|
408
|
-
} catch { /* ignore directory read errors */ }
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
return results;
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
function printReport(results) {
|
|
415
|
-
const totalFixed = results.filter(r => r.fixed).length;
|
|
416
|
-
const totalErrors = results.filter(r => r.errors.length > 0).length;
|
|
417
|
-
const totalClean = results.filter(r => !r.fixed && r.errors.length === 0).length;
|
|
418
|
-
|
|
419
|
-
for (const r of results) {
|
|
420
|
-
if (r.errors.length > 0) {
|
|
421
|
-
log.error(`❌ ${r.file}:`);
|
|
422
|
-
for (const err of r.errors) {
|
|
423
|
-
log.error(` ${err}`);
|
|
424
|
-
}
|
|
425
|
-
} else if (r.fixed) {
|
|
426
|
-
log.info(`🔧 ${r.file}: ${r.repairs.length} issue(s) repaired`);
|
|
427
|
-
for (const repair of r.repairs) {
|
|
428
|
-
log.dim(` ↳ ${repair}`);
|
|
429
|
-
}
|
|
430
|
-
} else {
|
|
431
|
-
log.info(`✅ ${r.file}: OK`);
|
|
432
|
-
}
|
|
433
|
-
log.blank();
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
// Summary
|
|
437
|
-
log.blank();
|
|
438
|
-
if (totalErrors > 0) {
|
|
439
|
-
log.error(`Summary: ${totalFixed} file(s) repaired, ${totalErrors} file(s) have unrecoverable errors`);
|
|
440
|
-
log.warn('For unrecoverable files, try: codex-copilot reset');
|
|
441
|
-
} else if (totalFixed > 0) {
|
|
442
|
-
log.title(`✅ Repaired ${totalFixed} file(s), ${totalClean} file(s) were already OK`);
|
|
443
|
-
} else {
|
|
444
|
-
log.title('✅ All files are healthy — no repairs needed');
|
|
445
|
-
}
|
|
446
|
-
log.blank();
|
|
447
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* codex-copilot fix - Repair corrupted or malformed project files
|
|
3
|
+
*
|
|
4
|
+
* Scans and repairs all JSON files in .codex-copilot/:
|
|
5
|
+
* - tasks.json: validates structure, fixes missing fields, re-indexes
|
|
6
|
+
* - state.json: validates checkpoint schema, resets if unrecoverable
|
|
7
|
+
* - config.json: validates required fields
|
|
8
|
+
* - rounds.json: validates round history structure
|
|
9
|
+
*
|
|
10
|
+
* Can also be invoked programmatically via autoFix() for self-healing.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readdirSync } from 'fs';
|
|
14
|
+
import { resolve, basename } from 'path';
|
|
15
|
+
import { log } from '../utils/logger.js';
|
|
16
|
+
import { readJSON, writeJSON, repairRawJSON } from '../utils/json.js';
|
|
17
|
+
|
|
18
|
+
// ─── Schema Validators & Fixers ───────────────────────────
|
|
19
|
+
|
|
20
|
+
const TASK_REQUIRED_FIELDS = ['id', 'title', 'description', 'branch', 'status'];
|
|
21
|
+
const VALID_TASK_STATUSES = ['pending', 'in_progress', 'completed', 'blocked', 'skipped', 'developed'];
|
|
22
|
+
|
|
23
|
+
const DEFAULT_STATE = {
|
|
24
|
+
current_task: 0,
|
|
25
|
+
phase: null,
|
|
26
|
+
phase_step: null,
|
|
27
|
+
current_pr: null,
|
|
28
|
+
review_round: 0,
|
|
29
|
+
branch: null,
|
|
30
|
+
status: 'initialized',
|
|
31
|
+
last_updated: new Date().toISOString(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fix a single JSON file: parse → validate → repair → write back.
|
|
36
|
+
* Returns { fixed, errors, warnings } report.
|
|
37
|
+
*/
|
|
38
|
+
function fixFile(filePath, validator) {
|
|
39
|
+
const report = { file: basename(filePath), fixed: false, repairs: [], errors: [] };
|
|
40
|
+
|
|
41
|
+
if (!existsSync(filePath)) {
|
|
42
|
+
report.errors.push('File does not exist');
|
|
43
|
+
return report;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Step 1: Try to parse (with auto-repair in readJSON)
|
|
47
|
+
let data;
|
|
48
|
+
try {
|
|
49
|
+
data = readJSON(filePath);
|
|
50
|
+
// readJSON already does basic corruption repair (BOM, trailing commas, etc.)
|
|
51
|
+
} catch (parseErr) {
|
|
52
|
+
// readJSON's auto-repair failed — try our deeper repair
|
|
53
|
+
try {
|
|
54
|
+
data = repairRawJSON(filePath);
|
|
55
|
+
report.repairs.push('Recovered from deep corruption via raw repair');
|
|
56
|
+
} catch {
|
|
57
|
+
report.errors.push(`Unrecoverable parse error: ${parseErr.message}`);
|
|
58
|
+
return report;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 2: Run schema validator/fixer
|
|
63
|
+
if (validator) {
|
|
64
|
+
const { data: fixedData, repairs } = validator(data);
|
|
65
|
+
if (repairs.length > 0) {
|
|
66
|
+
data = fixedData;
|
|
67
|
+
report.repairs.push(...repairs);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Step 3: Write back if any repairs were made
|
|
72
|
+
if (report.repairs.length > 0) {
|
|
73
|
+
try {
|
|
74
|
+
writeJSON(filePath, data);
|
|
75
|
+
report.fixed = true;
|
|
76
|
+
} catch (writeErr) {
|
|
77
|
+
report.errors.push(`Failed to write repaired file: ${writeErr.message}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return report;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Validators ───────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function validateTasks(data) {
|
|
87
|
+
const repairs = [];
|
|
88
|
+
|
|
89
|
+
// Ensure top-level structure
|
|
90
|
+
if (!data || typeof data !== 'object') {
|
|
91
|
+
return { data: { project: 'unknown', total: 0, tasks: [] }, repairs: ['Rebuilt empty tasks structure'] };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!Array.isArray(data.tasks)) {
|
|
95
|
+
if (data.tasks && typeof data.tasks === 'object') {
|
|
96
|
+
data.tasks = Object.values(data.tasks);
|
|
97
|
+
repairs.push('Converted tasks from object to array');
|
|
98
|
+
} else {
|
|
99
|
+
data.tasks = [];
|
|
100
|
+
repairs.push('Missing tasks array — initialized empty');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Validate each task
|
|
105
|
+
for (let i = 0; i < data.tasks.length; i++) {
|
|
106
|
+
const task = data.tasks[i];
|
|
107
|
+
|
|
108
|
+
// Ensure task is an object
|
|
109
|
+
if (!task || typeof task !== 'object') {
|
|
110
|
+
data.tasks[i] = { id: i + 1, title: `Task ${i + 1}`, description: '', branch: `feature/${String(i + 1).padStart(3, '0')}-unknown`, status: 'pending' };
|
|
111
|
+
repairs.push(`Task at index ${i}: rebuilt from invalid value`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Fix missing ID
|
|
116
|
+
if (task.id === undefined || task.id === null) {
|
|
117
|
+
task.id = i + 1;
|
|
118
|
+
repairs.push(`Task at index ${i}: assigned missing id=${task.id}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Fix missing required fields
|
|
122
|
+
if (!task.title) { task.title = `Untitled Task ${task.id}`; repairs.push(`Task #${task.id}: added missing title`); }
|
|
123
|
+
if (!task.description) { task.description = task.title; repairs.push(`Task #${task.id}: added missing description`); }
|
|
124
|
+
if (!task.branch) {
|
|
125
|
+
const slug = task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').substring(0, 30);
|
|
126
|
+
task.branch = `feature/${String(task.id).padStart(3, '0')}-${slug}`;
|
|
127
|
+
repairs.push(`Task #${task.id}: generated missing branch name`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Fix invalid status
|
|
131
|
+
if (!task.status || !VALID_TASK_STATUSES.includes(task.status)) {
|
|
132
|
+
const oldStatus = task.status;
|
|
133
|
+
task.status = 'pending';
|
|
134
|
+
repairs.push(`Task #${task.id}: invalid status '${oldStatus}' → 'pending'`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Ensure depends_on is an array
|
|
138
|
+
if (task.depends_on && !Array.isArray(task.depends_on)) {
|
|
139
|
+
task.depends_on = [];
|
|
140
|
+
repairs.push(`Task #${task.id}: fixed invalid depends_on`);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Ensure acceptance is an array
|
|
144
|
+
if (task.acceptance && !Array.isArray(task.acceptance)) {
|
|
145
|
+
if (typeof task.acceptance === 'string') {
|
|
146
|
+
task.acceptance = [task.acceptance];
|
|
147
|
+
repairs.push(`Task #${task.id}: converted acceptance string to array`);
|
|
148
|
+
} else {
|
|
149
|
+
task.acceptance = [];
|
|
150
|
+
repairs.push(`Task #${task.id}: fixed invalid acceptance`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check for duplicate IDs
|
|
156
|
+
const idMap = new Map();
|
|
157
|
+
for (const task of data.tasks) {
|
|
158
|
+
if (idMap.has(task.id)) {
|
|
159
|
+
// Reassign duplicate ID
|
|
160
|
+
const newId = Math.max(...data.tasks.map(t => t.id)) + 1;
|
|
161
|
+
repairs.push(`Task #${task.id}: duplicate ID → reassigned to #${newId}`);
|
|
162
|
+
task.id = newId;
|
|
163
|
+
}
|
|
164
|
+
idMap.set(task.id, true);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Fix total count
|
|
168
|
+
if (data.total !== data.tasks.length) {
|
|
169
|
+
repairs.push(`total: ${data.total} → ${data.tasks.length}`);
|
|
170
|
+
data.total = data.tasks.length;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Ensure project name exists
|
|
174
|
+
if (!data.project) {
|
|
175
|
+
data.project = 'Unknown Project';
|
|
176
|
+
repairs.push('Added missing project name');
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return { data, repairs };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function validateState(data) {
|
|
183
|
+
const repairs = [];
|
|
184
|
+
|
|
185
|
+
if (!data || typeof data !== 'object') {
|
|
186
|
+
return { data: { ...DEFAULT_STATE }, repairs: ['Rebuilt from scratch — was not an object'] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Check and fix each expected field
|
|
190
|
+
if (typeof data.current_task !== 'number') {
|
|
191
|
+
data.current_task = parseInt(data.current_task, 10) || 0;
|
|
192
|
+
repairs.push(`current_task: fixed type → ${data.current_task}`);
|
|
193
|
+
}
|
|
194
|
+
if (typeof data.review_round !== 'number') {
|
|
195
|
+
data.review_round = parseInt(data.review_round, 10) || 0;
|
|
196
|
+
repairs.push(`review_round: fixed type → ${data.review_round}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Validate phase
|
|
200
|
+
const validPhases = ['develop', 'pr', 'review', 'merge', null];
|
|
201
|
+
if (!validPhases.includes(data.phase)) {
|
|
202
|
+
repairs.push(`phase: invalid '${data.phase}' → null`);
|
|
203
|
+
data.phase = null;
|
|
204
|
+
data.phase_step = null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Ensure last_updated exists
|
|
208
|
+
if (!data.last_updated) {
|
|
209
|
+
data.last_updated = new Date().toISOString();
|
|
210
|
+
repairs.push('Added missing last_updated');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return { data, repairs };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateConfig(data) {
|
|
217
|
+
const repairs = [];
|
|
218
|
+
|
|
219
|
+
if (!data || typeof data !== 'object') {
|
|
220
|
+
return { data: {}, repairs: ['Config was not an object — replaced with empty'] };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Validate provider
|
|
224
|
+
if (data.provider && typeof data.provider !== 'string') {
|
|
225
|
+
data.provider = 'codex-cli';
|
|
226
|
+
repairs.push('Invalid provider — reset to codex-cli');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Validate numeric fields
|
|
230
|
+
for (const field of ['max_review_rounds', 'review_poll_interval', 'review_wait_timeout']) {
|
|
231
|
+
if (data[field] !== undefined && typeof data[field] !== 'number') {
|
|
232
|
+
const parsed = parseInt(data[field], 10);
|
|
233
|
+
if (!isNaN(parsed)) {
|
|
234
|
+
data[field] = parsed;
|
|
235
|
+
repairs.push(`${field}: converted string to number`);
|
|
236
|
+
} else {
|
|
237
|
+
delete data[field];
|
|
238
|
+
repairs.push(`${field}: removed invalid value`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { data, repairs };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function validateRounds(data) {
|
|
247
|
+
const repairs = [];
|
|
248
|
+
|
|
249
|
+
if (!data || typeof data !== 'object') {
|
|
250
|
+
return { data: { current_round: 0, rounds: [] }, repairs: ['Rebuilt rounds structure'] };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (typeof data.current_round !== 'number') {
|
|
254
|
+
data.current_round = parseInt(data.current_round, 10) || 0;
|
|
255
|
+
repairs.push(`current_round: fixed type → ${data.current_round}`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!Array.isArray(data.rounds)) {
|
|
259
|
+
data.rounds = [];
|
|
260
|
+
repairs.push('Missing rounds array — initialized empty');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { data, repairs };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── File → Validator mapping ─────────────────────────────
|
|
267
|
+
|
|
268
|
+
const FILE_VALIDATORS = {
|
|
269
|
+
'tasks.json': validateTasks,
|
|
270
|
+
'state.json': validateState,
|
|
271
|
+
'config.json': validateConfig,
|
|
272
|
+
'rounds.json': validateRounds,
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// ─── Public API ───────────────────────────────────────────
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Interactive fix command — scans and repairs all project files.
|
|
279
|
+
* Called when user runs: codex-copilot fix
|
|
280
|
+
*/
|
|
281
|
+
export async function fix(projectDir) {
|
|
282
|
+
log.title('🔧 Codex-Copilot File Repair');
|
|
283
|
+
log.blank();
|
|
284
|
+
|
|
285
|
+
const copilotDir = resolve(projectDir, '.codex-copilot');
|
|
286
|
+
if (!existsSync(copilotDir)) {
|
|
287
|
+
log.error('Project not initialized. Run: codex-copilot init');
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Phase 1: JSON file repair
|
|
292
|
+
log.info('Scanning .codex-copilot/ for JSON issues...');
|
|
293
|
+
log.blank();
|
|
294
|
+
|
|
295
|
+
const results = runRepairs(copilotDir);
|
|
296
|
+
printReport(results);
|
|
297
|
+
|
|
298
|
+
// Phase 2: Git state self-healing
|
|
299
|
+
log.info('Checking git state...');
|
|
300
|
+
log.blank();
|
|
301
|
+
|
|
302
|
+
try {
|
|
303
|
+
const { git } = await import('../utils/git.js');
|
|
304
|
+
|
|
305
|
+
// Lock files
|
|
306
|
+
const locks = git.clearStaleLocks(projectDir);
|
|
307
|
+
if (locks.length > 0) {
|
|
308
|
+
log.info(`🔧 Removed ${locks.length} stale lock file(s)`);
|
|
309
|
+
} else {
|
|
310
|
+
log.info('✅ No stale lock files');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Index state (merge/rebase/cherry-pick)
|
|
314
|
+
git.resolveIndex(projectDir);
|
|
315
|
+
|
|
316
|
+
// Detached HEAD
|
|
317
|
+
const recovered = git.recoverDetachedHead(projectDir);
|
|
318
|
+
if (recovered) {
|
|
319
|
+
log.info(`🔧 Recovered from detached HEAD → ${recovered}`);
|
|
320
|
+
} else {
|
|
321
|
+
log.info('✅ HEAD is attached to a branch');
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Stash check
|
|
325
|
+
const stash = git.checkOrphanedStash(projectDir);
|
|
326
|
+
if (stash.found) {
|
|
327
|
+
log.warn(`⚠ Found ${stash.count} stash entry(s) — run 'git stash list' to review`);
|
|
328
|
+
} else {
|
|
329
|
+
log.info('✅ No orphaned stashes');
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Repo integrity
|
|
333
|
+
const integrity = git.verifyRepo(projectDir);
|
|
334
|
+
if (integrity.ok) {
|
|
335
|
+
log.info('✅ Git repository integrity OK');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
log.blank();
|
|
339
|
+
} catch (err) {
|
|
340
|
+
log.warn(`Git state check failed: ${err.message}`);
|
|
341
|
+
log.blank();
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Auto-fix — called programmatically by other commands when errors are detected.
|
|
348
|
+
* Returns true if all files are healthy (either clean or successfully repaired).
|
|
349
|
+
* @param {string} projectDir - Project root directory
|
|
350
|
+
* @param {object} [options]
|
|
351
|
+
* @param {boolean} [options.silent] - Suppress non-error output
|
|
352
|
+
* @param {string[]} [options.files] - Only fix specific files (e.g. ['tasks.json'])
|
|
353
|
+
* @returns {{ ok: boolean, repaired: string[] }}
|
|
354
|
+
*/
|
|
355
|
+
export function autoFix(projectDir, options = {}) {
|
|
356
|
+
const copilotDir = resolve(projectDir, '.codex-copilot');
|
|
357
|
+
if (!existsSync(copilotDir)) {
|
|
358
|
+
return { ok: false, repaired: [] };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (!options.silent) {
|
|
362
|
+
log.info('🔧 Auto-repairing project files...');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const results = runRepairs(copilotDir, options.files);
|
|
366
|
+
const repaired = results.filter(r => r.fixed).map(r => r.file);
|
|
367
|
+
const failed = results.filter(r => r.errors.length > 0).map(r => r.file);
|
|
368
|
+
|
|
369
|
+
if (!options.silent) {
|
|
370
|
+
for (const r of results) {
|
|
371
|
+
if (r.fixed) {
|
|
372
|
+
log.info(` ✅ ${r.file}: ${r.repairs.length} issue(s) repaired`);
|
|
373
|
+
}
|
|
374
|
+
for (const err of r.errors) {
|
|
375
|
+
log.error(` ❌ ${r.file}: ${err}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return { ok: failed.length === 0, repaired };
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Internal helpers ─────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
function runRepairs(copilotDir, filterFiles) {
|
|
386
|
+
const results = [];
|
|
387
|
+
|
|
388
|
+
for (const [fileName, validator] of Object.entries(FILE_VALIDATORS)) {
|
|
389
|
+
if (filterFiles && !filterFiles.includes(fileName)) continue;
|
|
390
|
+
const filePath = resolve(copilotDir, fileName);
|
|
391
|
+
if (!existsSync(filePath)) continue;
|
|
392
|
+
results.push(fixFile(filePath, validator));
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Also scan for any other .json files (e.g. tasks_round_N.json)
|
|
396
|
+
if (!filterFiles) {
|
|
397
|
+
try {
|
|
398
|
+
const files = readdirSync(copilotDir).filter(f =>
|
|
399
|
+
f.endsWith('.json') &&
|
|
400
|
+
!f.endsWith('.tmp') &&
|
|
401
|
+
!f.endsWith('.bak') &&
|
|
402
|
+
!Object.keys(FILE_VALIDATORS).includes(f)
|
|
403
|
+
);
|
|
404
|
+
for (const fileName of files) {
|
|
405
|
+
const filePath = resolve(copilotDir, fileName);
|
|
406
|
+
results.push(fixFile(filePath, null)); // Parse-only repair, no schema validation
|
|
407
|
+
}
|
|
408
|
+
} catch { /* ignore directory read errors */ }
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
return results;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function printReport(results) {
|
|
415
|
+
const totalFixed = results.filter(r => r.fixed).length;
|
|
416
|
+
const totalErrors = results.filter(r => r.errors.length > 0).length;
|
|
417
|
+
const totalClean = results.filter(r => !r.fixed && r.errors.length === 0).length;
|
|
418
|
+
|
|
419
|
+
for (const r of results) {
|
|
420
|
+
if (r.errors.length > 0) {
|
|
421
|
+
log.error(`❌ ${r.file}:`);
|
|
422
|
+
for (const err of r.errors) {
|
|
423
|
+
log.error(` ${err}`);
|
|
424
|
+
}
|
|
425
|
+
} else if (r.fixed) {
|
|
426
|
+
log.info(`🔧 ${r.file}: ${r.repairs.length} issue(s) repaired`);
|
|
427
|
+
for (const repair of r.repairs) {
|
|
428
|
+
log.dim(` ↳ ${repair}`);
|
|
429
|
+
}
|
|
430
|
+
} else {
|
|
431
|
+
log.info(`✅ ${r.file}: OK`);
|
|
432
|
+
}
|
|
433
|
+
log.blank();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Summary
|
|
437
|
+
log.blank();
|
|
438
|
+
if (totalErrors > 0) {
|
|
439
|
+
log.error(`Summary: ${totalFixed} file(s) repaired, ${totalErrors} file(s) have unrecoverable errors`);
|
|
440
|
+
log.warn('For unrecoverable files, try: codex-copilot reset');
|
|
441
|
+
} else if (totalFixed > 0) {
|
|
442
|
+
log.title(`✅ Repaired ${totalFixed} file(s), ${totalClean} file(s) were already OK`);
|
|
443
|
+
} else {
|
|
444
|
+
log.title('✅ All files are healthy — no repairs needed');
|
|
445
|
+
}
|
|
446
|
+
log.blank();
|
|
447
|
+
}
|