@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,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
+ }